From 69a174af45f60c811bace9662e9bfbe2e988c7dc Mon Sep 17 00:00:00 2001 From: Jonas Falck Date: Thu, 3 Dec 2020 16:58:42 +0100 Subject: [PATCH 1/3] Add support for rate Also fixed a few other things: - bugs in active mode detection with my display (144hz and 60hz) - Turn off changed displays in a smarter way. Im using usb-c daisychain and had trouble with the current approach. it disabled my screen as soon as the screen went into hibernate because xrandr stopped detecting the display. - some autoformatting of comments and code according to efficient go guidelines - if xrand (ApplyRule) fails, try 3 times with a little more sleep each time. This also solved issues with my daisychained displays. Fixes #29 --- cmd_apply.go | 35 ++++++++++++++++++++------- cmd_watch.go | 65 +++++++-------------------------------------------- config.go | 6 ++--- main.go | 6 +++-- randr.go | 58 +++++++++++++++++++++++++++++---------------- randr_test.go | 38 ++++++++++++++++++++++++------ rule.go | 34 +++++++++++++++++++++++++++ rule_test.go | 20 +++++++++++++++- 8 files changed, 164 insertions(+), 98 deletions(-) diff --git a/cmd_apply.go b/cmd_apply.go index 952842b..3b0e279 100644 --- a/cmd_apply.go +++ b/cmd_apply.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "strings" + "time" ) type CmdApply struct{} @@ -37,23 +38,39 @@ func ApplyRule(outputs Outputs, rule Rule) error { return fmt.Errorf("no output configuration for rule %v", rule.Name) } - after := append(globalOpts.cfg.ExecuteAfter, rule.ExecuteAfter...) - if len(after) > 0 { - for _, cmd := range after { - cmds = append(cmds, exec.Command("sh", "-c", cmd)) - } - } - if err != nil { return err } + + foundError := false for _, cmd := range cmds { - err = RunCommand(cmd) - if err != nil { + for i := 0; i < 4; i++ { + err = RunCommand(cmd) + if err == nil { + break + } fmt.Fprintf(os.Stderr, "executing command for rule %v failed: %v\n", rule.Name, err) + + dur := time.Millisecond * 500 * time.Duration(i) + fmt.Fprintf(os.Stderr, "trying again in %s", dur) + time.Sleep(dur) } + if err != nil { + fmt.Fprint(os.Stderr, "failed after 3 retries") + foundError = true + } + } + if foundError { + return nil // Dont run ExecuteAfter if xrandr commands failed } + after := append(globalOpts.cfg.ExecuteAfter, rule.ExecuteAfter...) + for _, cmd := range after { + err = RunCommand(exec.Command("sh", "-c", cmd)) + if err != nil { + fmt.Fprintf(os.Stderr, "executing command for rule %v failed: %v\n", rule.Name, err) + } + } return nil } diff --git a/cmd_watch.go b/cmd_watch.go index e685ce8..abb9962 100644 --- a/cmd_watch.go +++ b/cmd_watch.go @@ -100,7 +100,6 @@ func (cmd CmdWatch) Execute(args []string) (err error) { var eventReceived bool var lastRule Rule - var lastOutputs Outputs for { if !disablePoll { var outputs Outputs @@ -117,53 +116,6 @@ func (cmd CmdWatch) Execute(args []string) (err error) { return fmt.Errorf("detecting outputs: %w", err) } - // disable outputs which have a changed display - var off Outputs - for _, o := range outputs { - for _, last := range lastOutputs { - if o.Name != last.Name { - continue - } - - if last.Active() && !o.Active() { - V(" output %v: monitor not active any more, disabling", o.Name) - off = append(off, o) - continue - } - - if o.Active() && o.MonitorID != last.MonitorID { - V(" output %v: monitor has changed, disabling", o.Name) - off = append(off, o) - continue - } - } - } - - if len(off) > 0 { - V("disable %d outputs", len(off)) - - cmd, err := DisableOutputs(off) - if err != nil { - return fmt.Errorf("disabling outputs: %w", err) - } - - // forget the last rule set, something has changed for sure - lastRule = Rule{} - - err = RunCommand(cmd) - if err != nil { - fmt.Fprintf(os.Stderr, "error disabling: %v\n", err) - } - - // refresh outputs again - outputs, err = GetOutputs() - if err != nil { - return fmt.Errorf("detecting outputs after disabling: %w", err) - } - - V("new outputs after disable: %v", outputs) - } - rule, err := MatchRules(globalOpts.cfg.Rules, outputs) if err != nil { return fmt.Errorf("matching rules: %w", err) @@ -173,6 +125,15 @@ func (cmd CmdWatch) Execute(args []string) (err error) { V("outputs: %v", outputs) V("new rule found: %v", rule.Name) + // Disable old rules outputs if they are not in current active rules outputs. + diff := rule.OutputsDiff(lastRule) + if len(diff) > 0 { + err = RunCommand(DisableOutputs(diff)) + if err != nil { + fmt.Fprintf(os.Stderr, "error disabling: %v", err) + } + } + err = ApplyRule(outputs, rule) if err != nil { return fmt.Errorf("applying rules: %w", err) @@ -185,15 +146,7 @@ func (cmd CmdWatch) Execute(args []string) (err error) { disablePoll = true backoffCh = time.After(time.Duration(globalOpts.Pause) * time.Second) } - - // refresh outputs for next cycle - outputs, err = GetOutputs() - if err != nil { - return fmt.Errorf("refreshing outputs: %w", err) - } } - - lastOutputs = outputs } select { diff --git a/config.go b/config.go index e4560c5..3b7d874 100644 --- a/config.go +++ b/config.go @@ -20,7 +20,7 @@ type Config struct { OnFailure []string `yaml:"on_failure"` } -// xdgConfigDir returns the config directory according to the xdg standard, see +// xdgConfigDir returns the config directory according to the xdg standard, see. // http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html. func xdgConfigDir() string { if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { @@ -37,7 +37,8 @@ func openConfigFile(name string) (io.ReadCloser, error) { os.Getenv("GROBI_CONFIG"), filepath.Join(xdgConfigDir(), "grobi.conf"), filepath.Join(os.Getenv("HOME"), ".grobi.conf"), - "/etc/xdg/grobi.conf"} { + "/etc/xdg/grobi.conf", + } { if filename != "" { if f, err := os.Open(filename); err == nil { V("reading config from %v\n", filename) @@ -81,7 +82,6 @@ func readConfig(name string) (Config, error) { // Valid returns an error if the config is invalid, ie a pattern is malformed. func (cfg Config) Valid() error { - for _, rule := range cfg.Rules { for _, list := range [][]string{rule.OutputsPresent, rule.OutputsAbsent, rule.OutputsConnected, rule.OutputsDisconnected} { for _, pat := range list { diff --git a/main.go b/main.go index 75aec52..568d711 100644 --- a/main.go +++ b/main.go @@ -56,8 +56,10 @@ func RunCommand(cmd *exec.Cmd) error { return cmd.Run() } -var globalOpts = GlobalOptions{} -var parser = flags.NewParser(&globalOpts, flags.Default) +var ( + globalOpts = GlobalOptions{} + parser = flags.NewParser(&globalOpts, flags.Default) +) func V(s string, data ...interface{}) { if globalOpts.Verbose && globalOpts.log == nil { diff --git a/randr.go b/randr.go index 2ad9cab..b723c41 100644 --- a/randr.go +++ b/randr.go @@ -190,7 +190,7 @@ func (m Modes) String() string { // GenerateMonitorID derives the monitor id from the edid func GenerateMonitorID(s string) (string, error) { - var errEdidCorrupted = errors.New("corrupt EDID: " + s) + errEdidCorrupted := errors.New("corrupt EDID: " + s) if len(s) < 32 || s[:16] != "00ffffffffffff00" { return "", errEdidCorrupted } @@ -314,22 +314,32 @@ func parseModeLine(line string) (mode Mode, err error) { } mode.Name = ws.Text() - if !ws.Scan() { - return Mode{}, fmt.Errorf("line too short, no refresh rate found: %s", line) - } - rate := ws.Text() + i := 0 + for ws.Scan() { + i++ + rate := ws.Text() + if len(rate) == 0 { + break + } + if rate[len(rate)-1] == '+' { + mode.Default = true + } - if rate[len(rate)-1] == '+' { - mode.Default = true - } + if len(rate) > 1 && rate[len(rate)-2] == '*' { + mode.Active = true + } - if rate[len(rate)-2] == '*' { - mode.Active = true - } + if rate[len(rate)-1] == '*' { + mode.Active = true + } - // handle single-word "+", which happens when a mode is default but not active - if ws.Scan() && ws.Text() == "+" { - mode.Default = true + // handle single-word "+", which happens when a mode is default but not active + if ws.Text() == "+" { + mode.Default = true + } + } + if i == 0 { + return Mode{}, fmt.Errorf("line too short, no refresh rate found: %s", line) } return mode, nil @@ -337,7 +347,7 @@ func parseModeLine(line string) (mode Mode, err error) { var errNotEdidLine = errors.New("not an edid line") -// parseEdidLine returns the partial EDID on that line +// parseEdidLine returns the partial EDID on that line. func parseEdidLine(line string) (edid string, err error) { if !strings.HasPrefix(line, " ") { return "", errNotEdidLine @@ -509,14 +519,18 @@ func BuildCommandOutputRow(rule Rule, current Outputs) ([]*exec.Cmd, error) { enableOutputArgs := [][]string{} active := make(map[string]struct{}) - var lastOutput = "" + lastOutput := "" for i, output := range outputs { - data := strings.SplitN(output, "@", 2) + data := strings.SplitN(output, "@", 3) name := data[0] mode := "" + rate := "" if len(data) > 1 { mode = data[1] } + if len(data) > 2 { + rate = data[2] + } active[name] = struct{}{} @@ -528,6 +542,10 @@ func BuildCommandOutputRow(rule Rule, current Outputs) ([]*exec.Cmd, error) { args = append(args, "--mode", mode) } + if rate != "" { + args = append(args, "--rate", rate) + } + if i > 0 { if row { args = append(args, "--right-of", lastOutput) @@ -618,9 +636,9 @@ func BuildCommandOutputRow(rule Rule, current Outputs) ([]*exec.Cmd, error) { } // DisableOutputs returns a call to `xrandr` to switch off the specified outputs. -func DisableOutputs(off Outputs) (*exec.Cmd, error) { +func DisableOutputs(off Outputs) *exec.Cmd { if len(off) == 0 { - return nil, nil + return nil } command := "xrandr" @@ -634,5 +652,5 @@ func DisableOutputs(off Outputs) (*exec.Cmd, error) { V("disable outputs: %v\n", outputs) - return exec.Command(command, args...), nil + return exec.Command(command, args...) } diff --git a/randr_test.go b/randr_test.go index 5a7fb51..aee9866 100644 --- a/randr_test.go +++ b/randr_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "fmt" "reflect" "testing" ) @@ -414,7 +415,8 @@ VIRTUAL1 disconnected (normal left inverted right x axis y axis)`, {Name: "DP1"}, {Name: "DP2"}, {Name: "HDMI1"}, - {Name: "HDMI2", + { + Name: "HDMI2", Modes: []Mode{ {Name: "1920x1080", Default: true, Active: true}, {Name: "1680x1050"}, @@ -542,20 +544,23 @@ func TestParseOutputLine(t *testing.T) { } var TestModeLines = []struct { - line string - mode Mode + line string + mode Mode + expectedError error }{ { " 1152x864 75.00", Mode{ Name: "1152x864", }, + nil, }, { " 1024x768 75.08 70.07 60.00", Mode{ Name: "1024x768", }, + nil, }, { " 1600x1200 60.00*+", @@ -564,6 +569,7 @@ var TestModeLines = []struct { Active: true, Default: true, }, + nil, }, { " 1366x768 60.10 +", @@ -571,12 +577,28 @@ var TestModeLines = []struct { Name: "1366x768", Default: true, }, + nil, }, { " 832x624 74.55", Mode{ Name: "832x624", }, + nil, + }, + { + " 2560x1440 143.86 + 119.88 59.95*", + Mode{ + Name: "2560x1440", + Active: true, + Default: true, + }, + nil, + }, + { + " 2560x1440", + Mode{}, + fmt.Errorf("line too short, no refresh rate found: 2560x1440"), }, } @@ -584,19 +606,21 @@ func TestParseModeLine(t *testing.T) { for i, test := range TestModeLines { mode, err := parseModeLine(test.line) if err != nil { - t.Errorf("test %d returned error: %v", i, err) - continue + if err.Error() != test.expectedError.Error() { + t.Errorf("test %d returned unexpected error: %v", i, err) + continue + } } if !reflect.DeepEqual(mode, test.mode) { - t.Errorf("test %d failed: expected Mode not found", i) + t.Errorf("test %d failed: expected %#v but got %#v", i, test.mode, mode) continue } } } func TestGenerateMonitorId(t *testing.T) { - var tests = []struct { + tests := []struct { edid string failure bool monitorID string diff --git a/rule.go b/rule.go index dfad240..c0aa884 100644 --- a/rule.go +++ b/rule.go @@ -1,5 +1,7 @@ package main +import "strings" + // Rule is a rule to configure outputs. type Rule struct { Name string @@ -23,6 +25,38 @@ type Rule struct { ExecuteAfter []string `yaml:"execute_after"` } +func (r Rule) OutputsDiff(old Rule) Outputs { + outputs := []string{r.ConfigureSingle} + outputs = append(outputs, r.ConfigureRow...) + outputs = append(outputs, r.ConfigureColumn...) + for k, v := range outputs { + outputs[k] = strings.SplitN(v, "@", 2)[0] + } + + outputsOld := []string{old.ConfigureSingle} + outputsOld = append(outputsOld, old.ConfigureRow...) + outputsOld = append(outputsOld, old.ConfigureColumn...) + for k, v := range outputsOld { + outputsOld[k] = strings.SplitN(v, "@", 2)[0] + } + + var diff Outputs + for _, old := range outputsOld { + foundInOutputs := false + for _, v := range outputs { + if old == v { + foundInOutputs = true + } + } + + if !foundInOutputs { + diff = append(diff, Output{Name: old}) + } + } + + return diff +} + // Match returns true iff the rule matches for the given list of outputs. func (r Rule) Match(outputs Outputs) bool { for _, name := range r.OutputsAbsent { diff --git a/rule_test.go b/rule_test.go index beb2fca..78b591d 100644 --- a/rule_test.go +++ b/rule_test.go @@ -1,6 +1,8 @@ package main -import "testing" +import ( + "testing" +) var testRules = []struct { rule Rule @@ -114,3 +116,19 @@ func TestRuleMatch(t *testing.T) { } } } + +func TestOutputsDiff(t *testing.T) { + old := Rule{ + ConfigureRow: []string{"one"}, + ConfigureColumn: []string{"two"}, + } + new := Rule{ + ConfigureRow: []string{"one"}, + ConfigureColumn: []string{"three"}, + } + + diff := new.OutputsDiff(old) + if diff[0].Name != "two" { + t.Errorf("expected diff to be two got: %s", diff[0].Name) + } +} From 8172a9fbaccb94aa9e5fac055b55a260c3a3a8b9 Mon Sep 17 00:00:00 2001 From: Jonas Falck Date: Fri, 4 Dec 2020 10:05:45 +0100 Subject: [PATCH 2/3] Fix retry logic since exec.Cmd cannot be reused --- cmd_apply.go | 6 +++--- randr.go | 25 +++++++++++++++++++------ rule.go | 10 ++++++++-- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/cmd_apply.go b/cmd_apply.go index 3b0e279..b47efd1 100644 --- a/cmd_apply.go +++ b/cmd_apply.go @@ -26,14 +26,14 @@ func (cmd CmdApply) Usage() string { } func ApplyRule(outputs Outputs, rule Rule) error { - var cmds []*exec.Cmd + var cmds commands var err error switch { case rule.ConfigureSingle != "" || len(rule.ConfigureRow) > 0 || len(rule.ConfigureColumn) > 0: cmds, err = BuildCommandOutputRow(rule, outputs) case rule.ConfigureCommand != "": - cmds = []*exec.Cmd{exec.Command("sh", "-c", rule.ConfigureCommand)} + cmds = commands{command{"sh", "-c", rule.ConfigureCommand}} default: return fmt.Errorf("no output configuration for rule %v", rule.Name) } @@ -45,7 +45,7 @@ func ApplyRule(outputs Outputs, rule Rule) error { foundError := false for _, cmd := range cmds { for i := 0; i < 4; i++ { - err = RunCommand(cmd) + err = RunCommand(cmd.Cmd()) if err == nil { break } diff --git a/randr.go b/randr.go index b723c41..b910fef 100644 --- a/randr.go +++ b/randr.go @@ -492,11 +492,25 @@ func DetectOutputs() (Outputs, error) { return RandrParse(bytes.NewReader(output)) } +type ( + commands []command + command []string +) + +func (c command) Cmd() *exec.Cmd { + return exec.Command(c[0], c[1:]...) // #nosec +} + +func newCommand(cmd string, args ...string) command { + c := command{cmd} + return append(c, args...) +} + // BuildCommandOutputRow return a sequence of calls to `xrandr` to configure // all named outputs in a row, left to right, given the currently active // Outputs and a list of output names, optionally followed by "@" and the // desired mode, e.g. LVDS1@1377x768. -func BuildCommandOutputRow(rule Rule, current Outputs) ([]*exec.Cmd, error) { +func BuildCommandOutputRow(rule Rule, current Outputs) (commands, error) { var outputs []string var row bool @@ -602,18 +616,17 @@ func BuildCommandOutputRow(rule Rule, current Outputs) ([]*exec.Cmd, error) { for _, enableArgs := range enableOutputArgs { args = append(args, enableArgs...) } - cmd := exec.Command(command, args...) - return []*exec.Cmd{cmd}, nil + return commands{newCommand(command, args...)}, nil } V("splitting the configuration into several calls to xrandr\n") // otherwise return several calls to xrandr - cmds := []*exec.Cmd{} + cmds := commands{} // disable an output if len(disableOutputArgs) > 0 { - cmds = append(cmds, exec.Command(command, disableOutputArgs[0]...)) + cmds = append(cmds, newCommand(command, disableOutputArgs[0]...)) disableOutputArgs = disableOutputArgs[1:] } @@ -629,7 +642,7 @@ func BuildCommandOutputRow(rule Rule, current Outputs) ([]*exec.Cmd, error) { enableOutputArgs = enableOutputArgs[1:] } - cmds = append(cmds, exec.Command(command, args...)) + cmds = append(cmds, newCommand(command, args...)) } return cmds, nil diff --git a/rule.go b/rule.go index c0aa884..4d898f4 100644 --- a/rule.go +++ b/rule.go @@ -26,14 +26,20 @@ type Rule struct { } func (r Rule) OutputsDiff(old Rule) Outputs { - outputs := []string{r.ConfigureSingle} + outputs := []string{} + if r.ConfigureSingle != "" { + outputs = append(outputs, r.ConfigureSingle) + } outputs = append(outputs, r.ConfigureRow...) outputs = append(outputs, r.ConfigureColumn...) for k, v := range outputs { outputs[k] = strings.SplitN(v, "@", 2)[0] } - outputsOld := []string{old.ConfigureSingle} + outputsOld := []string{} + if old.ConfigureSingle != "" { + outputsOld = append(outputsOld, old.ConfigureSingle) + } outputsOld = append(outputsOld, old.ConfigureRow...) outputsOld = append(outputsOld, old.ConfigureColumn...) for k, v := range outputsOld { From ed1cd44002f14d2a98d2447c711f6bf46a48e5c1 Mon Sep 17 00:00:00 2001 From: Jonas Falck Date: Wed, 9 Apr 2025 15:20:54 +0200 Subject: [PATCH 3/3] Support for wildcard configuration without knowing the numbers of the displays Happens for example in daisy chaining usb-c to DP it always changes the number when reconnecting to the display --- cmd_apply.go | 16 +++++++++++++++- randr.go | 15 ++++++++++++++- rule.go | 46 +++++++++++++++++++++++++++++++++++++++++++++- rule_test.go | 23 +++++++++++++++++++++++ 4 files changed, 97 insertions(+), 3 deletions(-) diff --git a/cmd_apply.go b/cmd_apply.go index b47efd1..09303fe 100644 --- a/cmd_apply.go +++ b/cmd_apply.go @@ -1,8 +1,10 @@ package main import ( + "bytes" "errors" "fmt" + "html/template" "os" "os/exec" "strings" @@ -64,9 +66,21 @@ func ApplyRule(outputs Outputs, rule Rule) error { return nil // Dont run ExecuteAfter if xrandr commands failed } + newOutputs, err := DetectOutputs() + if err != nil { + return err + } + after := append(globalOpts.cfg.ExecuteAfter, rule.ExecuteAfter...) for _, cmd := range after { - err = RunCommand(exec.Command("sh", "-c", cmd)) + buf := &bytes.Buffer{} + tmpl, err := template.New("titleTest").Parse(cmd) + if err != nil { + fmt.Fprintf(os.Stderr, "executing template for rule %v failed: %v\n", rule.Name, err) + continue + } + tmpl.Execute(buf, newOutputs) + err = RunCommand(exec.Command("sh", "-c", buf.String())) if err != nil { fmt.Fprintf(os.Stderr, "executing command for rule %v failed: %v\n", rule.Name, err) } diff --git a/randr.go b/randr.go index b910fef..9496542 100644 --- a/randr.go +++ b/randr.go @@ -85,6 +85,19 @@ func (o Output) Active() bool { // Outputs is a list of outputs. type Outputs []Output +func (os Outputs) NthConnected(i int) (string, error) { + outputs := []string{} + for _, o := range os { + if o.Connected { + outputs = append(outputs, o.Name) + } + } + if len(outputs) < i { + return "", fmt.Errorf("output not found") + } + return outputs[i], nil +} + // Present returns true iff the list of outputs contains the named output. func (os Outputs) Present(name string) bool { for _, o := range os { @@ -536,7 +549,7 @@ func BuildCommandOutputRow(rule Rule, current Outputs) (commands, error) { lastOutput := "" for i, output := range outputs { data := strings.SplitN(output, "@", 3) - name := data[0] + name := rule.GetOutputNameFromWildcard(data[0], current) mode := "" rate := "" if len(data) > 1 { diff --git a/rule.go b/rule.go index 4d898f4..71f3e24 100644 --- a/rule.go +++ b/rule.go @@ -1,6 +1,12 @@ package main -import "strings" +import ( + "fmt" + "os" + "path" + "strconv" + "strings" +) // Rule is a rule to configure outputs. type Rule struct { @@ -23,8 +29,46 @@ type Rule struct { Atomic bool `yaml:"atomic"` ExecuteAfter []string `yaml:"execute_after"` + + matchedOutputs []string } +func (r *Rule) GetOutputNameFromWildcard(name string, current Outputs) string { + if !strings.HasPrefix(name, "$") { + return name + } + + r.matchedOutputs = []string{} +outer: + for _, outputConnected := range r.OutputsConnected { + inner: + for _, c := range current { + if !c.Connected { + continue + } + for _, already := range r.matchedOutputs { + if already == c.Name { + continue inner + } + } + m, err := path.Match(outputConnected, c.Name) + if err != nil { + fmt.Fprint(os.Stderr, err.Error()) + } + if m { + r.matchedOutputs = append(r.matchedOutputs, c.Name) + continue outer + } + } + } + strIndx := strings.TrimLeft(name, "$") + index, err := strconv.Atoi(strIndx) + if err != nil { + fmt.Fprint(os.Stderr, err.Error()) + return "" + } + return r.matchedOutputs[index] +} func (r Rule) OutputsDiff(old Rule) Outputs { outputs := []string{} if r.ConfigureSingle != "" { diff --git a/rule_test.go b/rule_test.go index 78b591d..b36a30f 100644 --- a/rule_test.go +++ b/rule_test.go @@ -1,6 +1,7 @@ package main import ( + "strings" "testing" ) @@ -132,3 +133,25 @@ func TestOutputsDiff(t *testing.T) { t.Errorf("expected diff to be two got: %s", diff[0].Name) } } + +func TestBuildCommandOutputRow(t *testing.T) { + rule := Rule{ + OutputsConnected: []string{"eDP", "DisplayPort-*", "DisplayPort-*"}, + ConfigureRow: []string{"eDP", "$1", "$2"}, + Atomic: true, + } + + outputs := Outputs{ + Output{Name: "eDP", Connected: true}, + Output{Name: "DisplayPort-3", Connected: true}, + Output{Name: "DisplayPort-5", Connected: true}, + } + + commands, err := BuildCommandOutputRow(rule, outputs) + if err != nil { + t.Error(err) + } + if strings.Join(commands[0], " ") != "xrandr --output eDP --auto --output DisplayPort-3 --auto --right-of eDP --output DisplayPort-5 --auto --right-of DisplayPort-3" { + t.Error("unexpected command: ", commands) + } +}