detached 89b5b78

feat(drm): select output and mode from config and advertise real monitor info

commit
89b5b782b19a4cf961cca93c608b5859dc08714e
author
brice · · 2026-09-24 08:42 UTC
parent
8b2b354
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +381 -53
M examples/config.toml +12 -0
@@ -17,6 +17,18 @@ gaps = 8
1717 default_column_width = "1/2"
1818 presets = ["1/3", "1/2", "2/3", "1"]
1919
20+# Physical displays (drm backend). The first connected, enabled entry is used;
21+# without one, the first connected display that is not off. List names with
22+# `make tty` logs ("connector" lines) or wlr-randr.
23+# mode: "WxH" picks the highest refresh, "WxH@Hz" the closest; omit for preferred.
24+# [[output]]
25+# name = "DP-2"
26+# mode = "5120x2160@165.058"
27+#
28+# [[output]]
29+# name = "HDMI-A-1"
30+# off = true
31+
2032 [render]
2133 direct_scanout = true
2234
@@ -17,6 +17,18 @@ gaps = 8
17 default_column_width = "1/2"17 default_column_width = "1/2"
18 presets = ["1/3", "1/2", "2/3", "1"]18 presets = ["1/3", "1/2", "2/3", "1"]
19 19
20+# Physical displays (drm backend). The first connected, enabled entry is used;
21+# without one, the first connected display that is not off. List names with
22+# `make tty` logs ("connector" lines) or wlr-randr.
23+# mode: "WxH" picks the highest refresh, "WxH@Hz" the closest; omit for preferred.
24+# [[output]]
25+# name = "DP-2"
26+# mode = "5120x2160@165.058"
27+#
28+# [[output]]
29+# name = "HDMI-A-1"
30+# off = true
31+
20 [render]32 [render]
21 direct_scanout = true33 direct_scanout = true
22 34
M internal/adapters/config/config.go +27 -0
@@ -99,6 +99,24 @@ func Load(path string) (ports.Config, error) {
9999 }
100100 return c, nil
101101 }
102+
103+// ParseMode parses "WxH" or "WxH@Hz"; hz is 0 when omitted.
104+func ParseMode(s string) (w, h int, hz float64, err error) {
105+ size, rate, hasRate := strings.Cut(s, "@")
106+ ws, hs, ok := strings.Cut(size, "x")
107+ w, e1 := strconv.Atoi(ws)
108+ h, e2 := strconv.Atoi(hs)
109+ if !ok || e1 != nil || e2 != nil || w <= 0 || h <= 0 {
110+ return 0, 0, 0, fmt.Errorf("must be WxH or WxH@Hz, got %q", s)
111+ }
112+ if hasRate {
113+ if hz, err = strconv.ParseFloat(rate, 64); err != nil || hz <= 0 {
114+ return 0, 0, 0, fmt.Errorf("invalid refresh rate in %q", s)
115+ }
116+ }
117+ return w, h, hz, nil
118+}
119+
102120 func width(s string) bool {
103121 if s == "1" {
104122 return true
@@ -189,6 +207,15 @@ func Validate(c ports.Config) error {
189207 if !color.MatchString(c.Background.Color) {
190208 add("background.color", "must be #rrggbb")
191209 }
210+ for i, o := range c.Outputs {
211+ path := fmt.Sprintf("output[%d]", i)
212+ if o.Name == "" {
213+ add(path+".name", "must not be empty")
214+ }
215+ if _, _, _, err := ParseMode(o.Mode); o.Mode != "" && err != nil {
216+ add(path+".mode", err.Error())
217+ }
218+ }
192219 if c.Layout.Gaps < 0 || c.Layout.Gaps > 200 {
193220 add("layout.gaps", "must be between 0 and 200")
194221 }
@@ -99,6 +99,24 @@ func Load(path string) (ports.Config, error) {
99 }99 }
100 return c, nil100 return c, nil
101 }101 }
102+
103+// ParseMode parses "WxH" or "WxH@Hz"; hz is 0 when omitted.
104+func ParseMode(s string) (w, h int, hz float64, err error) {
105+ size, rate, hasRate := strings.Cut(s, "@")
106+ ws, hs, ok := strings.Cut(size, "x")
107+ w, e1 := strconv.Atoi(ws)
108+ h, e2 := strconv.Atoi(hs)
109+ if !ok || e1 != nil || e2 != nil || w <= 0 || h <= 0 {
110+ return 0, 0, 0, fmt.Errorf("must be WxH or WxH@Hz, got %q", s)
111+ }
112+ if hasRate {
113+ if hz, err = strconv.ParseFloat(rate, 64); err != nil || hz <= 0 {
114+ return 0, 0, 0, fmt.Errorf("invalid refresh rate in %q", s)
115+ }
116+ }
117+ return w, h, hz, nil
118+}
119+
102 func width(s string) bool {120 func width(s string) bool {
103 if s == "1" {121 if s == "1" {
104 return true122 return true
@@ -189,6 +207,15 @@ func Validate(c ports.Config) error {
189 if !color.MatchString(c.Background.Color) {207 if !color.MatchString(c.Background.Color) {
190 add("background.color", "must be #rrggbb")208 add("background.color", "must be #rrggbb")
191 }209 }
210+ for i, o := range c.Outputs {
211+ path := fmt.Sprintf("output[%d]", i)
212+ if o.Name == "" {
213+ add(path+".name", "must not be empty")
214+ }
215+ if _, _, _, err := ParseMode(o.Mode); o.Mode != "" && err != nil {
216+ add(path+".mode", err.Error())
217+ }
218+ }
192 if c.Layout.Gaps < 0 || c.Layout.Gaps > 200 {219 if c.Layout.Gaps < 0 || c.Layout.Gaps > 200 {
193 add("layout.gaps", "must be between 0 and 200")220 add("layout.gaps", "must be between 0 and 200")
194 }221 }
M internal/adapters/config/config_test.go +27 -0
@@ -86,3 +86,30 @@ func TestBindMerge(t *testing.T) {
8686 t.Fatal(c.Binds)
8787 }
8888 }
89+
90+func TestOutputs(t *testing.T) {
91+ path := filepath.Join(t.TempDir(), "c.toml")
92+ good := "[[output]]\nname = \"DP-2\"\nmode = \"5120x2160@165.058\"\n\n[[output]]\nname = \"HDMI-A-1\"\noff = true\n"
93+ if err := os.WriteFile(path, []byte(good), 0o600); err != nil {
94+ t.Fatal(err)
95+ }
96+ c, err := Load(path)
97+ if err != nil {
98+ t.Fatal(err)
99+ }
100+ if len(c.Outputs) != 2 || c.Outputs[0].Name != "DP-2" || !c.Outputs[1].Off {
101+ t.Fatalf("%+v", c.Outputs)
102+ }
103+ for _, bad := range []string{"[[output]]\nmode = \"1x1\"\n", "[[output]]\nname = \"DP-2\"\nmode = \"big\"\n", "[[output]]\nname = \"DP-2\"\nmode = \"1920x1080@0\"\n", "[[output]]\nname = \"DP-2\"\nscale = 2\n"} {
104+ if err := os.WriteFile(path, []byte(bad), 0o600); err != nil {
105+ t.Fatal(err)
106+ }
107+ if _, err := Load(path); err == nil {
108+ t.Errorf("accepted %q", bad)
109+ }
110+ }
111+ w, h, hz, err := ParseMode("3440x1440")
112+ if err != nil || w != 3440 || h != 1440 || hz != 0 {
113+ t.Fatalf("ParseMode: %d %d %v %v", w, h, hz, err)
114+ }
115+}
@@ -86,3 +86,30 @@ func TestBindMerge(t *testing.T) {
86 t.Fatal(c.Binds)86 t.Fatal(c.Binds)
87 }87 }
88 }88 }
89+
90+func TestOutputs(t *testing.T) {
91+ path := filepath.Join(t.TempDir(), "c.toml")
92+ good := "[[output]]\nname = \"DP-2\"\nmode = \"5120x2160@165.058\"\n\n[[output]]\nname = \"HDMI-A-1\"\noff = true\n"
93+ if err := os.WriteFile(path, []byte(good), 0o600); err != nil {
94+ t.Fatal(err)
95+ }
96+ c, err := Load(path)
97+ if err != nil {
98+ t.Fatal(err)
99+ }
100+ if len(c.Outputs) != 2 || c.Outputs[0].Name != "DP-2" || !c.Outputs[1].Off {
101+ t.Fatalf("%+v", c.Outputs)
102+ }
103+ for _, bad := range []string{"[[output]]\nmode = \"1x1\"\n", "[[output]]\nname = \"DP-2\"\nmode = \"big\"\n", "[[output]]\nname = \"DP-2\"\nmode = \"1920x1080@0\"\n", "[[output]]\nname = \"DP-2\"\nscale = 2\n"} {
104+ if err := os.WriteFile(path, []byte(bad), 0o600); err != nil {
105+ t.Fatal(err)
106+ }
107+ if _, err := Load(path); err == nil {
108+ t.Errorf("accepted %q", bad)
109+ }
110+ }
111+ w, h, hz, err := ParseMode("3440x1440")
112+ if err != nil || w != 3440 || h != 1440 || hz != 0 {
113+ t.Fatalf("ParseMode: %d %d %v %v", w, h, hz, err)
114+ }
115+}
A internal/adapters/drm/edid.go +63 -0
@@ -0,0 +1,63 @@
1+package drm
2+
3+import (
4+ "bufio"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+)
9+
10+// Monitor identifies the display attached to a connector.
11+type Monitor struct {
12+ Make, Model, Serial string
13+}
14+
15+// readMonitor parses the connector's EDID from sysfs. Missing data yields "Unknown".
16+func readMonitor(card, connector string) Monitor {
17+ data, err := os.ReadFile(filepath.Join("/sys/class/drm", filepath.Base(card)+"-"+connector, "edid"))
18+ if err != nil {
19+ return Monitor{Make: "Unknown", Model: "Unknown"}
20+ }
21+ return parseEDID(data)
22+}
23+
24+func parseEDID(d []byte) Monitor {
25+ m := Monitor{Make: "Unknown", Model: "Unknown"}
26+ if len(d) < 128 || string(d[:8]) != "\x00\xff\xff\xff\xff\xff\xff\x00" {
27+ return m
28+ }
29+ id := uint16(d[8])<<8 | uint16(d[9])
30+ pnp := string([]byte{byte(id>>10&0x1f) + '@', byte(id>>5&0x1f) + '@', byte(id&0x1f) + '@'})
31+ m.Make = pnpName(pnp)
32+ // Display descriptors: 0xFC name, 0xFF serial.
33+ for off := 54; off+18 <= 126; off += 18 {
34+ b := d[off : off+18]
35+ if b[0] != 0 || b[1] != 0 {
36+ continue
37+ }
38+ text := strings.TrimRight(strings.SplitN(string(b[5:18]), "\n", 2)[0], " ")
39+ switch b[3] {
40+ case 0xfc:
41+ m.Model = text
42+ case 0xff:
43+ m.Serial = text
44+ }
45+ }
46+ return m
47+}
48+
49+// pnpName maps a PNP id to a vendor name via hwdata, falling back to the id.
50+func pnpName(id string) string {
51+ f, err := os.Open("/usr/share/hwdata/pnp.ids")
52+ if err != nil {
53+ return id
54+ }
55+ defer f.Close()
56+ s := bufio.NewScanner(f)
57+ for s.Scan() {
58+ if name, ok := strings.CutPrefix(s.Text(), id+"\t"); ok {
59+ return name
60+ }
61+ }
62+ return id
63+}
@@ -0,0 +1,63 @@
1+package drm
2+
3+import (
4+ "bufio"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+)
9+
10+// Monitor identifies the display attached to a connector.
11+type Monitor struct {
12+ Make, Model, Serial string
13+}
14+
15+// readMonitor parses the connector's EDID from sysfs. Missing data yields "Unknown".
16+func readMonitor(card, connector string) Monitor {
17+ data, err := os.ReadFile(filepath.Join("/sys/class/drm", filepath.Base(card)+"-"+connector, "edid"))
18+ if err != nil {
19+ return Monitor{Make: "Unknown", Model: "Unknown"}
20+ }
21+ return parseEDID(data)
22+}
23+
24+func parseEDID(d []byte) Monitor {
25+ m := Monitor{Make: "Unknown", Model: "Unknown"}
26+ if len(d) < 128 || string(d[:8]) != "\x00\xff\xff\xff\xff\xff\xff\x00" {
27+ return m
28+ }
29+ id := uint16(d[8])<<8 | uint16(d[9])
30+ pnp := string([]byte{byte(id>>10&0x1f) + '@', byte(id>>5&0x1f) + '@', byte(id&0x1f) + '@'})
31+ m.Make = pnpName(pnp)
32+ // Display descriptors: 0xFC name, 0xFF serial.
33+ for off := 54; off+18 <= 126; off += 18 {
34+ b := d[off : off+18]
35+ if b[0] != 0 || b[1] != 0 {
36+ continue
37+ }
38+ text := strings.TrimRight(strings.SplitN(string(b[5:18]), "\n", 2)[0], " ")
39+ switch b[3] {
40+ case 0xfc:
41+ m.Model = text
42+ case 0xff:
43+ m.Serial = text
44+ }
45+ }
46+ return m
47+}
48+
49+// pnpName maps a PNP id to a vendor name via hwdata, falling back to the id.
50+func pnpName(id string) string {
51+ f, err := os.Open("/usr/share/hwdata/pnp.ids")
52+ if err != nil {
53+ return id
54+ }
55+ defer f.Close()
56+ s := bufio.NewScanner(f)
57+ for s.Scan() {
58+ if name, ok := strings.CutPrefix(s.Text(), id+"\t"); ok {
59+ return name
60+ }
61+ }
62+ return id
63+}
M internal/adapters/drm/kms.go +80 -22
@@ -2,9 +2,9 @@
22 package drm
33
44 import (
5- "bytes"
65 "errors"
76 "fmt"
7+ "math"
88 "unsafe"
99
1010 "golang.org/x/sys/unix"
@@ -47,7 +47,7 @@ type modeInfo struct {
4747 }
4848
4949 func (m modeInfo) String() string {
50- return fmt.Sprintf("%dx%d@%d", m.HDisplay, m.VDisplay, m.VRefresh)
50+ return fmt.Sprintf("%dx%d@%.3f", m.HDisplay, m.VDisplay, float64(m.refreshMilli())/1000)
5151 }
5252
5353 type getConnector struct {
@@ -107,26 +107,91 @@ type connector struct {
107107 id, encoderID uint32
108108 name string
109109 connected bool
110+ mmW, mmH int
110111 modes []modeInfo
111112 encoders []uint32
112113 }
113114
114115 var connectorTypes = map[uint32]string{1: "VGA", 2: "DVI-I", 3: "DVI-D", 10: "DP", 11: "HDMI-A", 12: "HDMI-B", 7: "LVDS", 14: "eDP", 15: "Virtual", 16: "DSI", 17: "DPI", 20: "USB"}
115116
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 {
117+// Want selects a connector and mode; see ports.OutputConfig.
118+type Want struct {
119+ Name string
120+ W, H int
121+ Hz float64 // 0: highest refresh at W x H
122+ Disabled map[string]bool
123+}
124+
125+// refreshMilli is the exact refresh in mHz, computed from the timings like wlroots.
126+func (m modeInfo) refreshMilli() int {
127+ if m.HTotal == 0 || m.VTotal == 0 {
128+ return int(m.VRefresh) * 1000
129+ }
130+ r := (int64(m.Clock)*1_000_000/int64(m.HTotal) + int64(m.VTotal)/2) / int64(m.VTotal)
131+ if m.Flags&(1<<4) != 0 { // interlace
132+ r *= 2
133+ }
134+ if m.Flags&(1<<5) != 0 { // doublescan
135+ r /= 2
136+ }
137+ if m.VScan > 1 {
138+ r /= int64(m.VScan)
139+ }
140+ return int(r)
141+}
142+
143+// pickConnector applies want: the named connector if connected, else the first
144+// connected one that is not disabled. The mode is the requested one, else preferred.
145+func pickConnector(conns []connector, want Want) (connector, modeInfo, error) {
146+ var chosen *connector
147+ for i := range conns {
148+ c := &conns[i]
149+ if !c.connected || len(c.modes) == 0 || want.Disabled[c.name] {
120150 continue
121151 }
122- for _, m := range c.modes {
123- if m.Type&modeTypePrefered != 0 {
124- return c, m, nil
125- }
152+ if c.name == want.Name {
153+ chosen = c
154+ break
155+ }
156+ if chosen == nil {
157+ chosen = c
158+ }
159+ }
160+ if chosen == nil {
161+ return connector{}, modeInfo{}, errors.New("no connected display")
162+ }
163+ if chosen.name == want.Name && want.W > 0 {
164+ if m, ok := pickMode(chosen.modes, want.W, want.H, want.Hz); ok {
165+ return *chosen, m, nil
126166 }
127- return c, c.modes[0], nil
128167 }
129- return connector{}, modeInfo{}, errors.New("no connected display")
168+ for _, m := range chosen.modes {
169+ if m.Type&modeTypePrefered != 0 {
170+ return *chosen, m, nil
171+ }
172+ }
173+ return *chosen, chosen.modes[0], nil
174+}
175+
176+// pickMode finds W x H with the refresh closest to hz, or the highest when hz is 0.
177+func pickMode(modes []modeInfo, w, h int, hz float64) (modeInfo, bool) {
178+ best, found := modeInfo{}, false
179+ score := func(m modeInfo) float64 {
180+ r := float64(m.refreshMilli()) / 1000
181+ if hz == 0 {
182+ return -r
183+ }
184+ return math.Abs(r - hz)
185+ }
186+ for _, m := range modes {
187+ if int(m.HDisplay) != w || int(m.VDisplay) != h || m.Flags&(1<<4) != 0 {
188+ continue
189+ }
190+ if !found || score(m) < score(best) {
191+ best, found = m, true
192+ }
193+ }
194+ return best, found
130195 }
131196
132197 func resources(fd int) (crtcs, conns []uint32, err error) {
@@ -172,9 +237,9 @@ func readConnector(fd int, id uint32) (connector, error) {
172237 }
173238 return connector{
174239 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))],
240+ connected: g2.connection == connected, mmW: int(g2.mmW), mmH: int(g2.mmH),
241+ modes: modes[:min(int(g2.countModes), len(modes))],
242+ encoders: encs[:min(int(g2.countEncoders), len(encs))],
178243 }, nil
179244 }
180245
@@ -282,10 +347,3 @@ func countFlipEvents(buf []byte) int {
282347 }
283348 return n
284349 }
285-
286-func cstr(b []byte) string {
287- if i := bytes.IndexByte(b, 0); i >= 0 {
288- return string(b[:i])
289- }
290- return string(b)
291-}
@@ -2,9 +2,9 @@
2 package drm2 package drm
3 3
4 import (4 import (
5- "bytes"
6 "errors"5 "errors"
7 "fmt"6 "fmt"
7+ "math"
8 "unsafe"8 "unsafe"
9 9
10 "golang.org/x/sys/unix"10 "golang.org/x/sys/unix"
@@ -47,7 +47,7 @@ type modeInfo struct {
47 }47 }
48 48
49 func (m modeInfo) String() string {49 func (m modeInfo) String() string {
50- return fmt.Sprintf("%dx%d@%d", m.HDisplay, m.VDisplay, m.VRefresh)50+ return fmt.Sprintf("%dx%d@%.3f", m.HDisplay, m.VDisplay, float64(m.refreshMilli())/1000)
51 }51 }
52 52
53 type getConnector struct {53 type getConnector struct {
@@ -107,26 +107,91 @@ type connector struct {
107 id, encoderID uint32107 id, encoderID uint32
108 name string108 name string
109 connected bool109 connected bool
110+ mmW, mmH int
110 modes []modeInfo111 modes []modeInfo
111 encoders []uint32112 encoders []uint32
112 }113 }
113 114
114 var connectorTypes = map[uint32]string{1: "VGA", 2: "DVI-I", 3: "DVI-D", 10: "DP", 11: "HDMI-A", 12: "HDMI-B", 7: "LVDS", 14: "eDP", 15: "Virtual", 16: "DSI", 17: "DPI", 20: "USB"}115 var connectorTypes = map[uint32]string{1: "VGA", 2: "DVI-I", 3: "DVI-D", 10: "DP", 11: "HDMI-A", 12: "HDMI-B", 7: "LVDS", 14: "eDP", 15: "Virtual", 16: "DSI", 17: "DPI", 20: "USB"}
115 116
116-// pickConnector returns the first connected connector and its preferred (else first) mode.117+// Want selects a connector and mode; see ports.OutputConfig.
117-func pickConnector(conns []connector) (connector, modeInfo, error) {118+type Want struct {
118- for _, c := range conns {119+ Name string
119- if !c.connected || len(c.modes) == 0 {120+ W, H int
121+ Hz float64 // 0: highest refresh at W x H
122+ Disabled map[string]bool
123+}
124+
125+// refreshMilli is the exact refresh in mHz, computed from the timings like wlroots.
126+func (m modeInfo) refreshMilli() int {
127+ if m.HTotal == 0 || m.VTotal == 0 {
128+ return int(m.VRefresh) * 1000
129+ }
130+ r := (int64(m.Clock)*1_000_000/int64(m.HTotal) + int64(m.VTotal)/2) / int64(m.VTotal)
131+ if m.Flags&(1<<4) != 0 { // interlace
132+ r *= 2
133+ }
134+ if m.Flags&(1<<5) != 0 { // doublescan
135+ r /= 2
136+ }
137+ if m.VScan > 1 {
138+ r /= int64(m.VScan)
139+ }
140+ return int(r)
141+}
142+
143+// pickConnector applies want: the named connector if connected, else the first
144+// connected one that is not disabled. The mode is the requested one, else preferred.
145+func pickConnector(conns []connector, want Want) (connector, modeInfo, error) {
146+ var chosen *connector
147+ for i := range conns {
148+ c := &conns[i]
149+ if !c.connected || len(c.modes) == 0 || want.Disabled[c.name] {
120 continue150 continue
121 }151 }
122- for _, m := range c.modes {152+ if c.name == want.Name {
123- if m.Type&modeTypePrefered != 0 {153+ chosen = c
124- return c, m, nil154+ break
125- }155+ }
156+ if chosen == nil {
157+ chosen = c
158+ }
159+ }
160+ if chosen == nil {
161+ return connector{}, modeInfo{}, errors.New("no connected display")
162+ }
163+ if chosen.name == want.Name && want.W > 0 {
164+ if m, ok := pickMode(chosen.modes, want.W, want.H, want.Hz); ok {
165+ return *chosen, m, nil
126 }166 }
127- return c, c.modes[0], nil
128 }167 }
129- return connector{}, modeInfo{}, errors.New("no connected display")168+ for _, m := range chosen.modes {
169+ if m.Type&modeTypePrefered != 0 {
170+ return *chosen, m, nil
171+ }
172+ }
173+ return *chosen, chosen.modes[0], nil
174+}
175+
176+// pickMode finds W x H with the refresh closest to hz, or the highest when hz is 0.
177+func pickMode(modes []modeInfo, w, h int, hz float64) (modeInfo, bool) {
178+ best, found := modeInfo{}, false
179+ score := func(m modeInfo) float64 {
180+ r := float64(m.refreshMilli()) / 1000
181+ if hz == 0 {
182+ return -r
183+ }
184+ return math.Abs(r - hz)
185+ }
186+ for _, m := range modes {
187+ if int(m.HDisplay) != w || int(m.VDisplay) != h || m.Flags&(1<<4) != 0 {
188+ continue
189+ }
190+ if !found || score(m) < score(best) {
191+ best, found = m, true
192+ }
193+ }
194+ return best, found
130 }195 }
131 196
132 func resources(fd int) (crtcs, conns []uint32, err error) {197 func resources(fd int) (crtcs, conns []uint32, err error) {
@@ -172,9 +237,9 @@ func readConnector(fd int, id uint32) (connector, error) {
172 }237 }
173 return connector{238 return connector{
174 id: id, encoderID: g2.encoderID, name: fmt.Sprintf("%s-%d", name, g2.typeID),239 id: id, encoderID: g2.encoderID, name: fmt.Sprintf("%s-%d", name, g2.typeID),
175- connected: g2.connection == connected,240+ connected: g2.connection == connected, mmW: int(g2.mmW), mmH: int(g2.mmH),
176- modes: modes[:min(int(g2.countModes), len(modes))],241+ modes: modes[:min(int(g2.countModes), len(modes))],
177- encoders: encs[:min(int(g2.countEncoders), len(encs))],242+ encoders: encs[:min(int(g2.countEncoders), len(encs))],
178 }, nil243 }, nil
179 }244 }
180 245
@@ -282,10 +347,3 @@ func countFlipEvents(buf []byte) int {
282 }347 }
283 return n348 return n
284 }349 }
285-
286-func cstr(b []byte) string {
287- if i := bytes.IndexByte(b, 0); i >= 0 {
288- return string(b[:i])
289- }
290- return string(b)
291-}
M internal/adapters/drm/kms_test.go +48 -11
@@ -31,27 +31,64 @@ func TestStructSizesMatchIoctls(t *testing.T) {
3131 }
3232
3333 func TestPickConnector(t *testing.T) {
34- plain := modeInfo{HDisplay: 1280, VDisplay: 720}
35- pref := modeInfo{HDisplay: 2560, VDisplay: 1440, Type: modeTypePrefered}
34+ mode := func(w, h uint16, hz uint32, pref bool) modeInfo {
35+ // htotal*vtotal = w*h keeps refreshMilli = clock*1000/(w*h).
36+ m := modeInfo{HDisplay: w, VDisplay: h, HTotal: w, VTotal: h, Clock: uint32(uint64(w) * uint64(h) * uint64(hz) / 1000), VRefresh: hz}
37+ if pref {
38+ m.Type = modeTypePrefered
39+ }
40+ return m
41+ }
42+ lg := connector{name: "DP-2", connected: true, modes: []modeInfo{mode(5120, 2160, 165, true), mode(3440, 1440, 100, true), mode(5120, 2160, 100, false), mode(5120, 2160, 60, false)}}
43+ tv := connector{name: "HDMI-A-1", connected: true, modes: []modeInfo{mode(3840, 2160, 60, true), mode(3840, 2160, 120, false)}}
44+ off := connector{name: "DP-1", modes: []modeInfo{mode(1920, 1080, 60, true)}}
3645 for _, tc := range []struct {
3746 name string
3847 conns []connector
39- want string
40- mode uint16
48+ want Want
49+ conn string
50+ mode string
4151 err bool
4252 }{
43- {"none", nil, "", 0, true},
44- {"disconnected skipped", []connector{{name: "DP-1", modes: []modeInfo{pref}}, {name: "DP-2", connected: true, modes: []modeInfo{plain}}}, "DP-2", 1280, false},
45- {"preferred mode", []connector{{name: "DP-1", connected: true, modes: []modeInfo{plain, pref}}}, "DP-1", 2560, false},
46- {"no modes skipped", []connector{{name: "DP-1", connected: true}}, "", 0, true},
53+ {"none", nil, Want{}, "", "", true},
54+ {"first connected, first preferred", []connector{off, tv, lg}, Want{}, "HDMI-A-1", "3840x2160@60.000", false},
55+ {"named", []connector{tv, lg}, Want{Name: "DP-2"}, "DP-2", "5120x2160@165.000", false},
56+ {"named mode closest refresh", []connector{tv, lg}, Want{Name: "DP-2", W: 5120, H: 2160, Hz: 99}, "DP-2", "5120x2160@100.000", false},
57+ {"named mode highest refresh", []connector{lg}, Want{Name: "DP-2", W: 5120, H: 2160}, "DP-2", "5120x2160@165.000", false},
58+ {"unknown mode falls back to preferred", []connector{lg}, Want{Name: "DP-2", W: 800, H: 600}, "DP-2", "5120x2160@165.000", false},
59+ {"missing name falls back", []connector{tv}, Want{Name: "DP-2"}, "HDMI-A-1", "3840x2160@60.000", false},
60+ {"disabled skipped", []connector{tv, lg}, Want{Disabled: map[string]bool{"HDMI-A-1": true}}, "DP-2", "5120x2160@165.000", false},
61+ {"all disabled", []connector{tv}, Want{Disabled: map[string]bool{"HDMI-A-1": true}}, "", "", true},
4762 } {
48- c, m, err := pickConnector(tc.conns)
49- if (err != nil) != tc.err || c.name != tc.want || m.HDisplay != tc.mode {
50- t.Errorf("%s: got %q %d %v", tc.name, c.name, m.HDisplay, err)
63+ c, m, err := pickConnector(tc.conns, tc.want)
64+ if (err != nil) != tc.err || c.name != tc.conn || (err == nil && m.String() != tc.mode) {
65+ t.Errorf("%s: got %q %s %v", tc.name, c.name, m, err)
5166 }
5267 }
5368 }
5469
70+func TestRefreshMilliLG(t *testing.T) {
71+ // Real 5120x2160@165.058 CVT-RB timings.
72+ m := modeInfo{Clock: 1_989_530, HDisplay: 5120, HTotal: 5200, VDisplay: 2160, VTotal: 2318}
73+ if got := m.refreshMilli(); got < 165_000 || got > 165_100 {
74+ t.Fatalf("refresh %d mHz", got)
75+ }
76+}
77+
78+func TestParseEDID(t *testing.T) {
79+ d := make([]byte, 128)
80+ copy(d, "\x00\xff\xff\xff\xff\xff\xff\x00")
81+ d[8], d[9] = 0x1e, 0x6d // GSM
82+ copy(d[54:], []byte{0, 0, 0, 0xfc, 0})
83+ copy(d[59:], "LG ULTRAGEAR+\n")
84+ copy(d[72:], []byte{0, 0, 0, 0xff, 0})
85+ copy(d[77:], "SN123\n ")
86+ m := parseEDID(d)
87+ if m.Model != "LG ULTRAGEAR+" || m.Serial != "SN123" || (m.Make != "GSM" && m.Make != "LG Electronics") {
88+ t.Fatalf("%+v", m)
89+ }
90+}
91+
5592 func TestCountFlipEvents(t *testing.T) {
5693 ev := func(typ uint32, n int) []byte {
5794 b := make([]byte, n)
@@ -31,27 +31,64 @@ func TestStructSizesMatchIoctls(t *testing.T) {
31 }31 }
32 32
33 func TestPickConnector(t *testing.T) {33 func TestPickConnector(t *testing.T) {
34- plain := modeInfo{HDisplay: 1280, VDisplay: 720}34+ mode := func(w, h uint16, hz uint32, pref bool) modeInfo {
35- pref := modeInfo{HDisplay: 2560, VDisplay: 1440, Type: modeTypePrefered}35+ // htotal*vtotal = w*h keeps refreshMilli = clock*1000/(w*h).
36+ m := modeInfo{HDisplay: w, VDisplay: h, HTotal: w, VTotal: h, Clock: uint32(uint64(w) * uint64(h) * uint64(hz) / 1000), VRefresh: hz}
37+ if pref {
38+ m.Type = modeTypePrefered
39+ }
40+ return m
41+ }
42+ lg := connector{name: "DP-2", connected: true, modes: []modeInfo{mode(5120, 2160, 165, true), mode(3440, 1440, 100, true), mode(5120, 2160, 100, false), mode(5120, 2160, 60, false)}}
43+ tv := connector{name: "HDMI-A-1", connected: true, modes: []modeInfo{mode(3840, 2160, 60, true), mode(3840, 2160, 120, false)}}
44+ off := connector{name: "DP-1", modes: []modeInfo{mode(1920, 1080, 60, true)}}
36 for _, tc := range []struct {45 for _, tc := range []struct {
37 name string46 name string
38 conns []connector47 conns []connector
39- want string48+ want Want
40- mode uint1649+ conn string
50+ mode string
41 err bool51 err bool
42 }{52 }{
43- {"none", nil, "", 0, true},53+ {"none", nil, Want{}, "", "", true},
44- {"disconnected skipped", []connector{{name: "DP-1", modes: []modeInfo{pref}}, {name: "DP-2", connected: true, modes: []modeInfo{plain}}}, "DP-2", 1280, false},54+ {"first connected, first preferred", []connector{off, tv, lg}, Want{}, "HDMI-A-1", "3840x2160@60.000", false},
45- {"preferred mode", []connector{{name: "DP-1", connected: true, modes: []modeInfo{plain, pref}}}, "DP-1", 2560, false},55+ {"named", []connector{tv, lg}, Want{Name: "DP-2"}, "DP-2", "5120x2160@165.000", false},
46- {"no modes skipped", []connector{{name: "DP-1", connected: true}}, "", 0, true},56+ {"named mode closest refresh", []connector{tv, lg}, Want{Name: "DP-2", W: 5120, H: 2160, Hz: 99}, "DP-2", "5120x2160@100.000", false},
57+ {"named mode highest refresh", []connector{lg}, Want{Name: "DP-2", W: 5120, H: 2160}, "DP-2", "5120x2160@165.000", false},
58+ {"unknown mode falls back to preferred", []connector{lg}, Want{Name: "DP-2", W: 800, H: 600}, "DP-2", "5120x2160@165.000", false},
59+ {"missing name falls back", []connector{tv}, Want{Name: "DP-2"}, "HDMI-A-1", "3840x2160@60.000", false},
60+ {"disabled skipped", []connector{tv, lg}, Want{Disabled: map[string]bool{"HDMI-A-1": true}}, "DP-2", "5120x2160@165.000", false},
61+ {"all disabled", []connector{tv}, Want{Disabled: map[string]bool{"HDMI-A-1": true}}, "", "", true},
47 } {62 } {
48- c, m, err := pickConnector(tc.conns)63+ c, m, err := pickConnector(tc.conns, tc.want)
49- if (err != nil) != tc.err || c.name != tc.want || m.HDisplay != tc.mode {64+ if (err != nil) != tc.err || c.name != tc.conn || (err == nil && m.String() != tc.mode) {
50- t.Errorf("%s: got %q %d %v", tc.name, c.name, m.HDisplay, err)65+ t.Errorf("%s: got %q %s %v", tc.name, c.name, m, err)
51 }66 }
52 }67 }
53 }68 }
54 69
70+func TestRefreshMilliLG(t *testing.T) {
71+ // Real 5120x2160@165.058 CVT-RB timings.
72+ m := modeInfo{Clock: 1_989_530, HDisplay: 5120, HTotal: 5200, VDisplay: 2160, VTotal: 2318}
73+ if got := m.refreshMilli(); got < 165_000 || got > 165_100 {
74+ t.Fatalf("refresh %d mHz", got)
75+ }
76+}
77+
78+func TestParseEDID(t *testing.T) {
79+ d := make([]byte, 128)
80+ copy(d, "\x00\xff\xff\xff\xff\xff\xff\x00")
81+ d[8], d[9] = 0x1e, 0x6d // GSM
82+ copy(d[54:], []byte{0, 0, 0, 0xfc, 0})
83+ copy(d[59:], "LG ULTRAGEAR+\n")
84+ copy(d[72:], []byte{0, 0, 0, 0xff, 0})
85+ copy(d[77:], "SN123\n ")
86+ m := parseEDID(d)
87+ if m.Model != "LG ULTRAGEAR+" || m.Serial != "SN123" || (m.Make != "GSM" && m.Make != "LG Electronics") {
88+ t.Fatalf("%+v", m)
89+ }
90+}
91+
55 func TestCountFlipEvents(t *testing.T) {92 func TestCountFlipEvents(t *testing.T) {
56 ev := func(typ uint32, n int) []byte {93 ev := func(typ uint32, n int) []byte {
57 b := make([]byte, n)94 b := make([]byte, n)
M internal/adapters/drm/output.go +33 -6
@@ -32,11 +32,21 @@ type Output struct {
3232 pending bool
3333 flipStart time.Time
3434 flips int
35+ monitor Monitor
3536 log zerowrap.Logger
3637 }
3738
38-// Open picks the first connected connector on the card fd and allocates buffers.
39-func Open(fd int, card string, log zerowrap.Logger) (*Output, error) {
39+// Info describes the chosen output for clients (wl_output, xdg-output).
40+type Info struct {
41+ Name string
42+ Monitor Monitor
43+ Width, Height int
44+ RefreshMilli int
45+ PhysicalW, PhysicalH int // millimetres
46+}
47+
48+// Open picks a connector on the card fd according to want and allocates buffers.
49+func Open(fd int, card string, want Want, log zerowrap.Logger) (*Output, error) {
4050 crtcs, ids, err := resources(fd)
4151 if err != nil {
4252 return nil, err
@@ -48,10 +58,19 @@ func Open(fd int, card string, log zerowrap.Logger) (*Output, error) {
4858 log.Warn().Err(err).Uint32("connector", id).Msg("read connector")
4959 continue
5060 }
51- log.Info().Str("connector", c.name).Bool("connected", c.connected).Int("modes", len(c.modes)).Msg("connector")
61+ ev := log.Info().Str("connector", c.name).Bool("connected", c.connected)
62+ if c.connected {
63+ mon := readMonitor(card, c.name)
64+ modes := make([]string, 0, len(c.modes))
65+ for _, m := range c.modes {
66+ modes = append(modes, m.String())
67+ }
68+ ev = ev.Str("make", mon.Make).Str("model", mon.Model).Int("mm_w", c.mmW).Int("mm_h", c.mmH).Strs("modes", modes)
69+ }
70+ ev.Msg("connector")
5271 conns = append(conns, c)
5372 }
54- c, mode, err := pickConnector(conns)
73+ c, mode, err := pickConnector(conns, want)
5574 if err != nil {
5675 return nil, fmt.Errorf("%s: %w", card, err)
5776 }
@@ -59,7 +78,10 @@ func Open(fd int, card string, log zerowrap.Logger) (*Output, error) {
5978 if err != nil {
6079 return nil, err
6180 }
62- o := &Output{fd: fd, crtc: crtc, conn: c, mode: mode, log: log}
81+ o := &Output{fd: fd, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card, c.name)}
82+ if want.Name != "" && want.Name != c.name {
83+ log.Warn().Str("wanted", want.Name).Str("using", c.name).Msg("configured output not connected")
84+ }
6385 if o.saved, err = getCrtc(fd, crtc); err != nil {
6486 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
6587 }
@@ -69,10 +91,15 @@ func Open(fd int, card string, log zerowrap.Logger) (*Output, error) {
6991 return nil, err
7092 }
7193 }
72- log.Info().Str("card", card).Str("connector", c.name).Str("mode", mode.String()).Str("mode_name", cstr(mode.Name[:])).Uint32("crtc", crtc).Msg("output")
94+ log.Info().Str("card", card).Str("connector", c.name).Str("mode", mode.String()).Str("make", o.monitor.Make).Str("model", o.monitor.Model).Uint32("crtc", crtc).Msg("output")
7395 return o, nil
7496 }
7597
98+// Info returns the chosen connector, monitor and mode.
99+func (o *Output) Info() Info {
100+ return Info{Name: o.conn.name, Monitor: o.monitor, Width: o.Width(), Height: o.Height(), RefreshMilli: o.mode.refreshMilli(), PhysicalW: o.conn.mmW, PhysicalH: o.conn.mmH}
101+}
102+
76103 func (o *Output) Width() int { return int(o.mode.HDisplay) }
77104 func (o *Output) Height() int { return int(o.mode.VDisplay) }
78105
@@ -32,11 +32,21 @@ type Output struct {
32 pending bool32 pending bool
33 flipStart time.Time33 flipStart time.Time
34 flips int34 flips int
35+ monitor Monitor
35 log zerowrap.Logger36 log zerowrap.Logger
36 }37 }
37 38
38-// Open picks the first connected connector on the card fd and allocates buffers.39+// Info describes the chosen output for clients (wl_output, xdg-output).
39-func Open(fd int, card string, log zerowrap.Logger) (*Output, error) {40+type Info struct {
41+ Name string
42+ Monitor Monitor
43+ Width, Height int
44+ RefreshMilli int
45+ PhysicalW, PhysicalH int // millimetres
46+}
47+
48+// Open picks a connector on the card fd according to want and allocates buffers.
49+func Open(fd int, card string, want Want, log zerowrap.Logger) (*Output, error) {
40 crtcs, ids, err := resources(fd)50 crtcs, ids, err := resources(fd)
41 if err != nil {51 if err != nil {
42 return nil, err52 return nil, err
@@ -48,10 +58,19 @@ func Open(fd int, card string, log zerowrap.Logger) (*Output, error) {
48 log.Warn().Err(err).Uint32("connector", id).Msg("read connector")58 log.Warn().Err(err).Uint32("connector", id).Msg("read connector")
49 continue59 continue
50 }60 }
51- log.Info().Str("connector", c.name).Bool("connected", c.connected).Int("modes", len(c.modes)).Msg("connector")61+ ev := log.Info().Str("connector", c.name).Bool("connected", c.connected)
62+ if c.connected {
63+ mon := readMonitor(card, c.name)
64+ modes := make([]string, 0, len(c.modes))
65+ for _, m := range c.modes {
66+ modes = append(modes, m.String())
67+ }
68+ ev = ev.Str("make", mon.Make).Str("model", mon.Model).Int("mm_w", c.mmW).Int("mm_h", c.mmH).Strs("modes", modes)
69+ }
70+ ev.Msg("connector")
52 conns = append(conns, c)71 conns = append(conns, c)
53 }72 }
54- c, mode, err := pickConnector(conns)73+ c, mode, err := pickConnector(conns, want)
55 if err != nil {74 if err != nil {
56 return nil, fmt.Errorf("%s: %w", card, err)75 return nil, fmt.Errorf("%s: %w", card, err)
57 }76 }
@@ -59,7 +78,10 @@ func Open(fd int, card string, log zerowrap.Logger) (*Output, error) {
59 if err != nil {78 if err != nil {
60 return nil, err79 return nil, err
61 }80 }
62- o := &Output{fd: fd, crtc: crtc, conn: c, mode: mode, log: log}81+ o := &Output{fd: fd, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card, c.name)}
82+ if want.Name != "" && want.Name != c.name {
83+ log.Warn().Str("wanted", want.Name).Str("using", c.name).Msg("configured output not connected")
84+ }
63 if o.saved, err = getCrtc(fd, crtc); err != nil {85 if o.saved, err = getCrtc(fd, crtc); err != nil {
64 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")86 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
65 }87 }
@@ -69,10 +91,15 @@ func Open(fd int, card string, log zerowrap.Logger) (*Output, error) {
69 return nil, err91 return nil, err
70 }92 }
71 }93 }
72- log.Info().Str("card", card).Str("connector", c.name).Str("mode", mode.String()).Str("mode_name", cstr(mode.Name[:])).Uint32("crtc", crtc).Msg("output")94+ log.Info().Str("card", card).Str("connector", c.name).Str("mode", mode.String()).Str("make", o.monitor.Make).Str("model", o.monitor.Model).Uint32("crtc", crtc).Msg("output")
73 return o, nil95 return o, nil
74 }96 }
75 97
98+// Info returns the chosen connector, monitor and mode.
99+func (o *Output) Info() Info {
100+ return Info{Name: o.conn.name, Monitor: o.monitor, Width: o.Width(), Height: o.Height(), RefreshMilli: o.mode.refreshMilli(), PhysicalW: o.conn.mmW, PhysicalH: o.conn.mmH}
101+}
102+
76 func (o *Output) Width() int { return int(o.mode.HDisplay) }103 func (o *Output) Width() int { return int(o.mode.HDisplay) }
77 func (o *Output) Height() int { return int(o.mode.VDisplay) }104 func (o *Output) Height() int { return int(o.mode.VDisplay) }
78 105
M internal/adapters/wayland/globals.go +5 -4
@@ -36,14 +36,15 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
3636 if e != nil {
3737 return
3838 }
39- r.SendGeometry(0, 0, 0, 0, 0, "nefertty", "headless", 0)
40- r.SendMode(3, int32(o.OutputWidth), int32(o.OutputHeight), 60000)
39+ info := o.output()
40+ r.SendGeometry(0, 0, int32(info.PhysicalW), int32(info.PhysicalH), 0, info.Make, info.Model, 0)
41+ r.SendMode(3, int32(o.OutputWidth), int32(o.OutputHeight), int32(info.RefreshMilli))
4142 if v >= 2 {
4243 r.SendScale(1)
4344 }
4445 if v >= 4 {
45- r.SendName("HEADLESS-1")
46- r.SendDescription("NeferTTY headless output")
46+ r.SendName(info.Name)
47+ r.SendDescription(info.Description)
4748 }
4849 if v >= 2 {
4950 r.SendDone()
@@ -36,14 +36,15 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
36 if e != nil {36 if e != nil {
37 return37 return
38 }38 }
39- r.SendGeometry(0, 0, 0, 0, 0, "nefertty", "headless", 0)39+ info := o.output()
40- r.SendMode(3, int32(o.OutputWidth), int32(o.OutputHeight), 60000)40+ r.SendGeometry(0, 0, int32(info.PhysicalW), int32(info.PhysicalH), 0, info.Make, info.Model, 0)
41+ r.SendMode(3, int32(o.OutputWidth), int32(o.OutputHeight), int32(info.RefreshMilli))
41 if v >= 2 {42 if v >= 2 {
42 r.SendScale(1)43 r.SendScale(1)
43 }44 }
44 if v >= 4 {45 if v >= 4 {
45- r.SendName("HEADLESS-1")46+ r.SendName(info.Name)
46- r.SendDescription("NeferTTY headless output")47+ r.SendDescription(info.Description)
47 }48 }
48 if v >= 2 {49 if v >= 2 {
49 r.SendDone()50 r.SendDone()
M internal/adapters/wayland/server.go +23 -0
@@ -19,10 +19,33 @@ import (
1919 type Options struct {
2020 RuntimeDir string
2121 OutputWidth, OutputHeight int
22+ Output OutputInfo
2223 Keymap string
2324 RepeatRate, RepeatDelay int
2425 }
2526
27+// OutputInfo describes the advertised wl_output. Zero values fall back to the headless output.
28+type OutputInfo struct {
29+ Name, Description string
30+ Make, Model string
31+ RefreshMilli int
32+ PhysicalW, PhysicalH int
33+}
34+
35+func (o Options) output() OutputInfo {
36+ i := o.Output
37+ if i.Name == "" {
38+ i.Name, i.Description = "HEADLESS-1", "NeferTTY headless output"
39+ }
40+ if i.Make == "" {
41+ i.Make, i.Model = "nefertty", "headless"
42+ }
43+ if i.RefreshMilli == 0 {
44+ i.RefreshMilli = 60000
45+ }
46+ return i
47+}
48+
2649 // Channels carries client notifications and commands. Events may be unbuffered.
2750 type Channels struct {
2851 Events chan<- ports.ClientEvent
@@ -19,10 +19,33 @@ import (
19 type Options struct {19 type Options struct {
20 RuntimeDir string20 RuntimeDir string
21 OutputWidth, OutputHeight int21 OutputWidth, OutputHeight int
22+ Output OutputInfo
22 Keymap string23 Keymap string
23 RepeatRate, RepeatDelay int24 RepeatRate, RepeatDelay int
24 }25 }
25 26
27+// OutputInfo describes the advertised wl_output. Zero values fall back to the headless output.
28+type OutputInfo struct {
29+ Name, Description string
30+ Make, Model string
31+ RefreshMilli int
32+ PhysicalW, PhysicalH int
33+}
34+
35+func (o Options) output() OutputInfo {
36+ i := o.Output
37+ if i.Name == "" {
38+ i.Name, i.Description = "HEADLESS-1", "NeferTTY headless output"
39+ }
40+ if i.Make == "" {
41+ i.Make, i.Model = "nefertty", "headless"
42+ }
43+ if i.RefreshMilli == 0 {
44+ i.RefreshMilli = 60000
45+ }
46+ return i
47+}
48+
26 // Channels carries client notifications and commands. Events may be unbuffered.49 // Channels carries client notifications and commands. Events may be unbuffered.
27 type Channels struct {50 type Channels struct {
28 Events chan<- ports.ClientEvent51 Events chan<- ports.ClientEvent
M internal/adapters/wayland/xdgoutput.go +7 -4
@@ -6,11 +6,14 @@ import (
66 "github.com/bnema/purego-libwayland/server"
77 )
88
9-type xdgOutputManager struct{ width, height int32 }
9+type xdgOutputManager struct {
10+ width, height int32
11+ info OutputInfo
12+}
1013
1114 func registerXDGOutput(d *server.Display, o Options) error {
1215 return xdgoutput.NewZxdgOutputManagerV1Global(d, 3, func(c server.Client, v, id uint32) {
13- _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{int32(o.OutputWidth), int32(o.OutputHeight)})
16+ _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{int32(o.OutputWidth), int32(o.OutputHeight), o.output()})
1417 })
1518 }
1619 func (xdgOutputManager) Destroy(*xdgoutput.ZxdgOutputManagerV1) {}
@@ -22,8 +25,8 @@ func (m xdgOutputManager) GetXdgOutput(r *xdgoutput.ZxdgOutputManagerV1, id uint
2225 x.SendLogicalPosition(0, 0)
2326 x.SendLogicalSize(m.width, m.height)
2427 if r.Version() >= 2 {
25- x.SendName("HEADLESS-1")
26- x.SendDescription("NeferTTY headless output")
28+ x.SendName(m.info.Name)
29+ x.SendDescription(m.info.Description)
2730 }
2831 if r.Version() < 3 || output == nil || output.Version() < 2 {
2932 x.SendDone()
@@ -6,11 +6,14 @@ import (
6 "github.com/bnema/purego-libwayland/server"6 "github.com/bnema/purego-libwayland/server"
7 )7 )
8 8
9-type xdgOutputManager struct{ width, height int32 }9+type xdgOutputManager struct {
10+ width, height int32
11+ info OutputInfo
12+}
10 13
11 func registerXDGOutput(d *server.Display, o Options) error {14 func registerXDGOutput(d *server.Display, o Options) error {
12 return xdgoutput.NewZxdgOutputManagerV1Global(d, 3, func(c server.Client, v, id uint32) {15 return xdgoutput.NewZxdgOutputManagerV1Global(d, 3, func(c server.Client, v, id uint32) {
13- _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{int32(o.OutputWidth), int32(o.OutputHeight)})16+ _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{int32(o.OutputWidth), int32(o.OutputHeight), o.output()})
14 })17 })
15 }18 }
16 func (xdgOutputManager) Destroy(*xdgoutput.ZxdgOutputManagerV1) {}19 func (xdgOutputManager) Destroy(*xdgoutput.ZxdgOutputManagerV1) {}
@@ -22,8 +25,8 @@ func (m xdgOutputManager) GetXdgOutput(r *xdgoutput.ZxdgOutputManagerV1, id uint
22 x.SendLogicalPosition(0, 0)25 x.SendLogicalPosition(0, 0)
23 x.SendLogicalSize(m.width, m.height)26 x.SendLogicalSize(m.width, m.height)
24 if r.Version() >= 2 {27 if r.Version() >= 2 {
25- x.SendName("HEADLESS-1")28+ x.SendName(m.info.Name)
26- x.SendDescription("NeferTTY headless output")29+ x.SendDescription(m.info.Description)
27 }30 }
28 if r.Version() < 3 || output == nil || output.Version() < 2 {31 if r.Version() < 3 || output == nil || output.Version() < 2 {
29 x.SendDone()32 x.SendDone()
M internal/app/app.go +4 -2
@@ -49,14 +49,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
4949 }
5050 defer cancel()
5151 width, height := 1920, 1080
52+ var outInfo wayland.OutputInfo
5253 var hw *drmBackend
5354 if opts.Backend == "drm" {
5455 var err error
55- if hw, err = openDRM(ctx); err != nil {
56+ if hw, err = openDRM(ctx, opts.Config.Outputs); err != nil {
5657 return err
5758 }
5859 defer hw.close()
5960 width, height = hw.out.Width(), hw.out.Height()
61+ outInfo = hw.outputInfo()
6062 hw.inputActive, hw.outputActive = hw.seat.Subscribe(), hw.seat.Subscribe()
6163 }
6264 client := make(chan ports.ClientEvent, 32)
@@ -81,7 +83,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
8183 }
8284 keymap := km.String()
8385 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
84- 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"))
86+ server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: width, OutputHeight: height, Output: outInfo, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland"))
8587 if err != nil {
8688 km.Close()
8789 return err
@@ -49,14 +49,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
49 }49 }
50 defer cancel()50 defer cancel()
51 width, height := 1920, 108051 width, height := 1920, 1080
52+ var outInfo wayland.OutputInfo
52 var hw *drmBackend53 var hw *drmBackend
53 if opts.Backend == "drm" {54 if opts.Backend == "drm" {
54 var err error55 var err error
55- if hw, err = openDRM(ctx); err != nil {56+ if hw, err = openDRM(ctx, opts.Config.Outputs); err != nil {
56 return err57 return err
57 }58 }
58 defer hw.close()59 defer hw.close()
59 width, height = hw.out.Width(), hw.out.Height()60 width, height = hw.out.Width(), hw.out.Height()
61+ outInfo = hw.outputInfo()
60 hw.inputActive, hw.outputActive = hw.seat.Subscribe(), hw.seat.Subscribe()62 hw.inputActive, hw.outputActive = hw.seat.Subscribe(), hw.seat.Subscribe()
61 }63 }
62 client := make(chan ports.ClientEvent, 32)64 client := make(chan ports.ClientEvent, 32)
@@ -81,7 +83,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
81 }83 }
82 keymap := km.String()84 keymap := km.String()
83 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")85 runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
84- 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"))86+ server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: width, OutputHeight: height, Output: outInfo, Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{Events: client, Commands: commands, Contents: contents}, logging.For(ctx, "wayland"))
85 if err != nil {87 if err != nil {
86 km.Close()88 km.Close()
87 return err89 return err
M internal/app/drm.go +37 -2
@@ -7,10 +7,14 @@ import (
77 "path/filepath"
88 "runtime/debug"
99 "sort"
10+ "strings"
1011
12+ "github.com/bnema/nefertty/internal/adapters/config"
1113 "github.com/bnema/nefertty/internal/adapters/drm"
1214 "github.com/bnema/nefertty/internal/adapters/seat"
15+ "github.com/bnema/nefertty/internal/adapters/wayland"
1316 "github.com/bnema/nefertty/internal/logging"
17+ "github.com/bnema/nefertty/internal/ports"
1418 )
1519
1620 var errTimeout = errors.New("timeout")
@@ -34,13 +38,14 @@ func safe(name string, fn func() error) (err error) {
3438 }
3539
3640 // openDRM opens the seat and the first card with a connected display.
37-func openDRM(ctx context.Context) (*drmBackend, error) {
41+func openDRM(ctx context.Context, outputs []ports.OutputConfig) (*drmBackend, error) {
3842 log := logging.For(ctx, "drm")
3943 s, err := seat.Open(ctx, logging.For(ctx, "seat"))
4044 if err != nil {
4145 log.Error().Err(err).Msg("open seat")
4246 return nil, fmt.Errorf("open seat: %w", err)
4347 }
48+ want := wantFromConfig(outputs)
4449 cards, _ := filepath.Glob("/dev/dri/card[0-9]*")
4550 sort.Strings(cards)
4651 var errs []error
@@ -50,7 +55,7 @@ func openDRM(ctx context.Context) (*drmBackend, error) {
5055 errs = append(errs, err)
5156 continue
5257 }
53- out, err := drm.Open(fd, card, log)
58+ out, err := drm.Open(fd, card, want, log)
5459 if err != nil {
5560 log.Warn().Err(err).Str("card", card).Msg("card unusable")
5661 errs = append(errs, err)
@@ -65,6 +70,36 @@ func openDRM(ctx context.Context) (*drmBackend, error) {
6570 return nil, err
6671 }
6772
73+// wantFromConfig turns [[output]] entries into a connector choice: the first enabled entry wins.
74+func wantFromConfig(outputs []ports.OutputConfig) drm.Want {
75+ want := drm.Want{Disabled: map[string]bool{}}
76+ for _, o := range outputs {
77+ if o.Off {
78+ want.Disabled[o.Name] = true
79+ continue
80+ }
81+ if want.Name != "" {
82+ continue
83+ }
84+ want.Name = o.Name
85+ if o.Mode != "" {
86+ // Validated at config load.
87+ want.W, want.H, want.Hz, _ = config.ParseMode(o.Mode)
88+ }
89+ }
90+ return want
91+}
92+
93+// outputInfo describes the chosen output for wl_output and xdg-output.
94+func (b *drmBackend) outputInfo() wayland.OutputInfo {
95+ i := b.out.Info()
96+ return wayland.OutputInfo{
97+ Name: i.Name, Make: i.Monitor.Make, Model: i.Monitor.Model,
98+ Description: strings.TrimSpace(i.Monitor.Make + " " + i.Monitor.Model + " " + i.Monitor.Serial + " (" + i.Name + ")"),
99+ RefreshMilli: i.RefreshMilli, PhysicalW: i.PhysicalW, PhysicalH: i.PhysicalH,
100+ }
101+}
102+
68103 // close restores the CRTC, then releases the card and the seat so the TTY comes back.
69104 func (b *drmBackend) close() {
70105 b.out.Close()
@@ -7,10 +7,14 @@ import (
7 "path/filepath"7 "path/filepath"
8 "runtime/debug"8 "runtime/debug"
9 "sort"9 "sort"
10+ "strings"
10 11
12+ "github.com/bnema/nefertty/internal/adapters/config"
11 "github.com/bnema/nefertty/internal/adapters/drm"13 "github.com/bnema/nefertty/internal/adapters/drm"
12 "github.com/bnema/nefertty/internal/adapters/seat"14 "github.com/bnema/nefertty/internal/adapters/seat"
15+ "github.com/bnema/nefertty/internal/adapters/wayland"
13 "github.com/bnema/nefertty/internal/logging"16 "github.com/bnema/nefertty/internal/logging"
17+ "github.com/bnema/nefertty/internal/ports"
14 )18 )
15 19
16 var errTimeout = errors.New("timeout")20 var errTimeout = errors.New("timeout")
@@ -34,13 +38,14 @@ func safe(name string, fn func() error) (err error) {
34 }38 }
35 39
36 // openDRM opens the seat and the first card with a connected display.40 // openDRM opens the seat and the first card with a connected display.
37-func openDRM(ctx context.Context) (*drmBackend, error) {41+func openDRM(ctx context.Context, outputs []ports.OutputConfig) (*drmBackend, error) {
38 log := logging.For(ctx, "drm")42 log := logging.For(ctx, "drm")
39 s, err := seat.Open(ctx, logging.For(ctx, "seat"))43 s, err := seat.Open(ctx, logging.For(ctx, "seat"))
40 if err != nil {44 if err != nil {
41 log.Error().Err(err).Msg("open seat")45 log.Error().Err(err).Msg("open seat")
42 return nil, fmt.Errorf("open seat: %w", err)46 return nil, fmt.Errorf("open seat: %w", err)
43 }47 }
48+ want := wantFromConfig(outputs)
44 cards, _ := filepath.Glob("/dev/dri/card[0-9]*")49 cards, _ := filepath.Glob("/dev/dri/card[0-9]*")
45 sort.Strings(cards)50 sort.Strings(cards)
46 var errs []error51 var errs []error
@@ -50,7 +55,7 @@ func openDRM(ctx context.Context) (*drmBackend, error) {
50 errs = append(errs, err)55 errs = append(errs, err)
51 continue56 continue
52 }57 }
53- out, err := drm.Open(fd, card, log)58+ out, err := drm.Open(fd, card, want, log)
54 if err != nil {59 if err != nil {
55 log.Warn().Err(err).Str("card", card).Msg("card unusable")60 log.Warn().Err(err).Str("card", card).Msg("card unusable")
56 errs = append(errs, err)61 errs = append(errs, err)
@@ -65,6 +70,36 @@ func openDRM(ctx context.Context) (*drmBackend, error) {
65 return nil, err70 return nil, err
66 }71 }
67 72
73+// wantFromConfig turns [[output]] entries into a connector choice: the first enabled entry wins.
74+func wantFromConfig(outputs []ports.OutputConfig) drm.Want {
75+ want := drm.Want{Disabled: map[string]bool{}}
76+ for _, o := range outputs {
77+ if o.Off {
78+ want.Disabled[o.Name] = true
79+ continue
80+ }
81+ if want.Name != "" {
82+ continue
83+ }
84+ want.Name = o.Name
85+ if o.Mode != "" {
86+ // Validated at config load.
87+ want.W, want.H, want.Hz, _ = config.ParseMode(o.Mode)
88+ }
89+ }
90+ return want
91+}
92+
93+// outputInfo describes the chosen output for wl_output and xdg-output.
94+func (b *drmBackend) outputInfo() wayland.OutputInfo {
95+ i := b.out.Info()
96+ return wayland.OutputInfo{
97+ Name: i.Name, Make: i.Monitor.Make, Model: i.Monitor.Model,
98+ Description: strings.TrimSpace(i.Monitor.Make + " " + i.Monitor.Model + " " + i.Monitor.Serial + " (" + i.Name + ")"),
99+ RefreshMilli: i.RefreshMilli, PhysicalW: i.PhysicalW, PhysicalH: i.PhysicalH,
100+ }
101+}
102+
68 // close restores the CRTC, then releases the card and the seat so the TTY comes back.103 // close restores the CRTC, then releases the card and the seat so the TTY comes back.
69 func (b *drmBackend) close() {104 func (b *drmBackend) close() {
70 b.out.Close()105 b.out.Close()
M internal/ports/config.go +15 -2
@@ -1,5 +1,16 @@
11 package ports
22
3+// OutputConfig configures one connector. Name is the connector (e.g. "DP-2").
4+// Mode is "WxH" (highest refresh) or "WxH@Hz" (closest refresh); empty picks the
5+// monitor's preferred mode. Off disables the connector.
6+// The first listed, connected, enabled output is used; without a match, the first
7+// connected output that is not off.
8+type OutputConfig struct {
9+ Name string
10+ Mode string
11+ Off bool
12+}
13+
314 // Config is the parsed compositor configuration.
415 type Config struct {
516 Keyboard struct {
@@ -15,8 +26,10 @@ type Config struct {
1526 DefaultColumnWidth string `toml:"default_column_width"`
1627 Presets []string
1728 } `toml:"layout"`
18- Binds map[string]string `toml:"binds"`
19- Render struct {
29+ // Outputs selects and configures physical displays (drm backend).
30+ Outputs []OutputConfig `toml:"output"`
31+ Binds map[string]string `toml:"binds"`
32+ Render struct {
2033 DirectScanout bool `toml:"direct_scanout"`
2134 } `toml:"render"`
2235 Log struct {
@@ -1,5 +1,16 @@
1 package ports1 package ports
2 2
3+// OutputConfig configures one connector. Name is the connector (e.g. "DP-2").
4+// Mode is "WxH" (highest refresh) or "WxH@Hz" (closest refresh); empty picks the
5+// monitor's preferred mode. Off disables the connector.
6+// The first listed, connected, enabled output is used; without a match, the first
7+// connected output that is not off.
8+type OutputConfig struct {
9+ Name string
10+ Mode string
11+ Off bool
12+}
13+
3 // Config is the parsed compositor configuration.14 // Config is the parsed compositor configuration.
4 type Config struct {15 type Config struct {
5 Keyboard struct {16 Keyboard struct {
@@ -15,8 +26,10 @@ type Config struct {
15 DefaultColumnWidth string `toml:"default_column_width"`26 DefaultColumnWidth string `toml:"default_column_width"`
16 Presets []string27 Presets []string
17 } `toml:"layout"`28 } `toml:"layout"`
18- Binds map[string]string `toml:"binds"`29+ // Outputs selects and configures physical displays (drm backend).
19- Render struct {30+ Outputs []OutputConfig `toml:"output"`
31+ Binds map[string]string `toml:"binds"`
32+ Render struct {
20 DirectScanout bool `toml:"direct_scanout"`33 DirectScanout bool `toml:"direct_scanout"`
21 } `toml:"render"`34 } `toml:"render"`
22 Log struct {35 Log struct {

Keyboard shortcuts

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

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

Branches 23

Tags 10

↑↓ moveenter openesc close