1# ARandR -- Another XRandR GUI
2# Copyright (C) 2008 -- 2011 chrysn <chrysn@fsfe.org>
3#
4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation, either version 3 of the License, or
7# (at your option) any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16"""Wrapper around command line xrandr (mostly 1.2 per output features supported)"""
17# pylint: disable=too-few-public-methods,wrong-import-position,missing-docstring,fixme
18
19import os
20import subprocess
21import warnings
22from functools import reduce
23
24from .auxiliary import (
25    BetterList, Size, Position, Geometry, FileLoadError, FileSyntaxError,
26    InadequateConfiguration, Rotation, ROTATIONS, NORMAL, NamedSize,
27)
28from .i18n import _
29
30SHELLSHEBANG = '#!/bin/sh'
31
32
33class Feature:
34    PRIMARY = 1
35
36
37class XRandR:
38    DEFAULTTEMPLATE = [SHELLSHEBANG, '%(xrandr)s']
39
40    configuration = None
41    state = None
42
43    def __init__(self, display=None, force_version=False):
44        """Create proxy object and check for xrandr at `display`. Fail with
45        untested versions unless `force_version` is True."""
46        self.environ = dict(os.environ)
47        if display:
48            self.environ['DISPLAY'] = display
49
50        version_output = self._output("--version")
51        supported_versions = ["1.2", "1.3", "1.4", "1.5"]
52        if not any(x in version_output for x in supported_versions) and not force_version:
53            raise Exception("XRandR %s required." %
54                            "/".join(supported_versions))
55
56        self.features = set()
57        if " 1.2" not in version_output:
58            self.features.add(Feature.PRIMARY)
59
60    def _get_outputs(self):
61        assert self.state.outputs.keys() == self.configuration.outputs.keys()
62        return self.state.outputs.keys()
63    outputs = property(_get_outputs)
64
65    #################### calling xrandr ####################
66
67    def _output(self, *args):
68        proc = subprocess.Popen(
69            ("xrandr",) + args,
70            stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=self.environ
71        )
72        ret, err = proc.communicate()
73        status = proc.wait()
74        if status != 0:
75            raise Exception("XRandR returned error code %d: %s" %
76                            (status, err))
77        if err:
78            warnings.warn(
79                "XRandR wrote to stderr, but did not report an error (Message was: %r)" % err)
80        return ret.decode('utf-8')
81
82    def _run(self, *args):
83        self._output(*args)
84
85    #################### loading ####################
86
87    def load_from_string(self, data):
88        data = data.replace("%", "%%")
89        lines = data.split("\n")
90        if lines[-1] == '':
91            lines.pop()  # don't create empty last line
92
93        if lines[0] != SHELLSHEBANG:
94            raise FileLoadError('Not a shell script.')
95
96        xrandrlines = [i for i, l in enumerate(
97            lines) if l.strip().startswith('xrandr ')]
98        if not xrandrlines:
99            raise FileLoadError('No recognized xrandr command in this shell script.')
100        if len(xrandrlines) > 1:
101            raise FileLoadError('More than one xrandr line in this shell script.')
102        self._load_from_commandlineargs(lines[xrandrlines[0]].strip())
103        lines[xrandrlines[0]] = '%(xrandr)s'
104
105        return lines
106
107    def _load_from_commandlineargs(self, commandline):
108        self.load_from_x()
109
110        args = BetterList(commandline.split(" "))
111        if args.pop(0) != 'xrandr':
112            raise FileSyntaxError()
113        # first part is empty, exclude empty parts
114        options = dict((a[0], a[1:]) for a in args.split('--output') if a)
115
116        for output_name, output_argument in options.items():
117            output = self.configuration.outputs[output_name]
118            output_state = self.state.outputs[output_name]
119            output.primary = False
120            if output_argument == ['--off']:
121                output.active = False
122            else:
123                if '--primary' in output_argument:
124                    if Feature.PRIMARY in self.features:
125                        output.primary = True
126                    output_argument.remove('--primary')
127                if len(output_argument) % 2 != 0:
128                    raise FileSyntaxError()
129                parts = [
130                    (output_argument[2 * i], output_argument[2 * i + 1])
131                    for i in range(len(output_argument) // 2)
132                ]
133                for part in parts:
134                    if part[0] == '--mode':
135                        for namedmode in output_state.modes:
136                            if namedmode.name == part[1]:
137                                output.mode = namedmode
138                                break
139                        else:
140                            raise FileLoadError("Not a known mode: %s" % part[1])
141                    elif part[0] == '--pos':
142                        output.position = Position(part[1])
143                    elif part[0] == '--rotate':
144                        if part[1] not in ROTATIONS:
145                            raise FileSyntaxError()
146                        output.rotation = Rotation(part[1])
147                    else:
148                        raise FileSyntaxError()
149                output.active = True
150
151    def load_from_x(self):  # FIXME -- use a library
152        self.configuration = self.Configuration(self)
153        self.state = self.State()
154
155        screenline, items = self._load_raw_lines()
156
157        self._load_parse_screenline(screenline)
158
159        for headline, details in items:
160            if headline.startswith("  "):
161                continue  # a currently disconnected part of the screen i can't currently get any info out of
162            if headline == "":
163                continue  # noise
164
165            headline = headline.replace(
166                'unknown connection', 'unknown-connection')
167            hsplit = headline.split(" ")
168            output = self.state.Output(hsplit[0])
169            assert hsplit[1] in (
170                "connected", "disconnected", 'unknown-connection')
171
172            output.connected = (hsplit[1] in ('connected', 'unknown-connection'))
173
174            primary = False
175            if 'primary' in hsplit:
176                if Feature.PRIMARY in self.features:
177                    primary = True
178                hsplit.remove('primary')
179
180            if not hsplit[2].startswith("("):
181                active = True
182
183                geometry = Geometry(hsplit[2])
184
185                # modeid = hsplit[3].strip("()")
186
187                if hsplit[4] in ROTATIONS:
188                    current_rotation = Rotation(hsplit[4])
189                else:
190                    current_rotation = NORMAL
191            else:
192                active = False
193                geometry = None
194                # modeid = None
195                current_rotation = None
196
197            output.rotations = set()
198            for rotation in ROTATIONS:
199                if rotation in headline:
200                    output.rotations.add(rotation)
201
202            currentname = None
203            for detail, w, h in details:
204                name, _mode_raw = detail[0:2]
205                mode_id = _mode_raw.strip("()")
206                try:
207                    size = Size([int(w), int(h)])
208                except ValueError:
209                    raise Exception(
210                        "Output %s parse error: modename %s modeid %s." % (output.name, name, mode_id)
211                    )
212                if "*current" in detail:
213                    currentname = name
214                for x in ["+preferred", "*current"]:
215                    if x in detail:
216                        detail.remove(x)
217
218                for old_mode in output.modes:
219                    if old_mode.name == name:
220                        if tuple(old_mode) != tuple(size):
221                            warnings.warn((
222                                "Supressing duplicate mode %s even "
223                                "though it has different resolutions (%s, %s)."
224                            ) % (name, size, old_mode))
225                        break
226                else:
227                    # the mode is really new
228                    output.modes.append(NamedSize(size, name=name))
229
230            self.state.outputs[output.name] = output
231            self.configuration.outputs[output.name] = self.configuration.OutputConfiguration(
232                active, primary, geometry, current_rotation, currentname
233            )
234
235    def _load_raw_lines(self):
236        output = self._output("--verbose")
237        items = []
238        screenline = None
239        for line in output.split('\n'):
240            if line.startswith("Screen "):
241                assert screenline is None
242                screenline = line
243            elif line.startswith('\t'):
244                continue
245            elif line.startswith(2 * ' '):  # [mode, width, height]
246                line = line.strip()
247                if reduce(bool.__or__, [line.startswith(x + ':') for x in "hv"]):
248                    line = line[-len(line):line.index(" start") - len(line)]
249                    items[-1][1][-1].append(line[line.rindex(' '):])
250                else:  # mode
251                    items[-1][1].append([line.split()])
252            else:
253                items.append([line, []])
254        return screenline, items
255
256    def _load_parse_screenline(self, screenline):
257        assert screenline is not None
258        ssplit = screenline.split(" ")
259
260        ssplit_expect = ["Screen", None, "minimum", None, "x", None,
261                         "current", None, "x", None, "maximum", None, "x", None]
262        assert all(a == b for (a, b) in zip(
263            ssplit, ssplit_expect) if b is not None)
264
265        self.state.virtual = self.state.Virtual(
266            min_mode=Size((int(ssplit[3]), int(ssplit[5][:-1]))),
267            max_mode=Size((int(ssplit[11]), int(ssplit[13])))
268        )
269        self.configuration.virtual = Size(
270            (int(ssplit[7]), int(ssplit[9][:-1]))
271        )
272
273    #################### saving ####################
274
275    def save_to_shellscript_string(self, template=None, additional=None):
276        """
277        Return a shellscript that will set the current configuration.
278        Output can be parsed by load_from_string.
279
280        You may specify a template, which must contain a %(xrandr)s parameter
281        and optionally others, which will be filled from the additional dictionary.
282        """
283        if not template:
284            template = self.DEFAULTTEMPLATE
285        template = '\n'.join(template) + '\n'
286
287        data = {
288            'xrandr': "xrandr " + " ".join(self.configuration.commandlineargs())
289        }
290        if additional:
291            data.update(additional)
292
293        return template % data
294
295    def save_to_x(self):
296        self.check_configuration()
297        self._run(*self.configuration.commandlineargs())
298
299    def check_configuration(self):
300        vmax = self.state.virtual.max
301
302        for output_name in self.outputs:
303            output_config = self.configuration.outputs[output_name]
304            # output_state = self.state.outputs[output_name]
305
306            if not output_config.active:
307                continue
308
309            # we trust users to know what they are doing
310            # (e.g. widget: will accept current mode,
311            # but not offer to change it lacking knowledge of alternatives)
312            #
313            # if output_config.rotation not in output_state.rotations:
314            #    raise InadequateConfiguration("Rotation not allowed.")
315            # if output_config.mode not in output_state.modes:
316            #    raise InadequateConfiguration("Mode not allowed.")
317
318            x = output_config.position[0] + output_config.size[0]
319            y = output_config.position[1] + output_config.size[1]
320
321            if x > vmax[0] or y > vmax[1]:
322                raise InadequateConfiguration(
323                    _("A part of an output is outside the virtual screen."))
324
325            if output_config.position[0] < 0 or output_config.position[1] < 0:
326                raise InadequateConfiguration(
327                    _("An output is outside the virtual screen."))
328
329    #################### sub objects ####################
330
331    class State:
332        """Represents everything that can not be set by xrandr."""
333
334        virtual = None
335
336        def __init__(self):
337            self.outputs = {}
338
339        def __repr__(self):
340            return '<%s for %d Outputs, %d connected>' % (
341                type(self).__name__, len(self.outputs),
342                len([x for x in self.outputs.values() if x.connected])
343            )
344
345        class Virtual:
346            def __init__(self, min_mode, max_mode):
347                self.min = min_mode
348                self.max = max_mode
349
350        class Output:
351            rotations = None
352            connected = None
353
354            def __init__(self, name):
355                self.name = name
356                self.modes = []
357
358            def __repr__(self):
359                return '<%s %r (%d modes)>' % (type(self).__name__, self.name, len(self.modes))
360
361    class Configuration:
362        """
363        Represents everything that can be set by xrandr
364        (and is therefore subject to saving and loading from files)
365        """
366
367        virtual = None
368
369        def __init__(self, xrandr):
370            self.outputs = {}
371            self._xrandr = xrandr
372
373        def __repr__(self):
374            return '<%s for %d Outputs, %d active>' % (
375                type(self).__name__, len(self.outputs),
376                len([x for x in self.outputs.values() if x.active])
377            )
378
379        def commandlineargs(self):
380            args = []
381            for output_name, output in self.outputs.items():
382                args.append("--output")
383                args.append(output_name)
384                if not output.active:
385                    args.append("--off")
386                else:
387                    if Feature.PRIMARY in self._xrandr.features:
388                        if output.primary:
389                            args.append("--primary")
390                    args.append("--mode")
391                    args.append(str(output.mode.name))
392                    args.append("--pos")
393                    args.append(str(output.position))
394                    args.append("--rotate")
395                    args.append(output.rotation)
396            return args
397
398        class OutputConfiguration:
399
400            def __init__(self, active, primary, geometry, rotation, modename):
401                self.active = active
402                self.primary = primary
403                if active:
404                    self.position = geometry.position
405                    self.rotation = rotation
406                    if rotation.is_odd:
407                        self.mode = NamedSize(
408                            Size(reversed(geometry.size)), name=modename)
409                    else:
410                        self.mode = NamedSize(geometry.size, name=modename)
411
412            size = property(lambda self: NamedSize(
413                Size(reversed(self.mode)), name=self.mode.name
414            ) if self.rotation.is_odd else self.mode)
415