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