detached 559fa4c

fix(bar): coalesce, dedup and rate-limit tooltips; bounded warm-up waits

Of the tooltip and close lines of one wake-up only the last counts; an identical tooltip of the same owner is not reopened; a module opens at most one tooltip per 250 ms. A tooltip is skipped while the GPU warm-up runs (menus wait), shutdown waits at most 2 s for it and removes the stylesheet, a menu level that cannot be resized scrolls, an undelivered menu-activate is logged, and per-module state is dropped with the module.
commit
559fa4c2164387b671f36fa781aace74e9a1f6bb
author
brice · · 2026-10-09 06:41 UTC
parent
96202ff
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +410 -38
M internal/bar/bar.go +12 -1
@@ -126,6 +126,8 @@ type Bar struct {
126126 warmed bool
127127 spanBuf []layout.Span
128128 badCtl map[*module.Module]bool // modules whose invalid control line was logged
129+ tips tooltipLimiter
130+ ctlBuf []module.Control // the control lines of one wake-up, reused
129131
130132 Stats Stats
131133 fatal error
@@ -341,6 +343,7 @@ func (b *Bar) syncModules(want []config.Module) {
341343 for _, r := range next {
342344 b.mods = append(b.mods, r.m)
343345 }
346+ b.forgetModules(b.mods)
344347 if b.ptr.hover != nil && !slices.Contains(b.mods, b.ptr.hover) {
345348 b.ptr.hover, b.ptr.hoverOff = nil, 0 // the hovered module was stopped
346349 }
@@ -773,7 +776,12 @@ func (b *Bar) shutdown() error {
773776 r.m.Wait(3 * time.Second)
774777 }
775778 b.closePopup()
776- b.pop.Wait() // the warm-up renderer must be gone before the GPU state is
779+ // The warm-up renderer must be gone before the GPU state is, but a stuck
780+ // GPU library must not hold the shutdown.
781+ if !b.pop.WaitTimeout(warmupWait) {
782+ b.log.Warn("the popup warm-up did not end; shutting down anyway")
783+ }
784+ popup.RemoveCSS()
777785 b.dropRenderer()
778786 b.face.Close()
779787 if b.node != nil {
@@ -790,6 +798,9 @@ func (b *Bar) shutdown() error {
790798 // change before it rebuilds.
791799 const settleDelay = 60 * time.Millisecond
792800
801+// warmupWait is how long shutdown waits for the popup warm-up.
802+const warmupWait = 2 * time.Second
803+
793804 // geometryChanged is called when the compositor reports a new scale or size.
794805 // The first report builds at once, because there is nothing on screen yet.
795806 // Later ones wait for the dust to settle, and nothing is drawn meanwhile.
@@ -126,6 +126,8 @@ type Bar struct {
126 warmed bool126 warmed bool
127 spanBuf []layout.Span127 spanBuf []layout.Span
128 badCtl map[*module.Module]bool // modules whose invalid control line was logged128 badCtl map[*module.Module]bool // modules whose invalid control line was logged
129+ tips tooltipLimiter
130+ ctlBuf []module.Control // the control lines of one wake-up, reused
129 131
130 Stats Stats132 Stats Stats
131 fatal error133 fatal error
@@ -341,6 +343,7 @@ func (b *Bar) syncModules(want []config.Module) {
341 for _, r := range next {343 for _, r := range next {
342 b.mods = append(b.mods, r.m)344 b.mods = append(b.mods, r.m)
343 }345 }
346+ b.forgetModules(b.mods)
344 if b.ptr.hover != nil && !slices.Contains(b.mods, b.ptr.hover) {347 if b.ptr.hover != nil && !slices.Contains(b.mods, b.ptr.hover) {
345 b.ptr.hover, b.ptr.hoverOff = nil, 0 // the hovered module was stopped348 b.ptr.hover, b.ptr.hoverOff = nil, 0 // the hovered module was stopped
346 }349 }
@@ -773,7 +776,12 @@ func (b *Bar) shutdown() error {
773 r.m.Wait(3 * time.Second)776 r.m.Wait(3 * time.Second)
774 }777 }
775 b.closePopup()778 b.closePopup()
776- b.pop.Wait() // the warm-up renderer must be gone before the GPU state is779+ // The warm-up renderer must be gone before the GPU state is, but a stuck
780+ // GPU library must not hold the shutdown.
781+ if !b.pop.WaitTimeout(warmupWait) {
782+ b.log.Warn("the popup warm-up did not end; shutting down anyway")
783+ }
784+ popup.RemoveCSS()
777 b.dropRenderer()785 b.dropRenderer()
778 b.face.Close()786 b.face.Close()
779 if b.node != nil {787 if b.node != nil {
@@ -790,6 +798,9 @@ func (b *Bar) shutdown() error {
790 // change before it rebuilds.798 // change before it rebuilds.
791 const settleDelay = 60 * time.Millisecond799 const settleDelay = 60 * time.Millisecond
792 800
801+// warmupWait is how long shutdown waits for the popup warm-up.
802+const warmupWait = 2 * time.Second
803+
793 // geometryChanged is called when the compositor reports a new scale or size.804 // geometryChanged is called when the compositor reports a new scale or size.
794 // The first report builds at once, because there is nothing on screen yet.805 // The first report builds at once, because there is nothing on screen yet.
795 // Later ones wait for the dust to settle, and nothing is drawn meanwhile.806 // Later ones wait for the dust to settle, and nothing is drawn meanwhile.
M internal/bar/popup.go +98 -17
@@ -1,6 +1,7 @@
11 package bar
22
33 import (
4+ "slices"
45 "time"
56
67 "github.com/bnema/neferbar/internal/layout"
@@ -58,13 +59,7 @@ func (b *Bar) stepPopup() {
5859 b.pop.Warm(b.fb)
5960 }
6061 for _, m := range b.mods {
61- for {
62- raw, ok := m.TakeControl()
63- if !ok {
64- break
65- }
66- b.control(m, raw)
67- }
62+ b.drainControl(m)
6863 }
6964 if b.pop.OwnerRestarted() {
7065 b.closePopup()
@@ -87,19 +82,102 @@ func (b *Bar) anyInteractive() bool {
8782 return false
8883 }
8984
90-// control handles one control line of module m.
91-func (b *Bar) control(m *module.Module, raw []byte) {
92- ctrl, err := module.ParseControl(raw)
93- if err != nil {
94- if !b.badCtl[m] {
95- if b.badCtl == nil {
96- b.badCtl = map[*module.Module]bool{}
97- }
98- b.badCtl[m] = true
99- b.log.Warn("module sent an invalid control line; ignored (shown once per module)", "module", m.Name, "err", err)
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.
90+func (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
100101 }
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+}
109+
110+// badControl logs an invalid control line, once per module.
111+func (b *Bar) badControl(m *module.Module, err error) {
112+ if b.badCtl[m] {
101113 return
102114 }
115+ if b.badCtl == nil {
116+ b.badCtl = map[*module.Module]bool{}
117+ }
118+ b.badCtl[m] = true
119+ b.log.Warn("module sent an invalid control line; ignored (shown once per module)", "module", m.Name, "err", err)
120+}
121+
122+// coalesceControl keeps, in order, every menu line and only the last of the
123+// tooltip and close lines. It works in place.
124+func coalesceControl(cs []module.Control) []module.Control {
125+ last := -1
126+ for i, c := range cs {
127+ if c.Type != module.ControlMenu {
128+ last = i
129+ }
130+ }
131+ i := 0
132+ return slices.DeleteFunc(cs, func(c module.Control) bool {
133+ drop := c.Type != module.ControlMenu && i != last
134+ i++
135+ return drop
136+ })
137+}
138+
139+// tooltipInterval is the shortest time between two tooltips opened for one
140+// module.
141+const tooltipInterval = 250 * time.Millisecond
142+
143+// tooltipLimiter rate-limits the tooltips of each module.
144+type tooltipLimiter struct {
145+ last map[*module.Module]time.Time
146+}
147+
148+// allow reports whether m may open a tooltip at now, and counts it if so.
149+func (l *tooltipLimiter) allow(m *module.Module, now time.Time) bool {
150+ if t, ok := l.last[m]; ok && now.Sub(t) < tooltipInterval {
151+ return false
152+ }
153+ if l.last == nil {
154+ l.last = map[*module.Module]time.Time{}
155+ }
156+ l.last[m] = now
157+ return true
158+}
159+
160+// forget drops what is kept for modules that are not in keep.
161+func (l *tooltipLimiter) forget(keep []*module.Module) {
162+ for m := range l.last {
163+ if !slices.Contains(keep, m) {
164+ delete(l.last, m)
165+ }
166+ }
167+}
168+
169+// forgetModules drops the per-module state of modules that were stopped.
170+func (b *Bar) forgetModules(keep []*module.Module) {
171+ for m := range b.badCtl {
172+ if !slices.Contains(keep, m) {
173+ delete(b.badCtl, m)
174+ }
175+ }
176+ b.tips.forget(keep)
177+}
178+
179+// control handles one control line of module m.
180+func (b *Bar) control(m *module.Module, ctrl module.Control) {
103181 if !m.Interactive {
104182 return // only modules that asked for the pointer may open popups
105183 }
@@ -113,6 +191,9 @@ func (b *Bar) control(m *module.Module, raw []byte) {
113191 if m != b.ptr.hover || b.pop.Kind() == popup.KindMenu {
114192 return
115193 }
194+ if b.pop.ShowsTooltip(m, ctrl) || !b.tips.allow(m, time.Now()) {
195+ return // already shown, or the script asks too often
196+ }
116197 b.openPopup(m, popup.KindTooltip, ctrl, 0)
117198 case module.ControlMenu:
118199 serial, ok := b.ptr.takeMenuPress(m, ctrl.Click, time.Now())
@@ -1,6 +1,7 @@
1 package bar1 package bar
2 2
3 import (3 import (
4+ "slices"
4 "time"5 "time"
5 6
6 "github.com/bnema/neferbar/internal/layout"7 "github.com/bnema/neferbar/internal/layout"
@@ -58,13 +59,7 @@ func (b *Bar) stepPopup() {
58 b.pop.Warm(b.fb)59 b.pop.Warm(b.fb)
59 }60 }
60 for _, m := range b.mods {61 for _, m := range b.mods {
61- for {62+ b.drainControl(m)
62- raw, ok := m.TakeControl()
63- if !ok {
64- break
65- }
66- b.control(m, raw)
67- }
68 }63 }
69 if b.pop.OwnerRestarted() {64 if b.pop.OwnerRestarted() {
70 b.closePopup()65 b.closePopup()
@@ -87,19 +82,102 @@ func (b *Bar) anyInteractive() bool {
87 return false82 return false
88 }83 }
89 84
90-// control handles one control line of module m.85+// drainControl takes the control lines module m printed since the last
91-func (b *Bar) control(m *module.Module, raw []byte) {86+// wake-up and carries them out. A script that prints a tooltip on every
92- ctrl, err := module.ParseControl(raw)87+// mouse move would otherwise open and close a popup (a renderer, a surface)
93- if err != nil {88+// for each one: of the tooltip and close lines of one wake-up only the last
94- if !b.badCtl[m] {89+// counts.
95- if b.badCtl == nil {90+func (b *Bar) drainControl(m *module.Module) {
96- b.badCtl = map[*module.Module]bool{}91+ raw, ok := m.TakeControl()
97- }92+ if !ok {
98- b.badCtl[m] = true93+ return
99- b.log.Warn("module sent an invalid control line; ignored (shown once per module)", "module", m.Name, "err", err)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
100 }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+}
109+
110+// badControl logs an invalid control line, once per module.
111+func (b *Bar) badControl(m *module.Module, err error) {
112+ if b.badCtl[m] {
101 return113 return
102 }114 }
115+ if b.badCtl == nil {
116+ b.badCtl = map[*module.Module]bool{}
117+ }
118+ b.badCtl[m] = true
119+ b.log.Warn("module sent an invalid control line; ignored (shown once per module)", "module", m.Name, "err", err)
120+}
121+
122+// coalesceControl keeps, in order, every menu line and only the last of the
123+// tooltip and close lines. It works in place.
124+func coalesceControl(cs []module.Control) []module.Control {
125+ last := -1
126+ for i, c := range cs {
127+ if c.Type != module.ControlMenu {
128+ last = i
129+ }
130+ }
131+ i := 0
132+ return slices.DeleteFunc(cs, func(c module.Control) bool {
133+ drop := c.Type != module.ControlMenu && i != last
134+ i++
135+ return drop
136+ })
137+}
138+
139+// tooltipInterval is the shortest time between two tooltips opened for one
140+// module.
141+const tooltipInterval = 250 * time.Millisecond
142+
143+// tooltipLimiter rate-limits the tooltips of each module.
144+type tooltipLimiter struct {
145+ last map[*module.Module]time.Time
146+}
147+
148+// allow reports whether m may open a tooltip at now, and counts it if so.
149+func (l *tooltipLimiter) allow(m *module.Module, now time.Time) bool {
150+ if t, ok := l.last[m]; ok && now.Sub(t) < tooltipInterval {
151+ return false
152+ }
153+ if l.last == nil {
154+ l.last = map[*module.Module]time.Time{}
155+ }
156+ l.last[m] = now
157+ return true
158+}
159+
160+// forget drops what is kept for modules that are not in keep.
161+func (l *tooltipLimiter) forget(keep []*module.Module) {
162+ for m := range l.last {
163+ if !slices.Contains(keep, m) {
164+ delete(l.last, m)
165+ }
166+ }
167+}
168+
169+// forgetModules drops the per-module state of modules that were stopped.
170+func (b *Bar) forgetModules(keep []*module.Module) {
171+ for m := range b.badCtl {
172+ if !slices.Contains(keep, m) {
173+ delete(b.badCtl, m)
174+ }
175+ }
176+ b.tips.forget(keep)
177+}
178+
179+// control handles one control line of module m.
180+func (b *Bar) control(m *module.Module, ctrl module.Control) {
103 if !m.Interactive {181 if !m.Interactive {
104 return // only modules that asked for the pointer may open popups182 return // only modules that asked for the pointer may open popups
105 }183 }
@@ -113,6 +191,9 @@ func (b *Bar) control(m *module.Module, raw []byte) {
113 if m != b.ptr.hover || b.pop.Kind() == popup.KindMenu {191 if m != b.ptr.hover || b.pop.Kind() == popup.KindMenu {
114 return192 return
115 }193 }
194+ if b.pop.ShowsTooltip(m, ctrl) || !b.tips.allow(m, time.Now()) {
195+ return // already shown, or the script asks too often
196+ }
116 b.openPopup(m, popup.KindTooltip, ctrl, 0)197 b.openPopup(m, popup.KindTooltip, ctrl, 0)
117 case module.ControlMenu:198 case module.ControlMenu:
118 serial, ok := b.ptr.takeMenuPress(m, ctrl.Click, time.Now())199 serial, ok := b.ptr.takeMenuPress(m, ctrl.Click, time.Now())
M internal/bar/popup_test.go +140 -5
@@ -2,9 +2,17 @@ package bar
22
33 import (
44 "bytes"
5+ "errors"
6+ "fmt"
57 "log/slog"
8+ "slices"
9+ "strings"
610 "testing"
711 "time"
12+
13+ "github.com/bnema/neferbar/internal/module"
14+ "github.com/bnema/neferbar/internal/popup"
15+ "github.com/bnema/neferbar/internal/racecheck"
816 )
917
1018 func TestMenuAcceptsOnlyTheLatestFreshPressOfTheSameModule(t *testing.T) {
@@ -53,20 +61,39 @@ func TestMenuAcceptsOnlyTheLatestFreshPressOfTheSameModule(t *testing.T) {
5361 }
5462 }
5563
64+// ctl parses the JSON of a control line, as the bar does.
65+func ctl(t testing.TB, js string) module.Control {
66+ t.Helper()
67+ c, err := module.ParseControl([]byte(js))
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ return c
72+}
73+
74+// feed prints control lines on module m's stdout, as its script would.
75+func feed(m *module.Module, lines ...string) {
76+ var sb strings.Builder
77+ for _, l := range lines {
78+ sb.WriteString("\x1b]777;neferbar;" + l + "\x07\n")
79+ }
80+ m.ReadFramesForTest(strings.NewReader(sb.String()))
81+}
82+
5683 func TestControlLinesAreCheckedBeforeAnythingOpens(t *testing.T) {
5784 r := newInputRig(t)
5885 var logs bytes.Buffer
5986 b := &Bar{lay: r.lay, log: slog.New(slog.NewTextHandler(&logs, nil))}
6087
6188 // An invalid line is logged once per module and otherwise ignored.
62- b.control(r.a, []byte(`{"type":"explode"}`))
63- b.control(r.a, []byte(`{nope`))
89+ feed(r.a, `{"type":"explode"}`, `{nope`)
90+ b.drainControl(r.a)
6491 if n := bytes.Count(logs.Bytes(), []byte("invalid control line")); n != 1 {
6592 t.Fatalf("logged %d times, want once:\n%s", n, logs.String())
6693 }
6794
68- tooltip := []byte(`{"type":"tooltip","col":0,"width":1,"title":"t"}`)
69- menu := []byte(`{"type":"menu","col":0,"width":1,"click":5,"items":[{"id":1,"label":"x"}]}`)
95+ tooltip := ctl(t, `{"type":"tooltip","col":0,"width":1,"title":"t"}`)
96+ menu := ctl(t, `{"type":"menu","col":0,"width":1,"click":5,"items":[{"id":1,"label":"x"}]}`)
7097 // None of these may reach openPopup: the bar has no surface, so reaching
7198 // it would return quietly, hence the checks go through the pointer state
7299 // the rules read.
@@ -78,8 +105,116 @@ func TestControlLinesAreCheckedBeforeAnythingOpens(t *testing.T) {
78105 if b.ptr.press.used {
79106 t.Fatal("a refused menu consumed the press")
80107 }
81- b.control(r.a, []byte(`{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"x"}]}`))
108+ b.control(r.a, ctl(t, `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"x"}]}`))
82109 if !b.ptr.press.used {
83110 t.Fatal("the right token should have been accepted")
84111 }
85112 }
113+
114+func TestCoalesceKeepsMenusAndTheLastTooltipOrClose(t *testing.T) {
115+ tip := func(title string) module.Control {
116+ return ctl(t, `{"type":"tooltip","col":0,"width":1,"title":"`+title+`"}`)
117+ }
118+ closeC := ctl(t, `{"type":"close"}`)
119+ menu := ctl(t, `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"x"}]}`)
120+ types := func(cs []module.Control) string {
121+ var sb strings.Builder
122+ for _, c := range cs {
123+ sb.WriteString(c.Type[:1] + c.Title + " ")
124+ }
125+ return strings.TrimSpace(sb.String())
126+ }
127+ for name, c := range map[string]struct {
128+ in []module.Control
129+ want string
130+ }{
131+ "none": {nil, ""},
132+ "one": {[]module.Control{tip("a")}, "ta"},
133+ "storm": {[]module.Control{tip("a"), tip("b"), tip("c")}, "tc"},
134+ "tooltip then close": {[]module.Control{tip("a"), closeC}, "c"},
135+ "close then tooltip": {[]module.Control{closeC, tip("z")}, "tz"},
136+ "menus are kept": {[]module.Control{tip("a"), menu, tip("b"), menu, closeC}, "m m c"},
137+ "only menus": {[]module.Control{menu, menu}, "m m"},
138+ } {
139+ if got := types(coalesceControl(slices.Clone(c.in))); got != c.want {
140+ t.Errorf("%s: got %q, want %q", name, got, c.want)
141+ }
142+ }
143+}
144+
145+func TestDrainAppliesOnlyTheLastTooltipOfAWakeUp(t *testing.T) {
146+ r := newInputRig(t)
147+ b := &Bar{lay: r.lay, log: slog.New(slog.DiscardHandler)}
148+ var seen []string
149+ b.ctlBuf = nil
150+ for i := range 50 {
151+ feed(r.a, fmt.Sprintf(`{"type":"tooltip","col":0,"width":1,"title":"t%d"}`, i))
152+ }
153+ // Count what survives by running the same steps drainControl does.
154+ raw, ok := r.a.TakeControl()
155+ for ; ok; raw, ok = r.a.TakeControl() {
156+ c, err := module.ParseControl(raw)
157+ if err != nil {
158+ t.Fatal(err)
159+ }
160+ b.ctlBuf = append(b.ctlBuf, c)
161+ }
162+ for _, c := range coalesceControl(b.ctlBuf) {
163+ seen = append(seen, c.Title)
164+ }
165+ // The queue holds 8 lines; of them one remains.
166+ if len(seen) != 1 || seen[0] != "t7" {
167+ t.Fatalf("applied %q, want just the last queued one", seen)
168+ }
169+}
170+
171+func TestTooltipRateLimit(t *testing.T) {
172+ r := newInputRig(t)
173+ var l tooltipLimiter
174+ t0 := time.Now()
175+ if !l.allow(r.a, t0) {
176+ t.Fatal("the first tooltip must pass")
177+ }
178+ if l.allow(r.a, t0.Add(tooltipInterval-time.Millisecond)) {
179+ t.Fatal("a second tooltip within the interval passed")
180+ }
181+ if !l.allow(r.b, t0.Add(time.Millisecond)) {
182+ t.Fatal("another module shares the limit")
183+ }
184+ if !l.allow(r.a, t0.Add(tooltipInterval)) {
185+ t.Fatal("the limit must lift after the interval")
186+ }
187+ // A refused tooltip does not push the window.
188+ l.allow(r.a, t0.Add(tooltipInterval+10*time.Millisecond))
189+ if !l.allow(r.a, t0.Add(2*tooltipInterval)) {
190+ t.Fatal("refusals extended the window")
191+ }
192+ l.forget([]*module.Module{r.b})
193+ if _, ok := l.last[r.a]; ok || len(l.last) != 1 {
194+ t.Fatalf("forget kept %v", l.last)
195+ }
196+}
197+
198+func TestStoppedModulesAreForgotten(t *testing.T) {
199+ r := newInputRig(t)
200+ b := &Bar{lay: r.lay, log: slog.New(slog.DiscardHandler)}
201+ b.badControl(r.a, errors.New("x"))
202+ b.badControl(r.b, errors.New("x"))
203+ b.tips.allow(r.a, time.Now())
204+ b.forgetModules([]*module.Module{r.b})
205+ if len(b.badCtl) != 1 || !b.badCtl[r.b] || len(b.tips.last) != 0 {
206+ t.Fatalf("badCtl %v, tips %v", b.badCtl, b.tips.last)
207+ }
208+}
209+
210+func TestStepPopupAllocsWithNoPopup(t *testing.T) {
211+ if racecheck.Enabled {
212+ t.Skip("the race detector allocates")
213+ }
214+ r := newInputRig(t)
215+ b := &Bar{lay: r.lay, log: slog.New(slog.DiscardHandler), pop: &popup.Host{}, mods: []*module.Module{r.a, r.b}}
216+ b.stepPopup() // warm any lazily grown state
217+ if got := testing.AllocsPerRun(200, b.stepPopup); got > 0 {
218+ t.Errorf("stepPopup with no popup open allocates %.1f objects; want 0", got)
219+ }
220+}
@@ -2,9 +2,17 @@ package bar
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "errors"
6+ "fmt"
5 "log/slog"7 "log/slog"
8+ "slices"
9+ "strings"
6 "testing"10 "testing"
7 "time"11 "time"
12+
13+ "github.com/bnema/neferbar/internal/module"
14+ "github.com/bnema/neferbar/internal/popup"
15+ "github.com/bnema/neferbar/internal/racecheck"
8 )16 )
9 17
10 func TestMenuAcceptsOnlyTheLatestFreshPressOfTheSameModule(t *testing.T) {18 func TestMenuAcceptsOnlyTheLatestFreshPressOfTheSameModule(t *testing.T) {
@@ -53,20 +61,39 @@ func TestMenuAcceptsOnlyTheLatestFreshPressOfTheSameModule(t *testing.T) {
53 }61 }
54 }62 }
55 63
64+// ctl parses the JSON of a control line, as the bar does.
65+func ctl(t testing.TB, js string) module.Control {
66+ t.Helper()
67+ c, err := module.ParseControl([]byte(js))
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ return c
72+}
73+
74+// feed prints control lines on module m's stdout, as its script would.
75+func feed(m *module.Module, lines ...string) {
76+ var sb strings.Builder
77+ for _, l := range lines {
78+ sb.WriteString("\x1b]777;neferbar;" + l + "\x07\n")
79+ }
80+ m.ReadFramesForTest(strings.NewReader(sb.String()))
81+}
82+
56 func TestControlLinesAreCheckedBeforeAnythingOpens(t *testing.T) {83 func TestControlLinesAreCheckedBeforeAnythingOpens(t *testing.T) {
57 r := newInputRig(t)84 r := newInputRig(t)
58 var logs bytes.Buffer85 var logs bytes.Buffer
59 b := &Bar{lay: r.lay, log: slog.New(slog.NewTextHandler(&logs, nil))}86 b := &Bar{lay: r.lay, log: slog.New(slog.NewTextHandler(&logs, nil))}
60 87
61 // An invalid line is logged once per module and otherwise ignored.88 // An invalid line is logged once per module and otherwise ignored.
62- b.control(r.a, []byte(`{"type":"explode"}`))89+ feed(r.a, `{"type":"explode"}`, `{nope`)
63- b.control(r.a, []byte(`{nope`))90+ b.drainControl(r.a)
64 if n := bytes.Count(logs.Bytes(), []byte("invalid control line")); n != 1 {91 if n := bytes.Count(logs.Bytes(), []byte("invalid control line")); n != 1 {
65 t.Fatalf("logged %d times, want once:\n%s", n, logs.String())92 t.Fatalf("logged %d times, want once:\n%s", n, logs.String())
66 }93 }
67 94
68- tooltip := []byte(`{"type":"tooltip","col":0,"width":1,"title":"t"}`)95+ tooltip := ctl(t, `{"type":"tooltip","col":0,"width":1,"title":"t"}`)
69- menu := []byte(`{"type":"menu","col":0,"width":1,"click":5,"items":[{"id":1,"label":"x"}]}`)96+ menu := ctl(t, `{"type":"menu","col":0,"width":1,"click":5,"items":[{"id":1,"label":"x"}]}`)
70 // None of these may reach openPopup: the bar has no surface, so reaching97 // None of these may reach openPopup: the bar has no surface, so reaching
71 // it would return quietly, hence the checks go through the pointer state98 // it would return quietly, hence the checks go through the pointer state
72 // the rules read.99 // the rules read.
@@ -78,8 +105,116 @@ func TestControlLinesAreCheckedBeforeAnythingOpens(t *testing.T) {
78 if b.ptr.press.used {105 if b.ptr.press.used {
79 t.Fatal("a refused menu consumed the press")106 t.Fatal("a refused menu consumed the press")
80 }107 }
81- b.control(r.a, []byte(`{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"x"}]}`))108+ b.control(r.a, ctl(t, `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"x"}]}`))
82 if !b.ptr.press.used {109 if !b.ptr.press.used {
83 t.Fatal("the right token should have been accepted")110 t.Fatal("the right token should have been accepted")
84 }111 }
85 }112 }
113+
114+func TestCoalesceKeepsMenusAndTheLastTooltipOrClose(t *testing.T) {
115+ tip := func(title string) module.Control {
116+ return ctl(t, `{"type":"tooltip","col":0,"width":1,"title":"`+title+`"}`)
117+ }
118+ closeC := ctl(t, `{"type":"close"}`)
119+ menu := ctl(t, `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"x"}]}`)
120+ types := func(cs []module.Control) string {
121+ var sb strings.Builder
122+ for _, c := range cs {
123+ sb.WriteString(c.Type[:1] + c.Title + " ")
124+ }
125+ return strings.TrimSpace(sb.String())
126+ }
127+ for name, c := range map[string]struct {
128+ in []module.Control
129+ want string
130+ }{
131+ "none": {nil, ""},
132+ "one": {[]module.Control{tip("a")}, "ta"},
133+ "storm": {[]module.Control{tip("a"), tip("b"), tip("c")}, "tc"},
134+ "tooltip then close": {[]module.Control{tip("a"), closeC}, "c"},
135+ "close then tooltip": {[]module.Control{closeC, tip("z")}, "tz"},
136+ "menus are kept": {[]module.Control{tip("a"), menu, tip("b"), menu, closeC}, "m m c"},
137+ "only menus": {[]module.Control{menu, menu}, "m m"},
138+ } {
139+ if got := types(coalesceControl(slices.Clone(c.in))); got != c.want {
140+ t.Errorf("%s: got %q, want %q", name, got, c.want)
141+ }
142+ }
143+}
144+
145+func TestDrainAppliesOnlyTheLastTooltipOfAWakeUp(t *testing.T) {
146+ r := newInputRig(t)
147+ b := &Bar{lay: r.lay, log: slog.New(slog.DiscardHandler)}
148+ var seen []string
149+ b.ctlBuf = nil
150+ for i := range 50 {
151+ feed(r.a, fmt.Sprintf(`{"type":"tooltip","col":0,"width":1,"title":"t%d"}`, i))
152+ }
153+ // Count what survives by running the same steps drainControl does.
154+ raw, ok := r.a.TakeControl()
155+ for ; ok; raw, ok = r.a.TakeControl() {
156+ c, err := module.ParseControl(raw)
157+ if err != nil {
158+ t.Fatal(err)
159+ }
160+ b.ctlBuf = append(b.ctlBuf, c)
161+ }
162+ for _, c := range coalesceControl(b.ctlBuf) {
163+ seen = append(seen, c.Title)
164+ }
165+ // The queue holds 8 lines; of them one remains.
166+ if len(seen) != 1 || seen[0] != "t7" {
167+ t.Fatalf("applied %q, want just the last queued one", seen)
168+ }
169+}
170+
171+func TestTooltipRateLimit(t *testing.T) {
172+ r := newInputRig(t)
173+ var l tooltipLimiter
174+ t0 := time.Now()
175+ if !l.allow(r.a, t0) {
176+ t.Fatal("the first tooltip must pass")
177+ }
178+ if l.allow(r.a, t0.Add(tooltipInterval-time.Millisecond)) {
179+ t.Fatal("a second tooltip within the interval passed")
180+ }
181+ if !l.allow(r.b, t0.Add(time.Millisecond)) {
182+ t.Fatal("another module shares the limit")
183+ }
184+ if !l.allow(r.a, t0.Add(tooltipInterval)) {
185+ t.Fatal("the limit must lift after the interval")
186+ }
187+ // A refused tooltip does not push the window.
188+ l.allow(r.a, t0.Add(tooltipInterval+10*time.Millisecond))
189+ if !l.allow(r.a, t0.Add(2*tooltipInterval)) {
190+ t.Fatal("refusals extended the window")
191+ }
192+ l.forget([]*module.Module{r.b})
193+ if _, ok := l.last[r.a]; ok || len(l.last) != 1 {
194+ t.Fatalf("forget kept %v", l.last)
195+ }
196+}
197+
198+func TestStoppedModulesAreForgotten(t *testing.T) {
199+ r := newInputRig(t)
200+ b := &Bar{lay: r.lay, log: slog.New(slog.DiscardHandler)}
201+ b.badControl(r.a, errors.New("x"))
202+ b.badControl(r.b, errors.New("x"))
203+ b.tips.allow(r.a, time.Now())
204+ b.forgetModules([]*module.Module{r.b})
205+ if len(b.badCtl) != 1 || !b.badCtl[r.b] || len(b.tips.last) != 0 {
206+ t.Fatalf("badCtl %v, tips %v", b.badCtl, b.tips.last)
207+ }
208+}
209+
210+func TestStepPopupAllocsWithNoPopup(t *testing.T) {
211+ if racecheck.Enabled {
212+ t.Skip("the race detector allocates")
213+ }
214+ r := newInputRig(t)
215+ b := &Bar{lay: r.lay, log: slog.New(slog.DiscardHandler), pop: &popup.Host{}, mods: []*module.Module{r.a, r.b}}
216+ b.stepPopup() // warm any lazily grown state
217+ if got := testing.AllocsPerRun(200, b.stepPopup); got > 0 {
218+ t.Errorf("stepPopup with no popup open allocates %.1f objects; want 0", got)
219+ }
220+}
M internal/module/module.go +3 -0
@@ -393,6 +393,9 @@ func DrainForTest(m *Module) []string {
393393 }
394394 }
395395
396+// ReadFramesForTest feeds r to the module as its script's stdout.
397+func (m *Module) ReadFramesForTest(r io.Reader) { m.readFrames(r) }
398+
396399 // PublishForTest injects a frame as if the script had printed it.
397400 func PublishForTest(m *Module, frame []byte) { m.publish(frame) }
398401
@@ -393,6 +393,9 @@ func DrainForTest(m *Module) []string {
393 }393 }
394 }394 }
395 395
396+// ReadFramesForTest feeds r to the module as its script's stdout.
397+func (m *Module) ReadFramesForTest(r io.Reader) { m.readFrames(r) }
398+
396 // PublishForTest injects a frame as if the script had printed it.399 // PublishForTest injects a frame as if the script had printed it.
397 func PublishForTest(m *Module, frame []byte) { m.publish(frame) }400 func PublishForTest(m *Module, frame []byte) { m.publish(frame) }
398 401
M internal/popup/host.go +69 -10
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "log/slog"
77 "strconv"
8+ "time"
89
910 "github.com/bnema/neferclient"
1011 "github.com/bnema/nefergui"
@@ -74,6 +75,7 @@ type Host struct {
7475 menu *menuModel
7576
7677 kind Kind
78+ tipKey tooltipKey // what the open tooltip shows
7779 owner *module.Module
7880 ownerGen uint64
7981 anchor neferclient.Rect
@@ -126,14 +128,64 @@ func (h *Host) Warm(fb *neferclient.Feedback) {
126128 }()
127129 }
128130
129-// Wait blocks until the warm-up renderer is gone. The bar calls it before it
130-// tears down its own GPU state.
131-func (h *Host) Wait() {
132- if h != nil && h.warm != nil {
133- <-h.warm
131+// WaitTimeout waits up to d for the warm-up renderer to be gone and reports
132+// whether it is. The bar calls it before it tears down its own GPU state, and
133+// goes on when the GPU libraries are stuck.
134+func (h *Host) WaitTimeout(d time.Duration) bool {
135+ if h == nil || h.warm == nil {
136+ return true
137+ }
138+ t := time.NewTimer(d)
139+ defer t.Stop()
140+ select {
141+ case <-h.warm:
142+ return true
143+ case <-t.C:
144+ return false
134145 }
135146 }
136147
148+// waitWarm decides whether Open of a popup of the given kind goes on while the
149+// warm-up renderer may still be loading the GPU libraries. A tooltip does not
150+// wait: it is skipped, since a tooltip that shows up half a second late is
151+// worse than none (false). A menu was asked for with a click and waits (true).
152+func (h *Host) waitWarm(kind Kind) (proceed bool) {
153+ if h.warm == nil {
154+ return true
155+ }
156+ select {
157+ case <-h.warm:
158+ return true
159+ default:
160+ }
161+ if kind == KindTooltip {
162+ return false
163+ }
164+ <-h.warm // two renderers must not start at once
165+ return true
166+}
167+
168+// tooltipKey is what identifies a tooltip: showing the same one again is
169+// pointless.
170+type tooltipKey struct {
171+ title, body string
172+ col, width int
173+}
174+
175+func keyOf(c module.Control) tooltipKey {
176+ return tooltipKey{c.Title, c.Body, c.Col, c.Width}
177+}
178+
179+// ShowsTooltip reports whether the open popup is a tooltip of owner that shows
180+// exactly what c says.
181+func (h *Host) ShowsTooltip(owner *module.Module, c module.Control) bool {
182+ return h != nil && h.r != nil && h.showsTooltip(owner, c)
183+}
184+
185+func (h *Host) showsTooltip(owner *module.Module, c module.Control) bool {
186+ return h.kind == KindTooltip && h.owner == owner && h.tipKey == keyOf(c)
187+}
188+
137189 // Active reports whether a popup is open.
138190 func (h *Host) Active() bool { return h != nil && h.r != nil }
139191
@@ -184,7 +236,7 @@ func (h *Host) measure(r *nefergui.Renderer) (w, ht int32, scroll bool, err erro
184236 h.menu.scroll = false
185237 fw, fh, err = r.Measure(h.menu, menuView, MaxMenuWidth)
186238 if err == nil && fh > MaxHeight {
187- h.menu.scroll = true
239+ h.menu.scroll, h.menu.scrollH = true, menuContentHeight
188240 fw, fh, err = r.Measure(h.menu, menuView, MaxMenuWidth)
189241 }
190242 }
@@ -207,12 +259,15 @@ func (h *Host) Open(req Request) error {
207259 case req.Kind != KindTooltip && req.Kind != KindMenu:
208260 return fmt.Errorf("popup: unknown kind %d", req.Kind)
209261 }
210- h.Wait() // two renderers must not start at once
262+ if !h.waitWarm(req.Kind) {
263+ return nil // the GPU libraries are still loading: skip this tooltip
264+ }
211265 r, err := nefergui.NewRenderer(nefergui.RendererConfig{MainDevice: req.Feedback.MainDevice, Formats: formats(req.Feedback), Styles: h.styles})
212266 if err != nil {
213267 return fmt.Errorf("popup: %w", err)
214268 }
215269 h.r, h.kind = r, req.Kind
270+ h.tipKey = keyOf(req.Ctrl)
216271 switch req.Kind {
217272 case KindTooltip:
218273 h.tip = newTooltip(req.Ctrl.Title, req.Ctrl.Body)
@@ -431,7 +486,9 @@ func (h *Host) Draw() {
431486 line := append([]byte("menu-activate "), strconv.FormatUint(uint64(h.menu.token), 10)...)
432487 line = append(line, ' ')
433488 line = strconv.AppendInt(line, int64(h.menu.chosen), 10)
434- h.owner.Send(line)
489+ if !h.owner.Send(line) {
490+ h.log.Debug("popup: the choice was not delivered: the script's input queue is full or closed", "module", h.owner.Name)
491+ }
435492 h.end(false)
436493 return
437494 }
@@ -456,9 +513,11 @@ func (h *Host) levelChanged() {
456513 return
457514 }
458515 if err = h.surf.Reposition(Placement(h.anchor, h.bottomBar, w, ht)); err != nil {
459- // Without xdg_wm_base 3 the popup keeps its size: the new level
460- // is clipped or scrolls inside it.
516+ // Without xdg_wm_base 3 the popup keeps its size: a level taller than
517+ // it scrolls inside it.
461518 h.log.Debug("popup: cannot resize the menu", "err", err)
519+ _, cur, _ := h.surf.Size()
520+ h.menu.fitTo(ht, cur)
462521 }
463522 h.r.Invalidate()
464523 }
@@ -5,6 +5,7 @@ import (
5 "fmt"5 "fmt"
6 "log/slog"6 "log/slog"
7 "strconv"7 "strconv"
8+ "time"
8 9
9 "github.com/bnema/neferclient"10 "github.com/bnema/neferclient"
10 "github.com/bnema/nefergui"11 "github.com/bnema/nefergui"
@@ -74,6 +75,7 @@ type Host struct {
74 menu *menuModel75 menu *menuModel
75 76
76 kind Kind77 kind Kind
78+ tipKey tooltipKey // what the open tooltip shows
77 owner *module.Module79 owner *module.Module
78 ownerGen uint6480 ownerGen uint64
79 anchor neferclient.Rect81 anchor neferclient.Rect
@@ -126,14 +128,64 @@ func (h *Host) Warm(fb *neferclient.Feedback) {
126 }()128 }()
127 }129 }
128 130
129-// Wait blocks until the warm-up renderer is gone. The bar calls it before it131+// WaitTimeout waits up to d for the warm-up renderer to be gone and reports
130-// tears down its own GPU state.132+// whether it is. The bar calls it before it tears down its own GPU state, and
131-func (h *Host) Wait() {133+// goes on when the GPU libraries are stuck.
132- if h != nil && h.warm != nil {134+func (h *Host) WaitTimeout(d time.Duration) bool {
133- <-h.warm135+ if h == nil || h.warm == nil {
136+ return true
137+ }
138+ t := time.NewTimer(d)
139+ defer t.Stop()
140+ select {
141+ case <-h.warm:
142+ return true
143+ case <-t.C:
144+ return false
134 }145 }
135 }146 }
136 147
148+// waitWarm decides whether Open of a popup of the given kind goes on while the
149+// warm-up renderer may still be loading the GPU libraries. A tooltip does not
150+// wait: it is skipped, since a tooltip that shows up half a second late is
151+// worse than none (false). A menu was asked for with a click and waits (true).
152+func (h *Host) waitWarm(kind Kind) (proceed bool) {
153+ if h.warm == nil {
154+ return true
155+ }
156+ select {
157+ case <-h.warm:
158+ return true
159+ default:
160+ }
161+ if kind == KindTooltip {
162+ return false
163+ }
164+ <-h.warm // two renderers must not start at once
165+ return true
166+}
167+
168+// tooltipKey is what identifies a tooltip: showing the same one again is
169+// pointless.
170+type tooltipKey struct {
171+ title, body string
172+ col, width int
173+}
174+
175+func keyOf(c module.Control) tooltipKey {
176+ return tooltipKey{c.Title, c.Body, c.Col, c.Width}
177+}
178+
179+// ShowsTooltip reports whether the open popup is a tooltip of owner that shows
180+// exactly what c says.
181+func (h *Host) ShowsTooltip(owner *module.Module, c module.Control) bool {
182+ return h != nil && h.r != nil && h.showsTooltip(owner, c)
183+}
184+
185+func (h *Host) showsTooltip(owner *module.Module, c module.Control) bool {
186+ return h.kind == KindTooltip && h.owner == owner && h.tipKey == keyOf(c)
187+}
188+
137 // Active reports whether a popup is open.189 // Active reports whether a popup is open.
138 func (h *Host) Active() bool { return h != nil && h.r != nil }190 func (h *Host) Active() bool { return h != nil && h.r != nil }
139 191
@@ -184,7 +236,7 @@ func (h *Host) measure(r *nefergui.Renderer) (w, ht int32, scroll bool, err erro
184 h.menu.scroll = false236 h.menu.scroll = false
185 fw, fh, err = r.Measure(h.menu, menuView, MaxMenuWidth)237 fw, fh, err = r.Measure(h.menu, menuView, MaxMenuWidth)
186 if err == nil && fh > MaxHeight {238 if err == nil && fh > MaxHeight {
187- h.menu.scroll = true239+ h.menu.scroll, h.menu.scrollH = true, menuContentHeight
188 fw, fh, err = r.Measure(h.menu, menuView, MaxMenuWidth)240 fw, fh, err = r.Measure(h.menu, menuView, MaxMenuWidth)
189 }241 }
190 }242 }
@@ -207,12 +259,15 @@ func (h *Host) Open(req Request) error {
207 case req.Kind != KindTooltip && req.Kind != KindMenu:259 case req.Kind != KindTooltip && req.Kind != KindMenu:
208 return fmt.Errorf("popup: unknown kind %d", req.Kind)260 return fmt.Errorf("popup: unknown kind %d", req.Kind)
209 }261 }
210- h.Wait() // two renderers must not start at once262+ if !h.waitWarm(req.Kind) {
263+ return nil // the GPU libraries are still loading: skip this tooltip
264+ }
211 r, err := nefergui.NewRenderer(nefergui.RendererConfig{MainDevice: req.Feedback.MainDevice, Formats: formats(req.Feedback), Styles: h.styles})265 r, err := nefergui.NewRenderer(nefergui.RendererConfig{MainDevice: req.Feedback.MainDevice, Formats: formats(req.Feedback), Styles: h.styles})
212 if err != nil {266 if err != nil {
213 return fmt.Errorf("popup: %w", err)267 return fmt.Errorf("popup: %w", err)
214 }268 }
215 h.r, h.kind = r, req.Kind269 h.r, h.kind = r, req.Kind
270+ h.tipKey = keyOf(req.Ctrl)
216 switch req.Kind {271 switch req.Kind {
217 case KindTooltip:272 case KindTooltip:
218 h.tip = newTooltip(req.Ctrl.Title, req.Ctrl.Body)273 h.tip = newTooltip(req.Ctrl.Title, req.Ctrl.Body)
@@ -431,7 +486,9 @@ func (h *Host) Draw() {
431 line := append([]byte("menu-activate "), strconv.FormatUint(uint64(h.menu.token), 10)...)486 line := append([]byte("menu-activate "), strconv.FormatUint(uint64(h.menu.token), 10)...)
432 line = append(line, ' ')487 line = append(line, ' ')
433 line = strconv.AppendInt(line, int64(h.menu.chosen), 10)488 line = strconv.AppendInt(line, int64(h.menu.chosen), 10)
434- h.owner.Send(line)489+ if !h.owner.Send(line) {
490+ h.log.Debug("popup: the choice was not delivered: the script's input queue is full or closed", "module", h.owner.Name)
491+ }
435 h.end(false)492 h.end(false)
436 return493 return
437 }494 }
@@ -456,9 +513,11 @@ func (h *Host) levelChanged() {
456 return513 return
457 }514 }
458 if err = h.surf.Reposition(Placement(h.anchor, h.bottomBar, w, ht)); err != nil {515 if err = h.surf.Reposition(Placement(h.anchor, h.bottomBar, w, ht)); err != nil {
459- // Without xdg_wm_base 3 the popup keeps its size: the new level516+ // Without xdg_wm_base 3 the popup keeps its size: a level taller than
460- // is clipped or scrolls inside it.517+ // it scrolls inside it.
461 h.log.Debug("popup: cannot resize the menu", "err", err)518 h.log.Debug("popup: cannot resize the menu", "err", err)
519+ _, cur, _ := h.surf.Size()
520+ h.menu.fitTo(ht, cur)
462 }521 }
463 h.r.Invalidate()522 h.r.Invalidate()
464 }523 }
M internal/popup/popup_test.go +73 -0
@@ -7,6 +7,7 @@ import (
77 "strconv"
88 "strings"
99 "testing"
10+ "time"
1011
1112 "github.com/bnema/neferclient"
1213
@@ -247,3 +248,75 @@ func TestMapKey(t *testing.T) {
247248 }
248249 }
249250 }
251+
252+func TestTooltipDoesNotWaitForTheWarmUpButMenusDo(t *testing.T) {
253+ h := &Host{}
254+ if !h.waitWarm(KindTooltip) || !h.waitWarm(KindMenu) {
255+ t.Fatal("without a warm-up nothing waits")
256+ }
257+ h.warm = make(chan struct{})
258+ if h.waitWarm(KindTooltip) {
259+ t.Fatal("a tooltip must be skipped while the warm-up runs")
260+ }
261+ if h.WaitTimeout(10 * time.Millisecond) {
262+ t.Fatal("WaitTimeout reported a running warm-up as done")
263+ }
264+ got := make(chan bool)
265+ go func() { got <- h.waitWarm(KindMenu) }()
266+ select {
267+ case <-got:
268+ t.Fatal("a menu must wait for the warm-up")
269+ case <-time.After(30 * time.Millisecond):
270+ }
271+ close(h.warm)
272+ if !<-got {
273+ t.Fatal("a menu goes on once the warm-up ended")
274+ }
275+ if !h.waitWarm(KindTooltip) || !h.WaitTimeout(time.Second) {
276+ t.Fatal("after the warm-up a tooltip goes on")
277+ }
278+}
279+
280+func TestShowsTooltip(t *testing.T) {
281+ m := &module.Module{}
282+ other := &module.Module{}
283+ c := module.Control{Type: module.ControlTooltip, Col: 1, Width: 2, Title: "T", Body: "B"}
284+ h := &Host{kind: KindTooltip, owner: m, tipKey: keyOf(c)}
285+ if !h.showsTooltip(m, c) {
286+ t.Fatal("the same tooltip of the same owner is shown")
287+ }
288+ for name, d := range map[string]module.Control{
289+ "title": {Col: 1, Width: 2, Title: "T2", Body: "B"}, "body": {Col: 1, Width: 2, Title: "T", Body: "B2"},
290+ "col": {Col: 2, Width: 2, Title: "T", Body: "B"}, "width": {Col: 1, Width: 3, Title: "T", Body: "B"},
291+ } {
292+ if h.showsTooltip(m, d) {
293+ t.Errorf("a tooltip with another %s counts as shown", name)
294+ }
295+ }
296+ if h.showsTooltip(other, c) {
297+ t.Error("another owner's tooltip counts as shown")
298+ }
299+ h.kind = KindMenu
300+ if h.showsTooltip(m, c) {
301+ t.Error("a menu counts as a tooltip")
302+ }
303+ if (*Host)(nil).ShowsTooltip(m, c) || (&Host{}).ShowsTooltip(m, c) {
304+ t.Error("no popup open must not match")
305+ }
306+}
307+
308+func TestFitToScrollsAMenuThatCannotBeResized(t *testing.T) {
309+ m := newMenu(nil, 1)
310+ m.fitTo(200, 300) // the new level fits: no scrolling
311+ if m.scroll {
312+ t.Fatal("a level that fits must not scroll")
313+ }
314+ m.fitTo(400, 300)
315+ if !m.scroll || m.scrollH != 300-2*Inset {
316+ t.Fatalf("scroll %v height %d", m.scroll, m.scrollH)
317+ }
318+ m.fitTo(400, 1)
319+ if m.scrollH < 1 {
320+ t.Fatalf("scroll height %d", m.scrollH)
321+ }
322+}
@@ -7,6 +7,7 @@ import (
7 "strconv"7 "strconv"
8 "strings"8 "strings"
9 "testing"9 "testing"
10+ "time"
10 11
11 "github.com/bnema/neferclient"12 "github.com/bnema/neferclient"
12 13
@@ -247,3 +248,75 @@ func TestMapKey(t *testing.T) {
247 }248 }
248 }249 }
249 }250 }
251+
252+func TestTooltipDoesNotWaitForTheWarmUpButMenusDo(t *testing.T) {
253+ h := &Host{}
254+ if !h.waitWarm(KindTooltip) || !h.waitWarm(KindMenu) {
255+ t.Fatal("without a warm-up nothing waits")
256+ }
257+ h.warm = make(chan struct{})
258+ if h.waitWarm(KindTooltip) {
259+ t.Fatal("a tooltip must be skipped while the warm-up runs")
260+ }
261+ if h.WaitTimeout(10 * time.Millisecond) {
262+ t.Fatal("WaitTimeout reported a running warm-up as done")
263+ }
264+ got := make(chan bool)
265+ go func() { got <- h.waitWarm(KindMenu) }()
266+ select {
267+ case <-got:
268+ t.Fatal("a menu must wait for the warm-up")
269+ case <-time.After(30 * time.Millisecond):
270+ }
271+ close(h.warm)
272+ if !<-got {
273+ t.Fatal("a menu goes on once the warm-up ended")
274+ }
275+ if !h.waitWarm(KindTooltip) || !h.WaitTimeout(time.Second) {
276+ t.Fatal("after the warm-up a tooltip goes on")
277+ }
278+}
279+
280+func TestShowsTooltip(t *testing.T) {
281+ m := &module.Module{}
282+ other := &module.Module{}
283+ c := module.Control{Type: module.ControlTooltip, Col: 1, Width: 2, Title: "T", Body: "B"}
284+ h := &Host{kind: KindTooltip, owner: m, tipKey: keyOf(c)}
285+ if !h.showsTooltip(m, c) {
286+ t.Fatal("the same tooltip of the same owner is shown")
287+ }
288+ for name, d := range map[string]module.Control{
289+ "title": {Col: 1, Width: 2, Title: "T2", Body: "B"}, "body": {Col: 1, Width: 2, Title: "T", Body: "B2"},
290+ "col": {Col: 2, Width: 2, Title: "T", Body: "B"}, "width": {Col: 1, Width: 3, Title: "T", Body: "B"},
291+ } {
292+ if h.showsTooltip(m, d) {
293+ t.Errorf("a tooltip with another %s counts as shown", name)
294+ }
295+ }
296+ if h.showsTooltip(other, c) {
297+ t.Error("another owner's tooltip counts as shown")
298+ }
299+ h.kind = KindMenu
300+ if h.showsTooltip(m, c) {
301+ t.Error("a menu counts as a tooltip")
302+ }
303+ if (*Host)(nil).ShowsTooltip(m, c) || (&Host{}).ShowsTooltip(m, c) {
304+ t.Error("no popup open must not match")
305+ }
306+}
307+
308+func TestFitToScrollsAMenuThatCannotBeResized(t *testing.T) {
309+ m := newMenu(nil, 1)
310+ m.fitTo(200, 300) // the new level fits: no scrolling
311+ if m.scroll {
312+ t.Fatal("a level that fits must not scroll")
313+ }
314+ m.fitTo(400, 300)
315+ if !m.scroll || m.scrollH != 300-2*Inset {
316+ t.Fatalf("scroll %v height %d", m.scroll, m.scrollH)
317+ }
318+ m.fitTo(400, 1)
319+ if m.scrollH < 1 {
320+ t.Fatalf("scroll height %d", m.scrollH)
321+ }
322+}
M internal/popup/view.go +15 -5
@@ -49,8 +49,18 @@ type menuModel struct {
4949 chosen int32 // the id of the activated item, -1 for none
5050 // changed is set when the level changed: the popup must be resized.
5151 changed bool
52- // scroll wraps the items in a scrolling box: they are taller than a popup.
53- scroll bool
52+ // scroll wraps the items in a scrolling box of scrollH pixels: they are
53+ // taller than the popup.
54+ scroll bool
55+ scrollH int
56+}
57+
58+// fitTo is the fallback when the popup could not be resized for a level that
59+// needs wantH pixels: if the surface keeps curH, the items scroll inside it.
60+func (m *menuModel) fitTo(wantH, curH int32) {
61+ if wantH > curH {
62+ m.scroll, m.scrollH = true, max(int(curH)-2*Inset, 1)
63+ }
5464 }
5565
5666 func newMenu(items []module.MenuItem, token uint32) *menuModel {
@@ -106,8 +116,8 @@ func (m *menuModel) levelKey() string {
106116 return k + "/"
107117 }
108118
109-// menuContentHeight is the height of the box that scrolls: the popup height
110-// minus the stylesheet's padding and border.
119+// menuContentHeight is the height of the box that scrolls in a popup of
120+// MaxHeight: the popup height minus the stylesheet's padding and border.
111121 const menuContentHeight = MaxHeight - 2*Inset
112122
113123 const (
@@ -120,7 +130,7 @@ const (
120130 func menuView(f *nefergui.Frame, m *menuModel) {
121131 parent := f.Root()
122132 if m.scroll {
123- parent = parent.Scroll(nefergui.Key("scroll"), nefergui.Inline("height: "+strconv.Itoa(menuContentHeight)+"px"))
133+ parent = parent.Scroll(nefergui.Key("scroll"), nefergui.Inline("height: "+strconv.Itoa(m.scrollH)+"px"))
124134 }
125135 col := parent.Column()
126136 if m.inSubmenu() {
@@ -49,8 +49,18 @@ type menuModel struct {
49 chosen int32 // the id of the activated item, -1 for none49 chosen int32 // the id of the activated item, -1 for none
50 // changed is set when the level changed: the popup must be resized.50 // changed is set when the level changed: the popup must be resized.
51 changed bool51 changed bool
52- // scroll wraps the items in a scrolling box: they are taller than a popup.52+ // scroll wraps the items in a scrolling box of scrollH pixels: they are
53- scroll bool53+ // taller than the popup.
54+ scroll bool
55+ scrollH int
56+}
57+
58+// fitTo is the fallback when the popup could not be resized for a level that
59+// needs wantH pixels: if the surface keeps curH, the items scroll inside it.
60+func (m *menuModel) fitTo(wantH, curH int32) {
61+ if wantH > curH {
62+ m.scroll, m.scrollH = true, max(int(curH)-2*Inset, 1)
63+ }
54 }64 }
55 65
56 func newMenu(items []module.MenuItem, token uint32) *menuModel {66 func newMenu(items []module.MenuItem, token uint32) *menuModel {
@@ -106,8 +116,8 @@ func (m *menuModel) levelKey() string {
106 return k + "/"116 return k + "/"
107 }117 }
108 118
109-// menuContentHeight is the height of the box that scrolls: the popup height119+// menuContentHeight is the height of the box that scrolls in a popup of
110-// minus the stylesheet's padding and border.120+// MaxHeight: the popup height minus the stylesheet's padding and border.
111 const menuContentHeight = MaxHeight - 2*Inset121 const menuContentHeight = MaxHeight - 2*Inset
112 122
113 const (123 const (
@@ -120,7 +130,7 @@ const (
120 func menuView(f *nefergui.Frame, m *menuModel) {130 func menuView(f *nefergui.Frame, m *menuModel) {
121 parent := f.Root()131 parent := f.Root()
122 if m.scroll {132 if m.scroll {
123- parent = parent.Scroll(nefergui.Key("scroll"), nefergui.Inline("height: "+strconv.Itoa(menuContentHeight)+"px"))133+ parent = parent.Scroll(nefergui.Key("scroll"), nefergui.Inline("height: "+strconv.Itoa(m.scrollH)+"px"))
124 }134 }
125 col := parent.Column()135 col := parent.Column()
126 if m.inSubmenu() {136 if m.inSubmenu() {

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