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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +1489 -17
M go.mod +2 -0
| @@ -6,6 +6,7 @@ require ( | |||
| 6 | 6 | github.com/BurntSushi/toml v1.6.0 | |
| 7 | 7 | github.com/bnema/go-wayland-bindings v0.1.0 | |
| 8 | 8 | github.com/bnema/neferclient v0.4.0 | |
| 9 | + | github.com/bnema/nefergui v0.7.0 | |
| 9 | 10 | github.com/bnema/purego-vulkan v0.6.0 | |
| 10 | 11 | github.com/bnema/vev-vt v0.6.0 | |
| 11 | 12 | github.com/bnema/wlturbo v0.6.2 | |
| @@ -17,5 +18,6 @@ require ( | |||
| 17 | 18 | require ( | |
| 18 | 19 | github.com/bnema/purego v0.13.0-bnema.1 // indirect | |
| 19 | 20 | github.com/bnema/purego-xkbcommon v0.2.0 // indirect | |
| 21 | + | github.com/go-text/typesetting v0.3.5 // indirect | |
| 20 | 22 | golang.org/x/text v0.42.0 // indirect | |
| 21 | 23 | ) | |
| @@ -6,6 +6,7 @@ require ( | |||||
| 6 | github.com/BurntSushi/toml v1.6.0 | 6 | github.com/BurntSushi/toml v1.6.0 | ||
| 7 | github.com/bnema/go-wayland-bindings v0.1.0 | 7 | github.com/bnema/go-wayland-bindings v0.1.0 | ||
| 8 | github.com/bnema/neferclient v0.4.0 | 8 | github.com/bnema/neferclient v0.4.0 | ||
| 9 | + | github.com/bnema/nefergui v0.7.0 | |||
| 9 | github.com/bnema/purego-vulkan v0.6.0 | 10 | github.com/bnema/purego-vulkan v0.6.0 | ||
| 10 | github.com/bnema/vev-vt v0.6.0 | 11 | github.com/bnema/vev-vt v0.6.0 | ||
| 11 | github.com/bnema/wlturbo v0.6.2 | 12 | 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 // indirect | 19 | github.com/bnema/purego v0.13.0-bnema.1 // indirect | ||
| 19 | github.com/bnema/purego-xkbcommon v0.2.0 // indirect | 20 | 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 // indirect | 22 | 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 ( | |||
| 28 | 28 | "github.com/bnema/neferbar/internal/gpu" | |
| 29 | 29 | "github.com/bnema/neferbar/internal/layout" | |
| 30 | 30 | "github.com/bnema/neferbar/internal/module" | |
| 31 | + | "github.com/bnema/neferbar/internal/popup" | |
| 31 | 32 | "github.com/bnema/neferbar/internal/syncobj" | |
| 32 | 33 | "github.com/bnema/neferbar/internal/theme" | |
| 33 | 34 | ) | |
| @@ -119,6 +120,13 @@ type Bar struct { | |||
| 119 | 120 | ptr pointerState // pointer events turned into module lines | |
| 120 | 121 | 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 | 130 | Stats Stats | |
| 123 | 131 | fatal error | |
| 124 | 132 | closed bool | |
| @@ -186,6 +194,7 @@ func (b *Bar) setTheme(th theme.Theme) { | |||
| 186 | 194 | } | |
| 187 | 195 | b.dirty = true | |
| 188 | 196 | b.watchTheme() | |
| 197 | + | b.updatePopupStyle() | |
| 189 | 198 | } | |
| 190 | 199 | ||
| 191 | 200 | // watchTheme watches every file the theme was read from, so changing the | |
| @@ -323,6 +332,9 @@ func (b *Bar) syncModules(want []config.Module) { | |||
| 323 | 332 | r.cancel() | |
| 324 | 333 | } | |
| 325 | 334 | 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 | 338 | clear(b.stale) | |
| 327 | 339 | b.watchScripts() | |
| 328 | 340 | b.mods = b.mods[:0] | |
| @@ -353,6 +365,7 @@ func (b *Bar) applyConfig(next config.Config) { | |||
| 353 | 365 | b.log.Warn("bar.output changed: restart the bar to move it", "from", old.Bar.Output, "to", next.Bar.Output) | |
| 354 | 366 | next.Bar.Output = old.Bar.Output | |
| 355 | 367 | } | |
| 368 | + | b.closePopup() // it was built from the old settings | |
| 356 | 369 | b.cfg = next | |
| 357 | 370 | b.fonts = fonts | |
| 358 | 371 | 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 | 378 | b.log.Info("moving the bar", "to", next.Bar.Position) | |
| 366 | 379 | b.dropRenderer() | |
| 367 | 380 | _, h, _ := b.surf.Size() | |
| 381 | + | b.closePopup() | |
| 368 | 382 | if err = b.surf.Close(); err != nil { | |
| 369 | 383 | b.fail(err) | |
| 370 | 384 | return | |
| @@ -389,6 +403,8 @@ func (b *Bar) Run(ctx context.Context) (err error) { | |||
| 389 | 403 | if err != nil { | |
| 390 | 404 | return connectError(b.display, err) | |
| 391 | 405 | } | |
| 406 | + | b.pop = popup.NewHost(b.conn, b.log) | |
| 407 | + | b.updatePopupStyle() | |
| 392 | 408 | defer func() { | |
| 393 | 409 | err = errors.Join(err, b.shutdown()) | |
| 394 | 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 | 433 | if err = b.conn.Dispatch(b); err != nil { | |
| 418 | 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 | 438 | case <-b.modCh: | |
| 421 | 439 | b.Stats.ModuleWake++ | |
| 422 | 440 | case next := <-b.cfgCh: | |
| @@ -435,6 +453,7 @@ func (b *Bar) Run(ctx context.Context) (err error) { | |||
| 435 | 453 | if err = b.step(); err != nil { | |
| 436 | 454 | return err | |
| 437 | 455 | } | |
| 456 | + | b.stepPopup() | |
| 438 | 457 | } | |
| 439 | 458 | return nil | |
| 440 | 459 | } | |
| @@ -637,6 +656,7 @@ func (b *Bar) reconcile() { | |||
| 637 | 656 | b.askedH, b.askedScale = wantH, scale | |
| 638 | 657 | b.log.Info("recreating layer surface", "height", wantH, "scale", scale) | |
| 639 | 658 | b.dropRenderer() | |
| 659 | + | b.closePopup() | |
| 640 | 660 | if err := b.surf.Close(); err != nil { | |
| 641 | 661 | b.fail(err) | |
| 642 | 662 | return | |
| @@ -664,6 +684,7 @@ func (b *Bar) reconcile() { | |||
| 664 | 684 | // off. A new surface is mapped fresh and placed from its real size. | |
| 665 | 685 | b.log.Info("recreating layer surface: the output width changed", "from", b.mappedW, "to", w) | |
| 666 | 686 | b.dropRenderer() | |
| 687 | + | b.closePopup() | |
| 667 | 688 | if err := b.surf.Close(); err != nil { | |
| 668 | 689 | b.fail(err) | |
| 669 | 690 | return | |
| @@ -751,6 +772,8 @@ func (b *Bar) shutdown() error { | |||
| 751 | 772 | for _, r := range b.runners { | |
| 752 | 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 | 777 | b.dropRenderer() | |
| 755 | 778 | b.face.Close() | |
| 756 | 779 | if b.node != nil { | |
| @@ -783,6 +806,10 @@ func (b *Bar) geometryChanged() { | |||
| 783 | 806 | ||
| 784 | 807 | // Configure marks the surface presentable and rebuilds as needed. | |
| 785 | 808 | func (b *Bar) Configure(id neferclient.SurfaceID, _, _ int32) { | |
| 809 | + | if b.pop.Is(id) { | |
| 810 | + | b.pop.Configure() | |
| 811 | + | return | |
| 812 | + | } | |
| 786 | 813 | if id != b.sid { | |
| 787 | 814 | return | |
| 788 | 815 | } | |
| @@ -795,6 +822,10 @@ func (b *Bar) Configure(id neferclient.SurfaceID, _, _ int32) { | |||
| 795 | 822 | ||
| 796 | 823 | // Scale follows the monitor's preferred scale. | |
| 797 | 824 | func (b *Bar) Scale(id neferclient.SurfaceID, _ float64) { | |
| 825 | + | if b.pop.Is(id) { | |
| 826 | + | b.pop.Scale() | |
| 827 | + | return | |
| 828 | + | } | |
| 798 | 829 | if id == b.sid { | |
| 799 | 830 | b.geometryChanged() | |
| 800 | 831 | } | |
| @@ -821,16 +852,43 @@ func (b *Bar) FeedbackDone(id neferclient.SurfaceID) { | |||
| 821 | 852 | ||
| 822 | 853 | // Frame allows the next Present. | |
| 823 | 854 | func (b *Bar) Frame(id neferclient.SurfaceID) { | |
| 855 | + | if b.pop.Is(id) { | |
| 856 | + | b.pop.Frame() | |
| 857 | + | return | |
| 858 | + | } | |
| 824 | 859 | if id == b.sid { | |
| 825 | 860 | b.canPresent = true | |
| 826 | 861 | } | |
| 827 | 862 | } | |
| 828 | 863 | ||
| 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 | + | } | |
| 831 | 885 | ||
| 832 | 886 | // Closed ends the loop when the compositor closes the surface. | |
| 833 | 887 | func (b *Bar) Closed(id neferclient.SurfaceID) { | |
| 888 | + | if b.pop.Is(id) { | |
| 889 | + | b.closePopup() | |
| 890 | + | return | |
| 891 | + | } | |
| 834 | 892 | if id == b.sid { | |
| 835 | 893 | b.closed = true | |
| 836 | 894 | } | |
| @@ -838,6 +896,10 @@ func (b *Bar) Closed(id neferclient.SurfaceID) { | |||
| 838 | 896 | ||
| 839 | 897 | // FDReady hands a slot back once the compositor released it. | |
| 840 | 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 | 903 | if b.rend == nil { | |
| 842 | 904 | return | |
| 843 | 905 | } | |
| @@ -862,6 +924,10 @@ func (b *Bar) syncInputRegion() { | |||
| 862 | 924 | ||
| 863 | 925 | // Pointer turns pointer events over the bar into lines for interactive modules. | |
| 864 | 926 | func (b *Bar) Pointer(ev *neferclient.PointerEvent) { | |
| 927 | + | if b.pop.Is(ev.Surface) { | |
| 928 | + | b.pop.Pointer(ev) | |
| 929 | + | return | |
| 930 | + | } | |
| 865 | 931 | if ev.Surface != b.sid || b.lay == nil || b.face == nil { | |
| 866 | 932 | return | |
| 867 | 933 | } | |
| @@ -874,12 +940,18 @@ func (b *Bar) handlePointer(ev *neferclient.PointerEvent, scale float64, cellW i | |||
| 874 | 940 | col := cellColumn(ev.X, scale, cellW) | |
| 875 | 941 | switch ev.Kind { | |
| 876 | 942 | case neferclient.PointerEnter, neferclient.PointerMotion: | |
| 943 | + | before := b.ptr.hover | |
| 877 | 944 | b.ptr.motion(b.lay, col) | |
| 945 | + | if b.ptr.hover != before { | |
| 946 | + | b.closeTooltip() // the pointer moved to another module | |
| 947 | + | } | |
| 878 | 948 | case neferclient.PointerLeave: | |
| 879 | 949 | b.ptr.leave() | |
| 950 | + | b.closeTooltip() | |
| 880 | 951 | case neferclient.PointerButton: | |
| 881 | 952 | 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) | |
| 883 | 955 | } | |
| 884 | 956 | case neferclient.PointerAxis: | |
| 885 | 957 | delta := ev.DY | |
| @@ -896,10 +968,8 @@ func (b *Bar) handlePointer(ev *neferclient.PointerEvent, scale float64, cellW i | |||
| 896 | 968 | func (b *Bar) Error(err error) { b.log.Warn("wayland", "err", err) } | |
| 897 | 969 | ||
| 898 | 970 | // 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 lines | 120 | ptr pointerState // pointer events turned into module lines | ||
| 120 | input inputRegion | 121 | 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 Stats | 130 | Stats Stats | ||
| 123 | fatal error | 131 | fatal error | ||
| 124 | closed bool | 132 | closed bool | ||
| @@ -186,6 +194,7 @@ func (b *Bar) setTheme(th theme.Theme) { | |||||
| 186 | } | 194 | } | ||
| 187 | b.dirty = true | 195 | 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 the | 200 | // 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 = next | 334 | 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.Output | 366 | next.Bar.Output = old.Bar.Output | ||
| 355 | } | 367 | } | ||
| 368 | + | b.closePopup() // it was built from the old settings | |||
| 356 | b.cfg = next | 369 | b.cfg = next | ||
| 357 | b.fonts = fonts | 370 | b.fonts = fonts | ||
| 358 | if err = b.loadTheme(); err != nil { // the theme setting or the colors may have changed | 371 | 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 | return | 384 | 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 err | 454 | return err | ||
| 437 | } | 455 | } | ||
| 456 | + | b.stepPopup() | |||
| 438 | } | 457 | } | ||
| 439 | return nil | 458 | return nil | ||
| 440 | } | 459 | } | ||
| @@ -637,6 +656,7 @@ func (b *Bar) reconcile() { | |||||
| 637 | b.askedH, b.askedScale = wantH, scale | 656 | 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 | return | 662 | 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 | return | 690 | 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 | return | 814 | 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 = true | 860 | 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 = true | 893 | 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 | return | 904 | 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 | return | 932 | 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 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) | |||
| 883 | } | 955 | } | ||
| 884 | case neferclient.PointerAxis: | 956 | case neferclient.PointerAxis: | ||
| 885 | delta := ev.DY | 957 | 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 ( | |||
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/neferbar/internal/layout" | |
| 12 | 12 | "github.com/bnema/neferbar/internal/module" | |
| 13 | + | "github.com/bnema/neferbar/internal/popup" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | // Evdev button codes. | |
| @@ -24,9 +25,28 @@ const pxPerStep = 15 | |||
| 24 | 25 | ||
| 25 | 26 | // press is the latest button press sent to a module. | |
| 26 | 27 | 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 | |
| 29 | 31 | 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 | 52 | // 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 | 127 | return | |
| 108 | 128 | } | |
| 109 | 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 | 131 | b := append(p.line("click "), name...) | |
| 112 | 132 | b = appendInt(b, off) | |
| 113 | 133 | b = append(b, ' ') | |
| @@ -204,10 +224,7 @@ func (a *wheelAcc) add(axis uint32, value120 int32, delta float64) int32 { | |||
| 204 | 224 | // inputRect is the logical-pixel rectangle of a span of cells: rounded | |
| 205 | 225 | // outward, full surface height. | |
| 206 | 226 | 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) | |
| 211 | 228 | } | |
| 212 | 229 | ||
| 213 | 230 | // 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 press | 28 | + | m *module.Module // the module the press was sent to |
| 28 | - | serial uint32 // the pointer serial of the press, for popup grabs | 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 | |||
| 29 | at time.Time | 31 | 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 module | 52 | // 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 | return | 127 | 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: rounded | 224 | // 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-9 | 227 | + | 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 | + | } | |||