import copy
import getopt
import hashlib
+import math
import os
import posix
import pwd
from functools import reduce
from itertools import chain
-try:
- from packaging.version import Version
-except ModuleNotFoundError:
- from distutils.version import LooseVersion as Version
if sys.version_info.major == 2:
import ConfigParser as configparser
else:
import configparser
-__version__ = "1.12.1"
+__version__ = "1.13.3"
try:
input = raw_input
("clone-largest", "Clone all connected outputs with the largest resolution (scaled down if necessary)", None),
("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
("vertical", "Stack all connected outputs vertically at their largest resolution", None),
+ ("horizontal-reverse", "Stack all connected outputs horizontally at their largest resolution in reverse order", None),
+ ("vertical-reverse", "Stack all connected outputs vertically at their largest resolution in reverse order", None),
]
properties = [
--dry-run don't change anything, only print the xrandr commands
--fingerprint fingerprint your current hardware setup
--ignore-lid treat outputs as connected even if their lids are closed
---match-edid match diplays based on edid instead of name
+--match-edid match displays based on edid instead of name
--force force (re)loading of a profile / overwrite exiting files
--list list configurations
--skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
""".strip()
+class Version(object):
+ def __init__(self, version):
+ self._version = version
+ self._version_parts = re.split("([0-9]+)", version)
+
+ def __eq__(self, other):
+ return self._version_parts == other._version_parts
+
+ def __lt__(self, other):
+ for my, theirs in zip(self._version_parts, other._version_parts):
+ if my.isnumeric() and theirs.isnumeric():
+ my = int(my)
+ theirs = int(theirs)
+ if my < theirs:
+ return True
+ return len(theirs) > len(my)
+
+ def __ge__(self, other):
+ return not (self < other)
+
+ def __ne__(self, other):
+ return not (self == other)
+
+ def __le__(self, other):
+ return (self < other) or (self == other)
+
+ def __gt__(self, other):
+ return self >= other and not (self == other)
+
def is_closed_lid(output):
if not re.match(r'(eDP(-?[0-9]\+)*|LVDS(-?[0-9]\+)*)', output):
return False
Gamma: (?P<gamma>(?:inf|-?[0-9\.\-: e])+) | # Gamma value
CRTC:\s*(?P<crtc>[0-9]) | # CRTC value
Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) | # Transformation matrix
+ filter:\s+(?P<filter>bilinear|nearest) | # Transformation filter
EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) | # EDID of the output
""" + XRANDR_PROPERTIES_REGEXP + """ | # Properties to include in the profile
(?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)* # Other properties
if xrandr_version() >= Version("1.2"):
options.update(self.XRANDR_12_DEFAULTS)
options.update(self.options)
+ if "set" in self.ignored_options:
+ options = {a: b for a, b in options.items() if not a.startswith("x-prop")}
return {a: b for a, b in options.items() if a not in self.ignored_options}
@property
def filtered_options(self):
"Return a dictionary of options without ignored options"
- return {a: b for a, b in self.options.items() if a not in self.ignored_options}
+ options = {a: b for a, b in self.options.items() if a not in self.ignored_options}
+ if "set" in self.ignored_options:
+ options = {a: b for a, b in options.items() if not a.startswith("x-prop")}
+ return options
@property
def option_vector(self):
return x + 10000 * y
def __init__(self, output, edid, options):
- "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
+ "Instantiate using output name, edid and a dictionary of XRandR command line parameters"
self.output = output
self.edid = edid
self.options = options
def parse_serial_from_edid(self):
self.serial = None
if self.edid:
+ if self.EDID_UNAVAILABLE in self.edid:
+ return
+ if "*" in self.edid:
+ return
# Thx to pyedid project, the following code was
# copied (and modified) from pyedid/__init__py:21 [parse_edid()]
raw = bytes.fromhex(self.edid)
self.ignored_options = list(options)
def remove_default_option_values(self):
- "Remove values from the options dictionary that are superflous"
+ "Remove values from the options dictionary that are superfluous"
if "off" in self.options and len(self.options.keys()) > 1:
self.options = {"off": None}
return
@classmethod
def from_xrandr_output(cls, xrandr_output):
- """Instanciate an XrandrOutput from the output of `xrandr --verbose'
+ """Instantiate an XrandrOutput from the output of `xrandr --verbose'
This method also returns a list of modes supported by the output.
"""
# I doubt that this special case is actually required.
print("Warning: Output %s has a transformation applied. Could not determine correct mode! "
"Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
+ if match["filter"]:
+ options["filter"] = match["filter"]
if match["gamma"]:
gamma = match["gamma"].strip()
# xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
return XrandrOutput(match["output"], edid, options), modes
@classmethod
- def from_config_file(cls, edid_map, configuration):
- "Instanciate an XrandrOutput from the contents of a configuration file"
+ def from_config_file(cls, profile, edid_map, configuration):
+ "Instantiate an XrandrOutput from the contents of a configuration file"
options = {}
for line in configuration.split("\n"):
if line:
if fuzzy_output in fuzzy_edid_map:
edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
elif "off" not in options:
- 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"]))
+ raise AutorandrException("Profile `%s': Failed to find an EDID for output `%s' in setup file, required "
+ "as `%s' is not off in config file." % (profile, options["output"], options["output"]))
output = options["output"]
del options["output"]
# We are not interested in screens
xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
- # Split at output boundaries and instanciate an XrandrOutput per output
+ # Split at output boundaries and instantiate an XrandrOutput per output
split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
if len(split_xrandr_output) < 2:
raise AutorandrException("No output boundaries found", report_bug=True)
buffer = []
for line in chain(open(config_name).readlines(), ["output"]):
if line[:6] == "output" and buffer:
- config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
+ config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(profile, edids, "".join(buffer))
buffer = [line]
else:
buffer.append(line)
o_height = int(detail.get("h")) + int(detail.get("y"))
width = max(width, o_width)
height = max(height, o_height)
- return int(width), int(height)
+ return math.ceil(width), math.ceil(height)
def apply_configuration(new_configuration, current_configuration, dry_run=False):
fb_dimensions = get_fb_dimensions(new_configuration)
try:
- base_argv += ["--fb", "%dx%d" % fb_dimensions]
+ fb_args = ["--fb", "%dx%d" % fb_dimensions]
except:
# Failed to obtain frame-buffer size. Doesn't matter, xrandr will choose for the user.
- pass
+ fb_args = []
auxiliary_changes_pre = []
disable_outputs = []
if auxiliary_changes_pre:
argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
if call_and_retry(argv, dry_run=dry_run) != 0:
- raise AutorandrException("Command failed: %s" % " ".join(argv))
+ raise AutorandrException("Command failed: %s" % " ".join(map(shlex.quote, argv)))
+
+ # Starting here, fix the frame buffer size
+ # Do not do this earlier, as disabling scaling might temporarily make the framebuffer
+ # dimensions larger than they will finally be.
+ base_argv += fb_args
# Disable unused outputs, but make sure that there always is at least one active screen
disable_keep = 0 if remain_active_count else 1
# If we did not find a candidate, we might need to inject a call
# If there is no output to disable, we will enable 0x and x0 at the same time
if not found_top_left_monitor and len(disable_outputs) > 0:
- # If the call to 0x and x0 is splitted, inject one of them
+ # If the call to 0x and x0 is split, inject one of them
if found_top_monitor and found_left_monitor:
enable_outputs.insert(0, enable_outputs[0])
for index in range(0, len(operations), 2):
argv = base_argv + list(chain.from_iterable(operations[index:index + 2]))
if call_and_retry(argv, dry_run=dry_run) != 0:
- raise AutorandrException("Command failed: %s" % " ".join(argv))
+ raise AutorandrException("Command failed: %s" % " ".join(map(shlex.quote, argv)))
+
+ # Adjust the frame buffer to match (see #319)
+ if fb_args:
+ argv = base_argv
+ if call_and_retry(argv, dry_run=dry_run) != 0:
+ raise AutorandrException("Command failed: %s" % " ".join(map(shlex.quote, argv)))
+
def is_equal_configuration(source_configuration, target_configuration):
configuration[output].options["pos"] = "0x0"
else:
configuration[output].options["off"] = None
- elif profile_name in ("horizontal", "vertical"):
+ elif profile_name in ("horizontal", "vertical", "horizontal-reverse", "vertical-reverse"):
shift = 0
- if profile_name == "horizontal":
+ if profile_name.startswith("horizontal"):
shift_index = "width"
pos_specifier = "%sx0"
else:
shift_index = "height"
pos_specifier = "0x%s"
-
- for output in configuration:
+
+ config_iter = reversed(configuration) if "reverse" in profile_name else iter(configuration)
+
+ for output in config_iter:
configuration[output].options = {}
if output in modes and configuration[output].edid:
def key(a):
if os.access(script, os.X_OK | os.F_OK):
try:
all_ok &= subprocess.call(script, env=env) != 0
- except:
- raise AutorandrException("Failed to execute user command: %s" % (script,))
+ except Exception as e:
+ raise AutorandrException("Failed to execute user command: %s. Error: %s" % (script, str(e)))
ran_scripts.add(script_name)
script_folder = os.path.join(folder, "%s.d" % script_name)
if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
- for file_name in os.listdir(script_folder):
+ for file_name in sorted(os.listdir(script_folder)):
check_name = "d/%s" % (file_name,)
if check_name not in ran_scripts:
script = os.path.join(script_folder, file_name)
if os.access(script, os.X_OK | os.F_OK):
try:
all_ok &= subprocess.call(script, env=env) != 0
- except:
- raise AutorandrException("Failed to execute user command: %s" % (script,))
+ except Exception as e:
+ raise AutorandrException("Failed to execute user command: %s. Error: %s" % (script, str(e)))
ran_scripts.add(check_name)
return all_ok
new_config, _ = parse_xrandr_output(
ignore_lid=ignore_lid,
)
+ if "--skip-options" in options:
+ for output in new_config.values():
+ output.set_ignored_options(skip_options)
if not is_equal_configuration(new_config, load_config):
print("The configuration change did not go as expected:")
print_profile_differences(new_config, load_config)