]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
Fixed --gamma value handling
[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  ")
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>(?:inf|[0-9\.: e])+) |                                     # 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_name>\S+).+?\*current.*\s+                                         # Interesting (current) resolution: Extract rate
140              h:\s+width\s+(?P<mode_width>[0-9]+).+\s+
141              v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
142             \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s*                                     # Other resolutions
143         )*)
144     """
145
146     XRANDR_OUTPUT_MODES_REGEXP = """(?x)
147         (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
148          h:\s+width\s+(?P<width>[0-9]+).+\s+
149          v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
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"]) if x.group("name") ]
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_name"]:
259                 options["mode"] = match["mode_name"]
260             elif match["mode_width"]:
261                 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
262             else:
263                 if match["rotate"] not in ("left", "right"):
264                     options["mode"] = "%sx%s" % (match["width"], match["height"])
265                 else:
266                     options["mode"] = "%sx%s" % (match["height"], match["width"])
267             options["rotate"] = match["rotate"]
268             if match["primary"]:
269                 options["primary"] = None
270             if match["reflect"] == "X":
271                 options["reflect"] = "x"
272             elif match["reflect"] == "Y":
273                 options["reflect"] = "y"
274             elif match["reflect"] == "X and Y":
275                 options["reflect"] = "xy"
276             options["pos"] = "%sx%s" % (match["x"], match["y"])
277             if match["panning"]:
278                 panning = [ match["panning"] ]
279                 if match["tracking"]:
280                     panning += [ "/", match["tracking"] ]
281                     if match["border"]:
282                         panning += [ "/", match["border"] ]
283                 options["panning"] = "".join(panning)
284             if match["transform"]:
285                 transformation = ",".join(match["transform"].strip().split())
286                 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
287                     options["transform"] = transformation
288                     if not match["mode_name"]:
289                         # TODO We'd need to apply the reverse transformation here. Let's see if someone complains, I doubt that this
290                         # special case is actually required.
291                         print("Warning: Output %s has a transformation applied. Could not determine correct mode! Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
292             if match["gamma"]:
293                 gamma = match["gamma"].strip()
294                 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
295                 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
296                 # so we approximate by 1e-10.
297                 gamma = ":".join([ str(max(1e-10, round(1./float(x), 3))) for x in gamma.split(":") ])
298                 options["gamma"] = gamma
299             if match["rate"]:
300                 options["rate"] = match["rate"]
301
302         return XrandrOutput(match["output"], edid, options), modes
303
304     @classmethod
305     def from_config_file(cls, edid_map, configuration):
306         "Instanciate an XrandrOutput from the contents of a configuration file"
307         options = {}
308         for line in configuration.split("\n"):
309             if line:
310                 line = line.split(None, 1)
311                 options[line[0]] = line[1] if len(line) > 1 else None
312
313         edid = None
314
315         if options["output"] in edid_map:
316             edid = edid_map[options["output"]]
317         else:
318             # This fuzzy matching is for legacy autorandr that used sysfs output names
319             fuzzy_edid_map = [ re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys() ]
320             fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
321             if fuzzy_output in fuzzy_edid_map:
322                 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
323             elif "off" not in options:
324                 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"]))
325         output = options["output"]
326         del options["output"]
327
328         return XrandrOutput(output, edid, options)
329
330     def edid_equals(self, other):
331         "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
332         if self.edid and other.edid:
333             if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
334                 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
335             if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
336                 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
337         return self.edid == other.edid
338
339     def __eq__(self, other):
340         return self.edid_equals(other) and self.output == other.output and self.options == other.options
341
342 def xrandr_version():
343     "Return the version of XRandR that this system uses"
344     if getattr(xrandr_version, "version", False) is False:
345         version_string = os.popen("xrandr -v").read()
346         try:
347             version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
348             xrandr_version.version = Version(version)
349         except AttributeError:
350             xrandr_version.version = Version("1.3.0")
351
352     return xrandr_version.version
353
354 def debug_regexp(pattern, string):
355     "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
356     try:
357         import regex
358         bounds = ( 0, len(string) )
359         while bounds[0] != bounds[1]:
360             half = int((bounds[0] + bounds[1]) / 2)
361             if half == bounds[0]:
362                 break
363             bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
364         partial_length = bounds[0]
365         return ("Regular expression matched until position "
366               "%d, ..'%s', and did not match from '%s'.." % (partial_length, string[max(0, partial_length-20):partial_length],
367                                                              string[partial_length:partial_length+10]))
368     except ImportError:
369         pass
370     return "Debug information would be available if the `regex' module was installed."
371
372 def parse_xrandr_output():
373     "Parse the output of `xrandr --verbose' into a list of outputs"
374     xrandr_output = os.popen("xrandr -q --verbose").read()
375     if not xrandr_output:
376         raise AutorandrException("Failed to run xrandr")
377
378     # We are not interested in screens
379     xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
380
381     # Split at output boundaries and instanciate an XrandrOutput per output
382     split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
383     if len(split_xrandr_output) < 2:
384         raise AutorandrException("No output boundaries found", report_bug=True)
385     outputs = OrderedDict()
386     modes = OrderedDict()
387     for i in range(1, len(split_xrandr_output), 2):
388         output_name = split_xrandr_output[i].split()[0]
389         output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i+2]))
390         outputs[output_name] = output
391         if output_modes:
392             modes[output_name] = output_modes
393
394     return outputs, modes
395
396 def load_profiles(profile_path):
397     "Load the stored profiles"
398
399     profiles = {}
400     for profile in os.listdir(profile_path):
401         config_name = os.path.join(profile_path, profile, "config")
402         setup_name  = os.path.join(profile_path, profile, "setup")
403         if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
404             continue
405
406         edids = dict([ x.strip().split() for x in open(setup_name).readlines() if x.strip() ])
407
408         config = {}
409         buffer = []
410         for line in chain(open(config_name).readlines(), ["output"]):
411             if line[:6] == "output" and buffer:
412                 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
413                 buffer = [ line ]
414             else:
415                 buffer.append(line)
416
417         for output_name in list(config.keys()):
418             if config[output_name].edid is None:
419                 del config[output_name]
420
421         profiles[profile] = { "config": config, "path": os.path.join(profile_path, profile), "config-mtime": os.stat(config_name).st_mtime }
422
423     return profiles
424
425 def find_profiles(current_config, profiles):
426     "Find profiles matching the currently connected outputs"
427     detected_profiles = []
428     for profile_name, profile in profiles.items():
429         config = profile["config"]
430         matches = True
431         for name, output in config.items():
432             if not output.edid:
433                 continue
434             if name not in current_config or not output.edid_equals(current_config[name]):
435                 matches = False
436                 break
437         if not matches or any(( name not in config.keys() for name in current_config.keys() if current_config[name].edid )):
438             continue
439         if matches:
440             detected_profiles.append(profile_name)
441     return detected_profiles
442
443 def profile_blocked(profile_path):
444     "Check if a profile is blocked"
445     script = os.path.join(profile_path, "block")
446     if not os.access(script, os.X_OK | os.F_OK):
447         return False
448     return subprocess.call(script) == 0
449
450 def output_configuration(configuration, config):
451     "Write a configuration file"
452     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
453     for output in outputs:
454         print(configuration[output].option_string, file=config)
455
456 def output_setup(configuration, setup):
457     "Write a setup (fingerprint) file"
458     outputs = sorted(configuration.keys())
459     for output in outputs:
460         if configuration[output].edid:
461             print(output, configuration[output].edid, file=setup)
462
463 def save_configuration(profile_path, configuration):
464     "Save a configuration into a profile"
465     if not os.path.isdir(profile_path):
466         os.makedirs(profile_path)
467     with open(os.path.join(profile_path, "config"), "w") as config:
468         output_configuration(configuration, config)
469     with open(os.path.join(profile_path, "setup"), "w") as setup:
470         output_setup(configuration, setup)
471
472 def update_mtime(filename):
473     "Update a file's mtime"
474     try:
475         os.utime(filename, None)
476         return True
477     except:
478         return False
479
480 def apply_configuration(new_configuration, current_configuration, dry_run=False):
481     "Apply a configuration"
482     outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
483     if dry_run:
484         base_argv = [ "echo", "xrandr" ]
485     else:
486         base_argv = [ "xrandr" ]
487
488     # There are several xrandr / driver bugs we need to take care of here:
489     # - We cannot enable more than two screens at the same time
490     #   See https://github.com/phillipberndt/autorandr/pull/6
491     #   and commits f4cce4d and 8429886.
492     # - We cannot disable all screens
493     #   See https://github.com/phillipberndt/autorandr/pull/20
494     # - We should disable screens before enabling others, because there's
495     #   a limit on the number of enabled screens
496     # - We must make sure that the screen at 0x0 is activated first,
497     #   or the other (first) screen to be activated would be moved there.
498     # - If an active screen already has a transformation and remains active,
499     #   the xrandr call fails with an invalid RRSetScreenSize parameter error.
500     #   Update the configuration in 3 passes in that case.  (On Haswell graphics,
501     #   at least.)
502
503     auxiliary_changes_pre = []
504     disable_outputs = []
505     enable_outputs = []
506     remain_active_count = 0
507     for output in outputs:
508         if not new_configuration[output].edid or "off" in new_configuration[output].options:
509             disable_outputs.append(new_configuration[output].option_vector)
510         else:
511             if "off" not in current_configuration[output].options:
512                 remain_active_count += 1
513             enable_outputs.append(new_configuration[output].option_vector)
514             if xrandr_version() >= Version("1.3.0") and "transform" in current_configuration[output].options:
515                 auxiliary_changes_pre.append(["--output", output, "--transform", "none"])
516
517     # Perform pe-change auxiliary changes
518     if auxiliary_changes_pre:
519         argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
520         if subprocess.call(argv) != 0:
521             raise AutorandrException("Command failed: %s" % " ".join(argv))
522
523     # Disable unused outputs, but make sure that there always is at least one active screen
524     disable_keep = 0 if remain_active_count else 1
525     if len(disable_outputs) > disable_keep:
526         if subprocess.call(base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))) != 0:
527             # Disabling the outputs failed. Retry with the next command:
528             # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
529             # This does not occur if simultaneously the primary screen is reset.
530             pass
531         else:
532             disable_outputs = disable_outputs[-1:] if disable_keep else []
533
534     # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
535     # disable the last two screens. This is a problem, so if this would happen, instead disable only
536     # one screen in the first call below.
537     if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
538         # In the context of a xrandr call that changes the display state, `--query' should do nothing
539         disable_outputs.insert(0, ['--query'])
540
541     # Enable the remaining outputs in pairs of two operations
542     operations = disable_outputs + enable_outputs
543     for index in range(0, len(operations), 2):
544         argv = base_argv + list(chain.from_iterable(operations[index:index+2]))
545         if subprocess.call(argv) != 0:
546             raise AutorandrException("Command failed: %s" % " ".join(argv))
547
548 def add_unused_outputs(source_configuration, target_configuration):
549     "Add outputs that are missing in target to target, in 'off' state"
550     for output_name, output in source_configuration.items():
551         if output_name not in target_configuration:
552             target_configuration[output_name] = XrandrOutput(output_name, output.edid, { "off": None })
553
554 def remove_irrelevant_outputs(source_configuration, target_configuration):
555     "Remove outputs from target that ought to be 'off' and already are"
556     for output_name, output in source_configuration.items():
557         if "off" in output.options and output_name in target_configuration and "off" in target_configuration[output_name].options:
558             del target_configuration[output_name]
559
560 def generate_virtual_profile(configuration, modes, profile_name):
561     "Generate one of the virtual profiles"
562     configuration = copy.deepcopy(configuration)
563     if profile_name == "common":
564         common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output )) for output in modes.values() ]
565         common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
566         common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
567         if common_resolution:
568             for output in configuration:
569                 configuration[output].options = {}
570                 if output in modes:
571                     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]
572                     configuration[output].options["pos"] = "0x0"
573                 else:
574                     configuration[output].options["off"] = None
575     elif profile_name in ("horizontal", "vertical"):
576         shift = 0
577         if profile_name == "horizontal":
578             shift_index = "width"
579             pos_specifier = "%sx0"
580         else:
581             shift_index = "height"
582             pos_specifier = "0x%s"
583
584         for output in configuration:
585             configuration[output].options = {}
586             if output in modes:
587                 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
588                 configuration[output].options["mode"] = mode["name"]
589                 configuration[output].options["rate"] = mode["rate"]
590                 configuration[output].options["pos"] = pos_specifier % shift
591                 shift += int(mode[shift_index])
592             else:
593                 configuration[output].options["off"] = None
594     return configuration
595
596 def exit_help():
597     "Print help and exit"
598     print(help_text)
599     for profile in virtual_profiles:
600         print("  %-10s %s" % profile[:2])
601     sys.exit(0)
602
603 def exec_scripts(profile_path, script_name):
604     "Run userscripts"
605     for script in (os.path.join(profile_path, script_name), os.path.join(os.path.dirname(profile_path), script_name)):
606         if os.access(script, os.X_OK | os.F_OK):
607             subprocess.call(script)
608
609 def main(argv):
610     try:
611        options = dict(getopt.getopt(argv[1:], "s:l:d:cfh", [ "dry-run", "change", "default=", "save=", "load=", "force", "fingerprint", "config", "help" ])[0])
612     except getopt.GetoptError as e:
613         print(str(e))
614         options = { "--help": True }
615
616     profiles = {}
617     try:
618         # Load profiles from each XDG config directory
619         for directory in os.environ.get("XDG_CONFIG_DIRS", "").split(":"):
620             system_profile_path = os.path.join(directory, "autorandr")
621             if os.path.isdir(system_profile_path):
622                 profiles.update(load_profiles(system_profile_path))
623         # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
624         # profile_path is also used later on to store configurations
625         profile_path = os.path.expanduser("~/.autorandr")
626         if not os.path.isdir(profile_path):
627             # Elsewise, follow the XDG specification
628             profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
629         if os.path.isdir(profile_path):
630             profiles.update(load_profiles(profile_path))
631         # Sort by descending mtime
632         profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
633     except Exception as e:
634         raise AutorandrException("Failed to load profiles", e)
635
636     config, modes = parse_xrandr_output()
637
638     if "--fingerprint" in options:
639         output_setup(config, sys.stdout)
640         sys.exit(0)
641
642     if "--config" in options:
643         output_configuration(config, sys.stdout)
644         sys.exit(0)
645
646     if "-s" in options:
647         options["--save"] = options["-s"]
648     if "--save" in options:
649         if options["--save"] in ( x[0] for x in virtual_profiles ):
650             raise AutorandrException("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
651         try:
652             save_configuration(os.path.join(profile_path, options["--save"]), config)
653         except Exception as e:
654             raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
655         print("Saved current configuration as profile '%s'" % options["--save"])
656         sys.exit(0)
657
658     if "-h" in options or "--help" in options:
659         exit_help()
660
661     detected_profiles = find_profiles(config, profiles)
662     load_profile = False
663
664     if "-l" in options:
665         options["--load"] = options["-l"]
666     if "--load" in options:
667         load_profile = options["--load"]
668     else:
669         for profile_name in profiles.keys():
670             if profile_blocked(os.path.join(profile_path, profile_name)):
671                 print("%s (blocked)" % profile_name, file=sys.stderr)
672                 continue
673             if profile_name in detected_profiles:
674                 print("%s (detected)" % profile_name, file=sys.stderr)
675                 if ("-c" in options or "--change" in options) and not load_profile:
676                     load_profile = profile_name
677             else:
678                 print(profile_name, file=sys.stderr)
679
680     if "-d" in options:
681         options["--default"] = options["-d"]
682     if not load_profile and "--default" in options:
683         load_profile = options["--default"]
684
685     if load_profile:
686         if load_profile in ( x[0] for x in virtual_profiles ):
687             load_config = generate_virtual_profile(config, modes, load_profile)
688             scripts_path = os.path.join(profile_path, load_profile)
689         else:
690             try:
691                 profile = profiles[load_profile]
692                 load_config = profile["config"]
693                 scripts_path = profile["path"]
694             except KeyError:
695                 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
696             if load_profile in detected_profiles and detected_profiles[0] != load_profile:
697                 update_mtime(os.path.join(scripts_path, "config"))
698         add_unused_outputs(config, load_config)
699         if load_config == dict(config) and not "-f" in options and not "--force" in options:
700             print("Config already loaded", file=sys.stderr)
701             sys.exit(0)
702         remove_irrelevant_outputs(config, load_config)
703
704         try:
705             if "--dry-run" in options:
706                 apply_configuration(load_config, config, True)
707             else:
708                 exec_scripts(scripts_path, "preswitch")
709                 apply_configuration(load_config, config, False)
710                 exec_scripts(scripts_path, "postswitch")
711         except Exception as e:
712             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
713
714     sys.exit(0)
715
716 if __name__ == '__main__':
717     try:
718         main(sys.argv)
719     except AutorandrException as e:
720         print(e, file=sys.stderr)
721         sys.exit(1)
722     except Exception as e:
723         if not len(str(e)):  # BdbQuit
724             print("Exception: {0}".format(e.__class__.__name__))
725             sys.exit(2)
726
727         print("Unhandled exception ({0}). Please report this as a bug.".format(e), file=sys.stderr)
728         raise