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
41 from collections import OrderedDict
42 from functools import reduce
43 from itertools import chain
46 from packaging.version import Version
47 except ModuleNotFoundError:
48 from distutils.version import LooseVersion as Version
50 if sys.version_info.major == 2:
51 import ConfigParser as configparser
63 # (name, description, callback)
64 ("off", "Disable all outputs", None),
65 ("common", "Clone all connected outputs at the largest common resolution", None),
66 ("clone-largest", "Clone all connected outputs with the largest resolution (scaled down if necessary)", None),
67 ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
68 ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
72 Usage: autorandr [options]
74 -h, --help get this small help
75 -c, --change automatically load the first detected profile
76 -d, --default <profile> make profile <profile> the default profile
77 -l, --load <profile> load profile <profile>
78 -s, --save <profile> save your current setup to profile <profile>
79 -r, --remove <profile> remove profile <profile>
80 --batch run autorandr for all users with active X11 sessions
81 --current only list current (active) configuration(s)
82 --config dump your current xrandr setup
83 --cycle automatically load the next detected profile
84 --debug enable verbose output
85 --detected only list detected (available) configuration(s)
86 --dry-run don't change anything, only print the xrandr commands
87 --fingerprint fingerprint your current hardware setup
88 --force force (re)loading of a profile / overwrite exiting files
89 --list list configurations
90 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
91 to skip both in detecting changes and applying a profile
92 --version show version information and exit
94 If no suitable profile can be identified, the current configuration is kept.
95 To change this behaviour and switch to a fallback configuration, specify
98 autorandr supports a set of per-profile and global hooks. See the documentation
101 The following virtual configurations are available:
105 def is_closed_lid(output):
106 if not re.match(r'(eDP(-?[0-9]\+)*|LVDS(-?[0-9]\+)*)', output):
108 lids = glob.glob("/proc/acpi/button/lid/*/state")
111 with open(state_file) as f:
113 return "close" in content
117 class AutorandrException(Exception):
118 def __init__(self, message, original_exception=None, report_bug=False):
119 self.message = message
120 self.report_bug = report_bug
121 if original_exception:
122 self.original_exception = original_exception
123 trace = sys.exc_info()[2]
125 trace = trace.tb_next
126 self.line = trace.tb_lineno
127 self.file_name = trace.tb_frame.f_code.co_filename
131 frame = inspect.currentframe().f_back
132 self.line = frame.f_lineno
133 self.file_name = frame.f_code.co_filename
136 self.file_name = None
137 self.original_exception = None
139 if os.path.abspath(self.file_name) == os.path.abspath(sys.argv[0]):
140 self.file_name = None
143 retval = [self.message]
145 retval.append(" (line %d%s)" % (self.line, ("; %s" % self.file_name) if self.file_name else ""))
146 if self.original_exception:
147 retval.append(":\n ")
148 retval.append(str(self.original_exception).replace("\n", "\n "))
150 retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream:"
151 "\nhttps://github.com/phillipberndt/autorandr/issues"
152 "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
153 return "".join(retval)
156 class XrandrOutput(object):
157 "Represents an XRandR output"
159 # This regular expression is used to parse an output in `xrandr --verbose'
160 XRANDR_OUTPUT_REGEXP = """(?x)
161 ^\s*(?P<output>\S[^ ]*)\s+ # Line starts with output name
162 (?: # Differentiate disconnected and connected
163 disconnected | # in first line
164 unknown\ connection |
165 (?P<connected>connected)
168 (?P<primary>primary\ )? # Might be primary screen
170 (?P<width>[0-9]+)x(?P<height>[0-9]+) # Resolution (might be overridden below!)
171 \+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+ # Position
172 (?:\(0x[0-9a-fA-F]+\)\s+)? # XID
173 (?P<rotate>(?:normal|left|right|inverted))\s+ # Rotation
174 (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)? # Reflection
175 )? # .. but only if the screen is in use.
176 (?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
177 (?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Panning information
178 (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Tracking information
179 (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))? # Border information
180 (?:\s*(?: # Properties of the output
181 Gamma: (?P<gamma>(?:inf|-?[0-9\.\-: e])+) | # Gamma value
182 CRTC:\s*(?P<crtc>[0-9]) | # CRTC value
183 Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) | # Transformation matrix
184 EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) | # EDID of the output
185 (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)* # Other properties
189 (?P<mode_name>\S+).+?\*current.*\s+ # Interesting (current) resolution:
190 h:\s+width\s+(?P<mode_width>[0-9]+).+\s+ # Extract rate
191 v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
192 \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s* # Other resolutions
196 XRANDR_OUTPUT_MODES_REGEXP = """(?x)
197 (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
198 h:\s+width\s+(?P<width>[0-9]+).+\s+
199 v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
202 XRANDR_13_DEFAULTS = {
203 "transform": "1,0,0,0,1,0,0,0,1",
207 XRANDR_12_DEFAULTS = {
210 "gamma": "1.0:1.0:1.0",
213 XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
215 EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
218 return "<%s%s %s>" % (self.output, self.short_edid, " ".join(self.option_vector))
221 def short_edid(self):
222 return ("%s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else ""
225 def options_with_defaults(self):
226 "Return the options dictionary, augmented with the default values that weren't set"
227 if "off" in self.options:
230 if xrandr_version() >= Version("1.3"):
231 options.update(self.XRANDR_13_DEFAULTS)
232 if xrandr_version() >= Version("1.2"):
233 options.update(self.XRANDR_12_DEFAULTS)
234 options.update(self.options)
235 return {a: b for a, b in options.items() if a not in self.ignored_options}
238 def filtered_options(self):
239 "Return a dictionary of options without ignored options"
240 return {a: b for a, b in self.options.items() if a not in self.ignored_options}
243 def option_vector(self):
244 "Return the command line parameters for XRandR for this instance"
245 args = ["--output", self.output]
246 for option, arg in sorted(self.options_with_defaults.items()):
247 args.append("--%s" % option)
253 def option_string(self):
254 "Return the command line parameters in the configuration file format"
255 options = ["output %s" % self.output]
256 for option, arg in sorted(self.filtered_options.items()):
258 options.append("%s %s" % (option, arg))
260 options.append(option)
261 return "\n".join(options)
265 "Return a key to sort the outputs for xrandr invocation"
268 if "off" in self.options:
270 if "pos" in self.options:
271 x, y = map(float, self.options["pos"].split("x"))
276 def __init__(self, output, edid, options):
277 "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
280 self.options = options
281 self.ignored_options = []
282 self.remove_default_option_values()
284 def set_ignored_options(self, options):
285 "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
286 self.ignored_options = list(options)
288 def remove_default_option_values(self):
289 "Remove values from the options dictionary that are superflous"
290 if "off" in self.options and len(self.options.keys()) > 1:
291 self.options = {"off": None}
293 for option, default_value in self.XRANDR_DEFAULTS.items():
294 if option in self.options and self.options[option] == default_value:
295 del self.options[option]
298 def from_xrandr_output(cls, xrandr_output):
299 """Instanciate an XrandrOutput from the output of `xrandr --verbose'
301 This method also returns a list of modes supported by the output.
304 xrandr_output = xrandr_output.replace("\r\n", "\n")
305 match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
307 raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.",
310 debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
311 raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug,
313 remainder = xrandr_output[len(match_object.group(0)):]
315 raise AutorandrException("Parsing XRandR output failed, %d bytes left unmatched after "
316 "regular expression, starting at byte %d with ..'%s'." %
317 (len(remainder), len(match_object.group(0)), remainder[:10]),
320 match = match_object.groupdict()
325 for mode_match in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]):
326 if mode_match.group("name"):
327 modes.append(mode_match.groupdict())
329 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
332 if not match["connected"]:
335 edid = "".join(match["edid"].strip().split())
337 edid = "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
339 # An output can be disconnected but still have a mode configured. This can only happen
340 # as a residual situation after a disconnect, you cannot associate a mode with an disconnected
343 # This code needs to be careful not to mix the two. An output should only be configured to
344 # "off" if it doesn't have a mode associated with it, which is modelled as "not a width" here.
345 if not match["width"]:
346 options["off"] = None
348 if match["mode_name"]:
349 options["mode"] = match["mode_name"]
350 elif match["mode_width"]:
351 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
353 if match["rotate"] not in ("left", "right"):
354 options["mode"] = "%sx%s" % (match["width"] or 0, match["height"] or 0)
356 options["mode"] = "%sx%s" % (match["height"] or 0, match["width"] or 0)
358 options["rotate"] = match["rotate"]
360 options["primary"] = None
361 if match["reflect"] == "X":
362 options["reflect"] = "x"
363 elif match["reflect"] == "Y":
364 options["reflect"] = "y"
365 elif match["reflect"] == "X and Y":
366 options["reflect"] = "xy"
367 if match["x"] or match["y"]:
368 options["pos"] = "%sx%s" % (match["x"] or "0", match["y"] or "0")
370 panning = [match["panning"]]
371 if match["tracking"]:
372 panning += ["/", match["tracking"]]
374 panning += ["/", match["border"]]
375 options["panning"] = "".join(panning)
376 if match["transform"]:
377 transformation = ",".join(match["transform"].strip().split())
378 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
379 options["transform"] = transformation
380 if not match["mode_name"]:
381 # TODO We'd need to apply the reverse transformation here. Let's see if someone complains,
382 # I doubt that this special case is actually required.
383 print("Warning: Output %s has a transformation applied. Could not determine correct mode! "
384 "Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
386 gamma = match["gamma"].strip()
387 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
388 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
389 # so we approximate by 1e-10.
390 gamma = ":".join([str(max(1e-10, round(1. / float(x), 3))) for x in gamma.split(":")])
391 options["gamma"] = gamma
393 options["crtc"] = match["crtc"]
395 options["rate"] = match["rate"]
397 return XrandrOutput(match["output"], edid, options), modes
400 def from_config_file(cls, edid_map, configuration):
401 "Instanciate an XrandrOutput from the contents of a configuration file"
403 for line in configuration.split("\n"):
405 line = line.split(None, 1)
406 if line and line[0].startswith("#"):
408 options[line[0]] = line[1] if len(line) > 1 else None
412 if options["output"] in edid_map:
413 edid = edid_map[options["output"]]
415 # This fuzzy matching is for legacy autorandr that used sysfs output names
416 fuzzy_edid_map = [re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys()]
417 fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
418 if fuzzy_output in fuzzy_edid_map:
419 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
420 elif "off" not in options:
421 raise AutorandrException("Failed to find an EDID for output `%s' in setup file, required as `%s' "
422 "is not off in config file." % (options["output"], options["output"]))
423 output = options["output"]
424 del options["output"]
426 return XrandrOutput(output, edid, options)
428 def edid_equals(self, other):
429 "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
430 if self.edid and other.edid:
431 if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
432 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
433 if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
434 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
436 return match_asterisk(self.edid, other.edid) > 0
437 elif "*" in other.edid:
438 return match_asterisk(other.edid, self.edid) > 0
439 return self.edid == other.edid
441 def __ne__(self, other):
442 return not (self == other)
444 def __eq__(self, other):
445 return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
447 def verbose_diff(self, other):
448 "Compare to another XrandrOutput and return a list of human readable differences"
450 if not self.edid_equals(other):
451 diffs.append("EDID `%s' differs from `%s'" % (self.short_edid, other.short_edid))
452 if self.output != other.output:
453 diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
454 if "off" in self.options and "off" not in other.options:
455 diffs.append("The output is disabled currently, but active in the new configuration")
456 elif "off" in other.options and "off" not in self.options:
457 diffs.append("The output is currently enabled, but inactive in the new configuration")
459 for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
460 if name not in other.options:
461 diffs.append("Option --%s %sis not present in the new configuration" %
462 (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
463 elif name not in self.options:
464 diffs.append("Option --%s (`%s' in the new configuration) is not present currently" %
465 (name, other.options[name]))
466 elif self.options[name] != other.options[name]:
467 diffs.append("Option --%s %sis `%s' in the new configuration" %
468 (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
472 def xrandr_version():
473 "Return the version of XRandR that this system uses"
474 if getattr(xrandr_version, "version", False) is False:
475 version_string = os.popen("xrandr -v").read()
477 version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
478 xrandr_version.version = Version(version)
479 except AttributeError:
480 xrandr_version.version = Version("1.3.0")
482 return xrandr_version.version
485 def debug_regexp(pattern, string):
486 "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
489 bounds = (0, len(string))
490 while bounds[0] != bounds[1]:
491 half = int((bounds[0] + bounds[1]) / 2)
492 if half == bounds[0]:
494 bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
495 partial_length = bounds[0]
496 return ("Regular expression matched until position %d, ..'%s', and did not match from '%s'.." %
497 (partial_length, string[max(0, partial_length - 20):partial_length],
498 string[partial_length:partial_length + 10]))
501 return "Debug information would be available if the `regex' module was installed."
504 def parse_xrandr_output():
505 "Parse the output of `xrandr --verbose' into a list of outputs"
506 xrandr_output = os.popen("xrandr -q --verbose").read()
507 if not xrandr_output:
508 raise AutorandrException("Failed to run xrandr")
510 # We are not interested in screens
511 xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
513 # Split at output boundaries and instanciate an XrandrOutput per output
514 split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
515 if len(split_xrandr_output) < 2:
516 raise AutorandrException("No output boundaries found", report_bug=True)
517 outputs = OrderedDict()
518 modes = OrderedDict()
519 for i in range(1, len(split_xrandr_output), 2):
520 output_name = split_xrandr_output[i].split()[0]
521 output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i + 2]))
522 outputs[output_name] = output
524 modes[output_name] = output_modes
526 # consider a closed lid as disconnected if other outputs are connected
527 if sum(o.edid != None for o in outputs.values()) > 1:
528 for output_name in outputs.keys():
529 if is_closed_lid(output_name):
530 outputs[output_name].edid = None
532 return outputs, modes
535 def load_profiles(profile_path):
536 "Load the stored profiles"
539 for profile in os.listdir(profile_path):
540 config_name = os.path.join(profile_path, profile, "config")
541 setup_name = os.path.join(profile_path, profile, "setup")
542 if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
545 edids = dict([x.split() for x in (y.strip() for y in open(setup_name).readlines()) if x and x[0] != "#"])
549 for line in chain(open(config_name).readlines(), ["output"]):
550 if line[:6] == "output" and buffer:
551 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
556 for output_name in list(config.keys()):
557 if config[output_name].edid is None:
558 del config[output_name]
560 profiles[profile] = {
562 "path": os.path.join(profile_path, profile),
563 "config-mtime": os.stat(config_name).st_mtime,
569 def get_symlinks(profile_path):
570 "Load all symlinks from a directory"
573 for link in os.listdir(profile_path):
574 file_name = os.path.join(profile_path, link)
575 if os.path.islink(file_name):
576 symlinks[link] = os.readlink(file_name)
581 def match_asterisk(pattern, data):
582 """Match data against a pattern
584 The difference to fnmatch is that this function only accepts patterns with a single
585 asterisk and that it returns a "closeness" number, which is larger the better the match.
586 Zero indicates no match at all.
588 if "*" not in pattern:
589 return 1 if pattern == data else 0
590 parts = pattern.split("*")
592 raise ValueError("Only patterns with a single asterisk are supported, %s is invalid" % pattern)
593 if not data.startswith(parts[0]):
595 if not data.endswith(parts[1]):
597 matched = len(pattern)
598 total = len(data) + 1
599 return matched * 1. / total
602 def find_profiles(current_config, profiles):
603 "Find profiles matching the currently connected outputs, sorting asterisk matches to the back"
604 detected_profiles = []
605 for profile_name, profile in profiles.items():
606 config = profile["config"]
608 for name, output in config.items():
611 if name not in current_config or not output.edid_equals(current_config[name]):
614 if not matches or any((name not in config.keys() for name in current_config.keys() if current_config[name].edid)):
617 closeness = max(match_asterisk(output.edid, current_config[name].edid), match_asterisk(current_config[name].edid, output.edid))
618 detected_profiles.append((closeness, profile_name))
619 detected_profiles = [o[1] for o in sorted(detected_profiles, key=lambda x: -x[0])]
620 return detected_profiles
623 def profile_blocked(profile_path, meta_information=None):
624 """Check if a profile is blocked.
626 meta_information is expected to be an dictionary. It will be passed to the block scripts
627 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
629 return not exec_scripts(profile_path, "block", meta_information)
632 def check_configuration_pre_save(configuration):
633 "Check that a configuration is safe for saving."
634 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
635 for output in outputs:
636 if "off" not in configuration[output].options and not configuration[output].edid:
637 return ("`%(o)s' is not off (has a mode configured) but is disconnected (does not have an EDID).\n"
638 "This typically means that it has been recently unplugged and then not properly disabled\n"
639 "by the user. Please disable it (e.g. using `xrandr --output %(o)s --off`) and then rerun\n"
640 "this command.") % {"o": output}
643 def output_configuration(configuration, config):
644 "Write a configuration file"
645 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
646 for output in outputs:
647 print(configuration[output].option_string, file=config)
650 def output_setup(configuration, setup):
651 "Write a setup (fingerprint) file"
652 outputs = sorted(configuration.keys())
653 for output in outputs:
654 if configuration[output].edid:
655 print(output, configuration[output].edid, file=setup)
658 def save_configuration(profile_path, profile_name, configuration, forced=False):
659 "Save a configuration into a profile"
660 if not os.path.isdir(profile_path):
661 os.makedirs(profile_path)
662 config_path = os.path.join(profile_path, "config")
663 setup_path = os.path.join(profile_path, "setup")
664 if os.path.isfile(config_path) and not forced:
665 raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
666 if os.path.isfile(setup_path) and not forced:
667 raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
669 with open(config_path, "w") as config:
670 output_configuration(configuration, config)
671 with open(setup_path, "w") as setup:
672 output_setup(configuration, setup)
675 def update_mtime(filename):
676 "Update a file's mtime"
678 os.utime(filename, None)
684 def call_and_retry(*args, **kwargs):
685 """Wrapper around subprocess.call that retries failed calls.
687 This function calls subprocess.call and on non-zero exit states,
688 waits a second and then retries once. This mitigates #47,
689 a timing issue with some drivers.
691 if "dry_run" in kwargs:
692 dry_run = kwargs["dry_run"]
693 del kwargs["dry_run"]
696 kwargs_redirected = dict(kwargs)
698 if hasattr(subprocess, "DEVNULL"):
699 kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
701 kwargs_redirected["stdout"] = open(os.devnull, "w")
702 kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
703 retval = subprocess.call(*args, **kwargs_redirected)
706 retval = subprocess.call(*args, **kwargs)
710 def get_fb_dimensions(configuration):
713 for output in configuration.values():
714 if "off" in output.options or not output.edid:
716 # This won't work with all modes -- but it's a best effort.
717 match = re.search("[0-9]{3,}x[0-9]{3,}", output.options["mode"])
720 o_mode = match.group(0)
721 o_width, o_height = map(int, o_mode.split("x"))
722 if "transform" in output.options:
723 a, b, c, d, e, f, g, h, i = map(float, output.options["transform"].split(","))
724 w = (g * o_width + h * o_height + i)
725 x = (a * o_width + b * o_height + c) / w
726 y = (d * o_width + e * o_height + f) / w
727 o_width, o_height = x, y
728 if "rotate" in output.options:
729 if output.options["rotate"] in ("left", "right"):
730 o_width, o_height = o_height, o_width
731 if "pos" in output.options:
732 o_left, o_top = map(int, output.options["pos"].split("x"))
735 if "panning" in output.options:
736 match = re.match("(?P<w>[0-9]+)x(?P<h>[0-9]+)(?:\+(?P<x>[0-9]+))?(?:\+(?P<y>[0-9]+))?.*", output.options["panning"])
738 detail = match.groupdict(default="0")
739 o_width = int(detail.get("w")) + int(detail.get("x"))
740 o_height = int(detail.get("h")) + int(detail.get("y"))
741 width = max(width, o_width)
742 height = max(height, o_height)
743 return int(width), int(height)
746 def apply_configuration(new_configuration, current_configuration, dry_run=False):
747 "Apply a configuration"
748 found_top_left_monitor = False
749 found_left_monitor = False
750 found_top_monitor = False
751 outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
753 base_argv = ["echo", "xrandr"]
755 base_argv = ["xrandr"]
757 # There are several xrandr / driver bugs we need to take care of here:
758 # - We cannot enable more than two screens at the same time
759 # See https://github.com/phillipberndt/autorandr/pull/6
760 # and commits f4cce4d and 8429886.
761 # - We cannot disable all screens
762 # See https://github.com/phillipberndt/autorandr/pull/20
763 # - We should disable screens before enabling others, because there's
764 # a limit on the number of enabled screens
765 # - We must make sure that the screen at 0x0 is activated first,
766 # or the other (first) screen to be activated would be moved there.
767 # - If an active screen already has a transformation and remains active,
768 # the xrandr call fails with an invalid RRSetScreenSize parameter error.
769 # Update the configuration in 3 passes in that case. (On Haswell graphics,
771 # - Some implementations can not handle --transform at all, so avoid it unless
772 # necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
773 # - Some implementations can not handle --panning without specifying --fb
774 # explicitly, so avoid it unless necessary.
775 # (See https://github.com/phillipberndt/autorandr/issues/72)
777 fb_dimensions = get_fb_dimensions(new_configuration)
779 base_argv += ["--fb", "%dx%d" % fb_dimensions]
781 # Failed to obtain frame-buffer size. Doesn't matter, xrandr will choose for the user.
784 auxiliary_changes_pre = []
787 remain_active_count = 0
788 for output in outputs:
789 if not new_configuration[output].edid or "off" in new_configuration[output].options:
790 disable_outputs.append(new_configuration[output].option_vector)
792 if output not in current_configuration:
793 raise AutorandrException("New profile configures output %s which does not exist in current xrandr --verbose output. "
794 "Don't know how to proceed." % output)
795 if "off" not in current_configuration[output].options:
796 remain_active_count += 1
798 option_vector = new_configuration[output].option_vector
799 if xrandr_version() >= Version("1.3.0"):
800 for option, off_value in (("transform", "none"), ("panning", "0x0")):
801 if option in current_configuration[output].options:
802 auxiliary_changes_pre.append(["--output", output, "--%s" % option, off_value])
805 option_index = option_vector.index("--%s" % option)
806 if option_vector[option_index + 1] == XrandrOutput.XRANDR_DEFAULTS[option]:
807 option_vector = option_vector[:option_index] + option_vector[option_index + 2:]
810 if not found_top_left_monitor:
811 position = new_configuration[output].options.get("pos", "0x0")
812 if position == "0x0":
813 found_top_left_monitor = True
814 enable_outputs.insert(0, option_vector)
815 elif not found_left_monitor and position.startswith("0x"):
816 found_left_monitor = True
817 enable_outputs.insert(0, option_vector)
818 elif not found_top_monitor and position.endswith("x0"):
819 found_top_monitor = True
820 enable_outputs.insert(0, option_vector)
822 enable_outputs.append(option_vector)
824 enable_outputs.append(option_vector)
826 # Perform pe-change auxiliary changes
827 if auxiliary_changes_pre:
828 argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
829 if call_and_retry(argv, dry_run=dry_run) != 0:
830 raise AutorandrException("Command failed: %s" % " ".join(argv))
832 # Disable unused outputs, but make sure that there always is at least one active screen
833 disable_keep = 0 if remain_active_count else 1
834 if len(disable_outputs) > disable_keep:
835 argv = base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))
836 if call_and_retry(argv, dry_run=dry_run) != 0:
837 # Disabling the outputs failed. Retry with the next command:
838 # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
839 # This does not occur if simultaneously the primary screen is reset.
842 disable_outputs = disable_outputs[-1:] if disable_keep else []
844 # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
845 # disable the last two screens. This is a problem, so if this would happen, instead disable only
846 # one screen in the first call below.
847 if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
848 # In the context of a xrandr call that changes the display state, `--query' should do nothing
849 disable_outputs.insert(0, ['--query'])
851 # If we did not find a candidate, we might need to inject a call
852 # If there is no output to disable, we will enable 0x and x0 at the same time
853 if not found_top_left_monitor and len(disable_outputs) > 0:
854 # If the call to 0x and x0 is splitted, inject one of them
855 if found_top_monitor and found_left_monitor:
856 enable_outputs.insert(0, enable_outputs[0])
858 # Enable the remaining outputs in pairs of two operations
859 operations = disable_outputs + enable_outputs
860 for index in range(0, len(operations), 2):
861 argv = base_argv + list(chain.from_iterable(operations[index:index + 2]))
862 if call_and_retry(argv, dry_run=dry_run) != 0:
863 raise AutorandrException("Command failed: %s" % " ".join(argv))
866 def is_equal_configuration(source_configuration, target_configuration):
868 Check if all outputs from target are already configured correctly in source and
869 that no other outputs are active.
871 for output in target_configuration.keys():
872 if "off" in target_configuration[output].options:
873 if (output in source_configuration and "off" not in source_configuration[output].options):
876 if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
878 for output in source_configuration.keys():
879 if "off" in source_configuration[output].options:
880 if output in target_configuration and "off" not in target_configuration[output].options:
883 if output not in target_configuration:
888 def add_unused_outputs(source_configuration, target_configuration):
889 "Add outputs that are missing in target to target, in 'off' state"
890 for output_name, output in source_configuration.items():
891 if output_name not in target_configuration:
892 target_configuration[output_name] = XrandrOutput(output_name, output.edid, {"off": None})
895 def remove_irrelevant_outputs(source_configuration, target_configuration):
896 "Remove outputs from target that ought to be 'off' and already are"
897 for output_name, output in source_configuration.items():
898 if "off" in output.options:
899 if output_name in target_configuration:
900 if "off" in target_configuration[output_name].options:
901 del target_configuration[output_name]
904 def generate_virtual_profile(configuration, modes, profile_name):
905 "Generate one of the virtual profiles"
906 configuration = copy.deepcopy(configuration)
907 if profile_name == "common":
909 for output, output_modes in modes.items():
911 if configuration[output].edid:
912 for mode in output_modes:
913 mode_set.add((mode["width"], mode["height"]))
914 mode_sets.append(mode_set)
915 common_resolution = reduce(lambda a, b: a & b, mode_sets[1:], mode_sets[0])
916 common_resolution = sorted(common_resolution, key=lambda a: int(a[0]) * int(a[1]))
917 if common_resolution:
918 for output in configuration:
919 configuration[output].options = {}
920 if output in modes and configuration[output].edid:
921 modes_sorted = sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1)
922 modes_filtered = [x for x in modes_sorted if (x["width"], x["height"]) == common_resolution[-1]]
923 mode = modes_filtered[0]
924 configuration[output].options["mode"] = mode['name']
925 configuration[output].options["pos"] = "0x0"
927 configuration[output].options["off"] = None
928 elif profile_name in ("horizontal", "vertical"):
930 if profile_name == "horizontal":
931 shift_index = "width"
932 pos_specifier = "%sx0"
934 shift_index = "height"
935 pos_specifier = "0x%s"
937 for output in configuration:
938 configuration[output].options = {}
939 if output in modes and configuration[output].edid:
941 score = int(a["width"]) * int(a["height"])
945 output_modes = sorted(modes[output], key=key)
946 mode = output_modes[-1]
947 configuration[output].options["mode"] = mode["name"]
948 configuration[output].options["rate"] = mode["rate"]
949 configuration[output].options["pos"] = pos_specifier % shift
950 shift += int(mode[shift_index])
952 configuration[output].options["off"] = None
953 elif profile_name == "clone-largest":
954 modes_unsorted = [output_modes[0] for output, output_modes in modes.items()]
955 modes_sorted = sorted(modes_unsorted, key=lambda x: int(x["width"]) * int(x["height"]), reverse=True)
956 biggest_resolution = modes_sorted[0]
957 for output in configuration:
958 configuration[output].options = {}
959 if output in modes and configuration[output].edid:
961 score = int(a["width"]) * int(a["height"])
965 output_modes = sorted(modes[output], key=key)
966 mode = output_modes[-1]
967 configuration[output].options["mode"] = mode["name"]
968 configuration[output].options["rate"] = mode["rate"]
969 configuration[output].options["pos"] = "0x0"
970 scale = max(float(biggest_resolution["width"]) / float(mode["width"]),
971 float(biggest_resolution["height"]) / float(mode["height"]))
972 mov_x = (float(mode["width"]) * scale - float(biggest_resolution["width"])) / -2
973 mov_y = (float(mode["height"]) * scale - float(biggest_resolution["height"])) / -2
974 configuration[output].options["transform"] = "{},0,{},0,{},{},0,0,1".format(scale, mov_x, scale, mov_y)
976 configuration[output].options["off"] = None
977 elif profile_name == "off":
978 for output in configuration:
979 for key in list(configuration[output].options.keys()):
980 del configuration[output].options[key]
981 configuration[output].options["off"] = None
985 def print_profile_differences(one, another):
986 "Print the differences between two profiles for debugging"
989 print("| Differences between the two profiles:")
990 for output in set(chain.from_iterable((one.keys(), another.keys()))):
991 if output not in one:
992 if "off" not in another[output].options:
993 print("| Output `%s' is missing from the active configuration" % output)
994 elif output not in another:
995 if "off" not in one[output].options:
996 print("| Output `%s' is missing from the new configuration" % output)
998 for line in one[output].verbose_diff(another[output]):
999 print("| [Output %s] %s" % (output, line))
1004 "Print help and exit"
1006 for profile in virtual_profiles:
1007 name, description = profile[:2]
1008 description = [description]
1010 while len(description[0]) > max_width + 1:
1011 left_over = description[0][max_width:]
1012 description[0] = description[0][:max_width] + "-"
1013 description.insert(1, " %-15s %s" % ("", left_over))
1014 description = "\n".join(description)
1015 print(" %-15s %s" % (name, description))
1019 def exec_scripts(profile_path, script_name, meta_information=None):
1022 This will run all executables from the profile folder, and global per-user
1023 and system-wide configuration folders, named script_name or residing in
1024 subdirectories named script_name.d.
1026 If profile_path is None, only global scripts will be invoked.
1028 meta_information is expected to be an dictionary. It will be passed to the block scripts
1029 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
1031 Returns True unless any of the scripts exited with non-zero exit status.
1034 env = os.environ.copy()
1035 if meta_information:
1036 for key, value in meta_information.items():
1037 env["AUTORANDR_{}".format(key.upper())] = str(value)
1039 # If there are multiple candidates, the XDG spec tells to only use the first one.
1042 user_profile_path = os.path.expanduser("~/.autorandr")
1043 if not os.path.isdir(user_profile_path):
1044 user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1046 candidate_directories = []
1048 candidate_directories.append(profile_path)
1049 candidate_directories.append(user_profile_path)
1050 for config_dir in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"):
1051 candidate_directories.append(os.path.join(config_dir, "autorandr"))
1053 for folder in candidate_directories:
1054 if script_name not in ran_scripts:
1055 script = os.path.join(folder, script_name)
1056 if os.access(script, os.X_OK | os.F_OK):
1058 all_ok &= subprocess.call(script, env=env) != 0
1060 raise AutorandrException("Failed to execute user command: %s" % (script,))
1061 ran_scripts.add(script_name)
1063 script_folder = os.path.join(folder, "%s.d" % script_name)
1064 if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
1065 for file_name in os.listdir(script_folder):
1066 check_name = "d/%s" % (file_name,)
1067 if check_name not in ran_scripts:
1068 script = os.path.join(script_folder, file_name)
1069 if os.access(script, os.X_OK | os.F_OK):
1071 all_ok &= subprocess.call(script, env=env) != 0
1073 raise AutorandrException("Failed to execute user command: %s" % (script,))
1074 ran_scripts.add(check_name)
1079 def dispatch_call_to_sessions(argv):
1080 """Invoke autorandr for each open local X11 session with the given options.
1082 The function iterates over all processes not owned by root and checks
1083 whether they have DISPLAY and XAUTHORITY variables set. It strips the
1084 screen from any variable it finds (i.e. :0.0 becomes :0) and checks whether
1085 this display has been handled already. If it has not, it forks, changes
1086 uid/gid to the user owning the process, reuses the process's environment
1087 and runs autorandr with the parameters from argv.
1089 This function requires root permissions. It only works for X11 servers that
1090 have at least one non-root process running. It is susceptible for attacks
1091 where one user runs a process with another user's DISPLAY variable - in
1092 this case, it might happen that autorandr is invoked for the other user,
1093 which won't work. Since no other harm than prevention of automated
1094 execution of autorandr can be done this way, the assumption is that in this
1095 situation, the local administrator will handle the situation."""
1097 X11_displays_done = set()
1099 autorandr_binary = os.path.abspath(argv[0])
1100 backup_candidates = {}
1102 def fork_child_autorandr(pwent, process_environ):
1103 print("Running autorandr as %s for display %s" % (pwent.pw_name, process_environ["DISPLAY"]))
1104 child_pid = os.fork()
1106 # This will throw an exception if any of the privilege changes fails,
1107 # so it should be safe. Also, note that since the environment
1108 # is taken from a process owned by the user, reusing it should
1109 # not leak any information.
1111 os.setgroups(os.getgrouplist(pwent.pw_name, pwent.pw_gid))
1112 except AttributeError:
1113 # Python 2 doesn't have getgrouplist
1115 os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
1116 os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
1117 os.chdir(pwent.pw_dir)
1119 os.environ.update(process_environ)
1120 if sys.executable != "" and sys.executable != None:
1121 os.execl(sys.executable, sys.executable, autorandr_binary, *argv[1:])
1123 os.execl(autorandr_binary, autorandr_binary, *argv[1:])
1125 os.waitpid(child_pid, 0)
1127 for directory in os.listdir("/proc"):
1128 directory = os.path.join("/proc/", directory)
1129 if not os.path.isdir(directory):
1131 environ_file = os.path.join(directory, "environ")
1132 if not os.path.isfile(environ_file):
1134 uid = os.stat(environ_file).st_uid
1136 # The following line assumes that user accounts start at 1000 and that
1137 # no one works using the root or another system account. This is rather
1138 # restrictive, but de facto default. Alternatives would be to use the
1139 # UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf;
1140 # but effectively, both values aren't binding in any way.
1141 # If this breaks your use case, please file a bug on Github.
1145 process_environ = {}
1146 for environ_entry in open(environ_file, 'rb').read().split(b"\0"):
1148 environ_entry = environ_entry.decode("ascii")
1149 except UnicodeDecodeError:
1151 name, sep, value = environ_entry.partition("=")
1153 if name == "DISPLAY" and "." in value:
1154 value = value[:value.find(".")]
1155 process_environ[name] = value
1157 if "DISPLAY" not in process_environ:
1158 # Cannot work with this environment, skip.
1161 # To allow scripts to detect batch invocation (especially useful for predetect)
1162 process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
1163 process_environ["UID"] = str(uid)
1165 display = process_environ["DISPLAY"]
1167 if "XAUTHORITY" not in process_environ:
1168 # It's very likely that we cannot work with this environment either,
1169 # but keep it as a backup just in case we don't find anything else.
1170 backup_candidates[display] = process_environ
1173 if display not in X11_displays_done:
1175 pwent = pwd.getpwuid(uid)
1177 # User has no pwd entry
1180 fork_child_autorandr(pwent, process_environ)
1181 X11_displays_done.add(display)
1183 # Run autorandr for any users/displays which didn't have a process with
1185 for display, process_environ in backup_candidates.items():
1186 if display not in X11_displays_done:
1188 pwent = pwd.getpwuid(int(process_environ["UID"]))
1190 # User has no pwd entry
1193 fork_child_autorandr(pwent, process_environ)
1194 X11_displays_done.add(display)
1197 def enabled_monitors(config):
1199 for monitor in config:
1200 if "--off" in config[monitor].option_vector:
1202 monitors.append(monitor)
1206 def read_config(options, directory):
1207 """Parse a configuration config.ini from directory and merge it into
1208 the options dictionary"""
1209 config = configparser.ConfigParser()
1210 config.read(os.path.join(directory, "settings.ini"))
1211 if config.has_section("config"):
1212 for key, value in config.items("config"):
1213 options.setdefault("--%s" % key, value)
1217 opts, args = getopt.getopt(argv[1:], "s:r:l:d:cfh",
1218 ["batch", "dry-run", "change", "cycle", "default=", "save=", "remove=", "load=",
1219 "force", "fingerprint", "config", "debug", "skip-options=", "help",
1220 "list", "current", "detected", "version"])
1221 except getopt.GetoptError as e:
1222 print("Failed to parse options: {0}.\n"
1223 "Use --help to get usage information.".format(str(e)),
1225 sys.exit(posix.EX_USAGE)
1227 options = dict(opts)
1229 if "-h" in options or "--help" in options:
1232 if "--version" in options:
1233 print("autorandr " + __version__)
1236 if "--current" in options and "--detected" in options:
1237 print("--current and --detected are mutually exclusive.", file=sys.stderr)
1238 sys.exit(posix.EX_USAGE)
1241 if "--batch" in options:
1242 if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
1243 dispatch_call_to_sessions([x for x in argv if x != "--batch"])
1245 print("--batch mode can only be used by root and if $DISPLAY is unset")
1247 if "AUTORANDR_BATCH_PID" in os.environ:
1248 user = pwd.getpwuid(os.getuid())
1249 user = user.pw_name if user else "#%d" % os.getuid()
1250 print("autorandr running as user %s (started from batch instance)" % user)
1253 profile_symlinks = {}
1255 # Load profiles from each XDG config directory
1256 # The XDG spec says that earlier entries should take precedence, so reverse the order
1257 for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
1258 system_profile_path = os.path.join(directory, "autorandr")
1259 if os.path.isdir(system_profile_path):
1260 profiles.update(load_profiles(system_profile_path))
1261 profile_symlinks.update(get_symlinks(system_profile_path))
1262 read_config(options, system_profile_path)
1263 # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
1264 # profile_path is also used later on to store configurations
1265 profile_path = os.path.expanduser("~/.autorandr")
1266 if not os.path.isdir(profile_path):
1267 # Elsewise, follow the XDG specification
1268 profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1269 if os.path.isdir(profile_path):
1270 profiles.update(load_profiles(profile_path))
1271 profile_symlinks.update(get_symlinks(profile_path))
1272 read_config(options, profile_path)
1275 if "--cycle" in options:
1276 # When cycling through profiles, put the profile least recently used to the top of the list
1278 profiles = OrderedDict(sorted(profiles.items(), key=lambda x: sort_direction * x[1]["config-mtime"]))
1279 except Exception as e:
1280 raise AutorandrException("Failed to load profiles", e)
1282 profile_symlinks = {k: v for k, v in profile_symlinks.items() if v in (x[0] for x in virtual_profiles) or v in profiles}
1284 exec_scripts(None, "predetect")
1285 config, modes = parse_xrandr_output()
1287 if "--fingerprint" in options:
1288 output_setup(config, sys.stdout)
1291 if "--config" in options:
1292 output_configuration(config, sys.stdout)
1295 if "--skip-options" in options:
1296 skip_options = [y[2:] if y[:2] == "--" else y for y in (x.strip() for x in options["--skip-options"].split(","))]
1297 for profile in profiles.values():
1298 for output in profile["config"].values():
1299 output.set_ignored_options(skip_options)
1300 for output in config.values():
1301 output.set_ignored_options(skip_options)
1304 options["--save"] = options["-s"]
1305 if "--save" in options:
1306 if options["--save"] in (x[0] for x in virtual_profiles):
1307 raise AutorandrException("Cannot save current configuration as profile '%s':\n"
1308 "This configuration name is a reserved virtual configuration." % options["--save"])
1309 error = check_configuration_pre_save(config)
1311 print("Cannot save current configuration as profile '%s':" % options["--save"])
1315 profile_folder = os.path.join(profile_path, options["--save"])
1316 save_configuration(profile_folder, options['--save'], config, forced="--force" in options)
1317 exec_scripts(profile_folder, "postsave", {
1318 "CURRENT_PROFILE": options["--save"],
1319 "PROFILE_FOLDER": profile_folder,
1320 "MONITORS": ":".join(enabled_monitors(config)),
1322 except AutorandrException as e:
1324 except Exception as e:
1325 raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
1326 print("Saved current configuration as profile '%s'" % options["--save"])
1330 options["--remove"] = options["-r"]
1331 if "--remove" in options:
1332 if options["--remove"] in (x[0] for x in virtual_profiles):
1333 raise AutorandrException("Cannot remove profile '%s':\n"
1334 "This configuration name is a reserved virtual configuration." % options["--remove"])
1335 if options["--remove"] not in profiles.keys():
1336 raise AutorandrException("Cannot remove profile '%s':\n"
1337 "This profile does not exist." % options["--remove"])
1340 profile_folder = os.path.join(profile_path, options["--remove"])
1341 profile_dirlist = os.listdir(profile_folder)
1342 profile_dirlist.remove("config")
1343 profile_dirlist.remove("setup")
1345 print("Profile folder '%s' contains the following additional files:\n"
1346 "---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
1347 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
1348 if response != "yes":
1351 shutil.rmtree(profile_folder)
1352 print("Removed profile '%s'" % options["--remove"])
1354 print("Profile '%s' was not removed" % options["--remove"])
1355 except Exception as e:
1356 raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
1359 detected_profiles = find_profiles(config, profiles)
1360 load_profile = False
1363 options["--load"] = options["-l"]
1364 if "--load" in options:
1365 load_profile = options["--load"]
1366 elif len(args) == 1:
1367 load_profile = args[0]
1369 # Find the active profile(s) first, for the block script (See #42)
1370 current_profiles = []
1371 for profile_name in profiles.keys():
1372 configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
1373 if configs_are_equal:
1374 current_profiles.append(profile_name)
1375 block_script_metadata = {
1376 "CURRENT_PROFILE": "".join(current_profiles[:1]),
1377 "CURRENT_PROFILES": ":".join(current_profiles)
1381 for profile_name in profiles.keys():
1382 if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
1383 if not any(opt in options for opt in ("--current", "--detected", "--list")):
1384 print("%s (blocked)" % profile_name)
1387 is_current_profile = profile_name in current_profiles
1388 if profile_name in detected_profiles:
1389 if len(detected_profiles) == 1:
1391 props.append("(detected)")
1393 index = detected_profiles.index(profile_name) + 1
1394 props.append("(detected) (%d%s match)" % (index, ["st", "nd", "rd"][index - 1] if index < 4 else "th"))
1395 if index < best_index:
1396 if "-c" in options or "--change" in options or ("--cycle" in options and not is_current_profile):
1397 load_profile = profile_name
1399 elif "--detected" in options:
1401 if is_current_profile:
1402 props.append("(current)")
1403 elif "--current" in options:
1405 if any(opt in options for opt in ("--current", "--detected", "--list")):
1406 print("%s" % (profile_name, ))
1408 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)))
1409 if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1410 print_profile_differences(config, profiles[profile_name]["config"])
1413 options["--default"] = options["-d"]
1414 if not load_profile and "--default" in options and ("-c" in options or "--change" in options or "--cycle" in options):
1415 load_profile = options["--default"]
1418 if load_profile in profile_symlinks:
1419 if "--debug" in options:
1420 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1421 load_profile = profile_symlinks[load_profile]
1423 if load_profile in (x[0] for x in virtual_profiles):
1424 load_config = generate_virtual_profile(config, modes, load_profile)
1425 scripts_path = os.path.join(profile_path, load_profile)
1428 profile = profiles[load_profile]
1429 load_config = profile["config"]
1430 scripts_path = profile["path"]
1432 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1433 if "--dry-run" not in options:
1434 update_mtime(os.path.join(scripts_path, "config"))
1435 add_unused_outputs(config, load_config)
1436 if load_config == dict(config) and "-f" not in options and "--force" not in options:
1437 print("Config already loaded", file=sys.stderr)
1439 if "--debug" in options and load_config != dict(config):
1440 print("Loading profile '%s'" % load_profile)
1441 print_profile_differences(config, load_config)
1443 remove_irrelevant_outputs(config, load_config)
1446 if "--dry-run" in options:
1447 apply_configuration(load_config, config, True)
1450 "CURRENT_PROFILE": load_profile,
1451 "PROFILE_FOLDER": scripts_path,
1452 "MONITORS": ":".join(enabled_monitors(load_config)),
1454 exec_scripts(scripts_path, "preswitch", script_metadata)
1455 if "--debug" in options:
1456 print("Going to run:")
1457 apply_configuration(load_config, config, True)
1458 apply_configuration(load_config, config, False)
1459 exec_scripts(scripts_path, "postswitch", script_metadata)
1460 except AutorandrException as e:
1461 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1462 except Exception as e:
1463 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1465 if "--dry-run" not in options and "--debug" in options:
1466 new_config, _ = parse_xrandr_output()
1467 if not is_equal_configuration(new_config, load_config):
1468 print("The configuration change did not go as expected:")
1469 print_profile_differences(new_config, load_config)
1474 def exception_handled_main(argv=sys.argv):
1477 except AutorandrException as e:
1478 print(e, file=sys.stderr)
1480 except Exception as e:
1481 if not len(str(e)): # BdbQuit
1482 print("Exception: {0}".format(e.__class__.__name__))
1485 print("Unhandled exception ({0}). Please report this as a bug at "
1486 "https://github.com/phillipberndt/autorandr/issues.".format(e),
1491 if __name__ == '__main__':
1492 exception_handled_main()