detached 07694a3

merge: state-file (session state file, nefertty state output-of)

commit
07694a30865daf054e7274fcfec90195379b8652
author
brice · · 2026-09-25 05:28 UTC
merge of
e318725 + fcccf22 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +716 -14
M .mockery.yml +7 -0
@@ -30,3 +30,10 @@ packages:
3030 dir: internal/adapters/wayland
3131 pkgname: wayland
3232 filename: proc_env_mock_test.go
33+ github.com/bnema/nefertty/internal/adapters/statefile:
34+ interfaces:
35+ procTree:
36+ config:
37+ dir: internal/adapters/statefile
38+ pkgname: statefile
39+ filename: proc_tree_mock_test.go
@@ -30,3 +30,10 @@ packages:
30 dir: internal/adapters/wayland30 dir: internal/adapters/wayland
31 pkgname: wayland31 pkgname: wayland
32 filename: proc_env_mock_test.go32 filename: proc_env_mock_test.go
33+ github.com/bnema/nefertty/internal/adapters/statefile:
34+ interfaces:
35+ procTree:
36+ config:
37+ dir: internal/adapters/statefile
38+ pkgname: statefile
39+ filename: proc_tree_mock_test.go
M README.md +11 -0
@@ -25,6 +25,17 @@ See `examples/config` for every key and its default. A missing file means defaul
2525
2626 Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published.
2727
28+## State for scripts
29+
30+While it runs, NeferTTY keeps its state in `$XDG_RUNTIME_DIR/nefertty/<wayland socket>.json` and passes that path to the programs it starts as `NEFERTTY_STATE`. The file lists every output (active numbered workspace, workspace count, name of the workspace on screen), the focused output and window, and every window with its app ID, PID, output and workspace.
31+
32+```sh
33+nefertty state # the whole state as JSON
34+nefertty state output-of "$PID" # the output of that process's window, or its nearest parent's
35+```
36+
37+`output-of` finds application windows only; bars have no single output.
38+
2839 ## Try it (headless)
2940
3041 Run `nefertty --backend=headless --screenshot /tmp/nefertty-shots`, then connect with
@@ -25,6 +25,17 @@ See `examples/config` for every key and its default. A missing file means defaul
25 25
26 Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published.26 Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published.
27 27
28+## State for scripts
29+
30+While it runs, NeferTTY keeps its state in `$XDG_RUNTIME_DIR/nefertty/<wayland socket>.json` and passes that path to the programs it starts as `NEFERTTY_STATE`. The file lists every output (active numbered workspace, workspace count, name of the workspace on screen), the focused output and window, and every window with its app ID, PID, output and workspace.
31+
32+```sh
33+nefertty state # the whole state as JSON
34+nefertty state output-of "$PID" # the output of that process's window, or its nearest parent's
35+```
36+
37+`output-of` finds application windows only; bars have no single output.
38+
28 ## Try it (headless)39 ## Try it (headless)
29 40
30 Run `nefertty --backend=headless --screenshot /tmp/nefertty-shots`, then connect with41 Run `nefertty --backend=headless --screenshot /tmp/nefertty-shots`, then connect with
M cmd/nefertty/main.go +56 -0
@@ -2,17 +2,20 @@ package main
22
33 import (
44 "context"
5+ "encoding/json"
56 "errors"
67 "flag"
78 "fmt"
89 "os"
910 "os/signal"
1011 "runtime/debug"
12+ "strconv"
1113 "strings"
1214 "syscall"
1315 "time"
1416
1517 "github.com/bnema/nefertty/internal/adapters/config"
18+ "github.com/bnema/nefertty/internal/adapters/statefile"
1619 "github.com/bnema/nefertty/internal/app"
1720 "github.com/bnema/nefertty/internal/logging"
1821 "github.com/bnema/nefertty/internal/ports"
@@ -70,6 +73,9 @@ func run() error {
7073 fmt.Println("ok: " + path)
7174 return nil
7275 }
76+ if len(os.Args) > 1 && os.Args[1] == "state" {
77+ return runState(os.Args[2:])
78+ }
7379 if len(os.Args) == 2 && os.Args[1] == "version" {
7480 info, ok := debug.ReadBuildInfo()
7581 if !ok {
@@ -214,3 +220,53 @@ func run() error {
214220 log.Info().Str("reason", reason).AnErr("error", err).Dur("uptime", time.Since(start)).Msg("exit")
215221 return err
216222 }
223+
224+// runState prints the session state for scripts:
225+//
226+// nefertty state the whole state
227+// nefertty state output-of <pid> the output showing that process's window
228+//
229+// The file is $NEFERTTY_STATE, else the one of $WAYLAND_DISPLAY.
230+func runState(args []string) error {
231+ usage := usageError{fmt.Errorf("usage: state [output-of <pid>]")}
232+ pid := 0
233+ switch {
234+ case len(args) == 0:
235+ case len(args) == 2 && args[0] == "output-of":
236+ n, err := strconv.Atoi(args[1])
237+ if err != nil || n <= 0 {
238+ fmt.Fprintln(os.Stderr, usage)
239+ return usage
240+ }
241+ pid = n
242+ default:
243+ fmt.Fprintln(os.Stderr, usage)
244+ return usage
245+ }
246+ path := os.Getenv(statefile.Env)
247+ if path == "" {
248+ var err error
249+ if path, err = statefile.Path(os.Getenv("XDG_RUNTIME_DIR"), os.Getenv("WAYLAND_DISPLAY")); err != nil {
250+ fmt.Fprintln(os.Stderr, err)
251+ return err
252+ }
253+ }
254+ st, err := statefile.Read(path)
255+ if err != nil {
256+ fmt.Fprintln(os.Stderr, err)
257+ return err
258+ }
259+ var out any = st
260+ if pid != 0 {
261+ if out, err = statefile.OutputOf(st, pid); err != nil {
262+ fmt.Fprintln(os.Stderr, err)
263+ return err
264+ }
265+ }
266+ data, err := json.Marshal(out)
267+ if err != nil {
268+ return err
269+ }
270+ fmt.Println(string(data))
271+ return nil
272+}
@@ -2,17 +2,20 @@ package main
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
5 "errors"6 "errors"
6 "flag"7 "flag"
7 "fmt"8 "fmt"
8 "os"9 "os"
9 "os/signal"10 "os/signal"
10 "runtime/debug"11 "runtime/debug"
12+ "strconv"
11 "strings"13 "strings"
12 "syscall"14 "syscall"
13 "time"15 "time"
14 16
15 "github.com/bnema/nefertty/internal/adapters/config"17 "github.com/bnema/nefertty/internal/adapters/config"
18+ "github.com/bnema/nefertty/internal/adapters/statefile"
16 "github.com/bnema/nefertty/internal/app"19 "github.com/bnema/nefertty/internal/app"
17 "github.com/bnema/nefertty/internal/logging"20 "github.com/bnema/nefertty/internal/logging"
18 "github.com/bnema/nefertty/internal/ports"21 "github.com/bnema/nefertty/internal/ports"
@@ -70,6 +73,9 @@ func run() error {
70 fmt.Println("ok: " + path)73 fmt.Println("ok: " + path)
71 return nil74 return nil
72 }75 }
76+ if len(os.Args) > 1 && os.Args[1] == "state" {
77+ return runState(os.Args[2:])
78+ }
73 if len(os.Args) == 2 && os.Args[1] == "version" {79 if len(os.Args) == 2 && os.Args[1] == "version" {
74 info, ok := debug.ReadBuildInfo()80 info, ok := debug.ReadBuildInfo()
75 if !ok {81 if !ok {
@@ -214,3 +220,53 @@ func run() error {
214 log.Info().Str("reason", reason).AnErr("error", err).Dur("uptime", time.Since(start)).Msg("exit")220 log.Info().Str("reason", reason).AnErr("error", err).Dur("uptime", time.Since(start)).Msg("exit")
215 return err221 return err
216 }222 }
223+
224+// runState prints the session state for scripts:
225+//
226+// nefertty state the whole state
227+// nefertty state output-of <pid> the output showing that process's window
228+//
229+// The file is $NEFERTTY_STATE, else the one of $WAYLAND_DISPLAY.
230+func runState(args []string) error {
231+ usage := usageError{fmt.Errorf("usage: state [output-of <pid>]")}
232+ pid := 0
233+ switch {
234+ case len(args) == 0:
235+ case len(args) == 2 && args[0] == "output-of":
236+ n, err := strconv.Atoi(args[1])
237+ if err != nil || n <= 0 {
238+ fmt.Fprintln(os.Stderr, usage)
239+ return usage
240+ }
241+ pid = n
242+ default:
243+ fmt.Fprintln(os.Stderr, usage)
244+ return usage
245+ }
246+ path := os.Getenv(statefile.Env)
247+ if path == "" {
248+ var err error
249+ if path, err = statefile.Path(os.Getenv("XDG_RUNTIME_DIR"), os.Getenv("WAYLAND_DISPLAY")); err != nil {
250+ fmt.Fprintln(os.Stderr, err)
251+ return err
252+ }
253+ }
254+ st, err := statefile.Read(path)
255+ if err != nil {
256+ fmt.Fprintln(os.Stderr, err)
257+ return err
258+ }
259+ var out any = st
260+ if pid != 0 {
261+ if out, err = statefile.OutputOf(st, pid); err != nil {
262+ fmt.Fprintln(os.Stderr, err)
263+ return err
264+ }
265+ }
266+ data, err := json.Marshal(out)
267+ if err != nil {
268+ return err
269+ }
270+ fmt.Println(string(data))
271+ return nil
272+}
A internal/adapters/statefile/proc_tree_mock_test.go +96 -0
@@ -0,0 +1,96 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package statefile
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockprocTree creates a new instance of mockprocTree. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockprocTree(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockprocTree {
17+ mock := &mockprocTree{}
18+ mock.Mock.Test(t)
19+
20+ t.Cleanup(func() { mock.AssertExpectations(t) })
21+
22+ return mock
23+}
24+
25+// mockprocTree is an autogenerated mock type for the procTree type
26+type mockprocTree struct {
27+ mock.Mock
28+}
29+
30+type mockprocTree_Expecter struct {
31+ mock *mock.Mock
32+}
33+
34+func (_m *mockprocTree) EXPECT() *mockprocTree_Expecter {
35+ return &mockprocTree_Expecter{mock: &_m.Mock}
36+}
37+
38+// Parent provides a mock function for the type mockprocTree
39+func (_mock *mockprocTree) Parent(pid int) (int, bool) {
40+ ret := _mock.Called(pid)
41+
42+ if len(ret) == 0 {
43+ panic("no return value specified for Parent")
44+ }
45+
46+ var r0 int
47+ var r1 bool
48+ if returnFunc, ok := ret.Get(0).(func(int) (int, bool)); ok {
49+ return returnFunc(pid)
50+ }
51+ if returnFunc, ok := ret.Get(0).(func(int) int); ok {
52+ r0 = returnFunc(pid)
53+ } else {
54+ r0 = ret.Get(0).(int)
55+ }
56+ if returnFunc, ok := ret.Get(1).(func(int) bool); ok {
57+ r1 = returnFunc(pid)
58+ } else {
59+ r1 = ret.Get(1).(bool)
60+ }
61+ return r0, r1
62+}
63+
64+// mockprocTree_Parent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Parent'
65+type mockprocTree_Parent_Call struct {
66+ *mock.Call
67+}
68+
69+// Parent is a helper method to define mock.On call
70+// - pid int
71+func (_e *mockprocTree_Expecter) Parent(pid any) *mockprocTree_Parent_Call {
72+ return &mockprocTree_Parent_Call{Call: _e.mock.On("Parent", pid)}
73+}
74+
75+func (_c *mockprocTree_Parent_Call) Run(run func(pid int)) *mockprocTree_Parent_Call {
76+ _c.Call.Run(func(args mock.Arguments) {
77+ var arg0 int
78+ if args[0] != nil {
79+ arg0 = args[0].(int)
80+ }
81+ run(
82+ arg0,
83+ )
84+ })
85+ return _c
86+}
87+
88+func (_c *mockprocTree_Parent_Call) Return(n int, b bool) *mockprocTree_Parent_Call {
89+ _c.Call.Return(n, b)
90+ return _c
91+}
92+
93+func (_c *mockprocTree_Parent_Call) RunAndReturn(run func(pid int) (int, bool)) *mockprocTree_Parent_Call {
94+ _c.Call.Return(run)
95+ return _c
96+}
@@ -0,0 +1,96 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package statefile
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockprocTree creates a new instance of mockprocTree. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockprocTree(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockprocTree {
17+ mock := &mockprocTree{}
18+ mock.Mock.Test(t)
19+
20+ t.Cleanup(func() { mock.AssertExpectations(t) })
21+
22+ return mock
23+}
24+
25+// mockprocTree is an autogenerated mock type for the procTree type
26+type mockprocTree struct {
27+ mock.Mock
28+}
29+
30+type mockprocTree_Expecter struct {
31+ mock *mock.Mock
32+}
33+
34+func (_m *mockprocTree) EXPECT() *mockprocTree_Expecter {
35+ return &mockprocTree_Expecter{mock: &_m.Mock}
36+}
37+
38+// Parent provides a mock function for the type mockprocTree
39+func (_mock *mockprocTree) Parent(pid int) (int, bool) {
40+ ret := _mock.Called(pid)
41+
42+ if len(ret) == 0 {
43+ panic("no return value specified for Parent")
44+ }
45+
46+ var r0 int
47+ var r1 bool
48+ if returnFunc, ok := ret.Get(0).(func(int) (int, bool)); ok {
49+ return returnFunc(pid)
50+ }
51+ if returnFunc, ok := ret.Get(0).(func(int) int); ok {
52+ r0 = returnFunc(pid)
53+ } else {
54+ r0 = ret.Get(0).(int)
55+ }
56+ if returnFunc, ok := ret.Get(1).(func(int) bool); ok {
57+ r1 = returnFunc(pid)
58+ } else {
59+ r1 = ret.Get(1).(bool)
60+ }
61+ return r0, r1
62+}
63+
64+// mockprocTree_Parent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Parent'
65+type mockprocTree_Parent_Call struct {
66+ *mock.Call
67+}
68+
69+// Parent is a helper method to define mock.On call
70+// - pid int
71+func (_e *mockprocTree_Expecter) Parent(pid any) *mockprocTree_Parent_Call {
72+ return &mockprocTree_Parent_Call{Call: _e.mock.On("Parent", pid)}
73+}
74+
75+func (_c *mockprocTree_Parent_Call) Run(run func(pid int)) *mockprocTree_Parent_Call {
76+ _c.Call.Run(func(args mock.Arguments) {
77+ var arg0 int
78+ if args[0] != nil {
79+ arg0 = args[0].(int)
80+ }
81+ run(
82+ arg0,
83+ )
84+ })
85+ return _c
86+}
87+
88+func (_c *mockprocTree_Parent_Call) Return(n int, b bool) *mockprocTree_Parent_Call {
89+ _c.Call.Return(n, b)
90+ return _c
91+}
92+
93+func (_c *mockprocTree_Parent_Call) RunAndReturn(run func(pid int) (int, bool)) *mockprocTree_Parent_Call {
94+ _c.Call.Return(run)
95+ return _c
96+}
A internal/adapters/statefile/statefile.go +206 -0
@@ -0,0 +1,206 @@
1+// Package statefile publishes core's state as JSON for scripts, and reads it
2+// back for the `nefertty state` command. The JSON format is defined here,
3+// not in ports: it is a public interface scripts depend on.
4+package statefile
5+
6+import (
7+ "context"
8+ "encoding/json"
9+ "errors"
10+ "fmt"
11+ "os"
12+ "path/filepath"
13+ "strconv"
14+ "strings"
15+
16+ "github.com/bnema/nefertty/internal/ports"
17+ "github.com/bnema/zerowrap"
18+)
19+
20+// Env names the state file in the environment of processes nefertty starts.
21+const Env = "NEFERTTY_STATE"
22+
23+// Path is the state file of the session on a wayland socket: one per
24+// session, so nested or test instances never overwrite each other.
25+func Path(runtimeDir, socket string) (string, error) {
26+ socket = filepath.Base(socket)
27+ if runtimeDir == "" || socket == "" || socket == "." || socket == "/" {
28+ return "", errors.New("XDG_RUNTIME_DIR and WAYLAND_DISPLAY must be set")
29+ }
30+ return filepath.Join(runtimeDir, "nefertty", socket+".json"), nil
31+}
32+
33+// State is the file format.
34+type State struct {
35+ Output string `json:"output"`
36+ Outputs []Output `json:"outputs"`
37+ Window *Window `json:"window"`
38+ Windows []Window `json:"windows"`
39+}
40+
41+// Output is one output: its active numbered workspace (0 while a hidden one
42+// is shown), how many numbered workspaces it has, and the name of the
43+// workspace on screen.
44+type Output struct {
45+ Name string `json:"name"`
46+ Active int `json:"active"`
47+ Count int `json:"count"`
48+ Workspace string `json:"workspace"`
49+}
50+
51+// Window is one mapped window.
52+type Window struct {
53+ ID uint64 `json:"id"`
54+ AppID string `json:"app_id"`
55+ PID int `json:"pid"`
56+ Output string `json:"output"`
57+ Workspace int `json:"workspace"`
58+ Visible bool `json:"visible"`
59+}
60+
61+func fromPorts(st ports.State) State {
62+ out := State{Output: st.Output, Outputs: make([]Output, 0, len(st.Outputs)), Windows: make([]Window, 0, len(st.Windows))}
63+ for _, o := range st.Outputs {
64+ out.Outputs = append(out.Outputs, Output{Name: o.Name, Active: o.Active, Count: o.Count, Workspace: o.Workspace})
65+ }
66+ for _, w := range st.Windows {
67+ out.Windows = append(out.Windows, window(w))
68+ }
69+ if st.Window != nil {
70+ w := window(*st.Window)
71+ out.Window = &w
72+ }
73+ return out
74+}
75+
76+func window(w ports.WindowState) Window {
77+ return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, Visible: w.Visible}
78+}
79+
80+// Run writes every state it receives to path until ctx ends, then removes
81+// the file: a stale state must not outlive the session.
82+func Run(ctx context.Context, path string, states <-chan ports.State, log zerowrap.Logger) error {
83+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
84+ return err
85+ }
86+ // A file left by a crashed session on this socket is not ours.
87+ _ = os.Remove(path)
88+ defer os.Remove(path)
89+ for {
90+ select {
91+ case <-ctx.Done():
92+ return nil
93+ case st := <-states:
94+ if err := write(path, fromPorts(st)); err != nil {
95+ // Scripts lose their state; the session goes on.
96+ log.Warn().Err(err).Str("path", path).Msg("write state")
97+ }
98+ }
99+ }
100+}
101+
102+// write replaces the file atomically: readers never see half a state.
103+func write(path string, st State) error {
104+ data, err := json.Marshal(st)
105+ if err != nil {
106+ return err
107+ }
108+ f, err := os.CreateTemp(filepath.Dir(path), ".state-*.json")
109+ if err != nil {
110+ return err
111+ }
112+ _, werr := f.Write(append(data, '\n'))
113+ cerr := f.Close()
114+ if err := errors.Join(werr, cerr); err != nil {
115+ os.Remove(f.Name())
116+ return err
117+ }
118+ return os.Rename(f.Name(), path)
119+}
120+
121+// Read loads the state file.
122+func Read(path string) (State, error) {
123+ var st State
124+ data, err := os.ReadFile(path)
125+ if err != nil {
126+ return st, err
127+ }
128+ return st, json.Unmarshal(data, &st)
129+}
130+
131+// procTree finds the parent of a process.
132+type procTree interface {
133+ Parent(pid int) (int, bool)
134+}
135+
136+// linuxProcTree reads /proc/<pid>/stat.
137+type linuxProcTree struct{}
138+
139+func (linuxProcTree) Parent(pid int) (int, bool) {
140+ data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
141+ if err != nil {
142+ return 0, false
143+ }
144+ // "pid (comm) state ppid ...": comm may hold spaces and parentheses.
145+ i := strings.LastIndexByte(string(data), ')')
146+ if i < 0 {
147+ return 0, false
148+ }
149+ f := strings.Fields(string(data[i+1:]))
150+ if len(f) < 2 {
151+ return 0, false
152+ }
153+ ppid, err := strconv.Atoi(f[1])
154+ return ppid, err == nil && ppid > 0
155+}
156+
157+// ErrNoWindow means no window belongs to the process or its ancestors.
158+var ErrNoWindow = errors.New("no window for this process")
159+
160+// OutputOf finds the output showing a process: the window of the process
161+// itself or of its nearest ancestor, e.g. the terminal of a shell. Only
162+// application windows count: bars (layer surfaces) have no single output.
163+// A process with windows on several outputs resolves to its focused window,
164+// else a visible one. PIDs are those of the compositor's PID namespace.
165+func OutputOf(st State, pid int) (Output, error) {
166+ return outputOf(st, pid, linuxProcTree{})
167+}
168+
169+func outputOf(st State, pid int, tree procTree) (Output, error) {
170+ byPID := map[int]Window{}
171+ for _, w := range st.Windows {
172+ if w.PID <= 0 {
173+ continue
174+ }
175+ cur, seen := byPID[w.PID]
176+ if !seen || better(st, w, cur) {
177+ byPID[w.PID] = w
178+ }
179+ }
180+ // A depth limit guards against a cycle from a reused PID mid-walk.
181+ for range 64 {
182+ if w, ok := byPID[pid]; ok {
183+ for _, o := range st.Outputs {
184+ if o.Name == w.Output {
185+ return o, nil
186+ }
187+ }
188+ return Output{}, fmt.Errorf("output %q not in state", w.Output)
189+ }
190+ parent, ok := tree.Parent(pid)
191+ if !ok || parent == pid {
192+ break
193+ }
194+ pid = parent
195+ }
196+ return Output{}, ErrNoWindow
197+}
198+
199+// better prefers the focused window, then a visible one.
200+func better(st State, a, b Window) bool {
201+ focused := func(w Window) bool { return st.Window != nil && st.Window.ID == w.ID }
202+ if focused(a) != focused(b) {
203+ return focused(a)
204+ }
205+ return a.Visible && !b.Visible
206+}
@@ -0,0 +1,206 @@
1+// Package statefile publishes core's state as JSON for scripts, and reads it
2+// back for the `nefertty state` command. The JSON format is defined here,
3+// not in ports: it is a public interface scripts depend on.
4+package statefile
5+
6+import (
7+ "context"
8+ "encoding/json"
9+ "errors"
10+ "fmt"
11+ "os"
12+ "path/filepath"
13+ "strconv"
14+ "strings"
15+
16+ "github.com/bnema/nefertty/internal/ports"
17+ "github.com/bnema/zerowrap"
18+)
19+
20+// Env names the state file in the environment of processes nefertty starts.
21+const Env = "NEFERTTY_STATE"
22+
23+// Path is the state file of the session on a wayland socket: one per
24+// session, so nested or test instances never overwrite each other.
25+func Path(runtimeDir, socket string) (string, error) {
26+ socket = filepath.Base(socket)
27+ if runtimeDir == "" || socket == "" || socket == "." || socket == "/" {
28+ return "", errors.New("XDG_RUNTIME_DIR and WAYLAND_DISPLAY must be set")
29+ }
30+ return filepath.Join(runtimeDir, "nefertty", socket+".json"), nil
31+}
32+
33+// State is the file format.
34+type State struct {
35+ Output string `json:"output"`
36+ Outputs []Output `json:"outputs"`
37+ Window *Window `json:"window"`
38+ Windows []Window `json:"windows"`
39+}
40+
41+// Output is one output: its active numbered workspace (0 while a hidden one
42+// is shown), how many numbered workspaces it has, and the name of the
43+// workspace on screen.
44+type Output struct {
45+ Name string `json:"name"`
46+ Active int `json:"active"`
47+ Count int `json:"count"`
48+ Workspace string `json:"workspace"`
49+}
50+
51+// Window is one mapped window.
52+type Window struct {
53+ ID uint64 `json:"id"`
54+ AppID string `json:"app_id"`
55+ PID int `json:"pid"`
56+ Output string `json:"output"`
57+ Workspace int `json:"workspace"`
58+ Visible bool `json:"visible"`
59+}
60+
61+func fromPorts(st ports.State) State {
62+ out := State{Output: st.Output, Outputs: make([]Output, 0, len(st.Outputs)), Windows: make([]Window, 0, len(st.Windows))}
63+ for _, o := range st.Outputs {
64+ out.Outputs = append(out.Outputs, Output{Name: o.Name, Active: o.Active, Count: o.Count, Workspace: o.Workspace})
65+ }
66+ for _, w := range st.Windows {
67+ out.Windows = append(out.Windows, window(w))
68+ }
69+ if st.Window != nil {
70+ w := window(*st.Window)
71+ out.Window = &w
72+ }
73+ return out
74+}
75+
76+func window(w ports.WindowState) Window {
77+ return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, Visible: w.Visible}
78+}
79+
80+// Run writes every state it receives to path until ctx ends, then removes
81+// the file: a stale state must not outlive the session.
82+func Run(ctx context.Context, path string, states <-chan ports.State, log zerowrap.Logger) error {
83+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
84+ return err
85+ }
86+ // A file left by a crashed session on this socket is not ours.
87+ _ = os.Remove(path)
88+ defer os.Remove(path)
89+ for {
90+ select {
91+ case <-ctx.Done():
92+ return nil
93+ case st := <-states:
94+ if err := write(path, fromPorts(st)); err != nil {
95+ // Scripts lose their state; the session goes on.
96+ log.Warn().Err(err).Str("path", path).Msg("write state")
97+ }
98+ }
99+ }
100+}
101+
102+// write replaces the file atomically: readers never see half a state.
103+func write(path string, st State) error {
104+ data, err := json.Marshal(st)
105+ if err != nil {
106+ return err
107+ }
108+ f, err := os.CreateTemp(filepath.Dir(path), ".state-*.json")
109+ if err != nil {
110+ return err
111+ }
112+ _, werr := f.Write(append(data, '\n'))
113+ cerr := f.Close()
114+ if err := errors.Join(werr, cerr); err != nil {
115+ os.Remove(f.Name())
116+ return err
117+ }
118+ return os.Rename(f.Name(), path)
119+}
120+
121+// Read loads the state file.
122+func Read(path string) (State, error) {
123+ var st State
124+ data, err := os.ReadFile(path)
125+ if err != nil {
126+ return st, err
127+ }
128+ return st, json.Unmarshal(data, &st)
129+}
130+
131+// procTree finds the parent of a process.
132+type procTree interface {
133+ Parent(pid int) (int, bool)
134+}
135+
136+// linuxProcTree reads /proc/<pid>/stat.
137+type linuxProcTree struct{}
138+
139+func (linuxProcTree) Parent(pid int) (int, bool) {
140+ data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
141+ if err != nil {
142+ return 0, false
143+ }
144+ // "pid (comm) state ppid ...": comm may hold spaces and parentheses.
145+ i := strings.LastIndexByte(string(data), ')')
146+ if i < 0 {
147+ return 0, false
148+ }
149+ f := strings.Fields(string(data[i+1:]))
150+ if len(f) < 2 {
151+ return 0, false
152+ }
153+ ppid, err := strconv.Atoi(f[1])
154+ return ppid, err == nil && ppid > 0
155+}
156+
157+// ErrNoWindow means no window belongs to the process or its ancestors.
158+var ErrNoWindow = errors.New("no window for this process")
159+
160+// OutputOf finds the output showing a process: the window of the process
161+// itself or of its nearest ancestor, e.g. the terminal of a shell. Only
162+// application windows count: bars (layer surfaces) have no single output.
163+// A process with windows on several outputs resolves to its focused window,
164+// else a visible one. PIDs are those of the compositor's PID namespace.
165+func OutputOf(st State, pid int) (Output, error) {
166+ return outputOf(st, pid, linuxProcTree{})
167+}
168+
169+func outputOf(st State, pid int, tree procTree) (Output, error) {
170+ byPID := map[int]Window{}
171+ for _, w := range st.Windows {
172+ if w.PID <= 0 {
173+ continue
174+ }
175+ cur, seen := byPID[w.PID]
176+ if !seen || better(st, w, cur) {
177+ byPID[w.PID] = w
178+ }
179+ }
180+ // A depth limit guards against a cycle from a reused PID mid-walk.
181+ for range 64 {
182+ if w, ok := byPID[pid]; ok {
183+ for _, o := range st.Outputs {
184+ if o.Name == w.Output {
185+ return o, nil
186+ }
187+ }
188+ return Output{}, fmt.Errorf("output %q not in state", w.Output)
189+ }
190+ parent, ok := tree.Parent(pid)
191+ if !ok || parent == pid {
192+ break
193+ }
194+ pid = parent
195+ }
196+ return Output{}, ErrNoWindow
197+}
198+
199+// better prefers the focused window, then a visible one.
200+func better(st State, a, b Window) bool {
201+ focused := func(w Window) bool { return st.Window != nil && st.Window.ID == w.ID }
202+ if focused(a) != focused(b) {
203+ return focused(a)
204+ }
205+ return a.Visible && !b.Visible
206+}
A internal/adapters/statefile/statefile_test.go +120 -0
@@ -0,0 +1,120 @@
1+package statefile
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "reflect"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/nefertty/internal/ports"
13+ "github.com/bnema/zerowrap"
14+)
15+
16+var sample = State{
17+ Output: "DP-2",
18+ Outputs: []Output{{Name: "HDMI-A-1", Active: 1, Count: 1}, {Name: "DP-2", Active: 2, Count: 3, Workspace: "web"}},
19+ Windows: []Window{{ID: 1, AppID: "foot", PID: 100, Output: "HDMI-A-1", Workspace: 1, Visible: true}, {ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 2, Visible: true}},
20+}
21+
22+// The file holds the latest state while the session runs; a stale file from
23+// a crashed session goes at start, and the file is removed at exit.
24+func TestRunWritesAndRemoves(t *testing.T) {
25+ path, err := Path(t.TempDir(), "wayland-9")
26+ if err != nil {
27+ t.Fatal(err)
28+ }
29+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
30+ t.Fatal(err)
31+ }
32+ if err := os.WriteFile(path, []byte(`{"output":"stale"}`), 0o600); err != nil {
33+ t.Fatal(err)
34+ }
35+ states := make(chan ports.State, 1)
36+ ctx, cancel := context.WithCancel(context.Background())
37+ done := make(chan error, 1)
38+ go func() { done <- Run(ctx, path, states, zerowrap.Default()) }()
39+ focused := ports.WindowState{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, Visible: true}
40+ states <- ports.State{Output: "DP-2", Outputs: []ports.OutputState{{Name: "DP-2", Active: 1, Count: 1}}, Window: &focused, Windows: []ports.WindowState{focused}}
41+ want := State{Output: "DP-2", Outputs: []Output{{Name: "DP-2", Active: 1, Count: 1}}, Window: &Window{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, Visible: true}, Windows: []Window{{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, Visible: true}}}
42+ deadline := time.Now().Add(2 * time.Second)
43+ for {
44+ if got, err := Read(path); err == nil && got.Output != "stale" {
45+ if !reflect.DeepEqual(got, want) {
46+ t.Fatalf("%+v", got)
47+ }
48+ break
49+ }
50+ if time.Now().After(deadline) {
51+ t.Fatal("no state file")
52+ }
53+ time.Sleep(5 * time.Millisecond)
54+ }
55+ cancel()
56+ if err := <-done; err != nil {
57+ t.Fatal(err)
58+ }
59+ if _, err := os.Stat(path); !os.IsNotExist(err) {
60+ t.Fatal("state file left behind", err)
61+ }
62+}
63+
64+func TestPath(t *testing.T) {
65+ if p, err := Path("/run/user/1", "/run/user/1/wayland-1"); err != nil || p != "/run/user/1/nefertty/wayland-1.json" {
66+ t.Fatal(p, err)
67+ }
68+ for _, c := range [][2]string{{"", "wayland-1"}, {"/run/user/1", ""}} {
69+ if _, err := Path(c[0], c[1]); err == nil {
70+ t.Fatal(c)
71+ }
72+ }
73+}
74+
75+// A shell finds its window through its ancestors.
76+func TestOutputOfWalksParents(t *testing.T) {
77+ tree := newMockprocTree(t)
78+ // shell 300 → login 250 → terminal 200 (window on DP-2)
79+ tree.EXPECT().Parent(300).Return(250, true)
80+ tree.EXPECT().Parent(250).Return(200, true)
81+ o, err := outputOf(sample, 300, tree)
82+ if err != nil || o.Name != "DP-2" || o.Active != 2 || o.Count != 3 {
83+ t.Fatal(o, err)
84+ }
85+ // The window's own process needs no walk.
86+ if o, err := outputOf(sample, 100, newMockprocTree(t)); err != nil || o.Name != "HDMI-A-1" {
87+ t.Fatal(o, err)
88+ }
89+ // No window up to init.
90+ orphan := newMockprocTree(t)
91+ orphan.EXPECT().Parent(400).Return(1, true)
92+ orphan.EXPECT().Parent(1).Return(0, false)
93+ if _, err := outputOf(sample, 400, orphan); !errors.Is(err, ErrNoWindow) {
94+ t.Fatal(err)
95+ }
96+}
97+
98+// One process with windows on two outputs (a terminal server): the focused
99+// window wins, else a visible one.
100+func TestOutputOfPrefersFocusedWindow(t *testing.T) {
101+ st := sample
102+ st.Windows = []Window{
103+ {ID: 1, PID: 100, Output: "HDMI-A-1", Visible: true},
104+ {ID: 2, PID: 100, Output: "DP-2", Visible: false},
105+ {ID: 3, PID: 100, Output: "DP-2", Visible: true},
106+ }
107+ if o, _ := outputOf(st, 100, newMockprocTree(t)); o.Name != "HDMI-A-1" {
108+ t.Fatal(o)
109+ }
110+ st.Window = &st.Windows[2]
111+ if o, _ := outputOf(st, 100, newMockprocTree(t)); o.Name != "DP-2" {
112+ t.Fatal(o)
113+ }
114+}
115+
116+func TestLinuxProcTreeParent(t *testing.T) {
117+ if ppid, ok := (linuxProcTree{}).Parent(os.Getpid()); !ok || ppid != os.Getppid() {
118+ t.Fatal(ppid, ok)
119+ }
120+}
@@ -0,0 +1,120 @@
1+package statefile
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "reflect"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/nefertty/internal/ports"
13+ "github.com/bnema/zerowrap"
14+)
15+
16+var sample = State{
17+ Output: "DP-2",
18+ Outputs: []Output{{Name: "HDMI-A-1", Active: 1, Count: 1}, {Name: "DP-2", Active: 2, Count: 3, Workspace: "web"}},
19+ Windows: []Window{{ID: 1, AppID: "foot", PID: 100, Output: "HDMI-A-1", Workspace: 1, Visible: true}, {ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 2, Visible: true}},
20+}
21+
22+// The file holds the latest state while the session runs; a stale file from
23+// a crashed session goes at start, and the file is removed at exit.
24+func TestRunWritesAndRemoves(t *testing.T) {
25+ path, err := Path(t.TempDir(), "wayland-9")
26+ if err != nil {
27+ t.Fatal(err)
28+ }
29+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
30+ t.Fatal(err)
31+ }
32+ if err := os.WriteFile(path, []byte(`{"output":"stale"}`), 0o600); err != nil {
33+ t.Fatal(err)
34+ }
35+ states := make(chan ports.State, 1)
36+ ctx, cancel := context.WithCancel(context.Background())
37+ done := make(chan error, 1)
38+ go func() { done <- Run(ctx, path, states, zerowrap.Default()) }()
39+ focused := ports.WindowState{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, Visible: true}
40+ states <- ports.State{Output: "DP-2", Outputs: []ports.OutputState{{Name: "DP-2", Active: 1, Count: 1}}, Window: &focused, Windows: []ports.WindowState{focused}}
41+ want := State{Output: "DP-2", Outputs: []Output{{Name: "DP-2", Active: 1, Count: 1}}, Window: &Window{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, Visible: true}, Windows: []Window{{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, Visible: true}}}
42+ deadline := time.Now().Add(2 * time.Second)
43+ for {
44+ if got, err := Read(path); err == nil && got.Output != "stale" {
45+ if !reflect.DeepEqual(got, want) {
46+ t.Fatalf("%+v", got)
47+ }
48+ break
49+ }
50+ if time.Now().After(deadline) {
51+ t.Fatal("no state file")
52+ }
53+ time.Sleep(5 * time.Millisecond)
54+ }
55+ cancel()
56+ if err := <-done; err != nil {
57+ t.Fatal(err)
58+ }
59+ if _, err := os.Stat(path); !os.IsNotExist(err) {
60+ t.Fatal("state file left behind", err)
61+ }
62+}
63+
64+func TestPath(t *testing.T) {
65+ if p, err := Path("/run/user/1", "/run/user/1/wayland-1"); err != nil || p != "/run/user/1/nefertty/wayland-1.json" {
66+ t.Fatal(p, err)
67+ }
68+ for _, c := range [][2]string{{"", "wayland-1"}, {"/run/user/1", ""}} {
69+ if _, err := Path(c[0], c[1]); err == nil {
70+ t.Fatal(c)
71+ }
72+ }
73+}
74+
75+// A shell finds its window through its ancestors.
76+func TestOutputOfWalksParents(t *testing.T) {
77+ tree := newMockprocTree(t)
78+ // shell 300 → login 250 → terminal 200 (window on DP-2)
79+ tree.EXPECT().Parent(300).Return(250, true)
80+ tree.EXPECT().Parent(250).Return(200, true)
81+ o, err := outputOf(sample, 300, tree)
82+ if err != nil || o.Name != "DP-2" || o.Active != 2 || o.Count != 3 {
83+ t.Fatal(o, err)
84+ }
85+ // The window's own process needs no walk.
86+ if o, err := outputOf(sample, 100, newMockprocTree(t)); err != nil || o.Name != "HDMI-A-1" {
87+ t.Fatal(o, err)
88+ }
89+ // No window up to init.
90+ orphan := newMockprocTree(t)
91+ orphan.EXPECT().Parent(400).Return(1, true)
92+ orphan.EXPECT().Parent(1).Return(0, false)
93+ if _, err := outputOf(sample, 400, orphan); !errors.Is(err, ErrNoWindow) {
94+ t.Fatal(err)
95+ }
96+}
97+
98+// One process with windows on two outputs (a terminal server): the focused
99+// window wins, else a visible one.
100+func TestOutputOfPrefersFocusedWindow(t *testing.T) {
101+ st := sample
102+ st.Windows = []Window{
103+ {ID: 1, PID: 100, Output: "HDMI-A-1", Visible: true},
104+ {ID: 2, PID: 100, Output: "DP-2", Visible: false},
105+ {ID: 3, PID: 100, Output: "DP-2", Visible: true},
106+ }
107+ if o, _ := outputOf(st, 100, newMockprocTree(t)); o.Name != "HDMI-A-1" {
108+ t.Fatal(o)
109+ }
110+ st.Window = &st.Windows[2]
111+ if o, _ := outputOf(st, 100, newMockprocTree(t)); o.Name != "DP-2" {
112+ t.Fatal(o)
113+ }
114+}
115+
116+func TestLinuxProcTreeParent(t *testing.T) {
117+ if ppid, ok := (linuxProcTree{}).Parent(os.Getpid()); !ok || ppid != os.Getppid() {
118+ t.Fatal(ppid, ok)
119+ }
120+}
M internal/adapters/wayland/slot_test.go +2 -2
@@ -15,8 +15,8 @@ func TestWindowMappedCarriesSlot(t *testing.T) {
1515 s, events, commands, dir := keyboardServer(t, func(s *Server) { s.env = env })
1616 mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events)
1717 // No slot pending: /proc is not read (the mock would fail on a call).
18- if w := mapWindow(); w.Slot != "" {
19- t.Fatalf("slot %q", w.Slot)
18+ if w := mapWindow(); w.Slot != "" || w.PID != os.Getpid() {
19+ t.Fatalf("slot %q pid %d", w.Slot, w.PID)
2020 }
2121 commands <- ports.SlotsPending{Pending: true}
2222 if w := mapWindow(); w.Slot != "7" {
@@ -15,8 +15,8 @@ func TestWindowMappedCarriesSlot(t *testing.T) {
15 s, events, commands, dir := keyboardServer(t, func(s *Server) { s.env = env })15 s, events, commands, dir := keyboardServer(t, func(s *Server) { s.env = env })
16 mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events)16 mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events)
17 // No slot pending: /proc is not read (the mock would fail on a call).17 // No slot pending: /proc is not read (the mock would fail on a call).
18- if w := mapWindow(); w.Slot != "" {18+ if w := mapWindow(); w.Slot != "" || w.PID != os.Getpid() {
19- t.Fatalf("slot %q", w.Slot)19+ t.Fatalf("slot %q pid %d", w.Slot, w.PID)
20 }20 }
21 commands <- ports.SlotsPending{Pending: true}21 commands <- ports.SlotsPending{Pending: true}
22 if w := mapWindow(); w.Slot != "7" {22 if w := mapWindow(); w.Slot != "7" {
M internal/adapters/wayland/xdg.go +13 -5
@@ -151,7 +151,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
151151 } else if buffer && !w.mapped && x.acked {
152152 w.mapped = true
153153 slot := x.server.slotToken(r.Client())
154- x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot})
154+ x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID()})
155155 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Msg("window mapped")
156156 } else if !buffer && w.mapped {
157157 w.unmap()
@@ -202,10 +202,18 @@ func (x *xdgSurface) AckConfigure(r *xdgshell.Surface, serial uint32) {
202202
203203 type top struct{ w *window }
204204
205-func (top) Destroy(*xdgshell.Toplevel) {}
206-func (top) SetParent(*xdgshell.Toplevel, *xdgshell.Toplevel) {}
207-func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { t.w.title = title }
208-func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { t.w.appID = appID }
205+func (top) Destroy(*xdgshell.Toplevel) {}
206+func (top) SetParent(*xdgshell.Toplevel, *xdgshell.Toplevel) {}
207+func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { t.w.title = title }
208+func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) {
209+ if appID == t.w.appID {
210+ return
211+ }
212+ t.w.appID = appID
213+ if t.w.mapped {
214+ t.w.xdg.server.emit(ports.WindowAppID{ID: t.w.id, AppID: appID})
215+ }
216+}
209217 func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {}
210218 func (top) Move(*xdgshell.Toplevel, *wayland.Seat, uint32) {}
211219 func (top) Resize(*xdgshell.Toplevel, *wayland.Seat, uint32, uint32) {}
@@ -151,7 +151,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
151 } else if buffer && !w.mapped && x.acked {151 } else if buffer && !w.mapped && x.acked {
152 w.mapped = true152 w.mapped = true
153 slot := x.server.slotToken(r.Client())153 slot := x.server.slotToken(r.Client())
154- x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot})154+ x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID()})
155 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Msg("window mapped")155 x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Msg("window mapped")
156 } else if !buffer && w.mapped {156 } else if !buffer && w.mapped {
157 w.unmap()157 w.unmap()
@@ -202,10 +202,18 @@ func (x *xdgSurface) AckConfigure(r *xdgshell.Surface, serial uint32) {
202 202
203 type top struct{ w *window }203 type top struct{ w *window }
204 204
205-func (top) Destroy(*xdgshell.Toplevel) {}205+func (top) Destroy(*xdgshell.Toplevel) {}
206-func (top) SetParent(*xdgshell.Toplevel, *xdgshell.Toplevel) {}206+func (top) SetParent(*xdgshell.Toplevel, *xdgshell.Toplevel) {}
207-func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { t.w.title = title }207+func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { t.w.title = title }
208-func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { t.w.appID = appID }208+func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) {
209+ if appID == t.w.appID {
210+ return
211+ }
212+ t.w.appID = appID
213+ if t.w.mapped {
214+ t.w.xdg.server.emit(ports.WindowAppID{ID: t.w.id, AppID: appID})
215+ }
216+}
209 func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {}217 func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {}
210 func (top) Move(*xdgshell.Toplevel, *wayland.Seat, uint32) {}218 func (top) Move(*xdgshell.Toplevel, *wayland.Seat, uint32) {}
211 func (top) Resize(*xdgshell.Toplevel, *wayland.Seat, uint32, uint32) {}219 func (top) Resize(*xdgshell.Toplevel, *wayland.Seat, uint32, uint32) {}
M internal/app/app.go +21 -2
@@ -16,6 +16,7 @@ import (
1616 "github.com/bnema/nefertty/internal/adapters/headlessinput"
1717 "github.com/bnema/nefertty/internal/adapters/launcher"
1818 "github.com/bnema/nefertty/internal/adapters/libinput"
19+ "github.com/bnema/nefertty/internal/adapters/statefile"
1920 "github.com/bnema/nefertty/internal/adapters/vulkan"
2021 "github.com/bnema/nefertty/internal/adapters/wayland"
2122 "github.com/bnema/nefertty/internal/adapters/xkb"
@@ -73,10 +74,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
7374 spawn := make(chan ports.SpawnRequest, 32)
7475 scenes := make(chan []ports.Scene, 1)
7576 layouts := make(chan ports.Layout, 1)
77+ states := make(chan ports.State, 1)
7678 configErrors := make(chan error, 8)
7779 renderScenes := make(chan []ports.Scene, 1)
7880 contents := make(chan ports.SurfaceContent, 64)
79- ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
81+ ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
8082 c, err := core.New(opts.Config, ch)
8183 if err != nil {
8284 return err
@@ -93,7 +95,14 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
9395 return err
9496 }
9597 log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening")
96- child := launcher.New(launcher.ChildEnv(os.Environ(), server.SocketName(), runtimeDir), logging.For(ctx, "launcher"))
98+ childEnv := launcher.ChildEnv(os.Environ(), server.SocketName(), runtimeDir)
99+ statePath, err := statefile.Path(runtimeDir, server.SocketName())
100+ if err != nil {
101+ log.Warn().Err(err).Msg("state file disabled")
102+ } else {
103+ childEnv = append(childEnv, statefile.Env+"="+statePath)
104+ }
105+ child := launcher.New(childEnv, logging.For(ctx, "launcher"))
97106 if inject != nil {
98107 inject(input)
99108 }
@@ -157,6 +166,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
157166 go func() { defer workers.Done(); done <- server.Run(ctx) }()
158167 go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }()
159168 go func() { defer workers.Done(); done <- c.Run(ctx) }()
169+ if statePath != "" {
170+ workers.Add(1)
171+ go func() {
172+ defer workers.Done()
173+ if err := statefile.Run(ctx, statePath, states, logging.For(ctx, "statefile")); err != nil {
174+ // Scripts lose their state; the session goes on.
175+ log.Warn().Err(err).Msg("state file disabled")
176+ }
177+ }()
178+ }
160179 go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes, renderScenes) }()
161180
162181 go func() {
@@ -16,6 +16,7 @@ import (
16 "github.com/bnema/nefertty/internal/adapters/headlessinput"16 "github.com/bnema/nefertty/internal/adapters/headlessinput"
17 "github.com/bnema/nefertty/internal/adapters/launcher"17 "github.com/bnema/nefertty/internal/adapters/launcher"
18 "github.com/bnema/nefertty/internal/adapters/libinput"18 "github.com/bnema/nefertty/internal/adapters/libinput"
19+ "github.com/bnema/nefertty/internal/adapters/statefile"
19 "github.com/bnema/nefertty/internal/adapters/vulkan"20 "github.com/bnema/nefertty/internal/adapters/vulkan"
20 "github.com/bnema/nefertty/internal/adapters/wayland"21 "github.com/bnema/nefertty/internal/adapters/wayland"
21 "github.com/bnema/nefertty/internal/adapters/xkb"22 "github.com/bnema/nefertty/internal/adapters/xkb"
@@ -73,10 +74,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
73 spawn := make(chan ports.SpawnRequest, 32)74 spawn := make(chan ports.SpawnRequest, 32)
74 scenes := make(chan []ports.Scene, 1)75 scenes := make(chan []ports.Scene, 1)
75 layouts := make(chan ports.Layout, 1)76 layouts := make(chan ports.Layout, 1)
77+ states := make(chan ports.State, 1)
76 configErrors := make(chan error, 8)78 configErrors := make(chan error, 8)
77 renderScenes := make(chan []ports.Scene, 1)79 renderScenes := make(chan []ports.Scene, 1)
78 contents := make(chan ports.SurfaceContent, 64)80 contents := make(chan ports.SurfaceContent, 64)
79- ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}81+ ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal}
80 c, err := core.New(opts.Config, ch)82 c, err := core.New(opts.Config, ch)
81 if err != nil {83 if err != nil {
82 return err84 return err
@@ -93,7 +95,14 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
93 return err95 return err
94 }96 }
95 log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening")97 log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening")
96- child := launcher.New(launcher.ChildEnv(os.Environ(), server.SocketName(), runtimeDir), logging.For(ctx, "launcher"))98+ childEnv := launcher.ChildEnv(os.Environ(), server.SocketName(), runtimeDir)
99+ statePath, err := statefile.Path(runtimeDir, server.SocketName())
100+ if err != nil {
101+ log.Warn().Err(err).Msg("state file disabled")
102+ } else {
103+ childEnv = append(childEnv, statefile.Env+"="+statePath)
104+ }
105+ child := launcher.New(childEnv, logging.For(ctx, "launcher"))
97 if inject != nil {106 if inject != nil {
98 inject(input)107 inject(input)
99 }108 }
@@ -157,6 +166,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
157 go func() { defer workers.Done(); done <- server.Run(ctx) }()166 go func() { defer workers.Done(); done <- server.Run(ctx) }()
158 go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }()167 go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }()
159 go func() { defer workers.Done(); done <- c.Run(ctx) }()168 go func() { defer workers.Done(); done <- c.Run(ctx) }()
169+ if statePath != "" {
170+ workers.Add(1)
171+ go func() {
172+ defer workers.Done()
173+ if err := statefile.Run(ctx, statePath, states, logging.For(ctx, "statefile")); err != nil {
174+ // Scripts lose their state; the session goes on.
175+ log.Warn().Err(err).Msg("state file disabled")
176+ }
177+ }()
178+ }
160 go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes, renderScenes) }()179 go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes, renderScenes) }()
161 180
162 go func() {181 go func() {
M internal/core/core.go +18 -3
@@ -28,6 +28,9 @@ type Channels struct {
2828 // latest first (capacity 1, drained like Scenes). Input and cursors use it.
2929 Layouts chan ports.Layout
3030 ConfigErrors chan<- error
31+ // State, when set, receives a snapshot for scripts whenever it changes,
32+ // latest first (capacity 1, drained like Scenes).
33+ State chan ports.State
3134 // Terminal, when set, keeps a window on every workspace on screen: an
3235 // empty one gets the configured terminal (the terminal is the desktop).
3336 Terminal bool
@@ -64,6 +67,9 @@ type Core struct {
6467 // terms are the terminals spawned for empty workspaces, by SlotEnv
6568 // token, until their window maps.
6669 terms map[string]*termSpawn
70+ // clients holds the app ID and PID of mapped windows, for State.
71+ clients map[WindowID]ports.WindowMapped
72+ sentState ports.State
6773 }
6874
6975 func keyName(s string) string {
@@ -242,11 +248,11 @@ func (c *Core) spawnSlots(ctx context.Context, shown bool) error {
242248 return nil
243249 }
244250 func New(cfg ports.Config, ch Channels) (*Core, error) {
245- if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 1) {
246- return nil, fmt.Errorf("scenes and layouts must have capacity 1")
251+ if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 1) || (ch.State != nil && cap(ch.State) != 1) {
252+ return nil, fmt.Errorf("scenes, layouts and state must have capacity 1")
247253 }
248254 // A placeholder screen holds windows until the first output arrives.
249- c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
255+ c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
250256 if err := c.apply(cfg); err != nil {
251257 return nil, err
252258 }
@@ -401,6 +407,7 @@ func (c *Core) publish(ctx context.Context) error {
401407 c.focus = focus
402408 }
403409 latest(c.ch.Scenes, scenes)
410+ c.publishState()
404411 return nil
405412 }
406413
@@ -463,6 +470,7 @@ func (c *Core) Run(ctx context.Context) error {
463470 if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil {
464471 return nil
465472 }
473+ c.publishState()
466474 for {
467475 select {
468476 case <-ctx.Done():
@@ -477,12 +485,19 @@ func (c *Core) Run(ctx context.Context) error {
477485 c.layerChanged = true
478486 c.setLayers(v.Layers)
479487 case ports.WindowMapped:
488+ c.clients[v.ID] = v
480489 if v.Slot == "" || (!c.placeSlotWindow(v.ID, v.Slot) && !c.placeTerminal(v.ID, v.Slot)) {
481490 if s, _ := c.screenOf(v.ID); s == nil {
482491 c.cur().mon.AddWindow(v.ID)
483492 }
484493 }
494+ case ports.WindowAppID:
495+ if info, ok := c.clients[v.ID]; ok {
496+ info.AppID = v.AppID
497+ c.clients[v.ID] = info
498+ }
485499 case ports.WindowUnmapped:
500+ delete(c.clients, v.ID)
486501 if s, _ := c.screenOf(v.ID); s != nil {
487502 s.mon.RemoveWindow(v.ID)
488503 }
@@ -28,6 +28,9 @@ type Channels struct {
28 // latest first (capacity 1, drained like Scenes). Input and cursors use it.28 // latest first (capacity 1, drained like Scenes). Input and cursors use it.
29 Layouts chan ports.Layout29 Layouts chan ports.Layout
30 ConfigErrors chan<- error30 ConfigErrors chan<- error
31+ // State, when set, receives a snapshot for scripts whenever it changes,
32+ // latest first (capacity 1, drained like Scenes).
33+ State chan ports.State
31 // Terminal, when set, keeps a window on every workspace on screen: an34 // Terminal, when set, keeps a window on every workspace on screen: an
32 // empty one gets the configured terminal (the terminal is the desktop).35 // empty one gets the configured terminal (the terminal is the desktop).
33 Terminal bool36 Terminal bool
@@ -64,6 +67,9 @@ type Core struct {
64 // terms are the terminals spawned for empty workspaces, by SlotEnv67 // terms are the terminals spawned for empty workspaces, by SlotEnv
65 // token, until their window maps.68 // token, until their window maps.
66 terms map[string]*termSpawn69 terms map[string]*termSpawn
70+ // clients holds the app ID and PID of mapped windows, for State.
71+ clients map[WindowID]ports.WindowMapped
72+ sentState ports.State
67 }73 }
68 74
69 func keyName(s string) string {75 func keyName(s string) string {
@@ -242,11 +248,11 @@ func (c *Core) spawnSlots(ctx context.Context, shown bool) error {
242 return nil248 return nil
243 }249 }
244 func New(cfg ports.Config, ch Channels) (*Core, error) {250 func New(cfg ports.Config, ch Channels) (*Core, error) {
245- if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 1) {251+ if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 1) || (ch.State != nil && cap(ch.State) != 1) {
246- return nil, fmt.Errorf("scenes and layouts must have capacity 1")252+ return nil, fmt.Errorf("scenes, layouts and state must have capacity 1")
247 }253 }
248 // A placeholder screen holds windows until the first output arrives.254 // A placeholder screen holds windows until the first output arrives.
249- c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}255+ c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}}
250 if err := c.apply(cfg); err != nil {256 if err := c.apply(cfg); err != nil {
251 return nil, err257 return nil, err
252 }258 }
@@ -401,6 +407,7 @@ func (c *Core) publish(ctx context.Context) error {
401 c.focus = focus407 c.focus = focus
402 }408 }
403 latest(c.ch.Scenes, scenes)409 latest(c.ch.Scenes, scenes)
410+ c.publishState()
404 return nil411 return nil
405 }412 }
406 413
@@ -463,6 +470,7 @@ func (c *Core) Run(ctx context.Context) error {
463 if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil {470 if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil {
464 return nil471 return nil
465 }472 }
473+ c.publishState()
466 for {474 for {
467 select {475 select {
468 case <-ctx.Done():476 case <-ctx.Done():
@@ -477,12 +485,19 @@ func (c *Core) Run(ctx context.Context) error {
477 c.layerChanged = true485 c.layerChanged = true
478 c.setLayers(v.Layers)486 c.setLayers(v.Layers)
479 case ports.WindowMapped:487 case ports.WindowMapped:
488+ c.clients[v.ID] = v
480 if v.Slot == "" || (!c.placeSlotWindow(v.ID, v.Slot) && !c.placeTerminal(v.ID, v.Slot)) {489 if v.Slot == "" || (!c.placeSlotWindow(v.ID, v.Slot) && !c.placeTerminal(v.ID, v.Slot)) {
481 if s, _ := c.screenOf(v.ID); s == nil {490 if s, _ := c.screenOf(v.ID); s == nil {
482 c.cur().mon.AddWindow(v.ID)491 c.cur().mon.AddWindow(v.ID)
483 }492 }
484 }493 }
494+ case ports.WindowAppID:
495+ if info, ok := c.clients[v.ID]; ok {
496+ info.AppID = v.AppID
497+ c.clients[v.ID] = info
498+ }
485 case ports.WindowUnmapped:499 case ports.WindowUnmapped:
500+ delete(c.clients, v.ID)
486 if s, _ := c.screenOf(v.ID); s != nil {501 if s, _ := c.screenOf(v.ID); s != nil {
487 s.mon.RemoveWindow(v.ID)502 s.mon.RemoveWindow(v.ID)
488 }503 }
M internal/core/screens_test.go +56 -2
@@ -2,6 +2,7 @@ package core_test
22
33 import (
44 "context"
5+ "reflect"
56 "testing"
67
78 "github.com/bnema/nefertty/internal/adapters/config"
@@ -17,6 +18,7 @@ type multiRig struct {
1718 commands chan ports.ClientCommand
1819 scenes chan []ports.Scene
1920 spawn chan ports.SpawnRequest
21+ state chan ports.State
2022 cfg ports.Config
2123 }
2224
@@ -45,9 +47,9 @@ func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...por
4547 r := &multiRig{
4648 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),
4749 output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
48- commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), cfg: cfg,
50+ commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), state: make(chan ports.State, 1), cfg: cfg,
4951 }
50- c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, Terminal: terminal})
52+ c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Terminal: terminal})
5153 if err != nil {
5254 t.Fatal(err)
5355 }
@@ -446,3 +448,55 @@ func TestKeyboardScreenFocusSticksUntilPointerLeaves(t *testing.T) {
446448 t.Fatal(got)
447449 }
448450 }
451+
452+func TestStateSnapshot(t *testing.T) {
453+ r := startMulti(t, nil, left, right)
454+ r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100}
455+ receive(t, r.scenes)
456+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
457+ <-r.state // the channel holds the latest snapshot only
458+ r.client <- ports.WindowMapped{ID: 2, AppID: "firefox", PID: 200}
459+ receive(t, r.scenes)
460+ st := receive(t, r.state)
461+ want := ports.State{
462+ Output: "DP-2",
463+ Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1}, {Name: "DP-2", Active: 1, Count: 1}},
464+ Windows: []ports.WindowState{
465+ {ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, Visible: true},
466+ {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, Visible: true},
467+ },
468+ }
469+ want.Window = &want.Windows[1]
470+ if !reflect.DeepEqual(st, want) {
471+ t.Fatalf("got %+v\nwant %+v", st, want)
472+ }
473+ // Moving to workspace 2 of DP-2: two workspaces there, window 2 off screen.
474+ r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
475+ receive(t, r.scenes)
476+ st = receive(t, r.state)
477+ if st.Outputs[1] != (ports.OutputState{Name: "DP-2", Active: 2, Count: 2}) || st.Windows[1].Visible || st.Window != nil {
478+ t.Fatalf("%+v", st)
479+ }
480+}
481+
482+func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) {
483+ r := startMulti(t, func(c *ports.Config) {
484+ c.Workspaces = []ports.WorkspaceConfig{{Name: "notes", Hidden: true}}
485+ c.Binds["Alt+n"] = "workspace notes"
486+ }, left)
487+ r.client <- ports.WindowMapped{ID: 1, PID: 100}
488+ receive(t, r.scenes)
489+ <-r.state
490+ // An app ID set after map reaches the state.
491+ r.client <- ports.WindowAppID{ID: 1, AppID: "foot"}
492+ receive(t, r.scenes)
493+ if st := receive(t, r.state); st.Windows[0].AppID != "foot" {
494+ t.Fatalf("%+v", st)
495+ }
496+ // A hidden workspace on screen: no active number, its name, window 1 off screen.
497+ r.key(t, "n", ports.ModAlt)
498+ st := receive(t, r.state)
499+ if st.Outputs[0].Active != 0 || st.Outputs[0].Workspace != "notes" || st.Windows[0].Visible {
500+ t.Fatalf("%+v", st)
501+ }
502+}
@@ -2,6 +2,7 @@ package core_test
2 2
3 import (3 import (
4 "context"4 "context"
5+ "reflect"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/nefertty/internal/adapters/config"8 "github.com/bnema/nefertty/internal/adapters/config"
@@ -17,6 +18,7 @@ type multiRig struct {
17 commands chan ports.ClientCommand18 commands chan ports.ClientCommand
18 scenes chan []ports.Scene19 scenes chan []ports.Scene
19 spawn chan ports.SpawnRequest20 spawn chan ports.SpawnRequest
21+ state chan ports.State
20 cfg ports.Config22 cfg ports.Config
21 }23 }
22 24
@@ -45,9 +47,9 @@ func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...por
45 r := &multiRig{47 r := &multiRig{
46 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),48 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),
47 output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),49 output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
48- commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), cfg: cfg,50+ commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), state: make(chan ports.State, 1), cfg: cfg,
49 }51 }
50- c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, Terminal: terminal})52+ c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Terminal: terminal})
51 if err != nil {53 if err != nil {
52 t.Fatal(err)54 t.Fatal(err)
53 }55 }
@@ -446,3 +448,55 @@ func TestKeyboardScreenFocusSticksUntilPointerLeaves(t *testing.T) {
446 t.Fatal(got)448 t.Fatal(got)
447 }449 }
448 }450 }
451+
452+func TestStateSnapshot(t *testing.T) {
453+ r := startMulti(t, nil, left, right)
454+ r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100}
455+ receive(t, r.scenes)
456+ r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
457+ <-r.state // the channel holds the latest snapshot only
458+ r.client <- ports.WindowMapped{ID: 2, AppID: "firefox", PID: 200}
459+ receive(t, r.scenes)
460+ st := receive(t, r.state)
461+ want := ports.State{
462+ Output: "DP-2",
463+ Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1}, {Name: "DP-2", Active: 1, Count: 1}},
464+ Windows: []ports.WindowState{
465+ {ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, Visible: true},
466+ {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, Visible: true},
467+ },
468+ }
469+ want.Window = &want.Windows[1]
470+ if !reflect.DeepEqual(st, want) {
471+ t.Fatalf("got %+v\nwant %+v", st, want)
472+ }
473+ // Moving to workspace 2 of DP-2: two workspaces there, window 2 off screen.
474+ r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
475+ receive(t, r.scenes)
476+ st = receive(t, r.state)
477+ if st.Outputs[1] != (ports.OutputState{Name: "DP-2", Active: 2, Count: 2}) || st.Windows[1].Visible || st.Window != nil {
478+ t.Fatalf("%+v", st)
479+ }
480+}
481+
482+func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) {
483+ r := startMulti(t, func(c *ports.Config) {
484+ c.Workspaces = []ports.WorkspaceConfig{{Name: "notes", Hidden: true}}
485+ c.Binds["Alt+n"] = "workspace notes"
486+ }, left)
487+ r.client <- ports.WindowMapped{ID: 1, PID: 100}
488+ receive(t, r.scenes)
489+ <-r.state
490+ // An app ID set after map reaches the state.
491+ r.client <- ports.WindowAppID{ID: 1, AppID: "foot"}
492+ receive(t, r.scenes)
493+ if st := receive(t, r.state); st.Windows[0].AppID != "foot" {
494+ t.Fatalf("%+v", st)
495+ }
496+ // A hidden workspace on screen: no active number, its name, window 1 off screen.
497+ r.key(t, "n", ports.ModAlt)
498+ st := receive(t, r.state)
499+ if st.Outputs[0].Active != 0 || st.Outputs[0].Workspace != "notes" || st.Windows[0].Visible {
500+ t.Fatalf("%+v", st)
501+ }
502+}
A internal/core/state.go +67 -0
@@ -0,0 +1,67 @@
1+package core
2+
3+import (
4+ "cmp"
5+ "reflect"
6+ "slices"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+)
10+
11+// publishState sends the script-facing snapshot when it changed.
12+func (c *Core) publishState() {
13+ if c.ch.State == nil {
14+ return
15+ }
16+ st := c.state()
17+ if reflect.DeepEqual(st, c.sentState) {
18+ return
19+ }
20+ c.sentState = st
21+ latest(c.ch.State, st)
22+}
23+
24+// state builds the snapshot. The placeholder screen is not an output.
25+func (c *Core) state() ports.State {
26+ st := ports.State{Output: c.cur().name(), Outputs: []ports.OutputState{}, Windows: []ports.WindowState{}}
27+ focus, _ := c.cur().mon.Focused()
28+ for _, s := range c.screens {
29+ if s.name() == "" {
30+ continue
31+ }
32+ m := s.mon
33+ cur := m.Current()
34+ o := ports.OutputState{Name: s.name(), Count: numbered(m), Workspace: cur.Name}
35+ if i := indexOf(m.Workspaces, cur); i >= 0 {
36+ o.Active = i + 1
37+ }
38+ st.Outputs = append(st.Outputs, o)
39+ for _, w := range m.all() {
40+ n := indexOf(m.Workspaces, w) + 1
41+ for _, col := range w.Columns {
42+ for _, id := range col.Windows {
43+ info := c.clients[id]
44+ st.Windows = append(st.Windows, ports.WindowState{ID: id, AppID: info.AppID, PID: info.PID, Output: s.name(), Workspace: n, Visible: w == cur})
45+ }
46+ }
47+ }
48+ }
49+ slices.SortFunc(st.Windows, func(a, b ports.WindowState) int { return cmp.Compare(a.ID, b.ID) })
50+ for i := range st.Windows {
51+ if st.Windows[i].ID == focus && focus != 0 {
52+ w := st.Windows[i]
53+ st.Window = &w
54+ }
55+ }
56+ return st
57+}
58+
59+// numbered counts numbered workspaces, without the trailing spare one
60+// unless it is on screen.
61+func numbered(m *Monitor) int {
62+ n := len(m.Workspaces)
63+ if last := m.Workspaces[n-1]; n > 1 && last.empty() && last.Name == "" && m.Current() != last {
64+ n--
65+ }
66+ return n
67+}
@@ -0,0 +1,67 @@
1+package core
2+
3+import (
4+ "cmp"
5+ "reflect"
6+ "slices"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+)
10+
11+// publishState sends the script-facing snapshot when it changed.
12+func (c *Core) publishState() {
13+ if c.ch.State == nil {
14+ return
15+ }
16+ st := c.state()
17+ if reflect.DeepEqual(st, c.sentState) {
18+ return
19+ }
20+ c.sentState = st
21+ latest(c.ch.State, st)
22+}
23+
24+// state builds the snapshot. The placeholder screen is not an output.
25+func (c *Core) state() ports.State {
26+ st := ports.State{Output: c.cur().name(), Outputs: []ports.OutputState{}, Windows: []ports.WindowState{}}
27+ focus, _ := c.cur().mon.Focused()
28+ for _, s := range c.screens {
29+ if s.name() == "" {
30+ continue
31+ }
32+ m := s.mon
33+ cur := m.Current()
34+ o := ports.OutputState{Name: s.name(), Count: numbered(m), Workspace: cur.Name}
35+ if i := indexOf(m.Workspaces, cur); i >= 0 {
36+ o.Active = i + 1
37+ }
38+ st.Outputs = append(st.Outputs, o)
39+ for _, w := range m.all() {
40+ n := indexOf(m.Workspaces, w) + 1
41+ for _, col := range w.Columns {
42+ for _, id := range col.Windows {
43+ info := c.clients[id]
44+ st.Windows = append(st.Windows, ports.WindowState{ID: id, AppID: info.AppID, PID: info.PID, Output: s.name(), Workspace: n, Visible: w == cur})
45+ }
46+ }
47+ }
48+ }
49+ slices.SortFunc(st.Windows, func(a, b ports.WindowState) int { return cmp.Compare(a.ID, b.ID) })
50+ for i := range st.Windows {
51+ if st.Windows[i].ID == focus && focus != 0 {
52+ w := st.Windows[i]
53+ st.Window = &w
54+ }
55+ }
56+ return st
57+}
58+
59+// numbered counts numbered workspaces, without the trailing spare one
60+// unless it is on screen.
61+func numbered(m *Monitor) int {
62+ n := len(m.Workspaces)
63+ if last := m.Workspaces[n-1]; n > 1 && last.empty() && last.Name == "" && m.Current() != last {
64+ n--
65+ }
66+ return n
67+}
M internal/ports/messages.go +43 -0
@@ -9,6 +9,8 @@ type WindowMapped struct {
99 AppID string
1010 // Slot is the SlotEnv value of the client process, empty if none.
1111 Slot string
12+ // PID is the client process, 0 when unknown.
13+ PID int
1214 }
1315
1416 // SlotEnv is the environment variable nefertty sets on processes it spawns
@@ -30,6 +32,14 @@ type WindowFullscreenRequest struct {
3032
3133 func (WindowFullscreenRequest) clientEvent() {}
3234
35+// WindowAppID carries wayland → core an app ID set after the window mapped.
36+type WindowAppID struct {
37+ ID WindowID
38+ AppID string
39+}
40+
41+func (WindowAppID) clientEvent() {}
42+
3343 // Layer is a wlr-layer-shell stacking layer.
3444 type Layer uint32
3545
@@ -350,3 +360,36 @@ type SceneWindow struct {
350360 // Borderless windows fill the usable width alone; no border is drawn.
351361 Borderless bool
352362 }
363+
364+// State carries core → state publisher a snapshot of what is on screen, for
365+// scripts (bars, status lines). Workspace numbers count from 1.
366+type State struct {
367+ // Output is the focused output; "" before the first one.
368+ Output string
369+ Outputs []OutputState
370+ // Window is the focused window, nil when none.
371+ Window *WindowState
372+ Windows []WindowState
373+}
374+
375+// OutputState is one output: its workspace on screen and how many numbered
376+// workspaces it has. Active is 0 while a hidden (named) workspace is shown.
377+type OutputState struct {
378+ Name string
379+ Active int
380+ Count int
381+ Workspace string // name of the workspace on screen, "" if unnamed
382+}
383+
384+// WindowState is one mapped window and where it is.
385+type WindowState struct {
386+ ID WindowID
387+ AppID string
388+ PID int
389+ Output string
390+ // Workspace is the window's numbered workspace, 0 for a hidden one.
391+ Workspace int
392+ // Visible means on the workspace on screen (it may be behind a
393+ // fullscreen window).
394+ Visible bool
395+}
@@ -9,6 +9,8 @@ type WindowMapped struct {
9 AppID string9 AppID string
10 // Slot is the SlotEnv value of the client process, empty if none.10 // Slot is the SlotEnv value of the client process, empty if none.
11 Slot string11 Slot string
12+ // PID is the client process, 0 when unknown.
13+ PID int
12 }14 }
13 15
14 // SlotEnv is the environment variable nefertty sets on processes it spawns16 // SlotEnv is the environment variable nefertty sets on processes it spawns
@@ -30,6 +32,14 @@ type WindowFullscreenRequest struct {
30 32
31 func (WindowFullscreenRequest) clientEvent() {}33 func (WindowFullscreenRequest) clientEvent() {}
32 34
35+// WindowAppID carries wayland → core an app ID set after the window mapped.
36+type WindowAppID struct {
37+ ID WindowID
38+ AppID string
39+}
40+
41+func (WindowAppID) clientEvent() {}
42+
33 // Layer is a wlr-layer-shell stacking layer.43 // Layer is a wlr-layer-shell stacking layer.
34 type Layer uint3244 type Layer uint32
35 45
@@ -350,3 +360,36 @@ type SceneWindow struct {
350 // Borderless windows fill the usable width alone; no border is drawn.360 // Borderless windows fill the usable width alone; no border is drawn.
351 Borderless bool361 Borderless bool
352 }362 }
363+
364+// State carries core → state publisher a snapshot of what is on screen, for
365+// scripts (bars, status lines). Workspace numbers count from 1.
366+type State struct {
367+ // Output is the focused output; "" before the first one.
368+ Output string
369+ Outputs []OutputState
370+ // Window is the focused window, nil when none.
371+ Window *WindowState
372+ Windows []WindowState
373+}
374+
375+// OutputState is one output: its workspace on screen and how many numbered
376+// workspaces it has. Active is 0 while a hidden (named) workspace is shown.
377+type OutputState struct {
378+ Name string
379+ Active int
380+ Count int
381+ Workspace string // name of the workspace on screen, "" if unnamed
382+}
383+
384+// WindowState is one mapped window and where it is.
385+type WindowState struct {
386+ ID WindowID
387+ AppID string
388+ PID int
389+ Output string
390+ // Workspace is the window's numbered workspace, 0 for a hidden one.
391+ Workspace int
392+ // Visible means on the workspace on screen (it may be behind a
393+ // fullscreen window).
394+ Visible bool
395+}

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 25

Tags 10

↑↓ moveenter openesc close