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.
4package app
5
6import (
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.
21type 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.
27const 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.
35type 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
44type 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.
52func NewTracker() *Tracker {
53 return &Tracker{wins: map[uint32]*window{}, Changed: make(chan struct{}, 1)}
54}
55
56func (t *Tracker) signal() {
57 select {
58 case t.Changed <- struct{}{}:
59 default:
60 }
61}
62
63func (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.
73func (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
79func (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
85func (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.
92func (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.
106func (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.
115func (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.
131func 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.
143func 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.
152func 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.
164func 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}