2 # DSL - easy Display Setup for Laptops
3 # Copyright (C) 2012-2015 Ralf Jung <post@ralfj.de>
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.
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.
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.
21 from functools import total_ordering
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:
30 yield line.decode("utf-8")
32 raise Exception("Error executing "+str(args))
33 def processOutputIt(*args):
34 return list(processOutputGen(*args)) # list() iterates over the generator
38 class RelativeScreenPosition(Enum):
39 '''Represents the relative position of the external screen to the internal one'''
45 def __init__(self, text):
48 self._value_ = len(cls.__members__)
52 '''Represents a resolution of a screen'''
53 def __init__(self, width, height):
57 def __eq__(self, other):
58 if not isinstance(other, Resolution):
60 return self.width == other.width and self.height == other.height
62 def __ne__(self, other):
63 return not self.__eq__(other)
66 return hash(("Resolution",self.width,self.height))
70 ratio = int(round(16.0*self.height/self.width))
71 if ratio == 12: # 16:12 = 4:3
73 elif ratio == 13: # 16:12.8 = 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)
80 return 'screen.Resolution('+self.forXrandr()+')'
83 return self.width * self.height
86 return str(self.width)+'x'+str(self.height)
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)
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
102 def getInternalArgs(self):
103 if self.intResolution is None:
105 args = ["--mode", self.intResolution.forXrandr()] # set internal screen to desired resolution
106 if not self.extIsPrimary:
107 args.append('--primary')
110 def getExternalArgs(self, intName):
111 if self.extResolution is None:
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:
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]
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
135 return str(self.name)
138 return """<Connector "%s" EDID="%s" resolutions="%s">""" % (str(self.name), str(self.edid), ", ".join(str(r) for r in self.resolutions))
140 def isConnected(self):
141 assert (self.edid is None) == (len(self.resolutions)==0)
142 return self.edid is not None
144 def addResolution(self, resolution):
145 assert isinstance(resolution, Resolution)
146 self.resolutions.add(resolution)
148 def appendToEdid(self, s):
149 if self.edid is None:
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
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:
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)
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
186 for line in processOutputGen("xrandr", "-q", "--verbose"):
188 m = re.match(r'^\s*([0-9a-f]+)\s*$', line)
190 connector.appendToEdid(m.group(1))
194 # fallthrough to the rest of the loop for parsing of this line
196 m = re.search(r'^Screen [0-9]+: ', line)
197 if m is not None: # ignore this line
201 m = re.search(r'^([\w\-]+) (dis)?connected ', line)
203 connector = Connector(m.group(1))
204 assert not any(c.name == connector.name for c in self.connectors)
205 self.connectors.append(connector)
208 m = re.search(r'^\s*([\d]+)x([\d]+)', line)
210 resolution = Resolution(int(m.group(1)), int(m.group(2)))
211 assert connector is not None
212 connector.addResolution(resolution)
215 m = re.search(r'^\s*EDID:\s*$', 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)
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):
229 # return available internal resolutions
230 def internalResolutions(self):
231 return self.internalConnector.resolutions
233 # return available external resolutions (or None, if there is no external screen connected)
234 def externalResolutions(self):
235 if self.externalConnector is None:
237 return self.externalConnector.resolutions
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())
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)
257 assert setup.extResolution is None, "There's no external screen to set a resolution for"
258 # now compose the arguments
260 for name in connectorArgs:
261 call += ["--output", name] + connectorArgs[name]