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
38 from collections import OrderedDict
39 from distutils.version import LooseVersion as Version
40 from functools import reduce
41 from itertools import chain
45 # (name, description, callback)
46 ("common", "Clone all connected outputs at the largest common resolution", None),
47 ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
48 ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
52 Usage: autorandr [options]
54 -h, --help get this small help
55 -c, --change reload current setup
56 -s, --save <profile> save your current setup to profile <profile>
57 -r, --remove <profile> remove profile <profile>
58 -l, --load <profile> load profile <profile>
59 -d, --default <profile> make profile <profile> the default profile
60 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
61 to skip both in detecting changes and applying a profile
62 --force force (re)loading of a profile
63 --fingerprint fingerprint your current hardware setup
64 --config dump your current xrandr setup
65 --dry-run don't change anything, only print the xrandr commands
66 --debug enable verbose output
68 To prevent a profile from being loaded, place a script call "block" in its
69 directory. The script is evaluated before the screen setup is inspected, and
70 in case of it returning a value of 0 the profile is skipped. This can be used
71 to query the status of a docking station you are about to leave.
73 If no suitable profile can be identified, the current configuration is kept.
74 To change this behaviour and switch to a fallback configuration, specify
77 Another script called "postswitch" can be placed in the directory
78 ~/.config/autorandr (or ~/.autorandr if you have an old installation) as well
79 as in any profile directories: The scripts are executed after a mode switch
80 has taken place and can notify window managers.
82 The following virtual configurations are available:
85 class AutorandrException(Exception):
86 def __init__(self, message, original_exception=None, report_bug=False):
87 self.message = message
88 self.report_bug = report_bug
89 if original_exception:
90 self.original_exception = original_exception
91 trace = sys.exc_info()[2]
94 self.line = trace.tb_lineno
98 self.line = inspect.currentframe().f_back.f_lineno
101 self.original_exception = None
104 retval = [ self.message ]
106 retval.append(" (line %d)" % self.line)
107 if self.original_exception:
108 retval.append(":\n ")
109 retval.append(str(self.original_exception).replace("\n", "\n "))
111 retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream."
112 "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
113 return "".join(retval)
115 class XrandrOutput(object):
116 "Represents an XRandR output"
118 # This regular expression is used to parse an output in `xrandr --verbose'
119 XRANDR_OUTPUT_REGEXP = """(?x)
120 ^(?P<output>[^ ]+)\s+ # Line starts with output name
121 (?: # Differentiate disconnected and connected in first line
123 unknown\ connection |
124 (?P<connected>connected)
127 (?P<primary>primary\ )? # Might be primary screen
129 (?P<width>[0-9]+)x(?P<height>[0-9]+) # Resolution (might be overridden below!)
130 \+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+ # Position
131 (?:\(0x[0-9a-fA-F]+\)\s+)? # XID
132 (?P<rotate>(?:normal|left|right|inverted))\s+ # Rotation
133 (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)? # Reflection
134 )? # .. but everything of the above only if the screen is in use.
135 (?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
136 (?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Panning information
137 (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Tracking information
138 (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))? # Border information
139 (?:\s*(?: # Properties of the output
140 Gamma: (?P<gamma>(?:inf|[0-9\.: e])+) | # Gamma value
141 Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) | # Transformation matrix
142 EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) | # EDID of the output
143 (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)* # Other properties
147 (?P<mode_name>\S+).+?\*current.*\s+ # Interesting (current) resolution: Extract rate
148 h:\s+width\s+(?P<mode_width>[0-9]+).+\s+
149 v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
150 \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s* # Other resolutions
154 XRANDR_OUTPUT_MODES_REGEXP = """(?x)
155 (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
156 h:\s+width\s+(?P<width>[0-9]+).+\s+
157 v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
160 XRANDR_13_DEFAULTS = {
161 "transform": "1,0,0,0,1,0,0,0,1",
165 XRANDR_12_DEFAULTS = {
168 "gamma": "1.0:1.0:1.0",
171 XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
173 EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
176 return "<%s%s %s>" % (self.output, self.short_edid, " ".join(self.option_vector))
179 def short_edid(self):
180 return ("%s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else ""
183 def options_with_defaults(self):
184 "Return the options dictionary, augmented with the default values that weren't set"
185 if "off" in self.options:
188 if xrandr_version() >= Version("1.3"):
189 options.update(self.XRANDR_13_DEFAULTS)
190 if xrandr_version() >= Version("1.2"):
191 options.update(self.XRANDR_12_DEFAULTS)
192 options.update(self.options)
193 return { a: b for a, b in options.items() if a not in self.ignored_options }
196 def filtered_options(self):
197 "Return a dictionary of options without ignored options"
198 return { a: b for a, b in self.options.items() if a not in self.ignored_options }
201 def option_vector(self):
202 "Return the command line parameters for XRandR for this instance"
203 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()))], [])
206 def option_string(self):
207 "Return the command line parameters in the configuration file format"
208 return "\n".join([ " ".join(option) if option[1] else option[0] for option in chain((("output", self.output),), sorted(self.filtered_options.items()))])
212 "Return a key to sort the outputs for xrandr invocation"
215 if "off" in self.options:
217 if "pos" in self.options:
218 x, y = map(float, self.options["pos"].split("x"))
223 def __init__(self, output, edid, options):
224 "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
227 self.options = options
228 self.ignored_options = []
229 self.remove_default_option_values()
231 def set_ignored_options(self, options):
232 "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
233 self.ignored_options = list(options)
235 def remove_default_option_values(self):
236 "Remove values from the options dictionary that are superflous"
237 if "off" in self.options and len(self.options.keys()) > 1:
238 self.options = { "off": None }
240 for option, default_value in self.XRANDR_DEFAULTS.items():
241 if option in self.options and self.options[option] == default_value:
242 del self.options[option]
245 def from_xrandr_output(cls, xrandr_output):
246 """Instanciate an XrandrOutput from the output of `xrandr --verbose'
248 This method also returns a list of modes supported by the output.
251 xrandr_output = xrandr_output.replace("\r\n", "\n")
252 match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
254 raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.", report_bug = True)
256 debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
257 raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug, report_bug = True)
258 remainder = xrandr_output[len(match_object.group(0)):]
260 raise AutorandrException(("Parsing XRandR output failed, %d bytes left unmatched after regular expression, "
261 "starting at byte %d with ..'%s'.") % (len(remainder), len(match_object.group(0)), remainder[:10]), report_bug=True)
263 match = match_object.groupdict()
267 modes = [ x.groupdict() for x in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]) if x.group("name") ]
269 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
272 if not match["connected"]:
275 edid = "".join(match["edid"].strip().split()) if match["edid"] else "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
277 if not match["width"]:
278 options["off"] = None
280 if match["mode_name"]:
281 options["mode"] = match["mode_name"]
282 elif match["mode_width"]:
283 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
285 if match["rotate"] not in ("left", "right"):
286 options["mode"] = "%sx%s" % (match["width"], match["height"])
288 options["mode"] = "%sx%s" % (match["height"], match["width"])
289 options["rotate"] = match["rotate"]
291 options["primary"] = None
292 if match["reflect"] == "X":
293 options["reflect"] = "x"
294 elif match["reflect"] == "Y":
295 options["reflect"] = "y"
296 elif match["reflect"] == "X and Y":
297 options["reflect"] = "xy"
298 options["pos"] = "%sx%s" % (match["x"], match["y"])
300 panning = [ match["panning"] ]
301 if match["tracking"]:
302 panning += [ "/", match["tracking"] ]
304 panning += [ "/", match["border"] ]
305 options["panning"] = "".join(panning)
306 if match["transform"]:
307 transformation = ",".join(match["transform"].strip().split())
308 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
309 options["transform"] = transformation
310 if not match["mode_name"]:
311 # TODO We'd need to apply the reverse transformation here. Let's see if someone complains, I doubt that this
312 # special case is actually required.
313 print("Warning: Output %s has a transformation applied. Could not determine correct mode! Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
315 gamma = match["gamma"].strip()
316 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
317 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
318 # so we approximate by 1e-10.
319 gamma = ":".join([ str(max(1e-10, round(1./float(x), 3))) for x in gamma.split(":") ])
320 options["gamma"] = gamma
322 options["rate"] = match["rate"]
324 return XrandrOutput(match["output"], edid, options), modes
327 def from_config_file(cls, edid_map, configuration):
328 "Instanciate an XrandrOutput from the contents of a configuration file"
330 for line in configuration.split("\n"):
332 line = line.split(None, 1)
333 options[line[0]] = line[1] if len(line) > 1 else None
337 if options["output"] in edid_map:
338 edid = edid_map[options["output"]]
340 # This fuzzy matching is for legacy autorandr that used sysfs output names
341 fuzzy_edid_map = [ re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys() ]
342 fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
343 if fuzzy_output in fuzzy_edid_map:
344 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
345 elif "off" not in options:
346 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"]))
347 output = options["output"]
348 del options["output"]
350 return XrandrOutput(output, edid, options)
352 def edid_equals(self, other):
353 "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
354 if self.edid and other.edid:
355 if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
356 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
357 if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
358 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
359 return self.edid == other.edid
361 def __ne__(self, other):
362 return not (self == other)
364 def __eq__(self, other):
365 return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
367 def verbose_diff(self, other):
368 "Compare to another XrandrOutput and return a list of human readable differences"
370 if not self.edid_equals(other):
371 diffs.append("EDID `%s' differs from `%s'" % (self.short_edid, other.short_edid))
372 if self.output != other.output:
373 diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
374 if "off" in self.options and "off" not in other.options:
375 diffs.append("The output is disabled currently, but active in the new configuration")
376 elif "off" in other.options and "off" not in self.options:
377 diffs.append("The output is currently enabled, but inactive in the new configuration")
379 for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
380 if name not in other.options:
381 diffs.append("Option --%s %sis not present in the new configuration" % (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
382 elif name not in self.options:
383 diffs.append("Option --%s (`%s' in the new configuration) is not present currently" % (name, other.options[name]))
384 elif self.options[name] != other.options[name]:
385 diffs.append("Option --%s %sis `%s' in the new configuration" % (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
388 def xrandr_version():
389 "Return the version of XRandR that this system uses"
390 if getattr(xrandr_version, "version", False) is False:
391 version_string = os.popen("xrandr -v").read()
393 version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
394 xrandr_version.version = Version(version)
395 except AttributeError:
396 xrandr_version.version = Version("1.3.0")
398 return xrandr_version.version
400 def debug_regexp(pattern, string):
401 "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
404 bounds = ( 0, len(string) )
405 while bounds[0] != bounds[1]:
406 half = int((bounds[0] + bounds[1]) / 2)
407 if half == bounds[0]:
409 bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
410 partial_length = bounds[0]
411 return ("Regular expression matched until position "
412 "%d, ..'%s', and did not match from '%s'.." % (partial_length, string[max(0, partial_length-20):partial_length],
413 string[partial_length:partial_length+10]))
416 return "Debug information would be available if the `regex' module was installed."
418 def parse_xrandr_output():
419 "Parse the output of `xrandr --verbose' into a list of outputs"
420 xrandr_output = os.popen("xrandr -q --verbose").read()
421 if not xrandr_output:
422 raise AutorandrException("Failed to run xrandr")
424 # We are not interested in screens
425 xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
427 # Split at output boundaries and instanciate an XrandrOutput per output
428 split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
429 if len(split_xrandr_output) < 2:
430 raise AutorandrException("No output boundaries found", report_bug=True)
431 outputs = OrderedDict()
432 modes = OrderedDict()
433 for i in range(1, len(split_xrandr_output), 2):
434 output_name = split_xrandr_output[i].split()[0]
435 output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i+2]))
436 outputs[output_name] = output
438 modes[output_name] = output_modes
440 return outputs, modes
442 def load_profiles(profile_path):
443 "Load the stored profiles"
446 for profile in os.listdir(profile_path):
447 config_name = os.path.join(profile_path, profile, "config")
448 setup_name = os.path.join(profile_path, profile, "setup")
449 if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
452 edids = dict([ x.strip().split() for x in open(setup_name).readlines() if x.strip() ])
456 for line in chain(open(config_name).readlines(), ["output"]):
457 if line[:6] == "output" and buffer:
458 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
463 for output_name in list(config.keys()):
464 if config[output_name].edid is None:
465 del config[output_name]
467 profiles[profile] = { "config": config, "path": os.path.join(profile_path, profile), "config-mtime": os.stat(config_name).st_mtime }
471 def find_profiles(current_config, profiles):
472 "Find profiles matching the currently connected outputs"
473 detected_profiles = []
474 for profile_name, profile in profiles.items():
475 config = profile["config"]
477 for name, output in config.items():
480 if name not in current_config or not output.edid_equals(current_config[name]):
483 if not matches or any(( name not in config.keys() for name in current_config.keys() if current_config[name].edid )):
486 detected_profiles.append(profile_name)
487 return detected_profiles
489 def profile_blocked(profile_path, meta_information=None):
490 """Check if a profile is blocked.
492 meta_information is expected to be an dictionary. It will be passed to the block scripts
493 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
495 return not exec_scripts(profile_path, "block", meta_information)
497 def output_configuration(configuration, config):
498 "Write a configuration file"
499 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
500 for output in outputs:
501 print(configuration[output].option_string, file=config)
503 def output_setup(configuration, setup):
504 "Write a setup (fingerprint) file"
505 outputs = sorted(configuration.keys())
506 for output in outputs:
507 if configuration[output].edid:
508 print(output, configuration[output].edid, file=setup)
510 def save_configuration(profile_path, configuration):
511 "Save a configuration into a profile"
512 if not os.path.isdir(profile_path):
513 os.makedirs(profile_path)
514 with open(os.path.join(profile_path, "config"), "w") as config:
515 output_configuration(configuration, config)
516 with open(os.path.join(profile_path, "setup"), "w") as setup:
517 output_setup(configuration, setup)
519 def update_mtime(filename):
520 "Update a file's mtime"
522 os.utime(filename, None)
527 def apply_configuration(new_configuration, current_configuration, dry_run=False):
528 "Apply a configuration"
529 outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
531 base_argv = [ "echo", "xrandr" ]
533 base_argv = [ "xrandr" ]
535 # There are several xrandr / driver bugs we need to take care of here:
536 # - We cannot enable more than two screens at the same time
537 # See https://github.com/phillipberndt/autorandr/pull/6
538 # and commits f4cce4d and 8429886.
539 # - We cannot disable all screens
540 # See https://github.com/phillipberndt/autorandr/pull/20
541 # - We should disable screens before enabling others, because there's
542 # a limit on the number of enabled screens
543 # - We must make sure that the screen at 0x0 is activated first,
544 # or the other (first) screen to be activated would be moved there.
545 # - If an active screen already has a transformation and remains active,
546 # the xrandr call fails with an invalid RRSetScreenSize parameter error.
547 # Update the configuration in 3 passes in that case. (On Haswell graphics,
549 # - Some implementations can not handle --transform at all, so avoid it unless
550 # necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
552 auxiliary_changes_pre = []
555 remain_active_count = 0
556 for output in outputs:
557 if not new_configuration[output].edid or "off" in new_configuration[output].options:
558 disable_outputs.append(new_configuration[output].option_vector)
560 if "off" not in current_configuration[output].options:
561 remain_active_count += 1
563 option_vector = new_configuration[output].option_vector
564 if xrandr_version() >= Version("1.3.0"):
565 if "transform" in current_configuration[output].options:
566 auxiliary_changes_pre.append(["--output", output, "--transform", "none"])
569 transform_index = option_vector.index("--transform")
570 if option_vector[transform_index+1] == XrandrOutput.XRANDR_DEFAULTS["transform"]:
571 option_vector = option_vector[:transform_index] + option_vector[transform_index+2:]
575 enable_outputs.append(option_vector)
577 # Perform pe-change auxiliary changes
578 if auxiliary_changes_pre:
579 argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
580 if subprocess.call(argv) != 0:
581 raise AutorandrException("Command failed: %s" % " ".join(argv))
583 # Disable unused outputs, but make sure that there always is at least one active screen
584 disable_keep = 0 if remain_active_count else 1
585 if len(disable_outputs) > disable_keep:
586 if subprocess.call(base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))) != 0:
587 # Disabling the outputs failed. Retry with the next command:
588 # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
589 # This does not occur if simultaneously the primary screen is reset.
592 disable_outputs = disable_outputs[-1:] if disable_keep else []
594 # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
595 # disable the last two screens. This is a problem, so if this would happen, instead disable only
596 # one screen in the first call below.
597 if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
598 # In the context of a xrandr call that changes the display state, `--query' should do nothing
599 disable_outputs.insert(0, ['--query'])
601 # Enable the remaining outputs in pairs of two operations
602 operations = disable_outputs + enable_outputs
603 for index in range(0, len(operations), 2):
604 argv = base_argv + list(chain.from_iterable(operations[index:index+2]))
605 if subprocess.call(argv) != 0:
606 raise AutorandrException("Command failed: %s" % " ".join(argv))
608 def is_equal_configuration(source_configuration, target_configuration):
609 "Check if all outputs from target are already configured correctly in source"
610 for output in target_configuration.keys():
611 if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
615 def add_unused_outputs(source_configuration, target_configuration):
616 "Add outputs that are missing in target to target, in 'off' state"
617 for output_name, output in source_configuration.items():
618 if output_name not in target_configuration:
619 target_configuration[output_name] = XrandrOutput(output_name, output.edid, { "off": None })
621 def remove_irrelevant_outputs(source_configuration, target_configuration):
622 "Remove outputs from target that ought to be 'off' and already are"
623 for output_name, output in source_configuration.items():
624 if "off" in output.options and output_name in target_configuration and "off" in target_configuration[output_name].options:
625 del target_configuration[output_name]
627 def generate_virtual_profile(configuration, modes, profile_name):
628 "Generate one of the virtual profiles"
629 configuration = copy.deepcopy(configuration)
630 if profile_name == "common":
631 common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output_modes )) for output, output_modes in modes.items() if configuration[output].edid ]
632 common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
633 common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
634 if common_resolution:
635 for output in configuration:
636 configuration[output].options = {}
637 if output in modes and configuration[output].edid:
638 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]
639 configuration[output].options["pos"] = "0x0"
641 configuration[output].options["off"] = None
642 elif profile_name in ("horizontal", "vertical"):
644 if profile_name == "horizontal":
645 shift_index = "width"
646 pos_specifier = "%sx0"
648 shift_index = "height"
649 pos_specifier = "0x%s"
651 for output in configuration:
652 configuration[output].options = {}
653 if output in modes and configuration[output].edid:
654 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
655 configuration[output].options["mode"] = mode["name"]
656 configuration[output].options["rate"] = mode["rate"]
657 configuration[output].options["pos"] = pos_specifier % shift
658 shift += int(mode[shift_index])
660 configuration[output].options["off"] = None
663 def print_profile_differences(one, another):
664 "Print the differences between two profiles for debugging"
667 print("| Differences between the two profiles:", file=sys.stderr)
668 for output in set(chain.from_iterable((one.keys(), another.keys()))):
669 if output not in one:
670 if "off" not in another[output].options:
671 print("| Output `%s' is missing from the active configuration" % output, file=sys.stderr)
672 elif output not in another:
673 if "off" not in one[output].options:
674 print("| Output `%s' is missing from the new configuration" % output, file=sys.stderr)
676 for line in one[output].verbose_diff(another[output]):
677 print("| [Output %s] %s" % (output, line), file=sys.stderr)
678 print ("\\-", file=sys.stderr)
681 "Print help and exit"
683 for profile in virtual_profiles:
684 print(" %-10s %s" % profile[:2])
687 def exec_scripts(profile_path, script_name, meta_information=None):
690 This will run all executables from the profile folder, and global per-user
691 and system-wide configuration folders, named script_name or residing in
692 subdirectories named script_name.d.
694 meta_information is expected to be an dictionary. It will be passed to the block scripts
695 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
697 Returns True unless any of the scripts exited with non-zero exit status.
701 env = os.environ.copy()
702 env.update({ "AUTORANDR_%s" % str(key).upper(): str(value) for (key, value) in meta_information.items() })
704 env = os.environ.copy()
706 # If there are multiple candidates, the XDG spec tells to only use the first one.
709 user_profile_path = os.path.expanduser("~/.autorandr")
710 if not os.path.isdir(user_profile_path):
711 user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
713 for folder in chain((profile_path, os.path.dirname(profile_path), user_profile_path),
714 (os.path.join(x, "autorandr") for x in os.environ.get("XDG_CONFIG_DIRS", "").split(":"))):
716 if script_name not in ran_scripts:
717 script = os.path.join(folder, script_name)
718 if os.access(script, os.X_OK | os.F_OK):
719 all_ok &= subprocess.call(script, env=env) != 0
720 ran_scripts.add(script_name)
722 script_folder = os.path.join(folder, "%s.d" % script_name)
723 if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
724 for file_name in os.listdir(script_folder):
725 check_name = "d/%s" % (file_name,)
726 if check_name not in ran_scripts:
727 script = os.path.join(script_folder, file_name)
728 if os.access(script, os.X_OK | os.F_OK):
729 all_ok &= subprocess.call(script, env=env) != 0
730 ran_scripts.add(check_name)
736 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])
737 except getopt.GetoptError as e:
738 print("Failed to parse options: {0}.\n"
739 "Use --help to get usage information.".format(str(e)),
741 sys.exit(posix.EX_USAGE)
745 # Load profiles from each XDG config directory
746 # The XDG spec says that earlier entries should take precedence, so reverse the order
747 for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "").split(":")):
748 system_profile_path = os.path.join(directory, "autorandr")
749 if os.path.isdir(system_profile_path):
750 profiles.update(load_profiles(system_profile_path))
751 # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
752 # profile_path is also used later on to store configurations
753 profile_path = os.path.expanduser("~/.autorandr")
754 if not os.path.isdir(profile_path):
755 # Elsewise, follow the XDG specification
756 profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
757 if os.path.isdir(profile_path):
758 profiles.update(load_profiles(profile_path))
759 # Sort by descending mtime
760 profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
761 except Exception as e:
762 raise AutorandrException("Failed to load profiles", e)
764 config, modes = parse_xrandr_output()
766 if "--fingerprint" in options:
767 output_setup(config, sys.stdout)
770 if "--config" in options:
771 output_configuration(config, sys.stdout)
774 if "--skip-options" in options:
775 skip_options = [ y[2:] if y[:2] == "--" else y for y in ( x.strip() for x in options["--skip-options"].split(",") ) ]
776 for profile in profiles.values():
777 for output in profile["config"].values():
778 output.set_ignored_options(skip_options)
779 for output in config.values():
780 output.set_ignored_options(skip_options)
783 options["--save"] = options["-s"]
784 if "--save" in options:
785 if options["--save"] in ( x[0] for x in virtual_profiles ):
786 raise AutorandrException("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
788 profile_folder = os.path.join(profile_path, options["--save"])
789 save_configuration(profile_folder, config)
790 exec_scripts(profile_folder, "postsave", {"CURRENT_PROFILE": options["--save"], "PROFILE_FOLDER": profile_folder})
791 except Exception as e:
792 raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
793 print("Saved current configuration as profile '%s'" % options["--save"])
797 options["--remove"] = options["-r"]
798 if "--remove" in options:
799 if options["--remove"] in ( x[0] for x in virtual_profiles ):
800 raise AutorandrException("Cannot remove profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--remove"])
801 if options["--remove"] not in profiles.keys():
802 raise AutorandrException("Cannot remove profile '%s':\nThis profile does not exist." % options["--remove"])
805 profile_folder = os.path.join(profile_path, options["--remove"])
806 profile_dirlist = os.listdir(profile_folder)
807 profile_dirlist.remove("config")
808 profile_dirlist.remove("setup")
810 print("Profile folder '%s' contains the following:\n---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
811 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
812 if response != "yes":
815 shutil.rmtree(profile_folder)
816 print("Removed profile '%s'" % options["--remove"])
818 print("Profile '%s' was not removed" % options["--remove"])
819 except Exception as e:
820 raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
823 if "-h" in options or "--help" in options:
826 detected_profiles = find_profiles(config, profiles)
830 options["--load"] = options["-l"]
831 if "--load" in options:
832 load_profile = options["--load"]
834 # Find the active profile(s) first, for the block script (See #42)
835 current_profiles = []
836 for profile_name in profiles.keys():
837 configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
838 if configs_are_equal:
839 current_profiles.append(profile_name)
840 block_script_metadata = {
841 "CURRENT_PROFILE": "".join(current_profiles[:1]),
842 "CURRENT_PROFILES": ":".join(current_profiles)
845 for profile_name in profiles.keys():
846 if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
847 print("%s (blocked)" % profile_name, file=sys.stderr)
850 if profile_name in detected_profiles:
851 props.append("(detected)")
852 if ("-c" in options or "--change" in options) and not load_profile:
853 load_profile = profile_name
854 if profile_name in current_profiles:
855 props.append("(current)")
856 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)), file=sys.stderr)
857 if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
858 print_profile_differences(config, profiles[profile_name]["config"])
861 options["--default"] = options["-d"]
862 if not load_profile and "--default" in options:
863 load_profile = options["--default"]
866 if load_profile in ( x[0] for x in virtual_profiles ):
867 load_config = generate_virtual_profile(config, modes, load_profile)
868 scripts_path = os.path.join(profile_path, load_profile)
871 profile = profiles[load_profile]
872 load_config = profile["config"]
873 scripts_path = profile["path"]
875 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
876 if load_profile in detected_profiles and detected_profiles[0] != load_profile:
877 update_mtime(os.path.join(scripts_path, "config"))
878 add_unused_outputs(config, load_config)
879 if load_config == dict(config) and not "-f" in options and not "--force" in options:
880 print("Config already loaded", file=sys.stderr)
882 if "--debug" in options and load_config != dict(config):
883 print("Loading profile '%s'" % load_profile)
884 print_profile_differences(config, load_config)
886 remove_irrelevant_outputs(config, load_config)
889 if "--dry-run" in options:
890 apply_configuration(load_config, config, True)
893 "CURRENT_PROFILE": load_profile,
894 "PROFILE_FOLDER": scripts_path,
896 exec_scripts(scripts_path, "preswitch", script_metadata)
897 apply_configuration(load_config, config, False)
898 exec_scripts(scripts_path, "postswitch", script_metadata)
899 except Exception as e:
900 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
902 if "--dry-run" not in options and "--debug" in options:
903 new_config, _ = parse_xrandr_output()
904 if not is_equal_configuration(new_config, load_config):
905 print("The configuration change did not go as expected:")
906 print_profile_differences(new_config, load_config)
910 if __name__ == '__main__':
913 except AutorandrException as e:
914 print(e, file=sys.stderr)
916 except Exception as e:
917 if not len(str(e)): # BdbQuit
918 print("Exception: {0}".format(e.__class__.__name__))
921 print("Unhandled exception ({0}). Please report this as a bug.".format(e), file=sys.stderr)