detached ea6e2cd

feat(popup): popup host

A Host owns one xdg_popup on the bar's layer surface, drawn by a nefergui renderer of its own (measured first, resized with Reposition). The bar routes the popup's surface events to it, reads control lines of interactive modules, accepts a menu only for the latest press (same module, at most 5 s old) and grabs with that press's serial. A tooltip opens only for the hovered module and closes on leave, move or press.
commit
ea6e2cdbd23e34564664b47a06baa8e2236e6082
author
brice · · 2026-10-09 05:18 UTC
parent
476c4a3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +1489 -17
M go.mod +2 -0
@@ -6,6 +6,7 @@ require (
66 github.com/BurntSushi/toml v1.6.0
77 github.com/bnema/go-wayland-bindings v0.1.0
88 github.com/bnema/neferclient v0.4.0
9+ github.com/bnema/nefergui v0.7.0
910 github.com/bnema/purego-vulkan v0.6.0
1011 github.com/bnema/vev-vt v0.6.0
1112 github.com/bnema/wlturbo v0.6.2
@@ -17,5 +18,6 @@ require (
1718 require (
1819 github.com/bnema/purego v0.13.0-bnema.1 // indirect
1920 github.com/bnema/purego-xkbcommon v0.2.0 // indirect
21+ github.com/go-text/typesetting v0.3.5 // indirect
2022 golang.org/x/text v0.42.0 // indirect
2123 )
@@ -6,6 +6,7 @@ require (
6 github.com/BurntSushi/toml v1.6.06 github.com/BurntSushi/toml v1.6.0
7 github.com/bnema/go-wayland-bindings v0.1.07 github.com/bnema/go-wayland-bindings v0.1.0
8 github.com/bnema/neferclient v0.4.08 github.com/bnema/neferclient v0.4.0
9+ github.com/bnema/nefergui v0.7.0
9 github.com/bnema/purego-vulkan v0.6.010 github.com/bnema/purego-vulkan v0.6.0
10 github.com/bnema/vev-vt v0.6.011 github.com/bnema/vev-vt v0.6.0
11 github.com/bnema/wlturbo v0.6.212 github.com/bnema/wlturbo v0.6.2
@@ -17,5 +18,6 @@ require (
17 require (18 require (
18 github.com/bnema/purego v0.13.0-bnema.1 // indirect19 github.com/bnema/purego v0.13.0-bnema.1 // indirect
19 github.com/bnema/purego-xkbcommon v0.2.0 // indirect20 github.com/bnema/purego-xkbcommon v0.2.0 // indirect
21+ github.com/go-text/typesetting v0.3.5 // indirect
20 golang.org/x/text v0.42.0 // indirect22 golang.org/x/text v0.42.0 // indirect
21 )23 )
M go.sum +6 -0 generated file: collapsed
M internal/bar/bar.go +80 -10
@@ -28,6 +28,7 @@ import (
2828 "github.com/bnema/neferbar/internal/gpu"
2929 "github.com/bnema/neferbar/internal/layout"
3030 "github.com/bnema/neferbar/internal/module"
31+ "github.com/bnema/neferbar/internal/popup"
3132 "github.com/bnema/neferbar/internal/syncobj"
3233 "github.com/bnema/neferbar/internal/theme"
3334 )
@@ -119,6 +120,13 @@ type Bar struct {
119120 ptr pointerState // pointer events turned into module lines
120121 input inputRegion
121122
123+ pop *popup.Host // the tooltip or menu being shown, if any
124+ popC <-chan time.Time
125+ popCSS string // the stylesheet last written for the popups
126+ warmed bool
127+ spanBuf []layout.Span
128+ badCtl map[*module.Module]bool // modules whose invalid control line was logged
129+
122130 Stats Stats
123131 fatal error
124132 closed bool
@@ -186,6 +194,7 @@ func (b *Bar) setTheme(th theme.Theme) {
186194 }
187195 b.dirty = true
188196 b.watchTheme()
197+ b.updatePopupStyle()
189198 }
190199
191200 // watchTheme watches every file the theme was read from, so changing the
@@ -323,6 +332,9 @@ func (b *Bar) syncModules(want []config.Module) {
323332 r.cancel()
324333 }
325334 b.runners = next
335+ if o := b.pop.Owner(); o != nil && !slices.ContainsFunc(next, func(r runner) bool { return r.m == o }) {
336+ b.closePopup() // its module was stopped or replaced
337+ }
326338 clear(b.stale)
327339 b.watchScripts()
328340 b.mods = b.mods[:0]
@@ -353,6 +365,7 @@ func (b *Bar) applyConfig(next config.Config) {
353365 b.log.Warn("bar.output changed: restart the bar to move it", "from", old.Bar.Output, "to", next.Bar.Output)
354366 next.Bar.Output = old.Bar.Output
355367 }
368+ b.closePopup() // it was built from the old settings
356369 b.cfg = next
357370 b.fonts = fonts
358371 if err = b.loadTheme(); err != nil { // the theme setting or the colors may have changed
@@ -365,6 +378,7 @@ func (b *Bar) applyConfig(next config.Config) {
365378 b.log.Info("moving the bar", "to", next.Bar.Position)
366379 b.dropRenderer()
367380 _, h, _ := b.surf.Size()
381+ b.closePopup()
368382 if err = b.surf.Close(); err != nil {
369383 b.fail(err)
370384 return
@@ -389,6 +403,8 @@ func (b *Bar) Run(ctx context.Context) (err error) {
389403 if err != nil {
390404 return connectError(b.display, err)
391405 }
406+ b.pop = popup.NewHost(b.conn, b.log)
407+ b.updatePopupStyle()
392408 defer func() {
393409 err = errors.Join(err, b.shutdown())
394410 b.log.Info("stats", "presented", b.Stats.Presented, "rebuilds", b.Stats.Rebuilds,
@@ -417,6 +433,8 @@ func (b *Bar) Run(ctx context.Context) (err error) {
417433 if err = b.conn.Dispatch(b); err != nil {
418434 return fmt.Errorf("dispatch: %w", err)
419435 }
436+ case <-b.pop.Wake(): // the popup renderer wants a frame
437+ case <-b.popC: // a popup frame was waiting for the GPU
420438 case <-b.modCh:
421439 b.Stats.ModuleWake++
422440 case next := <-b.cfgCh:
@@ -435,6 +453,7 @@ func (b *Bar) Run(ctx context.Context) (err error) {
435453 if err = b.step(); err != nil {
436454 return err
437455 }
456+ b.stepPopup()
438457 }
439458 return nil
440459 }
@@ -637,6 +656,7 @@ func (b *Bar) reconcile() {
637656 b.askedH, b.askedScale = wantH, scale
638657 b.log.Info("recreating layer surface", "height", wantH, "scale", scale)
639658 b.dropRenderer()
659+ b.closePopup()
640660 if err := b.surf.Close(); err != nil {
641661 b.fail(err)
642662 return
@@ -664,6 +684,7 @@ func (b *Bar) reconcile() {
664684 // off. A new surface is mapped fresh and placed from its real size.
665685 b.log.Info("recreating layer surface: the output width changed", "from", b.mappedW, "to", w)
666686 b.dropRenderer()
687+ b.closePopup()
667688 if err := b.surf.Close(); err != nil {
668689 b.fail(err)
669690 return
@@ -751,6 +772,8 @@ func (b *Bar) shutdown() error {
751772 for _, r := range b.runners {
752773 r.m.Wait(3 * time.Second)
753774 }
775+ b.closePopup()
776+ b.pop.Wait() // the warm-up renderer must be gone before the GPU state is
754777 b.dropRenderer()
755778 b.face.Close()
756779 if b.node != nil {
@@ -783,6 +806,10 @@ func (b *Bar) geometryChanged() {
783806
784807 // Configure marks the surface presentable and rebuilds as needed.
785808 func (b *Bar) Configure(id neferclient.SurfaceID, _, _ int32) {
809+ if b.pop.Is(id) {
810+ b.pop.Configure()
811+ return
812+ }
786813 if id != b.sid {
787814 return
788815 }
@@ -795,6 +822,10 @@ func (b *Bar) Configure(id neferclient.SurfaceID, _, _ int32) {
795822
796823 // Scale follows the monitor's preferred scale.
797824 func (b *Bar) Scale(id neferclient.SurfaceID, _ float64) {
825+ if b.pop.Is(id) {
826+ b.pop.Scale()
827+ return
828+ }
798829 if id == b.sid {
799830 b.geometryChanged()
800831 }
@@ -821,16 +852,43 @@ func (b *Bar) FeedbackDone(id neferclient.SurfaceID) {
821852
822853 // Frame allows the next Present.
823854 func (b *Bar) Frame(id neferclient.SurfaceID) {
855+ if b.pop.Is(id) {
856+ b.pop.Frame()
857+ return
858+ }
824859 if id == b.sid {
825860 b.canPresent = true
826861 }
827862 }
828863
829-// PopupDone is called when the compositor dismisses a popup of the bar.
830-func (b *Bar) PopupDone(neferclient.SurfaceID) {}
864+// PopupDone is called when the compositor dismisses a popup of the bar: an
865+// outside click, a denied grab. A menu tells its script.
866+func (b *Bar) PopupDone(id neferclient.SurfaceID) {
867+ if b.pop.Is(id) {
868+ b.pop.PopupDone()
869+ }
870+}
871+
872+// Key goes to the popup that has the keyboard.
873+func (b *Bar) Key(ev *neferclient.KeyEvent) {
874+ if b.pop.Is(ev.Surface) {
875+ b.pop.Key(ev)
876+ }
877+}
878+
879+// KeyboardFocus tells a popup whether it has the keyboard.
880+func (b *Bar) KeyboardFocus(id neferclient.SurfaceID, focused bool) {
881+ if b.pop.Is(id) {
882+ b.pop.KeyboardFocus(focused)
883+ }
884+}
831885
832886 // Closed ends the loop when the compositor closes the surface.
833887 func (b *Bar) Closed(id neferclient.SurfaceID) {
888+ if b.pop.Is(id) {
889+ b.closePopup()
890+ return
891+ }
834892 if id == b.sid {
835893 b.closed = true
836894 }
@@ -838,6 +896,10 @@ func (b *Bar) Closed(id neferclient.SurfaceID) {
838896
839897 // FDReady hands a slot back once the compositor released it.
840898 func (b *Bar) FDReady(id uint64) {
899+ if id >= popup.FDBase {
900+ b.pop.FDReady(id) // the release descriptor of a popup buffer
901+ return
902+ }
841903 if b.rend == nil {
842904 return
843905 }
@@ -862,6 +924,10 @@ func (b *Bar) syncInputRegion() {
862924
863925 // Pointer turns pointer events over the bar into lines for interactive modules.
864926 func (b *Bar) Pointer(ev *neferclient.PointerEvent) {
927+ if b.pop.Is(ev.Surface) {
928+ b.pop.Pointer(ev)
929+ return
930+ }
865931 if ev.Surface != b.sid || b.lay == nil || b.face == nil {
866932 return
867933 }
@@ -874,12 +940,18 @@ func (b *Bar) handlePointer(ev *neferclient.PointerEvent, scale float64, cellW i
874940 col := cellColumn(ev.X, scale, cellW)
875941 switch ev.Kind {
876942 case neferclient.PointerEnter, neferclient.PointerMotion:
943+ before := b.ptr.hover
877944 b.ptr.motion(b.lay, col)
945+ if b.ptr.hover != before {
946+ b.closeTooltip() // the pointer moved to another module
947+ }
878948 case neferclient.PointerLeave:
879949 b.ptr.leave()
950+ b.closeTooltip()
880951 case neferclient.PointerButton:
881952 if ev.Pressed {
882- b.ptr.button(b.lay, col, ev.Button, 0) // neferclient v0.4.0 adds the serial
953+ b.closePopup() // a press on the bar ends a tooltip or a menu
954+ b.ptr.button(b.lay, col, ev.Button, ev.Serial)
883955 }
884956 case neferclient.PointerAxis:
885957 delta := ev.DY
@@ -896,10 +968,8 @@ func (b *Bar) handlePointer(ev *neferclient.PointerEvent, scale float64, cellW i
896968 func (b *Bar) Error(err error) { b.log.Warn("wayland", "err", err) }
897969
898970 // Unused events.
899-func (b *Bar) OutputAdded(*neferclient.Output) {}
900-func (b *Bar) OutputRemoved(uint32) {}
901-func (b *Bar) Locked() {}
902-func (b *Bar) LockFinished() {}
903-func (b *Bar) Key(*neferclient.KeyEvent) {}
904-func (b *Bar) KeyboardFocus(neferclient.SurfaceID, bool) {}
905-func (b *Bar) SecretChanged(int) {}
971+func (b *Bar) OutputAdded(*neferclient.Output) {}
972+func (b *Bar) OutputRemoved(uint32) {}
973+func (b *Bar) Locked() {}
974+func (b *Bar) LockFinished() {}
975+func (b *Bar) SecretChanged(int) {}
@@ -28,6 +28,7 @@ import (
28 "github.com/bnema/neferbar/internal/gpu"28 "github.com/bnema/neferbar/internal/gpu"
29 "github.com/bnema/neferbar/internal/layout"29 "github.com/bnema/neferbar/internal/layout"
30 "github.com/bnema/neferbar/internal/module"30 "github.com/bnema/neferbar/internal/module"
31+ "github.com/bnema/neferbar/internal/popup"
31 "github.com/bnema/neferbar/internal/syncobj"32 "github.com/bnema/neferbar/internal/syncobj"
32 "github.com/bnema/neferbar/internal/theme"33 "github.com/bnema/neferbar/internal/theme"
33 )34 )
@@ -119,6 +120,13 @@ type Bar struct {
119 ptr pointerState // pointer events turned into module lines120 ptr pointerState // pointer events turned into module lines
120 input inputRegion121 input inputRegion
121 122
123+ pop *popup.Host // the tooltip or menu being shown, if any
124+ popC <-chan time.Time
125+ popCSS string // the stylesheet last written for the popups
126+ warmed bool
127+ spanBuf []layout.Span
128+ badCtl map[*module.Module]bool // modules whose invalid control line was logged
129+
122 Stats Stats130 Stats Stats
123 fatal error131 fatal error
124 closed bool132 closed bool
@@ -186,6 +194,7 @@ func (b *Bar) setTheme(th theme.Theme) {
186 }194 }
187 b.dirty = true195 b.dirty = true
188 b.watchTheme()196 b.watchTheme()
197+ b.updatePopupStyle()
189 }198 }
190 199
191 // watchTheme watches every file the theme was read from, so changing the200 // watchTheme watches every file the theme was read from, so changing the
@@ -323,6 +332,9 @@ func (b *Bar) syncModules(want []config.Module) {
323 r.cancel()332 r.cancel()
324 }333 }
325 b.runners = next334 b.runners = next
335+ if o := b.pop.Owner(); o != nil && !slices.ContainsFunc(next, func(r runner) bool { return r.m == o }) {
336+ b.closePopup() // its module was stopped or replaced
337+ }
326 clear(b.stale)338 clear(b.stale)
327 b.watchScripts()339 b.watchScripts()
328 b.mods = b.mods[:0]340 b.mods = b.mods[:0]
@@ -353,6 +365,7 @@ func (b *Bar) applyConfig(next config.Config) {
353 b.log.Warn("bar.output changed: restart the bar to move it", "from", old.Bar.Output, "to", next.Bar.Output)365 b.log.Warn("bar.output changed: restart the bar to move it", "from", old.Bar.Output, "to", next.Bar.Output)
354 next.Bar.Output = old.Bar.Output366 next.Bar.Output = old.Bar.Output
355 }367 }
368+ b.closePopup() // it was built from the old settings
356 b.cfg = next369 b.cfg = next
357 b.fonts = fonts370 b.fonts = fonts
358 if err = b.loadTheme(); err != nil { // the theme setting or the colors may have changed371 if err = b.loadTheme(); err != nil { // the theme setting or the colors may have changed
@@ -365,6 +378,7 @@ func (b *Bar) applyConfig(next config.Config) {
365 b.log.Info("moving the bar", "to", next.Bar.Position)378 b.log.Info("moving the bar", "to", next.Bar.Position)
366 b.dropRenderer()379 b.dropRenderer()
367 _, h, _ := b.surf.Size()380 _, h, _ := b.surf.Size()
381+ b.closePopup()
368 if err = b.surf.Close(); err != nil {382 if err = b.surf.Close(); err != nil {
369 b.fail(err)383 b.fail(err)
370 return384 return
@@ -389,6 +403,8 @@ func (b *Bar) Run(ctx context.Context) (err error) {
389 if err != nil {403 if err != nil {
390 return connectError(b.display, err)404 return connectError(b.display, err)
391 }405 }
406+ b.pop = popup.NewHost(b.conn, b.log)
407+ b.updatePopupStyle()
392 defer func() {408 defer func() {
393 err = errors.Join(err, b.shutdown())409 err = errors.Join(err, b.shutdown())
394 b.log.Info("stats", "presented", b.Stats.Presented, "rebuilds", b.Stats.Rebuilds,410 b.log.Info("stats", "presented", b.Stats.Presented, "rebuilds", b.Stats.Rebuilds,
@@ -417,6 +433,8 @@ func (b *Bar) Run(ctx context.Context) (err error) {
417 if err = b.conn.Dispatch(b); err != nil {433 if err = b.conn.Dispatch(b); err != nil {
418 return fmt.Errorf("dispatch: %w", err)434 return fmt.Errorf("dispatch: %w", err)
419 }435 }
436+ case <-b.pop.Wake(): // the popup renderer wants a frame
437+ case <-b.popC: // a popup frame was waiting for the GPU
420 case <-b.modCh:438 case <-b.modCh:
421 b.Stats.ModuleWake++439 b.Stats.ModuleWake++
422 case next := <-b.cfgCh:440 case next := <-b.cfgCh:
@@ -435,6 +453,7 @@ func (b *Bar) Run(ctx context.Context) (err error) {
435 if err = b.step(); err != nil {453 if err = b.step(); err != nil {
436 return err454 return err
437 }455 }
456+ b.stepPopup()
438 }457 }
439 return nil458 return nil
440 }459 }
@@ -637,6 +656,7 @@ func (b *Bar) reconcile() {
637 b.askedH, b.askedScale = wantH, scale656 b.askedH, b.askedScale = wantH, scale
638 b.log.Info("recreating layer surface", "height", wantH, "scale", scale)657 b.log.Info("recreating layer surface", "height", wantH, "scale", scale)
639 b.dropRenderer()658 b.dropRenderer()
659+ b.closePopup()
640 if err := b.surf.Close(); err != nil {660 if err := b.surf.Close(); err != nil {
641 b.fail(err)661 b.fail(err)
642 return662 return
@@ -664,6 +684,7 @@ func (b *Bar) reconcile() {
664 // off. A new surface is mapped fresh and placed from its real size.684 // off. A new surface is mapped fresh and placed from its real size.
665 b.log.Info("recreating layer surface: the output width changed", "from", b.mappedW, "to", w)685 b.log.Info("recreating layer surface: the output width changed", "from", b.mappedW, "to", w)
666 b.dropRenderer()686 b.dropRenderer()
687+ b.closePopup()
667 if err := b.surf.Close(); err != nil {688 if err := b.surf.Close(); err != nil {
668 b.fail(err)689 b.fail(err)
669 return690 return
@@ -751,6 +772,8 @@ func (b *Bar) shutdown() error {
751 for _, r := range b.runners {772 for _, r := range b.runners {
752 r.m.Wait(3 * time.Second)773 r.m.Wait(3 * time.Second)
753 }774 }
775+ b.closePopup()
776+ b.pop.Wait() // the warm-up renderer must be gone before the GPU state is
754 b.dropRenderer()777 b.dropRenderer()
755 b.face.Close()778 b.face.Close()
756 if b.node != nil {779 if b.node != nil {
@@ -783,6 +806,10 @@ func (b *Bar) geometryChanged() {
783 806
784 // Configure marks the surface presentable and rebuilds as needed.807 // Configure marks the surface presentable and rebuilds as needed.
785 func (b *Bar) Configure(id neferclient.SurfaceID, _, _ int32) {808 func (b *Bar) Configure(id neferclient.SurfaceID, _, _ int32) {
809+ if b.pop.Is(id) {
810+ b.pop.Configure()
811+ return
812+ }
786 if id != b.sid {813 if id != b.sid {
787 return814 return
788 }815 }
@@ -795,6 +822,10 @@ func (b *Bar) Configure(id neferclient.SurfaceID, _, _ int32) {
795 822
796 // Scale follows the monitor's preferred scale.823 // Scale follows the monitor's preferred scale.
797 func (b *Bar) Scale(id neferclient.SurfaceID, _ float64) {824 func (b *Bar) Scale(id neferclient.SurfaceID, _ float64) {
825+ if b.pop.Is(id) {
826+ b.pop.Scale()
827+ return
828+ }
798 if id == b.sid {829 if id == b.sid {
799 b.geometryChanged()830 b.geometryChanged()
800 }831 }
@@ -821,16 +852,43 @@ func (b *Bar) FeedbackDone(id neferclient.SurfaceID) {
821 852
822 // Frame allows the next Present.853 // Frame allows the next Present.
823 func (b *Bar) Frame(id neferclient.SurfaceID) {854 func (b *Bar) Frame(id neferclient.SurfaceID) {
855+ if b.pop.Is(id) {
856+ b.pop.Frame()
857+ return
858+ }
824 if id == b.sid {859 if id == b.sid {
825 b.canPresent = true860 b.canPresent = true
826 }861 }
827 }862 }
828 863
829-// PopupDone is called when the compositor dismisses a popup of the bar.864+// PopupDone is called when the compositor dismisses a popup of the bar: an
830-func (b *Bar) PopupDone(neferclient.SurfaceID) {}865+// outside click, a denied grab. A menu tells its script.
866+func (b *Bar) PopupDone(id neferclient.SurfaceID) {
867+ if b.pop.Is(id) {
868+ b.pop.PopupDone()
869+ }
870+}
871+
872+// Key goes to the popup that has the keyboard.
873+func (b *Bar) Key(ev *neferclient.KeyEvent) {
874+ if b.pop.Is(ev.Surface) {
875+ b.pop.Key(ev)
876+ }
877+}
878+
879+// KeyboardFocus tells a popup whether it has the keyboard.
880+func (b *Bar) KeyboardFocus(id neferclient.SurfaceID, focused bool) {
881+ if b.pop.Is(id) {
882+ b.pop.KeyboardFocus(focused)
883+ }
884+}
831 885
832 // Closed ends the loop when the compositor closes the surface.886 // Closed ends the loop when the compositor closes the surface.
833 func (b *Bar) Closed(id neferclient.SurfaceID) {887 func (b *Bar) Closed(id neferclient.SurfaceID) {
888+ if b.pop.Is(id) {
889+ b.closePopup()
890+ return
891+ }
834 if id == b.sid {892 if id == b.sid {
835 b.closed = true893 b.closed = true
836 }894 }
@@ -838,6 +896,10 @@ func (b *Bar) Closed(id neferclient.SurfaceID) {
838 896
839 // FDReady hands a slot back once the compositor released it.897 // FDReady hands a slot back once the compositor released it.
840 func (b *Bar) FDReady(id uint64) {898 func (b *Bar) FDReady(id uint64) {
899+ if id >= popup.FDBase {
900+ b.pop.FDReady(id) // the release descriptor of a popup buffer
901+ return
902+ }
841 if b.rend == nil {903 if b.rend == nil {
842 return904 return
843 }905 }
@@ -862,6 +924,10 @@ func (b *Bar) syncInputRegion() {
862 924
863 // Pointer turns pointer events over the bar into lines for interactive modules.925 // Pointer turns pointer events over the bar into lines for interactive modules.
864 func (b *Bar) Pointer(ev *neferclient.PointerEvent) {926 func (b *Bar) Pointer(ev *neferclient.PointerEvent) {
927+ if b.pop.Is(ev.Surface) {
928+ b.pop.Pointer(ev)
929+ return
930+ }
865 if ev.Surface != b.sid || b.lay == nil || b.face == nil {931 if ev.Surface != b.sid || b.lay == nil || b.face == nil {
866 return932 return
867 }933 }
@@ -874,12 +940,18 @@ func (b *Bar) handlePointer(ev *neferclient.PointerEvent, scale float64, cellW i
874 col := cellColumn(ev.X, scale, cellW)940 col := cellColumn(ev.X, scale, cellW)
875 switch ev.Kind {941 switch ev.Kind {
876 case neferclient.PointerEnter, neferclient.PointerMotion:942 case neferclient.PointerEnter, neferclient.PointerMotion:
943+ before := b.ptr.hover
877 b.ptr.motion(b.lay, col)944 b.ptr.motion(b.lay, col)
945+ if b.ptr.hover != before {
946+ b.closeTooltip() // the pointer moved to another module
947+ }
878 case neferclient.PointerLeave:948 case neferclient.PointerLeave:
879 b.ptr.leave()949 b.ptr.leave()
950+ b.closeTooltip()
880 case neferclient.PointerButton:951 case neferclient.PointerButton:
881 if ev.Pressed {952 if ev.Pressed {
882- b.ptr.button(b.lay, col, ev.Button, 0) // neferclient v0.4.0 adds the serial953+ b.closePopup() // a press on the bar ends a tooltip or a menu
954+ b.ptr.button(b.lay, col, ev.Button, ev.Serial)
883 }955 }
884 case neferclient.PointerAxis:956 case neferclient.PointerAxis:
885 delta := ev.DY957 delta := ev.DY
@@ -896,10 +968,8 @@ func (b *Bar) handlePointer(ev *neferclient.PointerEvent, scale float64, cellW i
896 func (b *Bar) Error(err error) { b.log.Warn("wayland", "err", err) }968 func (b *Bar) Error(err error) { b.log.Warn("wayland", "err", err) }
897 969
898 // Unused events.970 // Unused events.
899-func (b *Bar) OutputAdded(*neferclient.Output) {}971+func (b *Bar) OutputAdded(*neferclient.Output) {}
900-func (b *Bar) OutputRemoved(uint32) {}972+func (b *Bar) OutputRemoved(uint32) {}
901-func (b *Bar) Locked() {}973+func (b *Bar) Locked() {}
902-func (b *Bar) LockFinished() {}974+func (b *Bar) LockFinished() {}
903-func (b *Bar) Key(*neferclient.KeyEvent) {}975+func (b *Bar) SecretChanged(int) {}
904-func (b *Bar) KeyboardFocus(neferclient.SurfaceID, bool) {}
905-func (b *Bar) SecretChanged(int) {}
M internal/bar/input.go +24 -7
@@ -10,6 +10,7 @@ import (
1010
1111 "github.com/bnema/neferbar/internal/layout"
1212 "github.com/bnema/neferbar/internal/module"
13+ "github.com/bnema/neferbar/internal/popup"
1314 )
1415
1516 // Evdev button codes.
@@ -24,9 +25,28 @@ const pxPerStep = 15
2425
2526 // press is the latest button press sent to a module.
2627 type press struct {
27- token uint32 // the number the module echoes to refer to this press
28- serial uint32 // the pointer serial of the press, for popup grabs
28+ m *module.Module // the module the press was sent to
29+ token uint32 // the number the module echoes to refer to this press
30+ serial uint32 // the pointer serial of the press, for popup grabs
2931 at time.Time
32+ used bool // a menu already answered this press
33+}
34+
35+// menuPressTTL is how long after a press a module may still answer it with a
36+// menu.
37+const menuPressTTL = 5 * time.Second
38+
39+// takeMenuPress accepts a menu that module m opens in answer to the press
40+// numbered token: it must be the latest press, sent to m, not older than
41+// menuPressTTL, and not answered yet. The serial of the press is what the
42+// popup grabs with. A press answers one menu.
43+func (p *pointerState) takeMenuPress(m *module.Module, token uint32, now time.Time) (serial uint32, ok bool) {
44+ pr := &p.press
45+ if m == nil || pr.m != m || token == 0 || pr.token != token || pr.used || pr.serial == 0 || now.Sub(pr.at) > menuPressTTL {
46+ return 0, false
47+ }
48+ pr.used = true
49+ return pr.serial, true
3050 }
3151
3252 // pointerState turns pointer events over the bar into the lines of the module
@@ -107,7 +127,7 @@ func (p *pointerState) button(lay *layout.Layout, col int, code uint32, serial u
107127 return
108128 }
109129 p.token++
110- p.press = press{token: p.token, serial: serial, at: time.Now()}
130+ p.press = press{m: m, token: p.token, serial: serial, at: time.Now()}
111131 b := append(p.line("click "), name...)
112132 b = appendInt(b, off)
113133 b = append(b, ' ')
@@ -204,10 +224,7 @@ func (a *wheelAcc) add(axis uint32, value120 int32, delta float64) int32 {
204224 // inputRect is the logical-pixel rectangle of a span of cells: rounded
205225 // outward, full surface height.
206226 func inputRect(start, width, cellW int, scale float64, height int32) neferclient.Rect {
207- const eps = 1e-9
208- x0 := int32(math.Floor(float64(start)*float64(cellW)/scale + eps))
209- x1 := int32(math.Ceil(float64(start+width)*float64(cellW)/scale - eps))
210- return neferclient.Rect{X: x0, Y: 0, Width: x1 - x0, Height: height}
227+ return popup.CellRect(start, width, cellW, scale, height)
211228 }
212229
213230 // inputKey is everything but the spans that the input rectangles depend on.
@@ -10,6 +10,7 @@ import (
10 10
11 "github.com/bnema/neferbar/internal/layout"11 "github.com/bnema/neferbar/internal/layout"
12 "github.com/bnema/neferbar/internal/module"12 "github.com/bnema/neferbar/internal/module"
13+ "github.com/bnema/neferbar/internal/popup"
13 )14 )
14 15
15 // Evdev button codes.16 // Evdev button codes.
@@ -24,9 +25,28 @@ const pxPerStep = 15
24 25
25 // press is the latest button press sent to a module.26 // press is the latest button press sent to a module.
26 type press struct {27 type press struct {
27- token uint32 // the number the module echoes to refer to this press28+ m *module.Module // the module the press was sent to
28- serial uint32 // the pointer serial of the press, for popup grabs29+ token uint32 // the number the module echoes to refer to this press
30+ serial uint32 // the pointer serial of the press, for popup grabs
29 at time.Time31 at time.Time
32+ used bool // a menu already answered this press
33+}
34+
35+// menuPressTTL is how long after a press a module may still answer it with a
36+// menu.
37+const menuPressTTL = 5 * time.Second
38+
39+// takeMenuPress accepts a menu that module m opens in answer to the press
40+// numbered token: it must be the latest press, sent to m, not older than
41+// menuPressTTL, and not answered yet. The serial of the press is what the
42+// popup grabs with. A press answers one menu.
43+func (p *pointerState) takeMenuPress(m *module.Module, token uint32, now time.Time) (serial uint32, ok bool) {
44+ pr := &p.press
45+ if m == nil || pr.m != m || token == 0 || pr.token != token || pr.used || pr.serial == 0 || now.Sub(pr.at) > menuPressTTL {
46+ return 0, false
47+ }
48+ pr.used = true
49+ return pr.serial, true
30 }50 }
31 51
32 // pointerState turns pointer events over the bar into the lines of the module52 // pointerState turns pointer events over the bar into the lines of the module
@@ -107,7 +127,7 @@ func (p *pointerState) button(lay *layout.Layout, col int, code uint32, serial u
107 return127 return
108 }128 }
109 p.token++129 p.token++
110- p.press = press{token: p.token, serial: serial, at: time.Now()}130+ p.press = press{m: m, token: p.token, serial: serial, at: time.Now()}
111 b := append(p.line("click "), name...)131 b := append(p.line("click "), name...)
112 b = appendInt(b, off)132 b = appendInt(b, off)
113 b = append(b, ' ')133 b = append(b, ' ')
@@ -204,10 +224,7 @@ func (a *wheelAcc) add(axis uint32, value120 int32, delta float64) int32 {
204 // inputRect is the logical-pixel rectangle of a span of cells: rounded224 // inputRect is the logical-pixel rectangle of a span of cells: rounded
205 // outward, full surface height.225 // outward, full surface height.
206 func inputRect(start, width, cellW int, scale float64, height int32) neferclient.Rect {226 func inputRect(start, width, cellW int, scale float64, height int32) neferclient.Rect {
207- const eps = 1e-9227+ return popup.CellRect(start, width, cellW, scale, height)
208- x0 := int32(math.Floor(float64(start)*float64(cellW)/scale + eps))
209- x1 := int32(math.Ceil(float64(start+width)*float64(cellW)/scale - eps))
210- return neferclient.Rect{X: x0, Y: 0, Width: x1 - x0, Height: height}
211 }228 }
212 229
213 // inputKey is everything but the spans that the input rectangles depend on.230 // inputKey is everything but the spans that the input rectangles depend on.
A internal/bar/popup.go +155 -0
@@ -0,0 +1,155 @@
1+package bar
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/neferbar/internal/layout"
7+ "github.com/bnema/neferbar/internal/module"
8+ "github.com/bnema/neferbar/internal/popup"
9+)
10+
11+// popupPoll is how long the loop waits before it asks again for a popup frame
12+// that is waiting for the GPU.
13+const popupPoll = 2 * time.Millisecond
14+
15+// updatePopupStyle writes the stylesheet of the popups when the bar's colors,
16+// font or size changed. Without a stylesheet no popup opens.
17+func (b *Bar) updatePopupStyle() {
18+ if b.pop == nil {
19+ return
20+ }
21+ css := popup.CSS(popup.Style{
22+ Font: b.cfg.Bar.Font, Size: b.cfg.Bar.Size * b.cfg.Bar.Scale,
23+ FG: b.fg, BG: b.bg, Accent: b.pal[b.cfg.Bar.Accent&15],
24+ })
25+ if css == b.popCSS {
26+ return
27+ }
28+ path, err := popup.WriteCSS(css)
29+ if err != nil {
30+ b.log.Warn("popups are off: cannot write their stylesheet", "err", err)
31+ b.pop.SetStyles("")
32+ b.popCSS = ""
33+ return
34+ }
35+ b.popCSS = css
36+ b.pop.SetStyles(path)
37+}
38+
39+// closePopup dismisses the tooltip or menu being shown.
40+func (b *Bar) closePopup() { b.pop.Close() }
41+
42+// closeTooltip dismisses the popup when it is a tooltip; a menu stays.
43+func (b *Bar) closeTooltip() {
44+ if b.pop.Kind() == popup.KindTooltip {
45+ b.pop.Close()
46+ }
47+}
48+
49+// stepPopup runs after every wake-up of the loop: it starts the GPU warm-up,
50+// carries out what the modules asked for, and draws the popup.
51+func (b *Bar) stepPopup() {
52+ if b.pop == nil {
53+ return
54+ }
55+ b.popC = nil
56+ if !b.warmed && b.fb != nil && b.popCSS != "" && b.anyInteractive() {
57+ b.warmed = true
58+ b.pop.Warm(b.fb)
59+ }
60+ for _, m := range b.mods {
61+ for {
62+ raw, ok := m.TakeControl()
63+ if !ok {
64+ break
65+ }
66+ b.control(m, raw)
67+ }
68+ }
69+ if b.pop.OwnerRestarted() {
70+ b.closePopup()
71+ }
72+ if !b.pop.Active() {
73+ return
74+ }
75+ b.pop.Draw()
76+ if b.pop.Pending() {
77+ b.popC = time.After(popupPoll)
78+ }
79+}
80+
81+func (b *Bar) anyInteractive() bool {
82+ for _, m := range b.mods {
83+ if m.Interactive {
84+ return true
85+ }
86+ }
87+ return false
88+}
89+
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)
100+ }
101+ return
102+ }
103+ if !m.Interactive {
104+ return // only modules that asked for the pointer may open popups
105+ }
106+ switch ctrl.Type {
107+ case module.ControlClose:
108+ if b.pop.Kind() == popup.KindTooltip && b.pop.Owner() == m {
109+ b.pop.Close()
110+ }
111+ case module.ControlTooltip:
112+ // Only for the module under the pointer, and never over a menu.
113+ if m != b.ptr.hover || b.pop.Kind() == popup.KindMenu {
114+ return
115+ }
116+ b.openPopup(m, popup.KindTooltip, ctrl, 0)
117+ case module.ControlMenu:
118+ serial, ok := b.ptr.takeMenuPress(m, ctrl.Click, time.Now())
119+ if !ok {
120+ b.log.Debug("menu dropped: it does not answer the latest press", "module", m.Name, "click", ctrl.Click)
121+ return
122+ }
123+ b.openPopup(m, popup.KindMenu, ctrl, serial)
124+ }
125+}
126+
127+// openPopup shows ctrl as a popup of module m, anchored to the cells it names.
128+func (b *Bar) openPopup(m *module.Module, kind popup.Kind, ctrl module.Control, serial uint32) {
129+ if !b.configured || b.fb == nil || b.face == nil || b.lay == nil {
130+ return
131+ }
132+ b.spanBuf = b.lay.Spans(b.spanBuf[:0])
133+ var span layout.Span
134+ for _, s := range b.spanBuf {
135+ if s.M == m {
136+ span = s
137+ }
138+ }
139+ if span.M == nil {
140+ return // the module shows nothing now
141+ }
142+ _, h, scale := b.surf.Size()
143+ anchor, ok := popup.Anchor(span.Start, span.Width, ctrl.Col, ctrl.Width, b.face.CellW, scale, h)
144+ if !ok {
145+ b.log.Debug("popup dropped: its column is outside the module", "module", m.Name, "col", ctrl.Col)
146+ return
147+ }
148+ err := b.pop.Open(popup.Request{
149+ Kind: kind, Owner: m, Ctrl: ctrl, Anchor: anchor, Parent: b.surf,
150+ BottomBar: b.cfg.Bar.Position == "bottom", Feedback: b.fb, Serial: serial,
151+ })
152+ if err != nil {
153+ b.log.Warn("cannot open a popup", "module", m.Name, "err", err)
154+ }
155+}
@@ -0,0 +1,155 @@
1+package bar
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/neferbar/internal/layout"
7+ "github.com/bnema/neferbar/internal/module"
8+ "github.com/bnema/neferbar/internal/popup"
9+)
10+
11+// popupPoll is how long the loop waits before it asks again for a popup frame
12+// that is waiting for the GPU.
13+const popupPoll = 2 * time.Millisecond
14+
15+// updatePopupStyle writes the stylesheet of the popups when the bar's colors,
16+// font or size changed. Without a stylesheet no popup opens.
17+func (b *Bar) updatePopupStyle() {
18+ if b.pop == nil {
19+ return
20+ }
21+ css := popup.CSS(popup.Style{
22+ Font: b.cfg.Bar.Font, Size: b.cfg.Bar.Size * b.cfg.Bar.Scale,
23+ FG: b.fg, BG: b.bg, Accent: b.pal[b.cfg.Bar.Accent&15],
24+ })
25+ if css == b.popCSS {
26+ return
27+ }
28+ path, err := popup.WriteCSS(css)
29+ if err != nil {
30+ b.log.Warn("popups are off: cannot write their stylesheet", "err", err)
31+ b.pop.SetStyles("")
32+ b.popCSS = ""
33+ return
34+ }
35+ b.popCSS = css
36+ b.pop.SetStyles(path)
37+}
38+
39+// closePopup dismisses the tooltip or menu being shown.
40+func (b *Bar) closePopup() { b.pop.Close() }
41+
42+// closeTooltip dismisses the popup when it is a tooltip; a menu stays.
43+func (b *Bar) closeTooltip() {
44+ if b.pop.Kind() == popup.KindTooltip {
45+ b.pop.Close()
46+ }
47+}
48+
49+// stepPopup runs after every wake-up of the loop: it starts the GPU warm-up,
50+// carries out what the modules asked for, and draws the popup.
51+func (b *Bar) stepPopup() {
52+ if b.pop == nil {
53+ return
54+ }
55+ b.popC = nil
56+ if !b.warmed && b.fb != nil && b.popCSS != "" && b.anyInteractive() {
57+ b.warmed = true
58+ b.pop.Warm(b.fb)
59+ }
60+ for _, m := range b.mods {
61+ for {
62+ raw, ok := m.TakeControl()
63+ if !ok {
64+ break
65+ }
66+ b.control(m, raw)
67+ }
68+ }
69+ if b.pop.OwnerRestarted() {
70+ b.closePopup()
71+ }
72+ if !b.pop.Active() {
73+ return
74+ }
75+ b.pop.Draw()
76+ if b.pop.Pending() {
77+ b.popC = time.After(popupPoll)
78+ }
79+}
80+
81+func (b *Bar) anyInteractive() bool {
82+ for _, m := range b.mods {
83+ if m.Interactive {
84+ return true
85+ }
86+ }
87+ return false
88+}
89+
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)
100+ }
101+ return
102+ }
103+ if !m.Interactive {
104+ return // only modules that asked for the pointer may open popups
105+ }
106+ switch ctrl.Type {
107+ case module.ControlClose:
108+ if b.pop.Kind() == popup.KindTooltip && b.pop.Owner() == m {
109+ b.pop.Close()
110+ }
111+ case module.ControlTooltip:
112+ // Only for the module under the pointer, and never over a menu.
113+ if m != b.ptr.hover || b.pop.Kind() == popup.KindMenu {
114+ return
115+ }
116+ b.openPopup(m, popup.KindTooltip, ctrl, 0)
117+ case module.ControlMenu:
118+ serial, ok := b.ptr.takeMenuPress(m, ctrl.Click, time.Now())
119+ if !ok {
120+ b.log.Debug("menu dropped: it does not answer the latest press", "module", m.Name, "click", ctrl.Click)
121+ return
122+ }
123+ b.openPopup(m, popup.KindMenu, ctrl, serial)
124+ }
125+}
126+
127+// openPopup shows ctrl as a popup of module m, anchored to the cells it names.
128+func (b *Bar) openPopup(m *module.Module, kind popup.Kind, ctrl module.Control, serial uint32) {
129+ if !b.configured || b.fb == nil || b.face == nil || b.lay == nil {
130+ return
131+ }
132+ b.spanBuf = b.lay.Spans(b.spanBuf[:0])
133+ var span layout.Span
134+ for _, s := range b.spanBuf {
135+ if s.M == m {
136+ span = s
137+ }
138+ }
139+ if span.M == nil {
140+ return // the module shows nothing now
141+ }
142+ _, h, scale := b.surf.Size()
143+ anchor, ok := popup.Anchor(span.Start, span.Width, ctrl.Col, ctrl.Width, b.face.CellW, scale, h)
144+ if !ok {
145+ b.log.Debug("popup dropped: its column is outside the module", "module", m.Name, "col", ctrl.Col)
146+ return
147+ }
148+ err := b.pop.Open(popup.Request{
149+ Kind: kind, Owner: m, Ctrl: ctrl, Anchor: anchor, Parent: b.surf,
150+ BottomBar: b.cfg.Bar.Position == "bottom", Feedback: b.fb, Serial: serial,
151+ })
152+ if err != nil {
153+ b.log.Warn("cannot open a popup", "module", m.Name, "err", err)
154+ }
155+}
A internal/bar/popup_test.go +85 -0
@@ -0,0 +1,85 @@
1+package bar
2+
3+import (
4+ "bytes"
5+ "log/slog"
6+ "testing"
7+ "time"
8+)
9+
10+func TestMenuAcceptsOnlyTheLatestFreshPressOfTheSameModule(t *testing.T) {
11+ r := newInputRig(t)
12+ var p pointerState
13+ p.button(r.lay, 1, btnRight, 77) // token 1, serial 77, module a
14+ now := p.press.at
15+
16+ if _, ok := p.takeMenuPress(r.a, 2, now); ok {
17+ t.Error("a token that is not the latest press was accepted")
18+ }
19+ if _, ok := p.takeMenuPress(r.a, 0, now); ok {
20+ t.Error("token 0 was accepted")
21+ }
22+ if _, ok := p.takeMenuPress(r.b, 1, now); ok {
23+ t.Error("another module answered the press")
24+ }
25+ if _, ok := p.takeMenuPress(nil, 1, now); ok {
26+ t.Error("no module answered the press")
27+ }
28+ if _, ok := p.takeMenuPress(r.a, 1, now.Add(menuPressTTL+time.Millisecond)); ok {
29+ t.Error("a press older than the limit was accepted")
30+ }
31+ serial, ok := p.takeMenuPress(r.a, 1, now.Add(menuPressTTL))
32+ if !ok || serial != 77 {
33+ t.Fatalf("takeMenuPress = %d, %v; want 77, true", serial, ok)
34+ }
35+ if _, ok = p.takeMenuPress(r.a, 1, now); ok {
36+ t.Error("one press opened two menus")
37+ }
38+
39+ // A newer press supersedes the old token.
40+ p.button(r.lay, 1, btnRight, 78)
41+ now = p.press.at
42+ if _, ok = p.takeMenuPress(r.a, 1, now); ok {
43+ t.Error("the superseded token was accepted")
44+ }
45+ if serial, ok = p.takeMenuPress(r.a, 2, now); !ok || serial != 78 {
46+ t.Errorf("latest press: %d, %v", serial, ok)
47+ }
48+
49+ // A press without a serial cannot grab.
50+ p.button(r.lay, 1, btnRight, 0)
51+ if _, ok = p.takeMenuPress(r.a, 3, p.press.at); ok {
52+ t.Error("a press without a serial was accepted")
53+ }
54+}
55+
56+func TestControlLinesAreCheckedBeforeAnythingOpens(t *testing.T) {
57+ r := newInputRig(t)
58+ var logs bytes.Buffer
59+ b := &Bar{lay: r.lay, log: slog.New(slog.NewTextHandler(&logs, nil))}
60+
61+ // 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`))
64+ 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())
66+ }
67+
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"}]}`)
70+ // 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 state
72+ // the rules read.
73+ b.control(r.b, tooltip) // not interactive
74+ b.control(r.a, tooltip) // not hovered
75+ b.control(r.a, menu) // no press at all
76+ b.ptr.button(r.lay, 1, btnRight, 9)
77+ b.control(r.a, menu) // wrong token
78+ if b.ptr.press.used {
79+ t.Fatal("a refused menu consumed the press")
80+ }
81+ b.control(r.a, []byte(`{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"x"}]}`))
82+ if !b.ptr.press.used {
83+ t.Fatal("the right token should have been accepted")
84+ }
85+}
@@ -0,0 +1,85 @@
1+package bar
2+
3+import (
4+ "bytes"
5+ "log/slog"
6+ "testing"
7+ "time"
8+)
9+
10+func TestMenuAcceptsOnlyTheLatestFreshPressOfTheSameModule(t *testing.T) {
11+ r := newInputRig(t)
12+ var p pointerState
13+ p.button(r.lay, 1, btnRight, 77) // token 1, serial 77, module a
14+ now := p.press.at
15+
16+ if _, ok := p.takeMenuPress(r.a, 2, now); ok {
17+ t.Error("a token that is not the latest press was accepted")
18+ }
19+ if _, ok := p.takeMenuPress(r.a, 0, now); ok {
20+ t.Error("token 0 was accepted")
21+ }
22+ if _, ok := p.takeMenuPress(r.b, 1, now); ok {
23+ t.Error("another module answered the press")
24+ }
25+ if _, ok := p.takeMenuPress(nil, 1, now); ok {
26+ t.Error("no module answered the press")
27+ }
28+ if _, ok := p.takeMenuPress(r.a, 1, now.Add(menuPressTTL+time.Millisecond)); ok {
29+ t.Error("a press older than the limit was accepted")
30+ }
31+ serial, ok := p.takeMenuPress(r.a, 1, now.Add(menuPressTTL))
32+ if !ok || serial != 77 {
33+ t.Fatalf("takeMenuPress = %d, %v; want 77, true", serial, ok)
34+ }
35+ if _, ok = p.takeMenuPress(r.a, 1, now); ok {
36+ t.Error("one press opened two menus")
37+ }
38+
39+ // A newer press supersedes the old token.
40+ p.button(r.lay, 1, btnRight, 78)
41+ now = p.press.at
42+ if _, ok = p.takeMenuPress(r.a, 1, now); ok {
43+ t.Error("the superseded token was accepted")
44+ }
45+ if serial, ok = p.takeMenuPress(r.a, 2, now); !ok || serial != 78 {
46+ t.Errorf("latest press: %d, %v", serial, ok)
47+ }
48+
49+ // A press without a serial cannot grab.
50+ p.button(r.lay, 1, btnRight, 0)
51+ if _, ok = p.takeMenuPress(r.a, 3, p.press.at); ok {
52+ t.Error("a press without a serial was accepted")
53+ }
54+}
55+
56+func TestControlLinesAreCheckedBeforeAnythingOpens(t *testing.T) {
57+ r := newInputRig(t)
58+ var logs bytes.Buffer
59+ b := &Bar{lay: r.lay, log: slog.New(slog.NewTextHandler(&logs, nil))}
60+
61+ // 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`))
64+ 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())
66+ }
67+
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"}]}`)
70+ // 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 state
72+ // the rules read.
73+ b.control(r.b, tooltip) // not interactive
74+ b.control(r.a, tooltip) // not hovered
75+ b.control(r.a, menu) // no press at all
76+ b.ptr.button(r.lay, 1, btnRight, 9)
77+ b.control(r.a, menu) // wrong token
78+ if b.ptr.press.used {
79+ t.Fatal("a refused menu consumed the press")
80+ }
81+ b.control(r.a, []byte(`{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"x"}]}`))
82+ if !b.ptr.press.used {
83+ t.Fatal("the right token should have been accepted")
84+ }
85+}
A internal/popup/css.go +88 -0
@@ -0,0 +1,88 @@
1+package popup
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "strconv"
8+ "strings"
9+)
10+
11+// Inset is the space the popup stylesheet puts between the surface edge and
12+// the content: 4px of padding and a 1px border.
13+const Inset = 5
14+
15+// Style is what the popup stylesheet takes from the bar.
16+type Style struct {
17+ Font string
18+ Size float64 // font size in logical pixels
19+ FG, BG, Accent [3]uint8
20+}
21+
22+func hex(c [3]uint8) string { return fmt.Sprintf("#%02x%02x%02x", c[0], c[1], c[2]) }
23+
24+// cssString makes a font name safe inside a quoted CSS string.
25+func cssString(s string) string {
26+ return strings.Map(func(r rune) rune {
27+ switch {
28+ case r == '"' || r == '\\' || r == '{' || r == '}' || r == ';' || r < ' ' || r == 0x7f:
29+ return -1
30+ }
31+ return r
32+ }, s)
33+}
34+
35+// CSS is the stylesheet of every popup.
36+func CSS(s Style) string {
37+ fg, bg, ac := hex(s.FG), hex(s.BG), hex(s.Accent)
38+ size := strconv.FormatFloat(s.Size, 'f', -1, 64)
39+ var b strings.Builder
40+ fmt.Fprintf(&b, "app { background-color: %s; color: %s; font-family: %q; font-size: %spx; padding: 4px; border: 1px solid %s; }\n",
41+ bg, fg, cssString(s.Font), size, ac)
42+ b.WriteString(".title { font-weight: bold; }\n")
43+ b.WriteString(".body { opacity: 0.85; }\n")
44+ b.WriteString("button.item, checkbox.item { text-align: left; background-color: transparent; padding: 2px 12px; }\n")
45+ fmt.Fprintf(&b, "button.item:hover, checkbox.item:hover, button.item:focus-visible, checkbox.item:focus-visible { background-color: %s; color: %s; }\n", ac, bg)
46+ b.WriteString("button.item:disabled, checkbox.item:disabled { opacity: 0.5; }\n")
47+ fmt.Fprintf(&b, "button.item:disabled:hover, checkbox.item:disabled:hover { background-color: transparent; color: %s; }\n", fg)
48+ fmt.Fprintf(&b, "separator { background-color: %s; opacity: 0.3; margin: 4px 0; }\n", fg)
49+ return b.String()
50+}
51+
52+// WriteCSS stores css as popup.css in $XDG_RUNTIME_DIR/neferbar (directory
53+// 0700, file 0600, replaced atomically) and returns the path. nefergui reads
54+// its stylesheet from a path.
55+func WriteCSS(css string) (string, error) {
56+ rt := os.Getenv("XDG_RUNTIME_DIR")
57+ if rt == "" {
58+ return "", fmt.Errorf("popup: XDG_RUNTIME_DIR is not set")
59+ }
60+ dir := filepath.Join(rt, "neferbar")
61+ if err := os.MkdirAll(dir, 0o700); err != nil {
62+ return "", fmt.Errorf("popup: %w", err)
63+ }
64+ if err := os.Chmod(dir, 0o700); err != nil {
65+ return "", fmt.Errorf("popup: %w", err)
66+ }
67+ f, err := os.CreateTemp(dir, "popup-*.css")
68+ if err != nil {
69+ return "", fmt.Errorf("popup: %w", err)
70+ }
71+ tmp := f.Name()
72+ _, err = f.WriteString(css)
73+ if cerr := f.Close(); err == nil {
74+ err = cerr
75+ }
76+ if err == nil {
77+ err = os.Chmod(tmp, 0o600)
78+ }
79+ path := filepath.Join(dir, "popup.css")
80+ if err == nil {
81+ err = os.Rename(tmp, path)
82+ }
83+ if err != nil {
84+ _ = os.Remove(tmp)
85+ return "", fmt.Errorf("popup: %w", err)
86+ }
87+ return path, nil
88+}
@@ -0,0 +1,88 @@
1+package popup
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "strconv"
8+ "strings"
9+)
10+
11+// Inset is the space the popup stylesheet puts between the surface edge and
12+// the content: 4px of padding and a 1px border.
13+const Inset = 5
14+
15+// Style is what the popup stylesheet takes from the bar.
16+type Style struct {
17+ Font string
18+ Size float64 // font size in logical pixels
19+ FG, BG, Accent [3]uint8
20+}
21+
22+func hex(c [3]uint8) string { return fmt.Sprintf("#%02x%02x%02x", c[0], c[1], c[2]) }
23+
24+// cssString makes a font name safe inside a quoted CSS string.
25+func cssString(s string) string {
26+ return strings.Map(func(r rune) rune {
27+ switch {
28+ case r == '"' || r == '\\' || r == '{' || r == '}' || r == ';' || r < ' ' || r == 0x7f:
29+ return -1
30+ }
31+ return r
32+ }, s)
33+}
34+
35+// CSS is the stylesheet of every popup.
36+func CSS(s Style) string {
37+ fg, bg, ac := hex(s.FG), hex(s.BG), hex(s.Accent)
38+ size := strconv.FormatFloat(s.Size, 'f', -1, 64)
39+ var b strings.Builder
40+ fmt.Fprintf(&b, "app { background-color: %s; color: %s; font-family: %q; font-size: %spx; padding: 4px; border: 1px solid %s; }\n",
41+ bg, fg, cssString(s.Font), size, ac)
42+ b.WriteString(".title { font-weight: bold; }\n")
43+ b.WriteString(".body { opacity: 0.85; }\n")
44+ b.WriteString("button.item, checkbox.item { text-align: left; background-color: transparent; padding: 2px 12px; }\n")
45+ fmt.Fprintf(&b, "button.item:hover, checkbox.item:hover, button.item:focus-visible, checkbox.item:focus-visible { background-color: %s; color: %s; }\n", ac, bg)
46+ b.WriteString("button.item:disabled, checkbox.item:disabled { opacity: 0.5; }\n")
47+ fmt.Fprintf(&b, "button.item:disabled:hover, checkbox.item:disabled:hover { background-color: transparent; color: %s; }\n", fg)
48+ fmt.Fprintf(&b, "separator { background-color: %s; opacity: 0.3; margin: 4px 0; }\n", fg)
49+ return b.String()
50+}
51+
52+// WriteCSS stores css as popup.css in $XDG_RUNTIME_DIR/neferbar (directory
53+// 0700, file 0600, replaced atomically) and returns the path. nefergui reads
54+// its stylesheet from a path.
55+func WriteCSS(css string) (string, error) {
56+ rt := os.Getenv("XDG_RUNTIME_DIR")
57+ if rt == "" {
58+ return "", fmt.Errorf("popup: XDG_RUNTIME_DIR is not set")
59+ }
60+ dir := filepath.Join(rt, "neferbar")
61+ if err := os.MkdirAll(dir, 0o700); err != nil {
62+ return "", fmt.Errorf("popup: %w", err)
63+ }
64+ if err := os.Chmod(dir, 0o700); err != nil {
65+ return "", fmt.Errorf("popup: %w", err)
66+ }
67+ f, err := os.CreateTemp(dir, "popup-*.css")
68+ if err != nil {
69+ return "", fmt.Errorf("popup: %w", err)
70+ }
71+ tmp := f.Name()
72+ _, err = f.WriteString(css)
73+ if cerr := f.Close(); err == nil {
74+ err = cerr
75+ }
76+ if err == nil {
77+ err = os.Chmod(tmp, 0o600)
78+ }
79+ path := filepath.Join(dir, "popup.css")
80+ if err == nil {
81+ err = os.Rename(tmp, path)
82+ }
83+ if err != nil {
84+ _ = os.Remove(tmp)
85+ return "", fmt.Errorf("popup: %w", err)
86+ }
87+ return path, nil
88+}
A internal/popup/geometry.go +70 -0
@@ -0,0 +1,70 @@
1+// Package popup draws the tooltips and menus that interactive modules ask for
2+// with control lines (see module.ParseControl). A Host owns at most one popup:
3+// an xdg_popup on the bar's layer surface, drawn by a nefergui renderer of its
4+// own.
5+package popup
6+
7+import (
8+ "math"
9+
10+ "github.com/bnema/neferclient"
11+)
12+
13+// Size limits of a popup, in logical pixels.
14+const (
15+ MaxMenuWidth = 480
16+ MaxTooltipWidth = 360
17+ // MaxHeight is the tallest popup; a taller menu scrolls.
18+ MaxHeight = 600
19+)
20+
21+// CellRect is the logical-pixel rectangle of width cells starting at cell
22+// start of a row of cellW-physical-pixel cells on a surface of the given
23+// scale, rounded outward, height pixels high.
24+func CellRect(start, width, cellW int, scale float64, height int32) neferclient.Rect {
25+ const eps = 1e-9
26+ x0 := int32(math.Floor(float64(start)*float64(cellW)/scale + eps))
27+ x1 := int32(math.Ceil(float64(start+width)*float64(cellW)/scale - eps))
28+ return neferclient.Rect{X: x0, Y: 0, Width: x1 - x0, Height: height}
29+}
30+
31+// Anchor is the rectangle a popup attaches to: the cells col .. col+width-1
32+// of a module whose shown cells start at spanStart and number spanWidth. The
33+// request is clamped to the shown cells. It reports false when col lies
34+// outside them.
35+func Anchor(spanStart, spanWidth, col, width, cellW int, scale float64, height int32) (neferclient.Rect, bool) {
36+ if col < 0 || col >= spanWidth || width <= 0 || cellW <= 0 || !(scale > 0) {
37+ return neferclient.Rect{}, false
38+ }
39+ width = min(width, spanWidth-col)
40+ return CellRect(spanStart+col, width, cellW, scale, height), true
41+}
42+
43+// Edge is the anchor edge and the gravity that put a popup on the free side of
44+// a bar: below a bar at the top, above a bar at the bottom.
45+func Edge(bottomBar bool) neferclient.PopupEdge {
46+ if bottomBar {
47+ return neferclient.EdgeTop
48+ }
49+ return neferclient.EdgeBottom
50+}
51+
52+// Clamp rounds a measured size up to whole pixels and limits the height to
53+// MaxHeight; scroll reports that the content is taller than that.
54+func Clamp(w, h float64) (cw, ch int32, scroll bool) {
55+ cw = int32(math.Ceil(max(w, 1)))
56+ if h > MaxHeight {
57+ return cw, MaxHeight, true
58+ }
59+ return cw, int32(math.Ceil(max(h, 1))), false
60+}
61+
62+// Placement is where a popup of the given size goes for an anchor.
63+func Placement(anchor neferclient.Rect, bottomBar bool, w, h int32) neferclient.PopupPlacement {
64+ e := Edge(bottomBar)
65+ return neferclient.PopupPlacement{
66+ Anchor: anchor, Edge: e, Gravity: e,
67+ Adjust: neferclient.AdjustSlideX | neferclient.AdjustFlipY | neferclient.AdjustResizeY,
68+ Width: w, Height: h,
69+ }
70+}
@@ -0,0 +1,70 @@
1+// Package popup draws the tooltips and menus that interactive modules ask for
2+// with control lines (see module.ParseControl). A Host owns at most one popup:
3+// an xdg_popup on the bar's layer surface, drawn by a nefergui renderer of its
4+// own.
5+package popup
6+
7+import (
8+ "math"
9+
10+ "github.com/bnema/neferclient"
11+)
12+
13+// Size limits of a popup, in logical pixels.
14+const (
15+ MaxMenuWidth = 480
16+ MaxTooltipWidth = 360
17+ // MaxHeight is the tallest popup; a taller menu scrolls.
18+ MaxHeight = 600
19+)
20+
21+// CellRect is the logical-pixel rectangle of width cells starting at cell
22+// start of a row of cellW-physical-pixel cells on a surface of the given
23+// scale, rounded outward, height pixels high.
24+func CellRect(start, width, cellW int, scale float64, height int32) neferclient.Rect {
25+ const eps = 1e-9
26+ x0 := int32(math.Floor(float64(start)*float64(cellW)/scale + eps))
27+ x1 := int32(math.Ceil(float64(start+width)*float64(cellW)/scale - eps))
28+ return neferclient.Rect{X: x0, Y: 0, Width: x1 - x0, Height: height}
29+}
30+
31+// Anchor is the rectangle a popup attaches to: the cells col .. col+width-1
32+// of a module whose shown cells start at spanStart and number spanWidth. The
33+// request is clamped to the shown cells. It reports false when col lies
34+// outside them.
35+func Anchor(spanStart, spanWidth, col, width, cellW int, scale float64, height int32) (neferclient.Rect, bool) {
36+ if col < 0 || col >= spanWidth || width <= 0 || cellW <= 0 || !(scale > 0) {
37+ return neferclient.Rect{}, false
38+ }
39+ width = min(width, spanWidth-col)
40+ return CellRect(spanStart+col, width, cellW, scale, height), true
41+}
42+
43+// Edge is the anchor edge and the gravity that put a popup on the free side of
44+// a bar: below a bar at the top, above a bar at the bottom.
45+func Edge(bottomBar bool) neferclient.PopupEdge {
46+ if bottomBar {
47+ return neferclient.EdgeTop
48+ }
49+ return neferclient.EdgeBottom
50+}
51+
52+// Clamp rounds a measured size up to whole pixels and limits the height to
53+// MaxHeight; scroll reports that the content is taller than that.
54+func Clamp(w, h float64) (cw, ch int32, scroll bool) {
55+ cw = int32(math.Ceil(max(w, 1)))
56+ if h > MaxHeight {
57+ return cw, MaxHeight, true
58+ }
59+ return cw, int32(math.Ceil(max(h, 1))), false
60+}
61+
62+// Placement is where a popup of the given size goes for an anchor.
63+func Placement(anchor neferclient.Rect, bottomBar bool, w, h int32) neferclient.PopupPlacement {
64+ e := Edge(bottomBar)
65+ return neferclient.PopupPlacement{
66+ Anchor: anchor, Edge: e, Gravity: e,
67+ Adjust: neferclient.AdjustSlideX | neferclient.AdjustFlipY | neferclient.AdjustResizeY,
68+ Width: w, Height: h,
69+ }
70+}
A internal/popup/host.go +558 -0
@@ -0,0 +1,558 @@
1+package popup
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "log/slog"
7+ "strconv"
8+
9+ "github.com/bnema/neferclient"
10+ "github.com/bnema/nefergui"
11+
12+ "github.com/bnema/neferbar/internal/module"
13+)
14+
15+// FDBase is added to the id of a popup renderer's buffers when their release
16+// descriptors are watched: the bar's own slots use small ids, and
17+// Conn.WatchFD reports the same id for both through Handler.FDReady.
18+const FDBase = 1 << 48
19+
20+// Kind is what a popup shows.
21+type Kind uint8
22+
23+const (
24+ KindTooltip Kind = iota + 1
25+ KindMenu
26+)
27+
28+// guiMods are the modifier bits nefergui knows; neferclient uses the same order.
29+const guiMods = neferclient.ModShift | neferclient.ModCtrl | neferclient.ModAlt | neferclient.ModSuper |
30+ neferclient.ModCapsLock | neferclient.ModNumLock
31+
32+// Request asks the host to open a popup.
33+type Request struct {
34+ Kind Kind
35+ Owner *module.Module
36+ // Ctrl is the control line: its title and body for a tooltip, its items
37+ // and token for a menu.
38+ Ctrl module.Control
39+ // Anchor is the part of Parent the popup attaches to, in logical pixels.
40+ Anchor neferclient.Rect
41+ Parent *neferclient.Surface
42+ BottomBar bool
43+ // Feedback gives the GPU and the formats of the renderer.
44+ Feedback *neferclient.Feedback
45+ // Serial is the pointer serial of the press that opened a menu; the menu
46+ // grabs the keyboard and the pointer with it.
47+ Serial uint32
48+}
49+
50+// watch is a release descriptor being watched.
51+type watch struct {
52+ fd int
53+ buffer uint64
54+}
55+
56+// Host owns at most one open popup. Everything runs on the bar's loop
57+// goroutine, like the Handler methods that feed it.
58+type Host struct {
59+ conn *neferclient.Conn
60+ seat *neferclient.Seat
61+ log *slog.Logger
62+
63+ styles string // path of the stylesheet; "" disables popups
64+
65+ warm chan struct{} // closed when the warm-up renderer is gone; nil before Warm
66+
67+ surf *neferclient.Surface
68+ sid neferclient.SurfaceID
69+ r *nefergui.Renderer
70+ out nefergui.Output
71+
72+ render func(*nefergui.Output) (bool, error) // Render with the model's view
73+ tip *tooltipModel
74+ menu *menuModel
75+
76+ kind Kind
77+ owner *module.Module
78+ ownerGen uint64
79+ anchor neferclient.Rect
80+ bottomBar bool
81+
82+ watched []watch
83+ damage []neferclient.Rect
84+ configured bool
85+ canPresent bool
86+ haveAcquire bool
87+ inside bool // the pointer is over the popup
88+ cursor neferclient.CursorShape
89+}
90+
91+// NewHost makes a host for conn.
92+func NewHost(conn *neferclient.Conn, log *slog.Logger) *Host {
93+ return &Host{conn: conn, seat: conn.Seat(), log: log, cursor: neferclient.CursorDefault}
94+}
95+
96+// SetStyles sets the stylesheet path (see WriteCSS). An empty path disables
97+// popups. An open popup keeps the sheet it was built with.
98+func (h *Host) SetStyles(path string) { h.styles = path }
99+
100+func formats(fb *neferclient.Feedback) []nefergui.Format {
101+ out := make([]nefergui.Format, len(fb.Formats))
102+ for i, f := range fb.Formats {
103+ out[i] = nefergui.Format{FourCC: f.FourCC, Modifier: f.Modifier}
104+ }
105+ return out
106+}
107+
108+// Warm loads the GPU libraries on another goroutine so the first popup opens
109+// quickly: the first renderer of a process takes about half a second, later
110+// ones a fraction of that. It does nothing when called again.
111+func (h *Host) Warm(fb *neferclient.Feedback) {
112+ if h.warm != nil || fb == nil {
113+ return
114+ }
115+ h.warm = make(chan struct{})
116+ cfg := nefergui.RendererConfig{MainDevice: fb.MainDevice, Formats: formats(fb)}
117+ done := h.warm
118+ go func() {
119+ defer close(done)
120+ r, err := nefergui.NewRenderer(cfg)
121+ if err != nil {
122+ h.log.Debug("popup warm-up failed", "err", err)
123+ return
124+ }
125+ _ = r.Close()
126+ }()
127+}
128+
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
134+ }
135+}
136+
137+// Active reports whether a popup is open.
138+func (h *Host) Active() bool { return h != nil && h.r != nil }
139+
140+// Kind is the kind of the open popup, 0 when none.
141+func (h *Host) Kind() Kind {
142+ if h == nil || h.r == nil {
143+ return 0
144+ }
145+ return h.kind
146+}
147+
148+// Owner is the module the open popup belongs to, nil when none.
149+func (h *Host) Owner() *module.Module {
150+ if h == nil || h.r == nil {
151+ return nil
152+ }
153+ return h.owner
154+}
155+
156+// OwnerRestarted reports whether the script behind the popup started again
157+// since the popup opened.
158+func (h *Host) OwnerRestarted() bool {
159+ return h.owner != nil && h.owner.Gen() != h.ownerGen
160+}
161+
162+// Is reports whether id is the open popup's surface.
163+func (h *Host) Is(id neferclient.SurfaceID) bool { return h != nil && h.r != nil && id == h.sid }
164+
165+// Wake is signaled when the popup renderer needs a Render from another
166+// goroutine; nil when no popup is open.
167+func (h *Host) Wake() <-chan struct{} {
168+ if h == nil || h.r == nil {
169+ return nil
170+ }
171+ return h.r.Wake()
172+}
173+
174+// Pending reports a built frame waiting for the GPU: call Draw again soon.
175+func (h *Host) Pending() bool { return h != nil && h.r != nil && h.r.Pending() }
176+
177+// measure sizes the model of the open popup.
178+func (h *Host) measure(r *nefergui.Renderer) (w, ht int32, scroll bool, err error) {
179+ var fw, fh float64
180+ switch h.kind {
181+ case KindTooltip:
182+ fw, fh, err = r.Measure(h.tip, tooltipView, MaxTooltipWidth)
183+ default:
184+ h.menu.scroll = false
185+ fw, fh, err = r.Measure(h.menu, menuView, MaxMenuWidth)
186+ if err == nil && fh > MaxHeight {
187+ h.menu.scroll = true
188+ fw, fh, err = r.Measure(h.menu, menuView, MaxMenuWidth)
189+ }
190+ }
191+ if err != nil {
192+ return 0, 0, false, err
193+ }
194+ w, ht, scroll = Clamp(fw, fh)
195+ return w, ht, scroll, nil
196+}
197+
198+// Open shows a popup, replacing any open one. It can take a while the first
199+// time (the GPU libraries load) and allocates freely: popups are rare.
200+func (h *Host) Open(req Request) error {
201+ h.Close()
202+ switch {
203+ case h.styles == "":
204+ return errors.New("popup: no stylesheet")
205+ case req.Parent == nil || req.Feedback == nil || req.Owner == nil:
206+ return errors.New("popup: incomplete request")
207+ case req.Kind != KindTooltip && req.Kind != KindMenu:
208+ return fmt.Errorf("popup: unknown kind %d", req.Kind)
209+ }
210+ h.Wait() // two renderers must not start at once
211+ r, err := nefergui.NewRenderer(nefergui.RendererConfig{MainDevice: req.Feedback.MainDevice, Formats: formats(req.Feedback), Styles: h.styles})
212+ if err != nil {
213+ return fmt.Errorf("popup: %w", err)
214+ }
215+ h.r, h.kind = r, req.Kind
216+ switch req.Kind {
217+ case KindTooltip:
218+ h.tip = newTooltip(req.Ctrl.Title, req.Ctrl.Body)
219+ h.render = func(out *nefergui.Output) (bool, error) { return r.Render(out, h.tip, tooltipView) }
220+ default:
221+ h.menu = newMenu(req.Ctrl.Items, req.Ctrl.Click)
222+ h.render = func(out *nefergui.Output) (bool, error) { return r.Render(out, h.menu, menuView) }
223+ }
224+ w, ht, _, err := h.measure(r)
225+ if err != nil {
226+ h.reset()
227+ _ = r.Close()
228+ return fmt.Errorf("popup: %w", err)
229+ }
230+ h.anchor, h.bottomBar = req.Anchor, req.BottomBar
231+ h.owner, h.ownerGen = req.Owner, req.Owner.Gen()
232+ grab := req.Kind == KindMenu
233+ surf, err := h.conn.NewPopup(neferclient.PopupConfig{
234+ Parent: req.Parent, Place: Placement(req.Anchor, req.BottomBar, w, ht), Grab: grab, Serial: req.Serial,
235+ })
236+ if err != nil {
237+ h.reset()
238+ _ = r.Close()
239+ return fmt.Errorf("popup: %w", err)
240+ }
241+ h.surf, h.sid = surf, surf.ID()
242+ if req.Kind == KindTooltip {
243+ // A tooltip is never clicked: the pointer goes through it.
244+ if err = surf.SetInputRegion([]neferclient.Rect{}); err != nil {
245+ h.Close()
246+ return fmt.Errorf("popup: %w", err)
247+ }
248+ }
249+ return nil
250+}
251+
252+// reset forgets everything about the open popup; the caller has closed its
253+// renderer and surface.
254+func (h *Host) reset() {
255+ *h = Host{conn: h.conn, seat: h.seat, log: h.log, styles: h.styles, warm: h.warm, cursor: neferclient.CursorDefault}
256+}
257+
258+// Close dismisses the open popup. A menu that was not used tells its script
259+// ("menu-closed"). It does nothing when no popup is open.
260+func (h *Host) Close() { h.end(true) }
261+
262+func (h *Host) end(notify bool) {
263+ if h == nil || h.r == nil {
264+ return
265+ }
266+ for _, w := range h.watched {
267+ _ = h.conn.UnwatchFD(w.fd)
268+ }
269+ if h.surf != nil {
270+ if err := h.surf.Close(); err != nil {
271+ h.log.Warn("popup: cannot close the surface", "err", err)
272+ }
273+ }
274+ _ = h.r.Close()
275+ if notify && h.menu != nil && h.owner != nil && !h.OwnerRestarted() {
276+ h.owner.Send(strconv.AppendUint([]byte("menu-closed "), uint64(h.menu.token), 10))
277+ }
278+ h.reset()
279+}
280+
281+// fail logs err and closes the popup: a broken popup never takes the bar down.
282+func (h *Host) fail(what string, err error) {
283+ h.log.Warn("popup: "+what, "err", err)
284+ h.Close()
285+}
286+
287+// PopupDone is the compositor dismissing the popup (outside click, denied
288+// grab, gone parent).
289+func (h *Host) PopupDone() { h.Close() }
290+
291+// Configure follows the size the compositor gave the popup.
292+func (h *Host) Configure() {
293+ if h.r == nil {
294+ return
295+ }
296+ if !h.configured { // later configures must not lift the frame-callback gate
297+ h.canPresent = true
298+ }
299+ h.configured = true
300+ h.resize()
301+}
302+
303+// Scale follows the popup's preferred scale.
304+func (h *Host) Scale() { h.resize() }
305+
306+func (h *Host) resize() {
307+ if h.r == nil {
308+ return
309+ }
310+ w, ht, scale := h.surf.Size()
311+ h.r.Resize(int(w), int(ht), scale)
312+}
313+
314+// Frame allows the next Present.
315+func (h *Host) Frame() {
316+ if h.r != nil {
317+ h.canPresent = true
318+ }
319+}
320+
321+// FDReady hands a buffer back once the compositor released it; id is the
322+// watched id, FDBase plus the renderer's buffer.
323+func (h *Host) FDReady(id uint64) {
324+ if h.r == nil || id < FDBase {
325+ return
326+ }
327+ if err := h.r.Released(id - FDBase); err != nil {
328+ h.fail("release buffer", err)
329+ }
330+}
331+
332+// Pointer feeds a pointer event over the popup to its renderer.
333+func (h *Host) Pointer(ev *neferclient.PointerEvent) {
334+ if h.r == nil {
335+ return
336+ }
337+ in := nefergui.Input{X: ev.X, Y: ev.Y}
338+ switch ev.Kind {
339+ case neferclient.PointerEnter:
340+ h.inside = true
341+ in.Kind = nefergui.InputPointerMotion
342+ h.applyCursor() // the shape is only valid after the enter
343+ case neferclient.PointerMotion:
344+ in.Kind = nefergui.InputPointerMotion
345+ case neferclient.PointerLeave:
346+ h.inside = false
347+ in.Kind = nefergui.InputPointerLeave
348+ case neferclient.PointerButton:
349+ in.Kind, in.Button = nefergui.InputPointerRelease, ev.Button
350+ if ev.Pressed {
351+ in.Kind = nefergui.InputPointerPress
352+ }
353+ case neferclient.PointerAxis:
354+ in.Kind, in.DX, in.DY = nefergui.InputPointerAxis, ev.DX, ev.DY
355+ default:
356+ return
357+ }
358+ h.r.Input(&in)
359+}
360+
361+// Key feeds a key event to the popup. A menu maps its navigation keys first.
362+func (h *Host) Key(ev *neferclient.KeyEvent) {
363+ if h.r == nil {
364+ return
365+ }
366+ if h.menu != nil {
367+ switch act := mapKey(ev.Keysym, h.menu.inSubmenu()); act {
368+ case keyNext, keyPrevious:
369+ if ev.Pressed { // the repeats of a held arrow walk on
370+ mods := nefergui.Modifiers(0)
371+ if act == keyPrevious {
372+ mods = nefergui.ModShift
373+ }
374+ h.r.Input(&nefergui.Input{Kind: nefergui.InputKey, Keysym: keysymTab, Pressed: true, Modifiers: mods})
375+ }
376+ return
377+ case keyBack:
378+ if ev.Pressed && !ev.Repeat && h.menu.pop() {
379+ h.r.Invalidate()
380+ }
381+ return
382+ case keyClose:
383+ if ev.Pressed {
384+ h.Close()
385+ }
386+ return
387+ }
388+ }
389+ h.r.Input(&nefergui.Input{
390+ Kind: nefergui.InputKey,
391+ Keysym: ev.Keysym,
392+ Text: ev.Text, // valid only during the call, as for Input
393+ Pressed: ev.Pressed,
394+ Repeat: ev.Repeat,
395+ Modifiers: nefergui.Modifiers(ev.Modifiers & guiMods),
396+ })
397+}
398+
399+// KeyboardFocus tells the renderer whether the popup has the keyboard.
400+func (h *Host) KeyboardFocus(focused bool) {
401+ if h.r == nil {
402+ return
403+ }
404+ kind := nefergui.InputFocusOut
405+ if focused {
406+ kind = nefergui.InputFocusIn
407+ }
408+ h.r.Input(&nefergui.Input{Kind: kind})
409+}
410+
411+// Draw renders and presents the popup when something changed, and carries out
412+// what the user chose in a menu.
413+func (h *Host) Draw() {
414+ if h.r == nil {
415+ return
416+ }
417+ if h.menu != nil && h.menu.changed {
418+ h.levelChanged()
419+ }
420+ if h.r == nil || !h.configured || !h.canPresent || h.surf.Feedback() == nil {
421+ return
422+ }
423+ ok, err := h.render(&h.out)
424+ if err != nil {
425+ h.fail("render", err)
426+ return
427+ }
428+ if h.menu != nil && h.menu.chosen >= 0 {
429+ // Chosen during the build: tell the script and go, without
430+ // "menu-closed" (the choice ends the menu).
431+ line := append([]byte("menu-activate "), strconv.FormatUint(uint64(h.menu.token), 10)...)
432+ line = append(line, ' ')
433+ line = strconv.AppendInt(line, int64(h.menu.chosen), 10)
434+ h.owner.Send(line)
435+ h.end(false)
436+ return
437+ }
438+ if ok {
439+ if err = h.present(); err != nil {
440+ h.fail("present", err)
441+ return
442+ }
443+ h.applyCursor()
444+ }
445+ if h.menu != nil && h.menu.changed {
446+ h.levelChanged()
447+ }
448+}
449+
450+// levelChanged resizes the popup for the level of the menu now shown.
451+func (h *Host) levelChanged() {
452+ h.menu.changed = false
453+ w, ht, _, err := h.measure(h.r)
454+ if err != nil {
455+ h.fail("measure", err)
456+ return
457+ }
458+ 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.
461+ h.log.Debug("popup: cannot resize the menu", "err", err)
462+ }
463+ h.r.Invalidate()
464+}
465+
466+// present hands the frame in h.out to the compositor, as nefergui's demo does.
467+func (h *Host) present() error {
468+ out := &h.out
469+ // A resize retired buffers: stop watching and destroy them first.
470+ for _, rt := range out.Retired {
471+ if err := h.conn.UnwatchFD(rt.ReleaseFD); err != nil {
472+ return fmt.Errorf("unwatch retired buffer %d: %w", rt.Buffer, err)
473+ }
474+ for i, w := range h.watched {
475+ if w.fd == rt.ReleaseFD {
476+ h.watched = append(h.watched[:i], h.watched[i+1:]...)
477+ break
478+ }
479+ }
480+ if err := h.surf.DestroyBuffer(rt.Buffer); err != nil {
481+ return fmt.Errorf("destroy retired buffer %d: %w", rt.Buffer, err)
482+ }
483+ // The release timeline of a buffer is imported under the buffer's id.
484+ if err := h.surf.DestroyTimeline(rt.Buffer); err != nil {
485+ return fmt.Errorf("destroy retired timeline %d: %w", rt.Buffer, err)
486+ }
487+ }
488+ if out.NewBuffer {
489+ if out.Buffer >= FDBase {
490+ return fmt.Errorf("buffer id %d is out of range", out.Buffer)
491+ }
492+ buf := neferclient.Buffer{
493+ Width: out.Width, Height: out.Height, FourCC: out.FourCC, Modifier: out.Modifier,
494+ PlaneCount: out.PlaneCount,
495+ }
496+ for i, p := range out.Planes {
497+ buf.Planes[i] = neferclient.Plane{FD: p.FD, Offset: p.Offset, Stride: p.Stride}
498+ }
499+ if err := h.surf.ImportBuffer(out.Buffer, &buf); err != nil {
500+ return fmt.Errorf("import buffer: %w", err)
501+ }
502+ // The release eventfd is stable per buffer: watch it once.
503+ if err := h.conn.WatchFD(out.ReleaseFD, FDBase|out.Buffer); err != nil {
504+ return fmt.Errorf("watch release fd: %w", err)
505+ }
506+ h.watched = append(h.watched, watch{fd: out.ReleaseFD, buffer: out.Buffer})
507+ }
508+ if out.NewTimelines {
509+ if !h.haveAcquire { // the acquire timeline is shared by every buffer
510+ if err := h.surf.ImportTimeline(out.Acquire.ID, out.Acquire.FD); err != nil {
511+ return fmt.Errorf("import acquire timeline: %w", err)
512+ }
513+ h.haveAcquire = true
514+ }
515+ if err := h.surf.ImportTimeline(out.Release.ID, out.Release.FD); err != nil {
516+ return fmt.Errorf("import release timeline: %w", err)
517+ }
518+ }
519+ h.damage = h.damage[:0]
520+ for _, r := range out.Damage {
521+ h.damage = append(h.damage, neferclient.Rect{X: r.X, Y: r.Y, Width: r.Width, Height: r.Height})
522+ }
523+ err := h.surf.Present(&neferclient.Present{
524+ Buffer: out.Buffer,
525+ AcquireTimeline: out.Acquire.ID,
526+ ReleaseTimeline: out.Release.ID,
527+ AcquirePoint: out.AcquirePoint,
528+ ReleasePoint: out.ReleasePoint,
529+ Damage: h.damage,
530+ Opaque: true, // XRGB8888: RendererConfig.Transparent is false
531+ })
532+ if err != nil {
533+ return fmt.Errorf("present: %w", err)
534+ }
535+ h.canPresent = false // until Frame
536+ return nil
537+}
538+
539+// applyCursor shows the shape the hovered element asked for, while the
540+// pointer is over the popup.
541+func (h *Host) applyCursor() {
542+ switch h.out.Cursor {
543+ case nefergui.CursorPointer:
544+ h.cursor = neferclient.CursorPointer
545+ case nefergui.CursorText:
546+ h.cursor = neferclient.CursorText
547+ case nefergui.CursorNotAllowed:
548+ h.cursor = neferclient.CursorNotAllowed
549+ default:
550+ h.cursor = neferclient.CursorDefault
551+ }
552+ if !h.inside {
553+ return
554+ }
555+ if err := h.seat.SetCursor(h.cursor); err != nil {
556+ h.log.Debug("popup: cannot set the cursor", "err", err)
557+ }
558+}
@@ -0,0 +1,558 @@
1+package popup
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "log/slog"
7+ "strconv"
8+
9+ "github.com/bnema/neferclient"
10+ "github.com/bnema/nefergui"
11+
12+ "github.com/bnema/neferbar/internal/module"
13+)
14+
15+// FDBase is added to the id of a popup renderer's buffers when their release
16+// descriptors are watched: the bar's own slots use small ids, and
17+// Conn.WatchFD reports the same id for both through Handler.FDReady.
18+const FDBase = 1 << 48
19+
20+// Kind is what a popup shows.
21+type Kind uint8
22+
23+const (
24+ KindTooltip Kind = iota + 1
25+ KindMenu
26+)
27+
28+// guiMods are the modifier bits nefergui knows; neferclient uses the same order.
29+const guiMods = neferclient.ModShift | neferclient.ModCtrl | neferclient.ModAlt | neferclient.ModSuper |
30+ neferclient.ModCapsLock | neferclient.ModNumLock
31+
32+// Request asks the host to open a popup.
33+type Request struct {
34+ Kind Kind
35+ Owner *module.Module
36+ // Ctrl is the control line: its title and body for a tooltip, its items
37+ // and token for a menu.
38+ Ctrl module.Control
39+ // Anchor is the part of Parent the popup attaches to, in logical pixels.
40+ Anchor neferclient.Rect
41+ Parent *neferclient.Surface
42+ BottomBar bool
43+ // Feedback gives the GPU and the formats of the renderer.
44+ Feedback *neferclient.Feedback
45+ // Serial is the pointer serial of the press that opened a menu; the menu
46+ // grabs the keyboard and the pointer with it.
47+ Serial uint32
48+}
49+
50+// watch is a release descriptor being watched.
51+type watch struct {
52+ fd int
53+ buffer uint64
54+}
55+
56+// Host owns at most one open popup. Everything runs on the bar's loop
57+// goroutine, like the Handler methods that feed it.
58+type Host struct {
59+ conn *neferclient.Conn
60+ seat *neferclient.Seat
61+ log *slog.Logger
62+
63+ styles string // path of the stylesheet; "" disables popups
64+
65+ warm chan struct{} // closed when the warm-up renderer is gone; nil before Warm
66+
67+ surf *neferclient.Surface
68+ sid neferclient.SurfaceID
69+ r *nefergui.Renderer
70+ out nefergui.Output
71+
72+ render func(*nefergui.Output) (bool, error) // Render with the model's view
73+ tip *tooltipModel
74+ menu *menuModel
75+
76+ kind Kind
77+ owner *module.Module
78+ ownerGen uint64
79+ anchor neferclient.Rect
80+ bottomBar bool
81+
82+ watched []watch
83+ damage []neferclient.Rect
84+ configured bool
85+ canPresent bool
86+ haveAcquire bool
87+ inside bool // the pointer is over the popup
88+ cursor neferclient.CursorShape
89+}
90+
91+// NewHost makes a host for conn.
92+func NewHost(conn *neferclient.Conn, log *slog.Logger) *Host {
93+ return &Host{conn: conn, seat: conn.Seat(), log: log, cursor: neferclient.CursorDefault}
94+}
95+
96+// SetStyles sets the stylesheet path (see WriteCSS). An empty path disables
97+// popups. An open popup keeps the sheet it was built with.
98+func (h *Host) SetStyles(path string) { h.styles = path }
99+
100+func formats(fb *neferclient.Feedback) []nefergui.Format {
101+ out := make([]nefergui.Format, len(fb.Formats))
102+ for i, f := range fb.Formats {
103+ out[i] = nefergui.Format{FourCC: f.FourCC, Modifier: f.Modifier}
104+ }
105+ return out
106+}
107+
108+// Warm loads the GPU libraries on another goroutine so the first popup opens
109+// quickly: the first renderer of a process takes about half a second, later
110+// ones a fraction of that. It does nothing when called again.
111+func (h *Host) Warm(fb *neferclient.Feedback) {
112+ if h.warm != nil || fb == nil {
113+ return
114+ }
115+ h.warm = make(chan struct{})
116+ cfg := nefergui.RendererConfig{MainDevice: fb.MainDevice, Formats: formats(fb)}
117+ done := h.warm
118+ go func() {
119+ defer close(done)
120+ r, err := nefergui.NewRenderer(cfg)
121+ if err != nil {
122+ h.log.Debug("popup warm-up failed", "err", err)
123+ return
124+ }
125+ _ = r.Close()
126+ }()
127+}
128+
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
134+ }
135+}
136+
137+// Active reports whether a popup is open.
138+func (h *Host) Active() bool { return h != nil && h.r != nil }
139+
140+// Kind is the kind of the open popup, 0 when none.
141+func (h *Host) Kind() Kind {
142+ if h == nil || h.r == nil {
143+ return 0
144+ }
145+ return h.kind
146+}
147+
148+// Owner is the module the open popup belongs to, nil when none.
149+func (h *Host) Owner() *module.Module {
150+ if h == nil || h.r == nil {
151+ return nil
152+ }
153+ return h.owner
154+}
155+
156+// OwnerRestarted reports whether the script behind the popup started again
157+// since the popup opened.
158+func (h *Host) OwnerRestarted() bool {
159+ return h.owner != nil && h.owner.Gen() != h.ownerGen
160+}
161+
162+// Is reports whether id is the open popup's surface.
163+func (h *Host) Is(id neferclient.SurfaceID) bool { return h != nil && h.r != nil && id == h.sid }
164+
165+// Wake is signaled when the popup renderer needs a Render from another
166+// goroutine; nil when no popup is open.
167+func (h *Host) Wake() <-chan struct{} {
168+ if h == nil || h.r == nil {
169+ return nil
170+ }
171+ return h.r.Wake()
172+}
173+
174+// Pending reports a built frame waiting for the GPU: call Draw again soon.
175+func (h *Host) Pending() bool { return h != nil && h.r != nil && h.r.Pending() }
176+
177+// measure sizes the model of the open popup.
178+func (h *Host) measure(r *nefergui.Renderer) (w, ht int32, scroll bool, err error) {
179+ var fw, fh float64
180+ switch h.kind {
181+ case KindTooltip:
182+ fw, fh, err = r.Measure(h.tip, tooltipView, MaxTooltipWidth)
183+ default:
184+ h.menu.scroll = false
185+ fw, fh, err = r.Measure(h.menu, menuView, MaxMenuWidth)
186+ if err == nil && fh > MaxHeight {
187+ h.menu.scroll = true
188+ fw, fh, err = r.Measure(h.menu, menuView, MaxMenuWidth)
189+ }
190+ }
191+ if err != nil {
192+ return 0, 0, false, err
193+ }
194+ w, ht, scroll = Clamp(fw, fh)
195+ return w, ht, scroll, nil
196+}
197+
198+// Open shows a popup, replacing any open one. It can take a while the first
199+// time (the GPU libraries load) and allocates freely: popups are rare.
200+func (h *Host) Open(req Request) error {
201+ h.Close()
202+ switch {
203+ case h.styles == "":
204+ return errors.New("popup: no stylesheet")
205+ case req.Parent == nil || req.Feedback == nil || req.Owner == nil:
206+ return errors.New("popup: incomplete request")
207+ case req.Kind != KindTooltip && req.Kind != KindMenu:
208+ return fmt.Errorf("popup: unknown kind %d", req.Kind)
209+ }
210+ h.Wait() // two renderers must not start at once
211+ r, err := nefergui.NewRenderer(nefergui.RendererConfig{MainDevice: req.Feedback.MainDevice, Formats: formats(req.Feedback), Styles: h.styles})
212+ if err != nil {
213+ return fmt.Errorf("popup: %w", err)
214+ }
215+ h.r, h.kind = r, req.Kind
216+ switch req.Kind {
217+ case KindTooltip:
218+ h.tip = newTooltip(req.Ctrl.Title, req.Ctrl.Body)
219+ h.render = func(out *nefergui.Output) (bool, error) { return r.Render(out, h.tip, tooltipView) }
220+ default:
221+ h.menu = newMenu(req.Ctrl.Items, req.Ctrl.Click)
222+ h.render = func(out *nefergui.Output) (bool, error) { return r.Render(out, h.menu, menuView) }
223+ }
224+ w, ht, _, err := h.measure(r)
225+ if err != nil {
226+ h.reset()
227+ _ = r.Close()
228+ return fmt.Errorf("popup: %w", err)
229+ }
230+ h.anchor, h.bottomBar = req.Anchor, req.BottomBar
231+ h.owner, h.ownerGen = req.Owner, req.Owner.Gen()
232+ grab := req.Kind == KindMenu
233+ surf, err := h.conn.NewPopup(neferclient.PopupConfig{
234+ Parent: req.Parent, Place: Placement(req.Anchor, req.BottomBar, w, ht), Grab: grab, Serial: req.Serial,
235+ })
236+ if err != nil {
237+ h.reset()
238+ _ = r.Close()
239+ return fmt.Errorf("popup: %w", err)
240+ }
241+ h.surf, h.sid = surf, surf.ID()
242+ if req.Kind == KindTooltip {
243+ // A tooltip is never clicked: the pointer goes through it.
244+ if err = surf.SetInputRegion([]neferclient.Rect{}); err != nil {
245+ h.Close()
246+ return fmt.Errorf("popup: %w", err)
247+ }
248+ }
249+ return nil
250+}
251+
252+// reset forgets everything about the open popup; the caller has closed its
253+// renderer and surface.
254+func (h *Host) reset() {
255+ *h = Host{conn: h.conn, seat: h.seat, log: h.log, styles: h.styles, warm: h.warm, cursor: neferclient.CursorDefault}
256+}
257+
258+// Close dismisses the open popup. A menu that was not used tells its script
259+// ("menu-closed"). It does nothing when no popup is open.
260+func (h *Host) Close() { h.end(true) }
261+
262+func (h *Host) end(notify bool) {
263+ if h == nil || h.r == nil {
264+ return
265+ }
266+ for _, w := range h.watched {
267+ _ = h.conn.UnwatchFD(w.fd)
268+ }
269+ if h.surf != nil {
270+ if err := h.surf.Close(); err != nil {
271+ h.log.Warn("popup: cannot close the surface", "err", err)
272+ }
273+ }
274+ _ = h.r.Close()
275+ if notify && h.menu != nil && h.owner != nil && !h.OwnerRestarted() {
276+ h.owner.Send(strconv.AppendUint([]byte("menu-closed "), uint64(h.menu.token), 10))
277+ }
278+ h.reset()
279+}
280+
281+// fail logs err and closes the popup: a broken popup never takes the bar down.
282+func (h *Host) fail(what string, err error) {
283+ h.log.Warn("popup: "+what, "err", err)
284+ h.Close()
285+}
286+
287+// PopupDone is the compositor dismissing the popup (outside click, denied
288+// grab, gone parent).
289+func (h *Host) PopupDone() { h.Close() }
290+
291+// Configure follows the size the compositor gave the popup.
292+func (h *Host) Configure() {
293+ if h.r == nil {
294+ return
295+ }
296+ if !h.configured { // later configures must not lift the frame-callback gate
297+ h.canPresent = true
298+ }
299+ h.configured = true
300+ h.resize()
301+}
302+
303+// Scale follows the popup's preferred scale.
304+func (h *Host) Scale() { h.resize() }
305+
306+func (h *Host) resize() {
307+ if h.r == nil {
308+ return
309+ }
310+ w, ht, scale := h.surf.Size()
311+ h.r.Resize(int(w), int(ht), scale)
312+}
313+
314+// Frame allows the next Present.
315+func (h *Host) Frame() {
316+ if h.r != nil {
317+ h.canPresent = true
318+ }
319+}
320+
321+// FDReady hands a buffer back once the compositor released it; id is the
322+// watched id, FDBase plus the renderer's buffer.
323+func (h *Host) FDReady(id uint64) {
324+ if h.r == nil || id < FDBase {
325+ return
326+ }
327+ if err := h.r.Released(id - FDBase); err != nil {
328+ h.fail("release buffer", err)
329+ }
330+}
331+
332+// Pointer feeds a pointer event over the popup to its renderer.
333+func (h *Host) Pointer(ev *neferclient.PointerEvent) {
334+ if h.r == nil {
335+ return
336+ }
337+ in := nefergui.Input{X: ev.X, Y: ev.Y}
338+ switch ev.Kind {
339+ case neferclient.PointerEnter:
340+ h.inside = true
341+ in.Kind = nefergui.InputPointerMotion
342+ h.applyCursor() // the shape is only valid after the enter
343+ case neferclient.PointerMotion:
344+ in.Kind = nefergui.InputPointerMotion
345+ case neferclient.PointerLeave:
346+ h.inside = false
347+ in.Kind = nefergui.InputPointerLeave
348+ case neferclient.PointerButton:
349+ in.Kind, in.Button = nefergui.InputPointerRelease, ev.Button
350+ if ev.Pressed {
351+ in.Kind = nefergui.InputPointerPress
352+ }
353+ case neferclient.PointerAxis:
354+ in.Kind, in.DX, in.DY = nefergui.InputPointerAxis, ev.DX, ev.DY
355+ default:
356+ return
357+ }
358+ h.r.Input(&in)
359+}
360+
361+// Key feeds a key event to the popup. A menu maps its navigation keys first.
362+func (h *Host) Key(ev *neferclient.KeyEvent) {
363+ if h.r == nil {
364+ return
365+ }
366+ if h.menu != nil {
367+ switch act := mapKey(ev.Keysym, h.menu.inSubmenu()); act {
368+ case keyNext, keyPrevious:
369+ if ev.Pressed { // the repeats of a held arrow walk on
370+ mods := nefergui.Modifiers(0)
371+ if act == keyPrevious {
372+ mods = nefergui.ModShift
373+ }
374+ h.r.Input(&nefergui.Input{Kind: nefergui.InputKey, Keysym: keysymTab, Pressed: true, Modifiers: mods})
375+ }
376+ return
377+ case keyBack:
378+ if ev.Pressed && !ev.Repeat && h.menu.pop() {
379+ h.r.Invalidate()
380+ }
381+ return
382+ case keyClose:
383+ if ev.Pressed {
384+ h.Close()
385+ }
386+ return
387+ }
388+ }
389+ h.r.Input(&nefergui.Input{
390+ Kind: nefergui.InputKey,
391+ Keysym: ev.Keysym,
392+ Text: ev.Text, // valid only during the call, as for Input
393+ Pressed: ev.Pressed,
394+ Repeat: ev.Repeat,
395+ Modifiers: nefergui.Modifiers(ev.Modifiers & guiMods),
396+ })
397+}
398+
399+// KeyboardFocus tells the renderer whether the popup has the keyboard.
400+func (h *Host) KeyboardFocus(focused bool) {
401+ if h.r == nil {
402+ return
403+ }
404+ kind := nefergui.InputFocusOut
405+ if focused {
406+ kind = nefergui.InputFocusIn
407+ }
408+ h.r.Input(&nefergui.Input{Kind: kind})
409+}
410+
411+// Draw renders and presents the popup when something changed, and carries out
412+// what the user chose in a menu.
413+func (h *Host) Draw() {
414+ if h.r == nil {
415+ return
416+ }
417+ if h.menu != nil && h.menu.changed {
418+ h.levelChanged()
419+ }
420+ if h.r == nil || !h.configured || !h.canPresent || h.surf.Feedback() == nil {
421+ return
422+ }
423+ ok, err := h.render(&h.out)
424+ if err != nil {
425+ h.fail("render", err)
426+ return
427+ }
428+ if h.menu != nil && h.menu.chosen >= 0 {
429+ // Chosen during the build: tell the script and go, without
430+ // "menu-closed" (the choice ends the menu).
431+ line := append([]byte("menu-activate "), strconv.FormatUint(uint64(h.menu.token), 10)...)
432+ line = append(line, ' ')
433+ line = strconv.AppendInt(line, int64(h.menu.chosen), 10)
434+ h.owner.Send(line)
435+ h.end(false)
436+ return
437+ }
438+ if ok {
439+ if err = h.present(); err != nil {
440+ h.fail("present", err)
441+ return
442+ }
443+ h.applyCursor()
444+ }
445+ if h.menu != nil && h.menu.changed {
446+ h.levelChanged()
447+ }
448+}
449+
450+// levelChanged resizes the popup for the level of the menu now shown.
451+func (h *Host) levelChanged() {
452+ h.menu.changed = false
453+ w, ht, _, err := h.measure(h.r)
454+ if err != nil {
455+ h.fail("measure", err)
456+ return
457+ }
458+ 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.
461+ h.log.Debug("popup: cannot resize the menu", "err", err)
462+ }
463+ h.r.Invalidate()
464+}
465+
466+// present hands the frame in h.out to the compositor, as nefergui's demo does.
467+func (h *Host) present() error {
468+ out := &h.out
469+ // A resize retired buffers: stop watching and destroy them first.
470+ for _, rt := range out.Retired {
471+ if err := h.conn.UnwatchFD(rt.ReleaseFD); err != nil {
472+ return fmt.Errorf("unwatch retired buffer %d: %w", rt.Buffer, err)
473+ }
474+ for i, w := range h.watched {
475+ if w.fd == rt.ReleaseFD {
476+ h.watched = append(h.watched[:i], h.watched[i+1:]...)
477+ break
478+ }
479+ }
480+ if err := h.surf.DestroyBuffer(rt.Buffer); err != nil {
481+ return fmt.Errorf("destroy retired buffer %d: %w", rt.Buffer, err)
482+ }
483+ // The release timeline of a buffer is imported under the buffer's id.
484+ if err := h.surf.DestroyTimeline(rt.Buffer); err != nil {
485+ return fmt.Errorf("destroy retired timeline %d: %w", rt.Buffer, err)
486+ }
487+ }
488+ if out.NewBuffer {
489+ if out.Buffer >= FDBase {
490+ return fmt.Errorf("buffer id %d is out of range", out.Buffer)
491+ }
492+ buf := neferclient.Buffer{
493+ Width: out.Width, Height: out.Height, FourCC: out.FourCC, Modifier: out.Modifier,
494+ PlaneCount: out.PlaneCount,
495+ }
496+ for i, p := range out.Planes {
497+ buf.Planes[i] = neferclient.Plane{FD: p.FD, Offset: p.Offset, Stride: p.Stride}
498+ }
499+ if err := h.surf.ImportBuffer(out.Buffer, &buf); err != nil {
500+ return fmt.Errorf("import buffer: %w", err)
501+ }
502+ // The release eventfd is stable per buffer: watch it once.
503+ if err := h.conn.WatchFD(out.ReleaseFD, FDBase|out.Buffer); err != nil {
504+ return fmt.Errorf("watch release fd: %w", err)
505+ }
506+ h.watched = append(h.watched, watch{fd: out.ReleaseFD, buffer: out.Buffer})
507+ }
508+ if out.NewTimelines {
509+ if !h.haveAcquire { // the acquire timeline is shared by every buffer
510+ if err := h.surf.ImportTimeline(out.Acquire.ID, out.Acquire.FD); err != nil {
511+ return fmt.Errorf("import acquire timeline: %w", err)
512+ }
513+ h.haveAcquire = true
514+ }
515+ if err := h.surf.ImportTimeline(out.Release.ID, out.Release.FD); err != nil {
516+ return fmt.Errorf("import release timeline: %w", err)
517+ }
518+ }
519+ h.damage = h.damage[:0]
520+ for _, r := range out.Damage {
521+ h.damage = append(h.damage, neferclient.Rect{X: r.X, Y: r.Y, Width: r.Width, Height: r.Height})
522+ }
523+ err := h.surf.Present(&neferclient.Present{
524+ Buffer: out.Buffer,
525+ AcquireTimeline: out.Acquire.ID,
526+ ReleaseTimeline: out.Release.ID,
527+ AcquirePoint: out.AcquirePoint,
528+ ReleasePoint: out.ReleasePoint,
529+ Damage: h.damage,
530+ Opaque: true, // XRGB8888: RendererConfig.Transparent is false
531+ })
532+ if err != nil {
533+ return fmt.Errorf("present: %w", err)
534+ }
535+ h.canPresent = false // until Frame
536+ return nil
537+}
538+
539+// applyCursor shows the shape the hovered element asked for, while the
540+// pointer is over the popup.
541+func (h *Host) applyCursor() {
542+ switch h.out.Cursor {
543+ case nefergui.CursorPointer:
544+ h.cursor = neferclient.CursorPointer
545+ case nefergui.CursorText:
546+ h.cursor = neferclient.CursorText
547+ case nefergui.CursorNotAllowed:
548+ h.cursor = neferclient.CursorNotAllowed
549+ default:
550+ h.cursor = neferclient.CursorDefault
551+ }
552+ if !h.inside {
553+ return
554+ }
555+ if err := h.seat.SetCursor(h.cursor); err != nil {
556+ h.log.Debug("popup: cannot set the cursor", "err", err)
557+ }
558+}
A internal/popup/keys.go +42 -0
@@ -0,0 +1,42 @@
1+package popup
2+
3+// xkb keysyms the menu reacts to.
4+const (
5+ keysymBackSpace = 0xff08
6+ keysymTab = 0xff09
7+ keysymEscape = 0xff1b
8+ keysymLeft = 0xff51
9+ keysymUp = 0xff52
10+ keysymDown = 0xff54
11+)
12+
13+// keyAction is what a key press does in a menu.
14+type keyAction uint8
15+
16+const (
17+ keyForward keyAction = iota // pass the key to nefergui unchanged
18+ keyNext // move the focus to the next entry (Tab)
19+ keyPrevious // move the focus to the previous entry (Shift+Tab)
20+ keyBack // leave the submenu
21+ keyClose // dismiss the menu
22+)
23+
24+// mapKey says what a key does. Up and Down walk the entries, Left and
25+// BackSpace leave a submenu, Escape dismisses; everything else (Tab, Return,
26+// Space…) goes to nefergui. Left and BackSpace do nothing at the top level.
27+func mapKey(keysym uint32, inSubmenu bool) keyAction {
28+ switch keysym {
29+ case keysymDown:
30+ return keyNext
31+ case keysymUp:
32+ return keyPrevious
33+ case keysymEscape:
34+ return keyClose
35+ case keysymLeft, keysymBackSpace:
36+ if inSubmenu {
37+ return keyBack
38+ }
39+ return keyForward
40+ }
41+ return keyForward
42+}
@@ -0,0 +1,42 @@
1+package popup
2+
3+// xkb keysyms the menu reacts to.
4+const (
5+ keysymBackSpace = 0xff08
6+ keysymTab = 0xff09
7+ keysymEscape = 0xff1b
8+ keysymLeft = 0xff51
9+ keysymUp = 0xff52
10+ keysymDown = 0xff54
11+)
12+
13+// keyAction is what a key press does in a menu.
14+type keyAction uint8
15+
16+const (
17+ keyForward keyAction = iota // pass the key to nefergui unchanged
18+ keyNext // move the focus to the next entry (Tab)
19+ keyPrevious // move the focus to the previous entry (Shift+Tab)
20+ keyBack // leave the submenu
21+ keyClose // dismiss the menu
22+)
23+
24+// mapKey says what a key does. Up and Down walk the entries, Left and
25+// BackSpace leave a submenu, Escape dismisses; everything else (Tab, Return,
26+// Space…) goes to nefergui. Left and BackSpace do nothing at the top level.
27+func mapKey(keysym uint32, inSubmenu bool) keyAction {
28+ switch keysym {
29+ case keysymDown:
30+ return keyNext
31+ case keysymUp:
32+ return keyPrevious
33+ case keysymEscape:
34+ return keyClose
35+ case keysymLeft, keysymBackSpace:
36+ if inSubmenu {
37+ return keyBack
38+ }
39+ return keyForward
40+ }
41+ return keyForward
42+}
A internal/popup/popup_test.go +227 -0
@@ -0,0 +1,227 @@
1+package popup
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "slices"
7+ "strings"
8+ "testing"
9+
10+ "github.com/bnema/neferclient"
11+
12+ "github.com/bnema/neferbar/internal/module"
13+)
14+
15+func TestCellRectRoundsOutward(t *testing.T) {
16+ // Scale 1.6, 13 px cells: a cell is 8.125 logical px wide.
17+ got := CellRect(2, 1, 13, 1.6, 20)
18+ if want := (neferclient.Rect{X: 16, Y: 0, Width: 9, Height: 20}); got != want { // [16.25, 24.375) -> [16, 25)
19+ t.Fatalf("CellRect = %+v, want %+v", got, want)
20+ }
21+ got = CellRect(0, 4, 10, 1, 24)
22+ if want := (neferclient.Rect{X: 0, Y: 0, Width: 40, Height: 24}); got != want {
23+ t.Fatalf("CellRect = %+v, want %+v", got, want)
24+ }
25+}
26+
27+func TestAnchorIsClampedToTheShownCells(t *testing.T) {
28+ // The module shows cells 10..13 (4 cells) of the row; 10 px cells at scale 1.
29+ r, ok := Anchor(10, 4, 1, 2, 10, 1, 20)
30+ if !ok || r != (neferclient.Rect{X: 110, Width: 20, Height: 20}) {
31+ t.Fatalf("Anchor = %+v %v", r, ok)
32+ }
33+ // A width that runs past the shown cells is cut at the module's edge.
34+ r, ok = Anchor(10, 4, 3, 5, 10, 1, 20)
35+ if !ok || r.X != 130 || r.Width != 10 {
36+ t.Fatalf("clamped Anchor = %+v %v", r, ok)
37+ }
38+ for _, c := range [][2]int{{-1, 1}, {4, 1}, {0, 0}, {0, -2}} {
39+ if _, ok = Anchor(10, 4, c[0], c[1], 10, 1, 20); ok {
40+ t.Errorf("Anchor(col %d, width %d) must fail", c[0], c[1])
41+ }
42+ }
43+ if _, ok = Anchor(0, 4, 0, 1, 0, 1, 20); ok {
44+ t.Error("zero cell width must fail")
45+ }
46+ if _, ok = Anchor(0, 4, 0, 1, 10, 0, 20); ok {
47+ t.Error("zero scale must fail")
48+ }
49+}
50+
51+func TestEdgeFollowsTheBarPosition(t *testing.T) {
52+ if Edge(false) != neferclient.EdgeBottom || Edge(true) != neferclient.EdgeTop {
53+ t.Fatal("a top bar opens below, a bottom bar above")
54+ }
55+ p := Placement(neferclient.Rect{Width: 1, Height: 1}, true, 100, 50)
56+ if p.Edge != neferclient.EdgeTop || p.Gravity != neferclient.EdgeTop || p.Width != 100 || p.Height != 50 {
57+ t.Fatalf("placement = %+v", p)
58+ }
59+ want := neferclient.AdjustSlideX | neferclient.AdjustFlipY | neferclient.AdjustResizeY
60+ if p.Adjust != want {
61+ t.Fatalf("adjust = %v, want %v", p.Adjust, want)
62+ }
63+}
64+
65+func TestClamp(t *testing.T) {
66+ for _, c := range []struct {
67+ w, h float64
68+ cw, ch int32
69+ wantScroll bool
70+ }{
71+ {100.2, 50.1, 101, 51, false},
72+ {0, 0, 1, 1, false},
73+ {200, 600, 200, 600, false},
74+ {200, 600.5, 200, MaxHeight, true},
75+ {200, 5000, 200, MaxHeight, true},
76+ } {
77+ cw, ch, scroll := Clamp(c.w, c.h)
78+ if cw != c.cw || ch != c.ch || scroll != c.wantScroll {
79+ t.Errorf("Clamp(%v,%v) = %d,%d,%v; want %d,%d,%v", c.w, c.h, cw, ch, scroll, c.cw, c.ch, c.wantScroll)
80+ }
81+ }
82+}
83+
84+func TestCSS(t *testing.T) {
85+ got := CSS(Style{Font: `My "Nerd" Font`, Size: 14.5, FG: [3]uint8{0xcc, 0xcc, 0xcc}, BG: [3]uint8{0x10, 0x20, 0x30}, Accent: [3]uint8{0x35, 0x84, 0xe4}})
86+ want := `app { background-color: #102030; color: #cccccc; font-family: "My Nerd Font"; font-size: 14.5px; padding: 4px; border: 1px solid #3584e4; }
87+.title { font-weight: bold; }
88+.body { opacity: 0.85; }
89+button.item, checkbox.item { text-align: left; background-color: transparent; padding: 2px 12px; }
90+button.item:hover, checkbox.item:hover, button.item:focus-visible, checkbox.item:focus-visible { background-color: #3584e4; color: #102030; }
91+button.item:disabled, checkbox.item:disabled { opacity: 0.5; }
92+button.item:disabled:hover, checkbox.item:disabled:hover { background-color: transparent; color: #cccccc; }
93+separator { background-color: #cccccc; opacity: 0.3; margin: 4px 0; }
94+`
95+ if got != want {
96+ t.Fatalf("CSS =\n%s\nwant\n%s", got, want)
97+ }
98+ // A font name cannot break out of the declaration.
99+ evil := CSS(Style{Font: `x"; } body { `, Size: 10})
100+ if strings.Count(evil, "{") != strings.Count(want, "{") {
101+ t.Fatalf("a font name injected rules:\n%s", evil)
102+ }
103+}
104+
105+func TestWriteCSSPermissions(t *testing.T) {
106+ dir := t.TempDir()
107+ t.Setenv("XDG_RUNTIME_DIR", dir)
108+ path, err := WriteCSS("app { color: red; }\n")
109+ if err != nil {
110+ t.Fatal(err)
111+ }
112+ if path != filepath.Join(dir, "neferbar", "popup.css") {
113+ t.Fatalf("path = %s", path)
114+ }
115+ for p, want := range map[string]os.FileMode{filepath.Dir(path): 0o700, path: 0o600} {
116+ st, err := os.Stat(p)
117+ if err != nil || st.Mode().Perm() != want {
118+ t.Errorf("%s: mode %v err %v, want %v", p, st.Mode().Perm(), err, want)
119+ }
120+ }
121+ // A rewrite replaces the file and leaves no temporary behind.
122+ if _, err = WriteCSS("app { color: blue; }\n"); err != nil {
123+ t.Fatal(err)
124+ }
125+ if b, _ := os.ReadFile(path); string(b) != "app { color: blue; }\n" {
126+ t.Fatalf("content = %q", b)
127+ }
128+ if ents, _ := os.ReadDir(filepath.Dir(path)); len(ents) != 1 {
129+ t.Fatalf("directory holds %d entries, want 1", len(ents))
130+ }
131+ t.Setenv("XDG_RUNTIME_DIR", "")
132+ if _, err = WriteCSS("x"); err == nil {
133+ t.Fatal("no runtime dir must fail")
134+ }
135+}
136+
137+func TestTooltipLines(t *testing.T) {
138+ m := newTooltip("Title", "a\n\nb\n")
139+ if m.title != "Title" || !slices.Equal(m.lines, []string{"a", "b"}) {
140+ t.Fatalf("tooltip = %+v", m)
141+ }
142+ if m = newTooltip("", ""); len(m.lines) != 0 {
143+ t.Fatalf("empty tooltip = %+v", m)
144+ }
145+}
146+
147+func sampleMenu() *menuModel {
148+ return newMenu([]module.MenuItem{
149+ {ID: 1, Label: "Open", Kind: module.KindNormal, Enabled: true},
150+ {ID: 4, Label: "Settings", Kind: module.KindNormal, Enabled: true, Items: []module.MenuItem{
151+ {ID: 5, Label: "Advanced", Kind: module.KindNormal, Enabled: true, Items: []module.MenuItem{
152+ {ID: 8, Label: "Deep", Kind: module.KindNormal, Enabled: true},
153+ }},
154+ {ID: 6, Label: "Quiet", Kind: module.KindCheck, Enabled: true},
155+ }},
156+ }, 9)
157+}
158+
159+func TestMenuTransitions(t *testing.T) {
160+ m := sampleMenu()
161+ if m.chosen != -1 || m.inSubmenu() || len(m.level()) != 2 {
162+ t.Fatalf("start = %+v", m)
163+ }
164+ if m.pop() {
165+ t.Fatal("pop at the top level must do nothing")
166+ }
167+ if m.push(0) {
168+ t.Fatal("a leaf is not a submenu")
169+ }
170+ if m.push(7) || m.push(-1) {
171+ t.Fatal("out of range push must fail")
172+ }
173+ if m.changed {
174+ t.Fatal("failed moves must not flag a change")
175+ }
176+ if !m.push(1) || !m.inSubmenu() || !m.changed || m.level()[0].ID != 5 {
177+ t.Fatalf("after push = %+v", m)
178+ }
179+ m.changed = false
180+ if !m.push(0) || m.level()[0].ID != 8 || len(m.path) != 2 {
181+ t.Fatalf("after second push = %+v", m)
182+ }
183+ if !m.pop() {
184+ t.Fatalf("first pop = %+v", m)
185+ }
186+ if !m.pop() || m.inSubmenu() || len(m.level()) != 2 || !m.changed {
187+ t.Fatalf("after pops = %+v", m)
188+ }
189+ m.choose(6)
190+ if m.chosen != 6 {
191+ t.Fatalf("chosen = %d", m.chosen)
192+ }
193+}
194+
195+func TestMenuLevelKeysDiffer(t *testing.T) {
196+ m := sampleMenu()
197+ top := m.levelKey()
198+ m.push(1)
199+ if m.levelKey() == top {
200+ t.Fatal("levels must not share control keys")
201+ }
202+}
203+
204+func TestMapKey(t *testing.T) {
205+ for _, c := range []struct {
206+ sym uint32
207+ sub bool
208+ want keyAction
209+ comment string
210+ }{
211+ {keysymDown, false, keyNext, "Down walks forward"},
212+ {keysymUp, true, keyPrevious, "Up walks back"},
213+ {keysymEscape, false, keyClose, "Escape closes"},
214+ {keysymEscape, true, keyClose, "Escape closes from a submenu too"},
215+ {keysymLeft, true, keyBack, "Left leaves a submenu"},
216+ {keysymBackSpace, true, keyBack, "BackSpace leaves a submenu"},
217+ {keysymLeft, false, keyForward, "Left does nothing at the top"},
218+ {keysymBackSpace, false, keyForward, "BackSpace does nothing at the top"},
219+ {keysymTab, false, keyForward, "Tab goes to nefergui"},
220+ {0x20, false, keyForward, "Space goes to nefergui"},
221+ {0xff0d, true, keyForward, "Return goes to nefergui"},
222+ } {
223+ if got := mapKey(c.sym, c.sub); got != c.want {
224+ t.Errorf("%s: mapKey(%#x, %v) = %d, want %d", c.comment, c.sym, c.sub, got, c.want)
225+ }
226+ }
227+}
@@ -0,0 +1,227 @@
1+package popup
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "slices"
7+ "strings"
8+ "testing"
9+
10+ "github.com/bnema/neferclient"
11+
12+ "github.com/bnema/neferbar/internal/module"
13+)
14+
15+func TestCellRectRoundsOutward(t *testing.T) {
16+ // Scale 1.6, 13 px cells: a cell is 8.125 logical px wide.
17+ got := CellRect(2, 1, 13, 1.6, 20)
18+ if want := (neferclient.Rect{X: 16, Y: 0, Width: 9, Height: 20}); got != want { // [16.25, 24.375) -> [16, 25)
19+ t.Fatalf("CellRect = %+v, want %+v", got, want)
20+ }
21+ got = CellRect(0, 4, 10, 1, 24)
22+ if want := (neferclient.Rect{X: 0, Y: 0, Width: 40, Height: 24}); got != want {
23+ t.Fatalf("CellRect = %+v, want %+v", got, want)
24+ }
25+}
26+
27+func TestAnchorIsClampedToTheShownCells(t *testing.T) {
28+ // The module shows cells 10..13 (4 cells) of the row; 10 px cells at scale 1.
29+ r, ok := Anchor(10, 4, 1, 2, 10, 1, 20)
30+ if !ok || r != (neferclient.Rect{X: 110, Width: 20, Height: 20}) {
31+ t.Fatalf("Anchor = %+v %v", r, ok)
32+ }
33+ // A width that runs past the shown cells is cut at the module's edge.
34+ r, ok = Anchor(10, 4, 3, 5, 10, 1, 20)
35+ if !ok || r.X != 130 || r.Width != 10 {
36+ t.Fatalf("clamped Anchor = %+v %v", r, ok)
37+ }
38+ for _, c := range [][2]int{{-1, 1}, {4, 1}, {0, 0}, {0, -2}} {
39+ if _, ok = Anchor(10, 4, c[0], c[1], 10, 1, 20); ok {
40+ t.Errorf("Anchor(col %d, width %d) must fail", c[0], c[1])
41+ }
42+ }
43+ if _, ok = Anchor(0, 4, 0, 1, 0, 1, 20); ok {
44+ t.Error("zero cell width must fail")
45+ }
46+ if _, ok = Anchor(0, 4, 0, 1, 10, 0, 20); ok {
47+ t.Error("zero scale must fail")
48+ }
49+}
50+
51+func TestEdgeFollowsTheBarPosition(t *testing.T) {
52+ if Edge(false) != neferclient.EdgeBottom || Edge(true) != neferclient.EdgeTop {
53+ t.Fatal("a top bar opens below, a bottom bar above")
54+ }
55+ p := Placement(neferclient.Rect{Width: 1, Height: 1}, true, 100, 50)
56+ if p.Edge != neferclient.EdgeTop || p.Gravity != neferclient.EdgeTop || p.Width != 100 || p.Height != 50 {
57+ t.Fatalf("placement = %+v", p)
58+ }
59+ want := neferclient.AdjustSlideX | neferclient.AdjustFlipY | neferclient.AdjustResizeY
60+ if p.Adjust != want {
61+ t.Fatalf("adjust = %v, want %v", p.Adjust, want)
62+ }
63+}
64+
65+func TestClamp(t *testing.T) {
66+ for _, c := range []struct {
67+ w, h float64
68+ cw, ch int32
69+ wantScroll bool
70+ }{
71+ {100.2, 50.1, 101, 51, false},
72+ {0, 0, 1, 1, false},
73+ {200, 600, 200, 600, false},
74+ {200, 600.5, 200, MaxHeight, true},
75+ {200, 5000, 200, MaxHeight, true},
76+ } {
77+ cw, ch, scroll := Clamp(c.w, c.h)
78+ if cw != c.cw || ch != c.ch || scroll != c.wantScroll {
79+ t.Errorf("Clamp(%v,%v) = %d,%d,%v; want %d,%d,%v", c.w, c.h, cw, ch, scroll, c.cw, c.ch, c.wantScroll)
80+ }
81+ }
82+}
83+
84+func TestCSS(t *testing.T) {
85+ got := CSS(Style{Font: `My "Nerd" Font`, Size: 14.5, FG: [3]uint8{0xcc, 0xcc, 0xcc}, BG: [3]uint8{0x10, 0x20, 0x30}, Accent: [3]uint8{0x35, 0x84, 0xe4}})
86+ want := `app { background-color: #102030; color: #cccccc; font-family: "My Nerd Font"; font-size: 14.5px; padding: 4px; border: 1px solid #3584e4; }
87+.title { font-weight: bold; }
88+.body { opacity: 0.85; }
89+button.item, checkbox.item { text-align: left; background-color: transparent; padding: 2px 12px; }
90+button.item:hover, checkbox.item:hover, button.item:focus-visible, checkbox.item:focus-visible { background-color: #3584e4; color: #102030; }
91+button.item:disabled, checkbox.item:disabled { opacity: 0.5; }
92+button.item:disabled:hover, checkbox.item:disabled:hover { background-color: transparent; color: #cccccc; }
93+separator { background-color: #cccccc; opacity: 0.3; margin: 4px 0; }
94+`
95+ if got != want {
96+ t.Fatalf("CSS =\n%s\nwant\n%s", got, want)
97+ }
98+ // A font name cannot break out of the declaration.
99+ evil := CSS(Style{Font: `x"; } body { `, Size: 10})
100+ if strings.Count(evil, "{") != strings.Count(want, "{") {
101+ t.Fatalf("a font name injected rules:\n%s", evil)
102+ }
103+}
104+
105+func TestWriteCSSPermissions(t *testing.T) {
106+ dir := t.TempDir()
107+ t.Setenv("XDG_RUNTIME_DIR", dir)
108+ path, err := WriteCSS("app { color: red; }\n")
109+ if err != nil {
110+ t.Fatal(err)
111+ }
112+ if path != filepath.Join(dir, "neferbar", "popup.css") {
113+ t.Fatalf("path = %s", path)
114+ }
115+ for p, want := range map[string]os.FileMode{filepath.Dir(path): 0o700, path: 0o600} {
116+ st, err := os.Stat(p)
117+ if err != nil || st.Mode().Perm() != want {
118+ t.Errorf("%s: mode %v err %v, want %v", p, st.Mode().Perm(), err, want)
119+ }
120+ }
121+ // A rewrite replaces the file and leaves no temporary behind.
122+ if _, err = WriteCSS("app { color: blue; }\n"); err != nil {
123+ t.Fatal(err)
124+ }
125+ if b, _ := os.ReadFile(path); string(b) != "app { color: blue; }\n" {
126+ t.Fatalf("content = %q", b)
127+ }
128+ if ents, _ := os.ReadDir(filepath.Dir(path)); len(ents) != 1 {
129+ t.Fatalf("directory holds %d entries, want 1", len(ents))
130+ }
131+ t.Setenv("XDG_RUNTIME_DIR", "")
132+ if _, err = WriteCSS("x"); err == nil {
133+ t.Fatal("no runtime dir must fail")
134+ }
135+}
136+
137+func TestTooltipLines(t *testing.T) {
138+ m := newTooltip("Title", "a\n\nb\n")
139+ if m.title != "Title" || !slices.Equal(m.lines, []string{"a", "b"}) {
140+ t.Fatalf("tooltip = %+v", m)
141+ }
142+ if m = newTooltip("", ""); len(m.lines) != 0 {
143+ t.Fatalf("empty tooltip = %+v", m)
144+ }
145+}
146+
147+func sampleMenu() *menuModel {
148+ return newMenu([]module.MenuItem{
149+ {ID: 1, Label: "Open", Kind: module.KindNormal, Enabled: true},
150+ {ID: 4, Label: "Settings", Kind: module.KindNormal, Enabled: true, Items: []module.MenuItem{
151+ {ID: 5, Label: "Advanced", Kind: module.KindNormal, Enabled: true, Items: []module.MenuItem{
152+ {ID: 8, Label: "Deep", Kind: module.KindNormal, Enabled: true},
153+ }},
154+ {ID: 6, Label: "Quiet", Kind: module.KindCheck, Enabled: true},
155+ }},
156+ }, 9)
157+}
158+
159+func TestMenuTransitions(t *testing.T) {
160+ m := sampleMenu()
161+ if m.chosen != -1 || m.inSubmenu() || len(m.level()) != 2 {
162+ t.Fatalf("start = %+v", m)
163+ }
164+ if m.pop() {
165+ t.Fatal("pop at the top level must do nothing")
166+ }
167+ if m.push(0) {
168+ t.Fatal("a leaf is not a submenu")
169+ }
170+ if m.push(7) || m.push(-1) {
171+ t.Fatal("out of range push must fail")
172+ }
173+ if m.changed {
174+ t.Fatal("failed moves must not flag a change")
175+ }
176+ if !m.push(1) || !m.inSubmenu() || !m.changed || m.level()[0].ID != 5 {
177+ t.Fatalf("after push = %+v", m)
178+ }
179+ m.changed = false
180+ if !m.push(0) || m.level()[0].ID != 8 || len(m.path) != 2 {
181+ t.Fatalf("after second push = %+v", m)
182+ }
183+ if !m.pop() {
184+ t.Fatalf("first pop = %+v", m)
185+ }
186+ if !m.pop() || m.inSubmenu() || len(m.level()) != 2 || !m.changed {
187+ t.Fatalf("after pops = %+v", m)
188+ }
189+ m.choose(6)
190+ if m.chosen != 6 {
191+ t.Fatalf("chosen = %d", m.chosen)
192+ }
193+}
194+
195+func TestMenuLevelKeysDiffer(t *testing.T) {
196+ m := sampleMenu()
197+ top := m.levelKey()
198+ m.push(1)
199+ if m.levelKey() == top {
200+ t.Fatal("levels must not share control keys")
201+ }
202+}
203+
204+func TestMapKey(t *testing.T) {
205+ for _, c := range []struct {
206+ sym uint32
207+ sub bool
208+ want keyAction
209+ comment string
210+ }{
211+ {keysymDown, false, keyNext, "Down walks forward"},
212+ {keysymUp, true, keyPrevious, "Up walks back"},
213+ {keysymEscape, false, keyClose, "Escape closes"},
214+ {keysymEscape, true, keyClose, "Escape closes from a submenu too"},
215+ {keysymLeft, true, keyBack, "Left leaves a submenu"},
216+ {keysymBackSpace, true, keyBack, "BackSpace leaves a submenu"},
217+ {keysymLeft, false, keyForward, "Left does nothing at the top"},
218+ {keysymBackSpace, false, keyForward, "BackSpace does nothing at the top"},
219+ {keysymTab, false, keyForward, "Tab goes to nefergui"},
220+ {0x20, false, keyForward, "Space goes to nefergui"},
221+ {0xff0d, true, keyForward, "Return goes to nefergui"},
222+ } {
223+ if got := mapKey(c.sym, c.sub); got != c.want {
224+ t.Errorf("%s: mapKey(%#x, %v) = %d, want %d", c.comment, c.sym, c.sub, got, c.want)
225+ }
226+ }
227+}
A internal/popup/view.go +152 -0
@@ -0,0 +1,152 @@
1+package popup
2+
3+import (
4+ "strconv"
5+
6+ "github.com/bnema/nefergui"
7+
8+ "github.com/bnema/neferbar/internal/module"
9+)
10+
11+// tooltipModel is what a tooltip shows.
12+type tooltipModel struct {
13+ title string
14+ lines []string // the body, one entry per non-empty line
15+}
16+
17+func newTooltip(title, body string) *tooltipModel {
18+ m := &tooltipModel{title: title}
19+ start := 0
20+ for i := 0; i <= len(body); i++ {
21+ if i == len(body) || body[i] == '\n' {
22+ if i > start {
23+ m.lines = append(m.lines, body[start:i])
24+ }
25+ start = i + 1
26+ }
27+ }
28+ return m
29+}
30+
31+// tooltipView draws the title in bold, then the body.
32+func tooltipView(f *nefergui.Frame, m *tooltipModel) {
33+ col := f.Root().Column()
34+ if m.title != "" {
35+ col.Text(m.title, nefergui.Class("title"), nefergui.Key("title"))
36+ }
37+ for i, l := range m.lines {
38+ col.Text(l, nefergui.Class("body"), nefergui.Key("b"+strconv.Itoa(i)))
39+ }
40+}
41+
42+// menuModel is a menu and where the user is in it. Submenus open in place:
43+// path holds the indexes of the submenus entered, and only the level at the
44+// end of the path is shown.
45+type menuModel struct {
46+ items []module.MenuItem
47+ path []int
48+ token uint32
49+ chosen int32 // the id of the activated item, -1 for none
50+ // changed is set when the level changed: the popup must be resized.
51+ changed bool
52+ // scroll wraps the items in a scrolling box: they are taller than a popup.
53+ scroll bool
54+}
55+
56+func newMenu(items []module.MenuItem, token uint32) *menuModel {
57+ return &menuModel{items: items, token: token, chosen: -1}
58+}
59+
60+// level is the items of the submenu the path leads to.
61+func (m *menuModel) level() []module.MenuItem {
62+ items := m.items
63+ for _, i := range m.path {
64+ if i < 0 || i >= len(items) {
65+ return nil
66+ }
67+ items = items[i].Items
68+ }
69+ return items
70+}
71+
72+// inSubmenu reports whether the shown level is a submenu.
73+func (m *menuModel) inSubmenu() bool { return len(m.path) > 0 }
74+
75+// push enters the submenu at index i of the shown level.
76+func (m *menuModel) push(i int) bool {
77+ lv := m.level()
78+ if i < 0 || i >= len(lv) || len(lv[i].Items) == 0 || len(m.path) >= module.MaxMenuDepth {
79+ return false
80+ }
81+ m.path = append(m.path, i)
82+ m.changed = true
83+ return true
84+}
85+
86+// pop goes back to the parent level.
87+func (m *menuModel) pop() bool {
88+ if len(m.path) == 0 {
89+ return false
90+ }
91+ m.path = m.path[:len(m.path)-1]
92+ m.changed = true
93+ return true
94+}
95+
96+// choose records the activation of a leaf item.
97+func (m *menuModel) choose(id int32) { m.chosen = id }
98+
99+// levelKey prefixes the keys of a level, so a control keeps no hover or focus
100+// state from another level.
101+func (m *menuModel) levelKey() string {
102+ k := "l"
103+ for _, i := range m.path {
104+ k += "." + strconv.Itoa(i)
105+ }
106+ return k + "/"
107+}
108+
109+// menuContentHeight is the height of the box that scrolls: the popup height
110+// minus the stylesheet's padding and border.
111+const menuContentHeight = MaxHeight - 2*Inset
112+
113+const (
114+ backLabel = "‹ Back"
115+ submenuMark = " ›"
116+)
117+
118+// menuView draws the shown level. Activations happen here, while the view
119+// runs: Back and submenu entries move the path, other entries record the id.
120+func menuView(f *nefergui.Frame, m *menuModel) {
121+ parent := f.Root()
122+ if m.scroll {
123+ parent = parent.Scroll(nefergui.Key("scroll"), nefergui.Inline("height: "+strconv.Itoa(menuContentHeight)+"px"))
124+ }
125+ col := parent.Column()
126+ if m.inSubmenu() {
127+ if col.Button(backLabel, nefergui.Key("back"), nefergui.Class("item")).Activated() {
128+ m.pop()
129+ }
130+ }
131+ prefix := m.levelKey()
132+ for i, it := range m.level() {
133+ key := nefergui.Key(prefix + strconv.Itoa(i))
134+ switch {
135+ case it.Kind == module.KindSeparator:
136+ col.Separator(key)
137+ case len(it.Items) > 0:
138+ if col.Button(it.Label+submenuMark, key, nefergui.Class("item"), nefergui.Disabled(!it.Enabled)).Activated() {
139+ m.push(i)
140+ }
141+ case it.Kind == module.KindCheck || it.Kind == module.KindRadio:
142+ checked := it.Checked
143+ if col.Checkbox(it.Label, &checked, key, nefergui.Class("item"), nefergui.Disabled(!it.Enabled)).Changed() {
144+ m.choose(it.ID)
145+ }
146+ default:
147+ if col.Button(it.Label, key, nefergui.Class("item"), nefergui.Disabled(!it.Enabled)).Activated() {
148+ m.choose(it.ID)
149+ }
150+ }
151+ }
152+}
@@ -0,0 +1,152 @@
1+package popup
2+
3+import (
4+ "strconv"
5+
6+ "github.com/bnema/nefergui"
7+
8+ "github.com/bnema/neferbar/internal/module"
9+)
10+
11+// tooltipModel is what a tooltip shows.
12+type tooltipModel struct {
13+ title string
14+ lines []string // the body, one entry per non-empty line
15+}
16+
17+func newTooltip(title, body string) *tooltipModel {
18+ m := &tooltipModel{title: title}
19+ start := 0
20+ for i := 0; i <= len(body); i++ {
21+ if i == len(body) || body[i] == '\n' {
22+ if i > start {
23+ m.lines = append(m.lines, body[start:i])
24+ }
25+ start = i + 1
26+ }
27+ }
28+ return m
29+}
30+
31+// tooltipView draws the title in bold, then the body.
32+func tooltipView(f *nefergui.Frame, m *tooltipModel) {
33+ col := f.Root().Column()
34+ if m.title != "" {
35+ col.Text(m.title, nefergui.Class("title"), nefergui.Key("title"))
36+ }
37+ for i, l := range m.lines {
38+ col.Text(l, nefergui.Class("body"), nefergui.Key("b"+strconv.Itoa(i)))
39+ }
40+}
41+
42+// menuModel is a menu and where the user is in it. Submenus open in place:
43+// path holds the indexes of the submenus entered, and only the level at the
44+// end of the path is shown.
45+type menuModel struct {
46+ items []module.MenuItem
47+ path []int
48+ token uint32
49+ chosen int32 // the id of the activated item, -1 for none
50+ // changed is set when the level changed: the popup must be resized.
51+ changed bool
52+ // scroll wraps the items in a scrolling box: they are taller than a popup.
53+ scroll bool
54+}
55+
56+func newMenu(items []module.MenuItem, token uint32) *menuModel {
57+ return &menuModel{items: items, token: token, chosen: -1}
58+}
59+
60+// level is the items of the submenu the path leads to.
61+func (m *menuModel) level() []module.MenuItem {
62+ items := m.items
63+ for _, i := range m.path {
64+ if i < 0 || i >= len(items) {
65+ return nil
66+ }
67+ items = items[i].Items
68+ }
69+ return items
70+}
71+
72+// inSubmenu reports whether the shown level is a submenu.
73+func (m *menuModel) inSubmenu() bool { return len(m.path) > 0 }
74+
75+// push enters the submenu at index i of the shown level.
76+func (m *menuModel) push(i int) bool {
77+ lv := m.level()
78+ if i < 0 || i >= len(lv) || len(lv[i].Items) == 0 || len(m.path) >= module.MaxMenuDepth {
79+ return false
80+ }
81+ m.path = append(m.path, i)
82+ m.changed = true
83+ return true
84+}
85+
86+// pop goes back to the parent level.
87+func (m *menuModel) pop() bool {
88+ if len(m.path) == 0 {
89+ return false
90+ }
91+ m.path = m.path[:len(m.path)-1]
92+ m.changed = true
93+ return true
94+}
95+
96+// choose records the activation of a leaf item.
97+func (m *menuModel) choose(id int32) { m.chosen = id }
98+
99+// levelKey prefixes the keys of a level, so a control keeps no hover or focus
100+// state from another level.
101+func (m *menuModel) levelKey() string {
102+ k := "l"
103+ for _, i := range m.path {
104+ k += "." + strconv.Itoa(i)
105+ }
106+ return k + "/"
107+}
108+
109+// menuContentHeight is the height of the box that scrolls: the popup height
110+// minus the stylesheet's padding and border.
111+const menuContentHeight = MaxHeight - 2*Inset
112+
113+const (
114+ backLabel = "‹ Back"
115+ submenuMark = " ›"
116+)
117+
118+// menuView draws the shown level. Activations happen here, while the view
119+// runs: Back and submenu entries move the path, other entries record the id.
120+func menuView(f *nefergui.Frame, m *menuModel) {
121+ parent := f.Root()
122+ if m.scroll {
123+ parent = parent.Scroll(nefergui.Key("scroll"), nefergui.Inline("height: "+strconv.Itoa(menuContentHeight)+"px"))
124+ }
125+ col := parent.Column()
126+ if m.inSubmenu() {
127+ if col.Button(backLabel, nefergui.Key("back"), nefergui.Class("item")).Activated() {
128+ m.pop()
129+ }
130+ }
131+ prefix := m.levelKey()
132+ for i, it := range m.level() {
133+ key := nefergui.Key(prefix + strconv.Itoa(i))
134+ switch {
135+ case it.Kind == module.KindSeparator:
136+ col.Separator(key)
137+ case len(it.Items) > 0:
138+ if col.Button(it.Label+submenuMark, key, nefergui.Class("item"), nefergui.Disabled(!it.Enabled)).Activated() {
139+ m.push(i)
140+ }
141+ case it.Kind == module.KindCheck || it.Kind == module.KindRadio:
142+ checked := it.Checked
143+ if col.Checkbox(it.Label, &checked, key, nefergui.Class("item"), nefergui.Disabled(!it.Enabled)).Changed() {
144+ m.choose(it.ID)
145+ }
146+ default:
147+ if col.Button(it.Label, key, nefergui.Class("item"), nefergui.Disabled(!it.Enabled)).Activated() {
148+ m.choose(it.ID)
149+ }
150+ }
151+ }
152+}

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