Merge branch 'wf/renderer': Wayland-free Renderer
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 ( | |||
| 8 | 8 | github.com/bnema/purego-xkbcommon v0.1.0 | |
| 9 | 9 | github.com/bnema/wlturbo v0.5.0 | |
| 10 | 10 | github.com/go-text/typesetting v0.3.5 | |
| 11 | + | github.com/stretchr/testify v1.12.1 | |
| 11 | 12 | golang.org/x/image v0.46.0 | |
| 12 | 13 | golang.org/x/sys v0.48.0 | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 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.0 | 8 | github.com/bnema/purego-xkbcommon v0.1.0 | ||
| 9 | github.com/bnema/wlturbo v0.5.0 | 9 | github.com/bnema/wlturbo v0.5.0 | ||
| 10 | github.com/go-text/typesetting v0.3.5 | 10 | github.com/go-text/typesetting v0.3.5 | ||
| 11 | + | github.com/stretchr/testify v1.12.1 | |||
| 11 | golang.org/x/image v0.46.0 | 12 | golang.org/x/image v0.46.0 | ||
| 12 | golang.org/x/sys v0.48.0 | 13 | golang.org/x/sys v0.48.0 | ||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| 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 | + | ) | |||
M go.sum +6 -0 generated file: collapsed
M internal/presentation/session/session.go +57 -8
| @@ -24,6 +24,7 @@ import ( | |||
| 24 | 24 | "github.com/bnema/nefergui/internal/presentation/vkdevice" | |
| 25 | 25 | "github.com/bnema/nefergui/internal/render" | |
| 26 | 26 | "github.com/bnema/nefergui/internal/text" | |
| 27 | + | "github.com/bnema/wlturbo/protocol/core" | |
| 27 | 28 | "github.com/bnema/wlturbo/protocol/drmsyncobj" | |
| 28 | 29 | "golang.org/x/sys/unix" | |
| 29 | 30 | ) | |
| @@ -40,6 +41,13 @@ type Slot struct { | |||
| 40 | 41 | ReadbackPending bool | |
| 41 | 42 | ReleaseHandle uint32 | |
| 42 | 43 | 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 | 52 | type Session struct { | |
| 45 | 53 | Window *wayland.Window | |
| @@ -259,7 +267,13 @@ func (s *Session) newGeneration() error { | |||
| 259 | 267 | if err != nil { | |
| 260 | 268 | 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 | 277 | if err != nil { | |
| 264 | 278 | return err | |
| 265 | 279 | } | |
| @@ -286,7 +300,7 @@ func (s *Session) newGeneration() error { | |||
| 286 | 300 | if b.Generation != s.Pool.Generation { | |
| 287 | 301 | 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 | 304 | if e != nil { | |
| 291 | 305 | return e | |
| 292 | 306 | } | |
| @@ -302,25 +316,60 @@ func (s *Session) newGeneration() error { | |||
| 302 | 316 | return e | |
| 303 | 317 | } | |
| 304 | 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 | 324 | slot.ReleaseHandle, e = s.Node.Create() | |
| 306 | 325 | if e != nil { | |
| 326 | + | slot.closeImage() | |
| 307 | 327 | frame.Close() | |
| 308 | - | img.Close() | |
| 309 | 328 | return e | |
| 310 | 329 | } | |
| 311 | 330 | slot.ReleaseTimeline, e = s.importTimeline(slot.ReleaseHandle) | |
| 312 | 331 | if e != nil { | |
| 313 | 332 | _ = s.Node.Destroy(slot.ReleaseHandle) | |
| 333 | + | slot.closeImage() | |
| 314 | 334 | frame.Close() | |
| 315 | - | img.Close() | |
| 316 | 335 | return e | |
| 317 | 336 | } | |
| 318 | 337 | 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 | 340 | 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 | 373 | // beginReady does not change Pool ownership until the previous GPU submission | |
| 325 | 374 | // has completed. An unready available buffer is retried on a bounded wakeup. | |
| 326 | 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 | 504 | if err != nil { | |
| 456 | 505 | 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 | 508 | return false, err | |
| 460 | 509 | } | |
| 461 | 510 | s.traceState(b, "committed") | |
| @@ -670,7 +719,7 @@ func (s *Session) retire() error { | |||
| 670 | 719 | if slot.Wait != nil { | |
| 671 | 720 | slot.Wait.Close() | |
| 672 | 721 | } | |
| 673 | - | slot.Image.Close() | |
| 722 | + | slot.closeImage() | |
| 674 | 723 | _ = slot.ReleaseTimeline.Destroy() | |
| 675 | 724 | _ = s.Node.Destroy(slot.ReleaseHandle) | |
| 676 | 725 | s.traceState(b, "retired") | |
| @@ -898,7 +947,7 @@ stopWaiters: | |||
| 898 | 947 | } | |
| 899 | 948 | // The exported DMA-BUF remains alive in the compositor independently. | |
| 900 | 949 | if slot.Image != nil { | |
| 901 | - | slot.Image.Close() | |
| 950 | + | slot.closeImage() | |
| 902 | 951 | } | |
| 903 | 952 | if s.Node != nil && slot.ReleaseHandle != 0 { | |
| 904 | 953 | _ = 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 bool | 41 | ReadbackPending bool | ||
| 41 | ReleaseHandle uint32 | 42 | ReleaseHandle uint32 | ||
| 42 | ReleaseTimeline *drmsyncobj.WpLinuxDrmSyncobjTimeline | 43 | 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.Window | 53 | Window *wayland.Window | ||
| @@ -259,7 +267,13 @@ func (s *Session) newGeneration() error { | |||||
| 259 | if err != nil { | 267 | if err != nil { | ||
| 260 | return err | 268 | 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 err | 278 | 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 | continue | 301 | 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 e | 305 | return e | ||
| 292 | } | 306 | } | ||
| @@ -302,25 +316,60 @@ func (s *Session) newGeneration() error { | |||||
| 302 | return e | 316 | 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 e | 328 | 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 e | 335 | return e | ||
| 317 | } | 336 | } | ||
| 318 | s.Slots[b.ID] = slot | 337 | 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 nil | 340 | 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 submission | 373 | // 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, err | 505 | 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, err | 508 | 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 @@ | |||
| 3 | 3 | package session | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "errors" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 9 | + | "github.com/bnema/nefergui/internal/presentation/buffers" | |
| 8 | 10 | "github.com/bnema/nefergui/internal/presentation/vkdevice" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| @@ -24,3 +26,31 @@ func TestInstallReleaseWaitDefersUnreadySubmission(t *testing.T) { | |||
| 24 | 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 | + | } | |
| @@ -3,8 +3,10 @@ | |||||
| 3 | package session | 3 | 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 { | |||
| 41 | 41 | 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 | 53 | const ( | |
| 45 | 54 | ioctlCreate = uintptr(0xc00864bf) | |
| 46 | 55 | ioctlDestroy = uintptr(0xc00864c0) | |
| @@ -49,6 +58,8 @@ const ( | |||
| 49 | 58 | ioctlTimelineWait = uintptr(0xc03064ca) | |
| 50 | 59 | ioctlQuery = uintptr(0xc01864cb) | |
| 51 | 60 | ioctlTransfer = uintptr(0xc02064cc) | |
| 61 | + | ioctlTimelineSig = uintptr(0xc01864cd) | |
| 62 | + | ioctlEventFD = uintptr(0xc01864cf) | |
| 52 | 63 | importSyncFile = 1 | |
| 53 | 64 | exportSyncFile = 1 | |
| 54 | 65 | ) | |
| @@ -118,6 +129,30 @@ func (n *Node) ExportTimeline(handle uint32) (int, error) { | |||
| 118 | 129 | 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 | 156 | // ImportFence consumes syncFile only after FD_TO_HANDLE imports it into a | |
| 122 | 157 | // pre-created destination syncobj. With IMPORT_SYNC_FILE, Handle is an input, | |
| 123 | 158 | // not an output: asking the kernel to import into handle zero returns ENOENT. | |
| @@ -41,6 +41,15 @@ type queryArg struct { | |||||
| 41 | Count, Flags uint32 | 41 | 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 = 1 | 63 | importSyncFile = 1 | ||
| 53 | exportSyncFile = 1 | 64 | exportSyncFile = 1 | ||
| 54 | ) | 65 | ) | ||
| @@ -118,6 +129,30 @@ func (n *Node) ExportTimeline(handle uint32) (int, error) { | |||||
| 118 | return int(a.FD), nil | 129 | 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 a | 156 | // 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) { | |||
| 22 | 22 | {"transfer", unsafe.Sizeof(transferArg{}), 32}, | |
| 23 | 23 | {"wait", unsafe.Sizeof(timelineWaitArg{}), 48}, | |
| 24 | 24 | {"query", unsafe.Sizeof(queryArg{}), 24}, | |
| 25 | + | {"eventfd", unsafe.Sizeof(eventFDArg{}), 24}, | |
| 25 | 26 | } { | |
| 26 | 27 | if tc.got != tc.want { | |
| 27 | 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 | 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 | + | } | |
| @@ -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 ( | |||
| 8 | 8 | "unsafe" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/purego-vulkan/vulkan" | |
| 11 | - | "github.com/bnema/wlturbo/protocol/core" | |
| 12 | - | "github.com/bnema/wlturbo/protocol/linuxdmabuf" | |
| 13 | 11 | "golang.org/x/sys/unix" | |
| 14 | 12 | ) | |
| 15 | 13 | ||
| 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. | |
| 19 | 19 | type Image struct { | |
| 20 | 20 | Image vulkan.Image | |
| 21 | 21 | Memory vulkan.DeviceMemory | |
| 22 | - | Buffer *core.Buffer | |
| 22 | + | FD int // exported DMA-BUF, -1 when none | |
| 23 | 23 | Modifier uint64 | |
| 24 | 24 | Offset, Stride uint32 | |
| 25 | 25 | device *Device | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 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") | |
| 31 | 31 | } | |
| 32 | - | img = &Image{device: d} | |
| 32 | + | img = &Image{device: d, FD: -1} | |
| 33 | 33 | defer func() { | |
| 34 | 34 | if err != nil { | |
| 35 | 35 | img.Close() | |
| @@ -88,27 +88,7 @@ func (d *Device) AllocateImage(width, height int32, selected Modifier, dmabuf *l | |||
| 88 | 88 | if err = vulkan.Check(d.Dispatch.GetMemoryFdKHR(d.Logical, &fdInfo, &fd)); err != nil { | |
| 89 | 89 | return nil, fmt.Errorf("export DMA-BUF: %w", err) | |
| 90 | 90 | } | |
| 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) | |
| 112 | 92 | return img, nil | |
| 113 | 93 | } | |
| 114 | 94 | ||
| @@ -116,9 +96,9 @@ func (img *Image) Close() { | |||
| 116 | 96 | if img == nil || img.device == nil { | |
| 117 | 97 | return | |
| 118 | 98 | } | |
| 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 | |
| 122 | 102 | } | |
| 123 | 103 | if img.Image != 0 { | |
| 124 | 104 | 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's | 15 | + | // 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.Image | 20 | Image vulkan.Image | ||
| 21 | Memory vulkan.DeviceMemory | 21 | Memory vulkan.DeviceMemory | ||
| 22 | - | Buffer *core.Buffer | 22 | + | FD int // exported DMA-BUF, -1 when none |
| 23 | Modifier uint64 | 23 | Modifier uint64 | ||
| 24 | Offset, Stride uint32 | 24 | Offset, Stride uint32 | ||
| 25 | device *Device | 25 | 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 a | 91 | + | 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, nil | 92 | 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 | return | 97 | 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 = nil | 101 | + | 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 ( | |||
| 7 | 7 | "unsafe" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/purego-vulkan/vulkan" | |
| 10 | - | "github.com/bnema/wlturbo/protocol/linuxdmabuf" | |
| 11 | 10 | ) | |
| 12 | 11 | ||
| 13 | 12 | // Modifier is the one-plane GPU/compositor common image layout chosen in | |
| @@ -22,16 +21,22 @@ const ( | |||
| 22 | 21 | 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 | 33 | format := XRGB8888 | |
| 27 | 34 | if transparent { | |
| 28 | 35 | format = ARGB8888 | |
| 29 | 36 | } | |
| 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 | |
| 35 | 40 | } | |
| 36 | 41 | } | |
| 37 | 42 | 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 in | 12 | // Modifier is the one-plane GPU/compositor common image layout chosen in | ||
| @@ -22,16 +21,22 @@ const ( | |||||
| 22 | ARGB8888 uint32 = 0x34325241 | 21 | 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 := XRGB8888 | 33 | format := XRGB8888 | ||
| 27 | if transparent { | 34 | if transparent { | ||
| 28 | format = ARGB8888 | 35 | 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 @@ | |||
| 3 | 3 | package vkdevice | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | - | "github.com/bnema/wlturbo/protocol/linuxdmabuf" | |
| 7 | 6 | "golang.org/x/sys/unix" | |
| 8 | 7 | "os" | |
| 9 | 8 | "testing" | |
| 10 | 9 | ) | |
| 11 | 10 | ||
| 12 | 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 | 13 | for _, tc := range []struct { | |
| 15 | 14 | alpha bool | |
| 16 | 15 | available map[uint64]bool | |
| @@ -3,14 +3,13 @@ | |||||
| 3 | package vkdevice | 3 | 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 bool | 14 | alpha bool | ||
| 16 | available map[uint64]bool | 15 | available map[uint64]bool | ||
M internal/ui/controls.go +33 -0
| @@ -3,6 +3,7 @@ package ui | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "github.com/bnema/nefergui/internal/edit" | |
| 6 | + | "strconv" | |
| 6 | 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 | + | } | |
| @@ -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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "github.com/bnema/nefergui/internal/css" | |
| 5 | 5 | "github.com/bnema/nefergui/internal/edit" | |
| 6 | - | "github.com/bnema/nefergui/internal/keyboard" | |
| 7 | 6 | "testing" | |
| 8 | 7 | ) | |
| 9 | 8 | ||
| @@ -85,7 +84,7 @@ func TestDisabledEditorRejectsFocusAndText(t *testing.T) { | |||
| 85 | 84 | if r.state.focus != nil { | |
| 86 | 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 | 88 | r.Build(view) | |
| 90 | 89 | if value != "keep" { | |
| 91 | 90 | t.Fatalf("disabled input edited: %q", value) | |
| @@ -101,7 +100,7 @@ func TestEditFocusIMEAndClipboard(t *testing.T) { | |||
| 101 | 100 | r.clipboard = &clip{data: []byte("paste")} | |
| 102 | 101 | r.route(key("Tab")) | |
| 103 | 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 | 104 | r.Build(view) | |
| 106 | 105 | if value != "x" || r.committed.children[0].text != "•" { | |
| 107 | 106 | 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 ( | |||
| 12 | 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 | 15 | // InputEvent is a raw platform event, delivered on the owner loop before the | |
| 39 | 16 | // normal control routing. Coordinates are logical, surface-local pixels. | |
| 40 | 17 | // 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 the | 15 | // 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 set | 17 | // 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 ( | |||
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/nefergui/internal/edit" | |
| 7 | - | "github.com/bnema/nefergui/internal/keyboard" | |
| 8 | 7 | ) | |
| 9 | 8 | ||
| 10 | 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 | 13 | // 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 ( | |||
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/nefergui/internal/css" | |
| 10 | 10 | "github.com/bnema/nefergui/internal/edit" | |
| 11 | - | "github.com/bnema/nefergui/internal/keyboard" | |
| 12 | 11 | "github.com/bnema/nefergui/internal/layout" | |
| 13 | 12 | "github.com/bnema/nefergui/internal/text" | |
| 14 | 13 | ) | |
| @@ -19,13 +18,20 @@ type platformInput struct { | |||
| 19 | 18 | Kind string // motion, press, release, axis, leave, key, focus-in, focus-out, resize | |
| 20 | 19 | X, Y, DX, DY float64 | |
| 21 | 20 | Width, Height, Scale float64 | |
| 22 | - | Key keyboard.Key | |
| 21 | + | Key keyEvent | |
| 23 | 22 | Button uint32 // evdev button code (BTN_LEFT is primary) | |
| 24 | 23 | Shift, Ctrl bool | |
| 25 | 24 | IME edit.IMEBatch | |
| 26 | 25 | 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 | 35 | const pointerPrimary uint32 = 0x110 // Linux BTN_LEFT | |
| 30 | 36 | ||
| 31 | 37 | 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, resize | 18 | Kind string // motion, press, release, axis, leave, key, focus-in, focus-out, resize | ||
| 20 | X, Y, DX, DY float64 | 19 | X, Y, DX, DY float64 | ||
| 21 | Width, Height, Scale float64 | 20 | Width, Height, Scale float64 | ||
| 22 | - | Key keyboard.Key | 21 | + | 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 bool | 23 | Shift, Ctrl bool | ||
| 25 | IME edit.IMEBatch | 24 | IME edit.IMEBatch | ||
| 26 | Time time.Time // optional event timestamp; zero uses receipt time | 25 | 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_LEFT | 35 | 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 ( | |||
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/nefergui/internal/css" | |
| 9 | - | "github.com/bnema/nefergui/internal/keyboard" | |
| 10 | 9 | "github.com/bnema/nefergui/internal/platform/wayland" | |
| 11 | 10 | "github.com/bnema/nefergui/internal/text" | |
| 12 | 11 | ) | |
| 13 | 12 | ||
| 14 | 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 | 16 | func shell(r *runtime, activated *int) func(*Frame) { | |
| 18 | 17 | return func(f *Frame) { | |
| @@ -67,7 +66,7 @@ func TestShellStyleAndFocus(t *testing.T) { | |||
| 67 | 66 | if !r.state.focus.same(r.Target(3)) { | |
| 68 | 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 | 70 | r.Build(view) | |
| 72 | 71 | if !r.state.focus.same(r.Target(1)) { | |
| 73 | 72 | t.Fatal("reverse tab") | |
| @@ -361,7 +360,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) { | |||
| 361 | 360 | if count != 1 || r.state.space == nil || r.state.flags(r.committed.children[0])&css.Active == 0 { | |
| 362 | 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 | 364 | r.Build(view) | |
| 366 | 365 | if count != 2 || r.state.space != nil { | |
| 367 | 366 | t.Fatal("space release should activate once", count) | |
| @@ -369,7 +368,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) { | |||
| 369 | 368 | r.route(space) | |
| 370 | 369 | r.route(key("Escape")) | |
| 371 | 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 | 372 | if r.Build(view) { | |
| 374 | 373 | t.Fatal("cancelled release redrew") | |
| 375 | 374 | } | |
| @@ -379,7 +378,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) { | |||
| 379 | 378 | r.route(space) | |
| 380 | 379 | r.route(platformInput{Kind: "focus-out"}) | |
| 381 | 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 | 382 | if r.Build(view) || count != 2 { | |
| 384 | 383 | t.Fatal("focus loss failed to cancel") | |
| 385 | 384 | } | |
| @@ -388,7 +387,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) { | |||
| 388 | 387 | r.route(space) | |
| 389 | 388 | r.route(key("Tab")) | |
| 390 | 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 | 391 | if r.Build(view) || count != 2 { | |
| 393 | 392 | t.Fatal("focus change failed to cancel") | |
| 394 | 393 | } | |
| @@ -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) { | |||
| 28 | 28 | if in.Kind == "axis" && in.DX == 0 && in.DY == 0 { | |
| 29 | 29 | 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 | } | |
| @@ -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 | return | 29 | 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) } | |||