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