b096fb120382832979595c6f81d1bf9984f3d46d
[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
22 ## utility functions
23
24 # execute a process, return output as iterator, throw exception if there was an error
25 # you *must* iterate to the end if you use this!
26 def processOutputGen(*args):
27     with subprocess.Popen(args, stdout=subprocess.PIPE) as p:
28         for line in p.stdout:
29             yield line.decode("utf-8")
30     if p.returncode != 0:
31         raise Exception("Error executing "+str(args))
32 def processOutputIt(*args):
33     return list(processOutputGen(*args)) # list() iterates over the generator
34
35 ## the classes
36
37 class RelativeScreenPosition(Enum):
38     '''Represents the relative position of the external screen to the internal one'''
39     LEFT      = ("left of")
40     RIGHT     = ("right of")
41     ABOVE     = ("above")
42     BELOW     = ("below")
43     MIRROR    = ("same as")
44     def __init__(self, text):
45         # auto numbering
46         cls = self.__class__
47         self._value_ = len(cls.__members__)
48         self.text = text
49
50 class Resolution:
51     '''Represents a resolution of a screen'''
52     def __init__(self, width, height):
53         self.width = width
54         self.height = height
55     
56     def __eq__(self, other):
57         if not isinstance(other, Resolution):
58             return False
59         return self.width == other.width and self.height == other.height
60     
61     def __ne__(self, other):
62         return not self.__eq__(other)
63     
64     def __hash__(self):
65         return hash(("Resolution",self.width,self.height))
66     
67     def __str__(self):
68         # get ratio
69         ratio = int(round(16.0*self.height/self.width))
70         if ratio == 12: # 16:12 = 4:3
71             strRatio = '4:3'
72         elif ratio == 13: # 16:12.8 = 5:4
73             strRatio = '5:4'
74         else: # let's just hope this will never be 14 or more...
75             strRatio = '16:%d' % ratio
76         return '%dx%d (%s)' %(self.width, self.height, strRatio)
77     
78     def __repr__(self):
79         return 'screen.Resolution('+self.forXrandr()+')'
80     
81     def pixelCount(self):
82         return self.width * self.height
83     
84     def forXrandr(self):
85         return str(self.width)+'x'+str(self.height)
86
87
88 class ScreenSetup:
89     '''Represents a screen configuration (relative to some notion of an "internal" and an "external" screen): Which screens are enabled with which resolution, how
90        are they positioned, which is the primary screen.'''
91     def __init__(self, intResolution, extResolution, relPosition = None, extIsPrimary = True):
92         '''The resolutions can be None to disable the screen, instances of Resolution. The last two arguments only matter if both screens are enabled.'''
93         assert intResolution is None or isinstance(intResolution, Resolution)
94         assert extResolution is None or isinstance(extResolution, Resolution)
95         
96         self.intResolution = intResolution
97         self.extResolution = extResolution
98         self.relPosition = relPosition
99         self.extIsPrimary = extIsPrimary or self.intResolution is None # external is always primary if it is the only one
100     
101     def getInternalArgs(self):
102         if self.intResolution is None:
103             return ["--off"]
104         args = ["--mode", self.intResolution.forXrandr()] # set internal screen to desired resolution
105         if not self.extIsPrimary:
106             args.append('--primary')
107         return args
108     
109     def getExternalArgs(self, intName):
110         if self.extResolution is None:
111             return ["--off"]
112         args = ["--mode", self.extResolution.forXrandr()] # set external screen to desired resolution
113         if self.extIsPrimary:
114             args.append('--primary')
115         if self.intResolution is None:
116             return args
117         # set position
118         args += [{
119                 RelativeScreenPosition.LEFT  : '--left-of',
120                 RelativeScreenPosition.RIGHT : '--right-of',
121                 RelativeScreenPosition.ABOVE : '--above',
122                 RelativeScreenPosition.BELOW : '--below',
123                 RelativeScreenPosition.MIRROR: '--same-as',
124             }[self.relPosition], intName]
125         return args
126
127 class Connector:
128     def __init__(self, name=None):
129         self.name = name         # connector name, e.g. "HDMI1"
130         self.edid = None         # EDID string for the connector, or None if disconnected
131         self.resolutions = set() # list of Resolution objects, empty if disconnected
132         self.preferredResolution = None
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     def getPreferredResolution(self):
155         if self.preferredResolution:
156             return self.preferredResolution
157         return max(self.resolutions, key=lambda r: r.pixelCount())
158
159 class ScreenSituation:
160     connectors = [] # contains all the Connector objects
161     internalConnector = None # the internal Connector object (will be an enabled one)
162     externalConnector = None # the used external Connector object (an enabled one), or None
163     
164     '''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'''
165     def __init__(self, internalConnectorNames, externalConnectorNames = None):
166         '''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
167            just choose any remaining connector.'''
168         # which connectors are there?
169         self._getXrandrInformation()
170         for c in self.connectors:
171             print(repr(c))
172             print()
173         # figure out which is the internal connector
174         self.internalConnector = self._findAvailableConnector(internalConnectorNames)
175         if self.internalConnector is None:
176             raise Exception("Could not automatically find internal connector, please use (or fix) ~/.dsl.conf to specify it manually.")
177         print("Detected internal connector:",self.internalConnector)
178         # and the external one
179         if externalConnectorNames is None:
180             externalConnectorNames = map(lambda c: c.name, self.connectors)
181             externalConnectorNames = set(filter(lambda name: name != self.internalConnector.name, externalConnectorNames))
182         self.externalConnector = self._findAvailableConnector(externalConnectorNames)
183         if self.internalConnector == self.externalConnector:
184             raise Exception("Internal and external connector are the same. This must not happen. Please fix ~/.dsl.conf.");
185         print("Detected external connector:",self.externalConnector)
186     
187     # Run xrandr and fill the dict of connector names mapped to lists of available resolutions.
188     def _getXrandrInformation(self):
189         connector = None # current connector
190         readingEdid = False
191         for line in processOutputGen("xrandr", "-q", "--verbose"):
192             if readingEdid:
193                 m = re.match(r'^\s*([0-9a-f]+)\s*$', line)
194                 if m is not None:
195                     connector.appendToEdid(m.group(1))
196                     continue
197                 else:
198                     readingEdid = False
199                     # fallthrough to the rest of the loop for parsing of this line
200             # screen?
201             m = re.search(r'^Screen [0-9]+: ', line)
202             if m is not None: # ignore this line
203                 connector = None
204                 continue
205             # new connector?
206             m = re.search(r'^([\w\-]+) (dis)?connected ', line)
207             if m is not None:
208                 connector = Connector(m.group(1))
209                 assert not any(c.name == connector.name for c in self.connectors)
210                 self.connectors.append(connector)
211                 continue
212             # new resolution?
213             m = re.search(r'^\s*([\d]+)x([\d]+)', line)
214             if m is not None:
215                 resolution = Resolution(int(m.group(1)), int(m.group(2)))
216                 assert connector is not None
217                 connector.addResolution(resolution)
218                 if '+preferred' in line:
219                     connector.preferredResolution = resolution
220                 continue
221             # EDID?
222             m = re.search(r'^\s*EDID:\s*$', line)
223             if m is not None:
224                 readingEdid = True
225                 continue
226             # unknown line
227             # not fatal, e.g. xrandr shows strange stuff when a display is enabled, but not connected
228             #print("Warning: Unknown xrandr line %s" % line)
229     
230     # return the first available connector from those listed in <tryConnectorNames>, skipping disabled connectors
231     def _findAvailableConnector(self, tryConnectorNames):
232         for c in filter(lambda c: c.name in tryConnectorNames and c.isConnected(), self.connectors):
233             return c
234         return None
235     
236     # return available internal resolutions
237     def internalResolutions(self):
238         return self.internalConnector.resolutions
239     
240     # return available external resolutions (or None, if there is no external screen connected)
241     def externalResolutions(self):
242         if self.externalConnector is None:
243             return None
244         return self.externalConnector.resolutions
245     
246     # return resolutions available for both internal and external screen
247     def commonResolutions(self):
248         internalRes = self.internalResolutions()
249         externalRes = self.externalResolutions()
250         assert externalRes is not None
251         return sorted(set(externalRes).intersection(internalRes), key=lambda r: -r.pixelCount())
252     
253     # compute the xrandr call
254     def forXrandr(self, setup):
255         # turn all screens off
256         connectorArgs = {} # maps connector names to xrand arguments
257         for c in self.connectors:
258             connectorArgs[c.name] = ["--off"]
259         # set arguments for the relevant ones
260         connectorArgs[self.internalConnector.name] = setup.getInternalArgs()
261         if self.externalConnector is not None:
262             connectorArgs[self.externalConnector.name] = setup.getExternalArgs(self.internalConnector.name)
263         else:
264             assert setup.extResolution is None, "There's no external screen to set a resolution for"
265         # now compose the arguments
266         call = ["xrandr"]
267         for name in connectorArgs:
268             call += ["--output", name] + connectorArgs[name]
269         return call
270