e161fcc74521c291a86363c642da57c9f6872b9e
[lilass.git] / screen.py
1 #!/usr/bin/python3
2 # DSL - easy Display Setup for Laptops
3 # Copyright (C) 2012-2015 Ralf Jung <post@ralfj.de>
4 #
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program; if not, write to the Free Software
17 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18
19 import re, subprocess
20 from enum import Enum
21 from functools import total_ordering
22
23 ## utility functions
24
25 # execute a process, return output as iterator, throw exception if there was an error
26 # you *must* iterate to the end if you use this!
27 def processOutputGen(*args):
28     with subprocess.Popen(args, stdout=subprocess.PIPE) as p:
29         for line in p.stdout:
30             yield line.decode("utf-8")
31     if p.returncode != 0:
32         raise Exception("Error executing "+str(args))
33 def processOutputIt(*args):
34     return list(processOutputGen(*args)) # list() iterates over the generator
35
36 ## the classes
37
38 class RelativeScreenPosition(Enum):
39     '''Represents the relative position of the external screen to the internal one'''
40     LEFT      = ("left of")
41     RIGHT     = ("right of")
42     ABOVE     = ("above")
43     BELOW     = ("below")
44     MIRROR    = ("same as")
45     def __init__(self, text):
46         # auto numbering
47         cls = self.__class__
48         self._value_ = len(cls.__members__)
49         self.text = text
50
51 class Resolution:
52     '''Represents a resolution of a screen'''
53     def __init__(self, width, height):
54         self.width = width
55         self.height = height
56     
57     def __eq__(self, other):
58         if not isinstance(other, Resolution):
59             return False
60         return self.width == other.width and self.height == other.height
61     
62     def __ne__(self, other):
63         return not self.__eq__(other)
64     
65     def __hash__(self):
66         return hash(("Resolution",self.width,self.height))
67     
68     def __str__(self):
69         # get ratio
70         ratio = int(round(16.0*self.height/self.width))
71         if ratio == 12: # 16:12 = 4:3
72             strRatio = '4:3'
73         elif ratio == 13: # 16:12.8 = 5:4
74             strRatio = '5:4'
75         else: # let's just hope this will never be 14 or more...
76             strRatio = '16:%d' % ratio
77         return '%dx%d (%s)' %(self.width, self.height, strRatio)
78     
79     def __repr__(self):
80         return 'screen.Resolution('+self.forXrandr()+')'
81     
82     def pixelCount(self):
83         return self.width * self.height
84     
85     def forXrandr(self):
86         return str(self.width)+'x'+str(self.height)
87
88
89 class ScreenSetup:
90     '''Represents a screen configuration (relative to some notion of an "internal" and an "external" screen): Which screens are enabled with which resolution, how
91        are they positioned, which is the primary screen.'''
92     def __init__(self, intResolution, extResolution, relPosition = None, extIsPrimary = True):
93         '''The resolutions can be None to disable the screen, instances of Resolution. The last two arguments only matter if both screens are enabled.'''
94         assert intResolution is None or isinstance(intResolution, Resolution)
95         assert extResolution is None or isinstance(extResolution, Resolution)
96         
97         self.intResolution = intResolution
98         self.extResolution = extResolution
99         self.relPosition = relPosition
100         self.extIsPrimary = extIsPrimary or self.intResolution is None # external is always primary if it is the only one
101     
102     def getInternalArgs(self):
103         if self.intResolution is None:
104             return ["--off"]
105         args = ["--mode", self.intResolution.forXrandr()] # set internal screen to desired resolution
106         if not self.extIsPrimary:
107             args.append('--primary')
108         return args
109     
110     def getExternalArgs(self, intName):
111         if self.extResolution is None:
112             return ["--off"]
113         args = ["--mode", self.extResolution.forXrandr()] # set external screen to desired resolution
114         if self.extIsPrimary:
115             args.append('--primary')
116         if self.intResolution is None:
117             return args
118         # set position
119         args += [{
120                 RelativeScreenPosition.LEFT  : '--left-of',
121                 RelativeScreenPosition.RIGHT : '--right-of',
122                 RelativeScreenPosition.ABOVE : '--above',
123                 RelativeScreenPosition.BELOW : '--below',
124                 RelativeScreenPosition.MIRROR: '--same-as',
125             }[self.relPosition], intName]
126         return args
127
128 class Connector:
129     def __init__(self, name=None):
130         self.name = name         # connector name, e.g. "HDMI1"
131         self.edid = None         # EDID string for the connector, or None if disconnected
132         self.resolutions = set() # list of Resolution objects, empty if disconnected
133     
134     def __str__(self):
135         return str(self.name)
136     
137     def __repr__(self):
138         return """<Connector "%s" EDID="%s" resolutions="%s">""" % (str(self.name), str(self.edid), ", ".join(str(r) for r in self.resolutions))
139     
140     def isConnected(self):
141         assert (self.edid is None) == (len(self.resolutions)==0)
142         return self.edid is not None
143     
144     def addResolution(self, resolution):
145         assert isinstance(resolution, Resolution)
146         self.resolutions.add(resolution)
147     
148     def appendToEdid(self, s):
149         if self.edid is None:
150             self.edid = s
151         else:
152             self.edid += s
153
154 class ScreenSituation:
155     connectors = [] # contains all the Connector objects
156     internalConnector = None # the internal Connector object (will be an enabled one)
157     externalConnector = None # the used external Connector object (an enabled one), or None
158     
159     '''Represents the "screen situation" a machine can be in: Which connectors exist, which resolutions do they have, what are the names for the internal and external screen'''
160     def __init__(self, internalConnectorNames, externalConnectorNames = None):
161         '''Both arguments are lists of connector names. The first one which exists and has a screen attached is chosen for that class. <externalConnectorNames> can be None to
162            just choose any remaining connector.'''
163         # which connectors are there?
164         self._getXrandrInformation()
165         for c in self.connectors:
166             print(repr(c))
167             print()
168         # figure out which is the internal connector
169         self.internalConnector = self._findAvailableConnector(internalConnectorNames)
170         if self.internalConnector is None:
171             raise Exception("Could not automatically find internal connector, please use (or fix) ~/.dsl.conf to specify it manually.")
172         print("Detected internal connector:",self.internalConnector)
173         # and the external one
174         if externalConnectorNames is None:
175             externalConnectorNames = map(lambda c: c.name, self.connectors)
176             externalConnectorNames = set(filter(lambda name: name != self.internalConnector.name, externalConnectorNames))
177         self.externalConnector = self._findAvailableConnector(externalConnectorNames)
178         if self.internalConnector == self.externalConnector:
179             raise Exception("Internal and external connector are the same. This must not happen. Please fix ~/.dsl.conf.");
180         print("Detected external connector:",self.externalConnector)
181     
182     # Run xrandr and fill the dict of connector names mapped to lists of available resolutions.
183     def _getXrandrInformation(self):
184         connector = None # current connector
185         readingEdid = False
186         for line in processOutputGen("xrandr", "-q", "--verbose"):
187             if readingEdid:
188                 m = re.match(r'^\s*([0-9a-f]+)\s*$', line)
189                 if m is not None:
190                     connector.appendToEdid(m.group(1))
191                     continue
192                 else:
193                     readingEdid = False
194                     # fallthrough to the rest of the loop for parsing of this line
195             # screen?
196             m = re.search(r'^Screen [0-9]+: ', line)
197             if m is not None: # ignore this line
198                 connector = None
199                 continue
200             # new connector?
201             m = re.search(r'^([\w\-]+) (dis)?connected ', line)
202             if m is not None:
203                 connector = Connector(m.group(1))
204                 assert not any(c.name == connector.name for c in self.connectors)
205                 self.connectors.append(connector)
206                 continue
207             # new resolution?
208             m = re.search(r'^\s*([\d]+)x([\d]+)', line)
209             if m is not None:
210                 resolution = Resolution(int(m.group(1)), int(m.group(2)))
211                 assert connector is not None
212                 connector.addResolution(resolution)
213                 continue
214             # EDID?
215             m = re.search(r'^\s*EDID:\s*$', line)
216             if m is not None:
217                 readingEdid = True
218                 continue
219             # unknown line
220             # not fatal, e.g. xrandr shows strange stuff when a display is enabled, but not connected
221             #print("Warning: Unknown xrandr line %s" % line)
222     
223     # return the first available connector from those listed in <tryConnectorNames>, skipping disabled connectors
224     def _findAvailableConnector(self, tryConnectorNames):
225         for c in filter(lambda c: c.name in tryConnectorNames and c.isConnected(), self.connectors):
226             return c
227         return None
228     
229     # return available internal resolutions
230     def internalResolutions(self):
231         return self.internalConnector.resolutions
232     
233     # return available external resolutions (or None, if there is no external screen connected)
234     def externalResolutions(self):
235         if self.externalConnector is None:
236             return None
237         return self.externalConnector.resolutions
238     
239     # return resolutions available for both internal and external screen
240     def commonResolutions(self):
241         internalRes = self.internalResolutions()
242         externalRes = self.externalResolutions()
243         assert externalRes is not None
244         return sorted(set(res for res in externalRes if res in internalRes), key=lambda r: -r.pixelCount())
245     
246     # compute the xrandr call
247     def forXrandr(self, setup):
248         # turn all screens off
249         connectorArgs = {} # maps connector names to xrand arguments
250         for c in self.connectors:
251             connectorArgs[c.name] = ["--off"]
252         # set arguments for the relevant ones
253         connectorArgs[self.internalConnector.name] = setup.getInternalArgs()
254         if self.externalConnector is not None:
255             connectorArgs[self.externalConnector.name] = setup.getExternalArgs(self.internalConnector.name)
256         else:
257             assert setup.extResolution is None, "There's no external screen to set a resolution for"
258         # now compose the arguments
259         call = ["xrandr"]
260         for name in connectorArgs:
261             call += ["--output", name] + connectorArgs[name]
262         return call
263