Completed
Push — master ( 36e57a...f7d5df )
by Stephan
31s
created

WindowManager.calc_win_gravity()   B

Complexity

Conditions 1

Size

Total Lines 32

Duplication

Lines 0
Ratio 0 %

Importance

Changes 5
Bugs 1 Features 0
Metric Value
cc 1
c 5
b 1
f 0
dl 0
loc 32
rs 8.8571
1
"""Wrapper around libwnck for interacting with the window manager"""
2
3
__author__ = "Stephan Sokolow (deitarion/SSokolow)"
4
__license__ = "GNU GPL 2.0 or later"
5
6
import logging
7
from contextlib import contextmanager
8
9
import gtk.gdk, wnck           # pylint: disable=import-error
10
from gtk.gdk import Rectangle  # pylint: disable=import-error
11
12
from .util import clamp_idx, EnumSafeDict, XInitError
13
14
# Allow MyPy to work without depending on the `typing` package
15
# (And silence complaints from only using the imported types in comments)
16
try:
17
    # pylint: disable=unused-import
18
    from typing import List, Optional, Sequence, Tuple  # NOQA
19
    from .util import Strut  # NOQA
20
except:  # pylint: disable=bare-except
21
    pass
0 ignored issues
show
Unused Code introduced by
This except handler seems to be unused and could be removed.

Except handlers which only contain pass and do not have an else clause can usually simply be removed:

try:
    raises_exception()
except:  # Could be removed
    pass
Loading history...
22
23
#: Lookup table for internal window gravity support.
24
#: (libwnck's support is either unreliable or broken)
25
GRAVITY = EnumSafeDict({
26
    'NORTH_WEST': (0.0, 0.0),
27
    'NORTH': (0.5, 0.0),
28
    'NORTH_EAST': (1.0, 0.0),
29
    'WEST': (0.0, 0.5),
30
    'CENTER': (0.5, 0.5),
31
    'EAST': (1.0, 0.5),
32
    'SOUTH_WEST': (0.0, 1.0),
33
    'SOUTH': (0.5, 1.0),
34
    'SOUTH_EAST': (1.0, 1.0),
35
})
36
key, val = None, None  # Safety cushion for the "del" line.
0 ignored issues
show
Coding Style Naming introduced by
The name key does not conform to the constant naming conventions ((([A-Z_][A-Z0-9_]*)|(__.*__))$).

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
Coding Style Naming introduced by
The name val does not conform to the constant naming conventions ((([A-Z_][A-Z0-9_]*)|(__.*__))$).

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
37
for key, val in GRAVITY.items():
0 ignored issues
show
Bug introduced by
The Instance of EnumSafeDict does not seem to have a member named items.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
38
    # Support GDK gravity constants
39
    GRAVITY[getattr(gtk.gdk, 'GRAVITY_%s' % key)] = val
40
41
    # Support libwnck gravity constants
42
    _name = 'WINDOW_GRAVITY_%s' % key.replace('_', '')
43
    GRAVITY[getattr(wnck, _name)] = val
44
45
# Prevent these temporary variables from showing up in the apidocs
46
del _name, key, val
47
48
# ---
49
50
@contextmanager
51
def persist_maximization(win, keep_maximize=True):
52
    """Context manager to persist maximization state after a reposition
53
54
    @param keep_maximize: If C{False}, this becomes a no-op to ease writing
55
        clean code which needs to support both behaviours.
56
    """
57
    # Unmaximize and record the types we may need to restore
58
    max_types, maxed = ['', '_horizontally', '_vertically'], []
59
    for maxtype in max_types:
60
        if getattr(win, 'is_maximized' + maxtype)():
61
            maxed.append(maxtype)
62
            getattr(win, 'unmaximize' + maxtype)()
63
64
    yield
65
66
    # Restore maximization if asked
67
    if maxed and keep_maximize:
68
        for maxtype in maxed:
69
            getattr(win, 'maximize' + maxtype)()
70
71
72
class WorkArea(object):
73
    """Helper to calculate and query available workarea on the desktop."""
74
    def __init__(self, gdk_screen, ignore_struts=False):
75
        # type: (gtk.gdk.Screen, bool) -> None
76
        self.gdk_screen = gdk_screen
77
        self.ignore_struts = ignore_struts
78
79
    def get_struts(self, root_win):  # type: (gtk.gdk.Window) -> List[Strut]
80
        """Retrieve the struts from the root window if supported."""
81
        if not self.gdk_screen.supports_net_wm_hint("_NET_WM_STRUT_PARTIAL"):
82
            return []
83
84
        # Gather all struts
85
        struts = [root_win.property_get("_NET_WM_STRUT_PARTIAL")]
86
        if self.gdk_screen.supports_net_wm_hint("_NET_CLIENT_LIST"):
87
            # Source: http://stackoverflow.com/a/11332614/435253
88
            for wid in root_win.property_get('_NET_CLIENT_LIST')[2]:
89
                w = gtk.gdk.window_foreign_new(wid)
0 ignored issues
show
Coding Style Naming introduced by
The name w does not conform to the variable naming conventions ([a-z_][a-z0-9_]{2,30}$).

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
90
                struts.append(w.property_get("_NET_WM_STRUT_PARTIAL"))
91
        struts = [tuple(x[2]) for x in struts if x]
92
93
        logging.debug("Gathered _NET_WM_STRUT_PARTIAL values:\n\t%s",
94
                      struts)
95
        return struts
96
97
    def subtract_struts(self, usable_region,  # type: gtk.gdk.Region
98
                        struts                # type: Sequence[Strut]
99
                        ):  # type: (...) -> Tuple[gtk.gdk.Region, Rectangle]
100
        """Subtract the given struts from the given region."""
101
102
        # Subtract the struts from the usable region
103
        _Sub = lambda *g: usable_region.subtract(
0 ignored issues
show
Coding Style Naming introduced by
The name _Sub does not conform to the variable naming conventions ([a-z_][a-z0-9_]{2,30}$).

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
104
            gtk.gdk.region_rectangle(g))
105
        _w, _h = self.gdk_screen.get_width(), self.gdk_screen.get_height()
0 ignored issues
show
Coding Style Naming introduced by
The name _w does not conform to the variable naming conventions ([a-z_][a-z0-9_]{2,30}$).

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
Coding Style Naming introduced by
The name _h does not conform to the variable naming conventions ([a-z_][a-z0-9_]{2,30}$).

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
106
        for g in struts:  # pylint: disable=invalid-name
107
            # http://standards.freedesktop.org/wm-spec/1.5/ar01s05.html
108
            # XXX: Must not cache unless watching for notify events.
109
            _Sub(0, g[4], g[0], g[5] - g[4] + 1)             # left
110
            _Sub(_w - g[1], g[6], g[1], g[7] - g[6] + 1)     # right
111
            _Sub(g[8], 0, g[9] - g[8] + 1, g[2])             # top
112
            _Sub(g[10], _h - g[3], g[11] - g[10] + 1, g[3])  # bottom
113
114
        # Generate a more restrictive version used as a fallback
115
        usable_rect = usable_region.copy()
116
        _Sub = lambda *g: usable_rect.subtract(gtk.gdk.region_rectangle(g))
0 ignored issues
show
Coding Style Naming introduced by
The name _Sub does not conform to the variable naming conventions ([a-z_][a-z0-9_]{2,30}$).

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
117
        for geom in struts:
118
            # http://standards.freedesktop.org/wm-spec/1.5/ar01s05.html
119
            # XXX: Must not cache unless watching for notify events.
120
            _Sub(0, geom[4], geom[0], _h)             # left
121
            _Sub(_w - geom[1], geom[6], geom[1], _h)  # right
122
            _Sub(0, 0, _w, geom[2])                   # top
123
            _Sub(0, _h - geom[3], _w, geom[3])        # bottom
124
            # TODO: The required "+ 1" in certain spots confirms that we're
125
            #       going to need unit tests which actually check that the
126
            #       WM's code for constraining windows to the usable area
127
            #       doesn't cause off-by-one bugs.
128
            # TODO: Share this on http://stackoverflow.com/q/2598580/435253
129
        return usable_rect.get_clipbox(), usable_region
130
131
    def get(self, monitor, ignore_struts=None):
132
        # type: (Rectangle, bool) -> Tuple[gtk.gdk.Region, Rectangle]
133
        """Retrieve the usable area of the specified monitor using
134
        the most expressive method the window manager supports.
135
136
        @param monitor: The number or dimensions of the desired monitor.
137
        @param ignore_struts: If C{True}, just return the size of the whole
138
            monitor, allowing windows to overlap panels.
139
140
        @type monitor: C{gtk.gdk.Rectangle}
141
        @type ignore_struts: C{bool}
142
143
        @returns: The usable region and its largest rectangular subset.
144
        @rtype: C{gtk.gdk.Region}, C{gtk.gdk.Rectangle}
145
        """
146
147
        # Get the region and return failure early if it's empty
148
        usable_rect, usable_region = monitor, gtk.gdk.region_rectangle(monitor)
149
        if not usable_region.get_rectangles():
150
            logging.error("WorkArea.get_monitor_rect received "
151
                          "an empty monitor region!")
152
            return None, None
153
154
        # Return early if asked to ignore struts
155
        if ignore_struts or (ignore_struts is None and self.ignore_struts):
156
            logging.debug("Panels ignored. Reported monitor geometry is:\n%s",
157
                          usable_rect)
158
            return usable_region, usable_rect
159
160
        # Get the list of struts from the root window
161
        root_win = self.gdk_screen.get_root_window()
162
        struts = self.get_struts(root_win)
163
164
        # Fall back to _NET_WORKAREA if we couldn't get any struts
165
        if struts:
166
            usable_rect, usable_region = self.subtract_struts(usable_region,
167
                                                              struts)
168
        elif self.gdk_screen.supports_net_wm_hint("_NET_WORKAREA"):
169
            desktop_geo = tuple(root_win.property_get('_NET_WORKAREA')[2][0:4])
170
            logging.debug("Falling back to _NET_WORKAREA: %s", desktop_geo)
171
            usable_region.intersect(gtk.gdk.region_rectangle(desktop_geo))
172
            usable_rect = usable_region.get_clipbox()
173
174
        # FIXME: Only call get_rectangles if --debug
175
        logging.debug("Usable region of monitor calculated as:\n"
176
                      "\tRegion: %r\n\tRectangle: %r",
177
                      usable_region.get_rectangles(), usable_rect)
178
        return usable_region, usable_rect
179
180
181
class WindowManager(object):
182
    """A simple API-wrapper class for manipulating window positioning."""
183
184
    def __init__(self, screen=None, ignore_workarea=False):
185
        # type: (gtk.gdk.Screen, bool) -> None
186
        """
187
        Initializes C{WindowManager}.
188
189
        @param screen: The X11 screen to operate on. If C{None}, the default
190
            screen as retrieved by C{gtk.gdk.screen_get_default} will be used.
191
        @type screen: C{gtk.gdk.Screen}
192
193
        @todo: Confirm that the root window only changes on X11 server
194
               restart. (Something which will crash QuickTile anyway since
195
               PyGTK makes X server disconnects uncatchable.)
196
197
               It could possibly change while toggling "allow desktop icons"
198
               in KDE 3.x. (Not sure what would be equivalent elsewhere)
199
        """
200
        self.gdk_screen = screen or gtk.gdk.screen_get_default()
201
        if self.gdk_screen is None:
202
            raise XInitError("GTK+ could not open a connection to the X server"
203
                             " (bad DISPLAY value?)")
204
205
        # pylint: disable=no-member
206
        self.screen = wnck.screen_get(self.gdk_screen.get_number())
207
        self.workarea = WorkArea(self.gdk_screen,
208
                                 ignore_struts=ignore_workarea)
209
210
    @staticmethod
211
    def calc_win_gravity(geom, gravity):
212
        # (Rectangle, Tuple[float, float]) -> Tuple[int, int]
213
        """Calculate the X and Y coordinates necessary to simulate non-topleft
214
        gravity on a window.
215
216
        @param geom: The window geometry to which to apply the corrections.
217
        @param gravity: A desired gravity chosen from L{GRAVITY}.
218
        @type geom: C{gtk.gdk.Rectangle}
219
        @type gravity: C{wnck.WINDOW_GRAVITY_*} or C{gtk.gdk.GRAVITY_*}
220
221
        @returns: The coordinates to be used to achieve the desired position.
222
        @rtype: C{(x, y)}
223
224
        This exists because, for whatever reason, whether it's wnck, Openbox,
225
        or both at fault, the WM's support for window gravities seems to have
226
        no effect beyond double-compensating for window border thickness unless
227
        using WINDOW_GRAVITY_STATIC.
228
229
        My best guess is that the gravity modifiers are being applied to the
230
        window frame rather than the window itself, hence static gravity would
231
        position correctly and north-west gravity would double-compensate for
232
        the titlebar and border dimensions.
233
234
        ...however, that still doesn't explain why the non-topleft gravities
235
        have no effect. I'm guessing something's just broken.
236
        """
237
        grav_x, grav_y = GRAVITY[gravity]
238
239
        return (
240
            int(geom.x - (geom.width * grav_x)),
241
            int(geom.y - (geom.height * grav_y))
242
        )
243
244
    @staticmethod
245
    def get_geometry_rel(window, monitor_geom):
246
        # type: (wnck.Window, Rectangle) -> Rectangle
247
        """Get window position relative to the monitor rather than the desktop.
248
249
        @param monitor_geom: The rectangle returned by
250
            C{gdk.Screen.get_monitor_geometry}
251
        @type window: C{wnck.Window}
252
        @type monitor_geom: C{gtk.gdk.Rectangle}
253
254
        @rtype: C{gtk.gdk.Rectangle}
255
        """
256
        win_geom = Rectangle(*window.get_geometry())
257
        win_geom.x -= monitor_geom.x
258
        win_geom.y -= monitor_geom.y
259
260
        return win_geom
261
262
    def get_monitor(self, win):
263
        # type: (wnck.Window) -> Tuple[int, Rectangle]
264
        """Given a C{wnck.Window}, retrieve the monitor ID and geometry.
265
266
        @type win: C{wnck.Window}
267
        @returns: A tuple containing the monitor ID and geometry.
268
        @rtype: C{(int, gtk.gdk.Rectangle)}
269
        """
270
        # TODO: Look for a way to get the monitor ID without having
271
        #       to instantiate a gtk.gdk.Window
272
        if not isinstance(win, gtk.gdk.Window):
273
            win = gtk.gdk.window_foreign_new(win.get_xid())
274
275
        # TODO: How do I retrieve the root window from a given one?
276
        monitor_id = self.gdk_screen.get_monitor_at_window(win)
277
        monitor_geom = self.gdk_screen.get_monitor_geometry(monitor_id)
278
279
        logging.debug(" Window is on monitor %s, which has geometry %s",
280
                      monitor_id, monitor_geom)
281
        return monitor_id, monitor_geom
282
283
    def get_relevant_windows(self, workspace):
284
        """C{wnck.Screen.get_windows} without WINDOW_DESKTOP/DOCK windows."""
285
286
        for window in self.screen.get_windows():
287
            # Skip windows on other virtual desktops for intuitiveness
288
            if workspace and not window.is_on_workspace(workspace):
289
                logging.debug("Skipping window on other workspace: %r", window)
290
                continue
291
292
            # Don't cycle elements of the desktop
293
            if not self.is_relevant(window):
294
                continue
295
296
            yield window
297
298
    def get_workspace(self,
299
                      window=None,      # type: wnck.Window
300
                      direction=None,   # type: wnck.MotionDirection
301
                      wrap_around=True  # type: bool
302
                      ):                # type: (...) -> wnck.Workspace
303
        """Get a workspace relative to either a window or the active one.
304
305
        @param window: The point of reference. C{None} for the active workspace
306
        @param direction: The direction in which to look, relative to the point
307
            of reference. Accepts the following types:
308
             - C{wnck.MotionDirection}: Non-cycling direction
309
             - C{int}: Relative index in the list of workspaces
310
             - C{None}: Just get the workspace object for the point of
311
               reference
312
        @param wrap_around: Whether relative indexes should wrap around.
313
314
        @type window: C{wnck.Window} or C{None}
315
        @type wrap_around: C{bool}
316
        @rtype: C{wnck.Workspace} or C{None}
317
        @returns: The workspace object or C{None} if no match could be found.
318
        """
319
        if window:
320
            cur = window.get_workspace()
321
        else:
322
            cur = self.screen.get_active_workspace()
323
324
        if not cur:
325
            return None  # It's either pinned or on no workspaces
326
327
        # pylint: disable=no-member
328
        if isinstance(direction, wnck.MotionDirection):
329
            nxt = cur.get_neighbor(direction)
330
        elif isinstance(direction, int):
331
            # TODO: Deduplicate with the wrapping code in commands.py
332
            n_spaces = self.screen.get_workspace_count()
333
334
            nxt = self.screen.get_workspace(
335
                clamp_idx(cur.get_number() + direction, n_spaces, wrap_around))
336
337
        elif direction is None:
338
            nxt = cur
339
        else:
340
            nxt = None
341
            logging.warn("Unrecognized direction: %r", direction)
342
343
        return nxt
344
345
    def is_relevant(self, window):
0 ignored issues
show
Coding Style introduced by
This method could be written as a function/class method.

If a method does not access any attributes of the class, it could also be implemented as a function or static method. This can help improve readability. For example

class Foo:
    def some_method(self, x, y):
        return x + y;

could be written as

class Foo:
    @classmethod
    def some_method(cls, x, y):
        return x + y;
Loading history...
346
        # type: (wnck.Window) -> bool
347
        """Return False if the window should be ignored.
348
349
        (eg. If it's the desktop or a panel)
350
        """
351
        if not window:
352
            logging.debug("Received no window object to manipulate")
353
            return False
354
355
        if window.get_window_type() in [
356
                wnck.WINDOW_DESKTOP,  # pylint: disable=E1101
357
                wnck.WINDOW_DOCK]:    # pylint: disable=E1101
358
            logging.debug("Irrelevant window: %r", window)
359
            return False
360
        return True
361
362
    def reposition(self,
363
            win,                                    # type: wnck.Window
364
            geom=None,                              # type: Optional[Rectangle]
365
            monitor=Rectangle(0, 0, 0, 0),          # type: Rectangle
366
            keep_maximize=False,                    # type: bool
367
            gravity=wnck.WINDOW_GRAVITY_NORTHWEST,
368
            geometry_mask=wnck.WINDOW_CHANGE_X | wnck.WINDOW_CHANGE_Y |
369
                wnck.WINDOW_CHANGE_WIDTH |
370
                wnck.WINDOW_CHANGE_HEIGHT  # type: wnck.WindowMoveResizeMask
371
                   ):  # pylint: disable=no-member,too-many-arguments
372
        # type: (...) -> None
373
        # TODO: Complete MyPy type signature
374
        # pylint:disable=line-too-long
375
        """
376
        Position and size a window, decorations inclusive, according to the
377
        provided target window and monitor geometry rectangles.
378
379
        If no monitor rectangle is specified, position relative to the desktop
380
        as a whole.
381
382
        @param win: The C{wnck.Window} to operate on.
383
        @param geom: The new geometry for the window. Can be left unspecified
384
            if the intent is to move the window to another monitor without
385
            repositioning it.
386
        @param monitor: The frame relative to which C{geom} should be
387
            interpreted. The whole desktop if unspecified.
388
        @param keep_maximize: Whether to re-maximize a maximized window after
389
            un-maximizing it to move it.
390
        @param gravity: A constant specifying which point on the window is
391
            referred to by the X and Y coordinates in C{geom}.
392
        @param geometry_mask: A set of flags determining which aspects of the
393
            requested geometry should actually be applied to the window.
394
            (Allows the same geometry definition to easily be shared between
395
            operations like move and resize.)
396
        @type win: C{wnck.Window}
397
        @type geom: C{gtk.gdk.Rectangle} or C{None}
398
        @type monitor: C{gtk.gdk.Rectangle}
399
        @type keep_maximize: C{bool}
400
        @type gravity: U{WnckWindowGravity<https://developer.gnome.org/libwnck/stable/WnckWindow.html#WnckWindowGravity>} or U{GDK Gravity Constant<http://www.pygtk.org/docs/pygtk/gdk-constants.html#gdk-gravity-constants>}
401
        @type geometry_mask: U{WnckWindowMoveResizeMask<https://developer.gnome.org/libwnck/2.30/WnckWindow.html#WnckWindowMoveResizeMask>}
402
403
        @todo 1.0.0: Look for a way to accomplish this with a cleaner method
404
            signature. This is getting a little hairy. (API-breaking change)
405
        """  # NOQA
406
407
        # We need to ensure that ignored values are still present for
408
        # gravity calculations.
409
        old_geom = self.get_geometry_rel(win, self.get_monitor(win)[1])
410
        if geom:
411
            for attr in ('x', 'y', 'width', 'height'):
412
                if not geometry_mask & getattr(wnck,
413
                        'WINDOW_CHANGE_%s' % attr.upper()):
414
                    setattr(geom, attr, getattr(old_geom, attr))
415
        else:
416
            geom = old_geom
417
418
        with persist_maximization(win, keep_maximize):
419
            # Apply gravity and resolve to absolute desktop coordinates.
420
            new_x, new_y = self.calc_win_gravity(geom, gravity)
421
            new_x += monitor.x
422
            new_y += monitor.y
423
424
            logging.debug(" Repositioning to (%d, %d, %d, %d)\n",
425
                    new_x, new_y, geom.width, geom.height)
426
427
            # See the calc_win_gravity docstring for the rationale here
428
            win.set_geometry(wnck.WINDOW_GRAVITY_STATIC, geometry_mask,
429
                    new_x, new_y, geom.width, geom.height)
430