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