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