├── .coveragerc ├── .gitignore ├── .gitmodules ├── .travis.yml ├── LICENSE ├── README.md ├── plasma ├── __init__.py ├── debug.py ├── enum.py ├── layout.py └── node.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── test_debug.py ├── test_layout.py └── test_node.py ├── tools └── make_readme.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = plasma 4 | 5 | [paths] 6 | source = 7 | plasma 8 | .tox/*/lib/python*/site-packages/plasma 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | *$py.class 4 | *.egg-info/ 5 | .coverage 6 | .coverage.* 7 | .cache/ 8 | build/ 9 | dist/ 10 | TODO 11 | .pytest_cache/ 12 | .tox/ 13 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/qtile"] 2 | path = lib/qtile 3 | url = https://github.com/qtile/qtile 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.7" 4 | - "3.8" 5 | - "3.9" 6 | addons: 7 | apt: 8 | packages: 9 | - xserver-xephyr 10 | jobs: 11 | include: 12 | - python: 3.9 13 | env: TOXENV=lint 14 | install: 15 | - pip install tox-travis codecov 16 | script: 17 | - tox 18 | after_success: 19 | - codecov 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 numirias 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Plasma 2 | 3 | [![Build Status](https://travis-ci.org/numirias/qtile-plasma.svg?branch=master)](https://travis-ci.org/numirias/qtile-plasma) 4 | [![codecov](https://codecov.io/gh/numirias/qtile-plasma/branch/master/graph/badge.svg)](https://codecov.io/gh/numirias/qtile-plasma) 5 | [![PyPI Version](https://img.shields.io/pypi/v/qtile-plasma.svg)](https://pypi.python.org/pypi/qtile-plasma) 6 | [![Python Versions](https://img.shields.io/pypi/pyversions/qtile-plasma.svg)](https://pypi.python.org/pypi/qtile-plasma) 7 | 8 | Plasma is a flexible, tree-based layout for [Qtile](https://github.com/qtile/qtile/). 9 | 10 | If you're looking for a well-tested and maintained alternative to Qtile's default layouts, give it a try. 11 | 12 | ## About 13 | 14 | Plasma works on a tree structure. Each node represents a container with child containers aligned either horizontally or vertically (similar to [i3](https://i3wm.org/)). Each window is attached to a leaf, taking either a proportional or a specific custom amount of space in its parent container. Windows can be resized, rearranged and integrated into other containers, enabling lots of different setups. 15 | 16 | ## Demo 17 | 18 | Here is a quick demo showing some of the main features (adding modes, moving, integrating and resizing): 19 | 20 | ![Demo](https://i.imgur.com/N3CMonP.gif) 21 | 22 | ## Installation 23 | 24 | Install the package. You can [get it from PyPI](https://pypi.python.org/pypi/qtile-plasma/): 25 | 26 | ``` 27 | pip install --upgrade qtile-plasma 28 | ``` 29 | 30 | Then, add the layout to your config (`~/.config/qtile/config.py`): 31 | 32 | ```python 33 | from plasma import Plasma 34 | ... 35 | layouts = [ 36 | Plasma( 37 | border_normal='#333333', 38 | border_focus='#00e891', 39 | border_normal_fixed='#006863', 40 | border_focus_fixed='#00e8dc', 41 | border_width=1, 42 | border_width_single=0, 43 | margin=0 44 | ), 45 | ... 46 | ] 47 | ``` 48 | 49 | Add some key bindings, too. I am using these: 50 | 51 | ```python 52 | from libqtile.command import lazy 53 | from libqtile.config import EzKey 54 | ... 55 | keymap = { 56 | 'M-h': lazy.layout.left(), 57 | 'M-j': lazy.layout.down(), 58 | 'M-k': lazy.layout.up(), 59 | 'M-l': lazy.layout.right(), 60 | 'M-S-h': lazy.layout.move_left(), 61 | 'M-S-j': lazy.layout.move_down(), 62 | 'M-S-k': lazy.layout.move_up(), 63 | 'M-S-l': lazy.layout.move_right(), 64 | 'M-A-h': lazy.layout.integrate_left(), 65 | 'M-A-j': lazy.layout.integrate_down(), 66 | 'M-A-k': lazy.layout.integrate_up(), 67 | 'M-A-l': lazy.layout.integrate_right(), 68 | 'M-d': lazy.layout.mode_horizontal(), 69 | 'M-v': lazy.layout.mode_vertical(), 70 | 'M-S-d': lazy.layout.mode_horizontal_split(), 71 | 'M-S-v': lazy.layout.mode_vertical_split(), 72 | 'M-a': lazy.layout.grow_width(30), 73 | 'M-x': lazy.layout.grow_width(-30), 74 | 'M-S-a': lazy.layout.grow_height(30), 75 | 'M-S-x': lazy.layout.grow_height(-30), 76 | 'M-C-5': lazy.layout.size(500), 77 | 'M-C-8': lazy.layout.size(800), 78 | 'M-n': lazy.layout.reset_size(), 79 | } 80 | keys = [EzKey(k, v) for k, v in keymap.items()] 81 | ``` 82 | 83 | Done! 84 | 85 | ## Commands 86 | 87 | The layout exposes the following commands: 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 164 | 165 | 166 | 167 | 169 | 170 | 171 | 172 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 |
next()Focus next window.
previous()Focus previous window.
recent()Focus most recently focused window.
102 | (Toggles between the two latest active windows.)
left()Focus window to the left.
right()Focus window to the right.
up()Focus window above.
down()Focus window below.
move_left()Move current window left.
move_right()Move current window right.
move_up()Move current window up.
move_down()Move current window down.
integrate_left()Integrate current window left.
integrate_right()Integrate current window right.
integrate_up()Integrate current window up.
integrate_down()Integrate current window down.
mode_horizontal()Next window will be added horizontally.
mode_vertical()Next window will be added vertically.
mode_horizontal_split()Next window will be added horizontally, splitting space of current 163 | window.
mode_vertical_split()Next window will be added vertically, splitting space of current 168 | window.
size(x)Change size of current window.
173 | (It's recommended to use width()/height() instead.)
width(x)Set width of current window.
height(x)Set height of current window.
reset_size()Reset size of current window to automatic (relative) sizing.
grow(x)Grow size of current window.
190 | (It's recommended to use grow_width()/grow_height() instead.)
grow_width(x)Grow width of current window.
grow_height(x)Grow height of current window.
201 | 202 | 203 | ## Contributing 204 | 205 | If you have found a bug or want to suggest a feature, please [file an issue](https://github.com/numirias/qtile-plasma/issues/new). 206 | 207 | 208 | To work on Plasma locally, you need to clone submodules too, since the layout integration tests use some of Qtile's test fixtures: 209 | 210 | ``` 211 | git clone --recursive https://github.com/numirias/qtile-plasma/ 212 | ``` 213 | 214 | Also make sure you meet the [hacking requirements of Qtile](http://docs.qtile.org/en/latest/manual/hacking.html). In particular, have `xserver-xephyr` installed. Then run: 215 | 216 | ``` 217 | make init 218 | ``` 219 | 220 | If that fails, run the `init` instructions from the [Makefile](https://github.com/numirias/qtile-plasma/blob/master/Makefile) one by one. 221 | 222 | All new changes need to be fully test-covered and pass the linting: 223 | 224 | ``` 225 | make lint 226 | make test 227 | ``` 228 | 229 | If you made changes to the layout API, also re-build this README's [commands](#commands) section: 230 | 231 | ``` 232 | make readme 233 | ``` 234 | -------------------------------------------------------------------------------- /plasma/__init__.py: -------------------------------------------------------------------------------- 1 | from .layout import Plasma 2 | -------------------------------------------------------------------------------- /plasma/debug.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | 4 | class Canvas: 5 | 6 | horizontal_line = '\u2500' 7 | vertical_line = '\u2502' 8 | tl_corner = '\u250c' 9 | tr_corner = '\u2510' 10 | bl_corner = '\u2514' 11 | br_corner = '\u2518' 12 | 13 | def __init__(self, width, height): 14 | self.width = width 15 | self.height = height 16 | self.canvas = defaultdict(lambda: defaultdict(lambda: '#')) 17 | 18 | def add_box(self, x, y, width, height, name='*'): 19 | width = width-1 20 | height = height-1 21 | label = str(name)[:width-1] 22 | for i in range(x, width+x): 23 | self.canvas[i][y] = self.horizontal_line 24 | self.canvas[i][y+height] = self.horizontal_line 25 | for i in range(y, height+y): 26 | self.canvas[x][i] = self.vertical_line 27 | self.canvas[x+width][i] = self.vertical_line 28 | for i in range(x+1, width+x): 29 | for j in range(y+1, y+height): 30 | self.canvas[i][j] = '.' 31 | for i, char in enumerate(label): 32 | self.canvas[x+1+i][y+1] = char 33 | self.canvas[x][y] = self.tl_corner 34 | self.canvas[x+width][y] = self.tr_corner 35 | self.canvas[x][y+height] = self.bl_corner 36 | self.canvas[x+width][y+height] = self.br_corner 37 | 38 | def view(self): 39 | res = '' 40 | for y in range(self.height): 41 | for x in range(self.width): 42 | res += self.canvas[x][y] 43 | res += '\n' 44 | return res 45 | 46 | def tree(node, level=0): 47 | res = '{indent}{name} {orient} {repr_} {pos} {size} {parent}\n'.format( 48 | indent=level*4*' ', 49 | name='%s' % (node.payload or '*'), 50 | orient='H' if node.horizontal else 'V', 51 | repr_='%s' % repr(node), 52 | pos='%g*%g@%g:%g' % (node.width, node.height, node.x, node.y), 53 | size='size: %s%s' % (node.size, ' (auto)' if node.flexible else ''), 54 | parent='p: %s' % node.parent, 55 | ) 56 | for child in node: 57 | res += tree(child, level+1) 58 | return res 59 | 60 | def draw(root): 61 | canvas = Canvas(root.width, root.height) 62 | def add(node): 63 | if node.is_leaf: 64 | canvas.add_box( 65 | *node.pixel_perfect, 66 | node.payload 67 | ) 68 | for child in node: 69 | add(child) 70 | add(root) 71 | return canvas.view() 72 | 73 | def info(node): 74 | print(tree(node)) 75 | print(draw(node)) 76 | -------------------------------------------------------------------------------- /plasma/enum.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | # pylint: skip-file 3 | # This is an exact copy of lib/enum.py from Python 3.6 to make Flag and auto 4 | # available in Python 3.5 without extra dependencies. 5 | # https://github.com/python/cpython/blob/3.6/Lib/enum.py 6 | 7 | import sys 8 | from types import MappingProxyType, DynamicClassAttribute 9 | from functools import reduce 10 | from operator import or_ as _or_ 11 | 12 | # try _collections first to reduce startup cost 13 | try: 14 | from _collections import OrderedDict 15 | except ImportError: 16 | from collections import OrderedDict 17 | 18 | 19 | __all__ = [ 20 | 'EnumMeta', 21 | 'Enum', 'IntEnum', 'Flag', 'IntFlag', 22 | 'auto', 'unique', 23 | ] 24 | 25 | 26 | def _is_descriptor(obj): 27 | """Returns True if obj is a descriptor, False otherwise.""" 28 | return ( 29 | hasattr(obj, '__get__') or 30 | hasattr(obj, '__set__') or 31 | hasattr(obj, '__delete__')) 32 | 33 | 34 | def _is_dunder(name): 35 | """Returns True if a __dunder__ name, False otherwise.""" 36 | return (name[:2] == name[-2:] == '__' and 37 | name[2:3] != '_' and 38 | name[-3:-2] != '_' and 39 | len(name) > 4) 40 | 41 | 42 | def _is_sunder(name): 43 | """Returns True if a _sunder_ name, False otherwise.""" 44 | return (name[0] == name[-1] == '_' and 45 | name[1:2] != '_' and 46 | name[-2:-1] != '_' and 47 | len(name) > 2) 48 | 49 | def _make_class_unpicklable(cls): 50 | """Make the given class un-picklable.""" 51 | def _break_on_call_reduce(self, proto): 52 | raise TypeError('%r cannot be pickled' % self) 53 | cls.__reduce_ex__ = _break_on_call_reduce 54 | cls.__module__ = '' 55 | 56 | _auto_null = object() 57 | class auto: 58 | """ 59 | Instances are replaced with an appropriate value in Enum class suites. 60 | """ 61 | value = _auto_null 62 | 63 | 64 | class _EnumDict(dict): 65 | """Track enum member order and ensure member names are not reused. 66 | 67 | EnumMeta will use the names found in self._member_names as the 68 | enumeration member names. 69 | 70 | """ 71 | def __init__(self): 72 | super().__init__() 73 | self._member_names = [] 74 | self._last_values = [] 75 | 76 | def __setitem__(self, key, value): 77 | """Changes anything not dundered or not a descriptor. 78 | 79 | If an enum member name is used twice, an error is raised; duplicate 80 | values are not checked for. 81 | 82 | Single underscore (sunder) names are reserved. 83 | 84 | """ 85 | if _is_sunder(key): 86 | if key not in ( 87 | '_order_', '_create_pseudo_member_', 88 | '_generate_next_value_', '_missing_', 89 | ): 90 | raise ValueError('_names_ are reserved for future Enum use') 91 | if key == '_generate_next_value_': 92 | setattr(self, '_generate_next_value', value) 93 | elif _is_dunder(key): 94 | if key == '__order__': 95 | key = '_order_' 96 | elif key in self._member_names: 97 | # descriptor overwriting an enum? 98 | raise TypeError('Attempted to reuse key: %r' % key) 99 | elif not _is_descriptor(value): 100 | if key in self: 101 | # enum overwriting a descriptor? 102 | raise TypeError('%r already defined as: %r' % (key, self[key])) 103 | if isinstance(value, auto): 104 | if value.value == _auto_null: 105 | value.value = self._generate_next_value(key, 1, len(self._member_names), self._last_values[:]) 106 | value = value.value 107 | self._member_names.append(key) 108 | self._last_values.append(value) 109 | super().__setitem__(key, value) 110 | 111 | 112 | # Dummy value for Enum as EnumMeta explicitly checks for it, but of course 113 | # until EnumMeta finishes running the first time the Enum class doesn't exist. 114 | # This is also why there are checks in EnumMeta like `if Enum is not None` 115 | Enum = None 116 | 117 | 118 | class EnumMeta(type): 119 | """Metaclass for Enum""" 120 | @classmethod 121 | def __prepare__(metacls, cls, bases): 122 | # create the namespace dict 123 | enum_dict = _EnumDict() 124 | # inherit previous flags and _generate_next_value_ function 125 | member_type, first_enum = metacls._get_mixins_(bases) 126 | if first_enum is not None: 127 | enum_dict['_generate_next_value_'] = getattr(first_enum, '_generate_next_value_', None) 128 | return enum_dict 129 | 130 | def __new__(metacls, cls, bases, classdict): 131 | # an Enum class is final once enumeration items have been defined; it 132 | # cannot be mixed with other types (int, float, etc.) if it has an 133 | # inherited __new__ unless a new __new__ is defined (or the resulting 134 | # class will fail). 135 | member_type, first_enum = metacls._get_mixins_(bases) 136 | __new__, save_new, use_args = metacls._find_new_(classdict, member_type, 137 | first_enum) 138 | 139 | # save enum items into separate mapping so they don't get baked into 140 | # the new class 141 | enum_members = {k: classdict[k] for k in classdict._member_names} 142 | for name in classdict._member_names: 143 | del classdict[name] 144 | 145 | # adjust the sunders 146 | _order_ = classdict.pop('_order_', None) 147 | 148 | # check for illegal enum names (any others?) 149 | invalid_names = set(enum_members) & {'mro', } 150 | if invalid_names: 151 | raise ValueError('Invalid enum member name: {0}'.format( 152 | ','.join(invalid_names))) 153 | 154 | # create a default docstring if one has not been provided 155 | if '__doc__' not in classdict: 156 | classdict['__doc__'] = 'An enumeration.' 157 | 158 | # create our new Enum type 159 | enum_class = super().__new__(metacls, cls, bases, classdict) 160 | enum_class._member_names_ = [] # names in definition order 161 | enum_class._member_map_ = OrderedDict() # name->value map 162 | enum_class._member_type_ = member_type 163 | 164 | # save attributes from super classes so we know if we can take 165 | # the shortcut of storing members in the class dict 166 | base_attributes = {a for b in enum_class.mro() for a in b.__dict__} 167 | 168 | # Reverse value->name map for hashable values. 169 | enum_class._value2member_map_ = {} 170 | 171 | # If a custom type is mixed into the Enum, and it does not know how 172 | # to pickle itself, pickle.dumps will succeed but pickle.loads will 173 | # fail. Rather than have the error show up later and possibly far 174 | # from the source, sabotage the pickle protocol for this class so 175 | # that pickle.dumps also fails. 176 | # 177 | # However, if the new class implements its own __reduce_ex__, do not 178 | # sabotage -- it's on them to make sure it works correctly. We use 179 | # __reduce_ex__ instead of any of the others as it is preferred by 180 | # pickle over __reduce__, and it handles all pickle protocols. 181 | if '__reduce_ex__' not in classdict: 182 | if member_type is not object: 183 | methods = ('__getnewargs_ex__', '__getnewargs__', 184 | '__reduce_ex__', '__reduce__') 185 | if not any(m in member_type.__dict__ for m in methods): 186 | _make_class_unpicklable(enum_class) 187 | 188 | # instantiate them, checking for duplicates as we go 189 | # we instantiate first instead of checking for duplicates first in case 190 | # a custom __new__ is doing something funky with the values -- such as 191 | # auto-numbering ;) 192 | for member_name in classdict._member_names: 193 | value = enum_members[member_name] 194 | if not isinstance(value, tuple): 195 | args = (value, ) 196 | else: 197 | args = value 198 | if member_type is tuple: # special case for tuple enums 199 | args = (args, ) # wrap it one more time 200 | if not use_args: 201 | enum_member = __new__(enum_class) 202 | if not hasattr(enum_member, '_value_'): 203 | enum_member._value_ = value 204 | else: 205 | enum_member = __new__(enum_class, *args) 206 | if not hasattr(enum_member, '_value_'): 207 | if member_type is object: 208 | enum_member._value_ = value 209 | else: 210 | enum_member._value_ = member_type(*args) 211 | value = enum_member._value_ 212 | enum_member._name_ = member_name 213 | enum_member.__objclass__ = enum_class 214 | enum_member.__init__(*args) 215 | # If another member with the same value was already defined, the 216 | # new member becomes an alias to the existing one. 217 | for name, canonical_member in enum_class._member_map_.items(): 218 | if canonical_member._value_ == enum_member._value_: 219 | enum_member = canonical_member 220 | break 221 | else: 222 | # Aliases don't appear in member names (only in __members__). 223 | enum_class._member_names_.append(member_name) 224 | # performance boost for any member that would not shadow 225 | # a DynamicClassAttribute 226 | if member_name not in base_attributes: 227 | setattr(enum_class, member_name, enum_member) 228 | # now add to _member_map_ 229 | enum_class._member_map_[member_name] = enum_member 230 | try: 231 | # This may fail if value is not hashable. We can't add the value 232 | # to the map, and by-value lookups for this value will be 233 | # linear. 234 | enum_class._value2member_map_[value] = enum_member 235 | except TypeError: 236 | pass 237 | 238 | # double check that repr and friends are not the mixin's or various 239 | # things break (such as pickle) 240 | for name in ('__repr__', '__str__', '__format__', '__reduce_ex__'): 241 | class_method = getattr(enum_class, name) 242 | obj_method = getattr(member_type, name, None) 243 | enum_method = getattr(first_enum, name, None) 244 | if obj_method is not None and obj_method is class_method: 245 | setattr(enum_class, name, enum_method) 246 | 247 | # replace any other __new__ with our own (as long as Enum is not None, 248 | # anyway) -- again, this is to support pickle 249 | if Enum is not None: 250 | # if the user defined their own __new__, save it before it gets 251 | # clobbered in case they subclass later 252 | if save_new: 253 | enum_class.__new_member__ = __new__ 254 | enum_class.__new__ = Enum.__new__ 255 | 256 | # py3 support for definition order (helps keep py2/py3 code in sync) 257 | if _order_ is not None: 258 | if isinstance(_order_, str): 259 | _order_ = _order_.replace(',', ' ').split() 260 | if _order_ != enum_class._member_names_: 261 | raise TypeError('member order does not match _order_') 262 | 263 | return enum_class 264 | 265 | def __bool__(self): 266 | """ 267 | classes/types should always be True. 268 | """ 269 | return True 270 | 271 | def __call__(cls, value, names=None, *, module=None, qualname=None, type=None, start=1): 272 | """Either returns an existing member, or creates a new enum class. 273 | 274 | This method is used both when an enum class is given a value to match 275 | to an enumeration member (i.e. Color(3)) and for the functional API 276 | (i.e. Color = Enum('Color', names='RED GREEN BLUE')). 277 | 278 | When used for the functional API: 279 | 280 | `value` will be the name of the new class. 281 | 282 | `names` should be either a string of white-space/comma delimited names 283 | (values will start at `start`), or an iterator/mapping of name, value pairs. 284 | 285 | `module` should be set to the module this class is being created in; 286 | if it is not set, an attempt to find that module will be made, but if 287 | it fails the class will not be picklable. 288 | 289 | `qualname` should be set to the actual location this class can be found 290 | at in its module; by default it is set to the global scope. If this is 291 | not correct, unpickling will fail in some circumstances. 292 | 293 | `type`, if set, will be mixed in as the first base class. 294 | 295 | """ 296 | if names is None: # simple value lookup 297 | return cls.__new__(cls, value) 298 | # otherwise, functional API: we're creating a new Enum type 299 | return cls._create_(value, names, module=module, qualname=qualname, type=type, start=start) 300 | 301 | def __contains__(cls, member): 302 | return isinstance(member, cls) and member._name_ in cls._member_map_ 303 | 304 | def __delattr__(cls, attr): 305 | # nicer error message when someone tries to delete an attribute 306 | # (see issue19025). 307 | if attr in cls._member_map_: 308 | raise AttributeError( 309 | "%s: cannot delete Enum member." % cls.__name__) 310 | super().__delattr__(attr) 311 | 312 | def __dir__(self): 313 | return (['__class__', '__doc__', '__members__', '__module__'] + 314 | self._member_names_) 315 | 316 | def __getattr__(cls, name): 317 | """Return the enum member matching `name` 318 | 319 | We use __getattr__ instead of descriptors or inserting into the enum 320 | class' __dict__ in order to support `name` and `value` being both 321 | properties for enum members (which live in the class' __dict__) and 322 | enum members themselves. 323 | 324 | """ 325 | if _is_dunder(name): 326 | raise AttributeError(name) 327 | try: 328 | return cls._member_map_[name] 329 | except KeyError: 330 | raise AttributeError(name) from None 331 | 332 | def __getitem__(cls, name): 333 | return cls._member_map_[name] 334 | 335 | def __iter__(cls): 336 | return (cls._member_map_[name] for name in cls._member_names_) 337 | 338 | def __len__(cls): 339 | return len(cls._member_names_) 340 | 341 | @property 342 | def __members__(cls): 343 | """Returns a mapping of member name->value. 344 | 345 | This mapping lists all enum members, including aliases. Note that this 346 | is a read-only view of the internal mapping. 347 | 348 | """ 349 | return MappingProxyType(cls._member_map_) 350 | 351 | def __repr__(cls): 352 | return "" % cls.__name__ 353 | 354 | def __reversed__(cls): 355 | return (cls._member_map_[name] for name in reversed(cls._member_names_)) 356 | 357 | def __setattr__(cls, name, value): 358 | """Block attempts to reassign Enum members. 359 | 360 | A simple assignment to the class namespace only changes one of the 361 | several possible ways to get an Enum member from the Enum class, 362 | resulting in an inconsistent Enumeration. 363 | 364 | """ 365 | member_map = cls.__dict__.get('_member_map_', {}) 366 | if name in member_map: 367 | raise AttributeError('Cannot reassign members.') 368 | super().__setattr__(name, value) 369 | 370 | def _create_(cls, class_name, names=None, *, module=None, qualname=None, type=None, start=1): 371 | """Convenience method to create a new Enum class. 372 | 373 | `names` can be: 374 | 375 | * A string containing member names, separated either with spaces or 376 | commas. Values are incremented by 1 from `start`. 377 | * An iterable of member names. Values are incremented by 1 from `start`. 378 | * An iterable of (member name, value) pairs. 379 | * A mapping of member name -> value pairs. 380 | 381 | """ 382 | metacls = cls.__class__ 383 | bases = (cls, ) if type is None else (type, cls) 384 | _, first_enum = cls._get_mixins_(bases) 385 | classdict = metacls.__prepare__(class_name, bases) 386 | 387 | # special processing needed for names? 388 | if isinstance(names, str): 389 | names = names.replace(',', ' ').split() 390 | if isinstance(names, (tuple, list)) and names and isinstance(names[0], str): 391 | original_names, names = names, [] 392 | last_values = [] 393 | for count, name in enumerate(original_names): 394 | value = first_enum._generate_next_value_(name, start, count, last_values[:]) 395 | last_values.append(value) 396 | names.append((name, value)) 397 | 398 | # Here, names is either an iterable of (name, value) or a mapping. 399 | for item in names: 400 | if isinstance(item, str): 401 | member_name, member_value = item, names[item] 402 | else: 403 | member_name, member_value = item 404 | classdict[member_name] = member_value 405 | enum_class = metacls.__new__(metacls, class_name, bases, classdict) 406 | 407 | # TODO: replace the frame hack if a blessed way to know the calling 408 | # module is ever developed 409 | if module is None: 410 | try: 411 | module = sys._getframe(2).f_globals['__name__'] 412 | except (AttributeError, ValueError) as exc: 413 | pass 414 | if module is None: 415 | _make_class_unpicklable(enum_class) 416 | else: 417 | enum_class.__module__ = module 418 | if qualname is not None: 419 | enum_class.__qualname__ = qualname 420 | 421 | return enum_class 422 | 423 | @staticmethod 424 | def _get_mixins_(bases): 425 | """Returns the type for creating enum members, and the first inherited 426 | enum class. 427 | 428 | bases: the tuple of bases that was given to __new__ 429 | 430 | """ 431 | if not bases: 432 | return object, Enum 433 | 434 | # double check that we are not subclassing a class with existing 435 | # enumeration members; while we're at it, see if any other data 436 | # type has been mixed in so we can use the correct __new__ 437 | member_type = first_enum = None 438 | for base in bases: 439 | if (base is not Enum and 440 | issubclass(base, Enum) and 441 | base._member_names_): 442 | raise TypeError("Cannot extend enumerations") 443 | # base is now the last base in bases 444 | if not issubclass(base, Enum): 445 | raise TypeError("new enumerations must be created as " 446 | "`ClassName([mixin_type,] enum_type)`") 447 | 448 | # get correct mix-in type (either mix-in type of Enum subclass, or 449 | # first base if last base is Enum) 450 | if not issubclass(bases[0], Enum): 451 | member_type = bases[0] # first data type 452 | first_enum = bases[-1] # enum type 453 | else: 454 | for base in bases[0].__mro__: 455 | # most common: (IntEnum, int, Enum, object) 456 | # possible: (, , 457 | # , , 458 | # ) 459 | if issubclass(base, Enum): 460 | if first_enum is None: 461 | first_enum = base 462 | else: 463 | if member_type is None: 464 | member_type = base 465 | 466 | return member_type, first_enum 467 | 468 | @staticmethod 469 | def _find_new_(classdict, member_type, first_enum): 470 | """Returns the __new__ to be used for creating the enum members. 471 | 472 | classdict: the class dictionary given to __new__ 473 | member_type: the data type whose __new__ will be used by default 474 | first_enum: enumeration to check for an overriding __new__ 475 | 476 | """ 477 | # now find the correct __new__, checking to see of one was defined 478 | # by the user; also check earlier enum classes in case a __new__ was 479 | # saved as __new_member__ 480 | __new__ = classdict.get('__new__', None) 481 | 482 | # should __new__ be saved as __new_member__ later? 483 | save_new = __new__ is not None 484 | 485 | if __new__ is None: 486 | # check all possibles for __new_member__ before falling back to 487 | # __new__ 488 | for method in ('__new_member__', '__new__'): 489 | for possible in (member_type, first_enum): 490 | target = getattr(possible, method, None) 491 | if target not in { 492 | None, 493 | None.__new__, 494 | object.__new__, 495 | Enum.__new__, 496 | }: 497 | __new__ = target 498 | break 499 | if __new__ is not None: 500 | break 501 | else: 502 | __new__ = object.__new__ 503 | 504 | # if a non-object.__new__ is used then whatever value/tuple was 505 | # assigned to the enum member name will be passed to __new__ and to the 506 | # new enum member's __init__ 507 | if __new__ is object.__new__: 508 | use_args = False 509 | else: 510 | use_args = True 511 | 512 | return __new__, save_new, use_args 513 | 514 | 515 | class Enum(metaclass=EnumMeta): 516 | """Generic enumeration. 517 | 518 | Derive from this class to define new enumerations. 519 | 520 | """ 521 | def __new__(cls, value): 522 | # all enum instances are actually created during class construction 523 | # without calling this method; this method is called by the metaclass' 524 | # __call__ (i.e. Color(3) ), and by pickle 525 | if type(value) is cls: 526 | # For lookups like Color(Color.RED) 527 | return value 528 | # by-value search for a matching enum member 529 | # see if it's in the reverse mapping (for hashable values) 530 | try: 531 | if value in cls._value2member_map_: 532 | return cls._value2member_map_[value] 533 | except TypeError: 534 | # not there, now do long search -- O(n) behavior 535 | for member in cls._member_map_.values(): 536 | if member._value_ == value: 537 | return member 538 | # still not found -- try _missing_ hook 539 | return cls._missing_(value) 540 | 541 | def _generate_next_value_(name, start, count, last_values): 542 | for last_value in reversed(last_values): 543 | try: 544 | return last_value + 1 545 | except TypeError: 546 | pass 547 | else: 548 | return start 549 | 550 | @classmethod 551 | def _missing_(cls, value): 552 | raise ValueError("%r is not a valid %s" % (value, cls.__name__)) 553 | 554 | def __repr__(self): 555 | return "<%s.%s: %r>" % ( 556 | self.__class__.__name__, self._name_, self._value_) 557 | 558 | def __str__(self): 559 | return "%s.%s" % (self.__class__.__name__, self._name_) 560 | 561 | def __dir__(self): 562 | added_behavior = [ 563 | m 564 | for cls in self.__class__.mro() 565 | for m in cls.__dict__ 566 | if m[0] != '_' and m not in self._member_map_ 567 | ] 568 | return (['__class__', '__doc__', '__module__'] + added_behavior) 569 | 570 | def __format__(self, format_spec): 571 | # mixed-in Enums should use the mixed-in type's __format__, otherwise 572 | # we can get strange results with the Enum name showing up instead of 573 | # the value 574 | 575 | # pure Enum branch 576 | if self._member_type_ is object: 577 | cls = str 578 | val = str(self) 579 | # mix-in branch 580 | else: 581 | cls = self._member_type_ 582 | val = self._value_ 583 | return cls.__format__(val, format_spec) 584 | 585 | def __hash__(self): 586 | return hash(self._name_) 587 | 588 | def __reduce_ex__(self, proto): 589 | return self.__class__, (self._value_, ) 590 | 591 | # DynamicClassAttribute is used to provide access to the `name` and 592 | # `value` properties of enum members while keeping some measure of 593 | # protection from modification, while still allowing for an enumeration 594 | # to have members named `name` and `value`. This works because enumeration 595 | # members are not set directly on the enum class -- __getattr__ is 596 | # used to look them up. 597 | 598 | @DynamicClassAttribute 599 | def name(self): 600 | """The name of the Enum member.""" 601 | return self._name_ 602 | 603 | @DynamicClassAttribute 604 | def value(self): 605 | """The value of the Enum member.""" 606 | return self._value_ 607 | 608 | @classmethod 609 | def _convert(cls, name, module, filter, source=None): 610 | """ 611 | Create a new Enum subclass that replaces a collection of global constants 612 | """ 613 | # convert all constants from source (or module) that pass filter() to 614 | # a new Enum called name, and export the enum and its members back to 615 | # module; 616 | # also, replace the __reduce_ex__ method so unpickling works in 617 | # previous Python versions 618 | module_globals = vars(sys.modules[module]) 619 | if source: 620 | source = vars(source) 621 | else: 622 | source = module_globals 623 | # We use an OrderedDict of sorted source keys so that the 624 | # _value2member_map is populated in the same order every time 625 | # for a consistent reverse mapping of number to name when there 626 | # are multiple names for the same number rather than varying 627 | # between runs due to hash randomization of the module dictionary. 628 | members = [ 629 | (name, source[name]) 630 | for name in source.keys() 631 | if filter(name)] 632 | try: 633 | # sort by value 634 | members.sort(key=lambda t: (t[1], t[0])) 635 | except TypeError: 636 | # unless some values aren't comparable, in which case sort by name 637 | members.sort(key=lambda t: t[0]) 638 | cls = cls(name, members, module=module) 639 | cls.__reduce_ex__ = _reduce_ex_by_name 640 | module_globals.update(cls.__members__) 641 | module_globals[name] = cls 642 | return cls 643 | 644 | 645 | class IntEnum(int, Enum): 646 | """Enum where members are also (and must be) ints""" 647 | 648 | 649 | def _reduce_ex_by_name(self, proto): 650 | return self.name 651 | 652 | class Flag(Enum): 653 | """Support for flags""" 654 | 655 | def _generate_next_value_(name, start, count, last_values): 656 | """ 657 | Generate the next value when not given. 658 | 659 | name: the name of the member 660 | start: the initital start value or None 661 | count: the number of existing members 662 | last_value: the last value assigned or None 663 | """ 664 | if not count: 665 | return start if start is not None else 1 666 | for last_value in reversed(last_values): 667 | try: 668 | high_bit = _high_bit(last_value) 669 | break 670 | except Exception: 671 | raise TypeError('Invalid Flag value: %r' % last_value) from None 672 | return 2 ** (high_bit+1) 673 | 674 | @classmethod 675 | def _missing_(cls, value): 676 | original_value = value 677 | if value < 0: 678 | value = ~value 679 | possible_member = cls._create_pseudo_member_(value) 680 | if original_value < 0: 681 | possible_member = ~possible_member 682 | return possible_member 683 | 684 | @classmethod 685 | def _create_pseudo_member_(cls, value): 686 | """ 687 | Create a composite member iff value contains only members. 688 | """ 689 | pseudo_member = cls._value2member_map_.get(value, None) 690 | if pseudo_member is None: 691 | # verify all bits are accounted for 692 | _, extra_flags = _decompose(cls, value) 693 | if extra_flags: 694 | raise ValueError("%r is not a valid %s" % (value, cls.__name__)) 695 | # construct a singleton enum pseudo-member 696 | pseudo_member = object.__new__(cls) 697 | pseudo_member._name_ = None 698 | pseudo_member._value_ = value 699 | # use setdefault in case another thread already created a composite 700 | # with this value 701 | pseudo_member = cls._value2member_map_.setdefault(value, pseudo_member) 702 | return pseudo_member 703 | 704 | def __contains__(self, other): 705 | if not isinstance(other, self.__class__): 706 | return NotImplemented 707 | return other._value_ & self._value_ == other._value_ 708 | 709 | def __repr__(self): 710 | cls = self.__class__ 711 | if self._name_ is not None: 712 | return '<%s.%s: %r>' % (cls.__name__, self._name_, self._value_) 713 | members, uncovered = _decompose(cls, self._value_) 714 | return '<%s.%s: %r>' % ( 715 | cls.__name__, 716 | '|'.join([str(m._name_ or m._value_) for m in members]), 717 | self._value_, 718 | ) 719 | 720 | def __str__(self): 721 | cls = self.__class__ 722 | if self._name_ is not None: 723 | return '%s.%s' % (cls.__name__, self._name_) 724 | members, uncovered = _decompose(cls, self._value_) 725 | if len(members) == 1 and members[0]._name_ is None: 726 | return '%s.%r' % (cls.__name__, members[0]._value_) 727 | else: 728 | return '%s.%s' % ( 729 | cls.__name__, 730 | '|'.join([str(m._name_ or m._value_) for m in members]), 731 | ) 732 | 733 | def __bool__(self): 734 | return bool(self._value_) 735 | 736 | def __or__(self, other): 737 | if not isinstance(other, self.__class__): 738 | return NotImplemented 739 | return self.__class__(self._value_ | other._value_) 740 | 741 | def __and__(self, other): 742 | if not isinstance(other, self.__class__): 743 | return NotImplemented 744 | return self.__class__(self._value_ & other._value_) 745 | 746 | def __xor__(self, other): 747 | if not isinstance(other, self.__class__): 748 | return NotImplemented 749 | return self.__class__(self._value_ ^ other._value_) 750 | 751 | def __invert__(self): 752 | members, uncovered = _decompose(self.__class__, self._value_) 753 | inverted_members = [ 754 | m for m in self.__class__ 755 | if m not in members and not m._value_ & self._value_ 756 | ] 757 | inverted = reduce(_or_, inverted_members, self.__class__(0)) 758 | return self.__class__(inverted) 759 | 760 | 761 | class IntFlag(int, Flag): 762 | """Support for integer-based Flags""" 763 | 764 | @classmethod 765 | def _missing_(cls, value): 766 | if not isinstance(value, int): 767 | raise ValueError("%r is not a valid %s" % (value, cls.__name__)) 768 | new_member = cls._create_pseudo_member_(value) 769 | return new_member 770 | 771 | @classmethod 772 | def _create_pseudo_member_(cls, value): 773 | pseudo_member = cls._value2member_map_.get(value, None) 774 | if pseudo_member is None: 775 | need_to_create = [value] 776 | # get unaccounted for bits 777 | _, extra_flags = _decompose(cls, value) 778 | # timer = 10 779 | while extra_flags: 780 | # timer -= 1 781 | bit = _high_bit(extra_flags) 782 | flag_value = 2 ** bit 783 | if (flag_value not in cls._value2member_map_ and 784 | flag_value not in need_to_create 785 | ): 786 | need_to_create.append(flag_value) 787 | if extra_flags == -flag_value: 788 | extra_flags = 0 789 | else: 790 | extra_flags ^= flag_value 791 | for value in reversed(need_to_create): 792 | # construct singleton pseudo-members 793 | pseudo_member = int.__new__(cls, value) 794 | pseudo_member._name_ = None 795 | pseudo_member._value_ = value 796 | # use setdefault in case another thread already created a composite 797 | # with this value 798 | pseudo_member = cls._value2member_map_.setdefault(value, pseudo_member) 799 | return pseudo_member 800 | 801 | def __or__(self, other): 802 | if not isinstance(other, (self.__class__, int)): 803 | return NotImplemented 804 | result = self.__class__(self._value_ | self.__class__(other)._value_) 805 | return result 806 | 807 | def __and__(self, other): 808 | if not isinstance(other, (self.__class__, int)): 809 | return NotImplemented 810 | return self.__class__(self._value_ & self.__class__(other)._value_) 811 | 812 | def __xor__(self, other): 813 | if not isinstance(other, (self.__class__, int)): 814 | return NotImplemented 815 | return self.__class__(self._value_ ^ self.__class__(other)._value_) 816 | 817 | __ror__ = __or__ 818 | __rand__ = __and__ 819 | __rxor__ = __xor__ 820 | 821 | def __invert__(self): 822 | result = self.__class__(~self._value_) 823 | return result 824 | 825 | 826 | def _high_bit(value): 827 | """returns index of highest bit, or -1 if value is zero or negative""" 828 | return value.bit_length() - 1 829 | 830 | def unique(enumeration): 831 | """Class decorator for enumerations ensuring unique member values.""" 832 | duplicates = [] 833 | for name, member in enumeration.__members__.items(): 834 | if name != member.name: 835 | duplicates.append((name, member.name)) 836 | if duplicates: 837 | alias_details = ', '.join( 838 | ["%s -> %s" % (alias, name) for (alias, name) in duplicates]) 839 | raise ValueError('duplicate values found in %r: %s' % 840 | (enumeration, alias_details)) 841 | return enumeration 842 | 843 | def _decompose(flag, value): 844 | """Extract all members from the value.""" 845 | # _decompose is only called if the value is not named 846 | not_covered = value 847 | negative = value < 0 848 | # issue29167: wrap accesses to _value2member_map_ in a list to avoid race 849 | # conditions between iterating over it and having more psuedo- 850 | # members added to it 851 | if negative: 852 | # only check for named flags 853 | flags_to_check = [ 854 | (m, v) 855 | for v, m in list(flag._value2member_map_.items()) 856 | if m.name is not None 857 | ] 858 | else: 859 | # check for named flags and powers-of-two flags 860 | flags_to_check = [ 861 | (m, v) 862 | for v, m in list(flag._value2member_map_.items()) 863 | if m.name is not None or _power_of_two(v) 864 | ] 865 | members = [] 866 | for member, member_value in flags_to_check: 867 | if member_value and member_value & value == member_value: 868 | members.append(member) 869 | not_covered &= ~member_value 870 | if not members and value in flag._value2member_map_: 871 | members.append(flag._value2member_map_[value]) 872 | members.sort(key=lambda m: m._value_, reverse=True) 873 | if len(members) > 1 and members[0].value == value: 874 | # we have the breakdown, don't need the value member itself 875 | members.pop(0) 876 | return members, not_covered 877 | 878 | def _power_of_two(value): 879 | if value < 1: 880 | return False 881 | return value == 2 ** _high_bit(value) 882 | -------------------------------------------------------------------------------- /plasma/layout.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from xcffib.xproto import StackMode 4 | from libqtile.layout.base import Layout 5 | 6 | from .node import Node, AddMode, NotRestorableError 7 | 8 | 9 | class Plasma(Layout): 10 | """A flexible tree-based layout. 11 | 12 | Each tree node represents a container whose children are aligned either 13 | horizontally or vertically. Each window is attached to a leaf of the tree 14 | and takes either a calculated relative amount or a custom absolute amount 15 | of space in its parent container. Windows can be resized, rearranged and 16 | integrated into other containers. 17 | """ 18 | defaults = [ 19 | ('name', 'Plasma', 'Layout name'), 20 | ('border_normal', '#333333', 'Unfocused window border color'), 21 | ('border_focus', '#00e891', 'Focused window border color'), 22 | ('border_normal_fixed', '#333333', 23 | 'Unfocused fixed-size window border color'), 24 | ('border_focus_fixed', '#00e8dc', 25 | 'Focused fixed-size window border color'), 26 | ('border_width', 1, 'Border width'), 27 | ('border_width_single', 0, 'Border width for single window'), 28 | ('margin', 0, 'Layout margin'), 29 | ] 30 | # If windows are added before configure() was called, the screen size is 31 | # still unknown, so we need to set some arbitrary initial root dimensions 32 | default_dimensions = (0, 0, 1000, 1000) 33 | 34 | def __init__(self, **config): 35 | Layout.__init__(self, **config) 36 | self.add_defaults(Plasma.defaults) 37 | self.root = Node(None, *self.default_dimensions) 38 | self.focused = None 39 | self.add_mode = None 40 | 41 | @staticmethod 42 | def convert_names(tree): 43 | return [Plasma.convert_names(n) if isinstance(n, list) else 44 | n.payload.name for n in tree] 45 | 46 | @property 47 | def focused_node(self): 48 | return self.root.find_payload(self.focused) 49 | 50 | def info(self): 51 | info = super().info() 52 | info['tree'] = self.convert_names(self.root.tree) 53 | return info 54 | 55 | def clone(self, group): 56 | clone = copy.copy(self) 57 | clone.group = group 58 | clone.root = Node(None, *self.default_dimensions) 59 | clone.focused = None 60 | clone.add_mode = None 61 | return clone 62 | 63 | def add(self, client): 64 | node = self.root if self.focused_node is None else self.focused_node 65 | new = Node(client) 66 | try: 67 | self.root.restore(new) 68 | except NotRestorableError: 69 | node.add_node(new, self.add_mode) 70 | self.add_mode = None 71 | 72 | def remove(self, client): 73 | self.root.find_payload(client).remove() 74 | 75 | def configure(self, client, screen_rect): 76 | self.root.x = screen_rect.x 77 | self.root.y = screen_rect.y 78 | self.root.width = screen_rect.width 79 | self.root.height = screen_rect.height 80 | node = self.root.find_payload(client) 81 | border_width = self.border_width_single if self.root.tree == [node] \ 82 | else self.border_width 83 | border_color = getattr(self, 'border_' + 84 | ('focus' if client.has_focus else 'normal') + 85 | ('' if node.flexible else '_fixed')) 86 | x, y, width, height = node.pixel_perfect 87 | client.place( 88 | x, 89 | y, 90 | width-2*border_width, 91 | height-2*border_width, 92 | border_width, 93 | border_color, 94 | margin=self.margin, 95 | ) 96 | # Always keep tiles below floating windows 97 | client.window.configure(stackmode=StackMode.Below) 98 | client.unhide() 99 | 100 | def focus(self, client): 101 | self.focused = client 102 | self.root.find_payload(client).access() 103 | 104 | def focus_first(self): 105 | return self.root.first_leaf.payload 106 | 107 | def focus_last(self): 108 | return self.root.last_leaf.payload 109 | 110 | def focus_next(self, win): 111 | next_leaf = self.root.find_payload(win).next_leaf 112 | return None if next_leaf is self.root.first_leaf else next_leaf.payload 113 | 114 | def focus_previous(self, win): 115 | prev_leaf = self.root.find_payload(win).prev_leaf 116 | return None if prev_leaf is self.root.last_leaf else prev_leaf.payload 117 | 118 | def focus_node(self, node): 119 | if node is None: 120 | return 121 | self.group.focus(node.payload) 122 | 123 | def refocus(self): 124 | self.group.focus(self.focused) 125 | 126 | def cmd_next(self): 127 | """Focus next window.""" 128 | self.focus_node(self.focused_node.next_leaf) 129 | 130 | def cmd_previous(self): 131 | """Focus previous window.""" 132 | self.focus_node(self.focused_node.prev_leaf) 133 | 134 | def cmd_recent(self): 135 | """Focus most recently focused window. 136 | 137 | (Toggles between the two latest active windows.) 138 | """ 139 | nodes = [n for n in self.root.all_leafs if n is not self.focused_node] 140 | most_recent = max(nodes, key=lambda n: n.last_accessed) 141 | self.focus_node(most_recent) 142 | 143 | def cmd_left(self): 144 | """Focus window to the left.""" 145 | self.focus_node(self.focused_node.close_left) 146 | 147 | def cmd_right(self): 148 | """Focus window to the right.""" 149 | self.focus_node(self.focused_node.close_right) 150 | 151 | def cmd_up(self): 152 | """Focus window above.""" 153 | self.focus_node(self.focused_node.close_up) 154 | 155 | def cmd_down(self): 156 | """Focus window below.""" 157 | self.focus_node(self.focused_node.close_down) 158 | 159 | def cmd_move_left(self): 160 | """Move current window left.""" 161 | self.focused_node.move_left() 162 | self.refocus() 163 | 164 | def cmd_move_right(self): 165 | """Move current window right.""" 166 | self.focused_node.move_right() 167 | self.refocus() 168 | 169 | def cmd_move_up(self): 170 | """Move current window up.""" 171 | self.focused_node.move_up() 172 | self.refocus() 173 | 174 | def cmd_move_down(self): 175 | """Move current window down.""" 176 | self.focused_node.move_down() 177 | self.refocus() 178 | 179 | def cmd_integrate_left(self): 180 | """Integrate current window left.""" 181 | self.focused_node.integrate_left() 182 | self.refocus() 183 | 184 | def cmd_integrate_right(self): 185 | """Integrate current window right.""" 186 | self.focused_node.integrate_right() 187 | self.refocus() 188 | 189 | def cmd_integrate_up(self): 190 | """Integrate current window up.""" 191 | self.focused_node.integrate_up() 192 | self.refocus() 193 | 194 | def cmd_integrate_down(self): 195 | """Integrate current window down.""" 196 | self.focused_node.integrate_down() 197 | self.refocus() 198 | 199 | def cmd_mode_horizontal(self): 200 | """Next window will be added horizontally.""" 201 | self.add_mode = AddMode.HORIZONTAL 202 | 203 | def cmd_mode_vertical(self): 204 | """Next window will be added vertically.""" 205 | self.add_mode = AddMode.VERTICAL 206 | 207 | def cmd_mode_horizontal_split(self): 208 | """Next window will be added horizontally, splitting space of current 209 | window. 210 | """ 211 | self.add_mode = AddMode.HORIZONTAL | AddMode.SPLIT 212 | 213 | def cmd_mode_vertical_split(self): 214 | """Next window will be added vertically, splitting space of current 215 | window. 216 | """ 217 | self.add_mode = AddMode.VERTICAL | AddMode.SPLIT 218 | 219 | def cmd_size(self, x): 220 | """Change size of current window. 221 | 222 | (It's recommended to use `width()`/`height()` instead.) 223 | """ 224 | self.focused_node.size = x 225 | self.refocus() 226 | 227 | def cmd_width(self, x): 228 | """Set width of current window.""" 229 | self.focused_node.width = x 230 | self.refocus() 231 | 232 | def cmd_height(self, x): 233 | """Set height of current window.""" 234 | self.focused_node.height = x 235 | self.refocus() 236 | 237 | def cmd_reset_size(self): 238 | """Reset size of current window to automatic (relative) sizing.""" 239 | self.focused_node.reset_size() 240 | self.refocus() 241 | 242 | def cmd_grow(self, x): 243 | """Grow size of current window. 244 | 245 | (It's recommended to use `grow_width()`/`grow_height()` instead.) 246 | """ 247 | self.focused_node.size += x 248 | self.refocus() 249 | 250 | def cmd_grow_width(self, x): 251 | """Grow width of current window.""" 252 | self.focused_node.width += x 253 | self.refocus() 254 | 255 | def cmd_grow_height(self, x): 256 | """Grow height of current window.""" 257 | self.focused_node.height += x 258 | self.refocus() 259 | -------------------------------------------------------------------------------- /plasma/node.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | import time 3 | from math import isclose 4 | import sys 5 | 6 | if sys.version_info >= (3, 6): 7 | from enum import Enum, Flag, auto 8 | else: 9 | # Python 3.5 backport 10 | from .enum import Enum, Flag, auto 11 | 12 | 13 | Point = namedtuple('Point', 'x y') 14 | Dimensions = namedtuple('Dimensions', 'x y width height') 15 | 16 | class Orient(Flag): 17 | HORIZONTAL = 0 18 | VERTICAL = 1 19 | 20 | HORIZONTAL, VERTICAL = Orient 21 | 22 | class Direction(Enum): 23 | UP = auto() 24 | DOWN = auto() 25 | LEFT = auto() 26 | RIGHT = auto() 27 | 28 | @property 29 | def orient(self): 30 | return HORIZONTAL if self in [self.LEFT, self.RIGHT] else VERTICAL 31 | 32 | @property 33 | def offset(self): 34 | return 1 if self in [self.RIGHT, self.DOWN] else -1 35 | 36 | UP, DOWN, LEFT, RIGHT = Direction 37 | 38 | class AddMode(Flag): 39 | HORIZONTAL = 0 40 | VERTICAL = 1 41 | SPLIT = auto() 42 | 43 | @property 44 | def orient(self): 45 | return VERTICAL if self & self.VERTICAL else HORIZONTAL 46 | 47 | border_check = { 48 | UP: lambda a, b: isclose(a.y, b.y_end), 49 | DOWN: lambda a, b: isclose(a.y_end, b.y), 50 | LEFT: lambda a, b: isclose(a.x, b.x_end), 51 | RIGHT: lambda a, b: isclose(a.x_end, b.x), 52 | } 53 | 54 | class NotRestorableError(Exception): 55 | pass 56 | 57 | class Node: 58 | """A tree node. 59 | 60 | Each node represents a container that can hold a payload and child nodes. 61 | """ 62 | min_size_default = 100 63 | root_orient = HORIZONTAL 64 | 65 | def __init__(self, payload=None, x=None, y=None, width=None, height=None): 66 | self.payload = payload 67 | self._x = x 68 | self._y = y 69 | self._width = width 70 | self._height = height 71 | self._size = None 72 | self.children = [] 73 | self.last_accessed = 0 74 | self.parent = None 75 | self.restorables = {} 76 | 77 | def __repr__(self): 78 | info = self.payload or '' 79 | if self: 80 | info += ' +%d' % len(self) 81 | return '' % (info, id(self)) 82 | 83 | def __contains__(self, node): 84 | if node is self: 85 | return True 86 | for child in self: 87 | if node in child: 88 | return True 89 | return False 90 | 91 | def __iter__(self): 92 | yield from self.children 93 | 94 | def __getitem__(self, key): 95 | return self.children[key] 96 | 97 | def __setitem__(self, key, value): 98 | self.children[key] = value 99 | 100 | def __len__(self): 101 | return len(self.children) 102 | 103 | @property 104 | def root(self): 105 | try: 106 | return self.parent.root 107 | except AttributeError: 108 | return self 109 | 110 | @property 111 | def is_root(self): 112 | return self.parent is None 113 | 114 | @property 115 | def is_leaf(self): 116 | return not self 117 | 118 | @property 119 | def index(self): 120 | return self.parent.children.index(self) 121 | 122 | @property 123 | def tree(self): 124 | return [c.tree if c else c for c in self] 125 | 126 | @property 127 | def siblings(self): 128 | return [c for c in self.parent if c is not self] 129 | 130 | @property 131 | def first_leaf(self): 132 | if self.is_leaf: 133 | return self 134 | return self[0].first_leaf 135 | 136 | @property 137 | def last_leaf(self): 138 | if self.is_leaf: 139 | return self 140 | return self[-1].last_leaf 141 | 142 | @property 143 | def recent_leaf(self): 144 | if self.is_leaf: 145 | return self 146 | return max(self, key=lambda n: n.last_accessed).recent_leaf 147 | 148 | @property 149 | def prev_leaf(self): 150 | if self.is_root: 151 | return self.last_leaf 152 | idx = self.index - 1 153 | if idx < 0: 154 | return self.parent.prev_leaf 155 | return self.parent[idx].last_leaf 156 | 157 | @property 158 | def next_leaf(self): 159 | if self.is_root: 160 | return self.first_leaf 161 | idx = self.index + 1 162 | if idx >= len(self.parent): 163 | return self.parent.next_leaf 164 | return self.parent[idx].first_leaf 165 | 166 | @property 167 | def all_leafs(self): 168 | if self.is_leaf: 169 | yield self 170 | for child in self: 171 | yield from child.all_leafs 172 | 173 | @property 174 | def orient(self): 175 | if self.is_root: 176 | return self.root_orient 177 | return ~self.parent.orient 178 | 179 | @property 180 | def horizontal(self): 181 | return self.orient is HORIZONTAL 182 | 183 | @property 184 | def vertical(self): 185 | return self.orient is VERTICAL 186 | 187 | @property 188 | def x(self): 189 | if self.is_root: 190 | return self._x 191 | if self.horizontal: 192 | return self.parent.x 193 | return self.parent.x + self.size_offset 194 | 195 | @x.setter 196 | def x(self, val): 197 | if not self.is_root: 198 | return 199 | self._x = val 200 | 201 | @property 202 | def y(self): 203 | if self.is_root: 204 | return self._y 205 | if self.vertical: 206 | return self.parent.y 207 | return self.parent.y + self.size_offset 208 | 209 | @y.setter 210 | def y(self, val): 211 | if not self.is_root: 212 | return 213 | self._y = val 214 | 215 | @property 216 | def pos(self): 217 | return Point(self.x, self.y) 218 | 219 | @property 220 | def width(self): 221 | if self.is_root: 222 | return self._width 223 | if self.horizontal: 224 | return self.parent.width 225 | return self.size 226 | 227 | @width.setter 228 | def width(self, val): 229 | if self.is_root: 230 | self._width = val 231 | elif self.horizontal: 232 | self.parent.size = val 233 | else: 234 | self.size = val 235 | 236 | @property 237 | def height(self): 238 | if self.is_root: 239 | return self._height 240 | if self.vertical: 241 | return self.parent.height 242 | return self.size 243 | 244 | @height.setter 245 | def height(self, val): 246 | if self.is_root: 247 | self._height = val 248 | elif self.vertical: 249 | self.parent.size = val 250 | else: 251 | self.size = val 252 | 253 | @property 254 | def x_end(self): 255 | return self.x + self.width 256 | 257 | @property 258 | def y_end(self): 259 | return self.y + self.height 260 | 261 | @property 262 | def x_center(self): 263 | return self.x + self.width / 2 264 | 265 | @property 266 | def y_center(self): 267 | return self.y + self.height / 2 268 | 269 | @property 270 | def center(self): 271 | return Point(self.x_center, self.y_center) 272 | 273 | @property 274 | def top_left(self): 275 | return Point(self.x, self.y) 276 | 277 | @property 278 | def top_right(self): 279 | return Point(self.x + self.width, self.y) 280 | 281 | @property 282 | def bottom_left(self): 283 | return Point(self.x, self.y + self.height) 284 | 285 | @property 286 | def bottom_right(self): 287 | return Point(self.x + self.width, self.y + self.height) 288 | 289 | @property 290 | def pixel_perfect(self): 291 | """Return pixel-perfect int dimensions (x, y, width, height) which 292 | compensate for gaps in the layout grid caused by plain int conversion. 293 | """ 294 | x, y, width, height = self.x, self.y, self.width, self.height 295 | threshold = 0.99999 296 | if (x - int(x)) + (width - int(width)) > threshold: 297 | width += 1 298 | if (y - int(y)) + (height - int(height)) > threshold: 299 | height += 1 300 | return Dimensions(*map(int, (x, y, width, height))) 301 | 302 | @property 303 | def capacity(self): 304 | return self.width if self.horizontal else self.height 305 | 306 | @property 307 | def size(self): 308 | """Return amount of space taken in parent container.""" 309 | if self.is_root: 310 | return None 311 | if self.fixed: 312 | return self._size 313 | if self.flexible: 314 | # Distribute space evenly among flexible nodes 315 | taken = sum(n.size for n in self.siblings if not n.flexible) 316 | flexibles = [n for n in self.parent if n.flexible] 317 | return (self.parent.capacity - taken) / len(flexibles) 318 | return max(sum(gc.min_size for gc in c) for c in self) 319 | 320 | @size.setter 321 | def size(self, val): 322 | if self.is_root or not self.siblings: 323 | return 324 | if val is None: 325 | self.reset_size() 326 | return 327 | occupied = sum(s.min_size_bound for s in self.siblings) 328 | val = max(min(val, self.parent.capacity - occupied), 329 | self.min_size_bound) 330 | self.force_size(val) 331 | 332 | def force_size(self, val): 333 | """Set size without considering available space.""" 334 | Node.fit_into(self.siblings, self.parent.capacity - val) 335 | if val == 0: 336 | return 337 | if self: 338 | Node.fit_into([self], val) 339 | self._size = val 340 | 341 | @property 342 | def size_offset(self): 343 | return sum(c.size for c in self.parent[:self.index]) 344 | 345 | @staticmethod 346 | def fit_into(nodes, space): 347 | """Resize nodes to fit them into the available space.""" 348 | if not nodes: 349 | return 350 | occupied = sum(n.min_size for n in nodes) 351 | if space >= occupied and any(n.flexible for n in nodes): 352 | # If any flexible node exists, it will occupy the space 353 | # automatically, not requiring any action. 354 | return 355 | nodes_left = nodes[:] 356 | space_left = space 357 | if space < occupied: 358 | for node in nodes: 359 | if node.min_size_bound != node.min_size: 360 | continue 361 | # Substract nodes that are already at their minimal possible 362 | # size because they can't be shrinked any further. 363 | space_left -= node.min_size 364 | nodes_left.remove(node) 365 | if not nodes_left: 366 | return 367 | factor = space_left / sum(n.size for n in nodes_left) 368 | for node in nodes_left: 369 | new_size = node.size * factor 370 | if node.fixed: 371 | node._size = new_size # pylint: disable=protected-access 372 | for child in node: 373 | Node.fit_into(child, new_size) 374 | 375 | @property 376 | def fixed(self): 377 | """A node is fixed if it has a specified size.""" 378 | return self._size is not None 379 | 380 | @property 381 | def min_size(self): 382 | if self.fixed: 383 | return self._size 384 | if self.is_leaf: 385 | return self.min_size_default 386 | size = max(sum(gc.min_size for gc in c) for c in self) 387 | return max(size, self.min_size_default) 388 | 389 | @property 390 | def min_size_bound(self): 391 | if self.is_leaf: 392 | return self.min_size_default 393 | return max(sum(gc.min_size_bound for gc in c) or 394 | self.min_size_default for c in self) 395 | 396 | def reset_size(self): 397 | self._size = None 398 | 399 | @property 400 | def flexible(self): 401 | """A node is flexible if its size isn't (explicitly or implictly) 402 | determined. 403 | """ 404 | if self.fixed: 405 | return False 406 | return all((any(gc.flexible for gc in c) or c.is_leaf) for c in self) 407 | 408 | def access(self): 409 | self.last_accessed = time.time() 410 | try: 411 | self.parent.access() 412 | except AttributeError: 413 | pass 414 | 415 | def neighbor(self, direction): 416 | """Return adjacent leaf node in specified direction.""" 417 | if self.is_root: 418 | return None 419 | if direction.orient is self.parent.orient: 420 | target_idx = self.index + direction.offset 421 | if 0 <= target_idx < len(self.parent): 422 | return self.parent[target_idx].recent_leaf 423 | if self.parent.is_root: 424 | return None 425 | return self.parent.parent.neighbor(direction) 426 | return self.parent.neighbor(direction) 427 | 428 | @property 429 | def up(self): 430 | return self.neighbor(UP) 431 | 432 | @property 433 | def down(self): 434 | return self.neighbor(DOWN) 435 | 436 | @property 437 | def left(self): 438 | return self.neighbor(LEFT) 439 | 440 | @property 441 | def right(self): 442 | return self.neighbor(RIGHT) 443 | 444 | def common_border(self, node, direction): 445 | """Return whether a common border with given node in specified 446 | direction exists. 447 | """ 448 | if not border_check[direction](self, node): 449 | return False 450 | if direction in [UP, DOWN]: 451 | detached = node.x >= self.x_end or node.x_end <= self.x 452 | else: 453 | detached = node.y >= self.y_end or node.y_end <= self.y 454 | return not detached 455 | 456 | def close_neighbor(self, direction): 457 | """Return visually adjacent leaf node in specified direction.""" 458 | nodes = [n for n in self.root.all_leafs if 459 | self.common_border(n, direction)] 460 | if not nodes: 461 | return None 462 | most_recent = max(nodes, key=lambda n: n.last_accessed) 463 | if most_recent.last_accessed > 0: 464 | return most_recent 465 | if direction in [UP, DOWN]: 466 | match = lambda n: n.x <= self.x_center <= n.x_end 467 | else: 468 | match = lambda n: n.y <= self.y_center <= n.y_end 469 | return next(n for n in nodes if match(n)) 470 | 471 | @property 472 | def close_up(self): 473 | return self.close_neighbor(UP) 474 | 475 | @property 476 | def close_down(self): 477 | return self.close_neighbor(DOWN) 478 | 479 | @property 480 | def close_left(self): 481 | return self.close_neighbor(LEFT) 482 | 483 | @property 484 | def close_right(self): 485 | return self.close_neighbor(RIGHT) 486 | 487 | def add_child(self, node, idx=None): 488 | if idx is None: 489 | idx = len(self) 490 | self.children.insert(idx, node) 491 | node.parent = self 492 | if len(self) == 1: 493 | return 494 | total = self.capacity 495 | Node.fit_into(node.siblings, total - (total / len(self))) 496 | 497 | def add_child_after(self, new, old): 498 | self.add_child(new, idx=old.index+1) 499 | 500 | def remove_child(self, node): 501 | node._save_restore_state() # pylint: disable=W0212 502 | node.force_size(0) 503 | self.children.remove(node) 504 | if len(self) == 1: 505 | child = self[0] 506 | if self.is_root: 507 | # A single child doesn't need a fixed size 508 | child.reset_size() 509 | else: 510 | # Collapse tree with a single child 511 | self.parent.replace_child(self, child) 512 | Node.fit_into(child, self.capacity) 513 | 514 | def remove(self): 515 | self.parent.remove_child(self) 516 | 517 | def replace_child(self, old, new): 518 | self[old.index] = new 519 | new.parent = self 520 | new._size = old._size # pylint: disable=protected-access 521 | 522 | def flip_with(self, node, reverse=False): 523 | """Join with node in a new, orthogonal container.""" 524 | container = Node() 525 | self.parent.replace_child(self, container) 526 | self.reset_size() 527 | for child in [node, self] if reverse else [self, node]: 528 | container.add_child(child) 529 | 530 | def add_node(self, node, mode=None): 531 | """Add node according to the mode. 532 | 533 | This can result in adding it as a child, joining with it in a new 534 | flipped sub-container, or splitting the space with it. 535 | """ 536 | if self.is_root: 537 | self.add_child(node) 538 | elif mode is None: 539 | self.parent.add_child_after(node, self) 540 | elif mode.orient is self.parent.orient: 541 | if mode & AddMode.SPLIT: 542 | node._size = 0 # pylint: disable=protected-access 543 | self.parent.add_child_after(node, self) 544 | self._size = node._size = self.size / 2 545 | else: 546 | self.parent.add_child_after(node, self) 547 | else: 548 | self.flip_with(node) 549 | 550 | def restore(self, node): 551 | """Restore node. 552 | 553 | Try to add the node in a place where a node with the same payload 554 | has previously been. 555 | """ 556 | restorables = self.root.restorables 557 | try: 558 | parent, idx, sizes, fixed, flip = restorables[node.payload] 559 | except KeyError: 560 | raise NotRestorableError() # pylint: disable=raise-missing-from 561 | if parent not in self.root: 562 | # Don't try to restore if parent is not part of the tree anymore 563 | raise NotRestorableError() 564 | node.reset_size() 565 | if flip: 566 | old_parent_size = parent.size 567 | parent.flip_with(node, reverse=(idx == 0)) 568 | node.size, parent.size = sizes 569 | Node.fit_into(parent, old_parent_size) 570 | else: 571 | parent.add_child(node, idx=idx) 572 | node.size = sizes[0] 573 | if len(sizes) == 2: 574 | node.siblings[0].size = sizes[1] 575 | if not fixed: 576 | node.reset_size() 577 | del restorables[node.payload] 578 | 579 | def _save_restore_state(self): 580 | parent = self.parent 581 | sizes = (self.size,) 582 | flip = False 583 | if len(self.siblings) == 1: 584 | # If there is only one node left in the container, we need to save 585 | # its size too because the size will be lost. 586 | sizes += (self.siblings[0]._size,) # pylint: disable=W0212 587 | if not self.parent.is_root: 588 | flip = True 589 | parent = self.siblings[0] 590 | self.root.restorables[self.payload] = (parent, self.index, sizes, 591 | self.fixed, flip) 592 | 593 | def move(self, direction): 594 | """Move this node in `direction`. Return whether node was moved.""" 595 | if self.is_root: 596 | return False 597 | if direction.orient is self.parent.orient: 598 | old_idx = self.index 599 | new_idx = old_idx + direction.offset 600 | if 0 <= new_idx < len(self.parent): 601 | p = self.parent 602 | p[old_idx], p[new_idx] = p[new_idx], p[old_idx] 603 | return True 604 | new_sibling = self.parent.parent 605 | else: 606 | new_sibling = self.parent 607 | try: 608 | new_parent = new_sibling.parent 609 | idx = new_sibling.index 610 | except AttributeError: 611 | return False 612 | self.reset_size() 613 | self.parent.remove_child(self) 614 | new_parent.add_child(self, idx + (1 if direction.offset == 1 else 0)) 615 | return True 616 | 617 | def move_up(self): 618 | return self.move(UP) 619 | 620 | def move_down(self): 621 | return self.move(DOWN) 622 | 623 | def move_right(self): 624 | return self.move(RIGHT) 625 | 626 | def move_left(self): 627 | return self.move(LEFT) 628 | 629 | def _move_and_integrate(self, direction): 630 | old_parent = self.parent 631 | self.move(direction) 632 | if self.parent is not old_parent: 633 | self.integrate(direction) 634 | 635 | def integrate(self, direction): 636 | if direction.orient != self.parent.orient: 637 | self._move_and_integrate(direction) 638 | return 639 | target_idx = self.index + direction.offset 640 | if target_idx < 0 or target_idx >= len(self.parent): 641 | self._move_and_integrate(direction) 642 | return 643 | self.reset_size() 644 | target = self.parent[target_idx] 645 | self.parent.remove_child(self) 646 | if target.is_leaf: 647 | target.flip_with(self) 648 | else: 649 | target.add_child(self) 650 | 651 | def integrate_up(self): 652 | self.integrate(UP) 653 | 654 | def integrate_down(self): 655 | self.integrate(DOWN) 656 | 657 | def integrate_left(self): 658 | self.integrate(LEFT) 659 | 660 | def integrate_right(self): 661 | self.integrate(RIGHT) 662 | 663 | def find_payload(self, payload): 664 | if self.payload is payload: 665 | return self 666 | for child in self: 667 | needle = child.find_payload(payload) 668 | if needle is not None: 669 | return needle 670 | return None 671 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pylint] 2 | disable = 3 | invalid-name, 4 | missing-docstring, 5 | too-few-public-methods, 6 | too-many-public-methods, 7 | too-many-arguments, 8 | too-many-instance-attributes, 9 | unsupported-assignment-operation, 10 | unsubscriptable-object, 11 | not-an-iterable, 12 | fixme, 13 | no-name-in-module, 14 | attribute-defined-outside-init, 15 | 16 | [flake8] 17 | ignore = E226,E302,E305,E306,E731,F811,W504 18 | exclude = __init__.py,lib/ 19 | 20 | [coverage:run] 21 | omit = 22 | plasma/enum.py 23 | 24 | [coverage:report] 25 | exclude_lines = 26 | from enum import 27 | from .enum import 28 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='qtile-plasma', 5 | packages=['plasma'], 6 | version='1.5.6', 7 | description='A flexible, tree-based layout for Qtile', 8 | author='numirias', 9 | author_email='numirias@users.noreply.github.com', 10 | url='https://github.com/numirias/qtile-plasma', 11 | license='MIT', 12 | python_requires='>=3', 13 | install_requires=['xcffib>=0.5.0', 'qtile>=0.17'], 14 | classifiers=[ 15 | 'Development Status :: 4 - Beta', 16 | 'License :: OSI Approved :: MIT License', 17 | 'Operating System :: Unix', 18 | 'Programming Language :: Python', 19 | 'Programming Language :: Python :: 3', 20 | 'Programming Language :: Python :: 3.7', 21 | 'Programming Language :: Python :: 3.8', 22 | 'Programming Language :: Python :: 3.9', 23 | 'Topic :: Desktop Environment :: Window Managers', 24 | ], 25 | ) 26 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/numirias/qtile-plasma/4b57f313ed6948212582de05205a3ae4372ed812/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import sys 3 | 4 | from pytest import fixture 5 | 6 | from plasma.node import Node 7 | 8 | # We borrow Qtile's testing framework. That's not elegant but the best option. 9 | sys.path.insert(0, str(Path(__file__).parents[1] / 'lib')) # noqa: E402 10 | from qtile.test.conftest import pytest_addoption 11 | 12 | 13 | 14 | Node.min_size_default = 10 15 | 16 | @fixture 17 | def root(): 18 | root = Node('root', 0, 0, 120, 50) 19 | return root 20 | 21 | @fixture 22 | def tiny_grid(root): 23 | a, b, c = Nodes('a b c') 24 | root.add_child(a) 25 | root.add_child(b) 26 | b.flip_with(c) 27 | return a, b, c 28 | 29 | @fixture 30 | def small_grid(root): 31 | a, b, c, d = Nodes('a b c d') 32 | root.add_child(a) 33 | root.add_child(b) 34 | b.flip_with(c) 35 | c.flip_with(d) 36 | return a, b, c, d 37 | 38 | @fixture 39 | def grid(root): 40 | a, b, c, d, e = Nodes('a b c d e') 41 | root.add_child(a) 42 | root.add_child(b) 43 | b.flip_with(c) 44 | c.flip_with(d) 45 | c.parent.add_child(e) 46 | return a, b, c, d, e 47 | 48 | @fixture 49 | def complex_grid(root): 50 | a, b, c, d, e, f, g = Nodes('a b c d e f g') 51 | root.add_child(a) 52 | root.add_child(b) 53 | b.flip_with(c) 54 | c.flip_with(d) 55 | c.parent.add_child(e) 56 | c.flip_with(f) 57 | f.flip_with(g) 58 | return a, b, c, d, e, f, g 59 | 60 | def Nodes(string): 61 | for x in string.split(): 62 | yield Node(x) 63 | -------------------------------------------------------------------------------- /tests/test_debug.py: -------------------------------------------------------------------------------- 1 | from plasma.debug import draw, tree, info 2 | 3 | 4 | class TestDebugging: 5 | 6 | def test_tree(self, root, grid): 7 | lines = tree(root).split('\n') 8 | assert lines[0].startswith('root') 9 | assert lines[1].strip().startswith('a') 10 | assert lines[2].strip().startswith('*') 11 | assert lines[3].strip().startswith('b') 12 | 13 | def test_draw(self, root, grid): 14 | a, *_ = grid 15 | root._width = 24 16 | root._height = 10 17 | a.payload = 'XXXXXXXXXXXX' 18 | data = draw(root) 19 | assert data == ''' 20 | ┌──────────┐┌──────────┐ 21 | │XXXXXXXXXX││b.........│ 22 | │..........││..........│ 23 | │..........││..........│ 24 | │..........│└──────────┘ 25 | │..........│┌──┐┌──┐┌──┐ 26 | │..........││c.││d.││e.│ 27 | │..........││..││..││..│ 28 | │..........││..││..││..│ 29 | └──────────┘└──┘└──┘└──┘ 30 | '''.replace(' ', '')[1:] 31 | 32 | def test_info(self, root, grid, capsys): 33 | info(root) 34 | out, _ = capsys.readouterr() 35 | assert out == tree(root) + '\n' + draw(root) + '\n' 36 | -------------------------------------------------------------------------------- /tests/test_layout.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from pytest import fixture, mark 3 | import sys 4 | import time 5 | 6 | from plasma import Plasma 7 | from plasma.node import Node 8 | 9 | # We borrow Qtile's testing framework. That's not elegant but the best option. 10 | sys.path.insert(0, str(Path(__file__).parents[1] / 'lib')) # noqa: E402 11 | from qtile.libqtile import config 12 | from qtile.libqtile.layout import Floating 13 | from qtile.test.conftest import manager as qtile, no_xinerama, xephyr, xvfb # noqa: F401 14 | from qtile.test.layouts.layout_utils import assert_focused 15 | from qtile.libqtile.confreader import Config as _Config 16 | 17 | 18 | @fixture 19 | def grid(qtile): 20 | qtile.test_window('a') 21 | qtile.test_window('b') 22 | qtile.c.layout.previous() 23 | qtile.c.layout.mode_vertical() 24 | qtile.test_window('c') 25 | qtile.c.layout.right() 26 | qtile.c.layout.mode_vertical() 27 | qtile.test_window('d') 28 | 29 | class Config(_Config): 30 | 31 | auto_fullscreen = True 32 | main = None 33 | groups = [ 34 | config.Group('g0'), 35 | config.Group('g1'), 36 | config.Group('g2'), 37 | config.Group('g3') 38 | ] 39 | layouts = [Plasma()] 40 | floating_layout = Floating() 41 | keys = [] 42 | mouse = [] 43 | screens = [] 44 | follow_mouse_focus = False 45 | 46 | def plasma_config(func): 47 | config = mark.parametrize('qtile', [Config], indirect=True)(func) 48 | return no_xinerama(config) 49 | 50 | def tree(qtile): 51 | return qtile.c.layout.info()['tree'] 52 | 53 | class TestLayout: 54 | 55 | def test_init(self): 56 | layout = Plasma() 57 | assert isinstance(layout.root, Node) 58 | 59 | def test_focus(self, root): 60 | layout = Plasma() 61 | layout.root = root 62 | a, b, c, d = 'abcd' 63 | layout.add(a) 64 | layout.add(b) 65 | layout.add(c) 66 | layout.add(d) 67 | assert layout.focus_first() == 'a' 68 | assert layout.focus_last() == 'd' 69 | assert layout.focus_next('b') == 'c' 70 | assert layout.focus_previous('c') == 'b' 71 | layout.focus('c') 72 | assert layout.focused is c 73 | 74 | def test_access(self, root): 75 | layout = Plasma() 76 | layout.root = root 77 | layout.add('a') 78 | now = time.time() 79 | assert layout.root.find_payload('a').last_accessed < now 80 | layout.focus('a') 81 | assert layout.root.find_payload('a').last_accessed > now 82 | 83 | @plasma_config 84 | def test_info(self, qtile): 85 | qtile.test_window('a') 86 | qtile.test_window('b') 87 | assert qtile.c.layout.info()['tree'] == ['a', 'b'] 88 | 89 | @plasma_config 90 | def test_windows(self, qtile): 91 | qtile.test_window('a') 92 | qtile.test_window('b') 93 | qtile.test_window('c') 94 | assert_focused(qtile, 'c') 95 | assert tree(qtile) == ['a', 'b', 'c'] 96 | 97 | @plasma_config 98 | def test_split_directions(self, qtile): 99 | qtile.test_window('a') 100 | qtile.c.layout.mode_horizontal() 101 | qtile.test_window('b') 102 | qtile.c.layout.mode_vertical() 103 | qtile.test_window('c') 104 | assert tree(qtile) == ['a', ['b', 'c']] 105 | 106 | @plasma_config 107 | def test_directions(self, qtile, grid): 108 | assert_focused(qtile, 'd') 109 | qtile.c.layout.left() 110 | assert_focused(qtile, 'c') 111 | qtile.c.layout.up() 112 | assert_focused(qtile, 'a') 113 | qtile.c.layout.right() 114 | assert_focused(qtile, 'b') 115 | qtile.c.layout.down() 116 | assert_focused(qtile, 'd') 117 | qtile.c.layout.down() 118 | assert_focused(qtile, 'd') 119 | qtile.c.layout.previous() 120 | assert_focused(qtile, 'b') 121 | qtile.c.layout.next() 122 | assert_focused(qtile, 'd') 123 | 124 | @plasma_config 125 | def test_move(self, qtile, grid): 126 | assert tree(qtile) == [['a', 'c'], ['b', 'd']] 127 | qtile.c.layout.move_up() 128 | assert tree(qtile) == [['a', 'c'], ['d', 'b']] 129 | qtile.c.layout.move_down() 130 | assert tree(qtile) == [['a', 'c'], ['b', 'd']] 131 | qtile.c.layout.move_left() 132 | assert tree(qtile) == [['a', 'c'], 'd', 'b'] 133 | qtile.c.layout.move_right() 134 | assert tree(qtile) == [['a', 'c'], 'b', 'd'] 135 | 136 | @plasma_config 137 | def test_integrate(self, qtile, grid): 138 | qtile.c.layout.integrate_left() 139 | assert tree(qtile) == [['a', 'c', 'd'], 'b'] 140 | qtile.c.layout.integrate_up() 141 | assert tree(qtile) == [['a', ['c', 'd']], 'b'] 142 | qtile.c.layout.integrate_up() 143 | qtile.c.layout.integrate_down() 144 | assert tree(qtile) == [['a', ['c', 'd']], 'b'] 145 | qtile.c.layout.integrate_right() 146 | assert tree(qtile) == [['a', 'c'], ['b', 'd']] 147 | 148 | @plasma_config 149 | def test_sizes(self, qtile): 150 | qtile.test_window('a') 151 | qtile.test_window('b') 152 | qtile.c.layout.mode_vertical() 153 | qtile.test_window('c') 154 | info = qtile.c.window.info() 155 | assert info['x'] == 400 156 | assert info['y'] == 300 157 | assert info['width'] == 400 - 2 158 | assert info['height'] == 300 - 2 159 | qtile.c.layout.grow_height(50) 160 | info = qtile.c.window.info() 161 | assert info['height'] == 300 - 2 + 50 162 | qtile.c.layout.grow_width(50) 163 | info = qtile.c.window.info() 164 | assert info['width'] == 400 - 2 + 50 165 | qtile.c.layout.reset_size() 166 | info = qtile.c.window.info() 167 | assert info['height'] == 300 - 2 168 | qtile.c.layout.height(300) 169 | info = qtile.c.window.info() 170 | assert info['height'] == 300 - 2 171 | qtile.c.layout.width(250) 172 | info = qtile.c.window.info() 173 | assert info['width'] == 250 - 2 174 | qtile.c.layout.size(200) 175 | info = qtile.c.window.info() 176 | assert info['height'] == 200 - 2 177 | qtile.c.layout.grow(10) 178 | info = qtile.c.window.info() 179 | assert info['height'] == 210 - 2 180 | 181 | @plasma_config 182 | def test_remove(self, qtile): 183 | a = qtile.test_window('a') 184 | b = qtile.test_window('b') 185 | assert tree(qtile) == ['a', 'b'] 186 | qtile.kill_window(a) 187 | assert tree(qtile) == ['b'] 188 | qtile.kill_window(b) 189 | assert tree(qtile) == [] 190 | 191 | @plasma_config 192 | def test_split_mode(self, qtile): 193 | qtile.test_window('a') 194 | qtile.test_window('b') 195 | qtile.c.layout.mode_horizontal_split() 196 | qtile.test_window('c') 197 | assert qtile.c.window.info()['width'] == 200 - 2 198 | qtile.c.layout.mode_vertical() 199 | qtile.test_window('d') 200 | assert qtile.c.window.info()['height'] == 300 - 2 201 | qtile.test_window('e') 202 | assert qtile.c.window.info()['height'] == 200 - 2 203 | qtile.c.layout.mode_vertical_split() 204 | qtile.test_window('f') 205 | assert qtile.c.window.info()['height'] == 100 - 2 206 | 207 | @plasma_config 208 | def test_recent(self, qtile): 209 | qtile.test_window('a') 210 | qtile.test_window('b') 211 | qtile.test_window('c') 212 | assert_focused(qtile, 'c') 213 | qtile.c.layout.recent() 214 | assert_focused(qtile, 'b') 215 | qtile.c.layout.recent() 216 | assert_focused(qtile, 'c') 217 | qtile.c.layout.next() 218 | assert_focused(qtile, 'a') 219 | qtile.c.layout.recent() 220 | assert_focused(qtile, 'c') 221 | 222 | def test_bug_10(self): 223 | """Adding nodes when the correct root dimensions are still unknown 224 | should not raise an error. 225 | """ 226 | layout = Plasma() 227 | layout.add(object()) 228 | layout.add(object()) 229 | -------------------------------------------------------------------------------- /tests/test_node.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pytest import approx 3 | 4 | from plasma.debug import draw, info # noqa 5 | from plasma.node import Node, HORIZONTAL, AddMode, NotRestorableError 6 | 7 | from .conftest import Nodes 8 | 9 | 10 | class TestNode: 11 | 12 | def test_single_node(self): 13 | n = Node(None, 0, 0, 120, 50) 14 | assert n.x == 0 15 | assert n.y == 0 16 | assert n.width == 120 17 | assert n.height == 50 18 | assert n.is_root is True 19 | assert n.is_leaf is True 20 | assert n.parent is None 21 | assert n.children == [] 22 | assert n.orient == HORIZONTAL 23 | assert n.horizontal is True 24 | assert n.vertical is False 25 | assert n.size is None 26 | assert (n.x, n.y) == n.pos 27 | 28 | def test_add_child(self, root): 29 | child = Node('a') 30 | root.add_child(child) 31 | assert root.children == [child] 32 | assert child.parent == root 33 | assert root.width == child.width == 120 34 | assert root.height == child.height == 50 35 | assert root.x == child.x == 0 36 | assert root.y == child.y == 0 37 | 38 | def test_add_children(self, root): 39 | a, b = Nodes('a b') 40 | root.add_child(a) 41 | root.add_child(b) 42 | assert root.width == 120 43 | assert a.width == b.width == 60 44 | assert root.height == 50 45 | assert a.height == b.height == 50 46 | assert a.pos == (0, 0) 47 | assert b.pos == (60, 0) 48 | c = Node('c') 49 | root.add_child(c) 50 | assert a.width == b.width == c.width == 40 51 | assert a.pos == (0, 0) 52 | assert b.pos == (40, 0) 53 | assert c.pos == (80, 0) 54 | 55 | def test_add_child_after(self, root, grid): 56 | a, b, c, d, e = grid 57 | f = Node('f') 58 | g = Node('g') 59 | h = Node('h') 60 | root.add_child_after(f, a) 61 | assert root.tree == [a, f, [b, [c, d, e]]] 62 | b.parent.add_child_after(g, b) 63 | assert root.tree == [a, f, [b, g, [c, d, e]]] 64 | d.parent.add_child_after(h, d) 65 | assert root.tree == [a, f, [b, g, [c, d, h, e]]] 66 | 67 | def test_add_child_after_with_sizes(self, root): 68 | a, b, c = Nodes('a b c') 69 | root.add_child(a) 70 | root.add_child(b) 71 | a.size += 10 72 | b.size += 10 73 | b.parent.add_child_after(c, b) 74 | assert a.size == b.size == c.size == 40 75 | 76 | def test_remove_child(self, root): 77 | a, b = Nodes('a b') 78 | root.add_child(a) 79 | root.add_child(b) 80 | root.remove_child(a) 81 | assert root.children == [b] 82 | root.remove_child(b) 83 | assert root.children == [] 84 | 85 | def test_nested(self, root): 86 | a, b, c, d = Nodes('a b c d') 87 | root.add_child(a) 88 | root.add_child(b) 89 | b.flip_with(c) 90 | assert a.width == 60 91 | assert b.width == 60 92 | assert c.width == 60 93 | assert a.height == 50 94 | assert b.height == 25 95 | assert c.height == 25 96 | b.flip_with(d) 97 | assert b.width == 30 98 | assert d.width == 30 99 | 100 | def test_leaves(self, root, grid): 101 | a, b, c, d, e = grid 102 | assert root.first_leaf is a 103 | assert root.last_leaf is e 104 | assert b.parent.first_leaf is b 105 | assert b.parent.last_leaf is e 106 | 107 | def test_directions(self, root, grid): 108 | a, b, c, d, e = grid 109 | assert a.up is None 110 | assert a.right is b 111 | assert a.down is None 112 | assert a.left is None 113 | 114 | assert b.up is None 115 | assert b.right is None 116 | assert b.down is c 117 | assert b.left is a 118 | 119 | assert c.up is b 120 | assert c.right is d 121 | assert c.down is None 122 | assert c.left is a 123 | 124 | assert d.up is b 125 | assert d.right is e 126 | assert d.down is None 127 | assert d.left is c 128 | 129 | assert e.up is b 130 | assert e.right is None 131 | assert e.down is None 132 | assert e.left is d 133 | 134 | def test_prev_next(self, grid): 135 | a, b, c, d, e = grid 136 | assert a.next_leaf == b 137 | assert b.next_leaf == c 138 | assert c.next_leaf == d 139 | assert d.next_leaf == e 140 | assert e.next_leaf == a 141 | assert a.prev_leaf == e 142 | assert e.prev_leaf == d 143 | assert d.prev_leaf == c 144 | assert c.prev_leaf == b 145 | assert b.prev_leaf == a 146 | 147 | def test_siblings(self, grid): 148 | a, b, c, d, e = grid 149 | assert d.siblings == [c, e] 150 | assert b.siblings == [c.parent] 151 | 152 | def test_move_forward(self, root, grid): 153 | a, b, c, d, e = grid 154 | assert c.parent.children == [c, d, e] 155 | c.move_right() 156 | assert c.parent.children == [d, c, e] 157 | c.move_right() 158 | assert c.parent.children == [d, e, c] 159 | c.move_right() 160 | assert root.tree == [a, [b, [d, e]], c] 161 | 162 | def test_move_backward(self, root, grid): 163 | a, b, c, d, e = grid 164 | e.move_left() 165 | assert c.parent.children == [c, e, d] 166 | e.move_left() 167 | assert c.parent.children == [e, c, d] 168 | e.move_left() 169 | assert root.tree == [a, e, [b, [c, d]]] 170 | 171 | def test_advanced_move(self, grid): 172 | a, b, c, d, e = grid 173 | c.move_up() 174 | assert b.parent.tree == [b, c, [d, e]] 175 | a.move_up() 176 | assert b.parent.tree == [b, c, [d, e]] 177 | 178 | def test_advanced_move2(self, root, grid): 179 | a, b, c, d, e = grid 180 | res = c.move_down() 181 | assert b.parent.tree == [b, [d, e], c] 182 | assert res is True 183 | res = e.move_down() 184 | assert b.parent.tree == [b, d, e, c] 185 | assert res is True 186 | res = e.move_left() 187 | assert root.tree == [a, e, [b, d, c]] 188 | assert res is True 189 | res = d.move_right() 190 | assert root.tree == [a, e, [b, c], d] 191 | assert res is True 192 | res = a.move_left() 193 | assert root.tree == [a, e, [b, c], d] 194 | assert res is False 195 | res = d.move_right() 196 | assert root.tree == [a, e, [b, c], d] 197 | assert res is False 198 | 199 | def test_move_blocked(self, root, grid): 200 | a, b, c, d, e = grid 201 | orig_tree = root.tree.copy() 202 | res = a.move_up() 203 | assert root.tree == orig_tree 204 | assert res is False 205 | res = b.move_up() 206 | assert root.tree == orig_tree 207 | assert res is False 208 | 209 | def test_move_root(self, root): 210 | a = Node('a') 211 | root.add_child(a) 212 | root.move_up() 213 | assert root.tree == [a] 214 | 215 | def test_integrate(self, root): 216 | a, b, c, d, e = Nodes('a b c d e') 217 | root.add_child(a) 218 | root.add_child(b) 219 | root.add_child(c) 220 | root.add_child(d) 221 | c.integrate_left() 222 | assert root.tree == [a, [b, c], d] 223 | a.integrate_right() 224 | assert root.tree == [[b, c, a], d] 225 | a.parent.add_child(e) 226 | c.integrate_down() 227 | assert root.tree == [[b, [a, c], e], d] 228 | e.integrate_up() 229 | assert root.tree == [[b, [a, c, e]], d] 230 | 231 | def test_integrate_nested(self, root, grid): 232 | a, b, c, d, e = grid 233 | c.integrate_right() 234 | assert root.tree == [a, [b, [[d, c], e]]] 235 | 236 | def test_move_and_integrate(self, root, grid): 237 | a, b, c, d, e = grid 238 | c.integrate_left() 239 | assert root.tree == [[a, c], [b, [d, e]]] 240 | a.integrate_right() 241 | assert root.tree == [c, [b, [d, e], a]] 242 | d.integrate_down() 243 | assert root.tree == [c, [b, e, [a, d]]] 244 | a.integrate_up() 245 | assert root.tree == [c, [b, [e, a], d]] 246 | e.integrate_left() 247 | assert root.tree == [[c, e], [b, a, d]] 248 | f = Node('f') 249 | a.flip_with(f) 250 | g = Node('g') 251 | a.flip_with(g) 252 | g.integrate_left() 253 | assert root.tree == [[c, e, g], [b, [a, f], d]] 254 | 255 | def test_impossible_integrate(self, root, grid): 256 | a, b, c, d, e = grid 257 | orig_tree = root.tree.copy() 258 | a.integrate_left() 259 | assert orig_tree == root.tree 260 | b.integrate_up() 261 | assert orig_tree == root.tree 262 | 263 | def test_impossible_integrate2(self, root): 264 | a, b = Nodes('a b') 265 | root.add_child(a) 266 | root.add_child(b) 267 | orig_tree = root.tree.copy() 268 | b.integrate_up() 269 | assert root.tree == orig_tree 270 | b.integrate_down() 271 | assert root.tree == orig_tree 272 | b.integrate_right() 273 | assert root.tree == orig_tree 274 | a.integrate_up() 275 | assert root.tree == orig_tree 276 | a.integrate_down() 277 | assert root.tree == orig_tree 278 | a.integrate_left() 279 | assert root.tree == orig_tree 280 | 281 | def test_find_payload(self, root, grid): 282 | a, b, c, d, e = grid 283 | assert root.find_payload('a') is a 284 | assert root.find_payload('b') is b 285 | assert root.find_payload('d') is d 286 | assert root.find_payload('x') is None 287 | 288 | def test_last_access(self, grid): 289 | a, b, c, d, e = grid 290 | f = Node('f') 291 | a.flip_with(f) 292 | d.access() 293 | assert b.down is d 294 | b.access() 295 | assert f.right is b 296 | f.access() 297 | assert b.left is f 298 | 299 | def test_root_without_dimensions(self): 300 | """A root node with undef. dimensions should be able to add a child.""" 301 | root = Node() 302 | x = Node('x') 303 | root.add_child(x) 304 | 305 | def test_root(self, root, grid): 306 | for node in grid: 307 | assert node.root is root 308 | 309 | def test_all(self, root, grid): 310 | assert set(root.all_leafs) == set(grid) 311 | 312 | def test_close_neighbor(self, root): 313 | a, b, c, d = Nodes('a b c d') 314 | root.add_child(a) 315 | root.add_child(b) 316 | a.flip_with(c) 317 | b.flip_with(d) 318 | assert a.close_up is None 319 | assert a.close_left is None 320 | assert a.close_right is b 321 | assert a.close_down is c 322 | 323 | assert b.close_up is None 324 | assert b.close_left is a 325 | assert b.close_right is None 326 | assert b.close_down is d 327 | 328 | assert c.close_up is a 329 | assert c.close_left is None 330 | assert c.close_right is d 331 | assert c.close_down is None 332 | 333 | assert d.close_up is b 334 | assert d.close_left is c 335 | assert d.close_right is None 336 | assert d.close_down is None 337 | 338 | def test_close_neighbor2(self, root, small_grid): 339 | a, b, c, d = small_grid 340 | assert b.close_left is a 341 | 342 | def test_close_neighbor_nested(self, root, grid): 343 | a, b, c, d, e = grid 344 | f, g, h, i, j, k, L = Nodes('f g h i j k l') 345 | root.add_child(f) 346 | d.flip_with(h) 347 | a.flip_with(i) 348 | e.flip_with(j) 349 | e.parent.add_child(k) 350 | f.flip_with(L) 351 | f.height = 10 352 | assert b.close_down is d 353 | b.flip_with(g) 354 | assert b.close_down is c 355 | assert d.close_right is e 356 | assert e.close_left is d 357 | assert L.close_left is e 358 | assert e.close_up is g 359 | assert L.close_right is None 360 | assert h.close_down is None 361 | 362 | def test_close_neighbor_approx(self, root, small_grid): 363 | """Tolerate floating point errors when calculating common borders.""" 364 | root.height += 30 365 | a, b, c, d = small_grid 366 | e, f, g = Nodes('e f g') 367 | c.flip_with(f) 368 | b.parent.add_child(e) 369 | c.parent.add_child(g) 370 | assert g.close_down is e 371 | 372 | def test_points(self, root, small_grid): 373 | a, b, c, d = small_grid 374 | assert c.top_left == (60, 25) 375 | assert c.top_right == (90, 25) 376 | assert c.bottom_left == (60, 50) 377 | assert c.bottom_right == (90, 50) 378 | 379 | def test_center(self, root): 380 | assert root.x_center == 60 381 | assert root.y_center == 25 382 | assert root.center == (60, 25) 383 | 384 | def test_recent_leaf(self, root, grid): 385 | a, b, c, d, e = grid 386 | assert d.parent.recent_leaf is c 387 | c.access() 388 | d.access() 389 | assert d.parent.recent_leaf is d 390 | b.access() 391 | c.access() 392 | assert root.recent_leaf is c 393 | a.access() 394 | assert root.recent_leaf is a 395 | 396 | def test_recent_close_neighbor(self, root, grid): 397 | a, b, c, d, e = grid 398 | assert b.close_down is d 399 | c.access() 400 | assert b.close_down is c 401 | assert a.close_right is c 402 | b.access() 403 | assert a.close_right is b 404 | 405 | def test_add_node(self, root): 406 | a, b, c, d, e, f, g = Nodes('a b c d e f g') 407 | root.add_node(a) 408 | assert root.tree == [a] 409 | root.add_node(b) 410 | assert root.tree == [a, b] 411 | a.add_node(c) 412 | assert root.tree == [a, c, b] 413 | c.add_node(d, mode=AddMode.HORIZONTAL) 414 | assert root.tree == [a, c, d, b] 415 | root.remove_child(d) 416 | c.add_node(d, mode=AddMode.VERTICAL) 417 | c.parent.add_child_after 418 | assert root.tree == [a, [c, d], b] 419 | c.add_node(e, mode=AddMode.VERTICAL) 420 | assert root.tree == [a, [c, e, d], b] 421 | assert a.width == 40 422 | a.add_node(f, mode=AddMode.HORIZONTAL | AddMode.SPLIT) 423 | assert root.tree == [a, f, [c, e, d], b] 424 | assert a.width == f.width == 20 425 | assert c.parent.width == b.width == 40 426 | a.add_node(g, mode=AddMode.VERTICAL | AddMode.SPLIT) 427 | assert root.tree == [[a, g], f, [c, e, d], b] 428 | 429 | def test_contains(self, root, grid): 430 | x = Node('x') 431 | nodes = list(grid) 432 | nodes += [n.parent for n in nodes] 433 | nodes.append(root) 434 | for n in nodes: 435 | assert n in root 436 | assert x not in root 437 | 438 | class TestSizes: 439 | 440 | def test_size(self, grid): 441 | a, b, c, d, e = grid 442 | assert a.size == a.width == 60 443 | assert b.size == b.height == 25 444 | 445 | def test_capacity(self, root, grid): 446 | a, b, c, d, e = grid 447 | assert root.capacity == 120 448 | assert b.parent.capacity == 50 449 | assert c.parent.capacity == 60 450 | assert c.capacity == 25 451 | 452 | def test_capacity2(self, root): 453 | a, b, c = Nodes('a b c') 454 | root.add_child(a) 455 | root.add_child(b) 456 | b.flip_with(c) 457 | 458 | def test_resize(self, root, grid): 459 | a, b, c, d, e = grid 460 | a.size += 10 461 | assert a.width == a.size == 70 462 | assert b.height == b.size == 25 463 | assert b.width == 50 464 | assert c.width == d.width == e.width == 50/3 465 | assert a.pos == (0, 0) 466 | assert b.pos == (70, 0) 467 | assert c.pos == (70, 25) 468 | assert d.pos == (70 + 50/3, 25) 469 | assert e.pos == (70 + (50/3)*2, 25) 470 | b.size -= 5 471 | assert c.width == d.width == e.width == 50/3 472 | assert c.height == d.height == e.height == 30 473 | d.size += 5 474 | assert d.width == 50/3 + 5 475 | d.move_up() 476 | assert d.size == (50 - b.size) / 2 477 | b.integrate_down() 478 | assert b.size == d.size == 25 479 | assert b.parent.size == 25 480 | 481 | def test_resize_absolute(self, grid): 482 | a, b, c, d, e = grid 483 | b.size = 10 484 | assert b.size == b.height == 10 485 | assert c.parent.size == 40 486 | b.size = 5 487 | assert b.size == 10 488 | 489 | def test_resize_absolute2(self, root): 490 | a, b, c = Nodes('a b c') 491 | root.add_child(a) 492 | root.add_child(b) 493 | root.add_child(c) 494 | a.size = 30 495 | b.size = 60 496 | c.size = 40 497 | assert a.size == 30 * (80/90) 498 | assert b.size == 60 * (80/90) 499 | assert c.size == 40 500 | 501 | def test_resize_absolute_and_relative(self, root): 502 | a, b, c, d = Nodes('a b c d') 503 | root.add_child(a) 504 | root.add_child(b) 505 | a.size = 20 506 | b.size = 20 507 | assert a.size == 100 508 | assert b.size == 20 509 | root.add_child(c) 510 | assert c.size == approx(40) 511 | assert a.size == approx(100 * (2/3)) 512 | assert b.size == approx(20 * (2/3)) 513 | root.add_child(d) 514 | assert c.size == d.size == approx(20) 515 | 516 | def test_resize_absolute_and_relative2(self, root): 517 | a, b, c = Nodes('a b c') 518 | root.add_child(a) 519 | root.add_child(b) 520 | root.add_child(c) 521 | a.size += 10 522 | assert a.size == 50 523 | assert b.size == 35 524 | assert c.size == 35 525 | b.size += 10 526 | assert a.size == 50 527 | assert b.size == 45 528 | assert c.size == 25 529 | 530 | def test_resize_flat(self, root): 531 | a, b, c, d, e, f = Nodes('a b_abs c d e_abs f_abs') 532 | root.add_child(a) 533 | root.add_child(b) 534 | root.add_child(c) 535 | root.add_child(d) 536 | d.flip_with(e) 537 | e.flip_with(f) 538 | b.size = b.size 539 | e.size = e.size 540 | f.size = f.size 541 | a.size = 60 542 | assert a.size == 60 543 | assert b.size == 25 544 | assert c.size == 10 545 | assert d.parent.size == 25 546 | assert e.size == f.size == 25/2 547 | 548 | def test_resize_minimum(self, grid): 549 | a, b, c, d, e = grid 550 | b.size -= 100 551 | assert b.size == 10 552 | 553 | def test_resize_all_absolute_underflow(self, root, grid): 554 | a, b, c, d, e = grid 555 | c.size = 10 556 | d.size = 10 557 | assert e.size == 40 558 | e.size = 10 559 | assert e.size == 10 560 | assert c.size == d.size == 25 561 | 562 | def test_resize_all_absolute_overflow(self, grid): 563 | a, b, c, d, e = grid 564 | c.size = d.size = 15 565 | e.size = 40 566 | assert e.size == 40 567 | assert c.size == d.size == 10 568 | e.size = 50 569 | assert e.size == 40 570 | assert c.size == d.size == 10 571 | 572 | def test_resize_overflow_with_relative(self, root, grid): 573 | a, b, c, d, e = grid 574 | c.size = 20 575 | d.size = 40 576 | assert c.size == 10 577 | assert d.size == 40 578 | assert e.size == 10 579 | assert e.flexible 580 | d.size = 50 581 | assert c.size == 10 582 | assert d.size == 40 583 | assert e.size == 10 584 | assert e.flexible 585 | 586 | def test_resize_overflow_with_relative2(self, root, grid): 587 | a, b, c, d, e = grid 588 | c.size = 20 589 | d.size = 20 590 | a.size = 70 591 | assert a.size == 70 592 | assert c.size == d.size == 20 593 | assert e.size == 10 594 | a.size = 80 595 | assert a.size == 80 596 | assert c.size == d.size == 15 597 | assert e.size == 10 598 | a.size = 90 599 | assert a.size == 90 600 | assert c.size == d.size == e.size == 10 601 | a.size = 100 602 | assert a.size == 90 603 | 604 | def test_resize_only_absolute_remains(self, root): 605 | a, b, c = Nodes('a b c') 606 | root.add_child(a) 607 | root.add_child(b) 608 | a.size = 20 609 | b.size = 20 610 | root.add_child(c) 611 | root.remove_child(c) 612 | assert a.size == 100 613 | assert b.size == 20 614 | 615 | def test_reset_size(self, grid): 616 | a, b, c, d, e = grid 617 | a.size += 5 618 | assert a.size == 65 619 | a.reset_size() 620 | assert a.size == 60 621 | 622 | def test_size_after_split(self, root): 623 | a, b, c = Nodes('a b c') 624 | root.add_child(a) 625 | root.add_child(b) 626 | b.size -= 20 627 | b.flip_with(c) 628 | assert b.parent.size == 40 629 | assert b.size == c.size == 25 630 | b.remove() 631 | assert c.size == 40 632 | 633 | def test_only_child_must_be_flexible(self, root): 634 | a, b = Nodes('a b') 635 | root.add_child(a) 636 | root.add_child(b) 637 | a.size = 10 638 | root.remove_child(b) 639 | assert a.flexible 640 | 641 | def test_deny_only_child_resize(self, root): 642 | a = Node('a') 643 | root.add_child(a) 644 | a.size = 10 645 | assert a.size == 120 646 | 647 | def test_resize_parents(self, root): 648 | a, b, c = Nodes('a b c') 649 | root.add_child(a) 650 | root.add_child(b) 651 | b.flip_with(c) 652 | b.width += 10 653 | assert b.parent.size == 70 654 | assert b.size == c.size == 25 655 | 656 | def test_pixelperfect(self, root, tiny_grid): 657 | a, b, c = tiny_grid 658 | root._height = 11 659 | root._width = 11 660 | ds = a.pixel_perfect 661 | assert all(type(x) is int for x in (ds.x, ds.y, ds.width, ds.height)) 662 | assert a.width + b.width == 11 663 | assert a.pixel_perfect.width + b.pixel_perfect.width == 11 664 | assert b.height + c.height == 11 665 | assert b.pixel_perfect.height + c.pixel_perfect.height == 11 666 | 667 | def test_pixelperfect_draw(self, root, complex_grid): 668 | root._height = 10 669 | for i in range(40, 50): 670 | root._width = i 671 | view = draw(root) 672 | assert '#' not in view 673 | root._width = 50 674 | for i in range(10, 20): 675 | root._height = i 676 | view = draw(root) 677 | assert '#' not in view 678 | 679 | def test_resize_root(self, root): 680 | a, b, c = Nodes('a b c') 681 | root.add_child(a) 682 | root.add_child(b) 683 | a.height += 10 684 | root.height += 10 685 | root.width += 10 686 | root.size = 10 687 | assert a._size is b._size is root._size is None 688 | 689 | def test_set_xy(self, root, tiny_grid): 690 | a, b, c = tiny_grid 691 | root.x = 10 692 | root.y = 20 693 | assert root.x == 10 694 | assert root.y == 20 695 | a.x = 30 696 | a.y = 40 697 | assert a.x == root.x == 10 698 | assert a.y == root.y == 20 699 | root.width = 50 700 | root.height = 60 701 | assert root._width == 50 702 | assert root._height == 60 703 | 704 | def test_set_width_height(self, root, tiny_grid): 705 | a, b, c = tiny_grid 706 | a.width = 70 707 | assert a.width == 70 708 | assert b.width == c.width == 50 709 | b.height = 30 710 | assert b.height == 30 711 | assert c.height == 20 712 | b.width = 80 713 | assert b.width == c.width == 80 714 | assert a.width == 40 715 | a.height = 20 716 | assert a.height == 50 717 | 718 | def test_min_size(self, root, small_grid): 719 | a, b, c, d = small_grid 720 | c.size += 10 721 | d.size += 20 722 | b.size = 20 723 | assert a.min_size == Node.min_size_default 724 | assert b.parent.min_size == 60 725 | assert b.min_size == 20 726 | assert c.parent.min_size == Node.min_size_default 727 | assert c.min_size == 20 728 | assert d.min_size == 40 729 | 730 | def test_transitive_flexible(self, root, complex_grid): 731 | a, b, c, d, e, f, g = complex_grid 732 | assert b.parent.flexible 733 | d.size = 20 734 | e.size = 20 735 | f.size = 10 736 | assert b.parent.flexible 737 | g.size = 10 738 | assert not b.parent.flexible 739 | 740 | def test_resize_bubbles(self, root, small_grid): 741 | a, b, c, d = small_grid 742 | c.size += 10 743 | d.size += 20 744 | assert c.size == 20 745 | assert d.size == 40 746 | a.size = 30 747 | assert c.size == 30 748 | assert d.size == 60 749 | 750 | def test_resize_bubbles2(self, root, complex_grid): 751 | a, b, c, d, e, f, g = complex_grid 752 | c.flip_with(Node('h')) 753 | f.size += 10 754 | g.size += 10 755 | assert f.size == g.size == 10 756 | assert f.fixed and g.fixed 757 | assert d.size == e.size == 20 758 | assert d.flexible and e.flexible 759 | a.size -= 40 760 | assert a.size == 20 761 | assert f.size == g.size == 10 762 | assert d.size == e.size == 40 763 | d.size = 10 764 | assert d.size == 10 765 | assert e.size == 70 766 | assert f.size == g.size == 10 767 | assert e.flexible 768 | e.size = 10 769 | assert e.fixed 770 | 771 | def test_resize_bubbles3(self, root, complex_grid): 772 | a, b, c, d, e, f, g = complex_grid 773 | h = Node('h') 774 | c.flip_with(h) 775 | f.size += 10 776 | g.size += 10 777 | assert f.size == g.size == c.size == h.size == 10 778 | a.size = 10 779 | assert a.size == 10 780 | assert f.size == g.size == c.size == h.size == 10 781 | assert d.size == e.size == 45 782 | d.size = 10 783 | assert d.size == 10 784 | assert e.size == 80 785 | e.size = 10 786 | assert e.size == 10 787 | assert f.size == g.size == c.size == h.size == d.size == 100/3 788 | 789 | def test_resize_nested(self, root): 790 | a, b, c, d, e, f, g, h = Nodes('a b c_abs d_abs e f g h_abs') 791 | nu1, nu2, nd, mu, md1, md2 = Nodes('nu1_abs nu2_abs nd mu md1_abs md2') 792 | ou1, ou2, od, pu, pd1, pd2 = Nodes('ou1_abs ou2_abs od pu pd1_abs ' 793 | 'pd2_abs') 794 | root.add_child(a) 795 | root.add_child(b) 796 | b.flip_with(c) 797 | b.parent.add_child(e) 798 | b.parent.add_child(g) 799 | c.flip_with(d) 800 | e.flip_with(f) 801 | g.flip_with(h) 802 | 803 | b.parent.add_child(nu1) 804 | nu1.flip_with(mu) 805 | nu1.flip_with(nd) 806 | nu1.flip_with(nu2) 807 | mu.flip_with(md1) 808 | md1.flip_with(md2) 809 | 810 | b.parent.add_child(ou1) 811 | ou1.flip_with(pu) 812 | ou1.flip_with(od) 813 | ou1.flip_with(ou2) 814 | pu.flip_with(pd1) 815 | pd1.flip_with(pd2) 816 | 817 | def assert_first_state(): 818 | assert b.parent.size == 60 819 | assert c.size == 40 820 | assert d.size == 20 821 | assert e.size == f.size == 30 822 | assert g.size == 40 823 | assert h.size == 20 824 | assert nu1.size == 10 825 | assert nu2.size == 20 826 | assert nd.parent.size == 30 827 | assert mu.parent.size == 30 828 | assert md1.size == 10 829 | assert md2.size == 20 830 | assert ou1.size == 10 831 | assert ou2.size == 20 832 | assert od.parent.size == 30 833 | assert pu.parent.size == 30 834 | assert pd1.size == 10 835 | assert pd2.size == 20 836 | 837 | def assert_second_state(): 838 | assert a.size == 30 839 | assert b.parent.size == 90 840 | assert c.size == 60 841 | assert d.size == 30 842 | assert e.size == f.size == 45 843 | assert g.size == 70 844 | assert h.size == 20 845 | assert nu1.size == 10 846 | assert nu2.size == 20 847 | assert nd.parent.size == 30 848 | assert mu.parent.size == 60 849 | assert md1.size == 10 850 | assert md2.size == 50 851 | assert ou1.size == 15 852 | assert ou2.size == 30 853 | assert od.parent.size == 45 854 | assert pd1.size == 15 855 | assert pd2.size == 30 856 | assert pu.parent.size == 45 857 | 858 | b.parent.size = 60 859 | c.size += 5 860 | d.size -= 5 861 | h.size = 20 862 | nu1.size = 10 863 | nu2.size = 20 864 | md1.size = 10 865 | ou1.size = 10 866 | ou2.size = 20 867 | pd1.size = 10 868 | pd2.size = 20 869 | 870 | assert a.size == 60 871 | assert_first_state() 872 | a.size -= 30 873 | assert_second_state() 874 | a.size += 30 875 | assert a.size == 60 876 | assert_first_state() 877 | b.parent.size += 30 878 | assert_second_state() 879 | b.parent.size -= 30 880 | assert a.size == 60 881 | assert_first_state() 882 | 883 | a.size = 30 884 | x = Node('x') 885 | root.add_child(x) 886 | assert x.size == 40 887 | assert_first_state() 888 | x.remove() 889 | assert_second_state() 890 | 891 | a.remove() 892 | assert b.width == 120 893 | y = Node('y') 894 | root.add_child(y) 895 | assert_first_state() 896 | 897 | def test_resize_max(self, root, tiny_grid): 898 | a, b, c = tiny_grid 899 | a.width = 120 900 | assert a.width == 110 901 | assert b.width == c.width == 10 902 | 903 | class TestRestore: 904 | 905 | def test_restore(self, root, grid): 906 | a, b, c, d, e = grid 907 | tree = root.tree 908 | for node in grid: 909 | node.remove() 910 | root.restore(node) 911 | assert root.tree == tree 912 | 913 | def test_restore_same_payload(self, root, grid): 914 | """Restore a node that's not identical with the removed one but carries 915 | the same payload. 916 | """ 917 | a, b, c, d, e = grid 918 | d.remove() 919 | new_d = Node('d') 920 | root.restore(new_d) 921 | assert root.tree == [a, [b, [c, new_d, e]]] 922 | 923 | def test_restore_unknown(self, root, grid): 924 | a, b, c, d, e = grid 925 | with pytest.raises(NotRestorableError): 926 | root.restore(Node('x')) 927 | d.remove() 928 | with pytest.raises(NotRestorableError): 929 | root.restore(Node('x')) 930 | root.restore(d) 931 | assert root.tree == [a, [b, [c, d, e]]] 932 | 933 | def test_restore_no_parent(self, root, small_grid): 934 | a, b, c, d = small_grid 935 | c.remove() 936 | d.remove() 937 | with pytest.raises(NotRestorableError): 938 | root.restore(c) 939 | root.restore(d) 940 | assert root.tree == [a, [b, d]] 941 | 942 | def test_restore_bad_index(self, root, grid): 943 | a, b, c, d, e = grid 944 | f, g = Nodes('f g') 945 | e.parent.add_child(f) 946 | e.parent.add_child(g) 947 | g.remove() 948 | f.remove() 949 | e.remove() 950 | root.restore(g) 951 | assert root.tree == [a, [b, [c, d, g]]] 952 | 953 | def test_restore_sizes(self, root, grid): 954 | a, b, c, d, e = grid 955 | c.size = 30 956 | c.remove() 957 | root.restore(c) 958 | assert c.size == 30 959 | c.remove() 960 | d.size = 30 961 | e.size = 30 962 | assert d.size == e.size == 30 963 | root.restore(c) 964 | assert c.size == 30 965 | assert d.size == e.size == 15 966 | 967 | def test_restore_sizes_flip(self, root, tiny_grid): 968 | a, b, c = tiny_grid 969 | c.size = 10 970 | c.remove() 971 | assert a._size is b._size is None 972 | root.restore(c) 973 | assert c.size == 10 974 | b.size = 10 975 | c.remove() 976 | root.restore(c) 977 | assert b.size == 10 978 | b.remove() 979 | root.restore(b) 980 | assert b.size == 10 981 | 982 | def test_restore_root(self, root): 983 | a, b = Nodes('a b') 984 | root.add_child(a) 985 | root.add_child(b) 986 | a.size = 20 987 | a.remove() 988 | root.restore(a) 989 | assert a._size == 20 990 | assert b._size is None 991 | b.remove() 992 | root.restore(b) 993 | assert a._size == 20 994 | assert b._size is None 995 | 996 | def test_restore_root2(self, root): 997 | a, b, c = Nodes('a b c') 998 | root.add_child(a) 999 | root.add_child(b) 1000 | root.add_child(c) 1001 | b.size = 20 1002 | c.size = 40 1003 | a.remove() 1004 | assert b.size == 40 1005 | assert c.size == 80 1006 | root.restore(a) 1007 | assert not a.fixed 1008 | assert a.size == 60 1009 | assert b.size == 20 1010 | assert c.size == 40 1011 | 1012 | def test_restore_keep_flexible(self, root, tiny_grid): 1013 | a, b, c = tiny_grid 1014 | b.remove() 1015 | root.restore(b) 1016 | assert a._size is b._size is c._size is None 1017 | b.size = 10 1018 | b.remove() 1019 | root.restore(b) 1020 | assert b._size == 10 1021 | assert c._size is None 1022 | c.remove() 1023 | root.restore(c) 1024 | assert b._size == 10 1025 | assert c._size is None 1026 | c.size = 10 1027 | b.reset_size() 1028 | b.remove() 1029 | root.restore(b) 1030 | assert b._size is None 1031 | assert c._size == 10 1032 | c.remove() 1033 | root.restore(c) 1034 | assert b._size is None 1035 | assert c._size == 10 1036 | 1037 | def test_resize_with_collapse_and_restore(self, root, small_grid): 1038 | a, b, c, d = small_grid 1039 | root.height = 30 1040 | c.size = 30 1041 | d.size += 10 1042 | b.remove() 1043 | assert c.size == c.height == 10 1044 | assert d.size == d.height == 20 1045 | root.restore(b) 1046 | assert b.height == 15 1047 | assert b.width == 60 1048 | assert c.height == d.height == 15 1049 | assert c.width == 20 1050 | assert d.width == 40 1051 | -------------------------------------------------------------------------------- /tools/make_readme.py: -------------------------------------------------------------------------------- 1 | """Update documentation in readme. 2 | 3 | This tool extracts the documentation (docstrings) for all layout commands 4 | (startings with "cmd_") from the layout class and inserts them as a table into 5 | the readme file, in the marked area. 6 | 7 | It should be run with every API change. 8 | """ 9 | 10 | import ast 11 | import re 12 | 13 | 14 | readme_path = 'README.md' 15 | layout_path = 'plasma/layout.py' 16 | marker = '\n' 17 | start_marker = marker.format(pos='start') 18 | end_marker = marker.format(pos='end') 19 | 20 | def table(text): 21 | return '\n%s
\n' % text 22 | 23 | def row(text): 24 | return ' \n%s \n' % text 25 | 26 | def col(text): 27 | return ' %s\n' % text 28 | 29 | def code(text): 30 | return '%s' % text 31 | 32 | def function_name(name, args): 33 | return '%s(%s)' % (name, ', '.join(args)) 34 | 35 | def function_desc(text): 36 | return re.sub('`([^`]*)`', code('\\1'), text.replace('\n\n', '
\n')) 37 | 38 | def main(): 39 | with open(readme_path) as f: 40 | text = f.read() 41 | text_pre, rest = text.split(start_marker) 42 | _, text_post = rest.split(end_marker) 43 | 44 | with open(layout_path) as f: 45 | tree = ast.parse(f.read()) 46 | 47 | rows = '' 48 | for node in ast.walk(tree): 49 | if not isinstance(node, ast.FunctionDef): 50 | continue 51 | if not node.name.startswith('cmd_'): 52 | continue 53 | name = node.name[4:] 54 | args = [a.arg for a in node.args.args[1:]] 55 | docstring = ast.get_docstring(node) 56 | rows += row( 57 | col(code(function_name(name, args))) + 58 | col(function_desc(docstring)) 59 | ) 60 | text_table = table(rows) 61 | 62 | with open(readme_path, 'w') as f: 63 | f.write(text_pre + start_marker + text_table + end_marker + text_post) 64 | print('Commands written to "%s".' % readme_path) 65 | 66 | if __name__ == '__main__': 67 | main() 68 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37,py38,py39,lint,coverage-report 3 | 4 | [testenv] 5 | deps = 6 | setuptools>=41 7 | xcffib 8 | coverage 9 | pytest<5.0.0 10 | pytest-xdist 11 | commands = 12 | coverage run --parallel -m pytest -v {posargs} tests/ 13 | 14 | [testenv:coverage-report] 15 | basepython = python3.9 16 | skip_install = true 17 | deps = coverage 18 | commands = 19 | coverage combine 20 | coverage report 21 | 22 | [testenv:lint] 23 | deps = 24 | xcffib 25 | flake8 26 | pylint 27 | commands = 28 | flake8 plasma/ 29 | pylint --rcfile setup.cfg plasma/ 30 | 31 | [testenv:release] 32 | deps = 33 | wheel 34 | twine 35 | commands = 36 | rm -rf *.egg-info build/ dist/ 37 | python setup.py bdist_wheel sdist 38 | twine upload -r pypi dist/* 39 | rm -rf *.egg-info build/ dist/ 40 | 41 | [pylint] 42 | disable = 43 | missing-docstring, 44 | invalid-name, 45 | unused-argument, 46 | too-few-public-methods, 47 | too-many-public-methods, 48 | protected-access, 49 | no-self-use, 50 | too-many-instance-attributes, 51 | fixme, 52 | raise-missing-from, 53 | --------------------------------------------------------------------------------