detached 0679ea5

feat(drm): libseat session, KMS dumb-buffer output and libinput backend

commit
0679ea512aec2ea3d8360c04f5dfb9feac0137e1
author
brice · · 2026-09-24 08:00 UTC
parent
6d5f7c4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +1276 -11
M internal/adapters/config/config.go +1 -1
@@ -18,7 +18,7 @@ var defaultBinds = map[string]string{
1818 "Cmd+Return": "spawn-terminal", "Cmd+Left": "focus-column-left", "Cmd+Right": "focus-column-right", "Cmd+Up": "focus-window-up", "Cmd+Down": "focus-window-down", "Cmd+Shift+Left": "move-column-left", "Cmd+Shift+Right": "move-column-right", "Cmd+R": "cycle-column-width", "Cmd+Shift+F": "toggle-fullscreen", "Cmd+Q": "close-window", "Ctrl+Alt+BackSpace": "quit",
1919 }
2020 var actions = map[string]bool{"none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}
21-var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "render": true, "sync": true, "config": true, "app": true}
21+var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
2222 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
2323
2424 func DefaultPath() string {
@@ -18,7 +18,7 @@ var defaultBinds = map[string]string{
18 "Cmd+Return": "spawn-terminal", "Cmd+Left": "focus-column-left", "Cmd+Right": "focus-column-right", "Cmd+Up": "focus-window-up", "Cmd+Down": "focus-window-down", "Cmd+Shift+Left": "move-column-left", "Cmd+Shift+Right": "move-column-right", "Cmd+R": "cycle-column-width", "Cmd+Shift+F": "toggle-fullscreen", "Cmd+Q": "close-window", "Ctrl+Alt+BackSpace": "quit",18 "Cmd+Return": "spawn-terminal", "Cmd+Left": "focus-column-left", "Cmd+Right": "focus-column-right", "Cmd+Up": "focus-window-up", "Cmd+Down": "focus-window-down", "Cmd+Shift+Left": "move-column-left", "Cmd+Shift+Right": "move-column-right", "Cmd+R": "cycle-column-width", "Cmd+Shift+F": "toggle-fullscreen", "Cmd+Q": "close-window", "Ctrl+Alt+BackSpace": "quit",
19 }19 }
20 var actions = map[string]bool{"none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}20 var actions = map[string]bool{"none": true, "spawn-terminal": true, "focus-column-left": true, "focus-column-right": true, "focus-window-up": true, "focus-window-down": true, "move-column-left": true, "move-column-right": true, "cycle-column-width": true, "toggle-fullscreen": true, "close-window": true, "quit": true}
21-var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "render": true, "sync": true, "config": true, "app": true}21+var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
22 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)22 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
23 23
24 func DefaultPath() string {24 func DefaultPath() string {
A internal/adapters/drm/kms.go +303 -0
@@ -0,0 +1,303 @@
1+// Package drm drives one KMS output with raw ioctls and dumb buffers.
2+package drm
3+
4+import (
5+ "bytes"
6+ "errors"
7+ "fmt"
8+ "image"
9+ "unsafe"
10+
11+ "golang.org/x/sys/unix"
12+)
13+
14+// ioctl numbers for amd64/arm64 (DRM_IOWR('d', nr, size)).
15+const (
16+ ioctlGetResources = 0xC04064A0
17+ ioctlGetCrtc = 0xC06864A1
18+ ioctlSetCrtc = 0xC06864A2
19+ ioctlGetEncoder = 0xC01464A6
20+ ioctlGetConnector = 0xC05064A7
21+ ioctlAddFB = 0xC01C64AE
22+ ioctlRmFB = 0xC00464AF
23+ ioctlPageFlip = 0xC01864B0
24+ ioctlCreateDumb = 0xC02064B2
25+ ioctlMapDumb = 0xC01064B3
26+ ioctlDestroyDumb = 0xC00464B4
27+
28+ pageFlipEvent = 1
29+ eventFlipDone = 2
30+ modeTypePrefered = 1 << 3
31+ connected = 1
32+)
33+
34+type cardRes struct {
35+ fbs, crtcs, connectors, encoders uint64
36+ countFbs, countCrtcs, countConns, countEncoders uint32
37+ minW, maxW, minH, maxH uint32
38+}
39+
40+type modeInfo struct {
41+ Clock uint32
42+ HDisplay, HSyncStart, HSyncEnd, HTotal uint16
43+ HSkew uint16
44+ VDisplay, VSyncStart, VSyncEnd, VTotal uint16
45+ VScan uint16
46+ VRefresh, Flags, Type uint32
47+ Name [32]byte
48+}
49+
50+func (m modeInfo) String() string {
51+ return fmt.Sprintf("%dx%d@%d", m.HDisplay, m.VDisplay, m.VRefresh)
52+}
53+
54+type getConnector struct {
55+ encoders, modes, props, propValues uint64
56+ countModes, countProps, countEncoders uint32
57+ encoderID, connectorID, connType, typeID uint32
58+ connection, mmW, mmH, subpixel uint32
59+ _ uint32
60+}
61+
62+type getEncoder struct{ encoderID, encoderType, crtcID, possibleCrtcs, possibleClones uint32 }
63+
64+type modeCrtc struct {
65+ setConnectors uint64
66+ countConnectors uint32
67+ crtcID, fbID uint32
68+ x, y uint32
69+ gammaSize uint32
70+ modeValid uint32
71+ mode modeInfo
72+}
73+
74+type pageFlip struct {
75+ crtcID, fbID, flags, reserved uint32
76+ userData uint64
77+}
78+
79+type createDumb struct {
80+ height, width, bpp, flags uint32
81+ handle, pitch uint32
82+ size uint64
83+}
84+
85+type mapDumb struct {
86+ handle, pad uint32
87+ offset uint64
88+}
89+
90+type fbCmd struct{ fbID, width, height, pitch, bpp, depth, handle uint32 }
91+
92+func ioctl(fd int, req uintptr, arg unsafe.Pointer) error {
93+ for {
94+ _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), req, uintptr(arg))
95+ if e == unix.EINTR || e == unix.EAGAIN {
96+ continue
97+ }
98+ if e != 0 {
99+ return e
100+ }
101+ return nil
102+ }
103+}
104+
105+// connector is the subset of KMS connector state used to pick an output.
106+type connector struct {
107+ id, encoderID uint32
108+ name string
109+ connected bool
110+ modes []modeInfo
111+ encoders []uint32
112+}
113+
114+var connectorTypes = map[uint32]string{1: "VGA", 2: "DVI-I", 3: "DVI-D", 10: "DP", 11: "HDMI-A", 12: "HDMI-B", 14: "eDP", 15: "Virtual", 16: "DSI"}
115+
116+// pickConnector returns the first connected connector and its preferred (else first) mode.
117+func pickConnector(conns []connector) (connector, modeInfo, error) {
118+ for _, c := range conns {
119+ if !c.connected || len(c.modes) == 0 {
120+ continue
121+ }
122+ for _, m := range c.modes {
123+ if m.Type&modeTypePrefered != 0 {
124+ return c, m, nil
125+ }
126+ }
127+ return c, c.modes[0], nil
128+ }
129+ return connector{}, modeInfo{}, errors.New("no connected display")
130+}
131+
132+func resources(fd int) (crtcs, conns []uint32, err error) {
133+ var r cardRes
134+ if err := ioctl(fd, ioctlGetResources, unsafe.Pointer(&r)); err != nil {
135+ return nil, nil, fmt.Errorf("get resources: %w", err)
136+ }
137+ crtcs = make([]uint32, r.countCrtcs)
138+ conns = make([]uint32, r.countConns)
139+ r = cardRes{countCrtcs: r.countCrtcs, countConns: r.countConns}
140+ if len(crtcs) > 0 {
141+ r.crtcs = uint64(uintptr(unsafe.Pointer(&crtcs[0])))
142+ }
143+ if len(conns) > 0 {
144+ r.connectors = uint64(uintptr(unsafe.Pointer(&conns[0])))
145+ }
146+ if err := ioctl(fd, ioctlGetResources, unsafe.Pointer(&r)); err != nil {
147+ return nil, nil, fmt.Errorf("get resources: %w", err)
148+ }
149+ return crtcs[:r.countCrtcs], conns[:r.countConns], nil
150+}
151+
152+func readConnector(fd int, id uint32) (connector, error) {
153+ g := getConnector{connectorID: id}
154+ if err := ioctl(fd, ioctlGetConnector, unsafe.Pointer(&g)); err != nil {
155+ return connector{}, err
156+ }
157+ modes := make([]modeInfo, g.countModes)
158+ encs := make([]uint32, g.countEncoders)
159+ g2 := getConnector{connectorID: id, countModes: g.countModes, countEncoders: g.countEncoders}
160+ if len(modes) > 0 {
161+ g2.modes = uint64(uintptr(unsafe.Pointer(&modes[0])))
162+ }
163+ if len(encs) > 0 {
164+ g2.encoders = uint64(uintptr(unsafe.Pointer(&encs[0])))
165+ }
166+ if err := ioctl(fd, ioctlGetConnector, unsafe.Pointer(&g2)); err != nil {
167+ return connector{}, err
168+ }
169+ name := connectorTypes[g2.connType]
170+ if name == "" {
171+ name = "Unknown"
172+ }
173+ return connector{
174+ id: id, encoderID: g2.encoderID, name: fmt.Sprintf("%s-%d", name, g2.typeID),
175+ connected: g2.connection == connected,
176+ modes: modes[:min(int(g2.countModes), len(modes))],
177+ encoders: encs[:min(int(g2.countEncoders), len(encs))],
178+ }, nil
179+}
180+
181+// pickCrtc prefers the connector's current encoder CRTC, else any CRTC an encoder allows.
182+func pickCrtc(fd int, c connector, crtcs []uint32) (uint32, error) {
183+ encs := c.encoders
184+ if c.encoderID != 0 {
185+ encs = append([]uint32{c.encoderID}, encs...)
186+ }
187+ for _, id := range encs {
188+ e := getEncoder{encoderID: id}
189+ if ioctl(fd, ioctlGetEncoder, unsafe.Pointer(&e)) != nil {
190+ continue
191+ }
192+ if id == c.encoderID && e.crtcID != 0 {
193+ return e.crtcID, nil
194+ }
195+ for i, crtc := range crtcs {
196+ if e.possibleCrtcs&(1<<i) != 0 {
197+ return crtc, nil
198+ }
199+ }
200+ }
201+ return 0, fmt.Errorf("no CRTC for %s", c.name)
202+}
203+
204+type dumbBuffer struct {
205+ handle, fbID, pitch uint32
206+ mem []byte
207+}
208+
209+func newDumb(fd int, w, h int) (*dumbBuffer, error) {
210+ c := createDumb{width: uint32(w), height: uint32(h), bpp: 32}
211+ if err := ioctl(fd, ioctlCreateDumb, unsafe.Pointer(&c)); err != nil {
212+ return nil, fmt.Errorf("create dumb: %w", err)
213+ }
214+ b := &dumbBuffer{handle: c.handle, pitch: c.pitch}
215+ f := fbCmd{width: uint32(w), height: uint32(h), pitch: c.pitch, bpp: 32, depth: 24, handle: c.handle}
216+ if err := ioctl(fd, ioctlAddFB, unsafe.Pointer(&f)); err != nil {
217+ b.destroy(fd)
218+ return nil, fmt.Errorf("add fb: %w", err)
219+ }
220+ b.fbID = f.fbID
221+ m := mapDumb{handle: c.handle}
222+ if err := ioctl(fd, ioctlMapDumb, unsafe.Pointer(&m)); err != nil {
223+ b.destroy(fd)
224+ return nil, fmt.Errorf("map dumb: %w", err)
225+ }
226+ mem, err := unix.Mmap(fd, int64(m.offset), int(c.size), unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
227+ if err != nil {
228+ b.destroy(fd)
229+ return nil, fmt.Errorf("mmap dumb: %w", err)
230+ }
231+ b.mem = mem
232+ return b, nil
233+}
234+
235+func (b *dumbBuffer) destroy(fd int) {
236+ if b.mem != nil {
237+ _ = unix.Munmap(b.mem)
238+ b.mem = nil
239+ }
240+ if b.fbID != 0 {
241+ id := b.fbID
242+ _ = ioctl(fd, ioctlRmFB, unsafe.Pointer(&id))
243+ b.fbID = 0
244+ }
245+ h := b.handle
246+ _ = ioctl(fd, ioctlDestroyDumb, unsafe.Pointer(&h))
247+}
248+
249+// copyXRGB writes RGBA pixels into an XRGB8888 (little-endian BGRX) buffer.
250+func copyXRGB(dst []byte, pitch int, img *image.RGBA) {
251+ w, h := img.Rect.Dx(), img.Rect.Dy()
252+ for y := 0; y < h; y++ {
253+ src := img.Pix[y*img.Stride : y*img.Stride+w*4]
254+ row := dst[y*pitch : y*pitch+w*4]
255+ for x := 0; x < w*4; x += 4 {
256+ row[x], row[x+1], row[x+2], row[x+3] = src[x+2], src[x+1], src[x], 0xff
257+ }
258+ }
259+}
260+
261+func setCrtc(fd int, crtc, conn, fb uint32, mode *modeInfo) error {
262+ ids := []uint32{conn}
263+ c := modeCrtc{crtcID: crtc, fbID: fb, countConnectors: 1, setConnectors: uint64(uintptr(unsafe.Pointer(&ids[0])))}
264+ if mode != nil {
265+ c.mode, c.modeValid = *mode, 1
266+ }
267+ return ioctl(fd, ioctlSetCrtc, unsafe.Pointer(&c))
268+}
269+
270+func getCrtc(fd int, crtc uint32) (modeCrtc, error) {
271+ c := modeCrtc{crtcID: crtc}
272+ err := ioctl(fd, ioctlGetCrtc, unsafe.Pointer(&c))
273+ return c, err
274+}
275+
276+func flip(fd int, crtc, fb uint32) error {
277+ p := pageFlip{crtcID: crtc, fbID: fb, flags: pageFlipEvent}
278+ return ioctl(fd, ioctlPageFlip, unsafe.Pointer(&p))
279+}
280+
281+// countFlipEvents parses DRM events read from the card fd.
282+func countFlipEvents(buf []byte) int {
283+ n := 0
284+ for len(buf) >= 8 {
285+ typ := uint32(buf[0]) | uint32(buf[1])<<8 | uint32(buf[2])<<16 | uint32(buf[3])<<24
286+ length := int(uint32(buf[4]) | uint32(buf[5])<<8 | uint32(buf[6])<<16 | uint32(buf[7])<<24)
287+ if length < 8 || length > len(buf) {
288+ break
289+ }
290+ if typ == eventFlipDone {
291+ n++
292+ }
293+ buf = buf[length:]
294+ }
295+ return n
296+}
297+
298+func cstr(b []byte) string {
299+ if i := bytes.IndexByte(b, 0); i >= 0 {
300+ return string(b[:i])
301+ }
302+ return string(b)
303+}
@@ -0,0 +1,303 @@
1+// Package drm drives one KMS output with raw ioctls and dumb buffers.
2+package drm
3+
4+import (
5+ "bytes"
6+ "errors"
7+ "fmt"
8+ "image"
9+ "unsafe"
10+
11+ "golang.org/x/sys/unix"
12+)
13+
14+// ioctl numbers for amd64/arm64 (DRM_IOWR('d', nr, size)).
15+const (
16+ ioctlGetResources = 0xC04064A0
17+ ioctlGetCrtc = 0xC06864A1
18+ ioctlSetCrtc = 0xC06864A2
19+ ioctlGetEncoder = 0xC01464A6
20+ ioctlGetConnector = 0xC05064A7
21+ ioctlAddFB = 0xC01C64AE
22+ ioctlRmFB = 0xC00464AF
23+ ioctlPageFlip = 0xC01864B0
24+ ioctlCreateDumb = 0xC02064B2
25+ ioctlMapDumb = 0xC01064B3
26+ ioctlDestroyDumb = 0xC00464B4
27+
28+ pageFlipEvent = 1
29+ eventFlipDone = 2
30+ modeTypePrefered = 1 << 3
31+ connected = 1
32+)
33+
34+type cardRes struct {
35+ fbs, crtcs, connectors, encoders uint64
36+ countFbs, countCrtcs, countConns, countEncoders uint32
37+ minW, maxW, minH, maxH uint32
38+}
39+
40+type modeInfo struct {
41+ Clock uint32
42+ HDisplay, HSyncStart, HSyncEnd, HTotal uint16
43+ HSkew uint16
44+ VDisplay, VSyncStart, VSyncEnd, VTotal uint16
45+ VScan uint16
46+ VRefresh, Flags, Type uint32
47+ Name [32]byte
48+}
49+
50+func (m modeInfo) String() string {
51+ return fmt.Sprintf("%dx%d@%d", m.HDisplay, m.VDisplay, m.VRefresh)
52+}
53+
54+type getConnector struct {
55+ encoders, modes, props, propValues uint64
56+ countModes, countProps, countEncoders uint32
57+ encoderID, connectorID, connType, typeID uint32
58+ connection, mmW, mmH, subpixel uint32
59+ _ uint32
60+}
61+
62+type getEncoder struct{ encoderID, encoderType, crtcID, possibleCrtcs, possibleClones uint32 }
63+
64+type modeCrtc struct {
65+ setConnectors uint64
66+ countConnectors uint32
67+ crtcID, fbID uint32
68+ x, y uint32
69+ gammaSize uint32
70+ modeValid uint32
71+ mode modeInfo
72+}
73+
74+type pageFlip struct {
75+ crtcID, fbID, flags, reserved uint32
76+ userData uint64
77+}
78+
79+type createDumb struct {
80+ height, width, bpp, flags uint32
81+ handle, pitch uint32
82+ size uint64
83+}
84+
85+type mapDumb struct {
86+ handle, pad uint32
87+ offset uint64
88+}
89+
90+type fbCmd struct{ fbID, width, height, pitch, bpp, depth, handle uint32 }
91+
92+func ioctl(fd int, req uintptr, arg unsafe.Pointer) error {
93+ for {
94+ _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), req, uintptr(arg))
95+ if e == unix.EINTR || e == unix.EAGAIN {
96+ continue
97+ }
98+ if e != 0 {
99+ return e
100+ }
101+ return nil
102+ }
103+}
104+
105+// connector is the subset of KMS connector state used to pick an output.
106+type connector struct {
107+ id, encoderID uint32
108+ name string
109+ connected bool
110+ modes []modeInfo
111+ encoders []uint32
112+}
113+
114+var connectorTypes = map[uint32]string{1: "VGA", 2: "DVI-I", 3: "DVI-D", 10: "DP", 11: "HDMI-A", 12: "HDMI-B", 14: "eDP", 15: "Virtual", 16: "DSI"}
115+
116+// pickConnector returns the first connected connector and its preferred (else first) mode.
117+func pickConnector(conns []connector) (connector, modeInfo, error) {
118+ for _, c := range conns {
119+ if !c.connected || len(c.modes) == 0 {
120+ continue
121+ }
122+ for _, m := range c.modes {
123+ if m.Type&modeTypePrefered != 0 {
124+ return c, m, nil
125+ }
126+ }
127+ return c, c.modes[0], nil
128+ }
129+ return connector{}, modeInfo{}, errors.New("no connected display")
130+}
131+
132+func resources(fd int) (crtcs, conns []uint32, err error) {
133+ var r cardRes
134+ if err := ioctl(fd, ioctlGetResources, unsafe.Pointer(&r)); err != nil {
135+ return nil, nil, fmt.Errorf("get resources: %w", err)
136+ }
137+ crtcs = make([]uint32, r.countCrtcs)
138+ conns = make([]uint32, r.countConns)
139+ r = cardRes{countCrtcs: r.countCrtcs, countConns: r.countConns}
140+ if len(crtcs) > 0 {
141+ r.crtcs = uint64(uintptr(unsafe.Pointer(&crtcs[0])))
142+ }
143+ if len(conns) > 0 {
144+ r.connectors = uint64(uintptr(unsafe.Pointer(&conns[0])))
145+ }
146+ if err := ioctl(fd, ioctlGetResources, unsafe.Pointer(&r)); err != nil {
147+ return nil, nil, fmt.Errorf("get resources: %w", err)
148+ }
149+ return crtcs[:r.countCrtcs], conns[:r.countConns], nil
150+}
151+
152+func readConnector(fd int, id uint32) (connector, error) {
153+ g := getConnector{connectorID: id}
154+ if err := ioctl(fd, ioctlGetConnector, unsafe.Pointer(&g)); err != nil {
155+ return connector{}, err
156+ }
157+ modes := make([]modeInfo, g.countModes)
158+ encs := make([]uint32, g.countEncoders)
159+ g2 := getConnector{connectorID: id, countModes: g.countModes, countEncoders: g.countEncoders}
160+ if len(modes) > 0 {
161+ g2.modes = uint64(uintptr(unsafe.Pointer(&modes[0])))
162+ }
163+ if len(encs) > 0 {
164+ g2.encoders = uint64(uintptr(unsafe.Pointer(&encs[0])))
165+ }
166+ if err := ioctl(fd, ioctlGetConnector, unsafe.Pointer(&g2)); err != nil {
167+ return connector{}, err
168+ }
169+ name := connectorTypes[g2.connType]
170+ if name == "" {
171+ name = "Unknown"
172+ }
173+ return connector{
174+ id: id, encoderID: g2.encoderID, name: fmt.Sprintf("%s-%d", name, g2.typeID),
175+ connected: g2.connection == connected,
176+ modes: modes[:min(int(g2.countModes), len(modes))],
177+ encoders: encs[:min(int(g2.countEncoders), len(encs))],
178+ }, nil
179+}
180+
181+// pickCrtc prefers the connector's current encoder CRTC, else any CRTC an encoder allows.
182+func pickCrtc(fd int, c connector, crtcs []uint32) (uint32, error) {
183+ encs := c.encoders
184+ if c.encoderID != 0 {
185+ encs = append([]uint32{c.encoderID}, encs...)
186+ }
187+ for _, id := range encs {
188+ e := getEncoder{encoderID: id}
189+ if ioctl(fd, ioctlGetEncoder, unsafe.Pointer(&e)) != nil {
190+ continue
191+ }
192+ if id == c.encoderID && e.crtcID != 0 {
193+ return e.crtcID, nil
194+ }
195+ for i, crtc := range crtcs {
196+ if e.possibleCrtcs&(1<<i) != 0 {
197+ return crtc, nil
198+ }
199+ }
200+ }
201+ return 0, fmt.Errorf("no CRTC for %s", c.name)
202+}
203+
204+type dumbBuffer struct {
205+ handle, fbID, pitch uint32
206+ mem []byte
207+}
208+
209+func newDumb(fd int, w, h int) (*dumbBuffer, error) {
210+ c := createDumb{width: uint32(w), height: uint32(h), bpp: 32}
211+ if err := ioctl(fd, ioctlCreateDumb, unsafe.Pointer(&c)); err != nil {
212+ return nil, fmt.Errorf("create dumb: %w", err)
213+ }
214+ b := &dumbBuffer{handle: c.handle, pitch: c.pitch}
215+ f := fbCmd{width: uint32(w), height: uint32(h), pitch: c.pitch, bpp: 32, depth: 24, handle: c.handle}
216+ if err := ioctl(fd, ioctlAddFB, unsafe.Pointer(&f)); err != nil {
217+ b.destroy(fd)
218+ return nil, fmt.Errorf("add fb: %w", err)
219+ }
220+ b.fbID = f.fbID
221+ m := mapDumb{handle: c.handle}
222+ if err := ioctl(fd, ioctlMapDumb, unsafe.Pointer(&m)); err != nil {
223+ b.destroy(fd)
224+ return nil, fmt.Errorf("map dumb: %w", err)
225+ }
226+ mem, err := unix.Mmap(fd, int64(m.offset), int(c.size), unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
227+ if err != nil {
228+ b.destroy(fd)
229+ return nil, fmt.Errorf("mmap dumb: %w", err)
230+ }
231+ b.mem = mem
232+ return b, nil
233+}
234+
235+func (b *dumbBuffer) destroy(fd int) {
236+ if b.mem != nil {
237+ _ = unix.Munmap(b.mem)
238+ b.mem = nil
239+ }
240+ if b.fbID != 0 {
241+ id := b.fbID
242+ _ = ioctl(fd, ioctlRmFB, unsafe.Pointer(&id))
243+ b.fbID = 0
244+ }
245+ h := b.handle
246+ _ = ioctl(fd, ioctlDestroyDumb, unsafe.Pointer(&h))
247+}
248+
249+// copyXRGB writes RGBA pixels into an XRGB8888 (little-endian BGRX) buffer.
250+func copyXRGB(dst []byte, pitch int, img *image.RGBA) {
251+ w, h := img.Rect.Dx(), img.Rect.Dy()
252+ for y := 0; y < h; y++ {
253+ src := img.Pix[y*img.Stride : y*img.Stride+w*4]
254+ row := dst[y*pitch : y*pitch+w*4]
255+ for x := 0; x < w*4; x += 4 {
256+ row[x], row[x+1], row[x+2], row[x+3] = src[x+2], src[x+1], src[x], 0xff
257+ }
258+ }
259+}
260+
261+func setCrtc(fd int, crtc, conn, fb uint32, mode *modeInfo) error {
262+ ids := []uint32{conn}
263+ c := modeCrtc{crtcID: crtc, fbID: fb, countConnectors: 1, setConnectors: uint64(uintptr(unsafe.Pointer(&ids[0])))}
264+ if mode != nil {
265+ c.mode, c.modeValid = *mode, 1
266+ }
267+ return ioctl(fd, ioctlSetCrtc, unsafe.Pointer(&c))
268+}
269+
270+func getCrtc(fd int, crtc uint32) (modeCrtc, error) {
271+ c := modeCrtc{crtcID: crtc}
272+ err := ioctl(fd, ioctlGetCrtc, unsafe.Pointer(&c))
273+ return c, err
274+}
275+
276+func flip(fd int, crtc, fb uint32) error {
277+ p := pageFlip{crtcID: crtc, fbID: fb, flags: pageFlipEvent}
278+ return ioctl(fd, ioctlPageFlip, unsafe.Pointer(&p))
279+}
280+
281+// countFlipEvents parses DRM events read from the card fd.
282+func countFlipEvents(buf []byte) int {
283+ n := 0
284+ for len(buf) >= 8 {
285+ typ := uint32(buf[0]) | uint32(buf[1])<<8 | uint32(buf[2])<<16 | uint32(buf[3])<<24
286+ length := int(uint32(buf[4]) | uint32(buf[5])<<8 | uint32(buf[6])<<16 | uint32(buf[7])<<24)
287+ if length < 8 || length > len(buf) {
288+ break
289+ }
290+ if typ == eventFlipDone {
291+ n++
292+ }
293+ buf = buf[length:]
294+ }
295+ return n
296+}
297+
298+func cstr(b []byte) string {
299+ if i := bytes.IndexByte(b, 0); i >= 0 {
300+ return string(b[:i])
301+ }
302+ return string(b)
303+}
A internal/adapters/drm/kms_test.go +84 -0
@@ -0,0 +1,84 @@
1+package drm
2+
3+import (
4+ "encoding/binary"
5+ "image"
6+ "testing"
7+ "unsafe"
8+)
9+
10+func TestStructSizesMatchIoctls(t *testing.T) {
11+ for _, tc := range []struct {
12+ name string
13+ got uintptr
14+ req uintptr
15+ }{
16+ {"card_res", unsafe.Sizeof(cardRes{}), ioctlGetResources},
17+ {"mode_crtc", unsafe.Sizeof(modeCrtc{}), ioctlSetCrtc},
18+ {"get_encoder", unsafe.Sizeof(getEncoder{}), ioctlGetEncoder},
19+ {"get_connector", unsafe.Sizeof(getConnector{}), ioctlGetConnector},
20+ {"fb_cmd", unsafe.Sizeof(fbCmd{}), ioctlAddFB},
21+ {"page_flip", unsafe.Sizeof(pageFlip{}), ioctlPageFlip},
22+ {"create_dumb", unsafe.Sizeof(createDumb{}), ioctlCreateDumb},
23+ {"map_dumb", unsafe.Sizeof(mapDumb{}), ioctlMapDumb},
24+ } {
25+ if want := (tc.req >> 16) & 0x3fff; tc.got != want {
26+ t.Errorf("%s: size %d, ioctl encodes %d", tc.name, tc.got, want)
27+ }
28+ }
29+ if unsafe.Sizeof(modeInfo{}) != 68 {
30+ t.Errorf("modeInfo size %d, want 68", unsafe.Sizeof(modeInfo{}))
31+ }
32+}
33+
34+func TestPickConnector(t *testing.T) {
35+ plain := modeInfo{HDisplay: 1280, VDisplay: 720}
36+ pref := modeInfo{HDisplay: 2560, VDisplay: 1440, Type: modeTypePrefered}
37+ for _, tc := range []struct {
38+ name string
39+ conns []connector
40+ want string
41+ mode uint16
42+ err bool
43+ }{
44+ {"none", nil, "", 0, true},
45+ {"disconnected skipped", []connector{{name: "DP-1", modes: []modeInfo{pref}}, {name: "DP-2", connected: true, modes: []modeInfo{plain}}}, "DP-2", 1280, false},
46+ {"preferred mode", []connector{{name: "DP-1", connected: true, modes: []modeInfo{plain, pref}}}, "DP-1", 2560, false},
47+ {"no modes skipped", []connector{{name: "DP-1", connected: true}}, "", 0, true},
48+ } {
49+ c, m, err := pickConnector(tc.conns)
50+ if (err != nil) != tc.err || c.name != tc.want || m.HDisplay != tc.mode {
51+ t.Errorf("%s: got %q %d %v", tc.name, c.name, m.HDisplay, err)
52+ }
53+ }
54+}
55+
56+func TestCopyXRGB(t *testing.T) {
57+ img := image.NewRGBA(image.Rect(0, 0, 2, 2))
58+ copy(img.Pix, []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16})
59+ pitch := 12
60+ dst := make([]byte, pitch*2)
61+ copyXRGB(dst, pitch, img)
62+ want := []byte{3, 2, 1, 255, 7, 6, 5, 255, 0, 0, 0, 0, 11, 10, 9, 255, 15, 14, 13, 255, 0, 0, 0, 0}
63+ for i := range want {
64+ if dst[i] != want[i] {
65+ t.Fatalf("byte %d = %d, want %d (%v)", i, dst[i], want[i], dst)
66+ }
67+ }
68+}
69+
70+func TestCountFlipEvents(t *testing.T) {
71+ ev := func(typ uint32, n int) []byte {
72+ b := make([]byte, n)
73+ binary.LittleEndian.PutUint32(b, typ)
74+ binary.LittleEndian.PutUint32(b[4:], uint32(n))
75+ return b
76+ }
77+ buf := append(append(ev(eventFlipDone, 32), ev(1, 32)...), ev(eventFlipDone, 32)...)
78+ if n := countFlipEvents(buf); n != 2 {
79+ t.Fatalf("got %d flips", n)
80+ }
81+ if n := countFlipEvents(buf[:20]); n != 0 {
82+ t.Fatalf("truncated: got %d", n)
83+ }
84+}
@@ -0,0 +1,84 @@
1+package drm
2+
3+import (
4+ "encoding/binary"
5+ "image"
6+ "testing"
7+ "unsafe"
8+)
9+
10+func TestStructSizesMatchIoctls(t *testing.T) {
11+ for _, tc := range []struct {
12+ name string
13+ got uintptr
14+ req uintptr
15+ }{
16+ {"card_res", unsafe.Sizeof(cardRes{}), ioctlGetResources},
17+ {"mode_crtc", unsafe.Sizeof(modeCrtc{}), ioctlSetCrtc},
18+ {"get_encoder", unsafe.Sizeof(getEncoder{}), ioctlGetEncoder},
19+ {"get_connector", unsafe.Sizeof(getConnector{}), ioctlGetConnector},
20+ {"fb_cmd", unsafe.Sizeof(fbCmd{}), ioctlAddFB},
21+ {"page_flip", unsafe.Sizeof(pageFlip{}), ioctlPageFlip},
22+ {"create_dumb", unsafe.Sizeof(createDumb{}), ioctlCreateDumb},
23+ {"map_dumb", unsafe.Sizeof(mapDumb{}), ioctlMapDumb},
24+ } {
25+ if want := (tc.req >> 16) & 0x3fff; tc.got != want {
26+ t.Errorf("%s: size %d, ioctl encodes %d", tc.name, tc.got, want)
27+ }
28+ }
29+ if unsafe.Sizeof(modeInfo{}) != 68 {
30+ t.Errorf("modeInfo size %d, want 68", unsafe.Sizeof(modeInfo{}))
31+ }
32+}
33+
34+func TestPickConnector(t *testing.T) {
35+ plain := modeInfo{HDisplay: 1280, VDisplay: 720}
36+ pref := modeInfo{HDisplay: 2560, VDisplay: 1440, Type: modeTypePrefered}
37+ for _, tc := range []struct {
38+ name string
39+ conns []connector
40+ want string
41+ mode uint16
42+ err bool
43+ }{
44+ {"none", nil, "", 0, true},
45+ {"disconnected skipped", []connector{{name: "DP-1", modes: []modeInfo{pref}}, {name: "DP-2", connected: true, modes: []modeInfo{plain}}}, "DP-2", 1280, false},
46+ {"preferred mode", []connector{{name: "DP-1", connected: true, modes: []modeInfo{plain, pref}}}, "DP-1", 2560, false},
47+ {"no modes skipped", []connector{{name: "DP-1", connected: true}}, "", 0, true},
48+ } {
49+ c, m, err := pickConnector(tc.conns)
50+ if (err != nil) != tc.err || c.name != tc.want || m.HDisplay != tc.mode {
51+ t.Errorf("%s: got %q %d %v", tc.name, c.name, m.HDisplay, err)
52+ }
53+ }
54+}
55+
56+func TestCopyXRGB(t *testing.T) {
57+ img := image.NewRGBA(image.Rect(0, 0, 2, 2))
58+ copy(img.Pix, []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16})
59+ pitch := 12
60+ dst := make([]byte, pitch*2)
61+ copyXRGB(dst, pitch, img)
62+ want := []byte{3, 2, 1, 255, 7, 6, 5, 255, 0, 0, 0, 0, 11, 10, 9, 255, 15, 14, 13, 255, 0, 0, 0, 0}
63+ for i := range want {
64+ if dst[i] != want[i] {
65+ t.Fatalf("byte %d = %d, want %d (%v)", i, dst[i], want[i], dst)
66+ }
67+ }
68+}
69+
70+func TestCountFlipEvents(t *testing.T) {
71+ ev := func(typ uint32, n int) []byte {
72+ b := make([]byte, n)
73+ binary.LittleEndian.PutUint32(b, typ)
74+ binary.LittleEndian.PutUint32(b[4:], uint32(n))
75+ return b
76+ }
77+ buf := append(append(ev(eventFlipDone, 32), ev(1, 32)...), ev(eventFlipDone, 32)...)
78+ if n := countFlipEvents(buf); n != 2 {
79+ t.Fatalf("got %d flips", n)
80+ }
81+ if n := countFlipEvents(buf[:20]); n != 0 {
82+ t.Fatalf("truncated: got %d", n)
83+ }
84+}
A internal/adapters/drm/output.go +219 -0
@@ -0,0 +1,219 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "image"
8+ "time"
9+
10+ "github.com/bnema/nefertty/internal/ports"
11+ "github.com/bnema/zerowrap"
12+ "golang.org/x/sys/unix"
13+)
14+
15+// Renderer draws a scene into an RGBA image of the output size.
16+type Renderer interface {
17+ Render(ports.Scene, map[ports.WindowID]ports.SurfaceContent) error
18+ Pixels() *image.RGBA
19+ Close()
20+}
21+
22+// Output is one KMS connector driven with two dumb buffers.
23+type Output struct {
24+ fd int
25+ crtc uint32
26+ conn connector
27+ mode modeInfo
28+ saved modeCrtc
29+ bufs [2]*dumbBuffer
30+ back int
31+ pending bool
32+ flipStart time.Time
33+ flips int
34+ log zerowrap.Logger
35+}
36+
37+// Open picks the first connected connector on the card fd and allocates buffers.
38+func Open(fd int, card string, log zerowrap.Logger) (*Output, error) {
39+ crtcs, ids, err := resources(fd)
40+ if err != nil {
41+ return nil, err
42+ }
43+ var conns []connector
44+ for _, id := range ids {
45+ c, err := readConnector(fd, id)
46+ if err != nil {
47+ log.Warn().Err(err).Uint32("connector", id).Msg("read connector")
48+ continue
49+ }
50+ log.Info().Str("connector", c.name).Bool("connected", c.connected).Int("modes", len(c.modes)).Msg("connector")
51+ conns = append(conns, c)
52+ }
53+ c, mode, err := pickConnector(conns)
54+ if err != nil {
55+ return nil, fmt.Errorf("%s: %w", card, err)
56+ }
57+ crtc, err := pickCrtc(fd, c, crtcs)
58+ if err != nil {
59+ return nil, err
60+ }
61+ o := &Output{fd: fd, crtc: crtc, conn: c, mode: mode, log: log}
62+ o.saved, _ = getCrtc(fd, crtc)
63+ for i := range o.bufs {
64+ if o.bufs[i], err = newDumb(fd, o.Width(), o.Height()); err != nil {
65+ o.Close()
66+ return nil, err
67+ }
68+ }
69+ log.Info().Str("card", card).Str("connector", c.name).Str("mode", mode.String()).Str("mode_name", cstr(mode.Name[:])).Uint32("crtc", crtc).Msg("output")
70+ return o, nil
71+}
72+
73+func (o *Output) Width() int { return int(o.mode.HDisplay) }
74+func (o *Output) Height() int { return int(o.mode.VDisplay) }
75+
76+// modeset shows the front buffer; needed at start and after every VT resume.
77+func (o *Output) modeset() error {
78+ o.pending = false
79+ front := o.bufs[1-o.back]
80+ if err := setCrtc(o.fd, o.crtc, o.conn.id, front.fbID, &o.mode); err != nil {
81+ return fmt.Errorf("set crtc: %w", err)
82+ }
83+ o.log.Info().Str("connector", o.conn.name).Msg("modeset")
84+ return nil
85+}
86+
87+// present copies img into the back buffer and queues a page flip.
88+func (o *Output) present(img *image.RGBA) error {
89+ b := o.bufs[o.back]
90+ copyXRGB(b.mem, int(b.pitch), img)
91+ if err := flip(o.fd, o.crtc, b.fbID); err != nil {
92+ return err
93+ }
94+ o.pending, o.flipStart = true, time.Now()
95+ o.back = 1 - o.back
96+ return nil
97+}
98+
99+// Close restores the CRTC state found at startup and frees buffers.
100+func (o *Output) Close() {
101+ if o.saved.crtcID != 0 {
102+ ids := []uint32{o.conn.id}
103+ s := o.saved
104+ if s.modeValid != 0 {
105+ _ = setCrtc(o.fd, s.crtcID, ids[0], s.fbID, &s.mode)
106+ }
107+ }
108+ for _, b := range o.bufs {
109+ if b != nil {
110+ b.destroy(o.fd)
111+ }
112+ }
113+}
114+
115+// Run renders scenes and flips until ctx ends. active reports seat enable/disable.
116+func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer, error), active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent) error {
117+ r, err := newRenderer(o.Width(), o.Height())
118+ if err != nil {
119+ return fmt.Errorf("create renderer: %w", err)
120+ }
121+ defer r.Close()
122+ if err := o.modeset(); err != nil {
123+ return err
124+ }
125+ events := make(chan int, 4)
126+ evErr := make(chan error, 1)
127+ go o.readEvents(ctx, events, evErr)
128+ surfaces := make(map[ports.WindowID]ports.SurfaceContent)
129+ var scene ports.Scene
130+ haveScene, dirty, enabled := false, false, true
131+ frame := 0
132+ stats := time.NewTicker(10 * time.Second)
133+ defer stats.Stop()
134+ for {
135+ select {
136+ case <-ctx.Done():
137+ return nil
138+ case err := <-evErr:
139+ return err
140+ case on := <-active:
141+ if on && !enabled {
142+ if err := o.modeset(); err != nil {
143+ o.log.Error().Err(err).Msg("resume")
144+ return err
145+ }
146+ dirty = haveScene
147+ }
148+ enabled = on
149+ o.log.Info().Bool("enabled", on).Msg("output")
150+ case n := <-events:
151+ o.pending = false
152+ o.flips += n
153+ if d := time.Since(o.flipStart); d > 20*time.Millisecond {
154+ o.log.Info().Dur("flip_ms", d).Msg("slow flip")
155+ }
156+ case s := <-scenes:
157+ scene, haveScene, dirty = s, true, true
158+ case c := <-contents:
159+ if c.Pixels == nil {
160+ delete(surfaces, c.ID)
161+ } else {
162+ surfaces[c.ID] = c
163+ }
164+ dirty = true
165+ case <-stats.C:
166+ o.log.Info().Int("frames", frame).Int("flips", o.flips).Msg("stats")
167+ }
168+ if !dirty || !haveScene || !enabled || o.pending {
169+ continue
170+ }
171+ start := time.Now()
172+ if err := r.Render(scene, surfaces); err != nil {
173+ return fmt.Errorf("render frame: %w", err)
174+ }
175+ if err := o.present(r.Pixels()); err != nil {
176+ if errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM) {
177+ // Lost DRM master mid-switch; the seat disable follows.
178+ o.log.Warn().Err(err).Msg("flip refused")
179+ enabled = false
180+ continue
181+ }
182+ o.log.Error().Err(err).Msg("page flip")
183+ return fmt.Errorf("page flip: %w", err)
184+ }
185+ dirty = false
186+ frame++
187+ o.log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("render", time.Since(start)).Msg("frame")
188+ }
189+}
190+
191+func (o *Output) readEvents(ctx context.Context, events chan<- int, errs chan<- error) {
192+ buf := make([]byte, 1024)
193+ for ctx.Err() == nil {
194+ fds := []unix.PollFd{{Fd: int32(o.fd), Events: unix.POLLIN}}
195+ n, err := unix.Poll(fds, 100)
196+ if err != nil && !errors.Is(err, unix.EINTR) {
197+ errs <- fmt.Errorf("drm poll: %w", err)
198+ return
199+ }
200+ if n <= 0 {
201+ continue
202+ }
203+ m, err := unix.Read(o.fd, buf)
204+ if err != nil {
205+ if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
206+ continue
207+ }
208+ errs <- fmt.Errorf("drm read: %w", err)
209+ return
210+ }
211+ if c := countFlipEvents(buf[:m]); c > 0 {
212+ select {
213+ case events <- c:
214+ case <-ctx.Done():
215+ return
216+ }
217+ }
218+ }
219+}
@@ -0,0 +1,219 @@
1+package drm
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "image"
8+ "time"
9+
10+ "github.com/bnema/nefertty/internal/ports"
11+ "github.com/bnema/zerowrap"
12+ "golang.org/x/sys/unix"
13+)
14+
15+// Renderer draws a scene into an RGBA image of the output size.
16+type Renderer interface {
17+ Render(ports.Scene, map[ports.WindowID]ports.SurfaceContent) error
18+ Pixels() *image.RGBA
19+ Close()
20+}
21+
22+// Output is one KMS connector driven with two dumb buffers.
23+type Output struct {
24+ fd int
25+ crtc uint32
26+ conn connector
27+ mode modeInfo
28+ saved modeCrtc
29+ bufs [2]*dumbBuffer
30+ back int
31+ pending bool
32+ flipStart time.Time
33+ flips int
34+ log zerowrap.Logger
35+}
36+
37+// Open picks the first connected connector on the card fd and allocates buffers.
38+func Open(fd int, card string, log zerowrap.Logger) (*Output, error) {
39+ crtcs, ids, err := resources(fd)
40+ if err != nil {
41+ return nil, err
42+ }
43+ var conns []connector
44+ for _, id := range ids {
45+ c, err := readConnector(fd, id)
46+ if err != nil {
47+ log.Warn().Err(err).Uint32("connector", id).Msg("read connector")
48+ continue
49+ }
50+ log.Info().Str("connector", c.name).Bool("connected", c.connected).Int("modes", len(c.modes)).Msg("connector")
51+ conns = append(conns, c)
52+ }
53+ c, mode, err := pickConnector(conns)
54+ if err != nil {
55+ return nil, fmt.Errorf("%s: %w", card, err)
56+ }
57+ crtc, err := pickCrtc(fd, c, crtcs)
58+ if err != nil {
59+ return nil, err
60+ }
61+ o := &Output{fd: fd, crtc: crtc, conn: c, mode: mode, log: log}
62+ o.saved, _ = getCrtc(fd, crtc)
63+ for i := range o.bufs {
64+ if o.bufs[i], err = newDumb(fd, o.Width(), o.Height()); err != nil {
65+ o.Close()
66+ return nil, err
67+ }
68+ }
69+ log.Info().Str("card", card).Str("connector", c.name).Str("mode", mode.String()).Str("mode_name", cstr(mode.Name[:])).Uint32("crtc", crtc).Msg("output")
70+ return o, nil
71+}
72+
73+func (o *Output) Width() int { return int(o.mode.HDisplay) }
74+func (o *Output) Height() int { return int(o.mode.VDisplay) }
75+
76+// modeset shows the front buffer; needed at start and after every VT resume.
77+func (o *Output) modeset() error {
78+ o.pending = false
79+ front := o.bufs[1-o.back]
80+ if err := setCrtc(o.fd, o.crtc, o.conn.id, front.fbID, &o.mode); err != nil {
81+ return fmt.Errorf("set crtc: %w", err)
82+ }
83+ o.log.Info().Str("connector", o.conn.name).Msg("modeset")
84+ return nil
85+}
86+
87+// present copies img into the back buffer and queues a page flip.
88+func (o *Output) present(img *image.RGBA) error {
89+ b := o.bufs[o.back]
90+ copyXRGB(b.mem, int(b.pitch), img)
91+ if err := flip(o.fd, o.crtc, b.fbID); err != nil {
92+ return err
93+ }
94+ o.pending, o.flipStart = true, time.Now()
95+ o.back = 1 - o.back
96+ return nil
97+}
98+
99+// Close restores the CRTC state found at startup and frees buffers.
100+func (o *Output) Close() {
101+ if o.saved.crtcID != 0 {
102+ ids := []uint32{o.conn.id}
103+ s := o.saved
104+ if s.modeValid != 0 {
105+ _ = setCrtc(o.fd, s.crtcID, ids[0], s.fbID, &s.mode)
106+ }
107+ }
108+ for _, b := range o.bufs {
109+ if b != nil {
110+ b.destroy(o.fd)
111+ }
112+ }
113+}
114+
115+// Run renders scenes and flips until ctx ends. active reports seat enable/disable.
116+func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer, error), active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent) error {
117+ r, err := newRenderer(o.Width(), o.Height())
118+ if err != nil {
119+ return fmt.Errorf("create renderer: %w", err)
120+ }
121+ defer r.Close()
122+ if err := o.modeset(); err != nil {
123+ return err
124+ }
125+ events := make(chan int, 4)
126+ evErr := make(chan error, 1)
127+ go o.readEvents(ctx, events, evErr)
128+ surfaces := make(map[ports.WindowID]ports.SurfaceContent)
129+ var scene ports.Scene
130+ haveScene, dirty, enabled := false, false, true
131+ frame := 0
132+ stats := time.NewTicker(10 * time.Second)
133+ defer stats.Stop()
134+ for {
135+ select {
136+ case <-ctx.Done():
137+ return nil
138+ case err := <-evErr:
139+ return err
140+ case on := <-active:
141+ if on && !enabled {
142+ if err := o.modeset(); err != nil {
143+ o.log.Error().Err(err).Msg("resume")
144+ return err
145+ }
146+ dirty = haveScene
147+ }
148+ enabled = on
149+ o.log.Info().Bool("enabled", on).Msg("output")
150+ case n := <-events:
151+ o.pending = false
152+ o.flips += n
153+ if d := time.Since(o.flipStart); d > 20*time.Millisecond {
154+ o.log.Info().Dur("flip_ms", d).Msg("slow flip")
155+ }
156+ case s := <-scenes:
157+ scene, haveScene, dirty = s, true, true
158+ case c := <-contents:
159+ if c.Pixels == nil {
160+ delete(surfaces, c.ID)
161+ } else {
162+ surfaces[c.ID] = c
163+ }
164+ dirty = true
165+ case <-stats.C:
166+ o.log.Info().Int("frames", frame).Int("flips", o.flips).Msg("stats")
167+ }
168+ if !dirty || !haveScene || !enabled || o.pending {
169+ continue
170+ }
171+ start := time.Now()
172+ if err := r.Render(scene, surfaces); err != nil {
173+ return fmt.Errorf("render frame: %w", err)
174+ }
175+ if err := o.present(r.Pixels()); err != nil {
176+ if errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM) {
177+ // Lost DRM master mid-switch; the seat disable follows.
178+ o.log.Warn().Err(err).Msg("flip refused")
179+ enabled = false
180+ continue
181+ }
182+ o.log.Error().Err(err).Msg("page flip")
183+ return fmt.Errorf("page flip: %w", err)
184+ }
185+ dirty = false
186+ frame++
187+ o.log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("render", time.Since(start)).Msg("frame")
188+ }
189+}
190+
191+func (o *Output) readEvents(ctx context.Context, events chan<- int, errs chan<- error) {
192+ buf := make([]byte, 1024)
193+ for ctx.Err() == nil {
194+ fds := []unix.PollFd{{Fd: int32(o.fd), Events: unix.POLLIN}}
195+ n, err := unix.Poll(fds, 100)
196+ if err != nil && !errors.Is(err, unix.EINTR) {
197+ errs <- fmt.Errorf("drm poll: %w", err)
198+ return
199+ }
200+ if n <= 0 {
201+ continue
202+ }
203+ m, err := unix.Read(o.fd, buf)
204+ if err != nil {
205+ if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
206+ continue
207+ }
208+ errs <- fmt.Errorf("drm read: %w", err)
209+ return
210+ }
211+ if c := countFlipEvents(buf[:m]); c > 0 {
212+ select {
213+ case events <- c:
214+ case <-ctx.Done():
215+ return
216+ }
217+ }
218+ }
219+}
A internal/adapters/libinput/libinput.go +295 -0
@@ -0,0 +1,295 @@
1+// Package libinput reads keyboards and pointers through libinput's udev backend.
2+package libinput
3+
4+import (
5+ "context"
6+ "errors"
7+ "fmt"
8+ "strings"
9+ "sync"
10+ "time"
11+ "unsafe"
12+
13+ "github.com/bnema/nefertty/internal/adapters/xkb"
14+ "github.com/bnema/nefertty/internal/ports"
15+ "github.com/bnema/purego"
16+ "github.com/bnema/zerowrap"
17+ "golang.org/x/sys/unix"
18+)
19+
20+// ErrEmergencyQuit is returned when Ctrl+Alt+Backspace is pressed.
21+var ErrEmergencyQuit = errors.New("emergency quit key")
22+
23+// Seat is the device broker libinput opens devices through.
24+type Seat interface {
25+ OpenDevice(path string) (int, error)
26+ CloseDevice(fd int)
27+ SwitchVT(vt int)
28+}
29+
30+const (
31+ evDeviceAdded = 1
32+ evDeviceRemoved = 2
33+ evKeyboardKey = 300
34+ evPointerMotion = 400
35+ evPointerAbs = 401
36+ evPointerButton = 402
37+)
38+
39+var (
40+ loadOnce sync.Once
41+ loadErr error
42+
43+ udevNew func() uintptr
44+ udevUnref func(uintptr) uintptr
45+ createContext func(iface unsafe.Pointer, data uintptr, udev uintptr) uintptr
46+ assignSeat func(li uintptr, seat string) int32
47+ unref func(li uintptr) uintptr
48+ getFD func(li uintptr) int32
49+ dispatch func(li uintptr) int32
50+ getEvent func(li uintptr) uintptr
51+ eventType func(ev uintptr) int32
52+ eventDestroy func(ev uintptr)
53+ eventDevice func(ev uintptr) uintptr
54+ deviceName func(dev uintptr) string
55+ suspend func(li uintptr)
56+ resume func(li uintptr) int32
57+ keyboardEvent func(ev uintptr) uintptr
58+ keyboardKey func(kev uintptr) uint32
59+ keyboardState func(kev uintptr) int32
60+ pointerEvent func(ev uintptr) uintptr
61+ pointerDX func(pev uintptr) float64
62+ pointerDY func(pev uintptr) float64
63+ pointerAbsX func(pev uintptr, width uint32) float64
64+ pointerAbsY func(pev uintptr, height uint32) float64
65+ pointerButton func(pev uintptr) uint32
66+ pointerBtnState func(pev uintptr) int32
67+ iface [2]uintptr
68+ active Seat
69+)
70+
71+func load() error {
72+ loadOnce.Do(func() {
73+ udev, err := purego.Dlopen("libudev.so.1", purego.RTLD_NOW|purego.RTLD_LOCAL)
74+ if err != nil {
75+ loadErr = fmt.Errorf("open libudev.so.1: %w", err)
76+ return
77+ }
78+ lib, err := purego.Dlopen("libinput.so.10", purego.RTLD_NOW|purego.RTLD_LOCAL)
79+ if err != nil {
80+ loadErr = fmt.Errorf("open libinput.so.10: %w", err)
81+ return
82+ }
83+ defer func() {
84+ if r := recover(); r != nil {
85+ loadErr = fmt.Errorf("libinput: %v", r)
86+ }
87+ }()
88+ purego.RegisterLibFunc(&udevNew, udev, "udev_new")
89+ purego.RegisterLibFunc(&udevUnref, udev, "udev_unref")
90+ reg := func(fn any, name string) { purego.RegisterLibFunc(fn, lib, "libinput_"+name) }
91+ reg(&createContext, "udev_create_context")
92+ reg(&assignSeat, "udev_assign_seat")
93+ reg(&unref, "unref")
94+ reg(&getFD, "get_fd")
95+ reg(&dispatch, "dispatch")
96+ reg(&getEvent, "get_event")
97+ reg(&eventType, "event_get_type")
98+ reg(&eventDestroy, "event_destroy")
99+ reg(&eventDevice, "event_get_device")
100+ reg(&deviceName, "device_get_name")
101+ reg(&suspend, "suspend")
102+ reg(&resume, "resume")
103+ reg(&keyboardEvent, "event_get_keyboard_event")
104+ reg(&keyboardKey, "event_keyboard_get_key")
105+ reg(&keyboardState, "event_keyboard_get_key_state")
106+ reg(&pointerEvent, "event_get_pointer_event")
107+ reg(&pointerDX, "event_pointer_get_dx")
108+ reg(&pointerDY, "event_pointer_get_dy")
109+ reg(&pointerAbsX, "event_pointer_get_absolute_x_transformed")
110+ reg(&pointerAbsY, "event_pointer_get_absolute_y_transformed")
111+ reg(&pointerButton, "event_pointer_get_button")
112+ reg(&pointerBtnState, "event_pointer_get_button_state")
113+ iface[0] = purego.NewCallback(func(path *byte, _, _ uintptr) uintptr {
114+ fd, err := active.OpenDevice(goString(path))
115+ if err != nil {
116+ return uintptr(uint32(^uint32(unix.EACCES) + 1)) // -EACCES as C int
117+ }
118+ return uintptr(fd)
119+ })
120+ iface[1] = purego.NewCallback(func(fd, _ uintptr) { active.CloseDevice(int(int32(fd))) })
121+ })
122+ return loadErr
123+}
124+
125+func goString(p *byte) string {
126+ if p == nil {
127+ return ""
128+ }
129+ n := 0
130+ for *(*byte)(unsafe.Add(unsafe.Pointer(p), n)) != 0 {
131+ n++
132+ }
133+ return string(unsafe.Slice(p, n))
134+}
135+
136+// Options configures the input adapter.
137+type Options struct {
138+ Seat Seat
139+ SeatName string
140+ Keymap *xkb.Keymap
141+ Width, Height int
142+ Active <-chan bool
143+ Log zerowrap.Logger
144+}
145+
146+// Run owns libinput and the keymap, converting device events into input events.
147+func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error {
148+ defer opts.Keymap.Close()
149+ if err := load(); err != nil {
150+ return err
151+ }
152+ active = opts.Seat
153+ udev := udevNew()
154+ if udev == 0 {
155+ return errors.New("udev_new failed")
156+ }
157+ defer udevUnref(udev)
158+ li := createContext(unsafe.Pointer(&iface), 0, udev)
159+ if li == 0 {
160+ return errors.New("libinput_udev_create_context failed")
161+ }
162+ defer unref(li)
163+ name := opts.SeatName
164+ if name == "" {
165+ name = "seat0"
166+ }
167+ if assignSeat(li, name) != 0 {
168+ return fmt.Errorf("libinput_udev_assign_seat %s failed", name)
169+ }
170+ p := pointer{w: float64(opts.Width), h: float64(opts.Height)}
171+ p.x, p.y = p.w/2, p.h/2
172+ fd := getFD(li)
173+ for ctx.Err() == nil {
174+ select {
175+ case on := <-opts.Active:
176+ if on {
177+ resume(li)
178+ } else {
179+ suspend(li)
180+ }
181+ opts.Log.Info().Bool("active", on).Msg("input")
182+ default:
183+ }
184+ fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}
185+ n, err := unix.Poll(fds, 100)
186+ if err != nil && !errors.Is(err, unix.EINTR) {
187+ return fmt.Errorf("libinput poll: %w", err)
188+ }
189+ if n <= 0 {
190+ continue
191+ }
192+ if dispatch(li) != 0 {
193+ opts.Log.Warn().Msg("libinput_dispatch failed")
194+ }
195+ for ev := getEvent(li); ev != 0; ev = getEvent(li) {
196+ out, err := translate(ev, opts, &p)
197+ eventDestroy(ev)
198+ if err != nil {
199+ return err
200+ }
201+ if out == nil {
202+ continue
203+ }
204+ select {
205+ case input <- out:
206+ case <-ctx.Done():
207+ return nil
208+ }
209+ }
210+ }
211+ return nil
212+}
213+
214+func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) {
215+ now := uint32(time.Now().UnixMilli())
216+ log := opts.Log
217+ switch eventType(ev) {
218+ case evDeviceAdded:
219+ log.Info().Str("device", deviceName(eventDevice(ev))).Msg("input device added")
220+ case evDeviceRemoved:
221+ log.Info().Str("device", deviceName(eventDevice(ev))).Msg("input device removed")
222+ case evKeyboardKey:
223+ k := keyboardEvent(ev)
224+ code, pressed := keyboardKey(k), keyboardState(k) == 1
225+ ke := opts.Keymap.Key(code, pressed, now)
226+ log.Debug().Uint32("code", code).Str("keysym", ke.Keysym).Bool("pressed", pressed).Uint8("mods", uint8(ke.Mods)).Msg("key")
227+ switch action, vt := hotkey(ke); action {
228+ case hotkeyQuit:
229+ log.Warn().Str("reason", "emergency-key").Msg("quit")
230+ return nil, ErrEmergencyQuit
231+ case hotkeyVT:
232+ opts.Seat.SwitchVT(vt)
233+ return nil, nil
234+ }
235+ return ke, nil
236+ case evPointerMotion:
237+ pe := pointerEvent(ev)
238+ x, y := p.move(pointerDX(pe), pointerDY(pe))
239+ log.Debug().Float64("x", x).Float64("y", y).Msg("pointer")
240+ return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil
241+ case evPointerAbs:
242+ pe := pointerEvent(ev)
243+ x, y := p.set(pointerAbsX(pe, uint32(p.w)), pointerAbsY(pe, uint32(p.h)))
244+ return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil
245+ case evPointerButton:
246+ pe := pointerEvent(ev)
247+ b, pressed := pointerButton(pe), pointerBtnState(pe) == 1
248+ log.Debug().Uint32("button", b).Bool("pressed", pressed).Msg("button")
249+ return ports.PointerButton{Button: b, Pressed: pressed, TimeMsec: now}, nil
250+ }
251+ return nil, nil
252+}
253+
254+type hotkeyAction int
255+
256+const (
257+ hotkeyNone hotkeyAction = iota
258+ hotkeyQuit
259+ hotkeyVT
260+)
261+
262+// hotkey detects compositor-reserved keys on press: Ctrl+Alt+Backspace and Ctrl+Alt+F1..F12.
263+func hotkey(ke ports.KeyEvent) (hotkeyAction, int) {
264+ if !ke.Pressed {
265+ return hotkeyNone, 0
266+ }
267+ if vt, ok := strings.CutPrefix(ke.Keysym, "XF86Switch_VT_"); ok {
268+ var n int
269+ if _, err := fmt.Sscan(vt, &n); err == nil && n >= 1 && n <= 12 {
270+ return hotkeyVT, n
271+ }
272+ }
273+ if ke.Mods&(ports.ModCtrl|ports.ModAlt) != ports.ModCtrl|ports.ModAlt {
274+ return hotkeyNone, 0
275+ }
276+ switch {
277+ case ke.Keysym == "BackSpace" || ke.Keycode == 14:
278+ return hotkeyQuit, 0
279+ case ke.Keycode >= 59 && ke.Keycode <= 68: // F1..F10
280+ return hotkeyVT, int(ke.Keycode) - 58
281+ case ke.Keycode == 87 || ke.Keycode == 88: // F11, F12
282+ return hotkeyVT, int(ke.Keycode) - 76
283+ }
284+ return hotkeyNone, 0
285+}
286+
287+type pointer struct{ x, y, w, h float64 }
288+
289+func (p *pointer) move(dx, dy float64) (float64, float64) { return p.set(p.x+dx, p.y+dy) }
290+
291+func (p *pointer) set(x, y float64) (float64, float64) {
292+ p.x = min(max(x, 0), p.w-1)
293+ p.y = min(max(y, 0), p.h-1)
294+ return p.x, p.y
295+}
@@ -0,0 +1,295 @@
1+// Package libinput reads keyboards and pointers through libinput's udev backend.
2+package libinput
3+
4+import (
5+ "context"
6+ "errors"
7+ "fmt"
8+ "strings"
9+ "sync"
10+ "time"
11+ "unsafe"
12+
13+ "github.com/bnema/nefertty/internal/adapters/xkb"
14+ "github.com/bnema/nefertty/internal/ports"
15+ "github.com/bnema/purego"
16+ "github.com/bnema/zerowrap"
17+ "golang.org/x/sys/unix"
18+)
19+
20+// ErrEmergencyQuit is returned when Ctrl+Alt+Backspace is pressed.
21+var ErrEmergencyQuit = errors.New("emergency quit key")
22+
23+// Seat is the device broker libinput opens devices through.
24+type Seat interface {
25+ OpenDevice(path string) (int, error)
26+ CloseDevice(fd int)
27+ SwitchVT(vt int)
28+}
29+
30+const (
31+ evDeviceAdded = 1
32+ evDeviceRemoved = 2
33+ evKeyboardKey = 300
34+ evPointerMotion = 400
35+ evPointerAbs = 401
36+ evPointerButton = 402
37+)
38+
39+var (
40+ loadOnce sync.Once
41+ loadErr error
42+
43+ udevNew func() uintptr
44+ udevUnref func(uintptr) uintptr
45+ createContext func(iface unsafe.Pointer, data uintptr, udev uintptr) uintptr
46+ assignSeat func(li uintptr, seat string) int32
47+ unref func(li uintptr) uintptr
48+ getFD func(li uintptr) int32
49+ dispatch func(li uintptr) int32
50+ getEvent func(li uintptr) uintptr
51+ eventType func(ev uintptr) int32
52+ eventDestroy func(ev uintptr)
53+ eventDevice func(ev uintptr) uintptr
54+ deviceName func(dev uintptr) string
55+ suspend func(li uintptr)
56+ resume func(li uintptr) int32
57+ keyboardEvent func(ev uintptr) uintptr
58+ keyboardKey func(kev uintptr) uint32
59+ keyboardState func(kev uintptr) int32
60+ pointerEvent func(ev uintptr) uintptr
61+ pointerDX func(pev uintptr) float64
62+ pointerDY func(pev uintptr) float64
63+ pointerAbsX func(pev uintptr, width uint32) float64
64+ pointerAbsY func(pev uintptr, height uint32) float64
65+ pointerButton func(pev uintptr) uint32
66+ pointerBtnState func(pev uintptr) int32
67+ iface [2]uintptr
68+ active Seat
69+)
70+
71+func load() error {
72+ loadOnce.Do(func() {
73+ udev, err := purego.Dlopen("libudev.so.1", purego.RTLD_NOW|purego.RTLD_LOCAL)
74+ if err != nil {
75+ loadErr = fmt.Errorf("open libudev.so.1: %w", err)
76+ return
77+ }
78+ lib, err := purego.Dlopen("libinput.so.10", purego.RTLD_NOW|purego.RTLD_LOCAL)
79+ if err != nil {
80+ loadErr = fmt.Errorf("open libinput.so.10: %w", err)
81+ return
82+ }
83+ defer func() {
84+ if r := recover(); r != nil {
85+ loadErr = fmt.Errorf("libinput: %v", r)
86+ }
87+ }()
88+ purego.RegisterLibFunc(&udevNew, udev, "udev_new")
89+ purego.RegisterLibFunc(&udevUnref, udev, "udev_unref")
90+ reg := func(fn any, name string) { purego.RegisterLibFunc(fn, lib, "libinput_"+name) }
91+ reg(&createContext, "udev_create_context")
92+ reg(&assignSeat, "udev_assign_seat")
93+ reg(&unref, "unref")
94+ reg(&getFD, "get_fd")
95+ reg(&dispatch, "dispatch")
96+ reg(&getEvent, "get_event")
97+ reg(&eventType, "event_get_type")
98+ reg(&eventDestroy, "event_destroy")
99+ reg(&eventDevice, "event_get_device")
100+ reg(&deviceName, "device_get_name")
101+ reg(&suspend, "suspend")
102+ reg(&resume, "resume")
103+ reg(&keyboardEvent, "event_get_keyboard_event")
104+ reg(&keyboardKey, "event_keyboard_get_key")
105+ reg(&keyboardState, "event_keyboard_get_key_state")
106+ reg(&pointerEvent, "event_get_pointer_event")
107+ reg(&pointerDX, "event_pointer_get_dx")
108+ reg(&pointerDY, "event_pointer_get_dy")
109+ reg(&pointerAbsX, "event_pointer_get_absolute_x_transformed")
110+ reg(&pointerAbsY, "event_pointer_get_absolute_y_transformed")
111+ reg(&pointerButton, "event_pointer_get_button")
112+ reg(&pointerBtnState, "event_pointer_get_button_state")
113+ iface[0] = purego.NewCallback(func(path *byte, _, _ uintptr) uintptr {
114+ fd, err := active.OpenDevice(goString(path))
115+ if err != nil {
116+ return uintptr(uint32(^uint32(unix.EACCES) + 1)) // -EACCES as C int
117+ }
118+ return uintptr(fd)
119+ })
120+ iface[1] = purego.NewCallback(func(fd, _ uintptr) { active.CloseDevice(int(int32(fd))) })
121+ })
122+ return loadErr
123+}
124+
125+func goString(p *byte) string {
126+ if p == nil {
127+ return ""
128+ }
129+ n := 0
130+ for *(*byte)(unsafe.Add(unsafe.Pointer(p), n)) != 0 {
131+ n++
132+ }
133+ return string(unsafe.Slice(p, n))
134+}
135+
136+// Options configures the input adapter.
137+type Options struct {
138+ Seat Seat
139+ SeatName string
140+ Keymap *xkb.Keymap
141+ Width, Height int
142+ Active <-chan bool
143+ Log zerowrap.Logger
144+}
145+
146+// Run owns libinput and the keymap, converting device events into input events.
147+func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error {
148+ defer opts.Keymap.Close()
149+ if err := load(); err != nil {
150+ return err
151+ }
152+ active = opts.Seat
153+ udev := udevNew()
154+ if udev == 0 {
155+ return errors.New("udev_new failed")
156+ }
157+ defer udevUnref(udev)
158+ li := createContext(unsafe.Pointer(&iface), 0, udev)
159+ if li == 0 {
160+ return errors.New("libinput_udev_create_context failed")
161+ }
162+ defer unref(li)
163+ name := opts.SeatName
164+ if name == "" {
165+ name = "seat0"
166+ }
167+ if assignSeat(li, name) != 0 {
168+ return fmt.Errorf("libinput_udev_assign_seat %s failed", name)
169+ }
170+ p := pointer{w: float64(opts.Width), h: float64(opts.Height)}
171+ p.x, p.y = p.w/2, p.h/2
172+ fd := getFD(li)
173+ for ctx.Err() == nil {
174+ select {
175+ case on := <-opts.Active:
176+ if on {
177+ resume(li)
178+ } else {
179+ suspend(li)
180+ }
181+ opts.Log.Info().Bool("active", on).Msg("input")
182+ default:
183+ }
184+ fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}
185+ n, err := unix.Poll(fds, 100)
186+ if err != nil && !errors.Is(err, unix.EINTR) {
187+ return fmt.Errorf("libinput poll: %w", err)
188+ }
189+ if n <= 0 {
190+ continue
191+ }
192+ if dispatch(li) != 0 {
193+ opts.Log.Warn().Msg("libinput_dispatch failed")
194+ }
195+ for ev := getEvent(li); ev != 0; ev = getEvent(li) {
196+ out, err := translate(ev, opts, &p)
197+ eventDestroy(ev)
198+ if err != nil {
199+ return err
200+ }
201+ if out == nil {
202+ continue
203+ }
204+ select {
205+ case input <- out:
206+ case <-ctx.Done():
207+ return nil
208+ }
209+ }
210+ }
211+ return nil
212+}
213+
214+func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) {
215+ now := uint32(time.Now().UnixMilli())
216+ log := opts.Log
217+ switch eventType(ev) {
218+ case evDeviceAdded:
219+ log.Info().Str("device", deviceName(eventDevice(ev))).Msg("input device added")
220+ case evDeviceRemoved:
221+ log.Info().Str("device", deviceName(eventDevice(ev))).Msg("input device removed")
222+ case evKeyboardKey:
223+ k := keyboardEvent(ev)
224+ code, pressed := keyboardKey(k), keyboardState(k) == 1
225+ ke := opts.Keymap.Key(code, pressed, now)
226+ log.Debug().Uint32("code", code).Str("keysym", ke.Keysym).Bool("pressed", pressed).Uint8("mods", uint8(ke.Mods)).Msg("key")
227+ switch action, vt := hotkey(ke); action {
228+ case hotkeyQuit:
229+ log.Warn().Str("reason", "emergency-key").Msg("quit")
230+ return nil, ErrEmergencyQuit
231+ case hotkeyVT:
232+ opts.Seat.SwitchVT(vt)
233+ return nil, nil
234+ }
235+ return ke, nil
236+ case evPointerMotion:
237+ pe := pointerEvent(ev)
238+ x, y := p.move(pointerDX(pe), pointerDY(pe))
239+ log.Debug().Float64("x", x).Float64("y", y).Msg("pointer")
240+ return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil
241+ case evPointerAbs:
242+ pe := pointerEvent(ev)
243+ x, y := p.set(pointerAbsX(pe, uint32(p.w)), pointerAbsY(pe, uint32(p.h)))
244+ return ports.PointerMotion{X: x, Y: y, TimeMsec: now}, nil
245+ case evPointerButton:
246+ pe := pointerEvent(ev)
247+ b, pressed := pointerButton(pe), pointerBtnState(pe) == 1
248+ log.Debug().Uint32("button", b).Bool("pressed", pressed).Msg("button")
249+ return ports.PointerButton{Button: b, Pressed: pressed, TimeMsec: now}, nil
250+ }
251+ return nil, nil
252+}
253+
254+type hotkeyAction int
255+
256+const (
257+ hotkeyNone hotkeyAction = iota
258+ hotkeyQuit
259+ hotkeyVT
260+)
261+
262+// hotkey detects compositor-reserved keys on press: Ctrl+Alt+Backspace and Ctrl+Alt+F1..F12.
263+func hotkey(ke ports.KeyEvent) (hotkeyAction, int) {
264+ if !ke.Pressed {
265+ return hotkeyNone, 0
266+ }
267+ if vt, ok := strings.CutPrefix(ke.Keysym, "XF86Switch_VT_"); ok {
268+ var n int
269+ if _, err := fmt.Sscan(vt, &n); err == nil && n >= 1 && n <= 12 {
270+ return hotkeyVT, n
271+ }
272+ }
273+ if ke.Mods&(ports.ModCtrl|ports.ModAlt) != ports.ModCtrl|ports.ModAlt {
274+ return hotkeyNone, 0
275+ }
276+ switch {
277+ case ke.Keysym == "BackSpace" || ke.Keycode == 14:
278+ return hotkeyQuit, 0
279+ case ke.Keycode >= 59 && ke.Keycode <= 68: // F1..F10
280+ return hotkeyVT, int(ke.Keycode) - 58
281+ case ke.Keycode == 87 || ke.Keycode == 88: // F11, F12
282+ return hotkeyVT, int(ke.Keycode) - 76
283+ }
284+ return hotkeyNone, 0
285+}
286+
287+type pointer struct{ x, y, w, h float64 }
288+
289+func (p *pointer) move(dx, dy float64) (float64, float64) { return p.set(p.x+dx, p.y+dy) }
290+
291+func (p *pointer) set(x, y float64) (float64, float64) {
292+ p.x = min(max(x, 0), p.w-1)
293+ p.y = min(max(y, 0), p.h-1)
294+ return p.x, p.y
295+}
A internal/adapters/libinput/libinput_test.go +40 -0
@@ -0,0 +1,40 @@
1+package libinput
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+)
8+
9+func TestHotkey(t *testing.T) {
10+ ca := ports.ModCtrl | ports.ModAlt
11+ for _, tc := range []struct {
12+ name string
13+ ev ports.KeyEvent
14+ action hotkeyAction
15+ vt int
16+ }{
17+ {"ctrl alt backspace", ports.KeyEvent{Keysym: "BackSpace", Keycode: 14, Mods: ca, Pressed: true}, hotkeyQuit, 0},
18+ {"release ignored", ports.KeyEvent{Keysym: "BackSpace", Keycode: 14, Mods: ca}, hotkeyNone, 0},
19+ {"ctrl only", ports.KeyEvent{Keysym: "BackSpace", Keycode: 14, Mods: ports.ModCtrl, Pressed: true}, hotkeyNone, 0},
20+ {"xkb vt sym", ports.KeyEvent{Keysym: "XF86Switch_VT_3", Keycode: 61, Mods: ca, Pressed: true}, hotkeyVT, 3},
21+ {"ctrl alt f2 raw", ports.KeyEvent{Keysym: "F2", Keycode: 60, Mods: ca, Pressed: true}, hotkeyVT, 2},
22+ {"ctrl alt f12 raw", ports.KeyEvent{Keysym: "F12", Keycode: 88, Mods: ca, Pressed: true}, hotkeyVT, 12},
23+ {"plain f2", ports.KeyEvent{Keysym: "F2", Keycode: 60, Pressed: true}, hotkeyNone, 0},
24+ } {
25+ a, vt := hotkey(tc.ev)
26+ if a != tc.action || vt != tc.vt {
27+ t.Errorf("%s: got %v %d", tc.name, a, vt)
28+ }
29+ }
30+}
31+
32+func TestPointerClamp(t *testing.T) {
33+ p := pointer{x: 10, y: 10, w: 100, h: 50}
34+ if x, y := p.move(-20, 100); x != 0 || y != 49 {
35+ t.Fatalf("got %v %v", x, y)
36+ }
37+ if x, y := p.move(30.5, -5); x != 30.5 || y != 44 {
38+ t.Fatalf("got %v %v", x, y)
39+ }
40+}
@@ -0,0 +1,40 @@
1+package libinput
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/nefertty/internal/ports"
7+)
8+
9+func TestHotkey(t *testing.T) {
10+ ca := ports.ModCtrl | ports.ModAlt
11+ for _, tc := range []struct {
12+ name string
13+ ev ports.KeyEvent
14+ action hotkeyAction
15+ vt int
16+ }{
17+ {"ctrl alt backspace", ports.KeyEvent{Keysym: "BackSpace", Keycode: 14, Mods: ca, Pressed: true}, hotkeyQuit, 0},
18+ {"release ignored", ports.KeyEvent{Keysym: "BackSpace", Keycode: 14, Mods: ca}, hotkeyNone, 0},
19+ {"ctrl only", ports.KeyEvent{Keysym: "BackSpace", Keycode: 14, Mods: ports.ModCtrl, Pressed: true}, hotkeyNone, 0},
20+ {"xkb vt sym", ports.KeyEvent{Keysym: "XF86Switch_VT_3", Keycode: 61, Mods: ca, Pressed: true}, hotkeyVT, 3},
21+ {"ctrl alt f2 raw", ports.KeyEvent{Keysym: "F2", Keycode: 60, Mods: ca, Pressed: true}, hotkeyVT, 2},
22+ {"ctrl alt f12 raw", ports.KeyEvent{Keysym: "F12", Keycode: 88, Mods: ca, Pressed: true}, hotkeyVT, 12},
23+ {"plain f2", ports.KeyEvent{Keysym: "F2", Keycode: 60, Pressed: true}, hotkeyNone, 0},
24+ } {
25+ a, vt := hotkey(tc.ev)
26+ if a != tc.action || vt != tc.vt {
27+ t.Errorf("%s: got %v %d", tc.name, a, vt)
28+ }
29+ }
30+}
31+
32+func TestPointerClamp(t *testing.T) {
33+ p := pointer{x: 10, y: 10, w: 100, h: 50}
34+ if x, y := p.move(-20, 100); x != 0 || y != 49 {
35+ t.Fatalf("got %v %v", x, y)
36+ }
37+ if x, y := p.move(30.5, -5); x != 30.5 || y != 44 {
38+ t.Fatalf("got %v %v", x, y)
39+ }
40+}
A internal/adapters/seat/seat.go +234 -0
@@ -0,0 +1,234 @@
1+// Package seat opens a libseat session (logind or seatd) and hands out device fds.
2+package seat
3+
4+import (
5+ "context"
6+ "errors"
7+ "fmt"
8+ "runtime"
9+ "sync"
10+ "unsafe"
11+
12+ "github.com/bnema/purego"
13+ "github.com/bnema/zerowrap"
14+ "golang.org/x/sys/unix"
15+)
16+
17+var (
18+ loadOnce sync.Once
19+ loadErr error
20+
21+ openSeat func(listener unsafe.Pointer, data uintptr) uintptr
22+ closeSeat func(seat uintptr) int32
23+ disableSeat func(seat uintptr) int32
24+ openDevice func(seat uintptr, path string, fd *int32) int32
25+ closeDevice func(seat uintptr, id int32) int32
26+ seatName func(seat uintptr) string
27+ switchSession func(seat uintptr, session int32) int32
28+ getFD func(seat uintptr) int32
29+ dispatch func(seat uintptr, timeout int32) int32
30+
31+ listener [2]uintptr
32+ // libseat allows one seat per process; callbacks find it here.
33+ current *Seat
34+)
35+
36+func load() error {
37+ loadOnce.Do(func() {
38+ lib, err := purego.Dlopen("libseat.so.1", purego.RTLD_NOW|purego.RTLD_LOCAL)
39+ if err != nil {
40+ loadErr = fmt.Errorf("open libseat.so.1: %w", err)
41+ return
42+ }
43+ defer func() {
44+ if r := recover(); r != nil {
45+ loadErr = fmt.Errorf("libseat: %v", r)
46+ }
47+ }()
48+ reg := func(fn any, name string) { purego.RegisterLibFunc(fn, lib, "libseat_"+name) }
49+ reg(&openSeat, "open_seat")
50+ reg(&closeSeat, "close_seat")
51+ reg(&disableSeat, "disable_seat")
52+ reg(&openDevice, "open_device")
53+ reg(&closeDevice, "close_device")
54+ reg(&seatName, "seat_name")
55+ reg(&switchSession, "switch_session")
56+ reg(&getFD, "get_fd")
57+ reg(&dispatch, "dispatch")
58+ listener[0] = purego.NewCallback(func(_, _ uintptr) { current.changed(true) })
59+ listener[1] = purego.NewCallback(func(_, _ uintptr) { current.changed(false) })
60+ })
61+ return loadErr
62+}
63+
64+// Seat owns the libseat handle. libseat is not thread-safe, so every call goes
65+// through mu; callbacks only run inside dispatch, which already holds it.
66+type Seat struct {
67+ mu sync.Mutex
68+ handle uintptr
69+ active bool
70+ devices map[int32]int32 // fd → libseat device id
71+ subs []chan bool
72+ log zerowrap.Logger
73+}
74+
75+// Open connects to logind or seatd and waits for the first enable.
76+func Open(ctx context.Context, log zerowrap.Logger) (*Seat, error) {
77+ if err := load(); err != nil {
78+ return nil, err
79+ }
80+ if current != nil {
81+ return nil, errors.New("seat already open")
82+ }
83+ s := &Seat{devices: make(map[int32]int32), log: log}
84+ current = s
85+ s.mu.Lock()
86+ defer s.mu.Unlock()
87+ s.handle = openSeat(unsafe.Pointer(&listener), 0)
88+ runtime.KeepAlive(&listener)
89+ if s.handle == 0 {
90+ current = nil
91+ return nil, errors.New("libseat_open_seat failed: no logind/seatd session for this TTY (run from a text console you are logged into)")
92+ }
93+ log.Info().Str("seat", seatName(s.handle)).Msg("seat opened")
94+ for !s.active {
95+ if err := s.dispatchLocked(ctx, 100); err != nil {
96+ s.closeLocked()
97+ return nil, fmt.Errorf("waiting for seat enable: %w", err)
98+ }
99+ }
100+ return s, nil
101+}
102+
103+func (s *Seat) changed(active bool) {
104+ s.active = active
105+ s.log.Info().Bool("active", active).Msg("seat")
106+ if !active {
107+ // Devices are revoked by the seat manager; ack so the VT can switch.
108+ disableSeat(s.handle)
109+ }
110+ for _, c := range s.subs {
111+ select {
112+ case c <- active:
113+ default:
114+ select {
115+ case <-c:
116+ default:
117+ }
118+ c <- active
119+ }
120+ }
121+}
122+
123+// Name returns the seat name, e.g. seat0.
124+func (s *Seat) Name() string {
125+ s.mu.Lock()
126+ defer s.mu.Unlock()
127+ return seatName(s.handle)
128+}
129+
130+// Subscribe returns a channel that receives the seat state after every enable/disable.
131+func (s *Seat) Subscribe() <-chan bool {
132+ c := make(chan bool, 1)
133+ s.mu.Lock()
134+ s.subs = append(s.subs, c)
135+ s.mu.Unlock()
136+ return c
137+}
138+
139+func (s *Seat) dispatchLocked(ctx context.Context, timeoutMs int) error {
140+ if ctx.Err() != nil {
141+ return ctx.Err()
142+ }
143+ fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}}
144+ if _, err := unix.Poll(fds, timeoutMs); err != nil && !errors.Is(err, unix.EINTR) {
145+ return err
146+ }
147+ if dispatch(s.handle, 0) < 0 {
148+ return errors.New("libseat_dispatch failed")
149+ }
150+ return nil
151+}
152+
153+// Run dispatches seat events until ctx ends. The owner calls Close afterwards,
154+// once outputs have restored their CRTC state.
155+func (s *Seat) Run(ctx context.Context) error {
156+ for ctx.Err() == nil {
157+ fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}}
158+ n, err := unix.Poll(fds, 100)
159+ if err != nil && !errors.Is(err, unix.EINTR) {
160+ return fmt.Errorf("seat poll: %w", err)
161+ }
162+ if n <= 0 {
163+ continue
164+ }
165+ s.mu.Lock()
166+ r := dispatch(s.handle, 0)
167+ s.mu.Unlock()
168+ if r < 0 {
169+ return errors.New("libseat_dispatch failed")
170+ }
171+ }
172+ return nil
173+}
174+
175+// OpenDevice opens path through the seat manager and returns its fd.
176+func (s *Seat) OpenDevice(path string) (int, error) {
177+ s.mu.Lock()
178+ defer s.mu.Unlock()
179+ var fd int32 = -1
180+ id := openDevice(s.handle, path, &fd)
181+ if id < 0 {
182+ s.log.Warn().Str("path", path).Msg("open device failed")
183+ return -1, fmt.Errorf("libseat_open_device %s failed", path)
184+ }
185+ s.devices[fd] = id
186+ s.log.Info().Str("path", path).Int32("fd", fd).Msg("device opened")
187+ return int(fd), nil
188+}
189+
190+// CloseDevice releases a fd returned by OpenDevice.
191+func (s *Seat) CloseDevice(fd int) {
192+ s.mu.Lock()
193+ defer s.mu.Unlock()
194+ id, ok := s.devices[int32(fd)]
195+ if !ok {
196+ return
197+ }
198+ delete(s.devices, int32(fd))
199+ closeDevice(s.handle, id)
200+ _ = unix.Close(fd)
201+ s.log.Info().Int("fd", fd).Msg("device closed")
202+}
203+
204+// SwitchVT asks the seat manager to switch to virtual terminal vt.
205+func (s *Seat) SwitchVT(vt int) {
206+ s.mu.Lock()
207+ defer s.mu.Unlock()
208+ s.log.Info().Int("vt", vt).Msg("switch vt")
209+ if switchSession(s.handle, int32(vt)) < 0 {
210+ s.log.Warn().Int("vt", vt).Msg("switch vt failed")
211+ }
212+}
213+
214+// Close releases all devices and the seat; the VT returns to text mode. Safe to call twice.
215+func (s *Seat) Close() {
216+ s.mu.Lock()
217+ defer s.mu.Unlock()
218+ s.closeLocked()
219+}
220+
221+func (s *Seat) closeLocked() {
222+ if s.handle == 0 {
223+ return
224+ }
225+ for fd, id := range s.devices {
226+ closeDevice(s.handle, id)
227+ _ = unix.Close(int(fd))
228+ }
229+ s.devices = map[int32]int32{}
230+ closeSeat(s.handle)
231+ s.handle = 0
232+ current = nil
233+ s.log.Info().Msg("seat closed")
234+}
@@ -0,0 +1,234 @@
1+// Package seat opens a libseat session (logind or seatd) and hands out device fds.
2+package seat
3+
4+import (
5+ "context"
6+ "errors"
7+ "fmt"
8+ "runtime"
9+ "sync"
10+ "unsafe"
11+
12+ "github.com/bnema/purego"
13+ "github.com/bnema/zerowrap"
14+ "golang.org/x/sys/unix"
15+)
16+
17+var (
18+ loadOnce sync.Once
19+ loadErr error
20+
21+ openSeat func(listener unsafe.Pointer, data uintptr) uintptr
22+ closeSeat func(seat uintptr) int32
23+ disableSeat func(seat uintptr) int32
24+ openDevice func(seat uintptr, path string, fd *int32) int32
25+ closeDevice func(seat uintptr, id int32) int32
26+ seatName func(seat uintptr) string
27+ switchSession func(seat uintptr, session int32) int32
28+ getFD func(seat uintptr) int32
29+ dispatch func(seat uintptr, timeout int32) int32
30+
31+ listener [2]uintptr
32+ // libseat allows one seat per process; callbacks find it here.
33+ current *Seat
34+)
35+
36+func load() error {
37+ loadOnce.Do(func() {
38+ lib, err := purego.Dlopen("libseat.so.1", purego.RTLD_NOW|purego.RTLD_LOCAL)
39+ if err != nil {
40+ loadErr = fmt.Errorf("open libseat.so.1: %w", err)
41+ return
42+ }
43+ defer func() {
44+ if r := recover(); r != nil {
45+ loadErr = fmt.Errorf("libseat: %v", r)
46+ }
47+ }()
48+ reg := func(fn any, name string) { purego.RegisterLibFunc(fn, lib, "libseat_"+name) }
49+ reg(&openSeat, "open_seat")
50+ reg(&closeSeat, "close_seat")
51+ reg(&disableSeat, "disable_seat")
52+ reg(&openDevice, "open_device")
53+ reg(&closeDevice, "close_device")
54+ reg(&seatName, "seat_name")
55+ reg(&switchSession, "switch_session")
56+ reg(&getFD, "get_fd")
57+ reg(&dispatch, "dispatch")
58+ listener[0] = purego.NewCallback(func(_, _ uintptr) { current.changed(true) })
59+ listener[1] = purego.NewCallback(func(_, _ uintptr) { current.changed(false) })
60+ })
61+ return loadErr
62+}
63+
64+// Seat owns the libseat handle. libseat is not thread-safe, so every call goes
65+// through mu; callbacks only run inside dispatch, which already holds it.
66+type Seat struct {
67+ mu sync.Mutex
68+ handle uintptr
69+ active bool
70+ devices map[int32]int32 // fd → libseat device id
71+ subs []chan bool
72+ log zerowrap.Logger
73+}
74+
75+// Open connects to logind or seatd and waits for the first enable.
76+func Open(ctx context.Context, log zerowrap.Logger) (*Seat, error) {
77+ if err := load(); err != nil {
78+ return nil, err
79+ }
80+ if current != nil {
81+ return nil, errors.New("seat already open")
82+ }
83+ s := &Seat{devices: make(map[int32]int32), log: log}
84+ current = s
85+ s.mu.Lock()
86+ defer s.mu.Unlock()
87+ s.handle = openSeat(unsafe.Pointer(&listener), 0)
88+ runtime.KeepAlive(&listener)
89+ if s.handle == 0 {
90+ current = nil
91+ return nil, errors.New("libseat_open_seat failed: no logind/seatd session for this TTY (run from a text console you are logged into)")
92+ }
93+ log.Info().Str("seat", seatName(s.handle)).Msg("seat opened")
94+ for !s.active {
95+ if err := s.dispatchLocked(ctx, 100); err != nil {
96+ s.closeLocked()
97+ return nil, fmt.Errorf("waiting for seat enable: %w", err)
98+ }
99+ }
100+ return s, nil
101+}
102+
103+func (s *Seat) changed(active bool) {
104+ s.active = active
105+ s.log.Info().Bool("active", active).Msg("seat")
106+ if !active {
107+ // Devices are revoked by the seat manager; ack so the VT can switch.
108+ disableSeat(s.handle)
109+ }
110+ for _, c := range s.subs {
111+ select {
112+ case c <- active:
113+ default:
114+ select {
115+ case <-c:
116+ default:
117+ }
118+ c <- active
119+ }
120+ }
121+}
122+
123+// Name returns the seat name, e.g. seat0.
124+func (s *Seat) Name() string {
125+ s.mu.Lock()
126+ defer s.mu.Unlock()
127+ return seatName(s.handle)
128+}
129+
130+// Subscribe returns a channel that receives the seat state after every enable/disable.
131+func (s *Seat) Subscribe() <-chan bool {
132+ c := make(chan bool, 1)
133+ s.mu.Lock()
134+ s.subs = append(s.subs, c)
135+ s.mu.Unlock()
136+ return c
137+}
138+
139+func (s *Seat) dispatchLocked(ctx context.Context, timeoutMs int) error {
140+ if ctx.Err() != nil {
141+ return ctx.Err()
142+ }
143+ fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}}
144+ if _, err := unix.Poll(fds, timeoutMs); err != nil && !errors.Is(err, unix.EINTR) {
145+ return err
146+ }
147+ if dispatch(s.handle, 0) < 0 {
148+ return errors.New("libseat_dispatch failed")
149+ }
150+ return nil
151+}
152+
153+// Run dispatches seat events until ctx ends. The owner calls Close afterwards,
154+// once outputs have restored their CRTC state.
155+func (s *Seat) Run(ctx context.Context) error {
156+ for ctx.Err() == nil {
157+ fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}}
158+ n, err := unix.Poll(fds, 100)
159+ if err != nil && !errors.Is(err, unix.EINTR) {
160+ return fmt.Errorf("seat poll: %w", err)
161+ }
162+ if n <= 0 {
163+ continue
164+ }
165+ s.mu.Lock()
166+ r := dispatch(s.handle, 0)
167+ s.mu.Unlock()
168+ if r < 0 {
169+ return errors.New("libseat_dispatch failed")
170+ }
171+ }
172+ return nil
173+}
174+
175+// OpenDevice opens path through the seat manager and returns its fd.
176+func (s *Seat) OpenDevice(path string) (int, error) {
177+ s.mu.Lock()
178+ defer s.mu.Unlock()
179+ var fd int32 = -1
180+ id := openDevice(s.handle, path, &fd)
181+ if id < 0 {
182+ s.log.Warn().Str("path", path).Msg("open device failed")
183+ return -1, fmt.Errorf("libseat_open_device %s failed", path)
184+ }
185+ s.devices[fd] = id
186+ s.log.Info().Str("path", path).Int32("fd", fd).Msg("device opened")
187+ return int(fd), nil
188+}
189+
190+// CloseDevice releases a fd returned by OpenDevice.
191+func (s *Seat) CloseDevice(fd int) {
192+ s.mu.Lock()
193+ defer s.mu.Unlock()
194+ id, ok := s.devices[int32(fd)]
195+ if !ok {
196+ return
197+ }
198+ delete(s.devices, int32(fd))
199+ closeDevice(s.handle, id)
200+ _ = unix.Close(fd)
201+ s.log.Info().Int("fd", fd).Msg("device closed")
202+}
203+
204+// SwitchVT asks the seat manager to switch to virtual terminal vt.
205+func (s *Seat) SwitchVT(vt int) {
206+ s.mu.Lock()
207+ defer s.mu.Unlock()
208+ s.log.Info().Int("vt", vt).Msg("switch vt")
209+ if switchSession(s.handle, int32(vt)) < 0 {
210+ s.log.Warn().Int("vt", vt).Msg("switch vt failed")
211+ }
212+}
213+
214+// Close releases all devices and the seat; the VT returns to text mode. Safe to call twice.
215+func (s *Seat) Close() {
216+ s.mu.Lock()
217+ defer s.mu.Unlock()
218+ s.closeLocked()
219+}
220+
221+func (s *Seat) closeLocked() {
222+ if s.handle == 0 {
223+ return
224+ }
225+ for fd, id := range s.devices {
226+ closeDevice(s.handle, id)
227+ _ = unix.Close(int(fd))
228+ }
229+ s.devices = map[int32]int32{}
230+ closeSeat(s.handle)
231+ s.handle = 0
232+ current = nil
233+ s.log.Info().Msg("seat closed")
234+}
M internal/app/app.go +39 -9
@@ -5,16 +5,17 @@ import (
55 "bufio"
66 "context"
77 "errors"
8- "fmt"
98 "io"
109 "os"
1110 "sync"
1211 "time"
1312
1413 "github.com/bnema/nefertty/internal/adapters/config"
14+ "github.com/bnema/nefertty/internal/adapters/drm"
1515 "github.com/bnema/nefertty/internal/adapters/headless"
1616 "github.com/bnema/nefertty/internal/adapters/headlessinput"
1717 "github.com/bnema/nefertty/internal/adapters/launcher"
18+ "github.com/bnema/nefertty/internal/adapters/libinput"
1819 "github.com/bnema/nefertty/internal/adapters/vulkan"
1920 "github.com/bnema/nefertty/internal/adapters/wayland"
2021 "github.com/bnema/nefertty/internal/adapters/xkb"
@@ -39,17 +40,24 @@ func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }
3940
4041 func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error {
4142 log := logging.For(ctx, "app")
42- if opts.Backend != "headless" {
43- return fmt.Errorf("drm backend not implemented yet")
44- }
4543 log.Info().Str("backend", opts.Backend).Msg("starting nefertty")
4644 var cancel context.CancelFunc
4745 if opts.Timeout > 0 {
48- ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
46+ ctx, cancel = context.WithTimeoutCause(ctx, opts.Timeout, errTimeout)
4947 } else {
5048 ctx, cancel = context.WithCancel(ctx)
5149 }
5250 defer cancel()
51+ width, height := 1920, 1080
52+ var hw *drmBackend
53+ if opts.Backend == "drm" {
54+ var err error
55+ if hw, err = openDRM(ctx); err != nil {
56+ return err
57+ }
58+ defer hw.close()
59+ width, height = hw.out.Width(), hw.out.Height()
60+ }
5361 client := make(chan ports.ClientEvent, 32)
5462 input := make(chan ports.InputEvent, 32)
5563 output := make(chan ports.OutputEvent, 32)
@@ -65,14 +73,14 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
6573 if err != nil {
6674 return err
6775 }
68- output <- ports.OutputMode{Width: 1920, Height: 1080}
76+ output <- ports.OutputMode{Width: width, Height: height}
6977 km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options})
7078 if err != nil {
7179 return err
7280 }
7381 keymap := km.String()
7482 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
75- server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: 1920, OutputHeight: 1080, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland"))
83+ server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: width, OutputHeight: height, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland"))
7684 if err != nil {
7785 km.Close()
7886 return err
@@ -87,7 +95,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
8795 }
8896 var workers sync.WaitGroup
8997 workers.Add(7)
90- done := make(chan error, 7)
98+ done := make(chan error, 8)
9199 path := opts.ConfigPath
92100 if path == "" {
93101 path = config.DefaultPath()
@@ -99,6 +107,10 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
99107 script := make(chan string)
100108 go func() {
101109 defer workers.Done()
110+ if hw != nil {
111+ done <- libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Width: width, Height: height, Active: hw.seat.Subscribe(), Log: logging.For(ctx, "input")}, input)
112+ return
113+ }
102114 _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input"))
103115 }()
104116 if opts.Script != nil {
@@ -127,6 +139,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
127139
128140 go func() {
129141 defer workers.Done()
142+ if hw != nil {
143+ done <- hw.out.Run(ctx, func(w, h int) (drm.Renderer, error) {
144+ r, err := vulkan.New(w, h)
145+ if err != nil {
146+ return nil, err
147+ }
148+ return r, nil
149+ }, hw.seat.Subscribe(), renderScenes, contents)
150+ return
151+ }
130152 done <- headless.Run(ctx, headless.Options{Width: 1920, Height: 1080, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (headless.Renderer, error) {
131153 r, err := vulkan.New(w, h)
132154 if err != nil {
@@ -136,10 +158,18 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
136158 }}, renderScenes, contents)
137159 }()
138160
161+ if hw != nil {
162+ workers.Add(1)
163+ go func() { defer workers.Done(); done <- hw.seat.Run(ctx) }()
164+ }
139165 var result error
140166 select {
141167 case <-ctx.Done():
168+ if errors.Is(context.Cause(ctx), errTimeout) {
169+ log.Info().Str("reason", "timeout").Dur("after", opts.Timeout).Msg("stopping")
170+ }
142171 case result = <-done:
172+ log.Info().AnErr("cause", result).Msg("stopping")
143173 }
144174 cancel()
145175 workers.Wait()
@@ -149,7 +179,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
149179 result = err
150180 }
151181 }
152- if errors.Is(result, core.ErrQuit) {
182+ if errors.Is(result, core.ErrQuit) || errors.Is(result, libinput.ErrEmergencyQuit) {
153183 return nil
154184 }
155185 return result
@@ -5,16 +5,17 @@ import (
5 "bufio"5 "bufio"
6 "context"6 "context"
7 "errors"7 "errors"
8- "fmt"
9 "io"8 "io"
10 "os"9 "os"
11 "sync"10 "sync"
12 "time"11 "time"
13 12
14 "github.com/bnema/nefertty/internal/adapters/config"13 "github.com/bnema/nefertty/internal/adapters/config"
14+ "github.com/bnema/nefertty/internal/adapters/drm"
15 "github.com/bnema/nefertty/internal/adapters/headless"15 "github.com/bnema/nefertty/internal/adapters/headless"
16 "github.com/bnema/nefertty/internal/adapters/headlessinput"16 "github.com/bnema/nefertty/internal/adapters/headlessinput"
17 "github.com/bnema/nefertty/internal/adapters/launcher"17 "github.com/bnema/nefertty/internal/adapters/launcher"
18+ "github.com/bnema/nefertty/internal/adapters/libinput"
18 "github.com/bnema/nefertty/internal/adapters/vulkan"19 "github.com/bnema/nefertty/internal/adapters/vulkan"
19 "github.com/bnema/nefertty/internal/adapters/wayland"20 "github.com/bnema/nefertty/internal/adapters/wayland"
20 "github.com/bnema/nefertty/internal/adapters/xkb"21 "github.com/bnema/nefertty/internal/adapters/xkb"
@@ -39,17 +40,24 @@ func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }
39 40
40 func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error {41 func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error {
41 log := logging.For(ctx, "app")42 log := logging.For(ctx, "app")
42- if opts.Backend != "headless" {
43- return fmt.Errorf("drm backend not implemented yet")
44- }
45 log.Info().Str("backend", opts.Backend).Msg("starting nefertty")43 log.Info().Str("backend", opts.Backend).Msg("starting nefertty")
46 var cancel context.CancelFunc44 var cancel context.CancelFunc
47 if opts.Timeout > 0 {45 if opts.Timeout > 0 {
48- ctx, cancel = context.WithTimeout(ctx, opts.Timeout)46+ ctx, cancel = context.WithTimeoutCause(ctx, opts.Timeout, errTimeout)
49 } else {47 } else {
50 ctx, cancel = context.WithCancel(ctx)48 ctx, cancel = context.WithCancel(ctx)
51 }49 }
52 defer cancel()50 defer cancel()
51+ width, height := 1920, 1080
52+ var hw *drmBackend
53+ if opts.Backend == "drm" {
54+ var err error
55+ if hw, err = openDRM(ctx); err != nil {
56+ return err
57+ }
58+ defer hw.close()
59+ width, height = hw.out.Width(), hw.out.Height()
60+ }
53 client := make(chan ports.ClientEvent, 32)61 client := make(chan ports.ClientEvent, 32)
54 input := make(chan ports.InputEvent, 32)62 input := make(chan ports.InputEvent, 32)
55 output := make(chan ports.OutputEvent, 32)63 output := make(chan ports.OutputEvent, 32)
@@ -65,14 +73,14 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
65 if err != nil {73 if err != nil {
66 return err74 return err
67 }75 }
68- output <- ports.OutputMode{Width: 1920, Height: 1080}76+ output <- ports.OutputMode{Width: width, Height: height}
69 km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options})77 km, err := xkb.New(xkb.RMLVO{Layout: opts.Config.Keyboard.Layout, Variant: opts.Config.Keyboard.Variant, Options: opts.Config.Keyboard.Options})
70 if err != nil {78 if err != nil {
71 return err79 return err
72 }80 }
73 keymap := km.String()81 keymap := km.String()
74 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")82 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
75- server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: 1920, OutputHeight: 1080, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland"))83+ server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: width, OutputHeight: height, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland"))
76 if err != nil {84 if err != nil {
77 km.Close()85 km.Close()
78 return err86 return err
@@ -87,7 +95,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
87 }95 }
88 var workers sync.WaitGroup96 var workers sync.WaitGroup
89 workers.Add(7)97 workers.Add(7)
90- done := make(chan error, 7)98+ done := make(chan error, 8)
91 path := opts.ConfigPath99 path := opts.ConfigPath
92 if path == "" {100 if path == "" {
93 path = config.DefaultPath()101 path = config.DefaultPath()
@@ -99,6 +107,10 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
99 script := make(chan string)107 script := make(chan string)
100 go func() {108 go func() {
101 defer workers.Done()109 defer workers.Done()
110+ if hw != nil {
111+ done <- libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Width: width, Height: height, Active: hw.seat.Subscribe(), Log: logging.For(ctx, "input")}, input)
112+ return
113+ }
102 _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input"))114 _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input"))
103 }()115 }()
104 if opts.Script != nil {116 if opts.Script != nil {
@@ -127,6 +139,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
127 139
128 go func() {140 go func() {
129 defer workers.Done()141 defer workers.Done()
142+ if hw != nil {
143+ done <- hw.out.Run(ctx, func(w, h int) (drm.Renderer, error) {
144+ r, err := vulkan.New(w, h)
145+ if err != nil {
146+ return nil, err
147+ }
148+ return r, nil
149+ }, hw.seat.Subscribe(), renderScenes, contents)
150+ return
151+ }
130 done <- headless.Run(ctx, headless.Options{Width: 1920, Height: 1080, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (headless.Renderer, error) {152 done <- headless.Run(ctx, headless.Options{Width: 1920, Height: 1080, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (headless.Renderer, error) {
131 r, err := vulkan.New(w, h)153 r, err := vulkan.New(w, h)
132 if err != nil {154 if err != nil {
@@ -136,10 +158,18 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
136 }}, renderScenes, contents)158 }}, renderScenes, contents)
137 }()159 }()
138 160
161+ if hw != nil {
162+ workers.Add(1)
163+ go func() { defer workers.Done(); done <- hw.seat.Run(ctx) }()
164+ }
139 var result error165 var result error
140 select {166 select {
141 case <-ctx.Done():167 case <-ctx.Done():
168+ if errors.Is(context.Cause(ctx), errTimeout) {
169+ log.Info().Str("reason", "timeout").Dur("after", opts.Timeout).Msg("stopping")
170+ }
142 case result = <-done:171 case result = <-done:
172+ log.Info().AnErr("cause", result).Msg("stopping")
143 }173 }
144 cancel()174 cancel()
145 workers.Wait()175 workers.Wait()
@@ -149,7 +179,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
149 result = err179 result = err
150 }180 }
151 }181 }
152- if errors.Is(result, core.ErrQuit) {182+ if errors.Is(result, core.ErrQuit) || errors.Is(result, libinput.ErrEmergencyQuit) {
153 return nil183 return nil
154 }184 }
155 return result185 return result
A internal/app/drm.go +60 -0
@@ -0,0 +1,60 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "path/filepath"
8+ "sort"
9+
10+ "github.com/bnema/nefertty/internal/adapters/drm"
11+ "github.com/bnema/nefertty/internal/adapters/seat"
12+ "github.com/bnema/nefertty/internal/logging"
13+)
14+
15+var errTimeout = errors.New("timeout")
16+
17+type drmBackend struct {
18+ seat *seat.Seat
19+ out *drm.Output
20+ fd int
21+}
22+
23+// openDRM opens the seat and the first card with a connected display.
24+func openDRM(ctx context.Context) (*drmBackend, error) {
25+ log := logging.For(ctx, "drm")
26+ s, err := seat.Open(ctx, logging.For(ctx, "seat"))
27+ if err != nil {
28+ log.Error().Err(err).Msg("open seat")
29+ return nil, fmt.Errorf("open seat: %w", err)
30+ }
31+ cards, _ := filepath.Glob("/dev/dri/card[0-9]*")
32+ sort.Strings(cards)
33+ var errs []error
34+ for _, card := range cards {
35+ fd, err := s.OpenDevice(card)
36+ if err != nil {
37+ errs = append(errs, err)
38+ continue
39+ }
40+ out, err := drm.Open(fd, card, log)
41+ if err != nil {
42+ log.Warn().Err(err).Str("card", card).Msg("card unusable")
43+ errs = append(errs, err)
44+ s.CloseDevice(fd)
45+ continue
46+ }
47+ return &drmBackend{seat: s, out: out, fd: fd}, nil
48+ }
49+ s.Close()
50+ err = fmt.Errorf("no usable DRM card in %v: %w", cards, errors.Join(errs...))
51+ log.Error().Err(err).Msg("open drm")
52+ return nil, err
53+}
54+
55+// close restores the CRTC, then releases the card and the seat so the TTY comes back.
56+func (b *drmBackend) close() {
57+ b.out.Close()
58+ b.seat.CloseDevice(b.fd)
59+ b.seat.Close()
60+}
@@ -0,0 +1,60 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "path/filepath"
8+ "sort"
9+
10+ "github.com/bnema/nefertty/internal/adapters/drm"
11+ "github.com/bnema/nefertty/internal/adapters/seat"
12+ "github.com/bnema/nefertty/internal/logging"
13+)
14+
15+var errTimeout = errors.New("timeout")
16+
17+type drmBackend struct {
18+ seat *seat.Seat
19+ out *drm.Output
20+ fd int
21+}
22+
23+// openDRM opens the seat and the first card with a connected display.
24+func openDRM(ctx context.Context) (*drmBackend, error) {
25+ log := logging.For(ctx, "drm")
26+ s, err := seat.Open(ctx, logging.For(ctx, "seat"))
27+ if err != nil {
28+ log.Error().Err(err).Msg("open seat")
29+ return nil, fmt.Errorf("open seat: %w", err)
30+ }
31+ cards, _ := filepath.Glob("/dev/dri/card[0-9]*")
32+ sort.Strings(cards)
33+ var errs []error
34+ for _, card := range cards {
35+ fd, err := s.OpenDevice(card)
36+ if err != nil {
37+ errs = append(errs, err)
38+ continue
39+ }
40+ out, err := drm.Open(fd, card, log)
41+ if err != nil {
42+ log.Warn().Err(err).Str("card", card).Msg("card unusable")
43+ errs = append(errs, err)
44+ s.CloseDevice(fd)
45+ continue
46+ }
47+ return &drmBackend{seat: s, out: out, fd: fd}, nil
48+ }
49+ s.Close()
50+ err = fmt.Errorf("no usable DRM card in %v: %w", cards, errors.Join(errs...))
51+ log.Error().Err(err).Msg("open drm")
52+ return nil, err
53+}
54+
55+// close restores the CRTC, then releases the card and the seat so the TTY comes back.
56+func (b *drmBackend) close() {
57+ b.out.Close()
58+ b.seat.CloseDevice(b.fd)
59+ b.seat.Close()
60+}
M internal/logging/logging.go +1 -1
@@ -16,7 +16,7 @@ import (
1616 )
1717
1818 var components = map[string]bool{
19- "core": true, "wayland": true, "input": true, "drm": true,
19+ "core": true, "wayland": true, "input": true, "drm": true, "seat": true,
2020 "render": true, "sync": true, "config": true, "app": true,
2121 }
2222
@@ -16,7 +16,7 @@ import (
16 )16 )
17 17
18 var components = map[string]bool{18 var components = map[string]bool{
19- "core": true, "wayland": true, "input": true, "drm": true,19+ "core": true, "wayland": true, "input": true, "drm": true, "seat": true,
20 "render": true, "sync": true, "config": true, "app": true,20 "render": true, "sync": true, "config": true, "app": true,
21 }21 }
22 22

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 26

Tags 10

↑↓ moveenter openesc close