5 # Copyright (c) 2015, Phillip Berndt
7 # Autorandr rewrite in Python
9 # This script aims to be fully compatible with the original autorandr.
11 # This program is free software: you can redistribute it and/or modify
12 # it under the terms of the GNU General Public License as published by
13 # the Free Software Foundation, either version 3 of the License, or
14 # (at your option) any later version.
16 # This program is distributed in the hope that it will be useful,
17 # but WITHOUT ANY WARRANTY; without even the implied warranty of
18 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 # GNU General Public License for more details.
21 # You should have received a copy of the GNU General Public License
22 # along with this program. If not, see <http://www.gnu.org/licenses/>.
25 from __future__ import print_function
39 from collections import OrderedDict
40 from distutils.version import LooseVersion as Version
41 from functools import reduce
42 from itertools import chain
50 # (name, description, callback)
51 ("common", "Clone all connected outputs at the largest common resolution", None),
52 ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
53 ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
57 Usage: autorandr [options]
59 -h, --help get this small help
60 -c, --change reload current setup
61 -s, --save <profile> save your current setup to profile <profile>
62 -r, --remove <profile> remove profile <profile>
63 -l, --load <profile> load profile <profile>
64 -d, --default <profile> make profile <profile> the default profile
65 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
66 to skip both in detecting changes and applying a profile
67 --force force (re)loading of a profile
68 --fingerprint fingerprint your current hardware setup
69 --config dump your current xrandr setup
70 --dry-run don't change anything, only print the xrandr commands
71 --debug enable verbose output
73 To prevent a profile from being loaded, place a script call "block" in its
74 directory. The script is evaluated before the screen setup is inspected, and
75 in case of it returning a value of 0 the profile is skipped. This can be used
76 to query the status of a docking station you are about to leave.
78 If no suitable profile can be identified, the current configuration is kept.
79 To change this behaviour and switch to a fallback configuration, specify
82 Another script called "postswitch" can be placed in the directory
83 ~/.config/autorandr (or ~/.autorandr if you have an old installation) as well
84 as in any profile directories: The scripts are executed after a mode switch
85 has taken place and can notify window managers.
87 The following virtual configurations are available:
90 class AutorandrException(Exception):
91 def __init__(self, message, original_exception=None, report_bug=False):
92 self.message = message
93 self.report_bug = report_bug
94 if original_exception:
95 self.original_exception = original_exception
96 trace = sys.exc_info()[2]
99 self.line = trace.tb_lineno
103 self.line = inspect.currentframe().f_back.f_lineno
106 self.original_exception = None
109 retval = [ self.message ]
111 retval.append(" (line %d)" % self.line)
112 if self.original_exception:
113 retval.append(":\n ")
114 retval.append(str(self.original_exception).replace("\n", "\n "))
116 retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream:"
117 "\nhttps://github.com/phillipberndt/autorandr/issues"
118 "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
119 return "".join(retval)
121 class XrandrOutput(object):
122 "Represents an XRandR output"
124 # This regular expression is used to parse an output in `xrandr --verbose'
125 XRANDR_OUTPUT_REGEXP = """(?x)
126 ^(?P<output>[^ ]+)\s+ # Line starts with output name
127 (?: # Differentiate disconnected and connected in first line
129 unknown\ connection |
130 (?P<connected>connected)
133 (?P<primary>primary\ )? # Might be primary screen
135 (?P<width>[0-9]+)x(?P<height>[0-9]+) # Resolution (might be overridden below!)
136 \+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+ # Position
137 (?:\(0x[0-9a-fA-F]+\)\s+)? # XID
138 (?P<rotate>(?:normal|left|right|inverted))\s+ # Rotation
139 (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)? # Reflection
140 )? # .. but everything of the above only if the screen is in use.
141 (?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
142 (?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Panning information
143 (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Tracking information
144 (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))? # Border information
145 (?:\s*(?: # Properties of the output
146 Gamma: (?P<gamma>(?:inf|[0-9\.: e])+) | # Gamma value
147 Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) | # Transformation matrix
148 EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) | # EDID of the output
149 (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)* # Other properties
153 (?P<mode_name>\S+).+?\*current.*\s+ # Interesting (current) resolution: Extract rate
154 h:\s+width\s+(?P<mode_width>[0-9]+).+\s+
155 v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
156 \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s* # Other resolutions
160 XRANDR_OUTPUT_MODES_REGEXP = """(?x)
161 (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
162 h:\s+width\s+(?P<width>[0-9]+).+\s+
163 v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
166 XRANDR_13_DEFAULTS = {
167 "transform": "1,0,0,0,1,0,0,0,1",
171 XRANDR_12_DEFAULTS = {
174 "gamma": "1.0:1.0:1.0",
177 XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
179 EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
182 return "<%s%s %s>" % (self.output, self.short_edid, " ".join(self.option_vector))
185 def short_edid(self):
186 return ("%s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else ""
189 def options_with_defaults(self):
190 "Return the options dictionary, augmented with the default values that weren't set"
191 if "off" in self.options:
194 if xrandr_version() >= Version("1.3"):
195 options.update(self.XRANDR_13_DEFAULTS)
196 if xrandr_version() >= Version("1.2"):
197 options.update(self.XRANDR_12_DEFAULTS)
198 options.update(self.options)
199 return { a: b for a, b in options.items() if a not in self.ignored_options }
202 def filtered_options(self):
203 "Return a dictionary of options without ignored options"
204 return { a: b for a, b in self.options.items() if a not in self.ignored_options }
207 def option_vector(self):
208 "Return the command line parameters for XRandR for this instance"
209 return sum([["--%s" % option[0], option[1]] if option[1] else ["--%s" % option[0]] for option in chain((("output", self.output),), sorted(self.options_with_defaults.items()))], [])
212 def option_string(self):
213 "Return the command line parameters in the configuration file format"
214 return "\n".join([ " ".join(option) if option[1] else option[0] for option in chain((("output", self.output),), sorted(self.filtered_options.items()))])
218 "Return a key to sort the outputs for xrandr invocation"
221 if "off" in self.options:
223 if "pos" in self.options:
224 x, y = map(float, self.options["pos"].split("x"))
229 def __init__(self, output, edid, options):
230 "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
233 self.options = options
234 self.ignored_options = []
235 self.remove_default_option_values()
237 def set_ignored_options(self, options):
238 "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
239 self.ignored_options = list(options)
241 def remove_default_option_values(self):
242 "Remove values from the options dictionary that are superflous"
243 if "off" in self.options and len(self.options.keys()) > 1:
244 self.options = { "off": None }
246 for option, default_value in self.XRANDR_DEFAULTS.items():
247 if option in self.options and self.options[option] == default_value:
248 del self.options[option]
251 def from_xrandr_output(cls, xrandr_output):
252 """Instanciate an XrandrOutput from the output of `xrandr --verbose'
254 This method also returns a list of modes supported by the output.
257 xrandr_output = xrandr_output.replace("\r\n", "\n")
258 match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
260 raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.", report_bug = True)
262 debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
263 raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug, report_bug = True)
264 remainder = xrandr_output[len(match_object.group(0)):]
266 raise AutorandrException(("Parsing XRandR output failed, %d bytes left unmatched after regular expression, "
267 "starting at byte %d with ..'%s'.") % (len(remainder), len(match_object.group(0)), remainder[:10]), report_bug=True)
269 match = match_object.groupdict()
273 modes = [ x.groupdict() for x in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]) if x.group("name") ]
275 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
278 if not match["connected"]:
281 edid = "".join(match["edid"].strip().split()) if match["edid"] else "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
283 if not match["width"]:
284 options["off"] = None
286 if match["mode_name"]:
287 options["mode"] = match["mode_name"]
288 elif match["mode_width"]:
289 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
291 if match["rotate"] not in ("left", "right"):
292 options["mode"] = "%sx%s" % (match["width"], match["height"])
294 options["mode"] = "%sx%s" % (match["height"], match["width"])
295 options["rotate"] = match["rotate"]
297 options["primary"] = None
298 if match["reflect"] == "X":
299 options["reflect"] = "x"
300 elif match["reflect"] == "Y":
301 options["reflect"] = "y"
302 elif match["reflect"] == "X and Y":
303 options["reflect"] = "xy"
304 options["pos"] = "%sx%s" % (match["x"], match["y"])
306 panning = [ match["panning"] ]
307 if match["tracking"]:
308 panning += [ "/", match["tracking"] ]
310 panning += [ "/", match["border"] ]
311 options["panning"] = "".join(panning)
312 if match["transform"]:
313 transformation = ",".join(match["transform"].strip().split())
314 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
315 options["transform"] = transformation
316 if not match["mode_name"]:
317 # TODO We'd need to apply the reverse transformation here. Let's see if someone complains, I doubt that this
318 # special case is actually required.
319 print("Warning: Output %s has a transformation applied. Could not determine correct mode! Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
321 gamma = match["gamma"].strip()
322 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
323 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
324 # so we approximate by 1e-10.
325 gamma = ":".join([ str(max(1e-10, round(1./float(x), 3))) for x in gamma.split(":") ])
326 options["gamma"] = gamma
328 options["rate"] = match["rate"]
330 return XrandrOutput(match["output"], edid, options), modes
333 def from_config_file(cls, edid_map, configuration):
334 "Instanciate an XrandrOutput from the contents of a configuration file"
336 for line in configuration.split("\n"):
338 line = line.split(None, 1)
339 options[line[0]] = line[1] if len(line) > 1 else None
343 if options["output"] in edid_map:
344 edid = edid_map[options["output"]]
346 # This fuzzy matching is for legacy autorandr that used sysfs output names
347 fuzzy_edid_map = [ re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys() ]
348 fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
349 if fuzzy_output in fuzzy_edid_map:
350 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
351 elif "off" not in options:
352 raise AutorandrException("Failed to find an EDID for output `%s' in setup file, required as `%s' is not off in config file." % (options["output"], options["output"]))
353 output = options["output"]
354 del options["output"]
356 return XrandrOutput(output, edid, options)
358 def edid_equals(self, other):
359 "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
360 if self.edid and other.edid:
361 if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
362 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
363 if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
364 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
365 return self.edid == other.edid
367 def __ne__(self, other):
368 return not (self == other)
370 def __eq__(self, other):
371 return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
373 def verbose_diff(self, other):
374 "Compare to another XrandrOutput and return a list of human readable differences"
376 if not self.edid_equals(other):
377 diffs.append("EDID `%s' differs from `%s'" % (self.short_edid, other.short_edid))
378 if self.output != other.output:
379 diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
380 if "off" in self.options and "off" not in other.options:
381 diffs.append("The output is disabled currently, but active in the new configuration")
382 elif "off" in other.options and "off" not in self.options:
383 diffs.append("The output is currently enabled, but inactive in the new configuration")
385 for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
386 if name not in other.options:
387 diffs.append("Option --%s %sis not present in the new configuration" % (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
388 elif name not in self.options:
389 diffs.append("Option --%s (`%s' in the new configuration) is not present currently" % (name, other.options[name]))
390 elif self.options[name] != other.options[name]:
391 diffs.append("Option --%s %sis `%s' in the new configuration" % (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
394 def xrandr_version():
395 "Return the version of XRandR that this system uses"
396 if getattr(xrandr_version, "version", False) is False:
397 version_string = os.popen("xrandr -v").read()
399 version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
400 xrandr_version.version = Version(version)
401 except AttributeError:
402 xrandr_version.version = Version("1.3.0")
404 return xrandr_version.version
406 def debug_regexp(pattern, string):
407 "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
410 bounds = ( 0, len(string) )
411 while bounds[0] != bounds[1]:
412 half = int((bounds[0] + bounds[1]) / 2)
413 if half == bounds[0]:
415 bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
416 partial_length = bounds[0]
417 return ("Regular expression matched until position "
418 "%d, ..'%s', and did not match from '%s'.." % (partial_length, string[max(0, partial_length-20):partial_length],
419 string[partial_length:partial_length+10]))
422 return "Debug information would be available if the `regex' module was installed."
424 def parse_xrandr_output():
425 "Parse the output of `xrandr --verbose' into a list of outputs"
426 xrandr_output = os.popen("xrandr -q --verbose").read()
427 if not xrandr_output:
428 raise AutorandrException("Failed to run xrandr")
430 # We are not interested in screens
431 xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
433 # Split at output boundaries and instanciate an XrandrOutput per output
434 split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
435 if len(split_xrandr_output) < 2:
436 raise AutorandrException("No output boundaries found", report_bug=True)
437 outputs = OrderedDict()
438 modes = OrderedDict()
439 for i in range(1, len(split_xrandr_output), 2):
440 output_name = split_xrandr_output[i].split()[0]
441 output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i+2]))
442 outputs[output_name] = output
444 modes[output_name] = output_modes
446 return outputs, modes
448 def load_profiles(profile_path):
449 "Load the stored profiles"
452 for profile in os.listdir(profile_path):
453 config_name = os.path.join(profile_path, profile, "config")
454 setup_name = os.path.join(profile_path, profile, "setup")
455 if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
458 edids = dict([ x.strip().split() for x in open(setup_name).readlines() if x.strip() ])
462 for line in chain(open(config_name).readlines(), ["output"]):
463 if line[:6] == "output" and buffer:
464 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
469 for output_name in list(config.keys()):
470 if config[output_name].edid is None:
471 del config[output_name]
473 profiles[profile] = { "config": config, "path": os.path.join(profile_path, profile), "config-mtime": os.stat(config_name).st_mtime }
477 def find_profiles(current_config, profiles):
478 "Find profiles matching the currently connected outputs"
479 detected_profiles = []
480 for profile_name, profile in profiles.items():
481 config = profile["config"]
483 for name, output in config.items():
486 if name not in current_config or not output.edid_equals(current_config[name]):
489 if not matches or any(( name not in config.keys() for name in current_config.keys() if current_config[name].edid )):
492 detected_profiles.append(profile_name)
493 return detected_profiles
495 def profile_blocked(profile_path, meta_information=None):
496 """Check if a profile is blocked.
498 meta_information is expected to be an dictionary. It will be passed to the block scripts
499 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
501 return not exec_scripts(profile_path, "block", meta_information)
503 def output_configuration(configuration, config):
504 "Write a configuration file"
505 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
506 for output in outputs:
507 print(configuration[output].option_string, file=config)
509 def output_setup(configuration, setup):
510 "Write a setup (fingerprint) file"
511 outputs = sorted(configuration.keys())
512 for output in outputs:
513 if configuration[output].edid:
514 print(output, configuration[output].edid, file=setup)
516 def save_configuration(profile_path, configuration):
517 "Save a configuration into a profile"
518 if not os.path.isdir(profile_path):
519 os.makedirs(profile_path)
520 with open(os.path.join(profile_path, "config"), "w") as config:
521 output_configuration(configuration, config)
522 with open(os.path.join(profile_path, "setup"), "w") as setup:
523 output_setup(configuration, setup)
525 def update_mtime(filename):
526 "Update a file's mtime"
528 os.utime(filename, None)
533 def call_and_retry(*args, **kwargs):
534 """Wrapper around subprocess.call that retries failed calls.
536 This function calls subprocess.call and on non-zero exit states,
537 waits a second and then retries once. This mitigates #47,
538 a timing issue with some drivers.
540 kwargs_redirected = dict(kwargs)
541 if hasattr(subprocess, "DEVNULL"):
542 kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
544 kwargs_redirected["stdout"] = open(os.devnull, "w")
545 kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
546 retval = subprocess.call(*args, **kwargs_redirected)
549 retval = subprocess.call(*args, **kwargs)
552 def apply_configuration(new_configuration, current_configuration, dry_run=False):
553 "Apply a configuration"
554 outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
556 base_argv = [ "echo", "xrandr" ]
558 base_argv = [ "xrandr" ]
560 # There are several xrandr / driver bugs we need to take care of here:
561 # - We cannot enable more than two screens at the same time
562 # See https://github.com/phillipberndt/autorandr/pull/6
563 # and commits f4cce4d and 8429886.
564 # - We cannot disable all screens
565 # See https://github.com/phillipberndt/autorandr/pull/20
566 # - We should disable screens before enabling others, because there's
567 # a limit on the number of enabled screens
568 # - We must make sure that the screen at 0x0 is activated first,
569 # or the other (first) screen to be activated would be moved there.
570 # - If an active screen already has a transformation and remains active,
571 # the xrandr call fails with an invalid RRSetScreenSize parameter error.
572 # Update the configuration in 3 passes in that case. (On Haswell graphics,
574 # - Some implementations can not handle --transform at all, so avoid it unless
575 # necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
577 auxiliary_changes_pre = []
580 remain_active_count = 0
581 for output in outputs:
582 if not new_configuration[output].edid or "off" in new_configuration[output].options:
583 disable_outputs.append(new_configuration[output].option_vector)
585 if "off" not in current_configuration[output].options:
586 remain_active_count += 1
588 option_vector = new_configuration[output].option_vector
589 if xrandr_version() >= Version("1.3.0"):
590 if "transform" in current_configuration[output].options:
591 auxiliary_changes_pre.append(["--output", output, "--transform", "none"])
594 transform_index = option_vector.index("--transform")
595 if option_vector[transform_index+1] == XrandrOutput.XRANDR_DEFAULTS["transform"]:
596 option_vector = option_vector[:transform_index] + option_vector[transform_index+2:]
600 enable_outputs.append(option_vector)
602 # Perform pe-change auxiliary changes
603 if auxiliary_changes_pre:
604 argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
605 if call_and_retry(argv) != 0:
606 raise AutorandrException("Command failed: %s" % " ".join(argv))
608 # Disable unused outputs, but make sure that there always is at least one active screen
609 disable_keep = 0 if remain_active_count else 1
610 if len(disable_outputs) > disable_keep:
611 if call_and_retry(base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))) != 0:
612 # Disabling the outputs failed. Retry with the next command:
613 # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
614 # This does not occur if simultaneously the primary screen is reset.
617 disable_outputs = disable_outputs[-1:] if disable_keep else []
619 # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
620 # disable the last two screens. This is a problem, so if this would happen, instead disable only
621 # one screen in the first call below.
622 if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
623 # In the context of a xrandr call that changes the display state, `--query' should do nothing
624 disable_outputs.insert(0, ['--query'])
626 # Enable the remaining outputs in pairs of two operations
627 operations = disable_outputs + enable_outputs
628 for index in range(0, len(operations), 2):
629 argv = base_argv + list(chain.from_iterable(operations[index:index+2]))
630 if call_and_retry(argv) != 0:
631 raise AutorandrException("Command failed: %s" % " ".join(argv))
633 def is_equal_configuration(source_configuration, target_configuration):
634 "Check if all outputs from target are already configured correctly in source"
635 for output in target_configuration.keys():
636 if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
640 def add_unused_outputs(source_configuration, target_configuration):
641 "Add outputs that are missing in target to target, in 'off' state"
642 for output_name, output in source_configuration.items():
643 if output_name not in target_configuration:
644 target_configuration[output_name] = XrandrOutput(output_name, output.edid, { "off": None })
646 def remove_irrelevant_outputs(source_configuration, target_configuration):
647 "Remove outputs from target that ought to be 'off' and already are"
648 for output_name, output in source_configuration.items():
649 if "off" in output.options and output_name in target_configuration and "off" in target_configuration[output_name].options:
650 del target_configuration[output_name]
652 def generate_virtual_profile(configuration, modes, profile_name):
653 "Generate one of the virtual profiles"
654 configuration = copy.deepcopy(configuration)
655 if profile_name == "common":
656 common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output_modes )) for output, output_modes in modes.items() if configuration[output].edid ]
657 common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
658 common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
659 if common_resolution:
660 for output in configuration:
661 configuration[output].options = {}
662 if output in modes and configuration[output].edid:
663 configuration[output].options["mode"] = [ x["name"] for x in sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1) if x["width"] == common_resolution[-1][0] and x["height"] == common_resolution[-1][1] ][0]
664 configuration[output].options["pos"] = "0x0"
666 configuration[output].options["off"] = None
667 elif profile_name in ("horizontal", "vertical"):
669 if profile_name == "horizontal":
670 shift_index = "width"
671 pos_specifier = "%sx0"
673 shift_index = "height"
674 pos_specifier = "0x%s"
676 for output in configuration:
677 configuration[output].options = {}
678 if output in modes and configuration[output].edid:
679 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
680 configuration[output].options["mode"] = mode["name"]
681 configuration[output].options["rate"] = mode["rate"]
682 configuration[output].options["pos"] = pos_specifier % shift
683 shift += int(mode[shift_index])
685 configuration[output].options["off"] = None
688 def print_profile_differences(one, another):
689 "Print the differences between two profiles for debugging"
692 print("| Differences between the two profiles:", file=sys.stderr)
693 for output in set(chain.from_iterable((one.keys(), another.keys()))):
694 if output not in one:
695 if "off" not in another[output].options:
696 print("| Output `%s' is missing from the active configuration" % output, file=sys.stderr)
697 elif output not in another:
698 if "off" not in one[output].options:
699 print("| Output `%s' is missing from the new configuration" % output, file=sys.stderr)
701 for line in one[output].verbose_diff(another[output]):
702 print("| [Output %s] %s" % (output, line), file=sys.stderr)
703 print ("\\-", file=sys.stderr)
706 "Print help and exit"
708 for profile in virtual_profiles:
709 print(" %-10s %s" % profile[:2])
712 def exec_scripts(profile_path, script_name, meta_information=None):
715 This will run all executables from the profile folder, and global per-user
716 and system-wide configuration folders, named script_name or residing in
717 subdirectories named script_name.d.
719 meta_information is expected to be an dictionary. It will be passed to the block scripts
720 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
722 Returns True unless any of the scripts exited with non-zero exit status.
726 env = os.environ.copy()
727 env.update({ "AUTORANDR_%s" % str(key).upper(): str(value) for (key, value) in meta_information.items() })
729 env = os.environ.copy()
731 # If there are multiple candidates, the XDG spec tells to only use the first one.
734 user_profile_path = os.path.expanduser("~/.autorandr")
735 if not os.path.isdir(user_profile_path):
736 user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
738 for folder in chain((profile_path, os.path.dirname(profile_path), user_profile_path),
739 (os.path.join(x, "autorandr") for x in os.environ.get("XDG_CONFIG_DIRS", "").split(":"))):
741 if script_name not in ran_scripts:
742 script = os.path.join(folder, script_name)
743 if os.access(script, os.X_OK | os.F_OK):
744 all_ok &= subprocess.call(script, env=env) != 0
745 ran_scripts.add(script_name)
747 script_folder = os.path.join(folder, "%s.d" % script_name)
748 if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
749 for file_name in os.listdir(script_folder):
750 check_name = "d/%s" % (file_name,)
751 if check_name not in ran_scripts:
752 script = os.path.join(script_folder, file_name)
753 if os.access(script, os.X_OK | os.F_OK):
754 all_ok &= subprocess.call(script, env=env) != 0
755 ran_scripts.add(check_name)
761 options = dict(getopt.getopt(argv[1:], "s:r:l:d:cfh", [ "dry-run", "change", "default=", "save=", "remove=", "load=", "force", "fingerprint", "config", "debug", "skip-options=", "help" ])[0])
762 except getopt.GetoptError as e:
763 print("Failed to parse options: {0}.\n"
764 "Use --help to get usage information.".format(str(e)),
766 sys.exit(posix.EX_USAGE)
770 # Load profiles from each XDG config directory
771 # The XDG spec says that earlier entries should take precedence, so reverse the order
772 for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "").split(":")):
773 system_profile_path = os.path.join(directory, "autorandr")
774 if os.path.isdir(system_profile_path):
775 profiles.update(load_profiles(system_profile_path))
776 # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
777 # profile_path is also used later on to store configurations
778 profile_path = os.path.expanduser("~/.autorandr")
779 if not os.path.isdir(profile_path):
780 # Elsewise, follow the XDG specification
781 profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
782 if os.path.isdir(profile_path):
783 profiles.update(load_profiles(profile_path))
784 # Sort by descending mtime
785 profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
786 except Exception as e:
787 raise AutorandrException("Failed to load profiles", e)
789 config, modes = parse_xrandr_output()
791 if "--fingerprint" in options:
792 output_setup(config, sys.stdout)
795 if "--config" in options:
796 output_configuration(config, sys.stdout)
799 if "--skip-options" in options:
800 skip_options = [ y[2:] if y[:2] == "--" else y for y in ( x.strip() for x in options["--skip-options"].split(",") ) ]
801 for profile in profiles.values():
802 for output in profile["config"].values():
803 output.set_ignored_options(skip_options)
804 for output in config.values():
805 output.set_ignored_options(skip_options)
808 options["--save"] = options["-s"]
809 if "--save" in options:
810 if options["--save"] in ( x[0] for x in virtual_profiles ):
811 raise AutorandrException("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
813 profile_folder = os.path.join(profile_path, options["--save"])
814 save_configuration(profile_folder, config)
815 exec_scripts(profile_folder, "postsave", {"CURRENT_PROFILE": options["--save"], "PROFILE_FOLDER": profile_folder})
816 except Exception as e:
817 raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
818 print("Saved current configuration as profile '%s'" % options["--save"])
822 options["--remove"] = options["-r"]
823 if "--remove" in options:
824 if options["--remove"] in ( x[0] for x in virtual_profiles ):
825 raise AutorandrException("Cannot remove profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--remove"])
826 if options["--remove"] not in profiles.keys():
827 raise AutorandrException("Cannot remove profile '%s':\nThis profile does not exist." % options["--remove"])
830 profile_folder = os.path.join(profile_path, options["--remove"])
831 profile_dirlist = os.listdir(profile_folder)
832 profile_dirlist.remove("config")
833 profile_dirlist.remove("setup")
835 print("Profile folder '%s' contains the following additional files:\n---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
836 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
837 if response != "yes":
840 shutil.rmtree(profile_folder)
841 print("Removed profile '%s'" % options["--remove"])
843 print("Profile '%s' was not removed" % options["--remove"])
844 except Exception as e:
845 raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
848 if "-h" in options or "--help" in options:
851 detected_profiles = find_profiles(config, profiles)
855 options["--load"] = options["-l"]
856 if "--load" in options:
857 load_profile = options["--load"]
859 # Find the active profile(s) first, for the block script (See #42)
860 current_profiles = []
861 for profile_name in profiles.keys():
862 configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
863 if configs_are_equal:
864 current_profiles.append(profile_name)
865 block_script_metadata = {
866 "CURRENT_PROFILE": "".join(current_profiles[:1]),
867 "CURRENT_PROFILES": ":".join(current_profiles)
870 for profile_name in profiles.keys():
871 if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
872 print("%s (blocked)" % profile_name, file=sys.stderr)
875 if profile_name in detected_profiles:
876 props.append("(detected)")
877 if ("-c" in options or "--change" in options) and not load_profile:
878 load_profile = profile_name
879 if profile_name in current_profiles:
880 props.append("(current)")
881 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)), file=sys.stderr)
882 if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
883 print_profile_differences(config, profiles[profile_name]["config"])
886 options["--default"] = options["-d"]
887 if not load_profile and "--default" in options:
888 load_profile = options["--default"]
891 if load_profile in ( x[0] for x in virtual_profiles ):
892 load_config = generate_virtual_profile(config, modes, load_profile)
893 scripts_path = os.path.join(profile_path, load_profile)
896 profile = profiles[load_profile]
897 load_config = profile["config"]
898 scripts_path = profile["path"]
900 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
901 if load_profile in detected_profiles and detected_profiles[0] != load_profile:
902 update_mtime(os.path.join(scripts_path, "config"))
903 add_unused_outputs(config, load_config)
904 if load_config == dict(config) and not "-f" in options and not "--force" in options:
905 print("Config already loaded", file=sys.stderr)
907 if "--debug" in options and load_config != dict(config):
908 print("Loading profile '%s'" % load_profile)
909 print_profile_differences(config, load_config)
911 remove_irrelevant_outputs(config, load_config)
914 if "--dry-run" in options:
915 apply_configuration(load_config, config, True)
918 "CURRENT_PROFILE": load_profile,
919 "PROFILE_FOLDER": scripts_path,
921 exec_scripts(scripts_path, "preswitch", script_metadata)
922 if "--debug" in options:
923 print("Going to run:")
924 apply_configuration(load_config, config, True)
925 apply_configuration(load_config, config, False)
926 exec_scripts(scripts_path, "postswitch", script_metadata)
927 except Exception as e:
928 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
930 if "--dry-run" not in options and "--debug" in options:
931 new_config, _ = parse_xrandr_output()
932 if not is_equal_configuration(new_config, load_config):
933 print("The configuration change did not go as expected:")
934 print_profile_differences(new_config, load_config)
938 if __name__ == '__main__':
941 except AutorandrException as e:
942 print(e, file=sys.stderr)
944 except Exception as e:
945 if not len(str(e)): # BdbQuit
946 print("Exception: {0}".format(e.__class__.__name__))
949 print("Unhandled exception ({0}). Please report this as a bug at https://github.com/phillipberndt/autorandr/issues.".format(e), file=sys.stderr)