detached e9aeb28

feat(wayland): map xdg toplevels and apply core window commands

commit
e9aeb28461a83b499b6a08966c1503e43c5f4241
author
brice · · 2026-09-23 12:18 UTC
parent
f46a2b0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +332 -27
M internal/adapters/wayland/server.go +97 -17
@@ -2,13 +2,15 @@ package wayland
22
33 import (
44 "context"
5+ "encoding/binary"
56 "fmt"
6- "github.com/bnema/purego-libwayland/protocol/wayland"
77 "os"
8- "time"
9- "sync"
8+ "sync"
9+ "time"
1010
1111 "github.com/bnema/nefertty/internal/ports"
12+ "github.com/bnema/purego-libwayland/protocol/wayland"
13+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
1214 "github.com/bnema/purego-libwayland/server"
1315 "github.com/bnema/zerowrap"
1416 "golang.org/x/sys/unix"
@@ -18,21 +20,27 @@ type Options struct {
1820 RuntimeDir string
1921 OutputWidth, OutputHeight int
2022 }
23+
24+// Channels carries client notifications and commands. Events must be buffered.
2125 type Channels struct {
2226 Events chan<- ports.ClientEvent
2327 Commands <-chan ports.ClientCommand
2428 }
2529 type Server struct {
26- display *server.Display
27- name string
28- cleanup func()
29- log zerowrap.Logger
30- channels Channels
31- awaiting []*wayland.Callback
32- frames uint64
33- started time.Time
34- surfaces map[*server.Resource]*surface
35- serial uint32
30+ display *server.Display
31+ name string
32+ cleanup func()
33+ log zerowrap.Logger
34+ channels Channels
35+ awaiting []*wayland.Callback
36+ frames uint64
37+ started time.Time
38+ surfaces map[*server.Resource]*surface
39+ serial uint32
40+ ctx context.Context
41+ windows map[ports.WindowID]*window
42+ nextWindow ports.WindowID
43+ focused ports.WindowID
3644 }
3745
3846 func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
@@ -55,7 +63,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
5563 cleanup()
5664 return nil, err
5765 }
58- s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface)}
66+ s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), windows: make(map[ports.WindowID]*window), nextWindow: 1}
5967 if err = registerGlobals(d, opts, s); err != nil {
6068 cleanup()
6169 return nil, err
@@ -67,8 +75,34 @@ func (s *Server) Run(ctx context.Context) error {
6775 defer s.cleanup()
6876 s.log.Info().Str("socket", s.name).Msg("starting wayland")
6977 s.started = time.Now()
78+ s.ctx = ctx
7079 var wg sync.WaitGroup
71- wg.Add(1)
80+ wg.Add(2)
81+ go func() {
82+ defer wg.Done()
83+ for {
84+ select {
85+ case <-ctx.Done():
86+ return
87+ case <-s.display.Stopped():
88+ return
89+ case cmd, ok := <-s.channels.Commands:
90+ if !ok {
91+ return
92+ }
93+ if ctx.Err() != nil {
94+ return
95+ }
96+ if !s.display.Do(func() {
97+ if ctx.Err() == nil {
98+ s.apply(cmd)
99+ }
100+ }) {
101+ return
102+ }
103+ }
104+ }
105+ }()
72106 go func() {
73107 defer wg.Done()
74108 ticker := time.NewTicker(16 * time.Millisecond)
@@ -77,11 +111,13 @@ func (s *Server) Run(ctx context.Context) error {
77111 select {
78112 case <-ctx.Done():
79113 return
114+ case <-s.display.Stopped():
115+ return
80116 case <-ticker.C:
81117 if ctx.Err() != nil {
82118 return
83119 }
84- s.display.Do(func() {
120+ if !s.display.Do(func() {
85121 if ctx.Err() != nil {
86122 return
87123 }
@@ -95,7 +131,9 @@ func (s *Server) Run(ctx context.Context) error {
95131 cb.Destroy()
96132 s.frames++
97133 }
98- })
134+ }) {
135+ return
136+ }
99137 }
100138 }
101139 }()
@@ -103,3 +141,45 @@ func (s *Server) Run(ctx context.Context) error {
103141 wg.Wait()
104142 return err
105143 }
144+
145+func (s *Server) emit(ev ports.ClientEvent) {
146+ if s.channels.Events == nil {
147+ return
148+ }
149+ select {
150+ case s.channels.Events <- ev:
151+ case <-s.ctx.Done():
152+ }
153+}
154+
155+func (s *Server) apply(cmd ports.ClientCommand) {
156+ switch c := cmd.(type) {
157+ case ports.FocusWindow:
158+ s.focused = c.ID
159+ case ports.ForwardKey:
160+ s.log.Debug().Uint64("id", uint64(c.ID)).Msg("forward key")
161+ case ports.ConfigureWindow:
162+ w := s.windows[c.ID]
163+ if w == nil || !w.toplevel.Resource.Alive() || !w.xdg.resource.Resource.Alive() {
164+ s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window")
165+ return
166+ }
167+ var states []byte
168+ if c.Fullscreen {
169+ states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateFullscreen))
170+ }
171+ if c.Activated {
172+ states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateActivated))
173+ }
174+ w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), states)
175+ s.serial++
176+ w.xdg.resource.SendConfigure(s.serial)
177+ case ports.CloseWindow:
178+ w := s.windows[c.ID]
179+ if w == nil || !w.toplevel.Resource.Alive() {
180+ s.log.Debug().Uint64("id", uint64(c.ID)).Msg("close missing window")
181+ return
182+ }
183+ w.toplevel.SendClose()
184+ }
185+}
@@ -2,13 +2,15 @@ package wayland
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/binary"
5 "fmt"6 "fmt"
6- "github.com/bnema/purego-libwayland/protocol/wayland"
7 "os"7 "os"
8- "time"8+ "sync"
9- "sync"9+ "time"
10 10
11 "github.com/bnema/nefertty/internal/ports"11 "github.com/bnema/nefertty/internal/ports"
12+ "github.com/bnema/purego-libwayland/protocol/wayland"
13+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
12 "github.com/bnema/purego-libwayland/server"14 "github.com/bnema/purego-libwayland/server"
13 "github.com/bnema/zerowrap"15 "github.com/bnema/zerowrap"
14 "golang.org/x/sys/unix"16 "golang.org/x/sys/unix"
@@ -18,21 +20,27 @@ type Options struct {
18 RuntimeDir string20 RuntimeDir string
19 OutputWidth, OutputHeight int21 OutputWidth, OutputHeight int
20 }22 }
23+
24+// Channels carries client notifications and commands. Events must be buffered.
21 type Channels struct {25 type Channels struct {
22 Events chan<- ports.ClientEvent26 Events chan<- ports.ClientEvent
23 Commands <-chan ports.ClientCommand27 Commands <-chan ports.ClientCommand
24 }28 }
25 type Server struct {29 type Server struct {
26- display *server.Display30+ display *server.Display
27- name string31+ name string
28- cleanup func()32+ cleanup func()
29- log zerowrap.Logger33+ log zerowrap.Logger
30- channels Channels34+ channels Channels
31- awaiting []*wayland.Callback35+ awaiting []*wayland.Callback
32- frames uint6436+ frames uint64
33- started time.Time37+ started time.Time
34- surfaces map[*server.Resource]*surface38+ surfaces map[*server.Resource]*surface
35- serial uint3239+ serial uint32
40+ ctx context.Context
41+ windows map[ports.WindowID]*window
42+ nextWindow ports.WindowID
43+ focused ports.WindowID
36 }44 }
37 45
38 func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {46 func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
@@ -55,7 +63,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
55 cleanup()63 cleanup()
56 return nil, err64 return nil, err
57 }65 }
58- s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface)}66+ s := &Server{display: d, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), windows: make(map[ports.WindowID]*window), nextWindow: 1}
59 if err = registerGlobals(d, opts, s); err != nil {67 if err = registerGlobals(d, opts, s); err != nil {
60 cleanup()68 cleanup()
61 return nil, err69 return nil, err
@@ -67,8 +75,34 @@ func (s *Server) Run(ctx context.Context) error {
67 defer s.cleanup()75 defer s.cleanup()
68 s.log.Info().Str("socket", s.name).Msg("starting wayland")76 s.log.Info().Str("socket", s.name).Msg("starting wayland")
69 s.started = time.Now()77 s.started = time.Now()
78+ s.ctx = ctx
70 var wg sync.WaitGroup79 var wg sync.WaitGroup
71- wg.Add(1)80+ wg.Add(2)
81+ go func() {
82+ defer wg.Done()
83+ for {
84+ select {
85+ case <-ctx.Done():
86+ return
87+ case <-s.display.Stopped():
88+ return
89+ case cmd, ok := <-s.channels.Commands:
90+ if !ok {
91+ return
92+ }
93+ if ctx.Err() != nil {
94+ return
95+ }
96+ if !s.display.Do(func() {
97+ if ctx.Err() == nil {
98+ s.apply(cmd)
99+ }
100+ }) {
101+ return
102+ }
103+ }
104+ }
105+ }()
72 go func() {106 go func() {
73 defer wg.Done()107 defer wg.Done()
74 ticker := time.NewTicker(16 * time.Millisecond)108 ticker := time.NewTicker(16 * time.Millisecond)
@@ -77,11 +111,13 @@ func (s *Server) Run(ctx context.Context) error {
77 select {111 select {
78 case <-ctx.Done():112 case <-ctx.Done():
79 return113 return
114+ case <-s.display.Stopped():
115+ return
80 case <-ticker.C:116 case <-ticker.C:
81 if ctx.Err() != nil {117 if ctx.Err() != nil {
82 return118 return
83 }119 }
84- s.display.Do(func() {120+ if !s.display.Do(func() {
85 if ctx.Err() != nil {121 if ctx.Err() != nil {
86 return122 return
87 }123 }
@@ -95,7 +131,9 @@ func (s *Server) Run(ctx context.Context) error {
95 cb.Destroy()131 cb.Destroy()
96 s.frames++132 s.frames++
97 }133 }
98- })134+ }) {
135+ return
136+ }
99 }137 }
100 }138 }
101 }()139 }()
@@ -103,3 +141,45 @@ func (s *Server) Run(ctx context.Context) error {
103 wg.Wait()141 wg.Wait()
104 return err142 return err
105 }143 }
144+
145+func (s *Server) emit(ev ports.ClientEvent) {
146+ if s.channels.Events == nil {
147+ return
148+ }
149+ select {
150+ case s.channels.Events <- ev:
151+ case <-s.ctx.Done():
152+ }
153+}
154+
155+func (s *Server) apply(cmd ports.ClientCommand) {
156+ switch c := cmd.(type) {
157+ case ports.FocusWindow:
158+ s.focused = c.ID
159+ case ports.ForwardKey:
160+ s.log.Debug().Uint64("id", uint64(c.ID)).Msg("forward key")
161+ case ports.ConfigureWindow:
162+ w := s.windows[c.ID]
163+ if w == nil || !w.toplevel.Resource.Alive() || !w.xdg.resource.Resource.Alive() {
164+ s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window")
165+ return
166+ }
167+ var states []byte
168+ if c.Fullscreen {
169+ states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateFullscreen))
170+ }
171+ if c.Activated {
172+ states = binary.LittleEndian.AppendUint32(states, uint32(xdgshell.ToplevelStateActivated))
173+ }
174+ w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), states)
175+ s.serial++
176+ w.xdg.resource.SendConfigure(s.serial)
177+ case ports.CloseWindow:
178+ w := s.windows[c.ID]
179+ if w == nil || !w.toplevel.Resource.Alive() {
180+ s.log.Debug().Uint64("id", uint64(c.ID)).Msg("close missing window")
181+ return
182+ }
183+ w.toplevel.SendClose()
184+ }
185+}
M internal/adapters/wayland/server_test.go +174 -0
@@ -1,11 +1,14 @@
11 package wayland
22
33 import (
4+ "bytes"
45 "context"
6+ "github.com/bnema/nefertty/internal/ports"
57 "os"
68 "os/exec"
79 "path/filepath"
810 "strings"
11+ "sync"
912 "testing"
1013 "time"
1114
@@ -115,3 +118,174 @@ func TestWestonFrames(t *testing.T) {
115118 }
116119 }
117120 }
121+
122+// lifecycleServer starts a display with buffered command and event channels.
123+func lifecycleServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, string) {
124+ t.Helper()
125+ dir := t.TempDir()
126+ events := make(chan ports.ClientEvent, 16)
127+ commands := make(chan ports.ClientCommand, 16)
128+ s, err := New(Options{RuntimeDir: dir, OutputWidth: 1920, OutputHeight: 1080}, Channels{Events: events, Commands: commands}, logging.For(context.Background(), "wayland"))
129+ if err != nil {
130+ t.Fatal(err)
131+ }
132+ ctx, cancel := context.WithCancel(context.Background())
133+ done := make(chan error, 1)
134+ go func() { done <- s.Run(ctx) }()
135+ t.Cleanup(func() {
136+ cancel()
137+ select {
138+ case err := <-done:
139+ if err != nil {
140+ t.Errorf("Run: %v", err)
141+ }
142+ case <-time.After(2 * time.Second):
143+ t.Error("Run hung")
144+ }
145+ })
146+ return s, events, commands, dir
147+}
148+
149+func lifecycleClient(t *testing.T, tool string, s *Server, dir string, extra []string, args ...string) (*exec.Cmd, <-chan error, *lockedBuffer) {
150+ t.Helper()
151+ ctx, cancel := context.WithCancel(context.Background())
152+ cmd := exec.CommandContext(ctx, tool, args...)
153+ cmd.Env = append(append(os.Environ(), "XDG_RUNTIME_DIR="+dir, "WAYLAND_DISPLAY="+s.SocketName()), extra...)
154+ output := &lockedBuffer{}
155+ cmd.Stderr = output
156+ if err := cmd.Start(); err != nil {
157+ cancel()
158+ t.Fatal(err)
159+ }
160+ done := make(chan error, 1)
161+ finished := make(chan struct{})
162+ go func() { done <- cmd.Wait(); close(finished) }()
163+ t.Cleanup(func() {
164+ cancel()
165+ _ = cmd.Process.Kill()
166+ select {
167+ case <-finished:
168+ case <-time.After(2 * time.Second):
169+ t.Error("client hung")
170+ }
171+ })
172+ return cmd, done, output
173+}
174+
175+type lockedBuffer struct {
176+ mu sync.Mutex
177+ b bytes.Buffer
178+}
179+
180+func (b *lockedBuffer) Write(p []byte) (int, error) {
181+ b.mu.Lock()
182+ defer b.mu.Unlock()
183+ return b.b.Write(p)
184+}
185+func (b *lockedBuffer) String() string { b.mu.Lock(); defer b.mu.Unlock(); return b.b.String() }
186+
187+func waitEvent(t *testing.T, events <-chan ports.ClientEvent, duration time.Duration) ports.ClientEvent {
188+ t.Helper()
189+ select {
190+ case ev := <-events:
191+ return ev
192+ case <-time.After(duration):
193+ t.Fatal("event timeout")
194+ return nil
195+ }
196+}
197+func mapped(t *testing.T, events <-chan ports.ClientEvent, duration time.Duration) ports.WindowMapped {
198+ t.Helper()
199+ ev := waitEvent(t, events, duration)
200+ w, ok := ev.(ports.WindowMapped)
201+ if !ok {
202+ t.Fatalf("expected mapped, got %T", ev)
203+ }
204+ return w
205+}
206+func unmapped(t *testing.T, events <-chan ports.ClientEvent, id ports.WindowID) {
207+ t.Helper()
208+ ev := waitEvent(t, events, 3*time.Second)
209+ w, ok := ev.(ports.WindowUnmapped)
210+ if !ok || w.ID != id {
211+ t.Fatalf("expected unmapped %d, got %#v", id, ev)
212+ }
213+}
214+
215+func TestWindowClose(t *testing.T) {
216+ tool, err := exec.LookPath("weston-simple-shm")
217+ if err != nil {
218+ t.Skip("weston-simple-shm not installed")
219+ }
220+ s, events, commands, dir := lifecycleServer(t)
221+ _, done, output := lifecycleClient(t, tool, s, dir, nil)
222+ w := mapped(t, events, 5*time.Second)
223+ commands <- ports.CloseWindow{ID: w.ID}
224+ select {
225+ case err := <-done:
226+ if err != nil {
227+ t.Errorf("client: %v: %s", err, output.String())
228+ }
229+ case <-time.After(5 * time.Second):
230+ t.Fatal("client did not close")
231+ }
232+ unmapped(t, events, w.ID)
233+}
234+func TestWindowConfigure(t *testing.T) {
235+ tool, err := exec.LookPath("weston-simple-shm")
236+ if err != nil {
237+ t.Skip("weston-simple-shm not installed")
238+ }
239+ s, events, commands, dir := lifecycleServer(t)
240+ _, _, output := lifecycleClient(t, tool, s, dir, []string{"WAYLAND_DEBUG=client"})
241+ w := mapped(t, events, 5*time.Second)
242+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 640, Height: 480, Activated: true}
243+ deadline := time.After(3 * time.Second)
244+ tick := time.NewTicker(10 * time.Millisecond)
245+ defer tick.Stop()
246+ for {
247+ select {
248+ case <-deadline:
249+ t.Fatalf("missing configure(640, 480: %s", output.String())
250+ case <-tick.C:
251+ if strings.Contains(output.String(), "configure(640, 480") {
252+ return
253+ }
254+ }
255+ }
256+}
257+func TestWindowKilled(t *testing.T) {
258+ tool, err := exec.LookPath("weston-simple-shm")
259+ if err != nil {
260+ t.Skip("weston-simple-shm not installed")
261+ }
262+ s, events, _, dir := lifecycleServer(t)
263+ cmd, _, _ := lifecycleClient(t, tool, s, dir, nil)
264+ w := mapped(t, events, 5*time.Second)
265+ if err := cmd.Process.Kill(); err != nil {
266+ t.Fatal(err)
267+ }
268+ unmapped(t, events, w.ID)
269+}
270+func TestFootWindow(t *testing.T) {
271+ tool, err := exec.LookPath("foot")
272+ if err != nil {
273+ t.Skip("foot not installed")
274+ }
275+ s, events, _, dir := lifecycleServer(t)
276+ _, done, output := lifecycleClient(t, tool, s, dir, []string{"HOME=" + t.TempDir(), "PATH=" + os.Getenv("PATH")}, "-c", "/dev/null", "sh", "-c", "sleep 3")
277+ select {
278+ case ev := <-events:
279+ w, ok := ev.(ports.WindowMapped)
280+ if !ok {
281+ t.Fatalf("expected mapped, got %T", ev)
282+ }
283+ if w.AppID != "foot" {
284+ t.Fatalf("app ID: %q", w.AppID)
285+ }
286+ case err := <-done:
287+ t.Skipf("foot unavailable: %v: %s", err, output.String())
288+ case <-time.After(10 * time.Second):
289+ t.Skipf("foot did not map: %s", output.String())
290+ }
291+}
@@ -1,11 +1,14 @@
1 package wayland1 package wayland
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
6+ "github.com/bnema/nefertty/internal/ports"
5 "os"7 "os"
6 "os/exec"8 "os/exec"
7 "path/filepath"9 "path/filepath"
8 "strings"10 "strings"
11+ "sync"
9 "testing"12 "testing"
10 "time"13 "time"
11 14
@@ -115,3 +118,174 @@ func TestWestonFrames(t *testing.T) {
115 }118 }
116 }119 }
117 }120 }
121+
122+// lifecycleServer starts a display with buffered command and event channels.
123+func lifecycleServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, string) {
124+ t.Helper()
125+ dir := t.TempDir()
126+ events := make(chan ports.ClientEvent, 16)
127+ commands := make(chan ports.ClientCommand, 16)
128+ s, err := New(Options{RuntimeDir: dir, OutputWidth: 1920, OutputHeight: 1080}, Channels{Events: events, Commands: commands}, logging.For(context.Background(), "wayland"))
129+ if err != nil {
130+ t.Fatal(err)
131+ }
132+ ctx, cancel := context.WithCancel(context.Background())
133+ done := make(chan error, 1)
134+ go func() { done <- s.Run(ctx) }()
135+ t.Cleanup(func() {
136+ cancel()
137+ select {
138+ case err := <-done:
139+ if err != nil {
140+ t.Errorf("Run: %v", err)
141+ }
142+ case <-time.After(2 * time.Second):
143+ t.Error("Run hung")
144+ }
145+ })
146+ return s, events, commands, dir
147+}
148+
149+func lifecycleClient(t *testing.T, tool string, s *Server, dir string, extra []string, args ...string) (*exec.Cmd, <-chan error, *lockedBuffer) {
150+ t.Helper()
151+ ctx, cancel := context.WithCancel(context.Background())
152+ cmd := exec.CommandContext(ctx, tool, args...)
153+ cmd.Env = append(append(os.Environ(), "XDG_RUNTIME_DIR="+dir, "WAYLAND_DISPLAY="+s.SocketName()), extra...)
154+ output := &lockedBuffer{}
155+ cmd.Stderr = output
156+ if err := cmd.Start(); err != nil {
157+ cancel()
158+ t.Fatal(err)
159+ }
160+ done := make(chan error, 1)
161+ finished := make(chan struct{})
162+ go func() { done <- cmd.Wait(); close(finished) }()
163+ t.Cleanup(func() {
164+ cancel()
165+ _ = cmd.Process.Kill()
166+ select {
167+ case <-finished:
168+ case <-time.After(2 * time.Second):
169+ t.Error("client hung")
170+ }
171+ })
172+ return cmd, done, output
173+}
174+
175+type lockedBuffer struct {
176+ mu sync.Mutex
177+ b bytes.Buffer
178+}
179+
180+func (b *lockedBuffer) Write(p []byte) (int, error) {
181+ b.mu.Lock()
182+ defer b.mu.Unlock()
183+ return b.b.Write(p)
184+}
185+func (b *lockedBuffer) String() string { b.mu.Lock(); defer b.mu.Unlock(); return b.b.String() }
186+
187+func waitEvent(t *testing.T, events <-chan ports.ClientEvent, duration time.Duration) ports.ClientEvent {
188+ t.Helper()
189+ select {
190+ case ev := <-events:
191+ return ev
192+ case <-time.After(duration):
193+ t.Fatal("event timeout")
194+ return nil
195+ }
196+}
197+func mapped(t *testing.T, events <-chan ports.ClientEvent, duration time.Duration) ports.WindowMapped {
198+ t.Helper()
199+ ev := waitEvent(t, events, duration)
200+ w, ok := ev.(ports.WindowMapped)
201+ if !ok {
202+ t.Fatalf("expected mapped, got %T", ev)
203+ }
204+ return w
205+}
206+func unmapped(t *testing.T, events <-chan ports.ClientEvent, id ports.WindowID) {
207+ t.Helper()
208+ ev := waitEvent(t, events, 3*time.Second)
209+ w, ok := ev.(ports.WindowUnmapped)
210+ if !ok || w.ID != id {
211+ t.Fatalf("expected unmapped %d, got %#v", id, ev)
212+ }
213+}
214+
215+func TestWindowClose(t *testing.T) {
216+ tool, err := exec.LookPath("weston-simple-shm")
217+ if err != nil {
218+ t.Skip("weston-simple-shm not installed")
219+ }
220+ s, events, commands, dir := lifecycleServer(t)
221+ _, done, output := lifecycleClient(t, tool, s, dir, nil)
222+ w := mapped(t, events, 5*time.Second)
223+ commands <- ports.CloseWindow{ID: w.ID}
224+ select {
225+ case err := <-done:
226+ if err != nil {
227+ t.Errorf("client: %v: %s", err, output.String())
228+ }
229+ case <-time.After(5 * time.Second):
230+ t.Fatal("client did not close")
231+ }
232+ unmapped(t, events, w.ID)
233+}
234+func TestWindowConfigure(t *testing.T) {
235+ tool, err := exec.LookPath("weston-simple-shm")
236+ if err != nil {
237+ t.Skip("weston-simple-shm not installed")
238+ }
239+ s, events, commands, dir := lifecycleServer(t)
240+ _, _, output := lifecycleClient(t, tool, s, dir, []string{"WAYLAND_DEBUG=client"})
241+ w := mapped(t, events, 5*time.Second)
242+ commands <- ports.ConfigureWindow{ID: w.ID, Width: 640, Height: 480, Activated: true}
243+ deadline := time.After(3 * time.Second)
244+ tick := time.NewTicker(10 * time.Millisecond)
245+ defer tick.Stop()
246+ for {
247+ select {
248+ case <-deadline:
249+ t.Fatalf("missing configure(640, 480: %s", output.String())
250+ case <-tick.C:
251+ if strings.Contains(output.String(), "configure(640, 480") {
252+ return
253+ }
254+ }
255+ }
256+}
257+func TestWindowKilled(t *testing.T) {
258+ tool, err := exec.LookPath("weston-simple-shm")
259+ if err != nil {
260+ t.Skip("weston-simple-shm not installed")
261+ }
262+ s, events, _, dir := lifecycleServer(t)
263+ cmd, _, _ := lifecycleClient(t, tool, s, dir, nil)
264+ w := mapped(t, events, 5*time.Second)
265+ if err := cmd.Process.Kill(); err != nil {
266+ t.Fatal(err)
267+ }
268+ unmapped(t, events, w.ID)
269+}
270+func TestFootWindow(t *testing.T) {
271+ tool, err := exec.LookPath("foot")
272+ if err != nil {
273+ t.Skip("foot not installed")
274+ }
275+ s, events, _, dir := lifecycleServer(t)
276+ _, done, output := lifecycleClient(t, tool, s, dir, []string{"HOME=" + t.TempDir(), "PATH=" + os.Getenv("PATH")}, "-c", "/dev/null", "sh", "-c", "sleep 3")
277+ select {
278+ case ev := <-events:
279+ w, ok := ev.(ports.WindowMapped)
280+ if !ok {
281+ t.Fatalf("expected mapped, got %T", ev)
282+ }
283+ if w.AppID != "foot" {
284+ t.Fatalf("app ID: %q", w.AppID)
285+ }
286+ case err := <-done:
287+ t.Skipf("foot unavailable: %v: %s", err, output.String())
288+ case <-time.After(10 * time.Second):
289+ t.Skipf("foot did not map: %s", output.String())
290+ }
291+}
M internal/adapters/wayland/xdg.go +61 -10
@@ -1,6 +1,7 @@
11 package wayland
22
33 import (
4+ "github.com/bnema/nefertty/internal/ports"
45 "github.com/bnema/purego-libwayland/protocol/wayland"
56 "github.com/bnema/purego-libwayland/protocol/xdgshell"
67 "github.com/bnema/purego-libwayland/server"
@@ -55,24 +56,63 @@ type xdgSurface struct {
5556 server *Server
5657 surface *surface
5758 configured bool
59+ acked bool
60+ window *window
5861 serial uint32
5962 }
6063
6164 func (x *xdgSurface) Destroy(*xdgshell.Surface) {}
65+
66+type window struct {
67+ id ports.WindowID
68+ toplevel *xdgshell.Toplevel
69+ xdg *xdgSurface
70+ appID, title string
71+ mapped bool
72+}
73+
74+func (w *window) unmap() {
75+ if !w.mapped {
76+ return
77+ }
78+ w.mapped = false
79+ w.xdg.configured = false
80+ w.xdg.acked = false
81+ w.xdg.server.emit(ports.WindowUnmapped{ID: w.id})
82+}
6283 func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
63- t, err := xdgshell.NewToplevel(r.Client(), r.Version(), id, &top{x})
84+ if x.window != nil {
85+ r.PostError(uint32(xdgshell.SurfaceErrorAlreadyConstructed), "toplevel already constructed")
86+ return
87+ }
88+ w := &window{id: x.server.nextWindow, xdg: x}
89+ t, err := xdgshell.NewToplevel(r.Client(), r.Version(), id, &top{w})
6490 if err != nil {
6591 return
6692 }
93+ w.toplevel = t
94+ x.window = w
95+ x.server.nextWindow++
96+ x.server.windows[w.id] = w
6797 x.surface.role = func(buffer bool) {
68- if !buffer && !x.configured {
98+ if x.surface.destroyed {
99+ w.unmap()
100+ return
101+ }
102+ if !buffer && !w.mapped && !x.configured {
69103 x.configured = true
70104 x.server.serial++
71105 t.SendConfigure(0, 0, nil)
72106 r.SendConfigure(x.server.serial)
107+ } else if buffer && !w.mapped && x.acked {
108+ w.mapped = true
109+ x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID})
110+ x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Msg("window mapped")
111+ } else if !buffer && w.mapped {
112+ w.unmap()
73113 }
74114 }
75- t.OnDestroy = func() { x.surface.role = nil }
115+ t.OnDestroy = func() { w.unmap(); delete(x.server.windows, w.id); x.window = nil; x.surface.role = nil }
76116 }
77117 func (x *xdgSurface) GetPopup(r *xdgshell.Surface, id uint32, _ *xdgshell.Surface, _ *xdgshell.Positioner) {
78118 if p, e := xdgshell.NewPopup(r.Client(), r.Version(), id, popup{}); e == nil {
@@ -80,14 +120,17 @@ func (x *xdgSurface) GetPopup(r *xdgshell.Surface, id uint32, _ *xdgshell.Surfac
80120 }
81121 }
82122 func (x *xdgSurface) SetWindowGeometry(*xdgshell.Surface, int32, int32, int32, int32) {}
83-func (x *xdgSurface) AckConfigure(_ *xdgshell.Surface, serial uint32) { x.serial = serial }
123+func (x *xdgSurface) AckConfigure(_ *xdgshell.Surface, serial uint32) {
124+ x.serial = serial
125+ x.acked = true
126+}
84127
85-type top struct{ x *xdgSurface }
128+type top struct{ w *window }
86129
87130 func (top) Destroy(*xdgshell.Toplevel) {}
88131 func (top) SetParent(*xdgshell.Toplevel, *xdgshell.Toplevel) {}
89-func (top) SetTitle(*xdgshell.Toplevel, string) {}
90-func (top) SetAppId(*xdgshell.Toplevel, string) {}
132+func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { t.w.title = title }
133+func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { t.w.appID = appID }
91134 func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {}
92135 func (top) Move(*xdgshell.Toplevel, *wayland.Seat, uint32) {}
93136 func (top) Resize(*xdgshell.Toplevel, *wayland.Seat, uint32, uint32) {}
@@ -95,9 +138,17 @@ func (top) SetMaxSize(*xdgshell.Toplevel, int32, int32)
95138 func (top) SetMinSize(*xdgshell.Toplevel, int32, int32) {}
96139 func (top) SetMaximized(*xdgshell.Toplevel) {}
97140 func (top) UnsetMaximized(*xdgshell.Toplevel) {}
98-func (top) SetFullscreen(*xdgshell.Toplevel, *wayland.Output) {}
99-func (top) UnsetFullscreen(*xdgshell.Toplevel) {}
100-func (top) SetMinimized(*xdgshell.Toplevel) {}
141+func (t top) SetFullscreen(*xdgshell.Toplevel, *wayland.Output) {
142+ if t.w.mapped {
143+ t.w.xdg.server.emit(ports.WindowFullscreenRequest{ID: t.w.id, Fullscreen: true})
144+ }
145+}
146+func (t top) UnsetFullscreen(*xdgshell.Toplevel) {
147+ if t.w.mapped {
148+ t.w.xdg.server.emit(ports.WindowFullscreenRequest{ID: t.w.id})
149+ }
150+}
151+func (top) SetMinimized(*xdgshell.Toplevel) {}
101152
102153 type popup struct{}
103154
@@ -1,6 +1,7 @@
1 package wayland1 package wayland
2 2
3 import (3 import (
4+ "github.com/bnema/nefertty/internal/ports"
4 "github.com/bnema/purego-libwayland/protocol/wayland"5 "github.com/bnema/purego-libwayland/protocol/wayland"
5 "github.com/bnema/purego-libwayland/protocol/xdgshell"6 "github.com/bnema/purego-libwayland/protocol/xdgshell"
6 "github.com/bnema/purego-libwayland/server"7 "github.com/bnema/purego-libwayland/server"
@@ -55,24 +56,63 @@ type xdgSurface struct {
55 server *Server56 server *Server
56 surface *surface57 surface *surface
57 configured bool58 configured bool
59+ acked bool
60+ window *window
58 serial uint3261 serial uint32
59 }62 }
60 63
61 func (x *xdgSurface) Destroy(*xdgshell.Surface) {}64 func (x *xdgSurface) Destroy(*xdgshell.Surface) {}
65+
66+type window struct {
67+ id ports.WindowID
68+ toplevel *xdgshell.Toplevel
69+ xdg *xdgSurface
70+ appID, title string
71+ mapped bool
72+}
73+
74+func (w *window) unmap() {
75+ if !w.mapped {
76+ return
77+ }
78+ w.mapped = false
79+ w.xdg.configured = false
80+ w.xdg.acked = false
81+ w.xdg.server.emit(ports.WindowUnmapped{ID: w.id})
82+}
62 func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {83 func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
63- t, err := xdgshell.NewToplevel(r.Client(), r.Version(), id, &top{x})84+ if x.window != nil {
85+ r.PostError(uint32(xdgshell.SurfaceErrorAlreadyConstructed), "toplevel already constructed")
86+ return
87+ }
88+ w := &window{id: x.server.nextWindow, xdg: x}
89+ t, err := xdgshell.NewToplevel(r.Client(), r.Version(), id, &top{w})
64 if err != nil {90 if err != nil {
65 return91 return
66 }92 }
93+ w.toplevel = t
94+ x.window = w
95+ x.server.nextWindow++
96+ x.server.windows[w.id] = w
67 x.surface.role = func(buffer bool) {97 x.surface.role = func(buffer bool) {
68- if !buffer && !x.configured {98+ if x.surface.destroyed {
99+ w.unmap()
100+ return
101+ }
102+ if !buffer && !w.mapped && !x.configured {
69 x.configured = true103 x.configured = true
70 x.server.serial++104 x.server.serial++
71 t.SendConfigure(0, 0, nil)105 t.SendConfigure(0, 0, nil)
72 r.SendConfigure(x.server.serial)106 r.SendConfigure(x.server.serial)
107+ } else if buffer && !w.mapped && x.acked {
108+ w.mapped = true
109+ x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID})
110+ x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Msg("window mapped")
111+ } else if !buffer && w.mapped {
112+ w.unmap()
73 }113 }
74 }114 }
75- t.OnDestroy = func() { x.surface.role = nil }115+ t.OnDestroy = func() { w.unmap(); delete(x.server.windows, w.id); x.window = nil; x.surface.role = nil }
76 }116 }
77 func (x *xdgSurface) GetPopup(r *xdgshell.Surface, id uint32, _ *xdgshell.Surface, _ *xdgshell.Positioner) {117 func (x *xdgSurface) GetPopup(r *xdgshell.Surface, id uint32, _ *xdgshell.Surface, _ *xdgshell.Positioner) {
78 if p, e := xdgshell.NewPopup(r.Client(), r.Version(), id, popup{}); e == nil {118 if p, e := xdgshell.NewPopup(r.Client(), r.Version(), id, popup{}); e == nil {
@@ -80,14 +120,17 @@ func (x *xdgSurface) GetPopup(r *xdgshell.Surface, id uint32, _ *xdgshell.Surfac
80 }120 }
81 }121 }
82 func (x *xdgSurface) SetWindowGeometry(*xdgshell.Surface, int32, int32, int32, int32) {}122 func (x *xdgSurface) SetWindowGeometry(*xdgshell.Surface, int32, int32, int32, int32) {}
83-func (x *xdgSurface) AckConfigure(_ *xdgshell.Surface, serial uint32) { x.serial = serial }123+func (x *xdgSurface) AckConfigure(_ *xdgshell.Surface, serial uint32) {
124+ x.serial = serial
125+ x.acked = true
126+}
84 127
85-type top struct{ x *xdgSurface }128+type top struct{ w *window }
86 129
87 func (top) Destroy(*xdgshell.Toplevel) {}130 func (top) Destroy(*xdgshell.Toplevel) {}
88 func (top) SetParent(*xdgshell.Toplevel, *xdgshell.Toplevel) {}131 func (top) SetParent(*xdgshell.Toplevel, *xdgshell.Toplevel) {}
89-func (top) SetTitle(*xdgshell.Toplevel, string) {}132+func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { t.w.title = title }
90-func (top) SetAppId(*xdgshell.Toplevel, string) {}133+func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { t.w.appID = appID }
91 func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {}134 func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {}
92 func (top) Move(*xdgshell.Toplevel, *wayland.Seat, uint32) {}135 func (top) Move(*xdgshell.Toplevel, *wayland.Seat, uint32) {}
93 func (top) Resize(*xdgshell.Toplevel, *wayland.Seat, uint32, uint32) {}136 func (top) Resize(*xdgshell.Toplevel, *wayland.Seat, uint32, uint32) {}
@@ -95,9 +138,17 @@ func (top) SetMaxSize(*xdgshell.Toplevel, int32, int32)
95 func (top) SetMinSize(*xdgshell.Toplevel, int32, int32) {}138 func (top) SetMinSize(*xdgshell.Toplevel, int32, int32) {}
96 func (top) SetMaximized(*xdgshell.Toplevel) {}139 func (top) SetMaximized(*xdgshell.Toplevel) {}
97 func (top) UnsetMaximized(*xdgshell.Toplevel) {}140 func (top) UnsetMaximized(*xdgshell.Toplevel) {}
98-func (top) SetFullscreen(*xdgshell.Toplevel, *wayland.Output) {}141+func (t top) SetFullscreen(*xdgshell.Toplevel, *wayland.Output) {
99-func (top) UnsetFullscreen(*xdgshell.Toplevel) {}142+ if t.w.mapped {
100-func (top) SetMinimized(*xdgshell.Toplevel) {}143+ t.w.xdg.server.emit(ports.WindowFullscreenRequest{ID: t.w.id, Fullscreen: true})
144+ }
145+}
146+func (t top) UnsetFullscreen(*xdgshell.Toplevel) {
147+ if t.w.mapped {
148+ t.w.xdg.server.emit(ports.WindowFullscreenRequest{ID: t.w.id})
149+ }
150+}
151+func (top) SetMinimized(*xdgshell.Toplevel) {}
101 152
102 type popup struct{}153 type popup struct{}
103 154

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 23

Tags 10

↑↓ moveenter openesc close