detached 4c75474

Merge branch 'wf/renderer': Wayland-free Renderer

commit
4c754743b5b0734a113e3ecac67c741da7c4713a
author
brice · · 2026-10-01 17:41 UTC
merge of
2de4e1d + d1933fc · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
26 files · +2283 -90
A .mockery.yml +16 -0
@@ -0,0 +1,16 @@
1+all: false
2+formatter: goimports
3+force-file-write: true
4+structname: '{{.Mock}}{{.InterfaceName}}'
5+template: testify
6+template-schema: '{{.Template}}.schema.json'
7+require-template-schema-exists: true
8+packages:
9+ # Unexported GPU seam of Renderer: the mock lives next to the code, in a test file.
10+ github.com/bnema/nefergui/internal/ui:
11+ config:
12+ dir: internal/ui
13+ pkgname: ui
14+ filename: '{{.InterfaceName}}_mock_test.go'
15+ interfaces:
16+ target:
@@ -0,0 +1,16 @@
1+all: false
2+formatter: goimports
3+force-file-write: true
4+structname: '{{.Mock}}{{.InterfaceName}}'
5+template: testify
6+template-schema: '{{.Template}}.schema.json'
7+require-template-schema-exists: true
8+packages:
9+ # Unexported GPU seam of Renderer: the mock lives next to the code, in a test file.
10+ github.com/bnema/nefergui/internal/ui:
11+ config:
12+ dir: internal/ui
13+ pkgname: ui
14+ filename: '{{.InterfaceName}}_mock_test.go'
15+ interfaces:
16+ target:
M go.mod +6 -1
@@ -8,8 +8,13 @@ require (
88 github.com/bnema/purego-xkbcommon v0.1.0
99 github.com/bnema/wlturbo v0.5.0
1010 github.com/go-text/typesetting v0.3.5
11+ github.com/stretchr/testify v1.12.1
1112 golang.org/x/image v0.46.0
1213 golang.org/x/sys v0.48.0
1314 )
1415
15-require golang.org/x/text v0.42.0 // indirect
16+require (
17+ github.com/stretchr/objx v0.5.3 // indirect
18+ go.yaml.in/yaml/v3 v3.0.5 // indirect
19+ golang.org/x/text v0.42.0 // indirect
20+)
@@ -8,8 +8,13 @@ require (
8 github.com/bnema/purego-xkbcommon v0.1.08 github.com/bnema/purego-xkbcommon v0.1.0
9 github.com/bnema/wlturbo v0.5.09 github.com/bnema/wlturbo v0.5.0
10 github.com/go-text/typesetting v0.3.510 github.com/go-text/typesetting v0.3.5
11+ github.com/stretchr/testify v1.12.1
11 golang.org/x/image v0.46.012 golang.org/x/image v0.46.0
12 golang.org/x/sys v0.48.013 golang.org/x/sys v0.48.0
13 )14 )
14 15
15-require golang.org/x/text v0.42.0 // indirect16+require (
17+ github.com/stretchr/objx v0.5.3 // indirect
18+ go.yaml.in/yaml/v3 v3.0.5 // indirect
19+ golang.org/x/text v0.42.0 // indirect
20+)
M go.sum +6 -0 generated file: collapsed
M internal/presentation/session/session.go +57 -8
@@ -24,6 +24,7 @@ import (
2424 "github.com/bnema/nefergui/internal/presentation/vkdevice"
2525 "github.com/bnema/nefergui/internal/render"
2626 "github.com/bnema/nefergui/internal/text"
27+ "github.com/bnema/wlturbo/protocol/core"
2728 "github.com/bnema/wlturbo/protocol/drmsyncobj"
2829 "golang.org/x/sys/unix"
2930 )
@@ -40,6 +41,13 @@ type Slot struct {
4041 ReadbackPending bool
4142 ReleaseHandle uint32
4243 ReleaseTimeline *drmsyncobj.WpLinuxDrmSyncobjTimeline
44+ // WlBuffer is the compositor-side wl_buffer of the Wayland-owning Session.
45+ WlBuffer *core.Buffer
46+
47+ // Used only by Target, the Wayland-free core. Descriptors are -1 when unset.
48+ ReleaseFD int // eventfd armed on each commit's release point
49+ ReleaseExport int // exported syncobj fd of the release timeline
50+ Presented bool // the caller has been told to import this buffer
4351 }
4452 type Session struct {
4553 Window *wayland.Window
@@ -259,7 +267,13 @@ func (s *Session) newGeneration() error {
259267 if err != nil {
260268 return err
261269 }
262- selected, err := vkdevice.ChooseModifier(s.Window.Tranches, s.Transparent, mods)
270+ var formats []vkdevice.Format
271+ for _, tranche := range s.Window.Tranches {
272+ for _, entry := range tranche {
273+ formats = append(formats, vkdevice.Format{FourCC: entry.Format, Modifier: entry.Modifier})
274+ }
275+ }
276+ selected, err := vkdevice.ChooseModifier(formats, s.Transparent, mods)
263277 if err != nil {
264278 return err
265279 }
@@ -286,7 +300,7 @@ func (s *Session) newGeneration() error {
286300 if b.Generation != s.Pool.Generation {
287301 continue
288302 }
289- img, e := s.Device.AllocateImage(width, height, selected, s.Window.Dmabuf)
303+ img, e := s.Device.AllocateImage(width, height, selected)
290304 if e != nil {
291305 return e
292306 }
@@ -302,25 +316,60 @@ func (s *Session) newGeneration() error {
302316 return e
303317 }
304318 slot := &Slot{Buffer: b, Image: img, Frame: frame, First: true}
319+ if slot.WlBuffer, e = s.importBuffer(img, width, height, selected.DRMFormat); e != nil {
320+ frame.Close()
321+ img.Close()
322+ return e
323+ }
305324 slot.ReleaseHandle, e = s.Node.Create()
306325 if e != nil {
326+ slot.closeImage()
307327 frame.Close()
308- img.Close()
309328 return e
310329 }
311330 slot.ReleaseTimeline, e = s.importTimeline(slot.ReleaseHandle)
312331 if e != nil {
313332 _ = s.Node.Destroy(slot.ReleaseHandle)
333+ slot.closeImage()
314334 frame.Close()
315- img.Close()
316335 return e
317336 }
318337 s.Slots[b.ID] = slot
319- img.Buffer.OnRelease(func() { s.Window.BufferReleased(b.ID) })
338+ slot.WlBuffer.OnRelease(func() { s.Window.BufferReleased(b.ID) })
320339 }
321340 return nil
322341 }
323342
343+// importBuffer wraps the image's DMA-BUF in a wl_buffer. The image keeps its
344+// own descriptor; a duplicate goes to the compositor.
345+func (s *Session) importBuffer(img *vkdevice.Image, width, height int32, format uint32) (*core.Buffer, error) {
346+ fd, err := unix.FcntlInt(uintptr(img.FD), unix.F_DUPFD_CLOEXEC, 3)
347+ if err != nil {
348+ return nil, fmt.Errorf("duplicate DMA-BUF fd: %w", err)
349+ }
350+ params, err := s.Window.Dmabuf.CreateParams()
351+ if err != nil {
352+ _ = unix.Close(fd)
353+ return nil, err
354+ }
355+ defer params.Destroy()
356+ // WLTurbo consumes fd only when its complete Add request succeeds.
357+ if err = params.Add(fd, 0, img.Offset, img.Stride, uint32(img.Modifier>>32), uint32(img.Modifier)); err != nil {
358+ _ = unix.Close(fd)
359+ return nil, err
360+ }
361+ return params.CreateImmed(width, height, format, 0)
362+}
363+
364+// closeImage destroys the wl_buffer, then frees the GPU image.
365+func (slot *Slot) closeImage() {
366+ if slot.WlBuffer != nil {
367+ _ = slot.WlBuffer.Destroy()
368+ slot.WlBuffer = nil
369+ }
370+ slot.Image.Close()
371+}
372+
324373 // beginReady does not change Pool ownership until the previous GPU submission
325374 // has completed. An unready available buffer is retried on a bounded wakeup.
326375 func beginReady(pool *buffers.Pool, slots map[uint64]*Slot, ready func(*Slot) (bool, error)) (*buffers.Buffer, *Slot, bool, error) {
@@ -455,7 +504,7 @@ func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploa
455504 if err != nil {
456505 return false, err
457506 }
458- if err = s.Window.Present(slot.Image.Buffer, s.AcquireTimeline, slot.ReleaseTimeline, acquire, release); err != nil {
507+ if err = s.Window.Present(slot.WlBuffer, s.AcquireTimeline, slot.ReleaseTimeline, acquire, release); err != nil {
459508 return false, err
460509 }
461510 s.traceState(b, "committed")
@@ -670,7 +719,7 @@ func (s *Session) retire() error {
670719 if slot.Wait != nil {
671720 slot.Wait.Close()
672721 }
673- slot.Image.Close()
722+ slot.closeImage()
674723 _ = slot.ReleaseTimeline.Destroy()
675724 _ = s.Node.Destroy(slot.ReleaseHandle)
676725 s.traceState(b, "retired")
@@ -898,7 +947,7 @@ stopWaiters:
898947 }
899948 // The exported DMA-BUF remains alive in the compositor independently.
900949 if slot.Image != nil {
901- slot.Image.Close()
950+ slot.closeImage()
902951 }
903952 if s.Node != nil && slot.ReleaseHandle != 0 {
904953 _ = s.Node.Destroy(slot.ReleaseHandle)
@@ -24,6 +24,7 @@ import (
24 "github.com/bnema/nefergui/internal/presentation/vkdevice"24 "github.com/bnema/nefergui/internal/presentation/vkdevice"
25 "github.com/bnema/nefergui/internal/render"25 "github.com/bnema/nefergui/internal/render"
26 "github.com/bnema/nefergui/internal/text"26 "github.com/bnema/nefergui/internal/text"
27+ "github.com/bnema/wlturbo/protocol/core"
27 "github.com/bnema/wlturbo/protocol/drmsyncobj"28 "github.com/bnema/wlturbo/protocol/drmsyncobj"
28 "golang.org/x/sys/unix"29 "golang.org/x/sys/unix"
29 )30 )
@@ -40,6 +41,13 @@ type Slot struct {
40 ReadbackPending bool41 ReadbackPending bool
41 ReleaseHandle uint3242 ReleaseHandle uint32
42 ReleaseTimeline *drmsyncobj.WpLinuxDrmSyncobjTimeline43 ReleaseTimeline *drmsyncobj.WpLinuxDrmSyncobjTimeline
44+ // WlBuffer is the compositor-side wl_buffer of the Wayland-owning Session.
45+ WlBuffer *core.Buffer
46+
47+ // Used only by Target, the Wayland-free core. Descriptors are -1 when unset.
48+ ReleaseFD int // eventfd armed on each commit's release point
49+ ReleaseExport int // exported syncobj fd of the release timeline
50+ Presented bool // the caller has been told to import this buffer
43 }51 }
44 type Session struct {52 type Session struct {
45 Window *wayland.Window53 Window *wayland.Window
@@ -259,7 +267,13 @@ func (s *Session) newGeneration() error {
259 if err != nil {267 if err != nil {
260 return err268 return err
261 }269 }
262- selected, err := vkdevice.ChooseModifier(s.Window.Tranches, s.Transparent, mods)270+ var formats []vkdevice.Format
271+ for _, tranche := range s.Window.Tranches {
272+ for _, entry := range tranche {
273+ formats = append(formats, vkdevice.Format{FourCC: entry.Format, Modifier: entry.Modifier})
274+ }
275+ }
276+ selected, err := vkdevice.ChooseModifier(formats, s.Transparent, mods)
263 if err != nil {277 if err != nil {
264 return err278 return err
265 }279 }
@@ -286,7 +300,7 @@ func (s *Session) newGeneration() error {
286 if b.Generation != s.Pool.Generation {300 if b.Generation != s.Pool.Generation {
287 continue301 continue
288 }302 }
289- img, e := s.Device.AllocateImage(width, height, selected, s.Window.Dmabuf)303+ img, e := s.Device.AllocateImage(width, height, selected)
290 if e != nil {304 if e != nil {
291 return e305 return e
292 }306 }
@@ -302,25 +316,60 @@ func (s *Session) newGeneration() error {
302 return e316 return e
303 }317 }
304 slot := &Slot{Buffer: b, Image: img, Frame: frame, First: true}318 slot := &Slot{Buffer: b, Image: img, Frame: frame, First: true}
319+ if slot.WlBuffer, e = s.importBuffer(img, width, height, selected.DRMFormat); e != nil {
320+ frame.Close()
321+ img.Close()
322+ return e
323+ }
305 slot.ReleaseHandle, e = s.Node.Create()324 slot.ReleaseHandle, e = s.Node.Create()
306 if e != nil {325 if e != nil {
326+ slot.closeImage()
307 frame.Close()327 frame.Close()
308- img.Close()
309 return e328 return e
310 }329 }
311 slot.ReleaseTimeline, e = s.importTimeline(slot.ReleaseHandle)330 slot.ReleaseTimeline, e = s.importTimeline(slot.ReleaseHandle)
312 if e != nil {331 if e != nil {
313 _ = s.Node.Destroy(slot.ReleaseHandle)332 _ = s.Node.Destroy(slot.ReleaseHandle)
333+ slot.closeImage()
314 frame.Close()334 frame.Close()
315- img.Close()
316 return e335 return e
317 }336 }
318 s.Slots[b.ID] = slot337 s.Slots[b.ID] = slot
319- img.Buffer.OnRelease(func() { s.Window.BufferReleased(b.ID) })338+ slot.WlBuffer.OnRelease(func() { s.Window.BufferReleased(b.ID) })
320 }339 }
321 return nil340 return nil
322 }341 }
323 342
343+// importBuffer wraps the image's DMA-BUF in a wl_buffer. The image keeps its
344+// own descriptor; a duplicate goes to the compositor.
345+func (s *Session) importBuffer(img *vkdevice.Image, width, height int32, format uint32) (*core.Buffer, error) {
346+ fd, err := unix.FcntlInt(uintptr(img.FD), unix.F_DUPFD_CLOEXEC, 3)
347+ if err != nil {
348+ return nil, fmt.Errorf("duplicate DMA-BUF fd: %w", err)
349+ }
350+ params, err := s.Window.Dmabuf.CreateParams()
351+ if err != nil {
352+ _ = unix.Close(fd)
353+ return nil, err
354+ }
355+ defer params.Destroy()
356+ // WLTurbo consumes fd only when its complete Add request succeeds.
357+ if err = params.Add(fd, 0, img.Offset, img.Stride, uint32(img.Modifier>>32), uint32(img.Modifier)); err != nil {
358+ _ = unix.Close(fd)
359+ return nil, err
360+ }
361+ return params.CreateImmed(width, height, format, 0)
362+}
363+
364+// closeImage destroys the wl_buffer, then frees the GPU image.
365+func (slot *Slot) closeImage() {
366+ if slot.WlBuffer != nil {
367+ _ = slot.WlBuffer.Destroy()
368+ slot.WlBuffer = nil
369+ }
370+ slot.Image.Close()
371+}
372+
324 // beginReady does not change Pool ownership until the previous GPU submission373 // beginReady does not change Pool ownership until the previous GPU submission
325 // has completed. An unready available buffer is retried on a bounded wakeup.374 // has completed. An unready available buffer is retried on a bounded wakeup.
326 func beginReady(pool *buffers.Pool, slots map[uint64]*Slot, ready func(*Slot) (bool, error)) (*buffers.Buffer, *Slot, bool, error) {375 func beginReady(pool *buffers.Pool, slots map[uint64]*Slot, ready func(*Slot) (bool, error)) (*buffers.Buffer, *Slot, bool, error) {
@@ -455,7 +504,7 @@ func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploa
455 if err != nil {504 if err != nil {
456 return false, err505 return false, err
457 }506 }
458- if err = s.Window.Present(slot.Image.Buffer, s.AcquireTimeline, slot.ReleaseTimeline, acquire, release); err != nil {507+ if err = s.Window.Present(slot.WlBuffer, s.AcquireTimeline, slot.ReleaseTimeline, acquire, release); err != nil {
459 return false, err508 return false, err
460 }509 }
461 s.traceState(b, "committed")510 s.traceState(b, "committed")
@@ -670,7 +719,7 @@ func (s *Session) retire() error {
670 if slot.Wait != nil {719 if slot.Wait != nil {
671 slot.Wait.Close()720 slot.Wait.Close()
672 }721 }
673- slot.Image.Close()722+ slot.closeImage()
674 _ = slot.ReleaseTimeline.Destroy()723 _ = slot.ReleaseTimeline.Destroy()
675 _ = s.Node.Destroy(slot.ReleaseHandle)724 _ = s.Node.Destroy(slot.ReleaseHandle)
676 s.traceState(b, "retired")725 s.traceState(b, "retired")
@@ -898,7 +947,7 @@ stopWaiters:
898 }947 }
899 // The exported DMA-BUF remains alive in the compositor independently.948 // The exported DMA-BUF remains alive in the compositor independently.
900 if slot.Image != nil {949 if slot.Image != nil {
901- slot.Image.Close()950+ slot.closeImage()
902 }951 }
903 if s.Node != nil && slot.ReleaseHandle != 0 {952 if s.Node != nil && slot.ReleaseHandle != 0 {
904 _ = s.Node.Destroy(slot.ReleaseHandle)953 _ = s.Node.Destroy(slot.ReleaseHandle)
M internal/presentation/session/session_test.go +30 -0
@@ -3,8 +3,10 @@
33 package session
44
55 import (
6+ "errors"
67 "testing"
78
9+ "github.com/bnema/nefergui/internal/presentation/buffers"
810 "github.com/bnema/nefergui/internal/presentation/vkdevice"
911 )
1012
@@ -24,3 +26,31 @@ func TestInstallReleaseWaitDefersUnreadySubmission(t *testing.T) {
2426 t.Fatalf("failed replacement: installed=%v err=%v slot=%+v", installed, err, slot)
2527 }
2628 }
29+
30+// A resize while a release wait is outstanding leaves old-generation slots to
31+// retire: finishPending must not touch them, even if their frame is not ready.
32+func TestTargetFinishPendingSkipsOldGenerations(t *testing.T) {
33+ pool, _ := buffers.New(1)
34+ old := pool.Buffers[0]
35+ if err := pool.Resize(1); err != nil {
36+ t.Fatal(err)
37+ }
38+ // A nil Frame would panic if the old slot were examined.
39+ tg := &Target{Pool: pool, Slots: map[uint64]*Slot{old.ID: {Buffer: old, PendingWait: &vkdevice.BinaryFence{}}}}
40+ pending, err := tg.finishPending()
41+ if err != nil || pending {
42+ t.Fatalf("pending=%v err=%v", pending, err)
43+ }
44+ if old.State != buffers.Available || tg.Slots[old.ID].PendingWait == nil {
45+ t.Fatal("old-generation slot was modified")
46+ }
47+}
48+
49+func TestTargetBrokenIsSticky(t *testing.T) {
50+ tg := &Target{wantW: 1, wantH: 1}
51+ boom := errors.New("boom")
52+ first := tg.fail(boom)
53+ if _, err := tg.Draw(nil, 1, &Output{}); !errors.Is(err, boom) || err != first {
54+ t.Fatalf("Draw after failure: %v", err)
55+ }
56+}
@@ -3,8 +3,10 @@
3 package session3 package session
4 4
5 import (5 import (
6+ "errors"
6 "testing"7 "testing"
7 8
9+ "github.com/bnema/nefergui/internal/presentation/buffers"
8 "github.com/bnema/nefergui/internal/presentation/vkdevice"10 "github.com/bnema/nefergui/internal/presentation/vkdevice"
9 )11 )
10 12
@@ -24,3 +26,31 @@ func TestInstallReleaseWaitDefersUnreadySubmission(t *testing.T) {
24 t.Fatalf("failed replacement: installed=%v err=%v slot=%+v", installed, err, slot)26 t.Fatalf("failed replacement: installed=%v err=%v slot=%+v", installed, err, slot)
25 }27 }
26 }28 }
29+
30+// A resize while a release wait is outstanding leaves old-generation slots to
31+// retire: finishPending must not touch them, even if their frame is not ready.
32+func TestTargetFinishPendingSkipsOldGenerations(t *testing.T) {
33+ pool, _ := buffers.New(1)
34+ old := pool.Buffers[0]
35+ if err := pool.Resize(1); err != nil {
36+ t.Fatal(err)
37+ }
38+ // A nil Frame would panic if the old slot were examined.
39+ tg := &Target{Pool: pool, Slots: map[uint64]*Slot{old.ID: {Buffer: old, PendingWait: &vkdevice.BinaryFence{}}}}
40+ pending, err := tg.finishPending()
41+ if err != nil || pending {
42+ t.Fatalf("pending=%v err=%v", pending, err)
43+ }
44+ if old.State != buffers.Available || tg.Slots[old.ID].PendingWait == nil {
45+ t.Fatal("old-generation slot was modified")
46+ }
47+}
48+
49+func TestTargetBrokenIsSticky(t *testing.T) {
50+ tg := &Target{wantW: 1, wantH: 1}
51+ boom := errors.New("boom")
52+ first := tg.fail(boom)
53+ if _, err := tg.Draw(nil, 1, &Output{}); !errors.Is(err, boom) || err != first {
54+ t.Fatalf("Draw after failure: %v", err)
55+ }
56+}
A internal/presentation/session/target.go +596 -0
@@ -0,0 +1,596 @@
1+//go:build linux
2+
3+package session
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "image"
9+ "path/filepath"
10+
11+ "github.com/bnema/purego-vulkan/vulkan"
12+
13+ "github.com/bnema/nefergui/internal/layout"
14+ "github.com/bnema/nefergui/internal/presentation/buffers"
15+ "github.com/bnema/nefergui/internal/presentation/syncobj"
16+ "github.com/bnema/nefergui/internal/presentation/vkdevice"
17+ "github.com/bnema/nefergui/internal/render"
18+ "golang.org/x/sys/unix"
19+)
20+
21+// Plane is one DMA-BUF plane. FD stays owned by the Target.
22+type Plane struct {
23+ FD int
24+ Offset uint32
25+ Stride uint32
26+}
27+
28+// Timeline is a DRM syncobj timeline the transport must import. FD stays owned
29+// by the Target and is valid only when the Output says the timeline is new.
30+type Timeline struct {
31+ ID uint64
32+ FD int
33+}
34+
35+// Retired names a buffer the transport must stop watching and destroy before
36+// the next Draw. ReleaseFD closes at that Draw.
37+type Retired struct {
38+ Buffer uint64
39+ ReleaseFD int
40+}
41+
42+// Output describes one presented frame in physical pixels. Slices are reused;
43+// they are valid until the next Draw.
44+type Output struct {
45+ Buffer uint64
46+ NewBuffer bool // import Planes as a new wl_buffer
47+ Retired []Retired
48+ Width int32
49+ Height int32
50+ FourCC uint32
51+ Modifier uint64
52+ Planes [4]Plane
53+ PlaneCount int
54+ Acquire Timeline // ID 0; FD always valid (persistent)
55+ Release Timeline // ID is the buffer; FD valid only with NewTimelines
56+ NewTimelines bool
57+ AcquirePoint uint64
58+ ReleasePoint uint64
59+ ReleaseFD int // eventfd readable when ReleasePoint signals; stable per buffer
60+}
61+
62+// TargetConfig selects the GPU and the compositor-acceptable formats.
63+type TargetConfig struct {
64+ MainDevice uint64
65+ Formats []vkdevice.Format
66+ Transparent bool
67+}
68+
69+// Target is the Wayland-free presentation core: it renders prepared frames
70+// into DMA-BUF images, tracks buffer ownership with explicit-sync timelines
71+// and reports release through per-buffer eventfds. It starts no goroutines and
72+// is used from one owner goroutine.
73+type Target struct {
74+ Device *vkdevice.Device
75+ Node *syncobj.Node
76+ Pool *buffers.Pool
77+ Preparer *render.Preparer
78+ Slots map[uint64]*Slot
79+
80+ list render.ListBuffer
81+ pipeline *vkdevice.Pipeline
82+ pipelineGen uint64 // pool generation the current pipeline belongs to
83+ retiredPipelines []retiredPipelines
84+ genPending bool // Pool.Resize done, images of the new generation not yet built
85+ waiting bool // the last Draw found work that only GPU progress unblocks
86+ broken error // sticky: a failure after Submit left the pipeline inconsistent
87+ acquire uint32
88+ acquireFD int
89+ formats []vkdevice.Format
90+ transparent bool
91+ selected vkdevice.Modifier
92+ wantW, wantH int32
93+ width, height int32
94+ pending []Retired // retired since the last Draw output
95+ reported []Retired // given to the caller; fds close at the next Draw
96+ capture bool
97+ closed bool
98+}
99+
100+// NewTarget opens the GPU and the render node. It allocates no images.
101+func NewTarget(cfg TargetConfig) (_ *Target, err error) {
102+ t := &Target{Slots: make(map[uint64]*Slot), acquireFD: -1, formats: append([]vkdevice.Format(nil), cfg.Formats...), transparent: cfg.Transparent}
103+ defer func() {
104+ if err != nil {
105+ err = errors.Join(err, t.Close())
106+ }
107+ }()
108+ if t.Preparer, err = render.NewPreparer(vkdevice.AtlasSize, vkdevice.AtlasPages); err != nil {
109+ return nil, err
110+ }
111+ if t.Device, err = vkdevice.Open(vkdevice.DeviceIdentity(cfg.MainDevice)); err != nil {
112+ return nil, err
113+ }
114+ if t.Node, err = syncobj.Open(filepath.Join("/dev/dri", fmt.Sprintf("renderD%d", t.Device.Render.Minor))); err != nil {
115+ return nil, err
116+ }
117+ if t.acquire, err = t.Node.Create(); err != nil {
118+ return nil, err
119+ }
120+ if t.acquireFD, err = t.Node.ExportTimeline(t.acquire); err != nil {
121+ return nil, err
122+ }
123+ if t.Pool, err = buffers.New(3); err != nil {
124+ return nil, err
125+ }
126+ // The pool starts with generation 1 buffers but no images yet; the first
127+ // Draw allocates them for the requested size.
128+ return t, nil
129+}
130+
131+// EnableReadback makes later images capture their output for Readback. Tests only.
132+func (t *Target) EnableReadback() { t.capture = true }
133+
134+// Resize sets the physical surface size; images follow lazily at the next Draw.
135+func (t *Target) Resize(width, height int32) { t.wantW, t.wantH = width, height }
136+
137+// Size reports the physical size of the current image generation.
138+func (t *Target) Size() (int32, int32) { return t.width, t.height }
139+
140+// newGeneration builds the pipeline and every image of the current pool
141+// generation into locals and commits them only when all succeeded, so a
142+// failure leaves the previous size, pipeline and slots intact.
143+func (t *Target) newGeneration() (err error) {
144+ mods, err := t.Device.ExportableModifiers()
145+ if err != nil {
146+ return err
147+ }
148+ selected, err := vkdevice.ChooseModifier(t.formats, t.transparent, mods)
149+ if err != nil {
150+ return err
151+ }
152+ pipeline, err := t.Device.NewListPipeline(t.wantW, t.wantH)
153+ if err != nil {
154+ return err
155+ }
156+ fresh := make(map[uint64]*Slot)
157+ defer func() {
158+ if err != nil {
159+ for _, slot := range fresh {
160+ _ = t.closeSlot(slot)
161+ }
162+ pipeline.Close()
163+ }
164+ }()
165+ for _, b := range t.Pool.Buffers {
166+ if b.Generation != t.Pool.Generation {
167+ continue
168+ }
169+ slot, err := t.newSlot(b, t.wantW, t.wantH, selected)
170+ if err != nil {
171+ return err
172+ }
173+ fresh[b.ID] = slot
174+ }
175+ if t.pipeline != nil {
176+ t.retiredPipelines = append(t.retiredPipelines, retiredPipelines{generation: t.pipelineGen, list: t.pipeline})
177+ }
178+ t.pipeline, t.pipelineGen, t.selected = pipeline, t.Pool.Generation, selected
179+ t.width, t.height = t.wantW, t.wantH
180+ for id, slot := range fresh {
181+ t.Slots[id] = slot
182+ }
183+ return nil
184+}
185+
186+func (t *Target) newSlot(b *buffers.Buffer, width, height int32, selected vkdevice.Modifier) (_ *Slot, err error) {
187+ slot := &Slot{Buffer: b, First: true, ReleaseFD: -1, ReleaseExport: -1}
188+ defer func() {
189+ if err != nil {
190+ _ = t.closeSlot(slot)
191+ }
192+ }()
193+ if slot.Image, err = t.Device.AllocateImage(width, height, selected); err != nil {
194+ return nil, err
195+ }
196+ if slot.Frame, err = t.Device.NewFrame(slot.Image); err != nil {
197+ return nil, err
198+ }
199+ if t.capture {
200+ if slot.Frame.Readback, err = t.Device.NewReadback(width, height); err != nil {
201+ return nil, err
202+ }
203+ }
204+ if slot.ReleaseHandle, err = t.Node.Create(); err != nil {
205+ return nil, err
206+ }
207+ if slot.ReleaseExport, err = t.Node.ExportTimeline(slot.ReleaseHandle); err != nil {
208+ return nil, err
209+ }
210+ if slot.ReleaseFD, err = unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK); err != nil {
211+ return nil, err
212+ }
213+ return slot, nil
214+}
215+
216+// closeSlot frees a slot whose GPU work has completed (or never started).
217+func (t *Target) closeSlot(slot *Slot) error {
218+ if slot.Frame != nil {
219+ slot.Frame.Close()
220+ }
221+ if slot.PendingWait != nil {
222+ slot.PendingWait.Close()
223+ }
224+ if slot.Wait != nil {
225+ slot.Wait.Close()
226+ }
227+ if slot.Image != nil {
228+ slot.Image.Close()
229+ }
230+ var err error
231+ if slot.ReleaseHandle != 0 {
232+ err = t.Node.Destroy(slot.ReleaseHandle)
233+ }
234+ closeFD(&slot.ReleaseExport)
235+ return err
236+}
237+
238+func closeFD(fd *int) {
239+ if *fd >= 0 {
240+ _ = unix.Close(*fd)
241+ *fd = -1
242+ }
243+}
244+
245+// Draw prepares and presents one frame. It returns false, touching no GPU
246+// state, when no image is ready; the caller retries after Released, or after a
247+// short delay while Waiting reports GPU progress is needed.
248+func (t *Target) Draw(display []layout.Command, scale float64, out *Output) (bool, error) {
249+ if t.broken != nil {
250+ return false, t.broken
251+ }
252+ if t.closed {
253+ return false, errors.New("session: target closed")
254+ }
255+ if t.wantW <= 0 || t.wantH <= 0 {
256+ return false, nil
257+ }
258+ for _, r := range t.reported {
259+ _ = unix.Close(r.ReleaseFD)
260+ }
261+ t.reported = t.reported[:0]
262+ if t.genPending || t.width != t.wantW || t.height != t.wantH {
263+ if !t.genPending && t.width != 0 {
264+ if err := t.Pool.Resize(3); err != nil {
265+ return false, err
266+ }
267+ }
268+ t.genPending = true
269+ if err := t.newGeneration(); err != nil {
270+ return false, err
271+ }
272+ t.genPending = false
273+ }
274+ if err := t.retire(); err != nil {
275+ return false, err
276+ }
277+ pending, err := t.finishPending()
278+ if err != nil {
279+ return false, err
280+ }
281+ b, slot, waiting, err := beginReady(t.Pool, t.Slots, func(s *Slot) (bool, error) { return s.Frame.Ready() })
282+ if err != nil {
283+ return false, err
284+ }
285+ t.waiting = pending || waiting
286+ if b == nil {
287+ return false, nil
288+ }
289+ ok, err := t.submit(b, slot, display, scale, out)
290+ if err != nil {
291+ // Before Submit nothing reached the GPU and the buffer is reusable.
292+ if t.broken == nil && b.State == buffers.Rendering {
293+ b.State = buffers.Available
294+ }
295+ return false, err
296+ }
297+ t.waiting = false
298+ return ok, nil
299+}
300+
301+// Waiting reports that the last Draw found a frame blocked only on GPU
302+// progress (an image still being drawn, or a release wait not yet installed),
303+// which no event announces: the caller should retry after a short delay. It is
304+// false when every image is owned by the compositor; Released unblocks that.
305+func (t *Target) Waiting() bool { return t.waiting }
306+
307+func (t *Target) submit(b *buffers.Buffer, slot *Slot, display []layout.Command, scale float64, out *Output) (bool, error) {
308+ frame, err := t.Preparer.Prepare(display, scale, int(t.width), int(t.height))
309+ if err != nil {
310+ return false, err
311+ }
312+ defer t.list.Release()
313+ instances, batches, stats := t.list.List(frame)
314+ if len(stats.Skipped) != 0 {
315+ return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
316+ }
317+ if slot.Wait != nil && slot.WaitInFlight {
318+ slot.Wait.Close()
319+ slot.Wait = nil
320+ slot.WaitInFlight = false
321+ }
322+ if err = slot.Frame.RecordList(slot.Image, t.pipeline, t.width, t.height, instances, frame.Uploads, batches, slot.First, t.capture); err != nil {
323+ return false, err
324+ }
325+ if err = slot.Frame.Submit(slot.Wait); err != nil {
326+ return false, err
327+ }
328+ t.Preparer.Submitted(frame.Uploads)
329+ if slot.Wait != nil {
330+ slot.WaitInFlight = true
331+ }
332+ slot.First = false
333+ // From here the buffer is committed to the pipeline; failures are fatal.
334+ fd, err := slot.Frame.Signal.Export()
335+ if err != nil {
336+ return false, t.fail(err)
337+ }
338+ acquire, err := t.Pool.Acquire(b)
339+ if err != nil {
340+ _ = unix.Close(fd)
341+ return false, t.fail(err)
342+ }
343+ if err = t.Node.AcquirePoint(fd, t.acquire, acquire); err != nil {
344+ _ = unix.Close(fd)
345+ return false, t.fail(err)
346+ }
347+ release, err := t.Pool.Commit(b)
348+ if err != nil {
349+ return false, t.fail(err)
350+ }
351+ if err = t.Node.EventFD(slot.ReleaseHandle, release, slot.ReleaseFD); err != nil {
352+ return false, t.fail(err)
353+ }
354+ if err = t.Pool.Own(b); err != nil {
355+ return false, t.fail(err)
356+ }
357+ t.fill(out, slot, acquire, release)
358+ return true, nil
359+}
360+
361+// fail records an error after Submit: the buffer cannot go back to Available
362+// and every later Draw returns this error.
363+func (t *Target) fail(err error) error {
364+ t.broken = fmt.Errorf("session: presentation pipeline broken: %w", err)
365+ return t.broken
366+}
367+
368+func (t *Target) fill(out *Output, slot *Slot, acquire, release uint64) {
369+ b := slot.Buffer
370+ retired := append(out.Retired[:0], t.pending...)
371+ t.reported = append(t.reported[:0], t.pending...)
372+ t.pending = t.pending[:0]
373+ *out = Output{
374+ Buffer: b.ID, NewBuffer: !slot.Presented, Retired: retired,
375+ Width: t.width, Height: t.height, FourCC: t.selected.DRMFormat, Modifier: slot.Image.Modifier,
376+ PlaneCount: 1, Acquire: Timeline{ID: 0, FD: t.acquireFD}, Release: Timeline{ID: b.ID, FD: -1},
377+ NewTimelines: !slot.Presented, AcquirePoint: acquire, ReleasePoint: release, ReleaseFD: slot.ReleaseFD,
378+ }
379+ out.Planes[0] = Plane{FD: slot.Image.FD, Offset: slot.Image.Offset, Stride: slot.Image.Stride}
380+ if !slot.Presented {
381+ out.Release.FD = slot.ReleaseExport
382+ slot.Presented = true
383+ }
384+}
385+
386+// Released handles a readable release eventfd of the buffer: the compositor
387+// signaled its latest release point, so the image may be reused once the GPU
388+// wait is installed.
389+func (t *Target) Released(id uint64) error {
390+ slot := t.Slots[id]
391+ if slot == nil {
392+ return nil // retired: its descriptors are gone
393+ }
394+ // Drain first: a notification that lands after the drain is seen by the
395+ // Signaled check below or leaves the eventfd readable for a later no-op call.
396+ if err := drainEventFD(slot.ReleaseFD); err != nil {
397+ return err
398+ }
399+ b := slot.Buffer
400+ if b.State != buffers.CompositorOwned {
401+ return nil
402+ }
403+ signaled, err := t.Node.Signaled(slot.ReleaseHandle, b.ReleasePoint)
404+ if err != nil {
405+ return err
406+ }
407+ if !signaled {
408+ return nil // stale notification; the registration for the live point is still armed
409+ }
410+ return t.release(slot, b.ReleasePoint)
411+}
412+
413+// drainEventFD resets a non-blocking eventfd; an empty one is fine.
414+func drainEventFD(fd int) error {
415+ var counter [8]byte
416+ if _, err := unix.Read(fd, counter[:]); err != nil && err != unix.EAGAIN {
417+ return fmt.Errorf("session: read release eventfd: %w", err)
418+ }
419+ return nil
420+}
421+
422+func (t *Target) release(slot *Slot, point uint64) error {
423+ b := slot.Buffer
424+ // An older completion cannot authorize a newer commit of the same image.
425+ if b.ReleasePoint != point || b.State != buffers.CompositorOwned {
426+ return nil
427+ }
428+ if err := t.Pool.Signal(b, point); err != nil {
429+ return err
430+ }
431+ if b.Generation == t.Pool.Generation {
432+ fd, err := t.Node.ReleaseFence(slot.ReleaseHandle, point)
433+ if err != nil {
434+ return err
435+ }
436+ wait, err := t.Device.NewBinaryFence(false)
437+ if err != nil {
438+ _ = unix.Close(fd)
439+ return err
440+ }
441+ if err = wait.ImportTemporary(fd); err != nil {
442+ _ = unix.Close(fd)
443+ wait.Close()
444+ return err
445+ }
446+ installed, err := installReleaseWait(slot, wait, slot.Frame.Ready)
447+ if err != nil {
448+ wait.Close()
449+ return err
450+ }
451+ if !installed {
452+ return nil
453+ }
454+ if err = t.Pool.Reuse(b, true); err != nil {
455+ return err
456+ }
457+ }
458+ return t.retire()
459+}
460+
461+// finishPending installs release waits deferred because the old submission was
462+// still running. It never waits on the GPU.
463+func (t *Target) finishPending() (bool, error) {
464+ pending := false
465+ for _, slot := range t.Slots {
466+ // Old generations never return to Available; retire frees them.
467+ if slot.PendingWait == nil || slot.Buffer.Generation != t.Pool.Generation {
468+ continue
469+ }
470+ ready, err := slot.Frame.Ready()
471+ if err != nil {
472+ return false, err
473+ }
474+ if !ready {
475+ pending = true
476+ continue
477+ }
478+ if slot.Wait != nil {
479+ slot.Wait.Close()
480+ }
481+ slot.Wait, slot.PendingWait, slot.WaitInFlight = slot.PendingWait, nil, false
482+ if err := t.Pool.Reuse(slot.Buffer, true); err != nil {
483+ return false, err
484+ }
485+ }
486+ return pending, nil
487+}
488+
489+// retire frees images of old generations once nothing uses them, and queues
490+// the ones the transport imported for the next Output.
491+func (t *Target) retire() error {
492+ for id, slot := range t.Slots {
493+ b := slot.Buffer
494+ if !t.Pool.Retirable(b) {
495+ continue
496+ }
497+ ready, err := slot.Frame.Ready()
498+ if err != nil {
499+ return err
500+ }
501+ if !ready {
502+ continue
503+ }
504+ eventFD := slot.ReleaseFD
505+ slot.ReleaseFD = -1
506+ wasPresented := slot.Presented
507+ err = t.closeSlot(slot)
508+ if wasPresented {
509+ t.pending = append(t.pending, Retired{Buffer: id, ReleaseFD: eventFD})
510+ } else if eventFD >= 0 {
511+ _ = unix.Close(eventFD)
512+ }
513+ delete(t.Slots, id)
514+ if rerr := t.Pool.Remove(b); rerr != nil {
515+ return errors.Join(err, rerr)
516+ }
517+ if err != nil {
518+ return err
519+ }
520+ }
521+ for i := 0; i < len(t.retiredPipelines); {
522+ old := t.retiredPipelines[i]
523+ live := false
524+ for _, slot := range t.Slots {
525+ if slot.Buffer.Generation == old.generation {
526+ live = true
527+ break
528+ }
529+ }
530+ if live {
531+ i++
532+ continue
533+ }
534+ old.list.Close()
535+ t.retiredPipelines = append(t.retiredPipelines[:i], t.retiredPipelines[i+1:]...)
536+ }
537+ return nil
538+}
539+
540+// Readback returns the last drawn pixels of a buffer in RGBA order. Tests only;
541+// call EnableReadback before the first Draw.
542+func (t *Target) Readback(id uint64) (*image.NRGBA, error) {
543+ slot := t.Slots[id]
544+ if slot == nil || slot.Frame.Readback == nil {
545+ return nil, errors.New("session: no readback for buffer")
546+ }
547+ if err := slot.Frame.Wait(); err != nil {
548+ return nil, err
549+ }
550+ img, err := slot.Frame.Readback.Copy()
551+ if err != nil {
552+ return nil, err
553+ }
554+ vkdevice.SwizzleBGRA(img)
555+ return img, nil
556+}
557+
558+// Close waits for the GPU, then frees every descriptor and image, always
559+// completing the teardown. The compositor's imports keep their own kernel
560+// references. It returns the joined errors of the steps that failed.
561+func (t *Target) Close() error {
562+ if t == nil || t.closed {
563+ return nil
564+ }
565+ t.closed = true
566+ var errs []error
567+ if t.Device != nil && t.Device.Logical != 0 && t.Device.Dispatch != nil {
568+ errs = append(errs, vulkan.Check(t.Device.Dispatch.DeviceWaitIdle(t.Device.Logical)))
569+ }
570+ for _, r := range t.reported {
571+ _ = unix.Close(r.ReleaseFD)
572+ }
573+ for _, r := range t.pending {
574+ _ = unix.Close(r.ReleaseFD)
575+ }
576+ t.reported, t.pending = nil, nil
577+ for id, slot := range t.Slots {
578+ closeFD(&slot.ReleaseFD)
579+ errs = append(errs, t.closeSlot(slot))
580+ delete(t.Slots, id)
581+ }
582+ for _, old := range t.retiredPipelines {
583+ old.list.Close()
584+ }
585+ t.retiredPipelines = nil
586+ t.pipeline.Close()
587+ closeFD(&t.acquireFD)
588+ if t.Node != nil {
589+ if t.acquire != 0 {
590+ errs = append(errs, t.Node.Destroy(t.acquire))
591+ }
592+ errs = append(errs, t.Node.Close())
593+ }
594+ t.Device.Close()
595+ return errors.Join(errs...)
596+}
@@ -0,0 +1,596 @@
1+//go:build linux
2+
3+package session
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "image"
9+ "path/filepath"
10+
11+ "github.com/bnema/purego-vulkan/vulkan"
12+
13+ "github.com/bnema/nefergui/internal/layout"
14+ "github.com/bnema/nefergui/internal/presentation/buffers"
15+ "github.com/bnema/nefergui/internal/presentation/syncobj"
16+ "github.com/bnema/nefergui/internal/presentation/vkdevice"
17+ "github.com/bnema/nefergui/internal/render"
18+ "golang.org/x/sys/unix"
19+)
20+
21+// Plane is one DMA-BUF plane. FD stays owned by the Target.
22+type Plane struct {
23+ FD int
24+ Offset uint32
25+ Stride uint32
26+}
27+
28+// Timeline is a DRM syncobj timeline the transport must import. FD stays owned
29+// by the Target and is valid only when the Output says the timeline is new.
30+type Timeline struct {
31+ ID uint64
32+ FD int
33+}
34+
35+// Retired names a buffer the transport must stop watching and destroy before
36+// the next Draw. ReleaseFD closes at that Draw.
37+type Retired struct {
38+ Buffer uint64
39+ ReleaseFD int
40+}
41+
42+// Output describes one presented frame in physical pixels. Slices are reused;
43+// they are valid until the next Draw.
44+type Output struct {
45+ Buffer uint64
46+ NewBuffer bool // import Planes as a new wl_buffer
47+ Retired []Retired
48+ Width int32
49+ Height int32
50+ FourCC uint32
51+ Modifier uint64
52+ Planes [4]Plane
53+ PlaneCount int
54+ Acquire Timeline // ID 0; FD always valid (persistent)
55+ Release Timeline // ID is the buffer; FD valid only with NewTimelines
56+ NewTimelines bool
57+ AcquirePoint uint64
58+ ReleasePoint uint64
59+ ReleaseFD int // eventfd readable when ReleasePoint signals; stable per buffer
60+}
61+
62+// TargetConfig selects the GPU and the compositor-acceptable formats.
63+type TargetConfig struct {
64+ MainDevice uint64
65+ Formats []vkdevice.Format
66+ Transparent bool
67+}
68+
69+// Target is the Wayland-free presentation core: it renders prepared frames
70+// into DMA-BUF images, tracks buffer ownership with explicit-sync timelines
71+// and reports release through per-buffer eventfds. It starts no goroutines and
72+// is used from one owner goroutine.
73+type Target struct {
74+ Device *vkdevice.Device
75+ Node *syncobj.Node
76+ Pool *buffers.Pool
77+ Preparer *render.Preparer
78+ Slots map[uint64]*Slot
79+
80+ list render.ListBuffer
81+ pipeline *vkdevice.Pipeline
82+ pipelineGen uint64 // pool generation the current pipeline belongs to
83+ retiredPipelines []retiredPipelines
84+ genPending bool // Pool.Resize done, images of the new generation not yet built
85+ waiting bool // the last Draw found work that only GPU progress unblocks
86+ broken error // sticky: a failure after Submit left the pipeline inconsistent
87+ acquire uint32
88+ acquireFD int
89+ formats []vkdevice.Format
90+ transparent bool
91+ selected vkdevice.Modifier
92+ wantW, wantH int32
93+ width, height int32
94+ pending []Retired // retired since the last Draw output
95+ reported []Retired // given to the caller; fds close at the next Draw
96+ capture bool
97+ closed bool
98+}
99+
100+// NewTarget opens the GPU and the render node. It allocates no images.
101+func NewTarget(cfg TargetConfig) (_ *Target, err error) {
102+ t := &Target{Slots: make(map[uint64]*Slot), acquireFD: -1, formats: append([]vkdevice.Format(nil), cfg.Formats...), transparent: cfg.Transparent}
103+ defer func() {
104+ if err != nil {
105+ err = errors.Join(err, t.Close())
106+ }
107+ }()
108+ if t.Preparer, err = render.NewPreparer(vkdevice.AtlasSize, vkdevice.AtlasPages); err != nil {
109+ return nil, err
110+ }
111+ if t.Device, err = vkdevice.Open(vkdevice.DeviceIdentity(cfg.MainDevice)); err != nil {
112+ return nil, err
113+ }
114+ if t.Node, err = syncobj.Open(filepath.Join("/dev/dri", fmt.Sprintf("renderD%d", t.Device.Render.Minor))); err != nil {
115+ return nil, err
116+ }
117+ if t.acquire, err = t.Node.Create(); err != nil {
118+ return nil, err
119+ }
120+ if t.acquireFD, err = t.Node.ExportTimeline(t.acquire); err != nil {
121+ return nil, err
122+ }
123+ if t.Pool, err = buffers.New(3); err != nil {
124+ return nil, err
125+ }
126+ // The pool starts with generation 1 buffers but no images yet; the first
127+ // Draw allocates them for the requested size.
128+ return t, nil
129+}
130+
131+// EnableReadback makes later images capture their output for Readback. Tests only.
132+func (t *Target) EnableReadback() { t.capture = true }
133+
134+// Resize sets the physical surface size; images follow lazily at the next Draw.
135+func (t *Target) Resize(width, height int32) { t.wantW, t.wantH = width, height }
136+
137+// Size reports the physical size of the current image generation.
138+func (t *Target) Size() (int32, int32) { return t.width, t.height }
139+
140+// newGeneration builds the pipeline and every image of the current pool
141+// generation into locals and commits them only when all succeeded, so a
142+// failure leaves the previous size, pipeline and slots intact.
143+func (t *Target) newGeneration() (err error) {
144+ mods, err := t.Device.ExportableModifiers()
145+ if err != nil {
146+ return err
147+ }
148+ selected, err := vkdevice.ChooseModifier(t.formats, t.transparent, mods)
149+ if err != nil {
150+ return err
151+ }
152+ pipeline, err := t.Device.NewListPipeline(t.wantW, t.wantH)
153+ if err != nil {
154+ return err
155+ }
156+ fresh := make(map[uint64]*Slot)
157+ defer func() {
158+ if err != nil {
159+ for _, slot := range fresh {
160+ _ = t.closeSlot(slot)
161+ }
162+ pipeline.Close()
163+ }
164+ }()
165+ for _, b := range t.Pool.Buffers {
166+ if b.Generation != t.Pool.Generation {
167+ continue
168+ }
169+ slot, err := t.newSlot(b, t.wantW, t.wantH, selected)
170+ if err != nil {
171+ return err
172+ }
173+ fresh[b.ID] = slot
174+ }
175+ if t.pipeline != nil {
176+ t.retiredPipelines = append(t.retiredPipelines, retiredPipelines{generation: t.pipelineGen, list: t.pipeline})
177+ }
178+ t.pipeline, t.pipelineGen, t.selected = pipeline, t.Pool.Generation, selected
179+ t.width, t.height = t.wantW, t.wantH
180+ for id, slot := range fresh {
181+ t.Slots[id] = slot
182+ }
183+ return nil
184+}
185+
186+func (t *Target) newSlot(b *buffers.Buffer, width, height int32, selected vkdevice.Modifier) (_ *Slot, err error) {
187+ slot := &Slot{Buffer: b, First: true, ReleaseFD: -1, ReleaseExport: -1}
188+ defer func() {
189+ if err != nil {
190+ _ = t.closeSlot(slot)
191+ }
192+ }()
193+ if slot.Image, err = t.Device.AllocateImage(width, height, selected); err != nil {
194+ return nil, err
195+ }
196+ if slot.Frame, err = t.Device.NewFrame(slot.Image); err != nil {
197+ return nil, err
198+ }
199+ if t.capture {
200+ if slot.Frame.Readback, err = t.Device.NewReadback(width, height); err != nil {
201+ return nil, err
202+ }
203+ }
204+ if slot.ReleaseHandle, err = t.Node.Create(); err != nil {
205+ return nil, err
206+ }
207+ if slot.ReleaseExport, err = t.Node.ExportTimeline(slot.ReleaseHandle); err != nil {
208+ return nil, err
209+ }
210+ if slot.ReleaseFD, err = unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK); err != nil {
211+ return nil, err
212+ }
213+ return slot, nil
214+}
215+
216+// closeSlot frees a slot whose GPU work has completed (or never started).
217+func (t *Target) closeSlot(slot *Slot) error {
218+ if slot.Frame != nil {
219+ slot.Frame.Close()
220+ }
221+ if slot.PendingWait != nil {
222+ slot.PendingWait.Close()
223+ }
224+ if slot.Wait != nil {
225+ slot.Wait.Close()
226+ }
227+ if slot.Image != nil {
228+ slot.Image.Close()
229+ }
230+ var err error
231+ if slot.ReleaseHandle != 0 {
232+ err = t.Node.Destroy(slot.ReleaseHandle)
233+ }
234+ closeFD(&slot.ReleaseExport)
235+ return err
236+}
237+
238+func closeFD(fd *int) {
239+ if *fd >= 0 {
240+ _ = unix.Close(*fd)
241+ *fd = -1
242+ }
243+}
244+
245+// Draw prepares and presents one frame. It returns false, touching no GPU
246+// state, when no image is ready; the caller retries after Released, or after a
247+// short delay while Waiting reports GPU progress is needed.
248+func (t *Target) Draw(display []layout.Command, scale float64, out *Output) (bool, error) {
249+ if t.broken != nil {
250+ return false, t.broken
251+ }
252+ if t.closed {
253+ return false, errors.New("session: target closed")
254+ }
255+ if t.wantW <= 0 || t.wantH <= 0 {
256+ return false, nil
257+ }
258+ for _, r := range t.reported {
259+ _ = unix.Close(r.ReleaseFD)
260+ }
261+ t.reported = t.reported[:0]
262+ if t.genPending || t.width != t.wantW || t.height != t.wantH {
263+ if !t.genPending && t.width != 0 {
264+ if err := t.Pool.Resize(3); err != nil {
265+ return false, err
266+ }
267+ }
268+ t.genPending = true
269+ if err := t.newGeneration(); err != nil {
270+ return false, err
271+ }
272+ t.genPending = false
273+ }
274+ if err := t.retire(); err != nil {
275+ return false, err
276+ }
277+ pending, err := t.finishPending()
278+ if err != nil {
279+ return false, err
280+ }
281+ b, slot, waiting, err := beginReady(t.Pool, t.Slots, func(s *Slot) (bool, error) { return s.Frame.Ready() })
282+ if err != nil {
283+ return false, err
284+ }
285+ t.waiting = pending || waiting
286+ if b == nil {
287+ return false, nil
288+ }
289+ ok, err := t.submit(b, slot, display, scale, out)
290+ if err != nil {
291+ // Before Submit nothing reached the GPU and the buffer is reusable.
292+ if t.broken == nil && b.State == buffers.Rendering {
293+ b.State = buffers.Available
294+ }
295+ return false, err
296+ }
297+ t.waiting = false
298+ return ok, nil
299+}
300+
301+// Waiting reports that the last Draw found a frame blocked only on GPU
302+// progress (an image still being drawn, or a release wait not yet installed),
303+// which no event announces: the caller should retry after a short delay. It is
304+// false when every image is owned by the compositor; Released unblocks that.
305+func (t *Target) Waiting() bool { return t.waiting }
306+
307+func (t *Target) submit(b *buffers.Buffer, slot *Slot, display []layout.Command, scale float64, out *Output) (bool, error) {
308+ frame, err := t.Preparer.Prepare(display, scale, int(t.width), int(t.height))
309+ if err != nil {
310+ return false, err
311+ }
312+ defer t.list.Release()
313+ instances, batches, stats := t.list.List(frame)
314+ if len(stats.Skipped) != 0 {
315+ return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
316+ }
317+ if slot.Wait != nil && slot.WaitInFlight {
318+ slot.Wait.Close()
319+ slot.Wait = nil
320+ slot.WaitInFlight = false
321+ }
322+ if err = slot.Frame.RecordList(slot.Image, t.pipeline, t.width, t.height, instances, frame.Uploads, batches, slot.First, t.capture); err != nil {
323+ return false, err
324+ }
325+ if err = slot.Frame.Submit(slot.Wait); err != nil {
326+ return false, err
327+ }
328+ t.Preparer.Submitted(frame.Uploads)
329+ if slot.Wait != nil {
330+ slot.WaitInFlight = true
331+ }
332+ slot.First = false
333+ // From here the buffer is committed to the pipeline; failures are fatal.
334+ fd, err := slot.Frame.Signal.Export()
335+ if err != nil {
336+ return false, t.fail(err)
337+ }
338+ acquire, err := t.Pool.Acquire(b)
339+ if err != nil {
340+ _ = unix.Close(fd)
341+ return false, t.fail(err)
342+ }
343+ if err = t.Node.AcquirePoint(fd, t.acquire, acquire); err != nil {
344+ _ = unix.Close(fd)
345+ return false, t.fail(err)
346+ }
347+ release, err := t.Pool.Commit(b)
348+ if err != nil {
349+ return false, t.fail(err)
350+ }
351+ if err = t.Node.EventFD(slot.ReleaseHandle, release, slot.ReleaseFD); err != nil {
352+ return false, t.fail(err)
353+ }
354+ if err = t.Pool.Own(b); err != nil {
355+ return false, t.fail(err)
356+ }
357+ t.fill(out, slot, acquire, release)
358+ return true, nil
359+}
360+
361+// fail records an error after Submit: the buffer cannot go back to Available
362+// and every later Draw returns this error.
363+func (t *Target) fail(err error) error {
364+ t.broken = fmt.Errorf("session: presentation pipeline broken: %w", err)
365+ return t.broken
366+}
367+
368+func (t *Target) fill(out *Output, slot *Slot, acquire, release uint64) {
369+ b := slot.Buffer
370+ retired := append(out.Retired[:0], t.pending...)
371+ t.reported = append(t.reported[:0], t.pending...)
372+ t.pending = t.pending[:0]
373+ *out = Output{
374+ Buffer: b.ID, NewBuffer: !slot.Presented, Retired: retired,
375+ Width: t.width, Height: t.height, FourCC: t.selected.DRMFormat, Modifier: slot.Image.Modifier,
376+ PlaneCount: 1, Acquire: Timeline{ID: 0, FD: t.acquireFD}, Release: Timeline{ID: b.ID, FD: -1},
377+ NewTimelines: !slot.Presented, AcquirePoint: acquire, ReleasePoint: release, ReleaseFD: slot.ReleaseFD,
378+ }
379+ out.Planes[0] = Plane{FD: slot.Image.FD, Offset: slot.Image.Offset, Stride: slot.Image.Stride}
380+ if !slot.Presented {
381+ out.Release.FD = slot.ReleaseExport
382+ slot.Presented = true
383+ }
384+}
385+
386+// Released handles a readable release eventfd of the buffer: the compositor
387+// signaled its latest release point, so the image may be reused once the GPU
388+// wait is installed.
389+func (t *Target) Released(id uint64) error {
390+ slot := t.Slots[id]
391+ if slot == nil {
392+ return nil // retired: its descriptors are gone
393+ }
394+ // Drain first: a notification that lands after the drain is seen by the
395+ // Signaled check below or leaves the eventfd readable for a later no-op call.
396+ if err := drainEventFD(slot.ReleaseFD); err != nil {
397+ return err
398+ }
399+ b := slot.Buffer
400+ if b.State != buffers.CompositorOwned {
401+ return nil
402+ }
403+ signaled, err := t.Node.Signaled(slot.ReleaseHandle, b.ReleasePoint)
404+ if err != nil {
405+ return err
406+ }
407+ if !signaled {
408+ return nil // stale notification; the registration for the live point is still armed
409+ }
410+ return t.release(slot, b.ReleasePoint)
411+}
412+
413+// drainEventFD resets a non-blocking eventfd; an empty one is fine.
414+func drainEventFD(fd int) error {
415+ var counter [8]byte
416+ if _, err := unix.Read(fd, counter[:]); err != nil && err != unix.EAGAIN {
417+ return fmt.Errorf("session: read release eventfd: %w", err)
418+ }
419+ return nil
420+}
421+
422+func (t *Target) release(slot *Slot, point uint64) error {
423+ b := slot.Buffer
424+ // An older completion cannot authorize a newer commit of the same image.
425+ if b.ReleasePoint != point || b.State != buffers.CompositorOwned {
426+ return nil
427+ }
428+ if err := t.Pool.Signal(b, point); err != nil {
429+ return err
430+ }
431+ if b.Generation == t.Pool.Generation {
432+ fd, err := t.Node.ReleaseFence(slot.ReleaseHandle, point)
433+ if err != nil {
434+ return err
435+ }
436+ wait, err := t.Device.NewBinaryFence(false)
437+ if err != nil {
438+ _ = unix.Close(fd)
439+ return err
440+ }
441+ if err = wait.ImportTemporary(fd); err != nil {
442+ _ = unix.Close(fd)
443+ wait.Close()
444+ return err
445+ }
446+ installed, err := installReleaseWait(slot, wait, slot.Frame.Ready)
447+ if err != nil {
448+ wait.Close()
449+ return err
450+ }
451+ if !installed {
452+ return nil
453+ }
454+ if err = t.Pool.Reuse(b, true); err != nil {
455+ return err
456+ }
457+ }
458+ return t.retire()
459+}
460+
461+// finishPending installs release waits deferred because the old submission was
462+// still running. It never waits on the GPU.
463+func (t *Target) finishPending() (bool, error) {
464+ pending := false
465+ for _, slot := range t.Slots {
466+ // Old generations never return to Available; retire frees them.
467+ if slot.PendingWait == nil || slot.Buffer.Generation != t.Pool.Generation {
468+ continue
469+ }
470+ ready, err := slot.Frame.Ready()
471+ if err != nil {
472+ return false, err
473+ }
474+ if !ready {
475+ pending = true
476+ continue
477+ }
478+ if slot.Wait != nil {
479+ slot.Wait.Close()
480+ }
481+ slot.Wait, slot.PendingWait, slot.WaitInFlight = slot.PendingWait, nil, false
482+ if err := t.Pool.Reuse(slot.Buffer, true); err != nil {
483+ return false, err
484+ }
485+ }
486+ return pending, nil
487+}
488+
489+// retire frees images of old generations once nothing uses them, and queues
490+// the ones the transport imported for the next Output.
491+func (t *Target) retire() error {
492+ for id, slot := range t.Slots {
493+ b := slot.Buffer
494+ if !t.Pool.Retirable(b) {
495+ continue
496+ }
497+ ready, err := slot.Frame.Ready()
498+ if err != nil {
499+ return err
500+ }
501+ if !ready {
502+ continue
503+ }
504+ eventFD := slot.ReleaseFD
505+ slot.ReleaseFD = -1
506+ wasPresented := slot.Presented
507+ err = t.closeSlot(slot)
508+ if wasPresented {
509+ t.pending = append(t.pending, Retired{Buffer: id, ReleaseFD: eventFD})
510+ } else if eventFD >= 0 {
511+ _ = unix.Close(eventFD)
512+ }
513+ delete(t.Slots, id)
514+ if rerr := t.Pool.Remove(b); rerr != nil {
515+ return errors.Join(err, rerr)
516+ }
517+ if err != nil {
518+ return err
519+ }
520+ }
521+ for i := 0; i < len(t.retiredPipelines); {
522+ old := t.retiredPipelines[i]
523+ live := false
524+ for _, slot := range t.Slots {
525+ if slot.Buffer.Generation == old.generation {
526+ live = true
527+ break
528+ }
529+ }
530+ if live {
531+ i++
532+ continue
533+ }
534+ old.list.Close()
535+ t.retiredPipelines = append(t.retiredPipelines[:i], t.retiredPipelines[i+1:]...)
536+ }
537+ return nil
538+}
539+
540+// Readback returns the last drawn pixels of a buffer in RGBA order. Tests only;
541+// call EnableReadback before the first Draw.
542+func (t *Target) Readback(id uint64) (*image.NRGBA, error) {
543+ slot := t.Slots[id]
544+ if slot == nil || slot.Frame.Readback == nil {
545+ return nil, errors.New("session: no readback for buffer")
546+ }
547+ if err := slot.Frame.Wait(); err != nil {
548+ return nil, err
549+ }
550+ img, err := slot.Frame.Readback.Copy()
551+ if err != nil {
552+ return nil, err
553+ }
554+ vkdevice.SwizzleBGRA(img)
555+ return img, nil
556+}
557+
558+// Close waits for the GPU, then frees every descriptor and image, always
559+// completing the teardown. The compositor's imports keep their own kernel
560+// references. It returns the joined errors of the steps that failed.
561+func (t *Target) Close() error {
562+ if t == nil || t.closed {
563+ return nil
564+ }
565+ t.closed = true
566+ var errs []error
567+ if t.Device != nil && t.Device.Logical != 0 && t.Device.Dispatch != nil {
568+ errs = append(errs, vulkan.Check(t.Device.Dispatch.DeviceWaitIdle(t.Device.Logical)))
569+ }
570+ for _, r := range t.reported {
571+ _ = unix.Close(r.ReleaseFD)
572+ }
573+ for _, r := range t.pending {
574+ _ = unix.Close(r.ReleaseFD)
575+ }
576+ t.reported, t.pending = nil, nil
577+ for id, slot := range t.Slots {
578+ closeFD(&slot.ReleaseFD)
579+ errs = append(errs, t.closeSlot(slot))
580+ delete(t.Slots, id)
581+ }
582+ for _, old := range t.retiredPipelines {
583+ old.list.Close()
584+ }
585+ t.retiredPipelines = nil
586+ t.pipeline.Close()
587+ closeFD(&t.acquireFD)
588+ if t.Node != nil {
589+ if t.acquire != 0 {
590+ errs = append(errs, t.Node.Destroy(t.acquire))
591+ }
592+ errs = append(errs, t.Node.Close())
593+ }
594+ t.Device.Close()
595+ return errors.Join(errs...)
596+}
M internal/presentation/syncobj/syncobj.go +35 -0
@@ -41,6 +41,15 @@ type queryArg struct {
4141 Count, Flags uint32
4242 }
4343
44+// eventFDArg is struct drm_syncobj_eventfd: the kernel signals fd once the
45+// timeline point is signaled (flags 0) without any thread waiting on it.
46+type eventFDArg struct {
47+ Handle, Flags uint32
48+ Point uint64
49+ FD int32
50+ Pad uint32
51+}
52+
4453 const (
4554 ioctlCreate = uintptr(0xc00864bf)
4655 ioctlDestroy = uintptr(0xc00864c0)
@@ -49,6 +58,8 @@ const (
4958 ioctlTimelineWait = uintptr(0xc03064ca)
5059 ioctlQuery = uintptr(0xc01864cb)
5160 ioctlTransfer = uintptr(0xc02064cc)
61+ ioctlTimelineSig = uintptr(0xc01864cd)
62+ ioctlEventFD = uintptr(0xc01864cf)
5263 importSyncFile = 1
5364 exportSyncFile = 1
5465 )
@@ -118,6 +129,30 @@ func (n *Node) ExportTimeline(handle uint32) (int, error) {
118129 return int(a.FD), nil
119130 }
120131
132+// EventFD arms efd, an eventfd owned by the caller, to become readable when
133+// point of the timeline behind handle is signaled. The point need not be
134+// submitted yet. The registration is one-shot: re-arm after every use. The
135+// call returns at once and never waits.
136+func (n *Node) EventFD(handle uint32, point uint64, efd int) error {
137+ a := eventFDArg{Handle: handle, Point: point, FD: int32(efd)}
138+ return n.ioctl(ioctlEventFD, unsafe.Pointer(&a))
139+}
140+
141+// Signal signals one timeline point from the CPU (DRM_IOCTL_SYNCOBJ_TIMELINE_SIGNAL).
142+// The compositor does this through its release timeline; clients use it only
143+// to simulate that in tests.
144+func (n *Node) Signal(handle uint32, point uint64) error {
145+ h, p := handle, point
146+ a := queryArg{Handles: uint64(uintptr(unsafe.Pointer(&h))), Points: uint64(uintptr(unsafe.Pointer(&p))), Count: 1}
147+ errno := n.ioctlErrno(ioctlTimelineSig, unsafe.Pointer(&a))
148+ runtime.KeepAlive(h)
149+ runtime.KeepAlive(p)
150+ if errno != 0 {
151+ return ioctlError(ioctlTimelineSig, errno)
152+ }
153+ return nil
154+}
155+
121156 // ImportFence consumes syncFile only after FD_TO_HANDLE imports it into a
122157 // pre-created destination syncobj. With IMPORT_SYNC_FILE, Handle is an input,
123158 // not an output: asking the kernel to import into handle zero returns ENOENT.
@@ -41,6 +41,15 @@ type queryArg struct {
41 Count, Flags uint3241 Count, Flags uint32
42 }42 }
43 43
44+// eventFDArg is struct drm_syncobj_eventfd: the kernel signals fd once the
45+// timeline point is signaled (flags 0) without any thread waiting on it.
46+type eventFDArg struct {
47+ Handle, Flags uint32
48+ Point uint64
49+ FD int32
50+ Pad uint32
51+}
52+
44 const (53 const (
45 ioctlCreate = uintptr(0xc00864bf)54 ioctlCreate = uintptr(0xc00864bf)
46 ioctlDestroy = uintptr(0xc00864c0)55 ioctlDestroy = uintptr(0xc00864c0)
@@ -49,6 +58,8 @@ const (
49 ioctlTimelineWait = uintptr(0xc03064ca)58 ioctlTimelineWait = uintptr(0xc03064ca)
50 ioctlQuery = uintptr(0xc01864cb)59 ioctlQuery = uintptr(0xc01864cb)
51 ioctlTransfer = uintptr(0xc02064cc)60 ioctlTransfer = uintptr(0xc02064cc)
61+ ioctlTimelineSig = uintptr(0xc01864cd)
62+ ioctlEventFD = uintptr(0xc01864cf)
52 importSyncFile = 163 importSyncFile = 1
53 exportSyncFile = 164 exportSyncFile = 1
54 )65 )
@@ -118,6 +129,30 @@ func (n *Node) ExportTimeline(handle uint32) (int, error) {
118 return int(a.FD), nil129 return int(a.FD), nil
119 }130 }
120 131
132+// EventFD arms efd, an eventfd owned by the caller, to become readable when
133+// point of the timeline behind handle is signaled. The point need not be
134+// submitted yet. The registration is one-shot: re-arm after every use. The
135+// call returns at once and never waits.
136+func (n *Node) EventFD(handle uint32, point uint64, efd int) error {
137+ a := eventFDArg{Handle: handle, Point: point, FD: int32(efd)}
138+ return n.ioctl(ioctlEventFD, unsafe.Pointer(&a))
139+}
140+
141+// Signal signals one timeline point from the CPU (DRM_IOCTL_SYNCOBJ_TIMELINE_SIGNAL).
142+// The compositor does this through its release timeline; clients use it only
143+// to simulate that in tests.
144+func (n *Node) Signal(handle uint32, point uint64) error {
145+ h, p := handle, point
146+ a := queryArg{Handles: uint64(uintptr(unsafe.Pointer(&h))), Points: uint64(uintptr(unsafe.Pointer(&p))), Count: 1}
147+ errno := n.ioctlErrno(ioctlTimelineSig, unsafe.Pointer(&a))
148+ runtime.KeepAlive(h)
149+ runtime.KeepAlive(p)
150+ if errno != 0 {
151+ return ioctlError(ioctlTimelineSig, errno)
152+ }
153+ return nil
154+}
155+
121 // ImportFence consumes syncFile only after FD_TO_HANDLE imports it into a156 // ImportFence consumes syncFile only after FD_TO_HANDLE imports it into a
122 // pre-created destination syncobj. With IMPORT_SYNC_FILE, Handle is an input,157 // pre-created destination syncobj. With IMPORT_SYNC_FILE, Handle is an input,
123 // not an output: asking the kernel to import into handle zero returns ENOENT.158 // not an output: asking the kernel to import into handle zero returns ENOENT.
M internal/presentation/syncobj/syncobj_test.go +78 -0
@@ -22,6 +22,7 @@ func TestUAPILayouts(t *testing.T) {
2222 {"transfer", unsafe.Sizeof(transferArg{}), 32},
2323 {"wait", unsafe.Sizeof(timelineWaitArg{}), 48},
2424 {"query", unsafe.Sizeof(queryArg{}), 24},
25+ {"eventfd", unsafe.Sizeof(eventFDArg{}), 24},
2526 } {
2627 if tc.got != tc.want {
2728 t.Errorf("%s size: got %d want %d", tc.name, tc.got, tc.want)
@@ -102,3 +103,80 @@ func TestFailedImportRetainsFD(t *testing.T) {
102103 t.Fatalf("lost caller FD after failed import: %v", err)
103104 }
104105 }
106+
107+// DRM_IOWR(type 'd', nr, size): direction 3<<30, size<<16, type<<8, nr.
108+func TestEventFDRequestNumber(t *testing.T) {
109+ want := uintptr(3<<30 | unsafe.Sizeof(eventFDArg{})<<16 | 'd'<<8 | 0xCF)
110+ if ioctlEventFD != want {
111+ t.Fatalf("DRM_IOCTL_SYNCOBJ_EVENTFD: got %#x want %#x", ioctlEventFD, want)
112+ }
113+ want = uintptr(3<<30 | unsafe.Sizeof(queryArg{})<<16 | 'd'<<8 | 0xCD)
114+ if ioctlTimelineSig != want {
115+ t.Fatalf("DRM_IOCTL_SYNCOBJ_TIMELINE_SIGNAL: got %#x want %#x", ioctlTimelineSig, want)
116+ }
117+}
118+
119+func TestEventFDBadFDFailsWithErrno(t *testing.T) {
120+ n := &Node{fd: -1}
121+ if err := n.EventFD(1, 1, 0); !errors.Is(err, unix.EBADF) {
122+ t.Fatalf("want EBADF, got %v", err)
123+ }
124+}
125+
126+// A point signaled from the CPU makes the registered eventfd readable without
127+// any waiter thread; the registration is one-shot.
128+func TestEventFDSignaledPointHardware(t *testing.T) {
129+ path := os.Getenv("NEFERGUI_RENDER_NODE")
130+ if path == "" {
131+ t.Skip("opt in with NEFERGUI_RENDER_NODE=/dev/dri/renderD...")
132+ }
133+ n, err := Open(path)
134+ if err != nil {
135+ t.Fatal(err)
136+ }
137+ defer n.Close()
138+ handle, err := n.Create()
139+ if err != nil {
140+ t.Fatal(err)
141+ }
142+ defer n.Destroy(handle)
143+ efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
144+ if err != nil {
145+ t.Fatal(err)
146+ }
147+ defer unix.Close(efd)
148+ if err = n.EventFD(handle, 7, efd); err != nil {
149+ t.Fatalf("EventFD: %v", err)
150+ }
151+ readable := func(timeout int) bool {
152+ fds := []unix.PollFd{{Fd: int32(efd), Events: unix.POLLIN}}
153+ r, err := unix.Poll(fds, timeout)
154+ if err != nil {
155+ t.Fatal(err)
156+ }
157+ return r > 0
158+ }
159+ if readable(0) {
160+ t.Fatal("eventfd readable before the point signaled")
161+ }
162+ if err = n.Signal(handle, 7); err != nil {
163+ t.Fatalf("Signal: %v", err)
164+ }
165+ if !readable(1000) {
166+ t.Fatal("eventfd not readable after the point signaled")
167+ }
168+ var buf [8]byte
169+ if _, err = unix.Read(efd, buf[:]); err != nil {
170+ t.Fatal(err)
171+ }
172+ if readable(0) {
173+ t.Fatal("eventfd still readable after read")
174+ }
175+ // Re-arming for an already signaled point fires immediately.
176+ if err = n.EventFD(handle, 7, efd); err != nil {
177+ t.Fatal(err)
178+ }
179+ if !readable(1000) {
180+ t.Fatal("re-armed eventfd for a signaled point stayed silent")
181+ }
182+}
@@ -22,6 +22,7 @@ func TestUAPILayouts(t *testing.T) {
22 {"transfer", unsafe.Sizeof(transferArg{}), 32},22 {"transfer", unsafe.Sizeof(transferArg{}), 32},
23 {"wait", unsafe.Sizeof(timelineWaitArg{}), 48},23 {"wait", unsafe.Sizeof(timelineWaitArg{}), 48},
24 {"query", unsafe.Sizeof(queryArg{}), 24},24 {"query", unsafe.Sizeof(queryArg{}), 24},
25+ {"eventfd", unsafe.Sizeof(eventFDArg{}), 24},
25 } {26 } {
26 if tc.got != tc.want {27 if tc.got != tc.want {
27 t.Errorf("%s size: got %d want %d", tc.name, tc.got, tc.want)28 t.Errorf("%s size: got %d want %d", tc.name, tc.got, tc.want)
@@ -102,3 +103,80 @@ func TestFailedImportRetainsFD(t *testing.T) {
102 t.Fatalf("lost caller FD after failed import: %v", err)103 t.Fatalf("lost caller FD after failed import: %v", err)
103 }104 }
104 }105 }
106+
107+// DRM_IOWR(type 'd', nr, size): direction 3<<30, size<<16, type<<8, nr.
108+func TestEventFDRequestNumber(t *testing.T) {
109+ want := uintptr(3<<30 | unsafe.Sizeof(eventFDArg{})<<16 | 'd'<<8 | 0xCF)
110+ if ioctlEventFD != want {
111+ t.Fatalf("DRM_IOCTL_SYNCOBJ_EVENTFD: got %#x want %#x", ioctlEventFD, want)
112+ }
113+ want = uintptr(3<<30 | unsafe.Sizeof(queryArg{})<<16 | 'd'<<8 | 0xCD)
114+ if ioctlTimelineSig != want {
115+ t.Fatalf("DRM_IOCTL_SYNCOBJ_TIMELINE_SIGNAL: got %#x want %#x", ioctlTimelineSig, want)
116+ }
117+}
118+
119+func TestEventFDBadFDFailsWithErrno(t *testing.T) {
120+ n := &Node{fd: -1}
121+ if err := n.EventFD(1, 1, 0); !errors.Is(err, unix.EBADF) {
122+ t.Fatalf("want EBADF, got %v", err)
123+ }
124+}
125+
126+// A point signaled from the CPU makes the registered eventfd readable without
127+// any waiter thread; the registration is one-shot.
128+func TestEventFDSignaledPointHardware(t *testing.T) {
129+ path := os.Getenv("NEFERGUI_RENDER_NODE")
130+ if path == "" {
131+ t.Skip("opt in with NEFERGUI_RENDER_NODE=/dev/dri/renderD...")
132+ }
133+ n, err := Open(path)
134+ if err != nil {
135+ t.Fatal(err)
136+ }
137+ defer n.Close()
138+ handle, err := n.Create()
139+ if err != nil {
140+ t.Fatal(err)
141+ }
142+ defer n.Destroy(handle)
143+ efd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
144+ if err != nil {
145+ t.Fatal(err)
146+ }
147+ defer unix.Close(efd)
148+ if err = n.EventFD(handle, 7, efd); err != nil {
149+ t.Fatalf("EventFD: %v", err)
150+ }
151+ readable := func(timeout int) bool {
152+ fds := []unix.PollFd{{Fd: int32(efd), Events: unix.POLLIN}}
153+ r, err := unix.Poll(fds, timeout)
154+ if err != nil {
155+ t.Fatal(err)
156+ }
157+ return r > 0
158+ }
159+ if readable(0) {
160+ t.Fatal("eventfd readable before the point signaled")
161+ }
162+ if err = n.Signal(handle, 7); err != nil {
163+ t.Fatalf("Signal: %v", err)
164+ }
165+ if !readable(1000) {
166+ t.Fatal("eventfd not readable after the point signaled")
167+ }
168+ var buf [8]byte
169+ if _, err = unix.Read(efd, buf[:]); err != nil {
170+ t.Fatal(err)
171+ }
172+ if readable(0) {
173+ t.Fatal("eventfd still readable after read")
174+ }
175+ // Re-arming for an already signaled point fires immediately.
176+ if err = n.EventFD(handle, 7, efd); err != nil {
177+ t.Fatal(err)
178+ }
179+ if !readable(1000) {
180+ t.Fatal("re-armed eventfd for a signaled point stayed silent")
181+ }
182+}
M internal/presentation/vkdevice/image.go +14 -34
@@ -8,28 +8,28 @@ import (
88 "unsafe"
99
1010 "github.com/bnema/purego-vulkan/vulkan"
11- "github.com/bnema/wlturbo/protocol/core"
12- "github.com/bnema/wlturbo/protocol/linuxdmabuf"
1311 "golang.org/x/sys/unix"
1412 )
1513
16-// Image owns a Vulkan image, dedicated exportable memory and its Wayland buffer.
17-// Close must only be called after both wl_buffer.release and the compositor's
18-// release timeline point, or for an image never attached to a surface.
14+// Image owns a Vulkan image, dedicated exportable memory and the exported
15+// DMA-BUF file descriptor. FD stays owned by the Image and is closed by Close;
16+// a transport that consumes descriptors must be handed a dup. The compositor's
17+// import keeps its own kernel reference, so Close may run while the compositor
18+// still holds the buffer, but the GPU must be done with the image first.
1919 type Image struct {
2020 Image vulkan.Image
2121 Memory vulkan.DeviceMemory
22- Buffer *core.Buffer
22+ FD int // exported DMA-BUF, -1 when none
2323 Modifier uint64
2424 Offset, Stride uint32
2525 device *Device
2626 }
2727
28-func (d *Device) AllocateImage(width, height int32, selected Modifier, dmabuf *linuxdmabuf.LinuxDmabuf) (img *Image, err error) {
29- if width <= 0 || height <= 0 || dmabuf == nil {
30- return nil, fmt.Errorf("invalid DMA-BUF image dimensions or manager")
28+func (d *Device) AllocateImage(width, height int32, selected Modifier) (img *Image, err error) {
29+ if width <= 0 || height <= 0 {
30+ return nil, fmt.Errorf("invalid DMA-BUF image dimensions")
3131 }
32- img = &Image{device: d}
32+ img = &Image{device: d, FD: -1}
3333 defer func() {
3434 if err != nil {
3535 img.Close()
@@ -88,27 +88,7 @@ func (d *Device) AllocateImage(width, height int32, selected Modifier, dmabuf *l
8888 if err = vulkan.Check(d.Dispatch.GetMemoryFdKHR(d.Logical, &fdInfo, &fd)); err != nil {
8989 return nil, fmt.Errorf("export DMA-BUF: %w", err)
9090 }
91- // WLTurbo consumes fd only when its complete Add request succeeds. On a
92- // failed send Vulkan still owns the image and we still own the exported FD.
93- owned := true
94- defer func() {
95- if owned {
96- _ = unix.Close(int(fd))
97- }
98- }()
99- params, e := dmabuf.CreateParams()
100- if e != nil {
101- return nil, e
102- }
103- defer params.Destroy()
104- if err = params.Add(int(fd), 0, img.Offset, img.Stride, uint32(img.Modifier>>32), uint32(img.Modifier)); err != nil {
105- return nil, err
106- }
107- owned = false
108- img.Buffer, err = params.CreateImmed(width, height, selected.DRMFormat, 0)
109- if err != nil {
110- return nil, err
111- }
91+ img.FD = int(fd)
11292 return img, nil
11393 }
11494
@@ -116,9 +96,9 @@ func (img *Image) Close() {
11696 if img == nil || img.device == nil {
11797 return
11898 }
119- if img.Buffer != nil {
120- _ = img.Buffer.Destroy()
121- img.Buffer = nil
99+ if img.FD >= 0 {
100+ _ = unix.Close(img.FD)
101+ img.FD = -1
122102 }
123103 if img.Image != 0 {
124104 img.device.Dispatch.DestroyImage(img.device.Logical, img.Image, nil)
@@ -8,28 +8,28 @@ import (
8 "unsafe"8 "unsafe"
9 9
10 "github.com/bnema/purego-vulkan/vulkan"10 "github.com/bnema/purego-vulkan/vulkan"
11- "github.com/bnema/wlturbo/protocol/core"
12- "github.com/bnema/wlturbo/protocol/linuxdmabuf"
13 "golang.org/x/sys/unix"11 "golang.org/x/sys/unix"
14 )12 )
15 13
16-// Image owns a Vulkan image, dedicated exportable memory and its Wayland buffer.14+// Image owns a Vulkan image, dedicated exportable memory and the exported
17-// Close must only be called after both wl_buffer.release and the compositor's15+// DMA-BUF file descriptor. FD stays owned by the Image and is closed by Close;
18-// release timeline point, or for an image never attached to a surface.16+// a transport that consumes descriptors must be handed a dup. The compositor's
17+// import keeps its own kernel reference, so Close may run while the compositor
18+// still holds the buffer, but the GPU must be done with the image first.
19 type Image struct {19 type Image struct {
20 Image vulkan.Image20 Image vulkan.Image
21 Memory vulkan.DeviceMemory21 Memory vulkan.DeviceMemory
22- Buffer *core.Buffer22+ FD int // exported DMA-BUF, -1 when none
23 Modifier uint6423 Modifier uint64
24 Offset, Stride uint3224 Offset, Stride uint32
25 device *Device25 device *Device
26 }26 }
27 27
28-func (d *Device) AllocateImage(width, height int32, selected Modifier, dmabuf *linuxdmabuf.LinuxDmabuf) (img *Image, err error) {28+func (d *Device) AllocateImage(width, height int32, selected Modifier) (img *Image, err error) {
29- if width <= 0 || height <= 0 || dmabuf == nil {29+ if width <= 0 || height <= 0 {
30- return nil, fmt.Errorf("invalid DMA-BUF image dimensions or manager")30+ return nil, fmt.Errorf("invalid DMA-BUF image dimensions")
31 }31 }
32- img = &Image{device: d}32+ img = &Image{device: d, FD: -1}
33 defer func() {33 defer func() {
34 if err != nil {34 if err != nil {
35 img.Close()35 img.Close()
@@ -88,27 +88,7 @@ func (d *Device) AllocateImage(width, height int32, selected Modifier, dmabuf *l
88 if err = vulkan.Check(d.Dispatch.GetMemoryFdKHR(d.Logical, &fdInfo, &fd)); err != nil {88 if err = vulkan.Check(d.Dispatch.GetMemoryFdKHR(d.Logical, &fdInfo, &fd)); err != nil {
89 return nil, fmt.Errorf("export DMA-BUF: %w", err)89 return nil, fmt.Errorf("export DMA-BUF: %w", err)
90 }90 }
91- // WLTurbo consumes fd only when its complete Add request succeeds. On a91+ img.FD = int(fd)
92- // failed send Vulkan still owns the image and we still own the exported FD.
93- owned := true
94- defer func() {
95- if owned {
96- _ = unix.Close(int(fd))
97- }
98- }()
99- params, e := dmabuf.CreateParams()
100- if e != nil {
101- return nil, e
102- }
103- defer params.Destroy()
104- if err = params.Add(int(fd), 0, img.Offset, img.Stride, uint32(img.Modifier>>32), uint32(img.Modifier)); err != nil {
105- return nil, err
106- }
107- owned = false
108- img.Buffer, err = params.CreateImmed(width, height, selected.DRMFormat, 0)
109- if err != nil {
110- return nil, err
111- }
112 return img, nil92 return img, nil
113 }93 }
114 94
@@ -116,9 +96,9 @@ func (img *Image) Close() {
116 if img == nil || img.device == nil {96 if img == nil || img.device == nil {
117 return97 return
118 }98 }
119- if img.Buffer != nil {99+ if img.FD >= 0 {
120- _ = img.Buffer.Destroy()100+ _ = unix.Close(img.FD)
121- img.Buffer = nil101+ img.FD = -1
122 }102 }
123 if img.Image != 0 {103 if img.Image != 0 {
124 img.device.Dispatch.DestroyImage(img.device.Logical, img.Image, nil)104 img.device.Dispatch.DestroyImage(img.device.Logical, img.Image, nil)
M internal/presentation/vkdevice/modifier.go +12 -7
@@ -7,7 +7,6 @@ import (
77 "unsafe"
88
99 "github.com/bnema/purego-vulkan/vulkan"
10- "github.com/bnema/wlturbo/protocol/linuxdmabuf"
1110 )
1211
1312 // Modifier is the one-plane GPU/compositor common image layout chosen in
@@ -22,16 +21,22 @@ const (
2221 ARGB8888 uint32 = 0x34325241
2322 )
2423
25-func ChooseModifier(tranches [][]linuxdmabuf.FormatEntry, transparent bool, supported map[uint64]bool) (Modifier, error) {
24+// Format is one DRM format and modifier pair the compositor accepts.
25+type Format struct {
26+ FourCC uint32
27+ Modifier uint64
28+}
29+
30+// ChooseModifier picks the first format in the compositor's preference order
31+// whose modifier the GPU can also export.
32+func ChooseModifier(formats []Format, transparent bool, supported map[uint64]bool) (Modifier, error) {
2633 format := XRGB8888
2734 if transparent {
2835 format = ARGB8888
2936 }
30- for _, tranche := range tranches {
31- for _, entry := range tranche {
32- if entry.Format == format && supported[entry.Modifier] {
33- return Modifier{format, entry.Modifier}, nil
34- }
37+ for _, f := range formats {
38+ if f.FourCC == format && supported[f.Modifier] {
39+ return Modifier{format, f.Modifier}, nil
3540 }
3641 }
3742 return Modifier{}, fmt.Errorf("no one-plane exportable B8G8R8A8 modifier shared with compositor for DRM format %#x", format)
@@ -7,7 +7,6 @@ import (
7 "unsafe"7 "unsafe"
8 8
9 "github.com/bnema/purego-vulkan/vulkan"9 "github.com/bnema/purego-vulkan/vulkan"
10- "github.com/bnema/wlturbo/protocol/linuxdmabuf"
11 )10 )
12 11
13 // Modifier is the one-plane GPU/compositor common image layout chosen in12 // Modifier is the one-plane GPU/compositor common image layout chosen in
@@ -22,16 +21,22 @@ const (
22 ARGB8888 uint32 = 0x3432524121 ARGB8888 uint32 = 0x34325241
23 )22 )
24 23
25-func ChooseModifier(tranches [][]linuxdmabuf.FormatEntry, transparent bool, supported map[uint64]bool) (Modifier, error) {24+// Format is one DRM format and modifier pair the compositor accepts.
25+type Format struct {
26+ FourCC uint32
27+ Modifier uint64
28+}
29+
30+// ChooseModifier picks the first format in the compositor's preference order
31+// whose modifier the GPU can also export.
32+func ChooseModifier(formats []Format, transparent bool, supported map[uint64]bool) (Modifier, error) {
26 format := XRGB888833 format := XRGB8888
27 if transparent {34 if transparent {
28 format = ARGB888835 format = ARGB8888
29 }36 }
30- for _, tranche := range tranches {37+ for _, f := range formats {
31- for _, entry := range tranche {38+ if f.FourCC == format && supported[f.Modifier] {
32- if entry.Format == format && supported[entry.Modifier] {39+ return Modifier{format, f.Modifier}, nil
33- return Modifier{format, entry.Modifier}, nil
34- }
35 }40 }
36 }41 }
37 return Modifier{}, fmt.Errorf("no one-plane exportable B8G8R8A8 modifier shared with compositor for DRM format %#x", format)42 return Modifier{}, fmt.Errorf("no one-plane exportable B8G8R8A8 modifier shared with compositor for DRM format %#x", format)
M internal/presentation/vkdevice/modifier_test.go +1 -2
@@ -3,14 +3,13 @@
33 package vkdevice
44
55 import (
6- "github.com/bnema/wlturbo/protocol/linuxdmabuf"
76 "golang.org/x/sys/unix"
87 "os"
98 "testing"
109 )
1110
1211 func TestModifierIntersection(t *testing.T) {
13- tranches := [][]linuxdmabuf.FormatEntry{{{Format: XRGB8888, Modifier: 11}, {Format: ARGB8888, Modifier: 22}}, {{Format: XRGB8888, Modifier: 33}, {Format: ARGB8888, Modifier: 44}}}
12+ tranches := []Format{{XRGB8888, 11}, {ARGB8888, 22}, {XRGB8888, 33}, {ARGB8888, 44}}
1413 for _, tc := range []struct {
1514 alpha bool
1615 available map[uint64]bool
@@ -3,14 +3,13 @@
3 package vkdevice3 package vkdevice
4 4
5 import (5 import (
6- "github.com/bnema/wlturbo/protocol/linuxdmabuf"
7 "golang.org/x/sys/unix"6 "golang.org/x/sys/unix"
8 "os"7 "os"
9 "testing"8 "testing"
10 )9 )
11 10
12 func TestModifierIntersection(t *testing.T) {11 func TestModifierIntersection(t *testing.T) {
13- tranches := [][]linuxdmabuf.FormatEntry{{{Format: XRGB8888, Modifier: 11}, {Format: ARGB8888, Modifier: 22}}, {{Format: XRGB8888, Modifier: 33}, {Format: ARGB8888, Modifier: 44}}}12+ tranches := []Format{{XRGB8888, 11}, {ARGB8888, 22}, {XRGB8888, 33}, {ARGB8888, 44}}
14 for _, tc := range []struct {13 for _, tc := range []struct {
15 alpha bool14 alpha bool
16 available map[uint64]bool15 available map[uint64]bool
M internal/ui/controls.go +33 -0
@@ -3,6 +3,7 @@ package ui
33 import (
44 "fmt"
55 "github.com/bnema/nefergui/internal/edit"
6+ "strconv"
67 "strings"
78 )
89
@@ -231,3 +232,35 @@ func (n Node) Heading(text string, options ...HeadingOption) {
231232 }
232233 }
233234 }
235+
236+// TextInt shows prefix followed by v in decimal without fmt boxing. A prefix
237+// up to 76 bytes is built in a stack buffer, so the only allocation is the
238+// final string; a longer prefix costs one more.
239+func (n Node) TextInt(prefix string, v int64, options ...ContainerOption) {
240+ child := n.child("text", "", options)
241+ if !child.valid() {
242+ return
243+ }
244+ var stack [96]byte
245+ buf := append(stack[:0], prefix...) // grows on the heap for a long prefix
246+ buf = strconv.AppendInt(buf, v, 10)
247+ child.element.text = string(buf)
248+}
249+
250+// maskBullets backs Masked for common counts without allocating.
251+var maskBullets = strings.Repeat("•", 256)
252+
253+// Masked shows count bullet glyphs and stores no value, for a secret held
254+// outside the view (a password field fed from an external buffer). A negative
255+// count shows nothing.
256+func (n Node) Masked(count int, options ...ContainerOption) {
257+ child := n.child("text", "", options)
258+ if !child.valid() || count <= 0 {
259+ return
260+ }
261+ if count <= 256 {
262+ child.element.text = maskBullets[:count*len("•")]
263+ return
264+ }
265+ child.element.text = strings.Repeat("•", count)
266+}
@@ -3,6 +3,7 @@ package ui
3 import (3 import (
4 "fmt"4 "fmt"
5 "github.com/bnema/nefergui/internal/edit"5 "github.com/bnema/nefergui/internal/edit"
6+ "strconv"
6 "strings"7 "strings"
7 )8 )
8 9
@@ -231,3 +232,35 @@ func (n Node) Heading(text string, options ...HeadingOption) {
231 }232 }
232 }233 }
233 }234 }
235+
236+// TextInt shows prefix followed by v in decimal without fmt boxing. A prefix
237+// up to 76 bytes is built in a stack buffer, so the only allocation is the
238+// final string; a longer prefix costs one more.
239+func (n Node) TextInt(prefix string, v int64, options ...ContainerOption) {
240+ child := n.child("text", "", options)
241+ if !child.valid() {
242+ return
243+ }
244+ var stack [96]byte
245+ buf := append(stack[:0], prefix...) // grows on the heap for a long prefix
246+ buf = strconv.AppendInt(buf, v, 10)
247+ child.element.text = string(buf)
248+}
249+
250+// maskBullets backs Masked for common counts without allocating.
251+var maskBullets = strings.Repeat("•", 256)
252+
253+// Masked shows count bullet glyphs and stores no value, for a secret held
254+// outside the view (a password field fed from an external buffer). A negative
255+// count shows nothing.
256+func (n Node) Masked(count int, options ...ContainerOption) {
257+ child := n.child("text", "", options)
258+ if !child.valid() || count <= 0 {
259+ return
260+ }
261+ if count <= 256 {
262+ child.element.text = maskBullets[:count*len("•")]
263+ return
264+ }
265+ child.element.text = strings.Repeat("•", count)
266+}
M internal/ui/controls_extra_test.go +2 -3
@@ -3,7 +3,6 @@ package ui
33 import (
44 "github.com/bnema/nefergui/internal/css"
55 "github.com/bnema/nefergui/internal/edit"
6- "github.com/bnema/nefergui/internal/keyboard"
76 "testing"
87 )
98
@@ -85,7 +84,7 @@ func TestDisabledEditorRejectsFocusAndText(t *testing.T) {
8584 if r.state.focus != nil {
8685 t.Fatalf("disabled input focused: %s", idKey(r.state.focus))
8786 }
88- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "q", Text: "x", Pressed: true}})
87+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "q", Text: "x", Pressed: true}})
8988 r.Build(view)
9089 if value != "keep" {
9190 t.Fatalf("disabled input edited: %q", value)
@@ -101,7 +100,7 @@ func TestEditFocusIMEAndClipboard(t *testing.T) {
101100 r.clipboard = &clip{data: []byte("paste")}
102101 r.route(key("Tab"))
103102 r.Build(view)
104- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "q", Text: "x", Pressed: true}})
103+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "q", Text: "x", Pressed: true}})
105104 r.Build(view)
106105 if value != "x" || r.committed.children[0].text != "•" {
107106 t.Fatalf("value=%q display=%q focus=%s", value, r.committed.children[0].text, idKey(r.state.focus))
@@ -3,7 +3,6 @@ package ui
3 import (3 import (
4 "github.com/bnema/nefergui/internal/css"4 "github.com/bnema/nefergui/internal/css"
5 "github.com/bnema/nefergui/internal/edit"5 "github.com/bnema/nefergui/internal/edit"
6- "github.com/bnema/nefergui/internal/keyboard"
7 "testing"6 "testing"
8 )7 )
9 8
@@ -85,7 +84,7 @@ func TestDisabledEditorRejectsFocusAndText(t *testing.T) {
85 if r.state.focus != nil {84 if r.state.focus != nil {
86 t.Fatalf("disabled input focused: %s", idKey(r.state.focus))85 t.Fatalf("disabled input focused: %s", idKey(r.state.focus))
87 }86 }
88- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "q", Text: "x", Pressed: true}})87+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "q", Text: "x", Pressed: true}})
89 r.Build(view)88 r.Build(view)
90 if value != "keep" {89 if value != "keep" {
91 t.Fatalf("disabled input edited: %q", value)90 t.Fatalf("disabled input edited: %q", value)
@@ -101,7 +100,7 @@ func TestEditFocusIMEAndClipboard(t *testing.T) {
101 r.clipboard = &clip{data: []byte("paste")}100 r.clipboard = &clip{data: []byte("paste")}
102 r.route(key("Tab"))101 r.route(key("Tab"))
103 r.Build(view)102 r.Build(view)
104- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "q", Text: "x", Pressed: true}})103+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "q", Text: "x", Pressed: true}})
105 r.Build(view)104 r.Build(view)
106 if value != "x" || r.committed.children[0].text != "•" {105 if value != "x" || r.committed.children[0].text != "•" {
107 t.Fatalf("value=%q display=%q focus=%s", value, r.committed.children[0].text, idKey(r.state.focus))106 t.Fatalf("value=%q display=%q focus=%s", value, r.committed.children[0].text, idKey(r.state.focus))
M internal/ui/hooks.go +0 -23
@@ -12,29 +12,6 @@ import (
1212 "github.com/bnema/nefergui/internal/platform/wayland"
1313 )
1414
15-// InputKind classifies an InputEvent.
16-type InputKind uint8
17-
18-const (
19- InputPointerMotion InputKind = iota + 1
20- InputPointerPress
21- InputPointerRelease
22- InputPointerAxis
23- InputPointerLeave
24- InputKey
25- InputFocusIn
26- InputFocusOut
27- InputReset // platform dropped queued input; treat held state as released
28-)
29-
30-// Modifiers is a bit set of keyboard modifiers held during a key event.
31-type Modifiers uint8
32-
33-const (
34- ModShift Modifiers = 1 << iota
35- ModCtrl
36-)
37-
3815 // InputEvent is a raw platform event, delivered on the owner loop before the
3916 // normal control routing. Coordinates are logical, surface-local pixels.
4017 // Size changes are not input; use OnResize and Frame.Size. Which fields are set
@@ -12,29 +12,6 @@ import (
12 "github.com/bnema/nefergui/internal/platform/wayland"12 "github.com/bnema/nefergui/internal/platform/wayland"
13 )13 )
14 14
15-// InputKind classifies an InputEvent.
16-type InputKind uint8
17-
18-const (
19- InputPointerMotion InputKind = iota + 1
20- InputPointerPress
21- InputPointerRelease
22- InputPointerAxis
23- InputPointerLeave
24- InputKey
25- InputFocusIn
26- InputFocusOut
27- InputReset // platform dropped queued input; treat held state as released
28-)
29-
30-// Modifiers is a bit set of keyboard modifiers held during a key event.
31-type Modifiers uint8
32-
33-const (
34- ModShift Modifiers = 1 << iota
35- ModCtrl
36-)
37-
38 // InputEvent is a raw platform event, delivered on the owner loop before the15 // InputEvent is a raw platform event, delivered on the owner loop before the
39 // normal control routing. Coordinates are logical, surface-local pixels.16 // normal control routing. Coordinates are logical, surface-local pixels.
40 // Size changes are not input; use OnResize and Frame.Size. Which fields are set17 // Size changes are not input; use OnResize and Frame.Size. Which fields are set
M internal/ui/ime_return_test.go +1 -2
@@ -4,11 +4,10 @@ import (
44 "testing"
55
66 "github.com/bnema/nefergui/internal/edit"
7- "github.com/bnema/nefergui/internal/keyboard"
87 )
98
109 func typed(name, text string) platformInput {
11- return platformInput{Kind: "key", Key: keyboard.Key{Name: name, Text: text, Pressed: true}}
10+ return platformInput{Kind: "key", Key: keyEvent{Name: name, Text: text, Pressed: true}}
1211 }
1312
1413 // With text-input v3 the compositor routes keys to a grabbing input method;
@@ -4,11 +4,10 @@ import (
4 "testing"4 "testing"
5 5
6 "github.com/bnema/nefergui/internal/edit"6 "github.com/bnema/nefergui/internal/edit"
7- "github.com/bnema/nefergui/internal/keyboard"
8 )7 )
9 8
10 func typed(name, text string) platformInput {9 func typed(name, text string) platformInput {
11- return platformInput{Kind: "key", Key: keyboard.Key{Name: name, Text: text, Pressed: true}}10+ return platformInput{Kind: "key", Key: keyEvent{Name: name, Text: text, Pressed: true}}
12 }11 }
13 12
14 // With text-input v3 the compositor routes keys to a grabbing input method;13 // With text-input v3 the compositor routes keys to a grabbing input method;
A internal/ui/input.go +26 -0
@@ -0,0 +1,26 @@
1+package ui
2+
3+// InputKind classifies an InputEvent.
4+type InputKind uint8
5+
6+const (
7+ InputPointerMotion InputKind = iota + 1
8+ InputPointerPress
9+ InputPointerRelease
10+ InputPointerAxis
11+ InputPointerLeave
12+ InputKey
13+ InputFocusIn
14+ InputFocusOut
15+ InputReset // platform dropped queued input; treat held state as released
16+)
17+
18+// Modifiers is a bit set of keyboard modifiers held during a key event.
19+type Modifiers uint8
20+
21+const (
22+ ModShift Modifiers = 1 << iota
23+ ModCtrl
24+ ModAlt
25+ ModSuper
26+)
@@ -0,0 +1,26 @@
1+package ui
2+
3+// InputKind classifies an InputEvent.
4+type InputKind uint8
5+
6+const (
7+ InputPointerMotion InputKind = iota + 1
8+ InputPointerPress
9+ InputPointerRelease
10+ InputPointerAxis
11+ InputPointerLeave
12+ InputKey
13+ InputFocusIn
14+ InputFocusOut
15+ InputReset // platform dropped queued input; treat held state as released
16+)
17+
18+// Modifiers is a bit set of keyboard modifiers held during a key event.
19+type Modifiers uint8
20+
21+const (
22+ ModShift Modifiers = 1 << iota
23+ ModCtrl
24+ ModAlt
25+ ModSuper
26+)
M internal/ui/interaction.go +8 -2
@@ -8,7 +8,6 @@ import (
88
99 "github.com/bnema/nefergui/internal/css"
1010 "github.com/bnema/nefergui/internal/edit"
11- "github.com/bnema/nefergui/internal/keyboard"
1211 "github.com/bnema/nefergui/internal/layout"
1312 "github.com/bnema/nefergui/internal/text"
1413 )
@@ -19,13 +18,20 @@ type platformInput struct {
1918 Kind string // motion, press, release, axis, leave, key, focus-in, focus-out, resize
2019 X, Y, DX, DY float64
2120 Width, Height, Scale float64
22- Key keyboard.Key
21+ Key keyEvent
2322 Button uint32 // evdev button code (BTN_LEFT is primary)
2423 Shift, Ctrl bool
2524 IME edit.IMEBatch
2625 Time time.Time // optional event timestamp; zero uses receipt time
2726 }
2827
28+// keyEvent is the key part of a platformInput.
29+type keyEvent struct {
30+ Name, Text string
31+ Pressed, Repeat bool
32+ Shift, Ctrl bool
33+}
34+
2935 const pointerPrimary uint32 = 0x110 // Linux BTN_LEFT
3036
3137 type interactionState struct {
@@ -8,7 +8,6 @@ import (
8 8
9 "github.com/bnema/nefergui/internal/css"9 "github.com/bnema/nefergui/internal/css"
10 "github.com/bnema/nefergui/internal/edit"10 "github.com/bnema/nefergui/internal/edit"
11- "github.com/bnema/nefergui/internal/keyboard"
12 "github.com/bnema/nefergui/internal/layout"11 "github.com/bnema/nefergui/internal/layout"
13 "github.com/bnema/nefergui/internal/text"12 "github.com/bnema/nefergui/internal/text"
14 )13 )
@@ -19,13 +18,20 @@ type platformInput struct {
19 Kind string // motion, press, release, axis, leave, key, focus-in, focus-out, resize18 Kind string // motion, press, release, axis, leave, key, focus-in, focus-out, resize
20 X, Y, DX, DY float6419 X, Y, DX, DY float64
21 Width, Height, Scale float6420 Width, Height, Scale float64
22- Key keyboard.Key21+ Key keyEvent
23 Button uint32 // evdev button code (BTN_LEFT is primary)22 Button uint32 // evdev button code (BTN_LEFT is primary)
24 Shift, Ctrl bool23 Shift, Ctrl bool
25 IME edit.IMEBatch24 IME edit.IMEBatch
26 Time time.Time // optional event timestamp; zero uses receipt time25 Time time.Time // optional event timestamp; zero uses receipt time
27 }26 }
28 27
28+// keyEvent is the key part of a platformInput.
29+type keyEvent struct {
30+ Name, Text string
31+ Pressed, Repeat bool
32+ Shift, Ctrl bool
33+}
34+
29 const pointerPrimary uint32 = 0x110 // Linux BTN_LEFT35 const pointerPrimary uint32 = 0x110 // Linux BTN_LEFT
30 36
31 type interactionState struct {37 type interactionState struct {
M internal/ui/interaction_test.go +6 -7
@@ -6,13 +6,12 @@ import (
66 "testing"
77
88 "github.com/bnema/nefergui/internal/css"
9- "github.com/bnema/nefergui/internal/keyboard"
109 "github.com/bnema/nefergui/internal/platform/wayland"
1110 "github.com/bnema/nefergui/internal/text"
1211 )
1312
1413 func key(name string) platformInput {
15- return platformInput{Kind: "key", Key: keyboard.Key{Name: name, Pressed: true}}
14+ return platformInput{Kind: "key", Key: keyEvent{Name: name, Pressed: true}}
1615 }
1716 func shell(r *runtime, activated *int) func(*Frame) {
1817 return func(f *Frame) {
@@ -67,7 +66,7 @@ func TestShellStyleAndFocus(t *testing.T) {
6766 if !r.state.focus.same(r.Target(3)) {
6867 t.Fatal("disabled included in tab order")
6968 }
70- r.route(platformInput{Kind: "key", Shift: true, Key: keyboard.Key{Name: "Tab", Pressed: true}})
69+ r.route(platformInput{Kind: "key", Shift: true, Key: keyEvent{Name: "Tab", Pressed: true}})
7170 r.Build(view)
7271 if !r.state.focus.same(r.Target(1)) {
7372 t.Fatal("reverse tab")
@@ -361,7 +360,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) {
361360 if count != 1 || r.state.space == nil || r.state.flags(r.committed.children[0])&css.Active == 0 {
362361 t.Fatal("space press should only set active")
363362 }
364- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})
363+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
365364 r.Build(view)
366365 if count != 2 || r.state.space != nil {
367366 t.Fatal("space release should activate once", count)
@@ -369,7 +368,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) {
369368 r.route(space)
370369 r.route(key("Escape"))
371370 r.Build(view)
372- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})
371+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
373372 if r.Build(view) {
374373 t.Fatal("cancelled release redrew")
375374 }
@@ -379,7 +378,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) {
379378 r.route(space)
380379 r.route(platformInput{Kind: "focus-out"})
381380 r.Build(view)
382- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})
381+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
383382 if r.Build(view) || count != 2 {
384383 t.Fatal("focus loss failed to cancel")
385384 }
@@ -388,7 +387,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) {
388387 r.route(space)
389388 r.route(key("Tab"))
390389 r.Build(view)
391- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})
390+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
392391 if r.Build(view) || count != 2 {
393392 t.Fatal("focus change failed to cancel")
394393 }
@@ -6,13 +6,12 @@ import (
6 "testing"6 "testing"
7 7
8 "github.com/bnema/nefergui/internal/css"8 "github.com/bnema/nefergui/internal/css"
9- "github.com/bnema/nefergui/internal/keyboard"
10 "github.com/bnema/nefergui/internal/platform/wayland"9 "github.com/bnema/nefergui/internal/platform/wayland"
11 "github.com/bnema/nefergui/internal/text"10 "github.com/bnema/nefergui/internal/text"
12 )11 )
13 12
14 func key(name string) platformInput {13 func key(name string) platformInput {
15- return platformInput{Kind: "key", Key: keyboard.Key{Name: name, Pressed: true}}14+ return platformInput{Kind: "key", Key: keyEvent{Name: name, Pressed: true}}
16 }15 }
17 func shell(r *runtime, activated *int) func(*Frame) {16 func shell(r *runtime, activated *int) func(*Frame) {
18 return func(f *Frame) {17 return func(f *Frame) {
@@ -67,7 +66,7 @@ func TestShellStyleAndFocus(t *testing.T) {
67 if !r.state.focus.same(r.Target(3)) {66 if !r.state.focus.same(r.Target(3)) {
68 t.Fatal("disabled included in tab order")67 t.Fatal("disabled included in tab order")
69 }68 }
70- r.route(platformInput{Kind: "key", Shift: true, Key: keyboard.Key{Name: "Tab", Pressed: true}})69+ r.route(platformInput{Kind: "key", Shift: true, Key: keyEvent{Name: "Tab", Pressed: true}})
71 r.Build(view)70 r.Build(view)
72 if !r.state.focus.same(r.Target(1)) {71 if !r.state.focus.same(r.Target(1)) {
73 t.Fatal("reverse tab")72 t.Fatal("reverse tab")
@@ -361,7 +360,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) {
361 if count != 1 || r.state.space == nil || r.state.flags(r.committed.children[0])&css.Active == 0 {360 if count != 1 || r.state.space == nil || r.state.flags(r.committed.children[0])&css.Active == 0 {
362 t.Fatal("space press should only set active")361 t.Fatal("space press should only set active")
363 }362 }
364- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})363+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
365 r.Build(view)364 r.Build(view)
366 if count != 2 || r.state.space != nil {365 if count != 2 || r.state.space != nil {
367 t.Fatal("space release should activate once", count)366 t.Fatal("space release should activate once", count)
@@ -369,7 +368,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) {
369 r.route(space)368 r.route(space)
370 r.route(key("Escape"))369 r.route(key("Escape"))
371 r.Build(view)370 r.Build(view)
372- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})371+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
373 if r.Build(view) {372 if r.Build(view) {
374 t.Fatal("cancelled release redrew")373 t.Fatal("cancelled release redrew")
375 }374 }
@@ -379,7 +378,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) {
379 r.route(space)378 r.route(space)
380 r.route(platformInput{Kind: "focus-out"})379 r.route(platformInput{Kind: "focus-out"})
381 r.Build(view)380 r.Build(view)
382- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})381+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
383 if r.Build(view) || count != 2 {382 if r.Build(view) || count != 2 {
384 t.Fatal("focus loss failed to cancel")383 t.Fatal("focus loss failed to cancel")
385 }384 }
@@ -388,7 +387,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) {
388 r.route(space)387 r.route(space)
389 r.route(key("Tab"))388 r.route(key("Tab"))
390 r.Build(view)389 r.Build(view)
391- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})390+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
392 if r.Build(view) || count != 2 {391 if r.Build(view) || count != 2 {
393 t.Fatal("focus change failed to cancel")392 t.Fatal("focus change failed to cancel")
394 }393 }
M internal/ui/platform.go +1 -1
@@ -28,5 +28,5 @@ func (r *runtime) routeInput(in wayland.Input) {
2828 if in.Kind == "axis" && in.DX == 0 && in.DY == 0 {
2929 return
3030 }
31- r.route(platformInput{Kind: in.Kind, X: in.X, Y: in.Y, DX: in.DX, DY: in.DY, Button: in.Button, Key: in.Key, Shift: in.Key.Shift, Ctrl: in.Key.Ctrl})
31+ r.route(platformInput{Kind: in.Kind, X: in.X, Y: in.Y, DX: in.DX, DY: in.DY, Button: in.Button, Key: keyEvent{Name: in.Key.Name, Text: in.Key.Text, Pressed: in.Key.Pressed, Repeat: in.Key.Repeat, Shift: in.Key.Shift, Ctrl: in.Key.Ctrl}, Shift: in.Key.Shift, Ctrl: in.Key.Ctrl})
3232 }
@@ -28,5 +28,5 @@ func (r *runtime) routeInput(in wayland.Input) {
28 if in.Kind == "axis" && in.DX == 0 && in.DY == 0 {28 if in.Kind == "axis" && in.DX == 0 && in.DY == 0 {
29 return29 return
30 }30 }
31- r.route(platformInput{Kind: in.Kind, X: in.X, Y: in.Y, DX: in.DX, DY: in.DY, Button: in.Button, Key: in.Key, Shift: in.Key.Shift, Ctrl: in.Key.Ctrl})31+ r.route(platformInput{Kind: in.Kind, X: in.X, Y: in.Y, DX: in.DX, DY: in.DY, Button: in.Button, Key: keyEvent{Name: in.Key.Name, Text: in.Key.Text, Pressed: in.Key.Pressed, Repeat: in.Key.Repeat, Shift: in.Key.Shift, Ctrl: in.Key.Ctrl}, Shift: in.Key.Shift, Ctrl: in.Key.Ctrl})
32 }32 }
A internal/ui/race_off_test.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package ui
4+
5+const raceEnabled = false
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package ui
4+
5+const raceEnabled = false
A internal/ui/race_on_test.go +6 -0
@@ -0,0 +1,6 @@
1+//go:build race
2+
3+package ui
4+
5+// raceEnabled: the race detector adds allocations, so alloc guards skip.
6+const raceEnabled = true
@@ -0,0 +1,6 @@
1+//go:build race
2+
3+package ui
4+
5+// raceEnabled: the race detector adds allocations, so alloc guards skip.
6+const raceEnabled = true
A internal/ui/renderer.go +424 -0
@@ -0,0 +1,424 @@
1+//go:build linux
2+
3+package ui
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "math"
10+ "os"
11+ "slices"
12+
13+ "github.com/bnema/nefergui/internal/css"
14+ "github.com/bnema/nefergui/internal/edit"
15+ "github.com/bnema/nefergui/internal/layout"
16+ "github.com/bnema/nefergui/internal/presentation/session"
17+ "github.com/bnema/nefergui/internal/presentation/vkdevice"
18+ "github.com/bnema/nefergui/internal/text"
19+)
20+
21+// Format is a DRM format and modifier the compositor accepts.
22+type Format struct {
23+ FourCC uint32
24+ Modifier uint64
25+}
26+
27+// Clipboard is the optional asynchronous clipboard; IME the optional input method.
28+type (
29+ Clipboard = edit.AsyncClipboard
30+ IME = edit.IME
31+)
32+
33+// Plane, Timeline and Retired are the transport-facing parts of Output.
34+type (
35+ Plane = session.Plane
36+ Timeline = session.Timeline
37+ Retired = session.Retired
38+)
39+
40+// Cursor is the pointer shape the surface wants.
41+type Cursor uint8
42+
43+const (
44+ CursorDefault Cursor = iota
45+ CursorPointer
46+ CursorText
47+ CursorNotAllowed
48+)
49+
50+// RendererConfig configures a Wayland-free Renderer.
51+type RendererConfig struct {
52+ MainDevice uint64 // dev_t from linux-dmabuf feedback main_device
53+ Formats []Format // compositor preference order, tranches flattened
54+ Transparent bool // ARGB8888 instead of XRGB8888
55+ Styles string // optional CSS file path
56+ Clipboard Clipboard
57+ IME IME
58+}
59+
60+// Input is one platform input event. Text is read only during the call.
61+type Input struct {
62+ Kind InputKind
63+ X, Y, DX, DY float64 // logical pixels; DX, DY are scroll deltas
64+ Button uint32 // evdev code
65+ Pressed bool
66+ Repeat bool
67+ Keysym uint32 // xkb keysym
68+ Text []byte
69+ Modifiers Modifiers
70+}
71+
72+// Output describes one frame to present, in physical pixels. Every file
73+// descriptor stays owned by the Renderer. Slices are reused and valid until the
74+// next Render.
75+type Output struct {
76+ Buffer uint64
77+ NewBuffer bool
78+ Retired []Retired
79+ Width, Height int32
80+ FourCC uint32
81+ Modifier uint64
82+ Planes [4]Plane
83+ PlaneCount int
84+ Acquire, Release Timeline
85+ NewTimelines bool
86+ AcquirePoint uint64
87+ ReleasePoint uint64
88+ ReleaseFD int
89+ Damage []Rect
90+ Cursor Cursor
91+ InputRects []Rect
92+ InputRectsChanged bool
93+}
94+
95+// target is the GPU seam of Renderer. *session.Target implements it.
96+type target interface {
97+ Resize(width, height int32)
98+ Draw(display []layout.Command, scale float64, out *session.Output) (bool, error)
99+ Released(buffer uint64) error
100+ Waiting() bool
101+ Close() error
102+}
103+
104+// Renderer renders views into DMA-BUF buffers for a transport it does not
105+// know. It owns no Wayland object and starts no goroutine. All methods except
106+// Wake run on one owner goroutine.
107+type Renderer struct {
108+ rt *runtime
109+ target target
110+ tout session.Output
111+ sized bool
112+ unsent bool // a built frame still waits for a free buffer
113+ w, h float64
114+ scale float64
115+ pw, ph int32
116+ closed bool
117+
118+ // Last input region handed out, to report only changes.
119+ region []Rect
120+ regionNil bool
121+ haveRegion bool
122+}
123+
124+// asyncClipboard lets an AsyncClipboard satisfy the editor's Clipboard port;
125+// synchronous reads are not supported, paste uses ReadTextAsync.
126+type asyncClipboard struct{ edit.AsyncClipboard }
127+
128+func (asyncClipboard) ReadText(int) ([]byte, error) {
129+ return nil, errors.New("nefergui: synchronous clipboard read not supported")
130+}
131+
132+// NewRenderer opens the GPU for cfg.MainDevice and loads fonts and styles. It
133+// allocates no images.
134+func NewRenderer(cfg RendererConfig) (*Renderer, error) {
135+ formats := make([]vkdevice.Format, len(cfg.Formats))
136+ for i, f := range cfg.Formats {
137+ formats[i] = vkdevice.Format{FourCC: f.FourCC, Modifier: f.Modifier}
138+ }
139+ t, err := session.NewTarget(session.TargetConfig{MainDevice: cfg.MainDevice, Formats: formats, Transparent: cfg.Transparent})
140+ if err != nil {
141+ return nil, fmt.Errorf("nefergui: open renderer: %w", err)
142+ }
143+ r, err := newRenderer(cfg, t, text.SystemSource{})
144+ if err != nil {
145+ _ = t.Close()
146+ return nil, err
147+ }
148+ return r, nil
149+}
150+
151+func newRenderer(cfg RendererConfig, t target, fonts text.FontSource) (*Renderer, error) {
152+ rt := newRuntime()
153+ if cfg.Styles != "" {
154+ data, err := os.ReadFile(cfg.Styles)
155+ if err != nil {
156+ return nil, fmt.Errorf("nefergui: styles: %w", err)
157+ }
158+ rt.styles = css.Compile(css.UA(), css.Parse(string(data)))
159+ }
160+ catalog, err := text.Load(fonts)
161+ if err != nil {
162+ return nil, fmt.Errorf("nefergui: fonts: %w", err)
163+ }
164+ if len(catalog.Faces) == 0 {
165+ return nil, errors.New("nefergui: no usable fonts")
166+ }
167+ rt.setTextEngine(text.NewEngine(catalog))
168+ ctx, cancel := context.WithCancel(context.Background())
169+ rt.pasteCtx, rt.pasteCancel = ctx, cancel
170+ if cfg.Clipboard != nil {
171+ rt.clipboard = asyncClipboard{cfg.Clipboard}
172+ }
173+ if cfg.IME != nil {
174+ rt.ime = cfg.IME
175+ }
176+ return &Renderer{rt: rt, target: t, scale: 1}, nil
177+}
178+
179+// Wake returns a channel signaled when work arrives from another goroutine
180+// (for example a finished paste); call Render after receiving. Safe from any goroutine.
181+func (r *Renderer) Wake() <-chan struct{} { return r.rt.wake }
182+
183+// Pending reports that a built frame is waiting for the GPU (a buffer still
184+// being drawn, or a release wait not yet installed) rather than for the
185+// compositor. No event announces this: arm a short timer (for example 2 ms)
186+// and call Render again. It is false while every buffer belongs to the
187+// compositor; Released is the wake-up for that case.
188+func (r *Renderer) Pending() bool { return r.unsent && r.target.Waiting() }
189+
190+// Resize sets the logical surface size and scale. Invalid values are ignored.
191+func (r *Renderer) Resize(width, height int, scale float64) {
192+ if r.closed {
193+ return
194+ }
195+ w, h := float64(width), float64(height)
196+ if width <= 0 || height <= 0 || !(scale > 0) || math.IsInf(scale, 0) {
197+ return
198+ }
199+ pw, ph := math.Ceil(w*scale-1e-9), math.Ceil(h*scale-1e-9)
200+ if pw > math.MaxInt32 || ph > math.MaxInt32 {
201+ return
202+ }
203+ if r.sized && r.w == w && r.h == h && r.scale == scale {
204+ return
205+ }
206+ r.sized, r.w, r.h, r.scale = true, w, h, scale
207+ r.pw, r.ph = int32(pw), int32(ph)
208+ r.target.Resize(r.pw, r.ph)
209+ r.rt.route(platformInput{Kind: "resize", Width: w, Height: h, Scale: scale})
210+}
211+
212+// Invalidate requests a rebuild at the next Render.
213+func (r *Renderer) Invalidate() { r.rt.Redraw() }
214+
215+// Input routes one event and reports whether a redraw is needed.
216+func (r *Renderer) Input(ev *Input) bool {
217+ if ev == nil || r.closed {
218+ return false
219+ }
220+ switch ev.Kind {
221+ case InputReset:
222+ r.rt.route(platformInput{Kind: "leave"})
223+ r.rt.route(platformInput{Kind: "focus-out"})
224+ case InputPointerMotion:
225+ r.rt.route(platformInput{Kind: "motion", X: ev.X, Y: ev.Y})
226+ case InputPointerPress:
227+ r.rt.route(platformInput{Kind: "press", X: ev.X, Y: ev.Y, Button: ev.Button, Shift: ev.Modifiers&ModShift != 0})
228+ case InputPointerRelease:
229+ r.rt.route(platformInput{Kind: "release", X: ev.X, Y: ev.Y, Button: ev.Button})
230+ case InputPointerAxis:
231+ if ev.DX == 0 && ev.DY == 0 {
232+ return false
233+ }
234+ r.rt.route(platformInput{Kind: "axis", X: ev.X, Y: ev.Y, DX: ev.DX, DY: ev.DY})
235+ case InputPointerLeave:
236+ r.rt.route(platformInput{Kind: "leave"})
237+ case InputFocusIn:
238+ r.rt.route(platformInput{Kind: "focus-in"})
239+ case InputFocusOut:
240+ r.rt.route(platformInput{Kind: "focus-out"})
241+ case InputKey:
242+ shift, ctrl := ev.Modifiers&ModShift != 0, ev.Modifiers&ModCtrl != 0
243+ key := keyEvent{Name: keysymName(ev.Keysym), Pressed: ev.Pressed, Repeat: ev.Repeat, Shift: shift, Ctrl: ctrl}
244+ if len(ev.Text) > 0 {
245+ key.Text = string(ev.Text)
246+ }
247+ r.rt.route(platformInput{Kind: "key", Key: key, Shift: shift, Ctrl: ctrl})
248+ default:
249+ return false
250+ }
251+ r.rt.mu.Lock()
252+ need := r.rt.redraw || len(r.rt.pending) > 0
253+ r.rt.mu.Unlock()
254+ return need
255+}
256+
257+// Render builds the view and, when something changed and a buffer is free,
258+// draws it. It returns false without touching the GPU when nothing changed, no
259+// buffer is available yet (call it again after Released) or before the first
260+// Resize. When it returns true, out describes the frame to present.
261+func (r *Renderer) Render[T any](out *Output, model *T, view func(*Frame, *T)) (bool, error) {
262+ if r.closed {
263+ return false, errClosed
264+ }
265+ if out == nil || model == nil || view == nil {
266+ return false, errors.New("nefergui: nil output, model or view")
267+ }
268+ if !r.sized {
269+ return false, nil
270+ }
271+ if r.rt.Build(func(f *Frame) { view(f, model) }) {
272+ r.unsent = true
273+ }
274+ if !r.unsent {
275+ return false, nil
276+ }
277+ ok, err := r.target.Draw(r.rt.output.Display, r.scale, &r.tout)
278+ if err != nil || !ok {
279+ return false, err
280+ }
281+ r.unsent = false
282+ r.export(out)
283+ return true, nil
284+}
285+
286+// export copies the target's frame description into out and adds the parts
287+// derived from the UI: damage, cursor and the staged input region.
288+func (r *Renderer) export(out *Output) {
289+ t := &r.tout
290+ retired := append(out.Retired[:0], t.Retired...)
291+ damage := append(out.Damage[:0], Rect{Width: t.Width, Height: t.Height})
292+ rects := out.InputRects
293+ *out = Output{
294+ Buffer: t.Buffer, NewBuffer: t.NewBuffer, Retired: retired,
295+ Width: t.Width, Height: t.Height, FourCC: t.FourCC, Modifier: t.Modifier,
296+ Planes: t.Planes, PlaneCount: t.PlaneCount, Acquire: t.Acquire, Release: t.Release,
297+ NewTimelines: t.NewTimelines, AcquirePoint: t.AcquirePoint, ReleasePoint: t.ReleasePoint, ReleaseFD: t.ReleaseFD,
298+ Damage: damage, Cursor: cursorOf(r.rt.cursor()), InputRects: rects[:0],
299+ }
300+ rt := r.rt
301+ if !rt.inputStaged {
302+ return
303+ }
304+ rt.inputStaged = false
305+ // A region equal to the last reported one costs nothing.
306+ if r.haveRegion && r.regionNil == rt.inputNil && slices.Equal(r.region, rt.inputRects) {
307+ return
308+ }
309+ r.haveRegion, r.regionNil = true, rt.inputNil
310+ r.region = append(r.region[:0], rt.inputRects...)
311+ out.InputRectsChanged = true
312+ if rt.inputNil {
313+ out.InputRects = nil
314+ return
315+ }
316+ if out.InputRects == nil {
317+ out.InputRects = []Rect{}
318+ }
319+ out.InputRects = append(out.InputRects, rt.inputRects...)
320+}
321+
322+func cursorOf(k css.Keyword) Cursor {
323+ switch k {
324+ case css.KeywordPointer:
325+ return CursorPointer
326+ case css.KeywordText:
327+ return CursorText
328+ case css.KeywordNotAllowed:
329+ return CursorNotAllowed
330+ }
331+ return CursorDefault
332+}
333+
334+// Released must be called when the release eventfd of an Output buffer is
335+// readable. It lets the buffer be reused.
336+func (r *Renderer) Released(buffer uint64) error {
337+ if r.closed {
338+ return errClosed
339+ }
340+ return r.target.Released(buffer)
341+}
342+
343+var errClosed = errors.New("nefergui: renderer closed")
344+
345+// Close cancels pending pastes and frees the GPU resources. The Renderer is
346+// unusable afterwards.
347+func (r *Renderer) Close() error {
348+ if r.closed {
349+ return nil
350+ }
351+ r.closed = true
352+ if r.rt.pasteCancel != nil {
353+ r.rt.pasteCancel()
354+ }
355+ return r.target.Close()
356+}
357+
358+// xkb keysym values; internal/ui never imports an xkb library.
359+const (
360+ keysymSpace = 0x0020
361+ keysymBackspace = 0xff08
362+ keysymTab = 0xff09
363+ keysymReturn = 0xff0d
364+ keysymEscape = 0xff1b
365+ keysymHome = 0xff50
366+ keysymLeft = 0xff51
367+ keysymUp = 0xff52
368+ keysymRight = 0xff53
369+ keysymDown = 0xff54
370+ keysymPageUp = 0xff55
371+ keysymPageDown = 0xff56
372+ keysymEnd = 0xff57
373+ keysymKPEnter = 0xff8d
374+ keysymISOLeft = 0xfe20
375+ keysymDelete = 0xffff
376+)
377+
378+// keysymName maps a keysym to the name interaction.go dispatches on; other
379+// keysyms have no name because only their text matters.
380+func keysymName(sym uint32) string {
381+ switch sym {
382+ case keysymSpace:
383+ return "space"
384+ case keysymTab:
385+ return "tab"
386+ case keysymISOLeft:
387+ return "iso_left_tab"
388+ case keysymLeft:
389+ return "left"
390+ case keysymRight:
391+ return "right"
392+ case keysymUp:
393+ return "up"
394+ case keysymDown:
395+ return "down"
396+ case keysymHome:
397+ return "home"
398+ case keysymEnd:
399+ return "end"
400+ case keysymPageUp:
401+ return "page_up"
402+ case keysymPageDown:
403+ return "page_down"
404+ case keysymBackspace:
405+ return "backspace"
406+ case keysymDelete:
407+ return "delete"
408+ case 'a', 'A':
409+ return "a"
410+ case 'c', 'C':
411+ return "c"
412+ case 'x', 'X':
413+ return "x"
414+ case 'v', 'V':
415+ return "v"
416+ case keysymReturn:
417+ return "return"
418+ case keysymKPEnter:
419+ return "kp_enter"
420+ case keysymEscape:
421+ return "escape"
422+ }
423+ return ""
424+}
@@ -0,0 +1,424 @@
1+//go:build linux
2+
3+package ui
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "math"
10+ "os"
11+ "slices"
12+
13+ "github.com/bnema/nefergui/internal/css"
14+ "github.com/bnema/nefergui/internal/edit"
15+ "github.com/bnema/nefergui/internal/layout"
16+ "github.com/bnema/nefergui/internal/presentation/session"
17+ "github.com/bnema/nefergui/internal/presentation/vkdevice"
18+ "github.com/bnema/nefergui/internal/text"
19+)
20+
21+// Format is a DRM format and modifier the compositor accepts.
22+type Format struct {
23+ FourCC uint32
24+ Modifier uint64
25+}
26+
27+// Clipboard is the optional asynchronous clipboard; IME the optional input method.
28+type (
29+ Clipboard = edit.AsyncClipboard
30+ IME = edit.IME
31+)
32+
33+// Plane, Timeline and Retired are the transport-facing parts of Output.
34+type (
35+ Plane = session.Plane
36+ Timeline = session.Timeline
37+ Retired = session.Retired
38+)
39+
40+// Cursor is the pointer shape the surface wants.
41+type Cursor uint8
42+
43+const (
44+ CursorDefault Cursor = iota
45+ CursorPointer
46+ CursorText
47+ CursorNotAllowed
48+)
49+
50+// RendererConfig configures a Wayland-free Renderer.
51+type RendererConfig struct {
52+ MainDevice uint64 // dev_t from linux-dmabuf feedback main_device
53+ Formats []Format // compositor preference order, tranches flattened
54+ Transparent bool // ARGB8888 instead of XRGB8888
55+ Styles string // optional CSS file path
56+ Clipboard Clipboard
57+ IME IME
58+}
59+
60+// Input is one platform input event. Text is read only during the call.
61+type Input struct {
62+ Kind InputKind
63+ X, Y, DX, DY float64 // logical pixels; DX, DY are scroll deltas
64+ Button uint32 // evdev code
65+ Pressed bool
66+ Repeat bool
67+ Keysym uint32 // xkb keysym
68+ Text []byte
69+ Modifiers Modifiers
70+}
71+
72+// Output describes one frame to present, in physical pixels. Every file
73+// descriptor stays owned by the Renderer. Slices are reused and valid until the
74+// next Render.
75+type Output struct {
76+ Buffer uint64
77+ NewBuffer bool
78+ Retired []Retired
79+ Width, Height int32
80+ FourCC uint32
81+ Modifier uint64
82+ Planes [4]Plane
83+ PlaneCount int
84+ Acquire, Release Timeline
85+ NewTimelines bool
86+ AcquirePoint uint64
87+ ReleasePoint uint64
88+ ReleaseFD int
89+ Damage []Rect
90+ Cursor Cursor
91+ InputRects []Rect
92+ InputRectsChanged bool
93+}
94+
95+// target is the GPU seam of Renderer. *session.Target implements it.
96+type target interface {
97+ Resize(width, height int32)
98+ Draw(display []layout.Command, scale float64, out *session.Output) (bool, error)
99+ Released(buffer uint64) error
100+ Waiting() bool
101+ Close() error
102+}
103+
104+// Renderer renders views into DMA-BUF buffers for a transport it does not
105+// know. It owns no Wayland object and starts no goroutine. All methods except
106+// Wake run on one owner goroutine.
107+type Renderer struct {
108+ rt *runtime
109+ target target
110+ tout session.Output
111+ sized bool
112+ unsent bool // a built frame still waits for a free buffer
113+ w, h float64
114+ scale float64
115+ pw, ph int32
116+ closed bool
117+
118+ // Last input region handed out, to report only changes.
119+ region []Rect
120+ regionNil bool
121+ haveRegion bool
122+}
123+
124+// asyncClipboard lets an AsyncClipboard satisfy the editor's Clipboard port;
125+// synchronous reads are not supported, paste uses ReadTextAsync.
126+type asyncClipboard struct{ edit.AsyncClipboard }
127+
128+func (asyncClipboard) ReadText(int) ([]byte, error) {
129+ return nil, errors.New("nefergui: synchronous clipboard read not supported")
130+}
131+
132+// NewRenderer opens the GPU for cfg.MainDevice and loads fonts and styles. It
133+// allocates no images.
134+func NewRenderer(cfg RendererConfig) (*Renderer, error) {
135+ formats := make([]vkdevice.Format, len(cfg.Formats))
136+ for i, f := range cfg.Formats {
137+ formats[i] = vkdevice.Format{FourCC: f.FourCC, Modifier: f.Modifier}
138+ }
139+ t, err := session.NewTarget(session.TargetConfig{MainDevice: cfg.MainDevice, Formats: formats, Transparent: cfg.Transparent})
140+ if err != nil {
141+ return nil, fmt.Errorf("nefergui: open renderer: %w", err)
142+ }
143+ r, err := newRenderer(cfg, t, text.SystemSource{})
144+ if err != nil {
145+ _ = t.Close()
146+ return nil, err
147+ }
148+ return r, nil
149+}
150+
151+func newRenderer(cfg RendererConfig, t target, fonts text.FontSource) (*Renderer, error) {
152+ rt := newRuntime()
153+ if cfg.Styles != "" {
154+ data, err := os.ReadFile(cfg.Styles)
155+ if err != nil {
156+ return nil, fmt.Errorf("nefergui: styles: %w", err)
157+ }
158+ rt.styles = css.Compile(css.UA(), css.Parse(string(data)))
159+ }
160+ catalog, err := text.Load(fonts)
161+ if err != nil {
162+ return nil, fmt.Errorf("nefergui: fonts: %w", err)
163+ }
164+ if len(catalog.Faces) == 0 {
165+ return nil, errors.New("nefergui: no usable fonts")
166+ }
167+ rt.setTextEngine(text.NewEngine(catalog))
168+ ctx, cancel := context.WithCancel(context.Background())
169+ rt.pasteCtx, rt.pasteCancel = ctx, cancel
170+ if cfg.Clipboard != nil {
171+ rt.clipboard = asyncClipboard{cfg.Clipboard}
172+ }
173+ if cfg.IME != nil {
174+ rt.ime = cfg.IME
175+ }
176+ return &Renderer{rt: rt, target: t, scale: 1}, nil
177+}
178+
179+// Wake returns a channel signaled when work arrives from another goroutine
180+// (for example a finished paste); call Render after receiving. Safe from any goroutine.
181+func (r *Renderer) Wake() <-chan struct{} { return r.rt.wake }
182+
183+// Pending reports that a built frame is waiting for the GPU (a buffer still
184+// being drawn, or a release wait not yet installed) rather than for the
185+// compositor. No event announces this: arm a short timer (for example 2 ms)
186+// and call Render again. It is false while every buffer belongs to the
187+// compositor; Released is the wake-up for that case.
188+func (r *Renderer) Pending() bool { return r.unsent && r.target.Waiting() }
189+
190+// Resize sets the logical surface size and scale. Invalid values are ignored.
191+func (r *Renderer) Resize(width, height int, scale float64) {
192+ if r.closed {
193+ return
194+ }
195+ w, h := float64(width), float64(height)
196+ if width <= 0 || height <= 0 || !(scale > 0) || math.IsInf(scale, 0) {
197+ return
198+ }
199+ pw, ph := math.Ceil(w*scale-1e-9), math.Ceil(h*scale-1e-9)
200+ if pw > math.MaxInt32 || ph > math.MaxInt32 {
201+ return
202+ }
203+ if r.sized && r.w == w && r.h == h && r.scale == scale {
204+ return
205+ }
206+ r.sized, r.w, r.h, r.scale = true, w, h, scale
207+ r.pw, r.ph = int32(pw), int32(ph)
208+ r.target.Resize(r.pw, r.ph)
209+ r.rt.route(platformInput{Kind: "resize", Width: w, Height: h, Scale: scale})
210+}
211+
212+// Invalidate requests a rebuild at the next Render.
213+func (r *Renderer) Invalidate() { r.rt.Redraw() }
214+
215+// Input routes one event and reports whether a redraw is needed.
216+func (r *Renderer) Input(ev *Input) bool {
217+ if ev == nil || r.closed {
218+ return false
219+ }
220+ switch ev.Kind {
221+ case InputReset:
222+ r.rt.route(platformInput{Kind: "leave"})
223+ r.rt.route(platformInput{Kind: "focus-out"})
224+ case InputPointerMotion:
225+ r.rt.route(platformInput{Kind: "motion", X: ev.X, Y: ev.Y})
226+ case InputPointerPress:
227+ r.rt.route(platformInput{Kind: "press", X: ev.X, Y: ev.Y, Button: ev.Button, Shift: ev.Modifiers&ModShift != 0})
228+ case InputPointerRelease:
229+ r.rt.route(platformInput{Kind: "release", X: ev.X, Y: ev.Y, Button: ev.Button})
230+ case InputPointerAxis:
231+ if ev.DX == 0 && ev.DY == 0 {
232+ return false
233+ }
234+ r.rt.route(platformInput{Kind: "axis", X: ev.X, Y: ev.Y, DX: ev.DX, DY: ev.DY})
235+ case InputPointerLeave:
236+ r.rt.route(platformInput{Kind: "leave"})
237+ case InputFocusIn:
238+ r.rt.route(platformInput{Kind: "focus-in"})
239+ case InputFocusOut:
240+ r.rt.route(platformInput{Kind: "focus-out"})
241+ case InputKey:
242+ shift, ctrl := ev.Modifiers&ModShift != 0, ev.Modifiers&ModCtrl != 0
243+ key := keyEvent{Name: keysymName(ev.Keysym), Pressed: ev.Pressed, Repeat: ev.Repeat, Shift: shift, Ctrl: ctrl}
244+ if len(ev.Text) > 0 {
245+ key.Text = string(ev.Text)
246+ }
247+ r.rt.route(platformInput{Kind: "key", Key: key, Shift: shift, Ctrl: ctrl})
248+ default:
249+ return false
250+ }
251+ r.rt.mu.Lock()
252+ need := r.rt.redraw || len(r.rt.pending) > 0
253+ r.rt.mu.Unlock()
254+ return need
255+}
256+
257+// Render builds the view and, when something changed and a buffer is free,
258+// draws it. It returns false without touching the GPU when nothing changed, no
259+// buffer is available yet (call it again after Released) or before the first
260+// Resize. When it returns true, out describes the frame to present.
261+func (r *Renderer) Render[T any](out *Output, model *T, view func(*Frame, *T)) (bool, error) {
262+ if r.closed {
263+ return false, errClosed
264+ }
265+ if out == nil || model == nil || view == nil {
266+ return false, errors.New("nefergui: nil output, model or view")
267+ }
268+ if !r.sized {
269+ return false, nil
270+ }
271+ if r.rt.Build(func(f *Frame) { view(f, model) }) {
272+ r.unsent = true
273+ }
274+ if !r.unsent {
275+ return false, nil
276+ }
277+ ok, err := r.target.Draw(r.rt.output.Display, r.scale, &r.tout)
278+ if err != nil || !ok {
279+ return false, err
280+ }
281+ r.unsent = false
282+ r.export(out)
283+ return true, nil
284+}
285+
286+// export copies the target's frame description into out and adds the parts
287+// derived from the UI: damage, cursor and the staged input region.
288+func (r *Renderer) export(out *Output) {
289+ t := &r.tout
290+ retired := append(out.Retired[:0], t.Retired...)
291+ damage := append(out.Damage[:0], Rect{Width: t.Width, Height: t.Height})
292+ rects := out.InputRects
293+ *out = Output{
294+ Buffer: t.Buffer, NewBuffer: t.NewBuffer, Retired: retired,
295+ Width: t.Width, Height: t.Height, FourCC: t.FourCC, Modifier: t.Modifier,
296+ Planes: t.Planes, PlaneCount: t.PlaneCount, Acquire: t.Acquire, Release: t.Release,
297+ NewTimelines: t.NewTimelines, AcquirePoint: t.AcquirePoint, ReleasePoint: t.ReleasePoint, ReleaseFD: t.ReleaseFD,
298+ Damage: damage, Cursor: cursorOf(r.rt.cursor()), InputRects: rects[:0],
299+ }
300+ rt := r.rt
301+ if !rt.inputStaged {
302+ return
303+ }
304+ rt.inputStaged = false
305+ // A region equal to the last reported one costs nothing.
306+ if r.haveRegion && r.regionNil == rt.inputNil && slices.Equal(r.region, rt.inputRects) {
307+ return
308+ }
309+ r.haveRegion, r.regionNil = true, rt.inputNil
310+ r.region = append(r.region[:0], rt.inputRects...)
311+ out.InputRectsChanged = true
312+ if rt.inputNil {
313+ out.InputRects = nil
314+ return
315+ }
316+ if out.InputRects == nil {
317+ out.InputRects = []Rect{}
318+ }
319+ out.InputRects = append(out.InputRects, rt.inputRects...)
320+}
321+
322+func cursorOf(k css.Keyword) Cursor {
323+ switch k {
324+ case css.KeywordPointer:
325+ return CursorPointer
326+ case css.KeywordText:
327+ return CursorText
328+ case css.KeywordNotAllowed:
329+ return CursorNotAllowed
330+ }
331+ return CursorDefault
332+}
333+
334+// Released must be called when the release eventfd of an Output buffer is
335+// readable. It lets the buffer be reused.
336+func (r *Renderer) Released(buffer uint64) error {
337+ if r.closed {
338+ return errClosed
339+ }
340+ return r.target.Released(buffer)
341+}
342+
343+var errClosed = errors.New("nefergui: renderer closed")
344+
345+// Close cancels pending pastes and frees the GPU resources. The Renderer is
346+// unusable afterwards.
347+func (r *Renderer) Close() error {
348+ if r.closed {
349+ return nil
350+ }
351+ r.closed = true
352+ if r.rt.pasteCancel != nil {
353+ r.rt.pasteCancel()
354+ }
355+ return r.target.Close()
356+}
357+
358+// xkb keysym values; internal/ui never imports an xkb library.
359+const (
360+ keysymSpace = 0x0020
361+ keysymBackspace = 0xff08
362+ keysymTab = 0xff09
363+ keysymReturn = 0xff0d
364+ keysymEscape = 0xff1b
365+ keysymHome = 0xff50
366+ keysymLeft = 0xff51
367+ keysymUp = 0xff52
368+ keysymRight = 0xff53
369+ keysymDown = 0xff54
370+ keysymPageUp = 0xff55
371+ keysymPageDown = 0xff56
372+ keysymEnd = 0xff57
373+ keysymKPEnter = 0xff8d
374+ keysymISOLeft = 0xfe20
375+ keysymDelete = 0xffff
376+)
377+
378+// keysymName maps a keysym to the name interaction.go dispatches on; other
379+// keysyms have no name because only their text matters.
380+func keysymName(sym uint32) string {
381+ switch sym {
382+ case keysymSpace:
383+ return "space"
384+ case keysymTab:
385+ return "tab"
386+ case keysymISOLeft:
387+ return "iso_left_tab"
388+ case keysymLeft:
389+ return "left"
390+ case keysymRight:
391+ return "right"
392+ case keysymUp:
393+ return "up"
394+ case keysymDown:
395+ return "down"
396+ case keysymHome:
397+ return "home"
398+ case keysymEnd:
399+ return "end"
400+ case keysymPageUp:
401+ return "page_up"
402+ case keysymPageDown:
403+ return "page_down"
404+ case keysymBackspace:
405+ return "backspace"
406+ case keysymDelete:
407+ return "delete"
408+ case 'a', 'A':
409+ return "a"
410+ case 'c', 'C':
411+ return "c"
412+ case 'x', 'X':
413+ return "x"
414+ case 'v', 'V':
415+ return "v"
416+ case keysymReturn:
417+ return "return"
418+ case keysymKPEnter:
419+ return "kp_enter"
420+ case keysymEscape:
421+ return "escape"
422+ }
423+ return ""
424+}
A internal/ui/renderer_gpu_test.go +273 -0
@@ -0,0 +1,273 @@
1+//go:build linux
2+
3+package ui
4+
5+import (
6+ "cmp"
7+ "os"
8+ "slices"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/nefergui/internal/presentation/session"
13+ "github.com/bnema/nefergui/internal/presentation/vkdevice"
14+ "github.com/bnema/nefergui/internal/text"
15+ "golang.org/x/sys/unix"
16+)
17+
18+// gpuRenderer builds a real Renderer on the render node named by
19+// NEFERGUI_RENDER_NODE (skips without it) with readback enabled.
20+func gpuRenderer(t testing.TB) (*Renderer, *session.Target) {
21+ t.Helper()
22+ path := os.Getenv("NEFERGUI_RENDER_NODE")
23+ if path == "" {
24+ t.Skip("opt in with NEFERGUI_RENDER_NODE=/dev/dri/renderD...")
25+ }
26+ var stat unix.Stat_t
27+ if err := unix.Stat(path, &stat); err != nil {
28+ t.Fatal(err)
29+ }
30+ dev, err := vkdevice.Open(vkdevice.DeviceIdentity(uint64(stat.Rdev)))
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ mods, err := dev.ExportableModifiers()
35+ dev.Close()
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ var formats []Format
40+ for m := range mods {
41+ formats = append(formats, Format{FourCC: vkdevice.XRGB8888, Modifier: m})
42+ }
43+ slices.SortFunc(formats, func(a, b Format) int { return cmp.Compare(a.Modifier, b.Modifier) })
44+ vf := make([]vkdevice.Format, len(formats))
45+ for i, f := range formats {
46+ vf[i] = vkdevice.Format{FourCC: f.FourCC, Modifier: f.Modifier}
47+ }
48+ tg, err := session.NewTarget(session.TargetConfig{MainDevice: uint64(stat.Rdev), Formats: vf})
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ tg.EnableReadback()
53+ r, err := newRenderer(RendererConfig{}, tg, text.DirectorySource("../../testdata/fonts"))
54+ if err != nil {
55+ _ = tg.Close()
56+ t.Fatal(err)
57+ }
58+ t.Cleanup(func() { _ = r.Close() })
59+ return r, tg
60+}
61+
62+func readable(t testing.TB, fd int, timeout time.Duration) bool {
63+ t.Helper()
64+ fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
65+ n, err := unix.Poll(fds, int(timeout.Milliseconds()))
66+ if err != nil {
67+ t.Fatal(err)
68+ }
69+ return n > 0
70+}
71+
72+// signalRelease plays the compositor: it signals the buffer's release point.
73+func signalRelease(t testing.TB, tg *session.Target, out *Output) {
74+ t.Helper()
75+ slot := tg.Slots[out.Buffer]
76+ if err := tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint); err != nil {
77+ t.Fatal(err)
78+ }
79+}
80+
81+func TestRendererGPUPixelAndRelease(t *testing.T) {
82+ r, tg := gpuRenderer(t)
83+ var out Output
84+ var m struct{}
85+ view := func(f *Frame, _ *struct{}) {
86+ f.Root().Box(Inline("width:50px;height:50px;background:#336699"))
87+ }
88+ r.Resize(100, 80, 1)
89+ ok, err := r.Render(&out, &m, view)
90+ if err != nil || !ok {
91+ t.Fatalf("render: ok=%v err=%v", ok, err)
92+ }
93+ if !out.NewBuffer || !out.NewTimelines || out.Width != 100 || out.Height != 80 || out.PlaneCount != 1 ||
94+ out.Planes[0].FD < 0 || out.Planes[0].Stride < 400 || out.FourCC != vkdevice.XRGB8888 ||
95+ out.Acquire.FD < 0 || out.Release.FD < 0 || out.ReleaseFD < 0 || out.AcquirePoint == 0 || out.ReleasePoint <= out.AcquirePoint {
96+ t.Fatalf("output: %+v", out)
97+ }
98+ img, err := tg.Readback(out.Buffer)
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ near := func(a, b uint8) bool { return int(a)-int(b) <= 1 && int(b)-int(a) <= 1 }
103+ px := img.NRGBAAt(10, 10)
104+ if !near(px.R, 0x33) || !near(px.G, 0x66) || !near(px.B, 0x99) {
105+ t.Fatalf("pixel inside box = %+v", px)
106+ }
107+ if px = img.NRGBAAt(90, 70); px.R != 0 || px.G != 0 || px.B != 0 {
108+ t.Fatalf("pixel outside box = %+v", px)
109+ }
110+ // Nothing is released until the compositor signals the point.
111+ if readable(t, out.ReleaseFD, 20*time.Millisecond) {
112+ t.Fatal("release eventfd readable before the point signaled")
113+ }
114+ signalRelease(t, tg, &out)
115+ if !readable(t, out.ReleaseFD, time.Second) {
116+ t.Fatal("release eventfd not readable after the release point signaled")
117+ }
118+ if err = r.Released(out.Buffer); err != nil {
119+ t.Fatal(err)
120+ }
121+ if readable(t, out.ReleaseFD, 0) {
122+ t.Fatal("release eventfd still readable after Released")
123+ }
124+ // The same buffer comes back with stable descriptors and no re-import.
125+ first := out.Buffer
126+ r.Invalidate()
127+ seen := map[uint64]bool{first: true}
128+ for i := 0; i < 3 && len(seen) < 3; i++ {
129+ r.Invalidate()
130+ ok, err = r.Render(&out, &m, view)
131+ if err != nil || !ok {
132+ t.Fatalf("render %d: ok=%v err=%v", i, ok, err)
133+ }
134+ seen[out.Buffer] = true
135+ if !out.NewBuffer && out.NewTimelines {
136+ t.Fatal("timelines announced for a known buffer")
137+ }
138+ }
139+ if len(seen) != 3 {
140+ t.Fatalf("expected a pool of 3 distinct buffers, saw %v", seen)
141+ }
142+ // All three are compositor owned: no buffer is free, Render must not draw.
143+ r.Invalidate()
144+ if ok, err = r.Render(&out, &m, view); ok || err != nil {
145+ t.Fatalf("render with no free buffer: ok=%v err=%v", ok, err)
146+ }
147+}
148+
149+func TestRendererGPUResizeRetiresBuffers(t *testing.T) {
150+ r, tg := gpuRenderer(t)
151+ var out Output
152+ var m struct{}
153+ view := func(f *Frame, _ *struct{}) { f.Root().Box(Inline("width:10px;height:10px;background:#fff")) }
154+ r.Resize(64, 64, 1)
155+ if ok, err := r.Render(&out, &m, view); !ok || err != nil {
156+ t.Fatalf("ok=%v err=%v", ok, err)
157+ }
158+ old := out.Buffer
159+ signalRelease(t, tg, &out)
160+ if err := r.Released(old); err != nil {
161+ t.Fatal(err)
162+ }
163+ // Retirement waits for the old frame's GPU work; a late one is reported by
164+ // a later Render, so let it finish to keep the test deterministic.
165+ if err := tg.Slots[old].Frame.Wait(); err != nil {
166+ t.Fatal(err)
167+ }
168+ r.Resize(96, 64, 1)
169+ if ok, err := r.Render(&out, &m, view); !ok || err != nil {
170+ t.Fatalf("after resize: ok=%v err=%v", ok, err)
171+ }
172+ if out.Width != 96 || !out.NewBuffer || out.Buffer == old {
173+ t.Fatalf("resize output: %+v", out)
174+ }
175+ found := false
176+ for _, rt := range out.Retired {
177+ found = found || rt.Buffer == old
178+ }
179+ if !found {
180+ t.Fatalf("released old-generation buffer %d not retired: %+v", old, out.Retired)
181+ }
182+}
183+
184+// allocBaselineRenderer is the measured allocation count of one steady
185+// Render+Released cycle on 2026-10-01 (radv, renderD128 and renderD129); the
186+// target is 0, the remainder is frame build, layout and prepare.
187+const allocBaselineRenderer = 244
188+
189+// TestAllocRendererSteadyFrame measures Render with a view whose number
190+// changes on every frame and an unchanged structure. It runs on the real GPU
191+// path because testify mocks would allocate.
192+func TestAllocRendererSteadyFrame(t *testing.T) {
193+ if raceEnabled {
194+ t.Skip("race instrumentation changes allocation counts")
195+ }
196+ r, tg := gpuRenderer(t)
197+ var out Output
198+ n := 0
199+ view := func(f *Frame, n *int) {
200+ root := f.Root()
201+ root.Heading("Counter")
202+ root.TextInt("Value: ", int64(*n))
203+ root.Button("Go", Key("go"))
204+ }
205+ r.Resize(160, 120, 1)
206+ step := func() {
207+ n++
208+ r.Invalidate()
209+ ok, err := r.Render(&out, &n, view)
210+ for tries := 0; err == nil && !ok && r.Pending() && tries < 5000; tries++ {
211+ time.Sleep(200 * time.Microsecond)
212+ ok, err = r.Render(&out, &n, view)
213+ }
214+ if err != nil || !ok {
215+ t.Fatalf("render: ok=%v err=%v pending=%v", ok, err, r.Pending())
216+ }
217+ slot := tg.Slots[out.Buffer]
218+ if err = tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint); err != nil {
219+ t.Fatal(err)
220+ }
221+ if err = r.Released(out.Buffer); err != nil {
222+ t.Fatal(err)
223+ }
224+ }
225+ for i := 0; i < 20; i++ { // warm up caches, atlas, buffers
226+ step()
227+ }
228+ got := testing.AllocsPerRun(50, step)
229+ t.Logf("allocs per steady Render+Released cycle: %v", got)
230+ if got > allocBaselineRenderer {
231+ t.Fatalf("allocs per steady frame = %v, baseline %v", got, allocBaselineRenderer)
232+ }
233+}
234+
235+// A readable eventfd whose point is not signaled (a stale notification) must be
236+// drained without spinning, and the live registration must still work.
237+func TestRendererGPUStaleReleaseNotification(t *testing.T) {
238+ r, tg := gpuRenderer(t)
239+ var out Output
240+ var m struct{}
241+ view := func(f *Frame, _ *struct{}) { f.Root().Box(Inline("width:10px;height:10px;background:#fff")) }
242+ r.Resize(64, 64, 1)
243+ if ok, err := r.Render(&out, &m, view); !ok || err != nil {
244+ t.Fatalf("ok=%v err=%v", ok, err)
245+ }
246+ one := [8]byte{1}
247+ if _, err := unix.Write(out.ReleaseFD, one[:]); err != nil {
248+ t.Fatal(err)
249+ }
250+ if !readable(t, out.ReleaseFD, time.Second) {
251+ t.Fatal("test setup: eventfd not readable")
252+ }
253+ if err := r.Released(out.Buffer); err != nil {
254+ t.Fatal(err)
255+ }
256+ if readable(t, out.ReleaseFD, 0) {
257+ t.Fatal("stale notification left the eventfd readable: the caller would spin")
258+ }
259+ if tg.Slots[out.Buffer].Buffer.State == "available" {
260+ t.Fatal("buffer reused before the compositor released it")
261+ }
262+ signalRelease(t, tg, &out)
263+ if !readable(t, out.ReleaseFD, time.Second) {
264+ t.Fatal("live registration lost after a stale notification")
265+ }
266+ if err := r.Released(out.Buffer); err != nil {
267+ t.Fatal(err)
268+ }
269+ // Released for an unknown or idle buffer is a no-op that still drains.
270+ if err := r.Released(999); err != nil {
271+ t.Fatal(err)
272+ }
273+}
@@ -0,0 +1,273 @@
1+//go:build linux
2+
3+package ui
4+
5+import (
6+ "cmp"
7+ "os"
8+ "slices"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/nefergui/internal/presentation/session"
13+ "github.com/bnema/nefergui/internal/presentation/vkdevice"
14+ "github.com/bnema/nefergui/internal/text"
15+ "golang.org/x/sys/unix"
16+)
17+
18+// gpuRenderer builds a real Renderer on the render node named by
19+// NEFERGUI_RENDER_NODE (skips without it) with readback enabled.
20+func gpuRenderer(t testing.TB) (*Renderer, *session.Target) {
21+ t.Helper()
22+ path := os.Getenv("NEFERGUI_RENDER_NODE")
23+ if path == "" {
24+ t.Skip("opt in with NEFERGUI_RENDER_NODE=/dev/dri/renderD...")
25+ }
26+ var stat unix.Stat_t
27+ if err := unix.Stat(path, &stat); err != nil {
28+ t.Fatal(err)
29+ }
30+ dev, err := vkdevice.Open(vkdevice.DeviceIdentity(uint64(stat.Rdev)))
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ mods, err := dev.ExportableModifiers()
35+ dev.Close()
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ var formats []Format
40+ for m := range mods {
41+ formats = append(formats, Format{FourCC: vkdevice.XRGB8888, Modifier: m})
42+ }
43+ slices.SortFunc(formats, func(a, b Format) int { return cmp.Compare(a.Modifier, b.Modifier) })
44+ vf := make([]vkdevice.Format, len(formats))
45+ for i, f := range formats {
46+ vf[i] = vkdevice.Format{FourCC: f.FourCC, Modifier: f.Modifier}
47+ }
48+ tg, err := session.NewTarget(session.TargetConfig{MainDevice: uint64(stat.Rdev), Formats: vf})
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ tg.EnableReadback()
53+ r, err := newRenderer(RendererConfig{}, tg, text.DirectorySource("../../testdata/fonts"))
54+ if err != nil {
55+ _ = tg.Close()
56+ t.Fatal(err)
57+ }
58+ t.Cleanup(func() { _ = r.Close() })
59+ return r, tg
60+}
61+
62+func readable(t testing.TB, fd int, timeout time.Duration) bool {
63+ t.Helper()
64+ fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
65+ n, err := unix.Poll(fds, int(timeout.Milliseconds()))
66+ if err != nil {
67+ t.Fatal(err)
68+ }
69+ return n > 0
70+}
71+
72+// signalRelease plays the compositor: it signals the buffer's release point.
73+func signalRelease(t testing.TB, tg *session.Target, out *Output) {
74+ t.Helper()
75+ slot := tg.Slots[out.Buffer]
76+ if err := tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint); err != nil {
77+ t.Fatal(err)
78+ }
79+}
80+
81+func TestRendererGPUPixelAndRelease(t *testing.T) {
82+ r, tg := gpuRenderer(t)
83+ var out Output
84+ var m struct{}
85+ view := func(f *Frame, _ *struct{}) {
86+ f.Root().Box(Inline("width:50px;height:50px;background:#336699"))
87+ }
88+ r.Resize(100, 80, 1)
89+ ok, err := r.Render(&out, &m, view)
90+ if err != nil || !ok {
91+ t.Fatalf("render: ok=%v err=%v", ok, err)
92+ }
93+ if !out.NewBuffer || !out.NewTimelines || out.Width != 100 || out.Height != 80 || out.PlaneCount != 1 ||
94+ out.Planes[0].FD < 0 || out.Planes[0].Stride < 400 || out.FourCC != vkdevice.XRGB8888 ||
95+ out.Acquire.FD < 0 || out.Release.FD < 0 || out.ReleaseFD < 0 || out.AcquirePoint == 0 || out.ReleasePoint <= out.AcquirePoint {
96+ t.Fatalf("output: %+v", out)
97+ }
98+ img, err := tg.Readback(out.Buffer)
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ near := func(a, b uint8) bool { return int(a)-int(b) <= 1 && int(b)-int(a) <= 1 }
103+ px := img.NRGBAAt(10, 10)
104+ if !near(px.R, 0x33) || !near(px.G, 0x66) || !near(px.B, 0x99) {
105+ t.Fatalf("pixel inside box = %+v", px)
106+ }
107+ if px = img.NRGBAAt(90, 70); px.R != 0 || px.G != 0 || px.B != 0 {
108+ t.Fatalf("pixel outside box = %+v", px)
109+ }
110+ // Nothing is released until the compositor signals the point.
111+ if readable(t, out.ReleaseFD, 20*time.Millisecond) {
112+ t.Fatal("release eventfd readable before the point signaled")
113+ }
114+ signalRelease(t, tg, &out)
115+ if !readable(t, out.ReleaseFD, time.Second) {
116+ t.Fatal("release eventfd not readable after the release point signaled")
117+ }
118+ if err = r.Released(out.Buffer); err != nil {
119+ t.Fatal(err)
120+ }
121+ if readable(t, out.ReleaseFD, 0) {
122+ t.Fatal("release eventfd still readable after Released")
123+ }
124+ // The same buffer comes back with stable descriptors and no re-import.
125+ first := out.Buffer
126+ r.Invalidate()
127+ seen := map[uint64]bool{first: true}
128+ for i := 0; i < 3 && len(seen) < 3; i++ {
129+ r.Invalidate()
130+ ok, err = r.Render(&out, &m, view)
131+ if err != nil || !ok {
132+ t.Fatalf("render %d: ok=%v err=%v", i, ok, err)
133+ }
134+ seen[out.Buffer] = true
135+ if !out.NewBuffer && out.NewTimelines {
136+ t.Fatal("timelines announced for a known buffer")
137+ }
138+ }
139+ if len(seen) != 3 {
140+ t.Fatalf("expected a pool of 3 distinct buffers, saw %v", seen)
141+ }
142+ // All three are compositor owned: no buffer is free, Render must not draw.
143+ r.Invalidate()
144+ if ok, err = r.Render(&out, &m, view); ok || err != nil {
145+ t.Fatalf("render with no free buffer: ok=%v err=%v", ok, err)
146+ }
147+}
148+
149+func TestRendererGPUResizeRetiresBuffers(t *testing.T) {
150+ r, tg := gpuRenderer(t)
151+ var out Output
152+ var m struct{}
153+ view := func(f *Frame, _ *struct{}) { f.Root().Box(Inline("width:10px;height:10px;background:#fff")) }
154+ r.Resize(64, 64, 1)
155+ if ok, err := r.Render(&out, &m, view); !ok || err != nil {
156+ t.Fatalf("ok=%v err=%v", ok, err)
157+ }
158+ old := out.Buffer
159+ signalRelease(t, tg, &out)
160+ if err := r.Released(old); err != nil {
161+ t.Fatal(err)
162+ }
163+ // Retirement waits for the old frame's GPU work; a late one is reported by
164+ // a later Render, so let it finish to keep the test deterministic.
165+ if err := tg.Slots[old].Frame.Wait(); err != nil {
166+ t.Fatal(err)
167+ }
168+ r.Resize(96, 64, 1)
169+ if ok, err := r.Render(&out, &m, view); !ok || err != nil {
170+ t.Fatalf("after resize: ok=%v err=%v", ok, err)
171+ }
172+ if out.Width != 96 || !out.NewBuffer || out.Buffer == old {
173+ t.Fatalf("resize output: %+v", out)
174+ }
175+ found := false
176+ for _, rt := range out.Retired {
177+ found = found || rt.Buffer == old
178+ }
179+ if !found {
180+ t.Fatalf("released old-generation buffer %d not retired: %+v", old, out.Retired)
181+ }
182+}
183+
184+// allocBaselineRenderer is the measured allocation count of one steady
185+// Render+Released cycle on 2026-10-01 (radv, renderD128 and renderD129); the
186+// target is 0, the remainder is frame build, layout and prepare.
187+const allocBaselineRenderer = 244
188+
189+// TestAllocRendererSteadyFrame measures Render with a view whose number
190+// changes on every frame and an unchanged structure. It runs on the real GPU
191+// path because testify mocks would allocate.
192+func TestAllocRendererSteadyFrame(t *testing.T) {
193+ if raceEnabled {
194+ t.Skip("race instrumentation changes allocation counts")
195+ }
196+ r, tg := gpuRenderer(t)
197+ var out Output
198+ n := 0
199+ view := func(f *Frame, n *int) {
200+ root := f.Root()
201+ root.Heading("Counter")
202+ root.TextInt("Value: ", int64(*n))
203+ root.Button("Go", Key("go"))
204+ }
205+ r.Resize(160, 120, 1)
206+ step := func() {
207+ n++
208+ r.Invalidate()
209+ ok, err := r.Render(&out, &n, view)
210+ for tries := 0; err == nil && !ok && r.Pending() && tries < 5000; tries++ {
211+ time.Sleep(200 * time.Microsecond)
212+ ok, err = r.Render(&out, &n, view)
213+ }
214+ if err != nil || !ok {
215+ t.Fatalf("render: ok=%v err=%v pending=%v", ok, err, r.Pending())
216+ }
217+ slot := tg.Slots[out.Buffer]
218+ if err = tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint); err != nil {
219+ t.Fatal(err)
220+ }
221+ if err = r.Released(out.Buffer); err != nil {
222+ t.Fatal(err)
223+ }
224+ }
225+ for i := 0; i < 20; i++ { // warm up caches, atlas, buffers
226+ step()
227+ }
228+ got := testing.AllocsPerRun(50, step)
229+ t.Logf("allocs per steady Render+Released cycle: %v", got)
230+ if got > allocBaselineRenderer {
231+ t.Fatalf("allocs per steady frame = %v, baseline %v", got, allocBaselineRenderer)
232+ }
233+}
234+
235+// A readable eventfd whose point is not signaled (a stale notification) must be
236+// drained without spinning, and the live registration must still work.
237+func TestRendererGPUStaleReleaseNotification(t *testing.T) {
238+ r, tg := gpuRenderer(t)
239+ var out Output
240+ var m struct{}
241+ view := func(f *Frame, _ *struct{}) { f.Root().Box(Inline("width:10px;height:10px;background:#fff")) }
242+ r.Resize(64, 64, 1)
243+ if ok, err := r.Render(&out, &m, view); !ok || err != nil {
244+ t.Fatalf("ok=%v err=%v", ok, err)
245+ }
246+ one := [8]byte{1}
247+ if _, err := unix.Write(out.ReleaseFD, one[:]); err != nil {
248+ t.Fatal(err)
249+ }
250+ if !readable(t, out.ReleaseFD, time.Second) {
251+ t.Fatal("test setup: eventfd not readable")
252+ }
253+ if err := r.Released(out.Buffer); err != nil {
254+ t.Fatal(err)
255+ }
256+ if readable(t, out.ReleaseFD, 0) {
257+ t.Fatal("stale notification left the eventfd readable: the caller would spin")
258+ }
259+ if tg.Slots[out.Buffer].Buffer.State == "available" {
260+ t.Fatal("buffer reused before the compositor released it")
261+ }
262+ signalRelease(t, tg, &out)
263+ if !readable(t, out.ReleaseFD, time.Second) {
264+ t.Fatal("live registration lost after a stale notification")
265+ }
266+ if err := r.Released(out.Buffer); err != nil {
267+ t.Fatal(err)
268+ }
269+ // Released for an unknown or idle buffer is a no-op that still drains.
270+ if err := r.Released(999); err != nil {
271+ t.Fatal(err)
272+ }
273+}
A internal/ui/renderer_test.go +277 -0
@@ -0,0 +1,277 @@
1+//go:build linux
2+
3+package ui
4+
5+import (
6+ "errors"
7+ "testing"
8+
9+ "github.com/bnema/nefergui/internal/layout"
10+ "github.com/bnema/nefergui/internal/presentation/session"
11+ "github.com/bnema/nefergui/internal/text"
12+ "github.com/stretchr/testify/mock"
13+)
14+
15+type testModel struct {
16+ clicks int
17+ rects []Rect
18+}
19+
20+func testView(f *Frame, m *testModel) {
21+ root := f.Root()
22+ if root.Button("Go", Key("go"), Inline("width:60px;height:20px")).Activated() {
23+ m.clicks++
24+ }
25+ if m.rects != nil {
26+ _ = f.SetInputRects(m.rects)
27+ }
28+}
29+
30+func newTestRenderer(t *testing.T) (*Renderer, *mocktarget) {
31+ t.Helper()
32+ tg := newMocktarget(t)
33+ r, err := newRenderer(RendererConfig{}, tg, text.DirectorySource("../../testdata/fonts"))
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ return r, tg
38+}
39+
40+func drawOK(tg *mocktarget, fill func(*session.Output)) *mocktarget_Draw_Call {
41+ return tg.EXPECT().Draw(mock.Anything, mock.Anything, mock.Anything).
42+ RunAndReturn(func(_ []layout.Command, _ float64, out *session.Output) (bool, error) {
43+ *out = session.Output{Buffer: 1, Width: 100, Height: 50}
44+ if fill != nil {
45+ fill(out)
46+ }
47+ return true, nil
48+ })
49+}
50+
51+func TestKeysymNames(t *testing.T) {
52+ for sym, want := range map[uint32]string{
53+ 0x20: "space", 0xff09: "tab", 0xfe20: "iso_left_tab", 0xff51: "left", 0xff53: "right",
54+ 0xff52: "up", 0xff54: "down", 0xff50: "home", 0xff57: "end", 0xff55: "page_up",
55+ 0xff56: "page_down", 0xff08: "backspace", 0xffff: "delete", 'a': "a", 'A': "a",
56+ 'c': "c", 'x': "x", 'v': "v", 0xff0d: "return", 0xff8d: "kp_enter", 0xff1b: "escape",
57+ 'z': "", 0xffe1: "",
58+ } {
59+ if got := keysymName(sym); got != want {
60+ t.Errorf("keysym %#x: got %q want %q", sym, got, want)
61+ }
62+ }
63+}
64+
65+func TestRendererRenderBeforeResizeAndWhenUnchanged(t *testing.T) {
66+ r, tg := newTestRenderer(t)
67+ var m testModel
68+ var out Output
69+ if ok, err := r.Render(&out, &m, testView); ok || err != nil {
70+ t.Fatalf("before Resize: ok=%v err=%v", ok, err)
71+ }
72+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
73+ r.Resize(100, 50, 2)
74+ drawOK(tg, nil).Once()
75+ if ok, err := r.Render(&out, &m, testView); !ok || err != nil {
76+ t.Fatalf("first render: ok=%v err=%v", ok, err)
77+ }
78+ if out.Buffer != 1 || out.Width != 100 || len(out.Damage) != 1 || out.Damage[0] != (Rect{Width: 100, Height: 50}) {
79+ t.Fatalf("output: %+v", out)
80+ }
81+ // Nothing changed: no rebuild result, so no Draw (the mock has no further expectation).
82+ if ok, err := r.Render(&out, &m, testView); ok || err != nil {
83+ t.Fatalf("unchanged view: ok=%v err=%v", ok, err)
84+ }
85+}
86+
87+func TestRendererKeepsFrameWhenNoBufferFree(t *testing.T) {
88+ r, tg := newTestRenderer(t)
89+ var m testModel
90+ var out Output
91+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
92+ r.Resize(100, 50, 1)
93+ tg.EXPECT().Draw(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Once()
94+ tg.EXPECT().Waiting().Return(true).Once()
95+ if ok, err := r.Render(&out, &m, testView); ok || err != nil {
96+ t.Fatalf("no buffer: ok=%v err=%v", ok, err)
97+ }
98+ if !r.Pending() {
99+ t.Fatal("a built frame blocked on the GPU must report Pending")
100+ }
101+ drawOK(tg, nil).Once()
102+ if ok, err := r.Render(&out, &m, testView); !ok || err != nil {
103+ t.Fatalf("retry: ok=%v err=%v", ok, err)
104+ }
105+}
106+
107+func TestRendererDrawError(t *testing.T) {
108+ r, tg := newTestRenderer(t)
109+ var m testModel
110+ var out Output
111+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
112+ r.Resize(100, 50, 1)
113+ boom := errors.New("boom")
114+ tg.EXPECT().Draw(mock.Anything, mock.Anything, mock.Anything).Return(false, boom).Once()
115+ if ok, err := r.Render(&out, &m, testView); ok || !errors.Is(err, boom) {
116+ t.Fatalf("ok=%v err=%v", ok, err)
117+ }
118+}
119+
120+func TestRendererResizeReachesTargetInPhysicalPixels(t *testing.T) {
121+ r, tg := newTestRenderer(t)
122+ tg.EXPECT().Resize(int32(150), int32(75)).Return().Once()
123+ r.Resize(100, 50, 1.5)
124+ r.Resize(100, 50, 1.5) // unchanged: no second call
125+ r.Resize(0, 50, 1) // invalid: ignored
126+ r.Resize(10, 10, 0) // invalid: ignored
127+}
128+
129+func TestRendererInputActivatesButtonAndRequestsRedraw(t *testing.T) {
130+ r, tg := newTestRenderer(t)
131+ var m testModel
132+ var out Output
133+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
134+ r.Resize(200, 100, 1)
135+ drawOK(tg, nil).Maybe()
136+ for i := 0; i < 3; i++ {
137+ if _, err := r.Render(&out, &m, testView); err != nil {
138+ t.Fatal(err)
139+ }
140+ }
141+ if r.Input(&Input{Kind: InputPointerMotion, X: 5, Y: 5}) != true {
142+ t.Fatal("hover over a button needs a redraw")
143+ }
144+ r.Input(&Input{Kind: InputPointerPress, X: 5, Y: 5, Button: 0x110, Pressed: true})
145+ if !r.Input(&Input{Kind: InputPointerRelease, X: 5, Y: 5, Button: 0x110}) {
146+ t.Fatal("release did not request redraw")
147+ }
148+ for i := 0; i < 3; i++ {
149+ if _, err := r.Render(&out, &m, testView); err != nil {
150+ t.Fatal(err)
151+ }
152+ }
153+ if m.clicks != 1 {
154+ t.Fatalf("clicks=%d", m.clicks)
155+ }
156+ // Keyboard: Tab focuses, Return activates, text comes from Text.
157+ r.Input(&Input{Kind: InputPointerLeave})
158+ r.Input(&Input{Kind: InputKey, Keysym: 0xff09, Pressed: true})
159+ r.Input(&Input{Kind: InputKey, Keysym: 0xff0d, Pressed: true})
160+ for i := 0; i < 3; i++ {
161+ if _, err := r.Render(&out, &m, testView); err != nil {
162+ t.Fatal(err)
163+ }
164+ }
165+ if m.clicks != 2 {
166+ t.Fatalf("Return did not activate the focused button: clicks=%d", m.clicks)
167+ }
168+ if r.Input(&Input{Kind: 99}) || r.Input(nil) {
169+ t.Fatal("unknown input requested a redraw")
170+ }
171+ if r.Input(&Input{Kind: InputPointerAxis}) {
172+ t.Fatal("zero scroll requested a redraw")
173+ }
174+}
175+
176+func TestRendererTextInputUsesTextBytes(t *testing.T) {
177+ r, tg := newTestRenderer(t)
178+ value := ""
179+ var out Output
180+ view := func(f *Frame, v *string) { f.Root().Input("name", v, Key("name"), Inline("width:100px;height:20px")) }
181+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
182+ r.Resize(200, 100, 1)
183+ drawOK(tg, nil).Maybe()
184+ settle := func() {
185+ for i := 0; i < 3; i++ {
186+ if _, err := r.Render(&out, &value, view); err != nil {
187+ t.Fatal(err)
188+ }
189+ }
190+ }
191+ settle()
192+ r.Input(&Input{Kind: InputPointerPress, X: 5, Y: 5, Button: 0x110, Pressed: true})
193+ r.Input(&Input{Kind: InputPointerRelease, X: 5, Y: 5, Button: 0x110})
194+ settle()
195+ text := []byte("é")
196+ r.Input(&Input{Kind: InputKey, Keysym: 0xe9, Pressed: true, Text: text})
197+ text[0] = 0 // the caller may reuse the buffer after the call
198+ settle()
199+ if value != "é" {
200+ t.Fatalf("value=%q", value)
201+ }
202+}
203+
204+func TestRendererOutputCarriesCursorAndInputRects(t *testing.T) {
205+ r, tg := newTestRenderer(t)
206+ m := testModel{rects: []Rect{{1, 2, 30, 20}}}
207+ var out Output
208+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
209+ r.Resize(200, 100, 1)
210+ drawOK(tg, nil).Maybe()
211+ r.Input(&Input{Kind: InputPointerMotion, X: 5, Y: 5}) // over the button: pointer cursor
212+ if ok, err := r.Render(&out, &m, testView); !ok || err != nil {
213+ t.Fatalf("ok=%v err=%v", ok, err)
214+ }
215+ if out.Cursor != CursorPointer || !out.InputRectsChanged || len(out.InputRects) != 1 || out.InputRects[0] != (Rect{1, 2, 30, 20}) {
216+ t.Fatalf("output: cursor=%v changed=%v rects=%v", out.Cursor, out.InputRectsChanged, out.InputRects)
217+ }
218+ // An unchanged region is not reported again.
219+ r.Invalidate()
220+ ok, err := r.Render(&out, &m, testView)
221+ if err != nil {
222+ t.Fatal(err)
223+ }
224+ if ok && out.InputRectsChanged && len(out.InputRects) != 0 {
225+ t.Fatalf("region reported twice: %+v", out)
226+ }
227+ // nil restores the whole surface: changed with no rects.
228+ m.rects = nil
229+ var nilView = func(f *Frame, _ *testModel) { f.Root(); _ = f.SetInputRects(nil) }
230+ r.Invalidate()
231+ if ok, err = r.Render(&out, &m, nilView); err != nil || !ok || !out.InputRectsChanged || out.InputRects != nil {
232+ t.Fatalf("nil region: ok=%v err=%v %+v", ok, err, out)
233+ }
234+}
235+
236+func TestRendererForwardsReleasedAndClose(t *testing.T) {
237+ r, tg := newTestRenderer(t)
238+ tg.EXPECT().Released(uint64(3)).Return(nil).Once()
239+ if err := r.Released(3); err != nil {
240+ t.Fatal(err)
241+ }
242+ tg.EXPECT().Close().Return(nil).Once()
243+ if err := r.Close(); err != nil {
244+ t.Fatal(err)
245+ }
246+ // After Close nothing reaches the target (the mock has no expectation).
247+ r.Resize(10, 10, 1)
248+ if r.Input(&Input{Kind: InputPointerMotion}) {
249+ t.Fatal("Input after Close requested a redraw")
250+ }
251+ if err := r.Released(3); err == nil {
252+ t.Fatal("Released after Close succeeded")
253+ }
254+ if err := r.Close(); err != nil { // second Close is a no-op
255+ t.Fatal(err)
256+ }
257+ var out Output
258+ var m testModel
259+ if _, err := r.Render(&out, &m, testView); err == nil {
260+ t.Fatal("Render after Close succeeded")
261+ }
262+}
263+
264+func TestTextIntAndMasked(t *testing.T) {
265+ r := newRuntime()
266+ r.Build(func(f *Frame) {
267+ root := f.Root()
268+ root.TextInt("Count: ", -42)
269+ root.Masked(3)
270+ root.Masked(0)
271+ root.Masked(300)
272+ })
273+ c := r.committed.children
274+ if len(c) != 4 || c[0].text != "Count: -42" || c[1].text != "•••" || c[2].text != "" || len([]rune(c[3].text)) != 300 {
275+ t.Fatalf("texts: %q %q %q", c[0].text, c[1].text, c[2].text)
276+ }
277+}
@@ -0,0 +1,277 @@
1+//go:build linux
2+
3+package ui
4+
5+import (
6+ "errors"
7+ "testing"
8+
9+ "github.com/bnema/nefergui/internal/layout"
10+ "github.com/bnema/nefergui/internal/presentation/session"
11+ "github.com/bnema/nefergui/internal/text"
12+ "github.com/stretchr/testify/mock"
13+)
14+
15+type testModel struct {
16+ clicks int
17+ rects []Rect
18+}
19+
20+func testView(f *Frame, m *testModel) {
21+ root := f.Root()
22+ if root.Button("Go", Key("go"), Inline("width:60px;height:20px")).Activated() {
23+ m.clicks++
24+ }
25+ if m.rects != nil {
26+ _ = f.SetInputRects(m.rects)
27+ }
28+}
29+
30+func newTestRenderer(t *testing.T) (*Renderer, *mocktarget) {
31+ t.Helper()
32+ tg := newMocktarget(t)
33+ r, err := newRenderer(RendererConfig{}, tg, text.DirectorySource("../../testdata/fonts"))
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ return r, tg
38+}
39+
40+func drawOK(tg *mocktarget, fill func(*session.Output)) *mocktarget_Draw_Call {
41+ return tg.EXPECT().Draw(mock.Anything, mock.Anything, mock.Anything).
42+ RunAndReturn(func(_ []layout.Command, _ float64, out *session.Output) (bool, error) {
43+ *out = session.Output{Buffer: 1, Width: 100, Height: 50}
44+ if fill != nil {
45+ fill(out)
46+ }
47+ return true, nil
48+ })
49+}
50+
51+func TestKeysymNames(t *testing.T) {
52+ for sym, want := range map[uint32]string{
53+ 0x20: "space", 0xff09: "tab", 0xfe20: "iso_left_tab", 0xff51: "left", 0xff53: "right",
54+ 0xff52: "up", 0xff54: "down", 0xff50: "home", 0xff57: "end", 0xff55: "page_up",
55+ 0xff56: "page_down", 0xff08: "backspace", 0xffff: "delete", 'a': "a", 'A': "a",
56+ 'c': "c", 'x': "x", 'v': "v", 0xff0d: "return", 0xff8d: "kp_enter", 0xff1b: "escape",
57+ 'z': "", 0xffe1: "",
58+ } {
59+ if got := keysymName(sym); got != want {
60+ t.Errorf("keysym %#x: got %q want %q", sym, got, want)
61+ }
62+ }
63+}
64+
65+func TestRendererRenderBeforeResizeAndWhenUnchanged(t *testing.T) {
66+ r, tg := newTestRenderer(t)
67+ var m testModel
68+ var out Output
69+ if ok, err := r.Render(&out, &m, testView); ok || err != nil {
70+ t.Fatalf("before Resize: ok=%v err=%v", ok, err)
71+ }
72+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
73+ r.Resize(100, 50, 2)
74+ drawOK(tg, nil).Once()
75+ if ok, err := r.Render(&out, &m, testView); !ok || err != nil {
76+ t.Fatalf("first render: ok=%v err=%v", ok, err)
77+ }
78+ if out.Buffer != 1 || out.Width != 100 || len(out.Damage) != 1 || out.Damage[0] != (Rect{Width: 100, Height: 50}) {
79+ t.Fatalf("output: %+v", out)
80+ }
81+ // Nothing changed: no rebuild result, so no Draw (the mock has no further expectation).
82+ if ok, err := r.Render(&out, &m, testView); ok || err != nil {
83+ t.Fatalf("unchanged view: ok=%v err=%v", ok, err)
84+ }
85+}
86+
87+func TestRendererKeepsFrameWhenNoBufferFree(t *testing.T) {
88+ r, tg := newTestRenderer(t)
89+ var m testModel
90+ var out Output
91+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
92+ r.Resize(100, 50, 1)
93+ tg.EXPECT().Draw(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Once()
94+ tg.EXPECT().Waiting().Return(true).Once()
95+ if ok, err := r.Render(&out, &m, testView); ok || err != nil {
96+ t.Fatalf("no buffer: ok=%v err=%v", ok, err)
97+ }
98+ if !r.Pending() {
99+ t.Fatal("a built frame blocked on the GPU must report Pending")
100+ }
101+ drawOK(tg, nil).Once()
102+ if ok, err := r.Render(&out, &m, testView); !ok || err != nil {
103+ t.Fatalf("retry: ok=%v err=%v", ok, err)
104+ }
105+}
106+
107+func TestRendererDrawError(t *testing.T) {
108+ r, tg := newTestRenderer(t)
109+ var m testModel
110+ var out Output
111+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
112+ r.Resize(100, 50, 1)
113+ boom := errors.New("boom")
114+ tg.EXPECT().Draw(mock.Anything, mock.Anything, mock.Anything).Return(false, boom).Once()
115+ if ok, err := r.Render(&out, &m, testView); ok || !errors.Is(err, boom) {
116+ t.Fatalf("ok=%v err=%v", ok, err)
117+ }
118+}
119+
120+func TestRendererResizeReachesTargetInPhysicalPixels(t *testing.T) {
121+ r, tg := newTestRenderer(t)
122+ tg.EXPECT().Resize(int32(150), int32(75)).Return().Once()
123+ r.Resize(100, 50, 1.5)
124+ r.Resize(100, 50, 1.5) // unchanged: no second call
125+ r.Resize(0, 50, 1) // invalid: ignored
126+ r.Resize(10, 10, 0) // invalid: ignored
127+}
128+
129+func TestRendererInputActivatesButtonAndRequestsRedraw(t *testing.T) {
130+ r, tg := newTestRenderer(t)
131+ var m testModel
132+ var out Output
133+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
134+ r.Resize(200, 100, 1)
135+ drawOK(tg, nil).Maybe()
136+ for i := 0; i < 3; i++ {
137+ if _, err := r.Render(&out, &m, testView); err != nil {
138+ t.Fatal(err)
139+ }
140+ }
141+ if r.Input(&Input{Kind: InputPointerMotion, X: 5, Y: 5}) != true {
142+ t.Fatal("hover over a button needs a redraw")
143+ }
144+ r.Input(&Input{Kind: InputPointerPress, X: 5, Y: 5, Button: 0x110, Pressed: true})
145+ if !r.Input(&Input{Kind: InputPointerRelease, X: 5, Y: 5, Button: 0x110}) {
146+ t.Fatal("release did not request redraw")
147+ }
148+ for i := 0; i < 3; i++ {
149+ if _, err := r.Render(&out, &m, testView); err != nil {
150+ t.Fatal(err)
151+ }
152+ }
153+ if m.clicks != 1 {
154+ t.Fatalf("clicks=%d", m.clicks)
155+ }
156+ // Keyboard: Tab focuses, Return activates, text comes from Text.
157+ r.Input(&Input{Kind: InputPointerLeave})
158+ r.Input(&Input{Kind: InputKey, Keysym: 0xff09, Pressed: true})
159+ r.Input(&Input{Kind: InputKey, Keysym: 0xff0d, Pressed: true})
160+ for i := 0; i < 3; i++ {
161+ if _, err := r.Render(&out, &m, testView); err != nil {
162+ t.Fatal(err)
163+ }
164+ }
165+ if m.clicks != 2 {
166+ t.Fatalf("Return did not activate the focused button: clicks=%d", m.clicks)
167+ }
168+ if r.Input(&Input{Kind: 99}) || r.Input(nil) {
169+ t.Fatal("unknown input requested a redraw")
170+ }
171+ if r.Input(&Input{Kind: InputPointerAxis}) {
172+ t.Fatal("zero scroll requested a redraw")
173+ }
174+}
175+
176+func TestRendererTextInputUsesTextBytes(t *testing.T) {
177+ r, tg := newTestRenderer(t)
178+ value := ""
179+ var out Output
180+ view := func(f *Frame, v *string) { f.Root().Input("name", v, Key("name"), Inline("width:100px;height:20px")) }
181+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
182+ r.Resize(200, 100, 1)
183+ drawOK(tg, nil).Maybe()
184+ settle := func() {
185+ for i := 0; i < 3; i++ {
186+ if _, err := r.Render(&out, &value, view); err != nil {
187+ t.Fatal(err)
188+ }
189+ }
190+ }
191+ settle()
192+ r.Input(&Input{Kind: InputPointerPress, X: 5, Y: 5, Button: 0x110, Pressed: true})
193+ r.Input(&Input{Kind: InputPointerRelease, X: 5, Y: 5, Button: 0x110})
194+ settle()
195+ text := []byte("é")
196+ r.Input(&Input{Kind: InputKey, Keysym: 0xe9, Pressed: true, Text: text})
197+ text[0] = 0 // the caller may reuse the buffer after the call
198+ settle()
199+ if value != "é" {
200+ t.Fatalf("value=%q", value)
201+ }
202+}
203+
204+func TestRendererOutputCarriesCursorAndInputRects(t *testing.T) {
205+ r, tg := newTestRenderer(t)
206+ m := testModel{rects: []Rect{{1, 2, 30, 20}}}
207+ var out Output
208+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
209+ r.Resize(200, 100, 1)
210+ drawOK(tg, nil).Maybe()
211+ r.Input(&Input{Kind: InputPointerMotion, X: 5, Y: 5}) // over the button: pointer cursor
212+ if ok, err := r.Render(&out, &m, testView); !ok || err != nil {
213+ t.Fatalf("ok=%v err=%v", ok, err)
214+ }
215+ if out.Cursor != CursorPointer || !out.InputRectsChanged || len(out.InputRects) != 1 || out.InputRects[0] != (Rect{1, 2, 30, 20}) {
216+ t.Fatalf("output: cursor=%v changed=%v rects=%v", out.Cursor, out.InputRectsChanged, out.InputRects)
217+ }
218+ // An unchanged region is not reported again.
219+ r.Invalidate()
220+ ok, err := r.Render(&out, &m, testView)
221+ if err != nil {
222+ t.Fatal(err)
223+ }
224+ if ok && out.InputRectsChanged && len(out.InputRects) != 0 {
225+ t.Fatalf("region reported twice: %+v", out)
226+ }
227+ // nil restores the whole surface: changed with no rects.
228+ m.rects = nil
229+ var nilView = func(f *Frame, _ *testModel) { f.Root(); _ = f.SetInputRects(nil) }
230+ r.Invalidate()
231+ if ok, err = r.Render(&out, &m, nilView); err != nil || !ok || !out.InputRectsChanged || out.InputRects != nil {
232+ t.Fatalf("nil region: ok=%v err=%v %+v", ok, err, out)
233+ }
234+}
235+
236+func TestRendererForwardsReleasedAndClose(t *testing.T) {
237+ r, tg := newTestRenderer(t)
238+ tg.EXPECT().Released(uint64(3)).Return(nil).Once()
239+ if err := r.Released(3); err != nil {
240+ t.Fatal(err)
241+ }
242+ tg.EXPECT().Close().Return(nil).Once()
243+ if err := r.Close(); err != nil {
244+ t.Fatal(err)
245+ }
246+ // After Close nothing reaches the target (the mock has no expectation).
247+ r.Resize(10, 10, 1)
248+ if r.Input(&Input{Kind: InputPointerMotion}) {
249+ t.Fatal("Input after Close requested a redraw")
250+ }
251+ if err := r.Released(3); err == nil {
252+ t.Fatal("Released after Close succeeded")
253+ }
254+ if err := r.Close(); err != nil { // second Close is a no-op
255+ t.Fatal(err)
256+ }
257+ var out Output
258+ var m testModel
259+ if _, err := r.Render(&out, &m, testView); err == nil {
260+ t.Fatal("Render after Close succeeded")
261+ }
262+}
263+
264+func TestTextIntAndMasked(t *testing.T) {
265+ r := newRuntime()
266+ r.Build(func(f *Frame) {
267+ root := f.Root()
268+ root.TextInt("Count: ", -42)
269+ root.Masked(3)
270+ root.Masked(0)
271+ root.Masked(300)
272+ })
273+ c := r.committed.children
274+ if len(c) != 4 || c[0].text != "Count: -42" || c[1].text != "•••" || c[2].text != "" || len([]rune(c[3].text)) != 300 {
275+ t.Fatalf("texts: %q %q %q", c[0].text, c[1].text, c[2].text)
276+ }
277+}
A internal/ui/target_mock_test.go +304 -0
@@ -0,0 +1,304 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package ui
6+
7+import (
8+ "github.com/bnema/nefergui/internal/layout"
9+ "github.com/bnema/nefergui/internal/presentation/session"
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMocktarget creates a new instance of mocktarget. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func newMocktarget(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mocktarget {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mocktarget{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// mocktarget is an autogenerated mock type for the target type
37+type mocktarget struct {
38+ mock.Mock
39+}
40+
41+type mocktarget_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mocktarget) EXPECT() *mocktarget_Expecter {
46+ return &mocktarget_Expecter{mock: &_m.Mock}
47+}
48+
49+// Close provides a mock function for the type mocktarget
50+func (_mock *mocktarget) Close() error {
51+ ret := _mock.Called()
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for Close")
55+ }
56+
57+ var r0 error
58+ if returnFunc, ok := ret.Get(0).(func() error); ok {
59+ r0 = returnFunc()
60+ } else {
61+ r0 = ret.Error(0)
62+ }
63+ return r0
64+}
65+
66+// mocktarget_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
67+type mocktarget_Close_Call struct {
68+ *mock.Call
69+}
70+
71+// Close is a helper method to define mock.On call
72+func (_e *mocktarget_Expecter) Close() *mocktarget_Close_Call {
73+ return &mocktarget_Close_Call{Call: _e.mock.On("Close")}
74+}
75+
76+func (_c *mocktarget_Close_Call) Run(run func()) *mocktarget_Close_Call {
77+ _c.Call.Run(func(args mock.Arguments) {
78+ run()
79+ })
80+ return _c
81+}
82+
83+func (_c *mocktarget_Close_Call) Return(err error) *mocktarget_Close_Call {
84+ _c.Call.Return(err)
85+ return _c
86+}
87+
88+func (_c *mocktarget_Close_Call) RunAndReturn(run func() error) *mocktarget_Close_Call {
89+ _c.Call.Return(run)
90+ return _c
91+}
92+
93+// Draw provides a mock function for the type mocktarget
94+func (_mock *mocktarget) Draw(display []layout.Command, scale float64, out *session.Output) (bool, error) {
95+ ret := _mock.Called(display, scale, out)
96+
97+ if len(ret) == 0 {
98+ panic("no return value specified for Draw")
99+ }
100+
101+ var r0 bool
102+ var r1 error
103+ if returnFunc, ok := ret.Get(0).(func([]layout.Command, float64, *session.Output) (bool, error)); ok {
104+ return returnFunc(display, scale, out)
105+ }
106+ if returnFunc, ok := ret.Get(0).(func([]layout.Command, float64, *session.Output) bool); ok {
107+ r0 = returnFunc(display, scale, out)
108+ } else {
109+ r0 = ret.Get(0).(bool)
110+ }
111+ if returnFunc, ok := ret.Get(1).(func([]layout.Command, float64, *session.Output) error); ok {
112+ r1 = returnFunc(display, scale, out)
113+ } else {
114+ r1 = ret.Error(1)
115+ }
116+ return r0, r1
117+}
118+
119+// mocktarget_Draw_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Draw'
120+type mocktarget_Draw_Call struct {
121+ *mock.Call
122+}
123+
124+// Draw is a helper method to define mock.On call
125+// - display []layout.Command
126+// - scale float64
127+// - out *session.Output
128+func (_e *mocktarget_Expecter) Draw(display any, scale any, out any) *mocktarget_Draw_Call {
129+ return &mocktarget_Draw_Call{Call: _e.mock.On("Draw", display, scale, out)}
130+}
131+
132+func (_c *mocktarget_Draw_Call) Run(run func(display []layout.Command, scale float64, out *session.Output)) *mocktarget_Draw_Call {
133+ _c.Call.Run(func(args mock.Arguments) {
134+ var arg0 []layout.Command
135+ if args[0] != nil {
136+ arg0 = args[0].([]layout.Command)
137+ }
138+ var arg1 float64
139+ if args[1] != nil {
140+ arg1 = args[1].(float64)
141+ }
142+ var arg2 *session.Output
143+ if args[2] != nil {
144+ arg2 = args[2].(*session.Output)
145+ }
146+ run(
147+ arg0,
148+ arg1,
149+ arg2,
150+ )
151+ })
152+ return _c
153+}
154+
155+func (_c *mocktarget_Draw_Call) Return(b bool, err error) *mocktarget_Draw_Call {
156+ _c.Call.Return(b, err)
157+ return _c
158+}
159+
160+func (_c *mocktarget_Draw_Call) RunAndReturn(run func(display []layout.Command, scale float64, out *session.Output) (bool, error)) *mocktarget_Draw_Call {
161+ _c.Call.Return(run)
162+ return _c
163+}
164+
165+// Released provides a mock function for the type mocktarget
166+func (_mock *mocktarget) Released(buffer uint64) error {
167+ ret := _mock.Called(buffer)
168+
169+ if len(ret) == 0 {
170+ panic("no return value specified for Released")
171+ }
172+
173+ var r0 error
174+ if returnFunc, ok := ret.Get(0).(func(uint64) error); ok {
175+ r0 = returnFunc(buffer)
176+ } else {
177+ r0 = ret.Error(0)
178+ }
179+ return r0
180+}
181+
182+// mocktarget_Released_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Released'
183+type mocktarget_Released_Call struct {
184+ *mock.Call
185+}
186+
187+// Released is a helper method to define mock.On call
188+// - buffer uint64
189+func (_e *mocktarget_Expecter) Released(buffer any) *mocktarget_Released_Call {
190+ return &mocktarget_Released_Call{Call: _e.mock.On("Released", buffer)}
191+}
192+
193+func (_c *mocktarget_Released_Call) Run(run func(buffer uint64)) *mocktarget_Released_Call {
194+ _c.Call.Run(func(args mock.Arguments) {
195+ var arg0 uint64
196+ if args[0] != nil {
197+ arg0 = args[0].(uint64)
198+ }
199+ run(
200+ arg0,
201+ )
202+ })
203+ return _c
204+}
205+
206+func (_c *mocktarget_Released_Call) Return(err error) *mocktarget_Released_Call {
207+ _c.Call.Return(err)
208+ return _c
209+}
210+
211+func (_c *mocktarget_Released_Call) RunAndReturn(run func(buffer uint64) error) *mocktarget_Released_Call {
212+ _c.Call.Return(run)
213+ return _c
214+}
215+
216+// Resize provides a mock function for the type mocktarget
217+func (_mock *mocktarget) Resize(width int32, height int32) {
218+ _mock.Called(width, height)
219+ return
220+}
221+
222+// mocktarget_Resize_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resize'
223+type mocktarget_Resize_Call struct {
224+ *mock.Call
225+}
226+
227+// Resize is a helper method to define mock.On call
228+// - width int32
229+// - height int32
230+func (_e *mocktarget_Expecter) Resize(width any, height any) *mocktarget_Resize_Call {
231+ return &mocktarget_Resize_Call{Call: _e.mock.On("Resize", width, height)}
232+}
233+
234+func (_c *mocktarget_Resize_Call) Run(run func(width int32, height int32)) *mocktarget_Resize_Call {
235+ _c.Call.Run(func(args mock.Arguments) {
236+ var arg0 int32
237+ if args[0] != nil {
238+ arg0 = args[0].(int32)
239+ }
240+ var arg1 int32
241+ if args[1] != nil {
242+ arg1 = args[1].(int32)
243+ }
244+ run(
245+ arg0,
246+ arg1,
247+ )
248+ })
249+ return _c
250+}
251+
252+func (_c *mocktarget_Resize_Call) Return() *mocktarget_Resize_Call {
253+ _c.Call.Return()
254+ return _c
255+}
256+
257+func (_c *mocktarget_Resize_Call) RunAndReturn(run func(width int32, height int32)) *mocktarget_Resize_Call {
258+ _c.Run(run)
259+ return _c
260+}
261+
262+// Waiting provides a mock function for the type mocktarget
263+func (_mock *mocktarget) Waiting() bool {
264+ ret := _mock.Called()
265+
266+ if len(ret) == 0 {
267+ panic("no return value specified for Waiting")
268+ }
269+
270+ var r0 bool
271+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
272+ r0 = returnFunc()
273+ } else {
274+ r0 = ret.Get(0).(bool)
275+ }
276+ return r0
277+}
278+
279+// mocktarget_Waiting_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Waiting'
280+type mocktarget_Waiting_Call struct {
281+ *mock.Call
282+}
283+
284+// Waiting is a helper method to define mock.On call
285+func (_e *mocktarget_Expecter) Waiting() *mocktarget_Waiting_Call {
286+ return &mocktarget_Waiting_Call{Call: _e.mock.On("Waiting")}
287+}
288+
289+func (_c *mocktarget_Waiting_Call) Run(run func()) *mocktarget_Waiting_Call {
290+ _c.Call.Run(func(args mock.Arguments) {
291+ run()
292+ })
293+ return _c
294+}
295+
296+func (_c *mocktarget_Waiting_Call) Return(b bool) *mocktarget_Waiting_Call {
297+ _c.Call.Return(b)
298+ return _c
299+}
300+
301+func (_c *mocktarget_Waiting_Call) RunAndReturn(run func() bool) *mocktarget_Waiting_Call {
302+ _c.Call.Return(run)
303+ return _c
304+}
@@ -0,0 +1,304 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package ui
6+
7+import (
8+ "github.com/bnema/nefergui/internal/layout"
9+ "github.com/bnema/nefergui/internal/presentation/session"
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMocktarget creates a new instance of mocktarget. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func newMocktarget(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mocktarget {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mocktarget{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// mocktarget is an autogenerated mock type for the target type
37+type mocktarget struct {
38+ mock.Mock
39+}
40+
41+type mocktarget_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mocktarget) EXPECT() *mocktarget_Expecter {
46+ return &mocktarget_Expecter{mock: &_m.Mock}
47+}
48+
49+// Close provides a mock function for the type mocktarget
50+func (_mock *mocktarget) Close() error {
51+ ret := _mock.Called()
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for Close")
55+ }
56+
57+ var r0 error
58+ if returnFunc, ok := ret.Get(0).(func() error); ok {
59+ r0 = returnFunc()
60+ } else {
61+ r0 = ret.Error(0)
62+ }
63+ return r0
64+}
65+
66+// mocktarget_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
67+type mocktarget_Close_Call struct {
68+ *mock.Call
69+}
70+
71+// Close is a helper method to define mock.On call
72+func (_e *mocktarget_Expecter) Close() *mocktarget_Close_Call {
73+ return &mocktarget_Close_Call{Call: _e.mock.On("Close")}
74+}
75+
76+func (_c *mocktarget_Close_Call) Run(run func()) *mocktarget_Close_Call {
77+ _c.Call.Run(func(args mock.Arguments) {
78+ run()
79+ })
80+ return _c
81+}
82+
83+func (_c *mocktarget_Close_Call) Return(err error) *mocktarget_Close_Call {
84+ _c.Call.Return(err)
85+ return _c
86+}
87+
88+func (_c *mocktarget_Close_Call) RunAndReturn(run func() error) *mocktarget_Close_Call {
89+ _c.Call.Return(run)
90+ return _c
91+}
92+
93+// Draw provides a mock function for the type mocktarget
94+func (_mock *mocktarget) Draw(display []layout.Command, scale float64, out *session.Output) (bool, error) {
95+ ret := _mock.Called(display, scale, out)
96+
97+ if len(ret) == 0 {
98+ panic("no return value specified for Draw")
99+ }
100+
101+ var r0 bool
102+ var r1 error
103+ if returnFunc, ok := ret.Get(0).(func([]layout.Command, float64, *session.Output) (bool, error)); ok {
104+ return returnFunc(display, scale, out)
105+ }
106+ if returnFunc, ok := ret.Get(0).(func([]layout.Command, float64, *session.Output) bool); ok {
107+ r0 = returnFunc(display, scale, out)
108+ } else {
109+ r0 = ret.Get(0).(bool)
110+ }
111+ if returnFunc, ok := ret.Get(1).(func([]layout.Command, float64, *session.Output) error); ok {
112+ r1 = returnFunc(display, scale, out)
113+ } else {
114+ r1 = ret.Error(1)
115+ }
116+ return r0, r1
117+}
118+
119+// mocktarget_Draw_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Draw'
120+type mocktarget_Draw_Call struct {
121+ *mock.Call
122+}
123+
124+// Draw is a helper method to define mock.On call
125+// - display []layout.Command
126+// - scale float64
127+// - out *session.Output
128+func (_e *mocktarget_Expecter) Draw(display any, scale any, out any) *mocktarget_Draw_Call {
129+ return &mocktarget_Draw_Call{Call: _e.mock.On("Draw", display, scale, out)}
130+}
131+
132+func (_c *mocktarget_Draw_Call) Run(run func(display []layout.Command, scale float64, out *session.Output)) *mocktarget_Draw_Call {
133+ _c.Call.Run(func(args mock.Arguments) {
134+ var arg0 []layout.Command
135+ if args[0] != nil {
136+ arg0 = args[0].([]layout.Command)
137+ }
138+ var arg1 float64
139+ if args[1] != nil {
140+ arg1 = args[1].(float64)
141+ }
142+ var arg2 *session.Output
143+ if args[2] != nil {
144+ arg2 = args[2].(*session.Output)
145+ }
146+ run(
147+ arg0,
148+ arg1,
149+ arg2,
150+ )
151+ })
152+ return _c
153+}
154+
155+func (_c *mocktarget_Draw_Call) Return(b bool, err error) *mocktarget_Draw_Call {
156+ _c.Call.Return(b, err)
157+ return _c
158+}
159+
160+func (_c *mocktarget_Draw_Call) RunAndReturn(run func(display []layout.Command, scale float64, out *session.Output) (bool, error)) *mocktarget_Draw_Call {
161+ _c.Call.Return(run)
162+ return _c
163+}
164+
165+// Released provides a mock function for the type mocktarget
166+func (_mock *mocktarget) Released(buffer uint64) error {
167+ ret := _mock.Called(buffer)
168+
169+ if len(ret) == 0 {
170+ panic("no return value specified for Released")
171+ }
172+
173+ var r0 error
174+ if returnFunc, ok := ret.Get(0).(func(uint64) error); ok {
175+ r0 = returnFunc(buffer)
176+ } else {
177+ r0 = ret.Error(0)
178+ }
179+ return r0
180+}
181+
182+// mocktarget_Released_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Released'
183+type mocktarget_Released_Call struct {
184+ *mock.Call
185+}
186+
187+// Released is a helper method to define mock.On call
188+// - buffer uint64
189+func (_e *mocktarget_Expecter) Released(buffer any) *mocktarget_Released_Call {
190+ return &mocktarget_Released_Call{Call: _e.mock.On("Released", buffer)}
191+}
192+
193+func (_c *mocktarget_Released_Call) Run(run func(buffer uint64)) *mocktarget_Released_Call {
194+ _c.Call.Run(func(args mock.Arguments) {
195+ var arg0 uint64
196+ if args[0] != nil {
197+ arg0 = args[0].(uint64)
198+ }
199+ run(
200+ arg0,
201+ )
202+ })
203+ return _c
204+}
205+
206+func (_c *mocktarget_Released_Call) Return(err error) *mocktarget_Released_Call {
207+ _c.Call.Return(err)
208+ return _c
209+}
210+
211+func (_c *mocktarget_Released_Call) RunAndReturn(run func(buffer uint64) error) *mocktarget_Released_Call {
212+ _c.Call.Return(run)
213+ return _c
214+}
215+
216+// Resize provides a mock function for the type mocktarget
217+func (_mock *mocktarget) Resize(width int32, height int32) {
218+ _mock.Called(width, height)
219+ return
220+}
221+
222+// mocktarget_Resize_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resize'
223+type mocktarget_Resize_Call struct {
224+ *mock.Call
225+}
226+
227+// Resize is a helper method to define mock.On call
228+// - width int32
229+// - height int32
230+func (_e *mocktarget_Expecter) Resize(width any, height any) *mocktarget_Resize_Call {
231+ return &mocktarget_Resize_Call{Call: _e.mock.On("Resize", width, height)}
232+}
233+
234+func (_c *mocktarget_Resize_Call) Run(run func(width int32, height int32)) *mocktarget_Resize_Call {
235+ _c.Call.Run(func(args mock.Arguments) {
236+ var arg0 int32
237+ if args[0] != nil {
238+ arg0 = args[0].(int32)
239+ }
240+ var arg1 int32
241+ if args[1] != nil {
242+ arg1 = args[1].(int32)
243+ }
244+ run(
245+ arg0,
246+ arg1,
247+ )
248+ })
249+ return _c
250+}
251+
252+func (_c *mocktarget_Resize_Call) Return() *mocktarget_Resize_Call {
253+ _c.Call.Return()
254+ return _c
255+}
256+
257+func (_c *mocktarget_Resize_Call) RunAndReturn(run func(width int32, height int32)) *mocktarget_Resize_Call {
258+ _c.Run(run)
259+ return _c
260+}
261+
262+// Waiting provides a mock function for the type mocktarget
263+func (_mock *mocktarget) Waiting() bool {
264+ ret := _mock.Called()
265+
266+ if len(ret) == 0 {
267+ panic("no return value specified for Waiting")
268+ }
269+
270+ var r0 bool
271+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
272+ r0 = returnFunc()
273+ } else {
274+ r0 = ret.Get(0).(bool)
275+ }
276+ return r0
277+}
278+
279+// mocktarget_Waiting_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Waiting'
280+type mocktarget_Waiting_Call struct {
281+ *mock.Call
282+}
283+
284+// Waiting is a helper method to define mock.On call
285+func (_e *mocktarget_Expecter) Waiting() *mocktarget_Waiting_Call {
286+ return &mocktarget_Waiting_Call{Call: _e.mock.On("Waiting")}
287+}
288+
289+func (_c *mocktarget_Waiting_Call) Run(run func()) *mocktarget_Waiting_Call {
290+ _c.Call.Run(func(args mock.Arguments) {
291+ run()
292+ })
293+ return _c
294+}
295+
296+func (_c *mocktarget_Waiting_Call) Return(b bool) *mocktarget_Waiting_Call {
297+ _c.Call.Return(b)
298+ return _c
299+}
300+
301+func (_c *mocktarget_Waiting_Call) RunAndReturn(run func() bool) *mocktarget_Waiting_Call {
302+ _c.Call.Return(run)
303+ return _c
304+}
A renderer.go +66 -0
@@ -0,0 +1,66 @@
1+//go:build linux
2+
3+package nefergui
4+
5+import "github.com/bnema/nefergui/internal/ui"
6+
7+// Renderer renders views into DMA-BUF buffers for a Wayland client the
8+// application owns. It imports no Wayland library, starts no goroutine and is
9+// used from one owner goroutine. Typical loop: forward Wayland events with
10+// Input and Resize, call Render when a redraw is needed, import and present
11+// the Output buffer, and call Released when its ReleaseFD becomes readable.
12+//
13+// Pending() reports a built frame waiting for the GPU rather than the
14+// compositor; arm a short timer (about 2 ms) and call Render again.
15+//
16+// Render is generic over the model type, so it is a generic method:
17+//
18+// ok, err := r.Render(&out, &model, view)
19+type Renderer = ui.Renderer
20+
21+// RendererConfig configures NewRenderer. MainDevice and Formats come from the
22+// compositor's linux-dmabuf feedback.
23+type RendererConfig = ui.RendererConfig
24+
25+// Format is a DRM format and modifier the compositor accepts.
26+type Format = ui.Format
27+
28+// Input is one platform input event; Text is valid only during Renderer.Input.
29+type Input = ui.Input
30+
31+// Output describes one frame to present, in physical pixels. All file
32+// descriptors stay owned by the Renderer: duplicate them before handing them to
33+// a transport that consumes descriptors. Slices are valid until the next Render.
34+type Output = ui.Output
35+
36+// Plane, Timeline and Retired are parts of Output. A Timeline FD is valid only
37+// when Output.NewTimelines is set. The client must stop watching and destroy
38+// each Retired buffer before the next Render.
39+type (
40+ Plane = ui.Plane
41+ Timeline = ui.Timeline
42+ Retired = ui.Retired
43+)
44+
45+// Clipboard is an optional asynchronous clipboard; IME an optional input method.
46+type (
47+ Clipboard = ui.Clipboard
48+ IME = ui.IME
49+)
50+
51+// Cursor is the pointer shape the surface wants.
52+type Cursor = ui.Cursor
53+
54+const (
55+ CursorDefault = ui.CursorDefault
56+ CursorPointer = ui.CursorPointer
57+ CursorText = ui.CursorText
58+ CursorNotAllowed = ui.CursorNotAllowed
59+
60+ ModAlt = ui.ModAlt
61+ ModSuper = ui.ModSuper
62+)
63+
64+// NewRenderer opens the GPU named by cfg.MainDevice and loads fonts and
65+// styles. It allocates no images.
66+func NewRenderer(cfg RendererConfig) (*Renderer, error) { return ui.NewRenderer(cfg) }
@@ -0,0 +1,66 @@
1+//go:build linux
2+
3+package nefergui
4+
5+import "github.com/bnema/nefergui/internal/ui"
6+
7+// Renderer renders views into DMA-BUF buffers for a Wayland client the
8+// application owns. It imports no Wayland library, starts no goroutine and is
9+// used from one owner goroutine. Typical loop: forward Wayland events with
10+// Input and Resize, call Render when a redraw is needed, import and present
11+// the Output buffer, and call Released when its ReleaseFD becomes readable.
12+//
13+// Pending() reports a built frame waiting for the GPU rather than the
14+// compositor; arm a short timer (about 2 ms) and call Render again.
15+//
16+// Render is generic over the model type, so it is a generic method:
17+//
18+// ok, err := r.Render(&out, &model, view)
19+type Renderer = ui.Renderer
20+
21+// RendererConfig configures NewRenderer. MainDevice and Formats come from the
22+// compositor's linux-dmabuf feedback.
23+type RendererConfig = ui.RendererConfig
24+
25+// Format is a DRM format and modifier the compositor accepts.
26+type Format = ui.Format
27+
28+// Input is one platform input event; Text is valid only during Renderer.Input.
29+type Input = ui.Input
30+
31+// Output describes one frame to present, in physical pixels. All file
32+// descriptors stay owned by the Renderer: duplicate them before handing them to
33+// a transport that consumes descriptors. Slices are valid until the next Render.
34+type Output = ui.Output
35+
36+// Plane, Timeline and Retired are parts of Output. A Timeline FD is valid only
37+// when Output.NewTimelines is set. The client must stop watching and destroy
38+// each Retired buffer before the next Render.
39+type (
40+ Plane = ui.Plane
41+ Timeline = ui.Timeline
42+ Retired = ui.Retired
43+)
44+
45+// Clipboard is an optional asynchronous clipboard; IME an optional input method.
46+type (
47+ Clipboard = ui.Clipboard
48+ IME = ui.IME
49+)
50+
51+// Cursor is the pointer shape the surface wants.
52+type Cursor = ui.Cursor
53+
54+const (
55+ CursorDefault = ui.CursorDefault
56+ CursorPointer = ui.CursorPointer
57+ CursorText = ui.CursorText
58+ CursorNotAllowed = ui.CursorNotAllowed
59+
60+ ModAlt = ui.ModAlt
61+ ModSuper = ui.ModSuper
62+)
63+
64+// NewRenderer opens the GPU named by cfg.MainDevice and loads fonts and
65+// styles. It allocates no images.
66+func NewRenderer(cfg RendererConfig) (*Renderer, error) { return ui.NewRenderer(cfg) }

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 1

Tags 12

↑↓ moveenter openesc close