feat/focused-dot 6314afc

app.go RawHistoryFolder

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}

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