detached 4bf13d9

feat(app): add 'neferbar app' to print the focused window's id, name and title, and show them in the bundled center module

commit
4bf13d9ee7310e15d0f1f8502648873dca5527e0
author
brice · · 2026-10-05 17:26 UTC
parent
b65ab9e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +432 -19
M README.md +14 -0
@@ -232,6 +232,20 @@ neferbar [-config file] [-display socket] [-pprof addr] [-memstats interval]
232232 | `-pprof` | Serve Go profiling on a loopback address, such as `localhost:6060`. |
233233 | `-memstats` | Log allocation counters at this interval. |
234234
235+## Reading the focused window
236+
237+`neferbar app` prints fields of the window that has the focus: one line now, then a new one each time the focus or a title changes. It works on compositors that offer `zwlr_foreign_toplevel_manager_v1` (NeferWL, Sway, Hyprland, River, Niri).
238+
239+```
240+neferbar app title # Dumber - docs
241+neferbar app id # com.github.bnema.dumber
242+neferbar app name # dumber (the part of the id after the last dot)
243+neferbar app name title # several fields, separated by a tab
244+neferbar app -once title # print once and exit
245+```
246+
247+A line is empty when no window has the focus. Control characters are removed from every field, so a title cannot move a terminal's cursor. Scripts started by the bar find the binary in `$NEFERBAR_BIN`.
248+
235249 ## Test without touching your desktop
236250
237251 ```sh
@@ -232,6 +232,20 @@ neferbar [-config file] [-display socket] [-pprof addr] [-memstats interval]
232 | `-pprof` | Serve Go profiling on a loopback address, such as `localhost:6060`. |232 | `-pprof` | Serve Go profiling on a loopback address, such as `localhost:6060`. |
233 | `-memstats` | Log allocation counters at this interval. |233 | `-memstats` | Log allocation counters at this interval. |
234 234
235+## Reading the focused window
236+
237+`neferbar app` prints fields of the window that has the focus: one line now, then a new one each time the focus or a title changes. It works on compositors that offer `zwlr_foreign_toplevel_manager_v1` (NeferWL, Sway, Hyprland, River, Niri).
238+
239+```
240+neferbar app title # Dumber - docs
241+neferbar app id # com.github.bnema.dumber
242+neferbar app name # dumber (the part of the id after the last dot)
243+neferbar app name title # several fields, separated by a tab
244+neferbar app -once title # print once and exit
245+```
246+
247+A line is empty when no window has the focus. Control characters are removed from every field, so a title cannot move a terminal's cursor. Scripts started by the bar find the binary in `$NEFERBAR_BIN`.
248+
235 ## Test without touching your desktop249 ## Test without touching your desktop
236 250
237 ```sh251 ```sh
A cmd/neferbar/app.go +69 -0
@@ -0,0 +1,69 @@
1+package main
2+
3+import (
4+ "context"
5+ "flag"
6+ "fmt"
7+ "os"
8+
9+ "git.bnema.dev/bnema/neferbar/internal/app"
10+)
11+
12+const appUsage = `usage: neferbar app [-once] <field>...
13+
14+Prints fields of the window that has the focus: one line now, then one more
15+each time it changes. With several fields, a tab separates them on the line
16+(a title never contains a tab). Fields are empty when no window has the focus.
17+
18+fields:
19+ id the application id, such as com.github.bnema.dumber
20+ name the short form of the id, such as dumber
21+ title the window title, without control characters
22+
23+flags:
24+`
25+
26+// runApp is "neferbar app <field>": the focused window, for scripts.
27+func runApp(ctx context.Context, args []string, display string) error {
28+ fs := flag.NewFlagSet("neferbar app", flag.ContinueOnError)
29+ once := fs.Bool("once", false, "print the current value and exit")
30+ fs.Usage = func() { fmt.Fprint(os.Stderr, appUsage); fs.PrintDefaults() }
31+ if err := fs.Parse(args); err != nil {
32+ return err
33+ }
34+ if fs.NArg() == 0 {
35+ fs.Usage()
36+ return fmt.Errorf("give at least one field: id, name or title")
37+ }
38+ var picks []func(app.Window) string
39+ for _, field := range fs.Args() {
40+ switch field {
41+ case "id":
42+ picks = append(picks, func(w app.Window) string { return app.Clean(w.ID) })
43+ case "name":
44+ picks = append(picks, func(w app.Window) string { return app.Clean(app.Name(w.ID)) })
45+ case "title":
46+ picks = append(picks, func(w app.Window) string { return app.Clean(w.Title) })
47+ default:
48+ return fmt.Errorf("%q is not a field; use id, name or title", field)
49+ }
50+ }
51+ ctx, cancel := context.WithCancel(ctx)
52+ defer cancel()
53+ return app.Watch(ctx, display, func(w app.Window) error {
54+ var line []byte
55+ for i, pick := range picks {
56+ if i > 0 {
57+ line = append(line, '\t')
58+ }
59+ line = append(line, pick(w)...)
60+ }
61+ if _, err := os.Stdout.Write(append(line, '\n')); err != nil {
62+ return err
63+ }
64+ if *once {
65+ cancel()
66+ }
67+ return nil
68+ })
69+}
@@ -0,0 +1,69 @@
1+package main
2+
3+import (
4+ "context"
5+ "flag"
6+ "fmt"
7+ "os"
8+
9+ "git.bnema.dev/bnema/neferbar/internal/app"
10+)
11+
12+const appUsage = `usage: neferbar app [-once] <field>...
13+
14+Prints fields of the window that has the focus: one line now, then one more
15+each time it changes. With several fields, a tab separates them on the line
16+(a title never contains a tab). Fields are empty when no window has the focus.
17+
18+fields:
19+ id the application id, such as com.github.bnema.dumber
20+ name the short form of the id, such as dumber
21+ title the window title, without control characters
22+
23+flags:
24+`
25+
26+// runApp is "neferbar app <field>": the focused window, for scripts.
27+func runApp(ctx context.Context, args []string, display string) error {
28+ fs := flag.NewFlagSet("neferbar app", flag.ContinueOnError)
29+ once := fs.Bool("once", false, "print the current value and exit")
30+ fs.Usage = func() { fmt.Fprint(os.Stderr, appUsage); fs.PrintDefaults() }
31+ if err := fs.Parse(args); err != nil {
32+ return err
33+ }
34+ if fs.NArg() == 0 {
35+ fs.Usage()
36+ return fmt.Errorf("give at least one field: id, name or title")
37+ }
38+ var picks []func(app.Window) string
39+ for _, field := range fs.Args() {
40+ switch field {
41+ case "id":
42+ picks = append(picks, func(w app.Window) string { return app.Clean(w.ID) })
43+ case "name":
44+ picks = append(picks, func(w app.Window) string { return app.Clean(app.Name(w.ID)) })
45+ case "title":
46+ picks = append(picks, func(w app.Window) string { return app.Clean(w.Title) })
47+ default:
48+ return fmt.Errorf("%q is not a field; use id, name or title", field)
49+ }
50+ }
51+ ctx, cancel := context.WithCancel(ctx)
52+ defer cancel()
53+ return app.Watch(ctx, display, func(w app.Window) error {
54+ var line []byte
55+ for i, pick := range picks {
56+ if i > 0 {
57+ line = append(line, '\t')
58+ }
59+ line = append(line, pick(w)...)
60+ }
61+ if _, err := os.Stdout.Write(append(line, '\n')); err != nil {
62+ return err
63+ }
64+ if *once {
65+ cancel()
66+ }
67+ return nil
68+ })
69+}
M cmd/neferbar/main.go +11 -0
@@ -3,6 +3,7 @@ package main
33
44 import (
55 "context"
6+ "errors"
67 "flag"
78 "fmt"
89 "log/slog"
@@ -20,6 +21,16 @@ import (
2021 )
2122
2223 func main() {
24+ if len(os.Args) > 1 && os.Args[1] == "app" {
25+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
26+ err := runApp(ctx, os.Args[2:], os.Getenv("NEFERBAR_DISPLAY"))
27+ stop()
28+ if err != nil && !errors.Is(err, flag.ErrHelp) {
29+ fmt.Fprintf(os.Stderr, "neferbar app: %v\n", err)
30+ os.Exit(1)
31+ }
32+ return
33+ }
2334 cfgPath := flag.String("config", "", "config file (default: $XDG_CONFIG_HOME/neferbar/config.toml)")
2435 display := flag.String("display", os.Getenv("NEFERBAR_DISPLAY"), "Wayland socket name or absolute path (default: $NEFERBAR_DISPLAY, then $WAYLAND_DISPLAY)")
2536 pprofAddr := flag.String("pprof", "", "serve pprof on this loopback address, e.g. localhost:6060")
@@ -3,6 +3,7 @@ package main
3 3
4 import (4 import (
5 "context"5 "context"
6+ "errors"
6 "flag"7 "flag"
7 "fmt"8 "fmt"
8 "log/slog"9 "log/slog"
@@ -20,6 +21,16 @@ import (
20 )21 )
21 22
22 func main() {23 func main() {
24+ if len(os.Args) > 1 && os.Args[1] == "app" {
25+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
26+ err := runApp(ctx, os.Args[2:], os.Getenv("NEFERBAR_DISPLAY"))
27+ stop()
28+ if err != nil && !errors.Is(err, flag.ErrHelp) {
29+ fmt.Fprintf(os.Stderr, "neferbar app: %v\n", err)
30+ os.Exit(1)
31+ }
32+ return
33+ }
23 cfgPath := flag.String("config", "", "config file (default: $XDG_CONFIG_HOME/neferbar/config.toml)")34 cfgPath := flag.String("config", "", "config file (default: $XDG_CONFIG_HOME/neferbar/config.toml)")
24 display := flag.String("display", os.Getenv("NEFERBAR_DISPLAY"), "Wayland socket name or absolute path (default: $NEFERBAR_DISPLAY, then $WAYLAND_DISPLAY)")35 display := flag.String("display", os.Getenv("NEFERBAR_DISPLAY"), "Wayland socket name or absolute path (default: $NEFERBAR_DISPLAY, then $WAYLAND_DISPLAY)")
25 pprofAddr := flag.String("pprof", "", "serve pprof on this loopback address, e.g. localhost:6060")36 pprofAddr := flag.String("pprof", "", "serve pprof on this loopback address, e.g. localhost:6060")
M examples/bundle/center.sh +14 -17
@@ -1,23 +1,20 @@
11 #!/bin/bash
2-# Center: the focused window. NeferWL's state file has the app id but no title;
3-# a title needs the compositor's toplevel protocol, which the bar does not read yet.
2+# Center: the focused window, as "icon name title". "neferbar app" asks the
3+# compositor and prints one line each time the focus or a title changes, so this
4+# script sleeps until something happens.
45 . "$(dirname "$0")/lib.sh"
56
6-[ -r "${NEFERWL_STATE:-}" ] || {
7- echo
8- exec sleep infinity
9-}
7+MAX_TITLE=${NEFERBAR_TITLE_MAX:-60} # cells; a longer title ends with an ellipsis
108
11-draw() {
12- local app
13- # An app id is chosen by the application: drop control characters, which
14- # would otherwise reach the bar as escape sequences.
15- app=$(jq -r '(.window.app_id // empty) | explode | map(select(. >= 32 and . != 127)) | implode' "$NEFERWL_STATE" 2>/dev/null) || return
16- if [ -n "$app" ]; then
17- printf '%s%s%s %s%s%s\f' "$(bg "$BAR")" "$(fg "$NEFERBAR_COLOR3")" "$ICON_APP" "$(fg "$NEFERBAR_FOREGROUND")" "$app" "$RESET"
18- else
9+# $NEFERBAR_BIN is the neferbar that started this script. A line ends the bar
10+# when the compositor goes away; the bar then restarts the script.
11+"$NEFERBAR_BIN" app name title | while IFS=$'\t' read -r name title; do
12+ if [ -z "$name$title" ]; then
1913 printf '\f'
14+ continue
2015 fi
21-}
22-
23-state_changed_loop draw
16+ ((${#title} > MAX_TITLE)) && title="${title:0:MAX_TITLE-1}…"
17+ printf '%s%s%s %s%s%s' "$(bg "$BAR")" "$(fg "$NEFERBAR_COLOR3")" "$ICON_APP" "$(fg "$NEFERBAR_FOREGROUND")" "$name" "$RESET"
18+ [ -n "$title" ] && printf ' %s%s%s' "$(fg "$DIM")" "$title" "$RESET"
19+ printf '\f'
20+done
@@ -1,23 +1,20 @@
1 #!/bin/bash1 #!/bin/bash
2-# Center: the focused window. NeferWL's state file has the app id but no title;2+# Center: the focused window, as "icon name title". "neferbar app" asks the
3-# a title needs the compositor's toplevel protocol, which the bar does not read yet.3+# compositor and prints one line each time the focus or a title changes, so this
4+# script sleeps until something happens.
4 . "$(dirname "$0")/lib.sh"5 . "$(dirname "$0")/lib.sh"
5 6
6-[ -r "${NEFERWL_STATE:-}" ] || {7+MAX_TITLE=${NEFERBAR_TITLE_MAX:-60} # cells; a longer title ends with an ellipsis
7- echo
8- exec sleep infinity
9-}
10 8
11-draw() {9+# $NEFERBAR_BIN is the neferbar that started this script. A line ends the bar
12- local app10+# when the compositor goes away; the bar then restarts the script.
13- # An app id is chosen by the application: drop control characters, which11+"$NEFERBAR_BIN" app name title | while IFS=$'\t' read -r name title; do
14- # would otherwise reach the bar as escape sequences.12+ if [ -z "$name$title" ]; then
15- app=$(jq -r '(.window.app_id // empty) | explode | map(select(. >= 32 and . != 127)) | implode' "$NEFERWL_STATE" 2>/dev/null) || return
16- if [ -n "$app" ]; then
17- printf '%s%s%s %s%s%s\f' "$(bg "$BAR")" "$(fg "$NEFERBAR_COLOR3")" "$ICON_APP" "$(fg "$NEFERBAR_FOREGROUND")" "$app" "$RESET"
18- else
19 printf '\f'13 printf '\f'
14+ continue
20 fi15 fi
21-}16+ ((${#title} > MAX_TITLE)) && title="${title:0:MAX_TITLE-1}…"
22-17+ printf '%s%s%s %s%s%s' "$(bg "$BAR")" "$(fg "$NEFERBAR_COLOR3")" "$ICON_APP" "$(fg "$NEFERBAR_FOREGROUND")" "$name" "$RESET"
23-state_changed_loop draw18+ [ -n "$title" ] && printf ' %s%s%s' "$(fg "$DIM")" "$title" "$RESET"
19+ printf '\f'
20+done
M go.mod +2 -2
@@ -4,17 +4,17 @@ go 1.27
44
55 require (
66 github.com/BurntSushi/toml v1.6.0
7+ github.com/bnema/go-wayland-bindings v0.1.0
78 github.com/bnema/neferclient v0.3.0
89 github.com/bnema/purego-vulkan v0.6.0
910 github.com/bnema/vev-vt v0.6.0
11+ github.com/bnema/wlturbo v0.6.2
1012 golang.org/x/image v0.46.0
1113 golang.org/x/sys v0.48.0
1214 )
1315
1416 require (
15- github.com/bnema/go-wayland-bindings v0.1.0 // indirect
1617 github.com/bnema/purego v0.13.0-bnema.1 // indirect
1718 github.com/bnema/purego-xkbcommon v0.2.0 // indirect
18- github.com/bnema/wlturbo v0.6.2 // indirect
1919 golang.org/x/text v0.42.0 // indirect
2020 )
@@ -4,17 +4,17 @@ go 1.27
4 4
5 require (5 require (
6 github.com/BurntSushi/toml v1.6.06 github.com/BurntSushi/toml v1.6.0
7+ github.com/bnema/go-wayland-bindings v0.1.0
7 github.com/bnema/neferclient v0.3.08 github.com/bnema/neferclient v0.3.0
8 github.com/bnema/purego-vulkan v0.6.09 github.com/bnema/purego-vulkan v0.6.0
9 github.com/bnema/vev-vt v0.6.010 github.com/bnema/vev-vt v0.6.0
11+ github.com/bnema/wlturbo v0.6.2
10 golang.org/x/image v0.46.012 golang.org/x/image v0.46.0
11 golang.org/x/sys v0.48.013 golang.org/x/sys v0.48.0
12 )14 )
13 15
14 require (16 require (
15- github.com/bnema/go-wayland-bindings v0.1.0 // indirect
16 github.com/bnema/purego v0.13.0-bnema.1 // indirect17 github.com/bnema/purego v0.13.0-bnema.1 // indirect
17 github.com/bnema/purego-xkbcommon v0.2.0 // indirect18 github.com/bnema/purego-xkbcommon v0.2.0 // indirect
18- github.com/bnema/wlturbo v0.6.2 // indirect
19 golang.org/x/text v0.42.0 // indirect19 golang.org/x/text v0.42.0 // indirect
20 )20 )
A internal/app/app.go +213 -0
@@ -0,0 +1,213 @@
1+// Package app follows the window that has the keyboard focus, using the
2+// wlr-foreign-toplevel-management protocol that NeferWL, Sway, Hyprland, River
3+// and Niri speak.
4+package app
5+
6+import (
7+ "context"
8+ "errors"
9+ "fmt"
10+ "strings"
11+ "sync"
12+ "unicode"
13+
14+ wlr "github.com/bnema/go-wayland-bindings/client/wlrforeigntoplevelmanagement"
15+ "github.com/bnema/neferclient"
16+ wl "github.com/bnema/wlturbo"
17+)
18+
19+// Window is what the bar shows about the focused window. The zero Window means
20+// that nothing has the focus.
21+type Window struct {
22+ ID string // the application id, such as "org.mozilla.firefox"
23+ Title string
24+}
25+
26+// stateActivated is the protocol's "this window has the focus" state.
27+const stateActivated = 2
28+
29+// Tracker keeps what the compositor said about each window and answers which
30+// one has the focus. A window's properties apply together when its Done comes,
31+// as the protocol says, so Focused never shows half an update.
32+//
33+// The connection's reader goroutine calls the recording methods while another
34+// goroutine calls Focused, so a mutex guards the state.
35+type Tracker struct {
36+ mu sync.Mutex
37+ wins map[uint32]*window
38+ seq uint64
39+ // Changed has a signal waiting whenever Done or Closed may have changed the
40+ // focus. Capacity 1: a missed signal is harmless, as Focused is read after.
41+ Changed chan struct{}
42+}
43+
44+type window struct {
45+ pending, shown Window
46+ pendingActive bool
47+ active bool
48+ since uint64 // when it last became active: the newest wins
49+}
50+
51+// NewTracker returns an empty tracker.
52+func NewTracker() *Tracker {
53+ return &Tracker{wins: map[uint32]*window{}, Changed: make(chan struct{}, 1)}
54+}
55+
56+func (t *Tracker) signal() {
57+ select {
58+ case t.Changed <- struct{}{}:
59+ default:
60+ }
61+}
62+
63+func (t *Tracker) get(h uint32) *window {
64+ w := t.wins[h]
65+ if w == nil {
66+ w = &window{}
67+ t.wins[h] = w
68+ }
69+ return w
70+}
71+
72+// Title, ID and State record an update that applies at the next Done.
73+func (t *Tracker) Title(h uint32, s string) {
74+ t.mu.Lock()
75+ defer t.mu.Unlock()
76+ t.get(h).pending.Title = s
77+}
78+
79+func (t *Tracker) ID(h uint32, s string) {
80+ t.mu.Lock()
81+ defer t.mu.Unlock()
82+ t.get(h).pending.ID = s
83+}
84+
85+func (t *Tracker) State(h uint32, state []byte) {
86+ t.mu.Lock()
87+ defer t.mu.Unlock()
88+ t.get(h).pendingActive = hasState(state, stateActivated)
89+}
90+
91+// Done applies the updates recorded for window h.
92+func (t *Tracker) Done(h uint32) {
93+ defer t.signal()
94+ t.mu.Lock()
95+ defer t.mu.Unlock()
96+ w := t.get(h)
97+ w.shown = w.pending
98+ if w.pendingActive && !w.active {
99+ t.seq++
100+ w.since = t.seq
101+ }
102+ w.active = w.pendingActive
103+}
104+
105+// Closed forgets window h.
106+func (t *Tracker) Closed(h uint32) {
107+ defer t.signal()
108+ t.mu.Lock()
109+ defer t.mu.Unlock()
110+ delete(t.wins, h)
111+}
112+
113+// Focused returns the window that has the focus; when several do (one per
114+// seat or output), the one that got it last.
115+func (t *Tracker) Focused() Window {
116+ t.mu.Lock()
117+ defer t.mu.Unlock()
118+ var best *window
119+ for _, w := range t.wins {
120+ if w.active && (best == nil || w.since > best.since) {
121+ best = w
122+ }
123+ }
124+ if best == nil {
125+ return Window{}
126+ }
127+ return best.shown
128+}
129+
130+// hasState reports whether a state array (native-endian uint32 values) has s.
131+func hasState(state []byte, s uint32) bool {
132+ for i := 0; i+4 <= len(state); i += 4 {
133+ v := uint32(state[i]) | uint32(state[i+1])<<8 | uint32(state[i+2])<<16 | uint32(state[i+3])<<24
134+ if v == s {
135+ return true
136+ }
137+ }
138+ return false
139+}
140+
141+// Name is the short form of an application id: the part after the last dot,
142+// so "com.github.bnema.dumber" is "dumber". An id without a dot is its own name.
143+func Name(id string) string {
144+ if i := strings.LastIndexByte(id, '.'); i >= 0 && i < len(id)-1 {
145+ return id[i+1:]
146+ }
147+ return id
148+}
149+
150+// Clean removes control characters. An application chooses its own title, and
151+// a title must never be able to move the cursor or end a line in a terminal.
152+func Clean(s string) string {
153+ return strings.Map(func(r rune) rune {
154+ if unicode.IsControl(r) {
155+ return -1
156+ }
157+ return r
158+ }, s)
159+}
160+
161+// Watch calls emit with the focused window once, and again each time it
162+// changes, until ctx ends or the connection fails. display is the Wayland
163+// socket; empty uses $WAYLAND_DISPLAY.
164+func Watch(ctx context.Context, display string, emit func(Window) error) error {
165+ conn, err := neferclient.Connect(ctx, display)
166+ if err != nil {
167+ return fmt.Errorf("cannot connect to the compositor: %w", err)
168+ }
169+ defer conn.Close()
170+
171+ t := NewTracker()
172+ _, _, err = conn.Bind("zwlr_foreign_toplevel_manager_v1", 3, func(c *wl.Context) *wlr.ForeignToplevelManager {
173+ m := wlr.NewForeignToplevelManager(c)
174+ m.OnToplevel(func(h *wlr.ForeignToplevelHandle) {
175+ id := h.ID()
176+ h.OnTitle(func(s string) { t.Title(id, s) })
177+ h.OnAppId(func(s string) { t.ID(id, s) })
178+ h.OnState(func(b []byte) { t.State(id, b) })
179+ h.OnDone(func() { t.Done(id) })
180+ h.OnClosed(func() { t.Closed(id); _ = h.Destroy() })
181+ })
182+ return m
183+ })
184+ if errors.Is(err, neferclient.ErrGlobalNotFound) {
185+ return errors.New("this compositor does not offer zwlr_foreign_toplevel_manager_v1, so the focused window is unknown")
186+ }
187+ if err != nil {
188+ return fmt.Errorf("cannot ask the compositor for its windows: %w", err)
189+ }
190+ // The windows that exist now arrive after one round trip.
191+ if err = conn.Roundtrip(); err != nil {
192+ return err
193+ }
194+ var last Window
195+ first := true
196+ for {
197+ if err = conn.Dispatch(nil); err != nil {
198+ return fmt.Errorf("lost the connection to the compositor: %w", err)
199+ }
200+ if now := t.Focused(); first || now != last {
201+ first, last = false, now
202+ if err = emit(now); err != nil {
203+ return err
204+ }
205+ }
206+ select {
207+ case <-ctx.Done():
208+ return nil
209+ case <-conn.Wake(): // the connection's own events: drain them
210+ case <-t.Changed: // the compositor changed a window
211+ }
212+ }
213+}
@@ -0,0 +1,213 @@
1+// Package app follows the window that has the keyboard focus, using the
2+// wlr-foreign-toplevel-management protocol that NeferWL, Sway, Hyprland, River
3+// and Niri speak.
4+package app
5+
6+import (
7+ "context"
8+ "errors"
9+ "fmt"
10+ "strings"
11+ "sync"
12+ "unicode"
13+
14+ wlr "github.com/bnema/go-wayland-bindings/client/wlrforeigntoplevelmanagement"
15+ "github.com/bnema/neferclient"
16+ wl "github.com/bnema/wlturbo"
17+)
18+
19+// Window is what the bar shows about the focused window. The zero Window means
20+// that nothing has the focus.
21+type Window struct {
22+ ID string // the application id, such as "org.mozilla.firefox"
23+ Title string
24+}
25+
26+// stateActivated is the protocol's "this window has the focus" state.
27+const stateActivated = 2
28+
29+// Tracker keeps what the compositor said about each window and answers which
30+// one has the focus. A window's properties apply together when its Done comes,
31+// as the protocol says, so Focused never shows half an update.
32+//
33+// The connection's reader goroutine calls the recording methods while another
34+// goroutine calls Focused, so a mutex guards the state.
35+type Tracker struct {
36+ mu sync.Mutex
37+ wins map[uint32]*window
38+ seq uint64
39+ // Changed has a signal waiting whenever Done or Closed may have changed the
40+ // focus. Capacity 1: a missed signal is harmless, as Focused is read after.
41+ Changed chan struct{}
42+}
43+
44+type window struct {
45+ pending, shown Window
46+ pendingActive bool
47+ active bool
48+ since uint64 // when it last became active: the newest wins
49+}
50+
51+// NewTracker returns an empty tracker.
52+func NewTracker() *Tracker {
53+ return &Tracker{wins: map[uint32]*window{}, Changed: make(chan struct{}, 1)}
54+}
55+
56+func (t *Tracker) signal() {
57+ select {
58+ case t.Changed <- struct{}{}:
59+ default:
60+ }
61+}
62+
63+func (t *Tracker) get(h uint32) *window {
64+ w := t.wins[h]
65+ if w == nil {
66+ w = &window{}
67+ t.wins[h] = w
68+ }
69+ return w
70+}
71+
72+// Title, ID and State record an update that applies at the next Done.
73+func (t *Tracker) Title(h uint32, s string) {
74+ t.mu.Lock()
75+ defer t.mu.Unlock()
76+ t.get(h).pending.Title = s
77+}
78+
79+func (t *Tracker) ID(h uint32, s string) {
80+ t.mu.Lock()
81+ defer t.mu.Unlock()
82+ t.get(h).pending.ID = s
83+}
84+
85+func (t *Tracker) State(h uint32, state []byte) {
86+ t.mu.Lock()
87+ defer t.mu.Unlock()
88+ t.get(h).pendingActive = hasState(state, stateActivated)
89+}
90+
91+// Done applies the updates recorded for window h.
92+func (t *Tracker) Done(h uint32) {
93+ defer t.signal()
94+ t.mu.Lock()
95+ defer t.mu.Unlock()
96+ w := t.get(h)
97+ w.shown = w.pending
98+ if w.pendingActive && !w.active {
99+ t.seq++
100+ w.since = t.seq
101+ }
102+ w.active = w.pendingActive
103+}
104+
105+// Closed forgets window h.
106+func (t *Tracker) Closed(h uint32) {
107+ defer t.signal()
108+ t.mu.Lock()
109+ defer t.mu.Unlock()
110+ delete(t.wins, h)
111+}
112+
113+// Focused returns the window that has the focus; when several do (one per
114+// seat or output), the one that got it last.
115+func (t *Tracker) Focused() Window {
116+ t.mu.Lock()
117+ defer t.mu.Unlock()
118+ var best *window
119+ for _, w := range t.wins {
120+ if w.active && (best == nil || w.since > best.since) {
121+ best = w
122+ }
123+ }
124+ if best == nil {
125+ return Window{}
126+ }
127+ return best.shown
128+}
129+
130+// hasState reports whether a state array (native-endian uint32 values) has s.
131+func hasState(state []byte, s uint32) bool {
132+ for i := 0; i+4 <= len(state); i += 4 {
133+ v := uint32(state[i]) | uint32(state[i+1])<<8 | uint32(state[i+2])<<16 | uint32(state[i+3])<<24
134+ if v == s {
135+ return true
136+ }
137+ }
138+ return false
139+}
140+
141+// Name is the short form of an application id: the part after the last dot,
142+// so "com.github.bnema.dumber" is "dumber". An id without a dot is its own name.
143+func Name(id string) string {
144+ if i := strings.LastIndexByte(id, '.'); i >= 0 && i < len(id)-1 {
145+ return id[i+1:]
146+ }
147+ return id
148+}
149+
150+// Clean removes control characters. An application chooses its own title, and
151+// a title must never be able to move the cursor or end a line in a terminal.
152+func Clean(s string) string {
153+ return strings.Map(func(r rune) rune {
154+ if unicode.IsControl(r) {
155+ return -1
156+ }
157+ return r
158+ }, s)
159+}
160+
161+// Watch calls emit with the focused window once, and again each time it
162+// changes, until ctx ends or the connection fails. display is the Wayland
163+// socket; empty uses $WAYLAND_DISPLAY.
164+func Watch(ctx context.Context, display string, emit func(Window) error) error {
165+ conn, err := neferclient.Connect(ctx, display)
166+ if err != nil {
167+ return fmt.Errorf("cannot connect to the compositor: %w", err)
168+ }
169+ defer conn.Close()
170+
171+ t := NewTracker()
172+ _, _, err = conn.Bind("zwlr_foreign_toplevel_manager_v1", 3, func(c *wl.Context) *wlr.ForeignToplevelManager {
173+ m := wlr.NewForeignToplevelManager(c)
174+ m.OnToplevel(func(h *wlr.ForeignToplevelHandle) {
175+ id := h.ID()
176+ h.OnTitle(func(s string) { t.Title(id, s) })
177+ h.OnAppId(func(s string) { t.ID(id, s) })
178+ h.OnState(func(b []byte) { t.State(id, b) })
179+ h.OnDone(func() { t.Done(id) })
180+ h.OnClosed(func() { t.Closed(id); _ = h.Destroy() })
181+ })
182+ return m
183+ })
184+ if errors.Is(err, neferclient.ErrGlobalNotFound) {
185+ return errors.New("this compositor does not offer zwlr_foreign_toplevel_manager_v1, so the focused window is unknown")
186+ }
187+ if err != nil {
188+ return fmt.Errorf("cannot ask the compositor for its windows: %w", err)
189+ }
190+ // The windows that exist now arrive after one round trip.
191+ if err = conn.Roundtrip(); err != nil {
192+ return err
193+ }
194+ var last Window
195+ first := true
196+ for {
197+ if err = conn.Dispatch(nil); err != nil {
198+ return fmt.Errorf("lost the connection to the compositor: %w", err)
199+ }
200+ if now := t.Focused(); first || now != last {
201+ first, last = false, now
202+ if err = emit(now); err != nil {
203+ return err
204+ }
205+ }
206+ select {
207+ case <-ctx.Done():
208+ return nil
209+ case <-conn.Wake(): // the connection's own events: drain them
210+ case <-t.Changed: // the compositor changed a window
211+ }
212+ }
213+}
A internal/app/app_test.go +104 -0
@@ -0,0 +1,104 @@
1+package app
2+
3+import (
4+ "encoding/binary"
5+ "testing"
6+)
7+
8+func state(vals ...uint32) []byte {
9+ var b []byte
10+ for _, v := range vals {
11+ b = binary.NativeEndian.AppendUint32(b, v)
12+ }
13+ return b
14+}
15+
16+// open maps a window: its properties and its state, then the done that applies them.
17+func open(t *Tracker, h uint32, id, title string, st ...uint32) {
18+ t.ID(h, id)
19+ t.Title(h, title)
20+ t.State(h, state(st...))
21+ t.Done(h)
22+}
23+
24+func TestNothingFocusedIsTheZeroWindow(t *testing.T) {
25+ tr := NewTracker()
26+ if tr.Focused() != (Window{}) {
27+ t.Fatal("an empty tracker must have no focus")
28+ }
29+ open(tr, 1, "a", "A") // mapped, not activated
30+ if tr.Focused() != (Window{}) {
31+ t.Fatalf("a window without the activated state has no focus: %+v", tr.Focused())
32+ }
33+}
34+
35+func TestFocusFollowsTheActivatedState(t *testing.T) {
36+ tr := NewTracker()
37+ open(tr, 1, "org.mozilla.firefox", "Docs", 0, stateActivated) // maximized + activated
38+ open(tr, 2, "kitty", "~")
39+ if got := tr.Focused(); got != (Window{"org.mozilla.firefox", "Docs"}) {
40+ t.Fatalf("focused = %+v", got)
41+ }
42+ // Focus moves: 1 loses it, 2 gets it.
43+ open(tr, 1, "org.mozilla.firefox", "Docs", 0)
44+ open(tr, 2, "kitty", "~", stateActivated)
45+ if got := tr.Focused(); got != (Window{"kitty", "~"}) {
46+ t.Fatalf("after the focus moved: %+v", got)
47+ }
48+}
49+
50+func TestATitleChangeAppliesOnlyAtDone(t *testing.T) {
51+ tr := NewTracker()
52+ open(tr, 1, "kitty", "one", stateActivated)
53+ tr.Title(1, "two")
54+ if got := tr.Focused().Title; got != "one" {
55+ t.Fatalf("title changed before done: %q", got)
56+ }
57+ tr.Done(1)
58+ if got := tr.Focused().Title; got != "two" {
59+ t.Fatalf("title after done: %q", got)
60+ }
61+}
62+
63+func TestClosingTheFocusedWindowClearsTheFocus(t *testing.T) {
64+ tr := NewTracker()
65+ open(tr, 1, "kitty", "~", stateActivated)
66+ tr.Closed(1)
67+ if tr.Focused() != (Window{}) {
68+ t.Fatalf("a closed window still has the focus: %+v", tr.Focused())
69+ }
70+}
71+
72+func TestTheWindowThatGotTheFocusLastWins(t *testing.T) {
73+ tr := NewTracker()
74+ open(tr, 1, "a", "A", stateActivated)
75+ open(tr, 2, "b", "B", stateActivated) // two seats, two focused windows
76+ if got := tr.Focused().ID; got != "b" {
77+ t.Fatalf("focused = %q, want b (the newest)", got)
78+ }
79+ open(tr, 1, "a", "A2", stateActivated) // still active, only its title changed
80+ if got := tr.Focused().ID; got != "b" {
81+ t.Fatalf("a title change must not steal the focus: %q", got)
82+ }
83+}
84+
85+func TestCleanRemovesControlCharacters(t *testing.T) {
86+ in := "fire\x1b[2Jfox\n\ttab\x07 ok \u00e9"
87+ if got, want := Clean(in), "fire[2Jfoxtab ok \u00e9"; got != want {
88+ t.Fatalf("Clean = %q, want %q", got, want)
89+ }
90+}
91+
92+func TestNameIsTheLastPartOfTheID(t *testing.T) {
93+ for id, want := range map[string]string{
94+ "com.github.bnema.dumber": "dumber",
95+ "org.mozilla.firefox": "firefox",
96+ "kitty": "kitty",
97+ "": "",
98+ "trailing.": "trailing.",
99+ } {
100+ if got := Name(id); got != want {
101+ t.Errorf("Name(%q) = %q, want %q", id, got, want)
102+ }
103+ }
104+}
@@ -0,0 +1,104 @@
1+package app
2+
3+import (
4+ "encoding/binary"
5+ "testing"
6+)
7+
8+func state(vals ...uint32) []byte {
9+ var b []byte
10+ for _, v := range vals {
11+ b = binary.NativeEndian.AppendUint32(b, v)
12+ }
13+ return b
14+}
15+
16+// open maps a window: its properties and its state, then the done that applies them.
17+func open(t *Tracker, h uint32, id, title string, st ...uint32) {
18+ t.ID(h, id)
19+ t.Title(h, title)
20+ t.State(h, state(st...))
21+ t.Done(h)
22+}
23+
24+func TestNothingFocusedIsTheZeroWindow(t *testing.T) {
25+ tr := NewTracker()
26+ if tr.Focused() != (Window{}) {
27+ t.Fatal("an empty tracker must have no focus")
28+ }
29+ open(tr, 1, "a", "A") // mapped, not activated
30+ if tr.Focused() != (Window{}) {
31+ t.Fatalf("a window without the activated state has no focus: %+v", tr.Focused())
32+ }
33+}
34+
35+func TestFocusFollowsTheActivatedState(t *testing.T) {
36+ tr := NewTracker()
37+ open(tr, 1, "org.mozilla.firefox", "Docs", 0, stateActivated) // maximized + activated
38+ open(tr, 2, "kitty", "~")
39+ if got := tr.Focused(); got != (Window{"org.mozilla.firefox", "Docs"}) {
40+ t.Fatalf("focused = %+v", got)
41+ }
42+ // Focus moves: 1 loses it, 2 gets it.
43+ open(tr, 1, "org.mozilla.firefox", "Docs", 0)
44+ open(tr, 2, "kitty", "~", stateActivated)
45+ if got := tr.Focused(); got != (Window{"kitty", "~"}) {
46+ t.Fatalf("after the focus moved: %+v", got)
47+ }
48+}
49+
50+func TestATitleChangeAppliesOnlyAtDone(t *testing.T) {
51+ tr := NewTracker()
52+ open(tr, 1, "kitty", "one", stateActivated)
53+ tr.Title(1, "two")
54+ if got := tr.Focused().Title; got != "one" {
55+ t.Fatalf("title changed before done: %q", got)
56+ }
57+ tr.Done(1)
58+ if got := tr.Focused().Title; got != "two" {
59+ t.Fatalf("title after done: %q", got)
60+ }
61+}
62+
63+func TestClosingTheFocusedWindowClearsTheFocus(t *testing.T) {
64+ tr := NewTracker()
65+ open(tr, 1, "kitty", "~", stateActivated)
66+ tr.Closed(1)
67+ if tr.Focused() != (Window{}) {
68+ t.Fatalf("a closed window still has the focus: %+v", tr.Focused())
69+ }
70+}
71+
72+func TestTheWindowThatGotTheFocusLastWins(t *testing.T) {
73+ tr := NewTracker()
74+ open(tr, 1, "a", "A", stateActivated)
75+ open(tr, 2, "b", "B", stateActivated) // two seats, two focused windows
76+ if got := tr.Focused().ID; got != "b" {
77+ t.Fatalf("focused = %q, want b (the newest)", got)
78+ }
79+ open(tr, 1, "a", "A2", stateActivated) // still active, only its title changed
80+ if got := tr.Focused().ID; got != "b" {
81+ t.Fatalf("a title change must not steal the focus: %q", got)
82+ }
83+}
84+
85+func TestCleanRemovesControlCharacters(t *testing.T) {
86+ in := "fire\x1b[2Jfox\n\ttab\x07 ok \u00e9"
87+ if got, want := Clean(in), "fire[2Jfoxtab ok \u00e9"; got != want {
88+ t.Fatalf("Clean = %q, want %q", got, want)
89+ }
90+}
91+
92+func TestNameIsTheLastPartOfTheID(t *testing.T) {
93+ for id, want := range map[string]string{
94+ "com.github.bnema.dumber": "dumber",
95+ "org.mozilla.firefox": "firefox",
96+ "kitty": "kitty",
97+ "": "",
98+ "trailing.": "trailing.",
99+ } {
100+ if got := Name(id); got != want {
101+ t.Errorf("Name(%q) = %q, want %q", id, got, want)
102+ }
103+ }
104+}
M internal/module/module.go +5 -0
@@ -151,6 +151,11 @@ func (m *Module) runOnce(ctx context.Context) error {
151151 cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) }
152152 cmd.WaitDelay = 2 * time.Second
153153 cmd.Env = append(append(os.Environ(), m.Env...), "NEFERBAR_MODULE="+m.Name)
154+ // Scripts call "$NEFERBAR_BIN app title" and friends: the path of the very
155+ // binary that started them, which need not be in $PATH.
156+ if exe, err := os.Executable(); err == nil {
157+ cmd.Env = append(cmd.Env, "NEFERBAR_BIN="+exe)
158+ }
154159 out, err := cmd.StdoutPipe()
155160 if err != nil {
156161 return err
@@ -151,6 +151,11 @@ func (m *Module) runOnce(ctx context.Context) error {
151 cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) }151 cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) }
152 cmd.WaitDelay = 2 * time.Second152 cmd.WaitDelay = 2 * time.Second
153 cmd.Env = append(append(os.Environ(), m.Env...), "NEFERBAR_MODULE="+m.Name)153 cmd.Env = append(append(os.Environ(), m.Env...), "NEFERBAR_MODULE="+m.Name)
154+ // Scripts call "$NEFERBAR_BIN app title" and friends: the path of the very
155+ // binary that started them, which need not be in $PATH.
156+ if exe, err := os.Executable(); err == nil {
157+ cmd.Env = append(cmd.Env, "NEFERBAR_BIN="+exe)
158+ }
154 out, err := cmd.StdoutPipe()159 out, err := cmd.StdoutPipe()
155 if err != nil {160 if err != nil {
156 return err161 return err

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 5

Tags 4

↑↓ moveenter openesc close