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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +381 -53
M examples/config.toml +12 -0
| @@ -17,6 +17,18 @@ gaps = 8 | |||
| 17 | 17 | default_column_width = "1/2" | |
| 18 | 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 | 32 | [render] | |
| 21 | 33 | direct_scanout = true | |
| 22 | 34 | ||
| @@ -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 = true | 33 | direct_scanout = true | ||
| 22 | 34 | ||||
M internal/adapters/config/config.go +27 -0
| @@ -99,6 +99,24 @@ func Load(path string) (ports.Config, error) { | |||
| 99 | 99 | } | |
| 100 | 100 | 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 | 120 | func width(s string) bool { | |
| 103 | 121 | if s == "1" { | |
| 104 | 122 | return true | |
| @@ -189,6 +207,15 @@ func Validate(c ports.Config) error { | |||
| 189 | 207 | if !color.MatchString(c.Background.Color) { | |
| 190 | 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 | 219 | if c.Layout.Gaps < 0 || c.Layout.Gaps > 200 { | |
| 193 | 220 | add("layout.gaps", "must be between 0 and 200") | |
| 194 | 221 | } | |
| @@ -99,6 +99,24 @@ func Load(path string) (ports.Config, error) { | |||||
| 99 | } | 99 | } | ||
| 100 | return c, nil | 100 | 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 true | 122 | 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) { | |||
| 86 | 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 | + | } | |
| @@ -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 @@ | |||
| 2 | 2 | package drm | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | - | "bytes" | |
| 6 | 5 | "errors" | |
| 7 | 6 | "fmt" | |
| 7 | + | "math" | |
| 8 | 8 | "unsafe" | |
| 9 | 9 | ||
| 10 | 10 | "golang.org/x/sys/unix" | |
| @@ -47,7 +47,7 @@ type modeInfo struct { | |||
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | 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 | 53 | type getConnector struct { | |
| @@ -107,26 +107,91 @@ type connector struct { | |||
| 107 | 107 | id, encoderID uint32 | |
| 108 | 108 | name string | |
| 109 | 109 | connected bool | |
| 110 | + | mmW, mmH int | |
| 110 | 111 | modes []modeInfo | |
| 111 | 112 | encoders []uint32 | |
| 112 | 113 | } | |
| 113 | 114 | ||
| 114 | 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 | - | 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] { | |
| 120 | 150 | continue | |
| 121 | 151 | } | |
| 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 | |
| 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 | 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 | 238 | return connector{ | |
| 174 | 239 | 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))], | |
| 178 | 243 | }, nil | |
| 179 | 244 | } | |
| 180 | 245 | ||
| @@ -282,10 +347,3 @@ func countFlipEvents(buf []byte) int { | |||
| 282 | 347 | } | |
| 283 | 348 | 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 | - | } | |
| @@ -2,9 +2,9 @@ | |||||
| 2 | package drm | 2 | 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 uint32 | 107 | id, encoderID uint32 | ||
| 108 | name string | 108 | name string | ||
| 109 | connected bool | 109 | connected bool | ||
| 110 | + | mmW, mmH int | |||
| 110 | modes []modeInfo | 111 | modes []modeInfo | ||
| 111 | encoders []uint32 | 112 | 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 | continue | 150 | 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, nil | 154 | + | 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 | }, nil | 243 | }, nil | ||
| 179 | } | 244 | } | ||
| 180 | 245 | ||||
| @@ -282,10 +347,3 @@ func countFlipEvents(buf []byte) int { | |||||
| 282 | } | 347 | } | ||
| 283 | return n | 348 | 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) { | |||
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | 33 | 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)}} | |
| 36 | 45 | for _, tc := range []struct { | |
| 37 | 46 | name string | |
| 38 | 47 | conns []connector | |
| 39 | - | want string | |
| 40 | - | mode uint16 | |
| 48 | + | want Want | |
| 49 | + | conn string | |
| 50 | + | mode string | |
| 41 | 51 | err bool | |
| 42 | 52 | }{ | |
| 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}, | |
| 47 | 62 | } { | |
| 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) | |
| 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 | 92 | func TestCountFlipEvents(t *testing.T) { | |
| 56 | 93 | ev := func(typ uint32, n int) []byte { | |
| 57 | 94 | 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 string | 46 | name string | ||
| 38 | conns []connector | 47 | conns []connector | ||
| 39 | - | want string | 48 | + | want Want |
| 40 | - | mode uint16 | 49 | + | conn string |
| 50 | + | mode string | |||
| 41 | err bool | 51 | 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 { | |||
| 32 | 32 | pending bool | |
| 33 | 33 | flipStart time.Time | |
| 34 | 34 | flips int | |
| 35 | + | monitor Monitor | |
| 35 | 36 | log zerowrap.Logger | |
| 36 | 37 | } | |
| 37 | 38 | ||
| 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) { | |
| 40 | 50 | crtcs, ids, err := resources(fd) | |
| 41 | 51 | if err != nil { | |
| 42 | 52 | return nil, err | |
| @@ -48,10 +58,19 @@ func Open(fd int, card string, log zerowrap.Logger) (*Output, error) { | |||
| 48 | 58 | log.Warn().Err(err).Uint32("connector", id).Msg("read connector") | |
| 49 | 59 | 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 | 71 | conns = append(conns, c) | |
| 53 | 72 | } | |
| 54 | - | c, mode, err := pickConnector(conns) | |
| 73 | + | c, mode, err := pickConnector(conns, want) | |
| 55 | 74 | if err != nil { | |
| 56 | 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 | 78 | if err != nil { | |
| 60 | 79 | 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 | 85 | if o.saved, err = getCrtc(fd, crtc); err != nil { | |
| 64 | 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 | 91 | 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 | 95 | 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 | 103 | func (o *Output) Width() int { return int(o.mode.HDisplay) } | |
| 77 | 104 | func (o *Output) Height() int { return int(o.mode.VDisplay) } | |
| 78 | 105 | ||
| @@ -32,11 +32,21 @@ type Output struct { | |||||
| 32 | pending bool | 32 | pending bool | ||
| 33 | flipStart time.Time | 33 | flipStart time.Time | ||
| 34 | flips int | 34 | flips int | ||
| 35 | + | monitor Monitor | |||
| 35 | log zerowrap.Logger | 36 | 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, err | 52 | 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 | continue | 59 | 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, err | 79 | 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, err | 91 | 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, nil | 95 | 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 { | |||
| 36 | 36 | if e != nil { | |
| 37 | 37 | return | |
| 38 | 38 | } | |
| 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)) | |
| 41 | 42 | if v >= 2 { | |
| 42 | 43 | r.SendScale(1) | |
| 43 | 44 | } | |
| 44 | 45 | if v >= 4 { | |
| 45 | - | r.SendName("HEADLESS-1") | |
| 46 | - | r.SendDescription("NeferTTY headless output") | |
| 46 | + | r.SendName(info.Name) | |
| 47 | + | r.SendDescription(info.Description) | |
| 47 | 48 | } | |
| 48 | 49 | if v >= 2 { | |
| 49 | 50 | 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 | return | 37 | 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 ( | |||
| 19 | 19 | type Options struct { | |
| 20 | 20 | RuntimeDir string | |
| 21 | 21 | OutputWidth, OutputHeight int | |
| 22 | + | Output OutputInfo | |
| 22 | 23 | Keymap string | |
| 23 | 24 | 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 | 49 | // Channels carries client notifications and commands. Events may be unbuffered. | |
| 27 | 50 | type Channels struct { | |
| 28 | 51 | Events chan<- ports.ClientEvent | |
| @@ -19,10 +19,33 @@ import ( | |||||
| 19 | type Options struct { | 19 | type Options struct { | ||
| 20 | RuntimeDir string | 20 | RuntimeDir string | ||
| 21 | OutputWidth, OutputHeight int | 21 | OutputWidth, OutputHeight int | ||
| 22 | + | Output OutputInfo | |||
| 22 | Keymap string | 23 | Keymap string | ||
| 23 | RepeatRate, RepeatDelay int | 24 | 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.ClientEvent | 51 | Events chan<- ports.ClientEvent | ||
M internal/adapters/wayland/xdgoutput.go +7 -4
| @@ -6,11 +6,14 @@ import ( | |||
| 6 | 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 | 14 | func registerXDGOutput(d *server.Display, o Options) error { | |
| 12 | 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 | 19 | func (xdgOutputManager) Destroy(*xdgoutput.ZxdgOutputManagerV1) {} | |
| @@ -22,8 +25,8 @@ func (m xdgOutputManager) GetXdgOutput(r *xdgoutput.ZxdgOutputManagerV1, id uint | |||
| 22 | 25 | x.SendLogicalPosition(0, 0) | |
| 23 | 26 | x.SendLogicalSize(m.width, m.height) | |
| 24 | 27 | 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) | |
| 27 | 30 | } | |
| 28 | 31 | if r.Version() < 3 || output == nil || output.Version() < 2 { | |
| 29 | 32 | 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) | |||
| 49 | 49 | } | |
| 50 | 50 | defer cancel() | |
| 51 | 51 | width, height := 1920, 1080 | |
| 52 | + | var outInfo wayland.OutputInfo | |
| 52 | 53 | var hw *drmBackend | |
| 53 | 54 | if opts.Backend == "drm" { | |
| 54 | 55 | var err error | |
| 55 | - | if hw, err = openDRM(ctx); err != nil { | |
| 56 | + | if hw, err = openDRM(ctx, opts.Config.Outputs); err != nil { | |
| 56 | 57 | return err | |
| 57 | 58 | } | |
| 58 | 59 | defer hw.close() | |
| 59 | 60 | width, height = hw.out.Width(), hw.out.Height() | |
| 61 | + | outInfo = hw.outputInfo() | |
| 60 | 62 | hw.inputActive, hw.outputActive = hw.seat.Subscribe(), hw.seat.Subscribe() | |
| 61 | 63 | } | |
| 62 | 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 | 84 | keymap := km.String() | |
| 83 | 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 | 87 | if err != nil { | |
| 86 | 88 | km.Close() | |
| 87 | 89 | 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, 1080 | 51 | width, height := 1920, 1080 | ||
| 52 | + | var outInfo wayland.OutputInfo | |||
| 52 | var hw *drmBackend | 53 | var hw *drmBackend | ||
| 53 | if opts.Backend == "drm" { | 54 | if opts.Backend == "drm" { | ||
| 54 | var err error | 55 | var err error | ||
| 55 | - | if hw, err = openDRM(ctx); err != nil { | 56 | + | if hw, err = openDRM(ctx, opts.Config.Outputs); err != nil { |
| 56 | return err | 57 | 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 err | 89 | return err | ||
M internal/app/drm.go +37 -2
| @@ -7,10 +7,14 @@ import ( | |||
| 7 | 7 | "path/filepath" | |
| 8 | 8 | "runtime/debug" | |
| 9 | 9 | "sort" | |
| 10 | + | "strings" | |
| 10 | 11 | ||
| 12 | + | "github.com/bnema/nefertty/internal/adapters/config" | |
| 11 | 13 | "github.com/bnema/nefertty/internal/adapters/drm" | |
| 12 | 14 | "github.com/bnema/nefertty/internal/adapters/seat" | |
| 15 | + | "github.com/bnema/nefertty/internal/adapters/wayland" | |
| 13 | 16 | "github.com/bnema/nefertty/internal/logging" | |
| 17 | + | "github.com/bnema/nefertty/internal/ports" | |
| 14 | 18 | ) | |
| 15 | 19 | ||
| 16 | 20 | var errTimeout = errors.New("timeout") | |
| @@ -34,13 +38,14 @@ func safe(name string, fn func() error) (err error) { | |||
| 34 | 38 | } | |
| 35 | 39 | ||
| 36 | 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 | 42 | log := logging.For(ctx, "drm") | |
| 39 | 43 | s, err := seat.Open(ctx, logging.For(ctx, "seat")) | |
| 40 | 44 | if err != nil { | |
| 41 | 45 | log.Error().Err(err).Msg("open seat") | |
| 42 | 46 | return nil, fmt.Errorf("open seat: %w", err) | |
| 43 | 47 | } | |
| 48 | + | want := wantFromConfig(outputs) | |
| 44 | 49 | cards, _ := filepath.Glob("/dev/dri/card[0-9]*") | |
| 45 | 50 | sort.Strings(cards) | |
| 46 | 51 | var errs []error | |
| @@ -50,7 +55,7 @@ func openDRM(ctx context.Context) (*drmBackend, error) { | |||
| 50 | 55 | errs = append(errs, err) | |
| 51 | 56 | continue | |
| 52 | 57 | } | |
| 53 | - | out, err := drm.Open(fd, card, log) | |
| 58 | + | out, err := drm.Open(fd, card, want, log) | |
| 54 | 59 | if err != nil { | |
| 55 | 60 | log.Warn().Err(err).Str("card", card).Msg("card unusable") | |
| 56 | 61 | errs = append(errs, err) | |
| @@ -65,6 +70,36 @@ func openDRM(ctx context.Context) (*drmBackend, error) { | |||
| 65 | 70 | 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 | 103 | // close restores the CRTC, then releases the card and the seat so the TTY comes back. | |
| 69 | 104 | func (b *drmBackend) close() { | |
| 70 | 105 | 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 []error | 51 | 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 | continue | 56 | 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, err | 70 | 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 @@ | |||
| 1 | 1 | 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 | 14 | // Config is the parsed compositor configuration. | |
| 4 | 15 | type Config struct { | |
| 5 | 16 | Keyboard struct { | |
| @@ -15,8 +26,10 @@ type Config struct { | |||
| 15 | 26 | DefaultColumnWidth string `toml:"default_column_width"` | |
| 16 | 27 | Presets []string | |
| 17 | 28 | } `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 { | |
| 20 | 33 | DirectScanout bool `toml:"direct_scanout"` | |
| 21 | 34 | } `toml:"render"` | |
| 22 | 35 | Log struct { | |
| @@ -1,5 +1,16 @@ | |||||
| 1 | package ports | 1 | 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 []string | 27 | 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 { | ||