]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
Merge pull request #31 from blueyed/improve-usage-error
[deb_pkgs/autorandr.git] / autorandr.py
1 #!/usr/bin/env python
2 # encoding: utf-8
3 #
4 # autorandr.py
5 # Copyright (c) 2015, Phillip Berndt
6 #
7 # Autorandr rewrite in Python
8 #
9 # This script aims to be fully compatible with the original autorandr.
10 #
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.
15 #
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.
20 #
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/>.
23 #
24
25 from __future__ import print_function
26 import copy
27 import getopt
28
29 import binascii
30 import hashlib
31 import os
32 import re
33 import subprocess
34 import sys
35 from distutils.version import LooseVersion as Version
36
37 from functools import reduce
38 from itertools import chain
39 from collections import OrderedDict
40
41 import posix
42
43
44 virtual_profiles = [
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),
49 ]
50
51 help_text = """
52 Usage: autorandr [options]
53
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 -l, --load <profile>    load profile <profile>
58 -d, --default <profile> make profile <profile> the default profile
59 --force                 force (re)loading of a profile
60 --fingerprint           fingerprint your current hardware setup
61 --config                dump your current xrandr setup
62 --dry-run               don't change anything, only print the xrandr commands
63
64  To prevent a profile from being loaded, place a script call "block" in its
65  directory. The script is evaluated before the screen setup is inspected, and
66  in case of it returning a value of 0 the profile is skipped. This can be used
67  to query the status of a docking station you are about to leave.
68
69  If no suitable profile can be identified, the current configuration is kept.
70  To change this behaviour and switch to a fallback configuration, specify
71  --default <profile>.
72
73  Another script called "postswitch" can be placed in the directory
74  ~/.config/autorandr (or ~/.autorandr if you have an old installation) as well
75  as in any profile directories: The scripts are executed after a mode switch
76  has taken place and can notify window managers.
77
78  The following virtual configurations are available:
79 """.strip()
80
81 class AutorandrException(Exception):
82     def __init__(self, message, original_exception=None, report_bug=False):
83         self.message = message
84         self.report_bug = report_bug
85         if original_exception:
86             self.original_exception = original_exception
87             trace = sys.exc_info()[2]
88             while trace.tb_next:
89                 trace = trace.tb_next
90             self.line = trace.tb_lineno
91         else:
92             try:
93                 import inspect
94                 self.line = inspect.currentframe().f_back.f_lineno
95             except:
96                 self.line = None
97             self.original_exception = None
98
99     def __str__(self):
100         retval = [ self.message ]
101         if self.line:
102             retval.append(" (line %d)" % self.line)
103         if self.original_exception:
104             retval.append(":\n  ")
105             retval.append(str(self.original_exception).replace("\n", "\n  "))
106         if self.report_bug:
107             retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream."
108                          "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
109         return "".join(retval)
110
111 class XrandrOutput(object):
112     "Represents an XRandR output"
113
114     # This regular expression is used to parse an output in `xrandr --verbose'
115     XRANDR_OUTPUT_REGEXP = """(?x)
116         ^(?P<output>[^ ]+)\s+                                                           # Line starts with output name
117         (?:                                                                             # Differentiate disconnected and connected in first line
118             disconnected |
119             unknown\ connection |
120             (?P<connected>connected)
121         )
122         \s*
123         (?P<primary>primary\ )?                                                         # Might be primary screen
124         (?:\s*
125             (?P<width>[0-9]+)x(?P<height>[0-9]+)                                        # Resolution (might be overridden below!)
126             \+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+                                       # Position
127             (?:\(0x[0-9a-fA-F]+\)\s+)?                                                  # XID
128             (?P<rotate>(?:normal|left|right|inverted))\s+                               # Rotation
129             (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)?                                       # Reflection
130         )?                                                                              # .. but everything of the above only if the screen is in use.
131         (?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
132         (?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))?                 # Panning information
133         (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))?               # Tracking information
134         (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))?                            # Border information
135         (?:\s*(?:                                                                       # Properties of the output
136             Gamma: (?P<gamma>(?:inf|[0-9\.: e])+) |                                     # Gamma value
137             Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) |                           # Transformation matrix
138             EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) |                               # EDID of the output
139             (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)*                                 # Other properties
140         ))+
141         \s*
142         (?P<modes>(?:
143             (?P<mode_name>\S+).+?\*current.*\s+                                         # Interesting (current) resolution: Extract rate
144              h:\s+width\s+(?P<mode_width>[0-9]+).+\s+
145              v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
146             \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s*                                     # Other resolutions
147         )*)
148     """
149
150     XRANDR_OUTPUT_MODES_REGEXP = """(?x)
151         (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
152          h:\s+width\s+(?P<width>[0-9]+).+\s+
153          v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
154     """
155
156     XRANDR_13_DEFAULTS = {
157         "transform": "1,0,0,0,1,0,0,0,1",
158         "panning": "0x0",
159     }
160
161     XRANDR_12_DEFAULTS = {
162         "reflect": "normal",
163         "rotate": "normal",
164         "gamma": "1.0:1.0:1.0",
165     }
166
167     XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
168
169     EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
170
171     def __repr__(self):
172         return "<%s%s %s>" % (self.output, (" %s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else "", " ".join(self.option_vector))
173
174     @property
175     def options_with_defaults(self):
176         "Return the options dictionary, augmented with the default values that weren't set"
177         if "off" in self.options:
178             return self.options
179         options = {}
180         if xrandr_version() >= Version("1.3"):
181             options.update(self.XRANDR_13_DEFAULTS)
182         if xrandr_version() >= Version("1.2"):
183             options.update(self.XRANDR_12_DEFAULTS)
184         options.update(self.options)
185         return options
186
187     @property
188     def option_vector(self):
189         "Return the command line parameters for XRandR for this instance"
190         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()))], [])
191
192     @property
193     def option_string(self):
194         "Return the command line parameters in the configuration file format"
195         return "\n".join([ " ".join(option) if option[1] else option[0] for option in chain((("output", self.output),), sorted(self.options.items()))])
196
197     @property
198     def sort_key(self):
199         "Return a key to sort the outputs for xrandr invocation"
200         if not self.edid:
201             return -2
202         if "off" in self.options:
203             return -1
204         if "pos" in self.options:
205             x, y = map(float, self.options["pos"].split("x"))
206         else:
207             x, y = 0, 0
208         return x + 10000 * y
209
210     def __init__(self, output, edid, options):
211         "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
212         self.output = output
213         self.edid = edid
214         self.options = options
215         self.remove_default_option_values()
216
217     def remove_default_option_values(self):
218         "Remove values from the options dictionary that are superflous"
219         if "off" in self.options and len(self.options.keys()) > 1:
220             self.options = { "off": None }
221             return
222         for option, default_value in self.XRANDR_DEFAULTS.items():
223             if option in self.options and self.options[option] == default_value:
224                 del self.options[option]
225
226     @classmethod
227     def from_xrandr_output(cls, xrandr_output):
228         """Instanciate an XrandrOutput from the output of `xrandr --verbose'
229
230         This method also returns a list of modes supported by the output.
231         """
232         try:
233             xrandr_output = xrandr_output.replace("\r\n", "\n")
234             match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
235         except:
236             raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.", report_bug = True)
237         if not match_object:
238             debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
239             raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug, report_bug = True)
240         remainder = xrandr_output[len(match_object.group(0)):]
241         if remainder:
242             raise AutorandrException(("Parsing XRandR output failed, %d bytes left unmatched after regular expression, "
243                                 "starting at byte %d with ..'%s'.") % (len(remainder), len(match_object.group(0)), remainder[:10]), report_bug=True)
244
245         match = match_object.groupdict()
246
247         modes = []
248         if match["modes"]:
249             modes = [ x.groupdict() for x in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]) if x.group("name") ]
250             if not modes:
251                 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
252
253         options = {}
254         if not match["connected"]:
255             edid = None
256         else:
257             edid = "".join(match["edid"].strip().split()) if match["edid"] else "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
258
259         if not match["width"]:
260             options["off"] = None
261         else:
262             if match["mode_name"]:
263                 options["mode"] = match["mode_name"]
264             elif match["mode_width"]:
265                 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
266             else:
267                 if match["rotate"] not in ("left", "right"):
268                     options["mode"] = "%sx%s" % (match["width"], match["height"])
269                 else:
270                     options["mode"] = "%sx%s" % (match["height"], match["width"])
271             options["rotate"] = match["rotate"]
272             if match["primary"]:
273                 options["primary"] = None
274             if match["reflect"] == "X":
275                 options["reflect"] = "x"
276             elif match["reflect"] == "Y":
277                 options["reflect"] = "y"
278             elif match["reflect"] == "X and Y":
279                 options["reflect"] = "xy"
280             options["pos"] = "%sx%s" % (match["x"], match["y"])
281             if match["panning"]:
282                 panning = [ match["panning"] ]
283                 if match["tracking"]:
284                     panning += [ "/", match["tracking"] ]
285                     if match["border"]:
286                         panning += [ "/", match["border"] ]
287                 options["panning"] = "".join(panning)
288             if match["transform"]:
289                 transformation = ",".join(match["transform"].strip().split())
290                 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
291                     options["transform"] = transformation
292                     if not match["mode_name"]:
293                         # TODO We'd need to apply the reverse transformation here. Let's see if someone complains, I doubt that this
294                         # special case is actually required.
295                         print("Warning: Output %s has a transformation applied. Could not determine correct mode! Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
296             if match["gamma"]:
297                 gamma = match["gamma"].strip()
298                 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
299                 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
300                 # so we approximate by 1e-10.
301                 gamma = ":".join([ str(max(1e-10, round(1./float(x), 3))) for x in gamma.split(":") ])
302                 options["gamma"] = gamma
303             if match["rate"]:
304                 options["rate"] = match["rate"]
305
306         return XrandrOutput(match["output"], edid, options), modes
307
308     @classmethod
309     def from_config_file(cls, edid_map, configuration):
310         "Instanciate an XrandrOutput from the contents of a configuration file"
311         options = {}
312         for line in configuration.split("\n"):
313             if line:
314                 line = line.split(None, 1)
315                 options[line[0]] = line[1] if len(line) > 1 else None
316
317         edid = None
318
319         if options["output"] in edid_map:
320             edid = edid_map[options["output"]]
321         else:
322             # This fuzzy matching is for legacy autorandr that used sysfs output names
323             fuzzy_edid_map = [ re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys() ]
324             fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
325             if fuzzy_output in fuzzy_edid_map:
326                 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
327             elif "off" not in options:
328                 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"]))
329         output = options["output"]
330         del options["output"]
331
332         return XrandrOutput(output, edid, options)
333
334     def edid_equals(self, other):
335         "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
336         if self.edid and other.edid:
337             if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
338                 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
339             if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
340                 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
341         return self.edid == other.edid
342
343     def __eq__(self, other):
344         return self.edid_equals(other) and self.output == other.output and self.options == other.options
345
346 def xrandr_version():
347     "Return the version of XRandR that this system uses"
348     if getattr(xrandr_version, "version", False) is False:
349         version_string = os.popen("xrandr -v").read()
350         try:
351             version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
352             xrandr_version.version = Version(version)
353         except AttributeError:
354             xrandr_version.version = Version("1.3.0")
355
356     return xrandr_version.version
357
358 def debug_regexp(pattern, string):
359     "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
360     try:
361         import regex
362         bounds = ( 0, len(string) )
363         while bounds[0] != bounds[1]:
364             half = int((bounds[0] + bounds[1]) / 2)
365             if half == bounds[0]:
366                 break
367             bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
368         partial_length = bounds[0]
369         return ("Regular expression matched until position "
370               "%d, ..'%s', and did not match from '%s'.." % (partial_length, string[max(0, partial_length-20):partial_length],
371                                                              string[partial_length:partial_length+10]))
372     except ImportError:
373         pass
374     return "Debug information would be available if the `regex' module was installed."
375
376 def parse_xrandr_output():
377     "Parse the output of `xrandr --verbose' into a list of outputs"
378     xrandr_output = os.popen("xrandr -q --verbose").read()
379     if not xrandr_output:
380         raise AutorandrException("Failed to run xrandr")
381
382     # We are not interested in screens
383     xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
384
385     # Split at output boundaries and instanciate an XrandrOutput per output
386     split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
387     if len(split_xrandr_output) < 2:
388         raise AutorandrException("No output boundaries found", report_bug=True)
389     outputs = OrderedDict()
390     modes = OrderedDict()
391     for i in range(1, len(split_xrandr_output), 2):
392         output_name = split_xrandr_output[i].split()[0]
393         output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i+2]))
394         outputs[output_name] = output
395         if output_modes:
396             modes[output_name] = output_modes
397
398     return outputs, modes
399
400 def load_profiles(profile_path):
401     "Load the stored profiles"
402
403     profiles = {}
404     for profile in os.listdir(profile_path):
405         config_name = os.path.join(profile_path, profile, "config")
406         setup_name  = os.path.join(profile_path, profile, "setup")
407         if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
408             continue
409
410         edids = dict([ x.strip().split() for x in open(setup_name).readlines() if x.strip() ])
411
412         config = {}
413         buffer = []
414         for line in chain(open(config_name).readlines(), ["output"]):
415             if line[:6] == "output" and buffer:
416                 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
417                 buffer = [ line ]
418             else:
419                 buffer.append(line)
420
421         for output_name in list(config.keys()):
422             if config[output_name].edid is None:
423                 del config[output_name]
424
425         profiles[profile] = { "config": config, "path": os.path.join(profile_path, profile), "config-mtime": os.stat(config_name).st_mtime }
426
427     return profiles
428
429 def find_profiles(current_config, profiles):
430     "Find profiles matching the currently connected outputs"
431     detected_profiles = []
432     for profile_name, profile in profiles.items():
433         config = profile["config"]
434         matches = True
435         for name, output in config.items():
436             if not output.edid:
437                 continue
438             if name not in current_config or not output.edid_equals(current_config[name]):
439                 matches = False
440                 break
441         if not matches or any(( name not in config.keys() for name in current_config.keys() if current_config[name].edid )):
442             continue
443         if matches:
444             detected_profiles.append(profile_name)
445     return detected_profiles
446
447 def profile_blocked(profile_path):
448     "Check if a profile is blocked"
449     script = os.path.join(profile_path, "block")
450     if not os.access(script, os.X_OK | os.F_OK):
451         return False
452     return subprocess.call(script) == 0
453
454 def output_configuration(configuration, config):
455     "Write a configuration file"
456     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
457     for output in outputs:
458         print(configuration[output].option_string, file=config)
459
460 def output_setup(configuration, setup):
461     "Write a setup (fingerprint) file"
462     outputs = sorted(configuration.keys())
463     for output in outputs:
464         if configuration[output].edid:
465             print(output, configuration[output].edid, file=setup)
466
467 def save_configuration(profile_path, configuration):
468     "Save a configuration into a profile"
469     if not os.path.isdir(profile_path):
470         os.makedirs(profile_path)
471     with open(os.path.join(profile_path, "config"), "w") as config:
472         output_configuration(configuration, config)
473     with open(os.path.join(profile_path, "setup"), "w") as setup:
474         output_setup(configuration, setup)
475
476 def update_mtime(filename):
477     "Update a file's mtime"
478     try:
479         os.utime(filename, None)
480         return True
481     except:
482         return False
483
484 def apply_configuration(new_configuration, current_configuration, dry_run=False):
485     "Apply a configuration"
486     outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
487     if dry_run:
488         base_argv = [ "echo", "xrandr" ]
489     else:
490         base_argv = [ "xrandr" ]
491
492     # There are several xrandr / driver bugs we need to take care of here:
493     # - We cannot enable more than two screens at the same time
494     #   See https://github.com/phillipberndt/autorandr/pull/6
495     #   and commits f4cce4d and 8429886.
496     # - We cannot disable all screens
497     #   See https://github.com/phillipberndt/autorandr/pull/20
498     # - We should disable screens before enabling others, because there's
499     #   a limit on the number of enabled screens
500     # - We must make sure that the screen at 0x0 is activated first,
501     #   or the other (first) screen to be activated would be moved there.
502     # - If an active screen already has a transformation and remains active,
503     #   the xrandr call fails with an invalid RRSetScreenSize parameter error.
504     #   Update the configuration in 3 passes in that case.  (On Haswell graphics,
505     #   at least.)
506
507     auxiliary_changes_pre = []
508     disable_outputs = []
509     enable_outputs = []
510     remain_active_count = 0
511     for output in outputs:
512         if not new_configuration[output].edid or "off" in new_configuration[output].options:
513             disable_outputs.append(new_configuration[output].option_vector)
514         else:
515             if "off" not in current_configuration[output].options:
516                 remain_active_count += 1
517             enable_outputs.append(new_configuration[output].option_vector)
518             if xrandr_version() >= Version("1.3.0") and "transform" in current_configuration[output].options:
519                 auxiliary_changes_pre.append(["--output", output, "--transform", "none"])
520
521     # Perform pe-change auxiliary changes
522     if auxiliary_changes_pre:
523         argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
524         if subprocess.call(argv) != 0:
525             raise AutorandrException("Command failed: %s" % " ".join(argv))
526
527     # Disable unused outputs, but make sure that there always is at least one active screen
528     disable_keep = 0 if remain_active_count else 1
529     if len(disable_outputs) > disable_keep:
530         if subprocess.call(base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))) != 0:
531             # Disabling the outputs failed. Retry with the next command:
532             # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
533             # This does not occur if simultaneously the primary screen is reset.
534             pass
535         else:
536             disable_outputs = disable_outputs[-1:] if disable_keep else []
537
538     # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
539     # disable the last two screens. This is a problem, so if this would happen, instead disable only
540     # one screen in the first call below.
541     if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
542         # In the context of a xrandr call that changes the display state, `--query' should do nothing
543         disable_outputs.insert(0, ['--query'])
544
545     # Enable the remaining outputs in pairs of two operations
546     operations = disable_outputs + enable_outputs
547     for index in range(0, len(operations), 2):
548         argv = base_argv + list(chain.from_iterable(operations[index:index+2]))
549         if subprocess.call(argv) != 0:
550             raise AutorandrException("Command failed: %s" % " ".join(argv))
551
552 def add_unused_outputs(source_configuration, target_configuration):
553     "Add outputs that are missing in target to target, in 'off' state"
554     for output_name, output in source_configuration.items():
555         if output_name not in target_configuration:
556             target_configuration[output_name] = XrandrOutput(output_name, output.edid, { "off": None })
557
558 def remove_irrelevant_outputs(source_configuration, target_configuration):
559     "Remove outputs from target that ought to be 'off' and already are"
560     for output_name, output in source_configuration.items():
561         if "off" in output.options and output_name in target_configuration and "off" in target_configuration[output_name].options:
562             del target_configuration[output_name]
563
564 def generate_virtual_profile(configuration, modes, profile_name):
565     "Generate one of the virtual profiles"
566     configuration = copy.deepcopy(configuration)
567     if profile_name == "common":
568         common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output )) for output in modes.values() ]
569         common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
570         common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
571         if common_resolution:
572             for output in configuration:
573                 configuration[output].options = {}
574                 if output in modes:
575                     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]
576                     configuration[output].options["pos"] = "0x0"
577                 else:
578                     configuration[output].options["off"] = None
579     elif profile_name in ("horizontal", "vertical"):
580         shift = 0
581         if profile_name == "horizontal":
582             shift_index = "width"
583             pos_specifier = "%sx0"
584         else:
585             shift_index = "height"
586             pos_specifier = "0x%s"
587
588         for output in configuration:
589             configuration[output].options = {}
590             if output in modes:
591                 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
592                 configuration[output].options["mode"] = mode["name"]
593                 configuration[output].options["rate"] = mode["rate"]
594                 configuration[output].options["pos"] = pos_specifier % shift
595                 shift += int(mode[shift_index])
596             else:
597                 configuration[output].options["off"] = None
598     return configuration
599
600 def exit_help():
601     "Print help and exit"
602     print(help_text)
603     for profile in virtual_profiles:
604         print("  %-10s %s" % profile[:2])
605     sys.exit(0)
606
607 def exec_scripts(profile_path, script_name):
608     "Run userscripts"
609     for script in (os.path.join(profile_path, script_name), os.path.join(os.path.dirname(profile_path), script_name)):
610         if os.access(script, os.X_OK | os.F_OK):
611             subprocess.call(script)
612
613 def main(argv):
614     try:
615        options = dict(getopt.getopt(argv[1:], "s:l:d:cfh", [ "dry-run", "change", "default=", "save=", "load=", "force", "fingerprint", "config", "help" ])[0])
616     except getopt.GetoptError as e:
617         print("Failed to parse options: {0}.\n"
618               "Use --help to get usage information.".format(str(e)),
619               file=sys.stderr)
620         sys.exit(posix.EX_USAGE)
621
622     profiles = {}
623     try:
624         # Load profiles from each XDG config directory
625         for directory in os.environ.get("XDG_CONFIG_DIRS", "").split(":"):
626             system_profile_path = os.path.join(directory, "autorandr")
627             if os.path.isdir(system_profile_path):
628                 profiles.update(load_profiles(system_profile_path))
629         # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
630         # profile_path is also used later on to store configurations
631         profile_path = os.path.expanduser("~/.autorandr")
632         if not os.path.isdir(profile_path):
633             # Elsewise, follow the XDG specification
634             profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
635         if os.path.isdir(profile_path):
636             profiles.update(load_profiles(profile_path))
637         # Sort by descending mtime
638         profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
639     except Exception as e:
640         raise AutorandrException("Failed to load profiles", e)
641
642     config, modes = parse_xrandr_output()
643
644     if "--fingerprint" in options:
645         output_setup(config, sys.stdout)
646         sys.exit(0)
647
648     if "--config" in options:
649         output_configuration(config, sys.stdout)
650         sys.exit(0)
651
652     if "-s" in options:
653         options["--save"] = options["-s"]
654     if "--save" in options:
655         if options["--save"] in ( x[0] for x in virtual_profiles ):
656             raise AutorandrException("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
657         try:
658             save_configuration(os.path.join(profile_path, options["--save"]), config)
659         except Exception as e:
660             raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
661         print("Saved current configuration as profile '%s'" % options["--save"])
662         sys.exit(0)
663
664     if "-h" in options or "--help" in options:
665         exit_help()
666
667     detected_profiles = find_profiles(config, profiles)
668     load_profile = False
669
670     if "-l" in options:
671         options["--load"] = options["-l"]
672     if "--load" in options:
673         load_profile = options["--load"]
674     else:
675         for profile_name in profiles.keys():
676             if profile_blocked(os.path.join(profile_path, profile_name)):
677                 print("%s (blocked)" % profile_name, file=sys.stderr)
678                 continue
679             if profile_name in detected_profiles:
680                 print("%s (detected)" % profile_name, file=sys.stderr)
681                 if ("-c" in options or "--change" in options) and not load_profile:
682                     load_profile = profile_name
683             else:
684                 print(profile_name, file=sys.stderr)
685
686     if "-d" in options:
687         options["--default"] = options["-d"]
688     if not load_profile and "--default" in options:
689         load_profile = options["--default"]
690
691     if load_profile:
692         if load_profile in ( x[0] for x in virtual_profiles ):
693             load_config = generate_virtual_profile(config, modes, load_profile)
694             scripts_path = os.path.join(profile_path, load_profile)
695         else:
696             try:
697                 profile = profiles[load_profile]
698                 load_config = profile["config"]
699                 scripts_path = profile["path"]
700             except KeyError:
701                 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
702             if load_profile in detected_profiles and detected_profiles[0] != load_profile:
703                 update_mtime(os.path.join(scripts_path, "config"))
704         add_unused_outputs(config, load_config)
705         if load_config == dict(config) and not "-f" in options and not "--force" in options:
706             print("Config already loaded", file=sys.stderr)
707             sys.exit(0)
708         remove_irrelevant_outputs(config, load_config)
709
710         try:
711             if "--dry-run" in options:
712                 apply_configuration(load_config, config, True)
713             else:
714                 exec_scripts(scripts_path, "preswitch")
715                 apply_configuration(load_config, config, False)
716                 exec_scripts(scripts_path, "postswitch")
717         except Exception as e:
718             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
719
720     sys.exit(0)
721
722 if __name__ == '__main__':
723     try:
724         main(sys.argv)
725     except AutorandrException as e:
726         print(e, file=sys.stderr)
727         sys.exit(1)
728     except Exception as e:
729         if not len(str(e)):  # BdbQuit
730             print("Exception: {0}".format(e.__class__.__name__))
731             sys.exit(2)
732
733         print("Unhandled exception ({0}). Please report this as a bug.".format(e), file=sys.stderr)
734         raise