Completed
Pull Request — master (#83)
by
unknown
01:03
created

workspace_go()   A

Complexity

Conditions 2

Size

Total Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
c 1
b 0
f 0
dl 0
loc 12
rs 9.4285
1
#!/usr/bin/env python2
2
# -*- coding: utf-8 -*-
3
# pylint: disable=line-too-long,too-many-lines
4
"""QuickTile, a WinSplit clone for X11 desktops
5
6
Thanks to Thomas Vander Stichele for some of the documentation cleanups.
7
8
@todo:
9
 - Reconsider use of C{--daemonize}. That tends to imply self-backgrounding.
10
 - Look into supporting XPyB (the Python equivalent to C{libxcb}) for global
11
   keybinding.
12
 - Clean up the code. It's functional, but an ugly rush-job.
13
 - Implement the secondary major features of WinSplit Revolution (eg.
14
   process-shape associations, locking/welding window edges, etc.)
15
 - Consider rewriting L{cycle_dimensions} to allow command-line use to jump to
16
   a specific index without actually flickering the window through all the
17
   intermediate shapes.
18
 - Can I hook into the GNOME and KDE keybinding APIs without using PyKDE or
19
   gnome-python? (eg. using D-Bus, perhaps?)
20
21
@todo: Merge remaining appropriate portions of:
22
 - U{https://thomas.apestaart.org/thomas/trac/changeset/1123/patches/quicktile/quicktile.py}
23
 - U{https://thomas.apestaart.org/thomas/trac/changeset/1122/patches/quicktile/quicktile.py}
24
 - U{https://thomas.apestaart.org/thomas/trac/browser/patches/quicktile/README}
25
26
@todo 1.0.0: Retire L{KEYLOOKUP}. (API-breaking change)
27
28
@newfield appname: Application Name
29
"""  # NOQA
30
31
__author__ = "Stephan Sokolow (deitarion/SSokolow)"
32
__appname__ = "QuickTile"
33
__version__ = "0.2.2"
34
__license__ = "GNU GPL 2.0 or later"
35
36
import errno, logging, os, sys, time
37
from ConfigParser import RawConfigParser
38
from heapq import heappop, heappush
39
from itertools import chain, combinations
40
from functools import wraps
41
from UserDict import DictMixin
42
43
# TODO: Decide on a way to test this since Nose can't.
44
#: Used to filter spurious libwnck error messages from stderr since PyGTK
45
#: doesn't expose g_log_set_handler() to allow proper filtering.
46
if __name__ == '__main__':  # pragma: nocover
47
    import subprocess
48
    glib_log_filter = subprocess.Popen(
49
            ['grep', '-v', 'Unhandled action type _OB_WM'],
50
            stdin=subprocess.PIPE)
51
52
    # Redirect stderr through grep
53
    os.dup2(glib_log_filter.stdin.fileno(), sys.stderr.fileno())
54
55
try:
56
    import pygtk
57
    pygtk.require('2.0')
58
except ImportError:
59
    pass  # Apparently Travis-CI's build environment doesn't add this
60
61
import gtk, gobject, wnck
62
63
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
64
wnck.set_client_type(wnck.CLIENT_TYPE_PAGER)  # pylint: disable=no-member
65
66
try:
67
    from Xlib import X
68
    from Xlib.display import Display
69
    from Xlib.error import BadAccess, DisplayConnectionError
70
    XLIB_PRESENT = True  #: Indicates presence of python-xlib (runtime check)
71
except ImportError:
72
    XLIB_PRESENT = False  #: Indicates presence of python-xlib (runtime check)
73
74
DBUS_PRESENT = False  #: Indicates availability of D-Bus (runtime check)
75
try:
76
    import dbus.service
77
    from dbus import SessionBus
78
    from dbus.exceptions import DBusException
79
    from dbus.mainloop.glib import DBusGMainLoop
80
except ImportError:
81
    pass
82
else:
83
    try:
84
        DBusGMainLoop(set_as_default=True)
85
        sessBus = SessionBus()  #: D-Bus Session Bus for L{QuickTileApp.run}
86
    except DBusException:
87
        pass
88
    else:
89
        DBUS_PRESENT = True  #: Indicates availability of D-Bus (runtime check)
90
91
#: Location for config files (determined at runtime).
92
XDG_CONFIG_DIR = os.environ.get('XDG_CONFIG_HOME',
93
                                os.path.expanduser('~/.config'))
94
#{ Settings
95
96
class GravityLayout(object):  # pylint: disable=too-few-public-methods
97
    """Helper for translating top-left relative dimensions to other corners.
98
99
    Used to generate L{cycle_dimensions} presets.
100
101
    Expects to operate on decimal percentage values. (0 <= x <= 1)
102
    """
103
    #: Possible window alignments relative to the monitor/desktop.
104
    #: @todo 1.0.0: Normalize these to X11 or CSS terminology for 1.0
105
    #:     (API-breaking change)
106
    GRAVITIES = {
107
        'top-left': (0.0, 0.0),
108
        'top': (0.5, 0.0),
109
        'top-right': (1.0, 0.0),
110
        'left': (0.0, 0.5),
111
        'middle': (0.5, 0.5),
112
        'right': (1.0, 0.5),
113
        'bottom-left': (0.0, 1.0),
114
        'bottom': (0.5, 1.0),
115
        'bottom-right': (1.0, 1.0),
116
    }
117
118
    def __init__(self, margin_x=0, margin_y=0):
119
        """
120
        @param margin_x: Horizontal margin to apply when calculating window
121
            positions, as decimal percentage of screen width.
122
        @param margin_y: Vertical margin to apply when calculating window
123
            positions, as decimal percentage of screen height.
124
        """
125
        self.margin_x = margin_x
126
        self.margin_y = margin_y
127
128
    # pylint: disable=too-many-arguments
129
    def __call__(self, w, h, gravity='top-left', x=None, y=None):
130
        """Return an C{(x, y, w, h)} tuple relative to C{gravity}.
131
132
        This function takes and returns percentages, represented as decimals
133
        in the range 0 <= x <= 1, which can be multiplied by width and height
134
        values in actual units to produce actual window geometry.
135
136
        It can be used in two ways:
137
138
          1. If called B{without} C{x} and C{y} values, it will compute a
139
          geometry tuple which will align a window C{w} wide and C{h} tall
140
          according to C{geometry}.
141
142
          2. If called B{with} C{x} and C{y} values, it will translate a
143
          geometry tuple which is relative to the top-left corner so that it is
144
          instead relative to another corner.
145
146
        @param w: Desired width
147
        @param h: Desired height
148
        @param gravity: Desired window alignment from L{GRAVITIES}
149
        @param x: Desired horizontal position if not the same as C{gravity}
150
        @param y: Desired vertical position if not the same as C{gravity}
151
152
        @returns: C{(x, y, w, h)}
153
154
        @note: All parameters except C{gravity} are decimal values in the range
155
        C{0 <= x <= 1}.
156
        """
157
158
        x = x or self.GRAVITIES[gravity][0]
159
        y = y or self.GRAVITIES[gravity][1]
160
        offset_x = w * self.GRAVITIES[gravity][0]
161
        offset_y = h * self.GRAVITIES[gravity][1]
162
163
        return (x - offset_x + self.margin_x,
164
                y - offset_y + self.margin_y,
165
                w - (self.margin_x * 2),
166
                h - (self.margin_y * 2))
167
168
#: Number of columns to base generated L{POSITIONS} presets on
169
def make_positions(column_count):
170
    """Generate the classic WinSplit Revolution tiling presets
171
172
    @todo: Figure out how best to put this in the config file.
173
    """
174
175
    # TODO: Plumb GravityLayout.__init__'s arguments into the config file
176
    gvlay = GravityLayout()
177
    col_width = 1.0 / column_count
178
    cycle_steps = tuple(col_width * x for x in range(1, column_count))
179
180
    edge_steps = (1.0,) + cycle_steps
181
    corner_steps = (0.5,) + cycle_steps
182
183
    positions = {
184
        'middle': [gvlay(width, 1, 'middle') for width in edge_steps],
185
    }
186
187
    for grav in ('top', 'bottom'):
188
        positions[grav] = [gvlay(width, 0.5, grav) for width in edge_steps]
189
    for grav in ('left', 'right'):
190
        positions[grav] = [gvlay(width, 1, grav) for width in corner_steps]
191
    for grav in ('top-left', 'top-right', 'bottom-left', 'bottom-right'):
192
        positions[grav] = [gvlay(width, 0.5, grav) for width in corner_steps]
193
194
    return positions
195
196
#: Default content for the config file
197
DEFAULTS = {
198
    'general': {
199
        'ColumnCount': 3,
200
        # Use Ctrl+Alt as the default base for key combinations
201
        'ModMask': '<Ctrl><Alt>',
202
        'UseWorkarea': True,
203
    },
204
    'keys': {
205
        "KP_Enter": "monitor-switch",
206
        "KP_0": "maximize",
207
        "KP_1": "bottom-left",
208
        "KP_2": "bottom",
209
        "KP_3": "bottom-right",
210
        "KP_4": "left",
211
        "KP_5": "middle",
212
        "KP_6": "right",
213
        "KP_7": "top-left",
214
        "KP_8": "top",
215
        "KP_9": "top-right",
216
        "V": "vertical-maximize",
217
        "H": "horizontal-maximize",
218
        "C": "move-to-center",
219
    }
220
}
221
222
KEYLOOKUP = {
223
    ',': 'comma',
224
    '.': 'period',
225
    '+': 'plus',
226
    '-': 'minus',
227
}  #: Used for resolving certain keysyms
228
229
#}
230
#{ Helpers
231
232
def powerset(iterable):
233
    """C{powerset([1,2,3])} --> C{() (1,) (2,) (3,) (1,2) (1,3) (2,3) (1,2,3)}
234
235
    @rtype: iterable
236
    """
237
    i = list(iterable)
238
    return chain.from_iterable(combinations(i, j) for j in range(len(i) + 1))
239
240
def fmt_table(rows, headers, group_by=None):
241
    """Format a collection as a textual table.
242
243
    @param headers: Header labels for the columns
244
    @param group_by: Index of the column to group results by.
245
    @type rows: C{dict} or iterable of iterables
246
    @type headers: C{list(str)}
247
    @type group_by: C{int}
248
249
    @attention: This uses C{zip()} to combine things. The number of columns
250
        displayed will be defined by the narrowest of all rows.
251
252
    @rtype: C{str}
253
    """
254
    output = []
255
256
    if isinstance(rows, dict):
257
        rows = list(sorted(rows.items()))
258
259
    groups = {}
260
    if group_by is not None:
261
        headers = list(headers)
262
        headers.pop(group_by)
263
        rows = [list(row) for row in rows]
264
        for row in rows:
265
            group = row.pop(group_by)
266
            groups.setdefault(group, []).append(row)
267
    else:
268
        groups[''] = rows
269
270
    # Identify how much space needs to be allocated for each column
271
    col_maxlens = []
272
    for pos, header in enumerate(headers):
273
        maxlen = max(len(x[pos]) for x in rows if len(x) > pos)
274
        col_maxlens.append(max(maxlen, len(header)))
275
276
    def fmt_row(row, pad=' ', indent=0, min_width=0):
277
        result = []
278
        for width, label in zip(col_maxlens, row):
279
            result.append('%s%s ' % (' ' * indent, label.ljust(width, pad)))
280
281
        _w = sum(len(x) for x in result)
282
        if _w < min_width:
283
            result[-1] = result[-1][:-1]
284
            result.append(pad * (min_width - _w + 1))
285
286
        result.append('\n')
287
        return result
288
289
    # Print the headers and divider
290
    group_width = max(len(x) for x in groups)
291
    output.extend(fmt_row(headers))
292
    output.extend(fmt_row([''] * len(headers), '-', min_width=group_width + 1))
293
294
    for group in sorted(groups):
295
        if group:
296
            output.append("\n%s\n" % group)
297
        for row in groups[group]:
298
            output.extend(fmt_row(row, indent=1))
299
300
    return ''.join(output)
301
302
class EnumSafeDict(DictMixin):
303
    """A dict-like object which avoids comparing objects of different types
304
    to avoid triggering spurious Glib "comparing different enum types"
305
    warnings.
306
    """
307
308
    def __init__(self, *args):
309
        self._contents = {}
310
311
        for inDict in args:
312
            for key, val in inDict.items():
313
                self[key] = val
314
315
    def __contains__(self, key):
316
        ktype = type(key)
317
        return ktype in self._contents and key in self._contents[ktype]
318
319
    def __delitem__(self, key):
320
        if key in self:
321
            ktype = type(key)
322
            section = self._contents[ktype]
323
            del section[key]
324
            if not section:
325
                del self._contents[ktype]
326
        else:
327
            raise KeyError(key)
328
329
    def __getitem__(self, key):
330
        if key in self:
331
            return self._contents[type(key)][key]
332
        else:
333
            raise KeyError(key)
334
335
    def __iter__(self):
336
        for section in self._contents.values():
337
            for key in section.keys():
338
                yield key
339
340
    def __repr__(self):
341
        return "%s(%s)" % (self.__class__.__name__,
342
            ', '.join(repr(x) for x in self._contents.values()))
343
344
    def __setitem__(self, key, value):
345
        ktype = type(key)
346
        self._contents.setdefault(ktype, {})[key] = value
347
348
    def iteritems(self):
349
        return [(key, self[key]) for key in self]
350
351
    def keys(self):
352
        """D.keys() -> list of D's keys"""
353
        return list(self)
354
355
class XInitError(Exception):
356
    """Raised when something outside our control causes the X11 connection to
357
       fail.
358
    """
359
360
    def __str__(self):
361
        return ("%s\n\t(The cause of this error lies outside of QuickTile)" %
362
                Exception.__str__(self))
363
#}
364
365
class CommandRegistry(object):
366
    """Handles lookup and boilerplate for window management commands.
367
368
    Separated from WindowManager so its lifecycle is not tied to a specific
369
    GDK Screen object.
370
    """
371
372
    def __init__(self):
373
        self.commands = {}
374
        self.help = {}
375
376
    def __iter__(self):
377
        for x in self.commands:
378
            yield x
379
380
    def __str__(self):
381
        return fmt_table(self.help, ('Known Commands', 'desc'), group_by=1)
382
383
    def add(self, name, *p_args, **p_kwargs):
384
        """Decorator to wrap a function in boilerplate and add it to the
385
            command registry under the given name.
386
387
            @param name: The name to know the command by.
388
            @param p_args: Positional arguments to prepend to all calls made
389
                via C{name}.
390
            @param p_kwargs: Keyword arguments to prepend to all calls made
391
                via C{name}.
392
393
            @type name: C{str}
394
            """
395
396
        def decorate(func):
397
            """Closure used to allow decorator to take arguments"""
398
            @wraps(func)
399
            # pylint: disable=missing-docstring
400
            def wrapper(winman, window=None, *args, **kwargs):
401
402
                # Get Wnck and GDK window objects
403
                window = window or winman.screen.get_active_window()
404
                if isinstance(window, gtk.gdk.Window):
405
                    win = wnck.window_get(window.xid)  # pylint: disable=E1101
406
                else:
407
                    win = window
408
409
                # pylint: disable=no-member
410
                if not win:
411
                    logging.debug("Received no window object to manipulate.")
412
                    return None
413
                elif win.get_window_type() == wnck.WINDOW_DESKTOP:
414
                    logging.debug("Received desktop window object. Ignoring.")
415
                    return None
416
                else:
417
                    # FIXME: Make calls to win.get_* lazy in case --debug
418
                    #        wasn't passed.
419
                    logging.debug("Operating on window 0x%x with title \"%s\" "
420
                                  "and geometry %r",
421
                                  win.get_xid(), win.get_name(),
422
                                  win.get_geometry())
423
424
                monitor_id, monitor_geom = winman.get_monitor(window)
425
426
                use_area, use_rect = winman.get_workarea(
427
                    monitor_geom, winman.ignore_workarea)
428
429
                # TODO: Replace this MPlayer safety hack with a properly
430
                #       comprehensive exception catcher.
431
                if not use_rect:
432
                    logging.debug("Received a worthless value for largest "
433
                                  "rectangular subset of desktop (%r). Doing "
434
                                  "nothing.", use_rect)
435
                    return None
436
437
                state = {
438
                    "cmd_name": name,
439
                    "monitor_id": monitor_id,
440
                    "monitor_geom": monitor_geom,
441
                    "usable_region": use_area,
442
                    "usable_rect": use_rect,
443
                }
444
445
                args, kwargs = p_args + args, dict(p_kwargs, **kwargs)
446
                func(winman, win, state, *args, **kwargs)
447
448
            if name in self.commands:
449
                logging.warn("Redefining existing command: %s", name)
450
            self.commands[name] = wrapper
451
452
            help_str = func.__doc__.strip().split('\n')[0].split('. ')[0]
453
            self.help[name] = help_str.strip('.')
454
455
            # Return the unwrapped function so decorators can be stacked
456
            # to define multiple commands using the same code with different
457
            # arguments
458
            return func
459
        return decorate
460
461
    def add_many(self, command_map):
462
        """Convenience decorator to allow many commands to be defined from
463
           the same function with different arguments.
464
465
           @param command_map: A dict mapping command names to argument lists.
466
           @type command_map: C{dict}
467
        """
468
        def decorate(func):
469
            """Closure used to allow decorator to take arguments"""
470
            for cmd, arglist in command_map.items():
471
                self.add(cmd, *arglist)(func)
472
            return func
473
        return decorate
474
475
    def call(self, command, winman, *args, **kwargs):
476
        """Resolve a textual positioning command and execute it."""
477
        cmd = self.commands.get(command, None)
478
479
        if cmd:
480
            logging.debug("Executing command '%s' with arguments %r, %r",
481
                          command, args, kwargs)
482
            cmd(winman, *args, **kwargs)
483
        else:
484
            logging.error("Unrecognized command: %s", command)
485
486
class WindowManager(object):
487
    """A simple API-wrapper class for manipulating window positioning."""
488
489
    #: Lookup table for internal window gravity support.
490
    #: (libwnck's support is either unreliable or broken)
491
    gravities = EnumSafeDict({
492
        'NORTH_WEST': (0.0, 0.0),
493
        'NORTH': (0.5, 0.0),
494
        'NORTH_EAST': (1.0, 0.0),
495
        'WEST': (0.0, 0.5),
496
        'CENTER': (0.5, 0.5),
497
        'EAST': (1.0, 0.5),
498
        'SOUTH_WEST': (0.0, 1.0),
499
        'SOUTH': (0.5, 1.0),
500
        'SOUTH_EAST': (1.0, 1.0),
501
    })
502
    key, val = None, None  # Safety cushion for the "del" line.
503
    for key, val in gravities.items():
504
        del gravities[key]
505
506
        # Support GDK gravity constants
507
        gravities[getattr(gtk.gdk, 'GRAVITY_%s' % key)] = val
508
509
        # Support libwnck gravity constants
510
        _name = 'WINDOW_GRAVITY_%s' % key.replace('_', '')
511
        gravities[getattr(wnck, _name)] = val
512
513
    # Prevent these temporary variables from showing up in the apidocs
514
    del _name, key, val
515
516
    def __init__(self, screen=None, ignore_workarea=False):
517
        """
518
        Initializes C{WindowManager}.
519
520
        @param screen: The X11 screen to operate on. If C{None}, the default
521
            screen as retrieved by C{gtk.gdk.screen_get_default} will be used.
522
        @type screen: C{gtk.gdk.Screen}
523
524
        @todo: Confirm that the root window only changes on X11 server
525
               restart. (Something which will crash QuickTile anyway since
526
               PyGTK makes X server disconnects uncatchable.)
527
528
               It could possibly change while toggling "allow desktop icons"
529
               in KDE 3.x. (Not sure what would be equivalent elsewhere)
530
        """
531
        self.gdk_screen = screen or gtk.gdk.screen_get_default()
532
        if self.gdk_screen is None:
533
            raise XInitError("GTK+ could not open a connection to the X server"
534
                             " (bad DISPLAY value?)")
535
536
        # pylint: disable=no-member
537
        self.screen = wnck.screen_get(self.gdk_screen.get_number())
538
        self.ignore_workarea = ignore_workarea
539
540
    @classmethod
541
    def calc_win_gravity(cls, geom, gravity):
542
        """Calculate the X and Y coordinates necessary to simulate non-topleft
543
        gravity on a window.
544
545
        @param geom: The window geometry to which to apply the corrections.
546
        @param gravity: A desired gravity chosen from L{gravities}.
547
        @type geom: C{gtk.gdk.Rectangle}
548
        @type gravity: C{wnck.WINDOW_GRAVITY_*} or C{gtk.gdk.GRAVITY_*}
549
550
        @returns: The coordinates to be used to achieve the desired position.
551
        @rtype: C{(x, y)}
552
        """
553
        grav_x, grav_y = cls.gravities[gravity]
554
555
        return (
556
            int(geom.x - (geom.width * grav_x)),
557
            int(geom.y - (geom.height * grav_y))
558
        )
559
560
    @staticmethod
561
    def get_geometry_rel(window, monitor_geom):
562
        """Get window position relative to the monitor rather than the desktop.
563
564
        @param monitor_geom: The rectangle returned by
565
            C{gdk.Screen.get_monitor_geometry}
566
        @type window: C{wnck.Window}
567
        @type monitor_geom: C{gtk.gdk.Rectangle}
568
569
        @rtype: C{gtk.gdk.Rectangle}
570
        """
571
        win_geom = gtk.gdk.Rectangle(*window.get_geometry())
572
        win_geom.x -= monitor_geom.x
573
        win_geom.y -= monitor_geom.y
574
575
        return win_geom
576
577
    @staticmethod
578
    def get_monitor(win):
579
        """Given a Window (Wnck or GDK), retrieve the monitor ID and geometry.
580
581
        @type win: C{wnck.Window} or C{gtk.gdk.Window}
582
        @returns: A tuple containing the monitor ID and geometry.
583
        @rtype: C{(int, gtk.gdk.Rectangle)}
584
        """
585
        # TODO: Look for a way to get the monitor ID without having
586
        #       to instantiate a gtk.gdk.Window
587
        if not isinstance(win, gtk.gdk.Window):
588
            win = gtk.gdk.window_foreign_new(win.get_xid())
589
590
        # TODO: How do I retrieve the root window from a given one?
591
        monitor_id = winman.gdk_screen.get_monitor_at_window(win)
592
        monitor_geom = winman.gdk_screen.get_monitor_geometry(monitor_id)
593
594
        logging.debug(" Window is on monitor %s, which has geometry %s",
595
                      monitor_id, monitor_geom)
596
        return monitor_id, monitor_geom
597
598
    def get_workarea(self, monitor, ignore_struts=False):
599
        """Retrieve the usable area of the specified monitor using
600
        the most expressive method the window manager supports.
601
602
        @param monitor: The number or dimensions of the desired monitor.
603
        @param ignore_struts: If C{True}, just return the size of the whole
604
            monitor, allowing windows to overlap panels.
605
        @type monitor: C{int} or C{gtk.gdk.Rectangle}
606
        @type ignore_struts: C{bool}
607
608
        @returns: The usable region and its largest rectangular subset.
609
        @rtype: C{gtk.gdk.Region}, C{gtk.gdk.Rectangle}
610
        """
611
        if isinstance(monitor, int):
612
            usable_rect = self.gdk_screen.get_monitor_geometry(monitor)
613
            logging.debug("Retrieved geometry %s for monitor #%s",
614
                          usable_rect, monitor)
615
        elif not isinstance(monitor, gtk.gdk.Rectangle):
616
            logging.debug("Converting geometry %s to gtk.gdk.Rectangle",
617
                          monitor)
618
            usable_rect = gtk.gdk.Rectangle(monitor)
619
        else:
620
            usable_rect = monitor
621
622
        usable_region = gtk.gdk.region_rectangle(usable_rect)
623
        if not usable_region.get_rectangles():
624
            logging.error("get_workarea received an empty monitor region!")
625
626
        if ignore_struts:
627
            logging.debug("Panels ignored. Reported monitor geometry is:\n%s",
628
                          usable_rect)
629
            return usable_region, usable_rect
630
631
        root_win = self.gdk_screen.get_root_window()
632
633
        struts = []
634
        if self.gdk_screen.supports_net_wm_hint("_NET_WM_STRUT_PARTIAL"):
635
            # Gather all struts
636
            struts.append(root_win.property_get("_NET_WM_STRUT_PARTIAL"))
637
            if self.gdk_screen.supports_net_wm_hint("_NET_CLIENT_LIST"):
638
                # Source: http://stackoverflow.com/a/11332614/435253
639
                for wid in root_win.property_get('_NET_CLIENT_LIST')[2]:
640
                    w = gtk.gdk.window_foreign_new(wid)
641
                    struts.append(w.property_get("_NET_WM_STRUT_PARTIAL"))
642
            struts = [x[2] for x in struts if x]
643
644
            logging.debug("Gathered _NET_WM_STRUT_PARTIAL values:\n\t%s",
645
                          struts)
646
647
            # Subtract the struts from the usable region
648
            _Sub = lambda *g: usable_region.subtract(
649
                gtk.gdk.region_rectangle(g))
650
            _w, _h = self.gdk_screen.get_width(), self.gdk_screen.get_height()
651
            for g in struts:  # pylint: disable=invalid-name
652
                # http://standards.freedesktop.org/wm-spec/1.5/ar01s05.html
653
                # XXX: Must not cache unless watching for notify events.
654
                _Sub(0, g[4], g[0], g[5] - g[4] + 1)             # left
655
                _Sub(_w - g[1], g[6], g[1], g[7] - g[6] + 1)     # right
656
                _Sub(g[8], 0, g[9] - g[8] + 1, g[2])             # top
657
                _Sub(g[10], _h - g[3], g[11] - g[10] + 1, g[3])  # bottom
658
659
            # Generate a more restrictive version used as a fallback
660
            usable_rect = usable_region.copy()
661
            _Sub = lambda *g: usable_rect.subtract(gtk.gdk.region_rectangle(g))
662
            for geom in struts:
663
                # http://standards.freedesktop.org/wm-spec/1.5/ar01s05.html
664
                # XXX: Must not cache unless watching for notify events.
665
                _Sub(0, geom[4], geom[0], _h)             # left
666
                _Sub(_w - geom[1], geom[6], geom[1], _h)  # right
667
                _Sub(0, 0, _w, geom[2])                   # top
668
                _Sub(0, _h - geom[3], _w, geom[3])        # bottom
669
                # TODO: The required "+ 1" in certain spots confirms that we're
670
                #       going to need unit tests which actually check that the
671
                #       WM's code for constraining windows to the usable area
672
                #       doesn't cause off-by-one bugs.
673
                # TODO: Share this on http://stackoverflow.com/q/2598580/435253
674
            usable_rect = usable_rect.get_clipbox()
675
        elif self.gdk_screen.supports_net_wm_hint("_NET_WORKAREA"):
676
            desktop_geo = tuple(root_win.property_get('_NET_WORKAREA')[2][0:4])
677
            logging.debug("Falling back to _NET_WORKAREA: %s", desktop_geo)
678
            usable_region.intersect(gtk.gdk.region_rectangle(desktop_geo))
679
            usable_rect = usable_region.get_clipbox()
680
681
        # FIXME: Only call get_rectangles if --debug
682
        logging.debug("Usable region of monitor calculated as:\n"
683
                      "\tRegion: %r\n\tRectangle: %r",
684
                      usable_region.get_rectangles(), usable_rect)
685
        return usable_region, usable_rect
686
687
    def get_workspace(self, window=None, direction=None):
688
        """Get a workspace relative to either a window or the active one.
689
690
        @param window: The point of reference. C{None} for the active workspace
691
        @param direction: The direction in which to look, relative to the point
692
            of reference. Accepts the following types:
693
             - C{wnck.MotionDirection}: Non-cycling direction
694
             - C{int}: Relative index in the list of workspaces
695
             - C{None}: Just get the workspace object for the point of
696
               reference
697
698
        @type window: C{wnck.Window} or C{None}
699
        @rtype: C{wnck.Workspace} or C{None}
700
        @returns: The workspace object or C{None} if no match could be found.
701
        """
702
        if window:
703
            cur = window.get_workspace()
704
        else:
705
            cur = self.screen.get_active_workspace()
706
707
        if not cur:
708
            return None  # It's either pinned or on no workspaces
709
710
        # pylint: disable=no-member
711
        if isinstance(direction, wnck.MotionDirection):
712
            nxt = cur.get_neighbor(direction)
713
        elif isinstance(direction, int):
714
            nxt = winman.screen.get_workspace((cur.get_number() + direction) %
715
                    winman.screen.get_workspace_count())
716
        elif direction is None:
717
            nxt = cur
718
        else:
719
            nxt = None
720
            logging.warn("Unrecognized direction: %r", direction)
721
722
        return nxt
723
724
    @classmethod
725
    def reposition(cls,
726
            win,
727
            geom=None,
728
            monitor=gtk.gdk.Rectangle(0, 0, 0, 0),
729
            keep_maximize=False,
730
            gravity=wnck.WINDOW_GRAVITY_NORTHWEST,
731
            geometry_mask=wnck.WINDOW_CHANGE_X | wnck.WINDOW_CHANGE_Y |
732
                wnck.WINDOW_CHANGE_WIDTH | wnck.WINDOW_CHANGE_HEIGHT
733
                   ):  # pylint: disable=no-member,too-many-arguments
734
        """
735
        Position and size a window, decorations inclusive, according to the
736
        provided target window and monitor geometry rectangles.
737
738
        If no monitor rectangle is specified, position relative to the desktop
739
        as a whole.
740
741
        @param win: The C{wnck.Window} to operate on.
742
        @param geom: The new geometry for the window. Can be left unspecified
743
            if the intent is to move the window to another monitor without
744
            repositioning it.
745
        @param monitor: The frame relative to which C{geom} should be
746
            interpreted. The whole desktop if unspecified.
747
        @param keep_maximize: Whether to re-maximize a maximized window after
748
            un-maximizing it to move it.
749
        @param gravity: A constant specifying which point on the window is
750
            referred to by the X and Y coordinates in C{geom}.
751
        @param geometry_mask: A set of flags determining which aspects of the
752
            requested geometry should actually be applied to the window.
753
            (Allows the same geometry definition to easily be shared between
754
            operations like move and resize.)
755
        @type win: C{gtk.gdk.Window}
756
        @type geom: C{gtk.gdk.Rectangle}
757
        @type monitor: C{gtk.gdk.Rectangle}
758
        @type keep_maximize: C{bool}
759
        @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>}
760
        @type geometry_mask: U{WnckWindowMoveResizeMask<https://developer.gnome.org/libwnck/2.30/WnckWindow.html#WnckWindowMoveResizeMask>}
761
762
        @todo 1.0.0: Look for a way to accomplish this with a cleaner method
763
            signature. This is getting a little hairy. (API-breaking change)
764
        """  # NOQA
765
766
        # We need to ensure that ignored values are still present for
767
        # gravity calculations.
768
        old_geom = winman.get_geometry_rel(win, winman.get_monitor(win)[1])
769
        if geom:
770
            for attr in ('x', 'y', 'width', 'height'):
771
                if not geometry_mask & getattr(wnck,
772
                        'WINDOW_CHANGE_%s' % attr.upper()):
773
                    setattr(geom, attr, getattr(old_geom, attr))
774
        else:
775
            geom = old_geom
776
777
        # Unmaximize and record the types we may need to restore
778
        max_types, maxed = ['', '_horizontally', '_vertically'], []
779
        for maxtype in max_types:
780
            if getattr(win, 'is_maximized' + maxtype)():
781
                maxed.append(maxtype)
782
                getattr(win, 'unmaximize' + maxtype)()
783
784
        # Apply gravity and resolve to absolute desktop coordinates.
785
        new_x, new_y = cls.calc_win_gravity(geom, gravity)
786
        new_x += monitor.x
787
        new_y += monitor.y
788
789
        logging.debug(" Repositioning to (%d, %d, %d, %d)\n",
790
                new_x, new_y, geom.width, geom.height)
791
792
        # XXX: I'm not sure whether wnck, Openbox, or both are at fault,
793
        #      but window gravities seem to have no effect beyond double-
794
        #      compensating for window border thickness unless using
795
        #      WINDOW_GRAVITY_STATIC.
796
        #
797
        #      My best guess is that the gravity modifiers are being applied
798
        #      to the window frame rather than the window itself, hence why
799
        #      static gravity would position correctly and north-west gravity
800
        #      would double-compensate for the titlebar and border dimensions.
801
        #
802
        #      ...however, that still doesn't explain why the non-topleft
803
        #      gravities have no effect. I'm guessing something's just broken.
804
        win.set_geometry(wnck.WINDOW_GRAVITY_STATIC, geometry_mask,
805
                new_x, new_y, geom.width, geom.height)
806
807
        # Restore maximization if asked
808
        if maxed and keep_maximize:
809
            for maxtype in maxed:
810
                getattr(win, 'maximize' + maxtype)()
811
812
class KeyBinder(object):
813
    """A convenience class for wrapping C{XGrabKey}."""
814
815
    #: @todo: Figure out how to set the modifier mask in X11 and use
816
    #:        C{gtk.accelerator_get_default_mod_mask()} to feed said code.
817
    ignored_modifiers = ['Mod2Mask', 'LockMask']
818
819
    #: Used to pass state from L{handle_xerror}
820
    keybind_failed = False
821
822
    def __init__(self, xdisplay=None):
823
        """Connect to X11 and the Glib event loop.
824
825
        @param xdisplay: A C{python-xlib} display handle.
826
        @type xdisplay: C{Xlib.display.Display}
827
        """
828
        try:
829
            self.xdisp = xdisplay or Display()
830
        except (UnicodeDecodeError, DisplayConnectionError), err:
831
            raise XInitError("python-xlib failed with %s when asked to open"
832
                             " a connection to the X server. Cannot bind keys."
833
                             "\n\tIt's unclear why this happens, but it is"
834
                             " usually fixed by deleting your ~/.Xauthority"
835
                             " file and rebooting."
836
                             % err.__class__.__name__)
837
838
        self.xroot = self.xdisp.screen().root
839
        self._keys = {}
840
841
        # Resolve these at runtime to avoid NameErrors
842
        self.ignored_modifiers = [getattr(X, name) for name in
843
                self.ignored_modifiers]
844
845
        # We want to receive KeyPress events
846
        self.xroot.change_attributes(event_mask=X.KeyPressMask)
847
848
        # Set up a handler to catch XGrabKey() failures
849
        self.xdisp.set_error_handler(self.handle_xerror)
850
851
        # Merge python-xlib into the Glib event loop
852
        # Source: http://www.pygtk.org/pygtk2tutorial/sec-MonitoringIO.html
853
        gobject.io_add_watch(self.xroot.display,
854
                             gobject.IO_IN, self.handle_xevent)
855
856
    def bind(self, accel, callback):
857
        """Bind a global key combination to a callback.
858
859
        @param accel: An accelerator as either a string to be parsed by
860
            C{gtk.accelerator_parse()} or a tuple as returned by it.)
861
        @param callback: The function to call when the key is pressed.
862
863
        @type accel: C{str} or C{(int, gtk.gdk.ModifierType)} or C{(int, int)}
864
        @type callback: C{function}
865
866
        @returns: A boolean indicating whether the provided keybinding was
867
            parsed successfully. (But not whether it was registered
868
            successfully due to the asynchronous nature of the C{XGrabKey}
869
            request.)
870
        @rtype: C{bool}
871
        """
872
        if isinstance(accel, basestring):
873
            # pylint: disable=no-member
874
            keysym, modmask = gtk.accelerator_parse(accel)
875
        else:
876
            keysym, modmask = accel
877
878
        if not gtk.accelerator_valid(keysym, modmask):  # pylint: disable=E1101
879
            logging.error("Invalid keybinding: %s", accel)
880
            return False
881
882
        if modmask > 2**16 - 1:
883
            logging.error("Modifier out of range for XGrabKey "
884
                          "(int(modmask) > 65535). "
885
                          "Did you use <Super> instead of <Mod4>?")
886
            return False
887
888
        # Convert to what XGrabKey expects
889
        keycode = self.xdisp.keysym_to_keycode(keysym)
890
        if isinstance(modmask, gtk.gdk.ModifierType):
891
            modmask = modmask.real
892
893
        # Ignore modifiers like Mod2 (NumLock) and Lock (CapsLock)
894
        for mmask in self._vary_modmask(modmask, self.ignored_modifiers):
895
            self._keys.setdefault(keycode, []).append((mmask, callback))
896
            self.xroot.grab_key(keycode, mmask,
897
                    1, X.GrabModeAsync, X.GrabModeAsync)
898
899
        # If we don't do this, then nothing works.
900
        # I assume it flushes the XGrabKey calls to the server.
901
        self.xdisp.sync()
902
903
        if self.keybind_failed:
904
            self.keybind_failed = False
905
            logging.warning("Failed to bind key. It may already be in use: %s",
906
                accel)
907
908
    def handle_xerror(self, err, _):
909
        """Used to identify when attempts to bind keys fail.
910
        @note: If you can make python-xlib's C{CatchError} actually work or if
911
               you can retrieve more information to show, feel free.
912
        """
913
        if isinstance(err, BadAccess):
914
            self.keybind_failed = True
915
        else:
916
            self.xdisp.display.default_error_handler(err)
917
918
    def handle_xevent(self, src, cond, handle=None):  # pylint: disable=W0613
919
        """Dispatch C{XKeyPress} events to their callbacks.
920
921
        @rtype: C{True}
922
923
        @todo: Make sure uncaught exceptions are prevented from making
924
            quicktile unresponsive in the general case.
925
        """
926
        handle = handle or self.xroot.display
927
928
        for _ in range(0, handle.pending_events()):
929
            xevent = handle.next_event()
930
            if xevent.type == X.KeyPress:
931
                if xevent.detail in self._keys:
932
                    for mmask, callback in self._keys[xevent.detail]:
933
                        if mmask == xevent.state:
934
                            # FIXME: Only call accelerator_name if --debug
935
                            # FIXME: Proper "index" arg for keycode_to_keysym
936
                            keysym = self.xdisp.keycode_to_keysym(
937
                                xevent.detail, 0)
938
939
                            # pylint: disable=no-member
940
                            kb_str = gtk.accelerator_name(keysym, xevent.state)
941
                            logging.debug("Received keybind: %s", kb_str)
942
                            callback()
943
                            break
944
                        elif mmask == 0:
945
                            logging.debug("X11 returned null modifier!")
946
                            callback()
947
                            break
948
                    else:
949
                        logging.error("Received an event for a recognized key "
950
                                  "with unrecognized modifiers: %s, %s",
951
                                  xevent.detail, xevent.state)
952
953
                else:
954
                    logging.error("Received an event for an unrecognized "
955
                        "keybind: %s, %s", xevent.detail, xevent.state)
956
957
        # Necessary for proper function
958
        return True
959
960
    @staticmethod
961
    def _vary_modmask(modmask, ignored):
962
        """Generate all possible variations on C{modmask} that need to be
963
        taken into consideration if we can't properly ignore the modifiers in
964
        C{ignored}. (Typically NumLock and CapsLock)
965
966
        @param modmask: A bitfield to be combinatorically grown.
967
        @param ignored: Modifiers to be combined with C{modmask}.
968
969
        @type modmask: C{int} or C{gtk.gdk.ModifierType}
970
        @type ignored: C{list(int)}
971
972
        @rtype: generator of C{type(modmask)}
973
        """
974
975
        for ignored in powerset(ignored):
976
            imask = reduce(lambda x, y: x | y, ignored, 0)
977
            yield modmask | imask
978
979
class QuickTileApp(object):
980
    """The basic Glib application itself."""
981
982
    def __init__(self, winman, commands, keys=None, modmask=None):
983
        """Populate the instance variables.
984
985
        @param keys: A dict mapping X11 keysyms to L{CommandRegistry}
986
            command names.
987
        @param modmask: A modifier mask to prefix to all keybindings.
988
        @type winman: The L{WindowManager} instance to use.
989
        @type keys: C{dict}
990
        @type modmask: C{GdkModifierType}
991
        """
992
        self.winman = winman
993
        self.commands = commands
994
        self._keys = keys or {}
995
        self._modmask = modmask or gtk.gdk.ModifierType(0)
996
997
    def run(self):
998
        """Initialize keybinding and D-Bus if available, then call
999
        C{gtk.main()}.
1000
1001
        @returns: C{False} if none of the supported backends were available.
1002
        @rtype: C{bool}
1003
1004
        @todo 1.0.0: Retire the C{doCommand} name. (API-breaking change)
1005
        """
1006
1007
        if XLIB_PRESENT:
1008
            try:
1009
                self.keybinder = KeyBinder()
1010
            except XInitError as err:
1011
                logging.error(err)
1012
            else:
1013
                for key, func in self._keys.items():
1014
                    def call(func=func):
1015
                        self.commands.call(func, winman)
1016
1017
                    self.keybinder.bind(self._modmask + key, call)
1018
        else:
1019
            logging.error("Could not find python-xlib. Cannot bind keys.")
1020
1021
        if DBUS_PRESENT:
1022
            class QuickTile(dbus.service.Object):
1023
                def __init__(self, commands):
1024
                    dbus.service.Object.__init__(self, sessBus,
1025
                                                 '/com/ssokolow/QuickTile')
1026
                    self.commands = commands
1027
1028
                @dbus.service.method(dbus_interface='com.ssokolow.QuickTile',
1029
                         in_signature='s', out_signature='b')
1030
                def doCommand(self, command):
1031
                    return self.commands.call(command, winman)
1032
1033
            self.dbusName = dbus.service.BusName("com.ssokolow.QuickTile",
1034
                                                 sessBus)
1035
            self.dbusObj = QuickTile(self.commands)
1036
        else:
1037
            logging.warn("Could not connect to the D-Bus Session Bus.")
1038
1039
        if XLIB_PRESENT or DBUS_PRESENT:
1040
            gtk.main()  # pylint: disable=no-member
1041
            return True
1042
        else:
1043
            return False
1044
1045
    def show_binds(self):
1046
        """Print a formatted readout of defined keybindings and the modifier
1047
        mask to stdout.
1048
1049
        @todo: Look into moving this into L{KeyBinder}
1050
        """
1051
1052
        print "Keybindings defined for use with --daemonize:\n"
1053
        print "Modifier: %s\n" % self._modmask
1054
        print fmt_table(self._keys, ('Key', 'Action'))
1055
1056
def create_commands(column_count):
1057
    #: command-to-position mappings for L{cycle_dimensions}
1058
    POSITIONS = make_positions(column_count)
1059
1060
    #: The instance of L{CommandRegistry} to be used in 99.9% of use cases.
1061
    commands = CommandRegistry()
1062
    #{ Tiling Commands
1063
1064
    @commands.add_many(POSITIONS)
1065
    def cycle_dimensions(winman, win, state, *dimensions):
1066
        """Cycle the active window through a list of positions and shapes.
1067
1068
        Takes one step each time this function is called.
1069
1070
        If the window's dimensions are not within 100px (by euclidean distance)
1071
        of an entry in the list, set them to the first list entry.
1072
1073
        @param dimensions: A list of tuples representing window geometries as
1074
            floating-point values between 0 and 1, inclusive.
1075
        @type dimensions: C{[(x, y, w, h), ...]}
1076
        @type win: C{gtk.gdk.Window}
1077
1078
        @returns: The new window dimensions.
1079
        @rtype: C{gtk.gdk.Rectangle}
1080
        """
1081
        win_geom = winman.get_geometry_rel(win, state['monitor_geom'])
1082
        usable_region = state['usable_region']
1083
1084
        # Get the bounding box for the usable region (overlaps panels which
1085
        # don't fill 100% of their edge of the screen)
1086
        clip_box = usable_region.get_clipbox()
1087
1088
        logging.debug("Selected preset sequence:\n\t%r", dimensions)
1089
1090
        # Resolve proportional (eg. 0.5) and preserved (None) coordinates
1091
        dims = []
1092
        for tup in dimensions:
1093
            current_dim = []
1094
            for pos, val in enumerate(tup):
1095
                if val is None:
1096
                    current_dim.append(tuple(win_geom)[pos])
1097
                else:
1098
                    # FIXME: This is a bit of an ugly way to get (w, h, w, h)
1099
                    # from clip_box.
1100
                    current_dim.append(int(val * tuple(clip_box)[2 + pos % 2]))
1101
1102
            dims.append(current_dim)
1103
1104
        if not dims:
1105
            return None
1106
1107
        logging.debug("Selected preset sequence resolves to these monitor-relative"
1108
                      " pixel dimensions:\n\t%r", dims)
1109
1110
        # Calculate euclidean distances between the window's current geometry
1111
        # and all presets and store them in a min heap.
1112
        euclid_distance = []
1113
        for pos, val in enumerate(dims):
1114
            distance = sum([(wg - vv) ** 2 for (wg, vv)
1115
                            in zip(tuple(win_geom), tuple(val))]) ** 0.5
1116
            heappush(euclid_distance, (distance, pos))
1117
1118
        # If the window is already on one of the configured geometries, advance
1119
        # to the next configuration. Otherwise, use the first configuration.
1120
        min_distance = heappop(euclid_distance)
1121
        if float(min_distance[0]) / tuple(clip_box)[2] < 0.1:
1122
            pos = (min_distance[1] + 1) % len(dims)
1123
        else:
1124
            pos = 0
1125
        result = gtk.gdk.Rectangle(*dims[pos])
1126
1127
        logging.debug("Target preset is %s relative to monitor %s",
1128
                      result, clip_box)
1129
        result.x += clip_box.x
1130
        result.y += clip_box.y
1131
1132
        # If we're overlapping a panel, fall back to a monitor-specific
1133
        # analogue to _NET_WORKAREA to prevent overlapping any panels and
1134
        # risking the WM potentially meddling with the result of resposition()
1135
        if not usable_region.rect_in(result) == gtk.gdk.OVERLAP_RECTANGLE_IN:
1136
            result = result.intersect(state['usable_rect'])
1137
            logging.debug("Result exceeds usable (non-rectangular) region of "
1138
                          "desktop. (overlapped a non-fullwidth panel?) Reducing "
1139
                          "to within largest usable rectangle: %s",
1140
                          state['usable_rect'])
1141
1142
        logging.debug("Calling reposition() with default gravity and dimensions "
1143
                      "%r", tuple(result))
1144
        winman.reposition(win, result)
1145
        return result
1146
1147
    @commands.add('monitor-switch')
1148
    @commands.add('monitor-next', 1)
1149
    @commands.add('monitor-prev', -1)
1150
    def cycle_monitors(winman, win, state, step=1):
1151
        """Cycle the active window between monitors while preserving position.
1152
1153
        @todo 1.0.0: Remove C{monitor-switch} in favor of C{monitor-next}
1154
            (API-breaking change)
1155
        """
1156
        mon_id = state['monitor_id']
1157
        new_mon_id = (mon_id + step) % winman.gdk_screen.get_n_monitors()
1158
1159
        new_mon_geom = winman.gdk_screen.get_monitor_geometry(new_mon_id)
1160
        logging.debug("Moving window to monitor %s, which has geometry %s",
1161
                      new_mon_id, new_mon_geom)
1162
1163
        winman.reposition(win, None, new_mon_geom, keep_maximize=True)
1164
1165
    # pylint: disable=no-member
1166
    MOVE_TO_COMMANDS = {
1167
        'move-to-top-left': [wnck.WINDOW_GRAVITY_NORTHWEST,
1168
                             wnck.WINDOW_CHANGE_X | wnck.WINDOW_CHANGE_Y],
1169
        'move-to-top': [wnck.WINDOW_GRAVITY_NORTH, wnck.WINDOW_CHANGE_Y],
1170
        'move-to-top-right': [wnck.WINDOW_GRAVITY_NORTHEAST,
1171
                              wnck.WINDOW_CHANGE_X | wnck.WINDOW_CHANGE_Y],
1172
        'move-to-left': [wnck.WINDOW_GRAVITY_WEST, wnck.WINDOW_CHANGE_X],
1173
        'move-to-center': [wnck.WINDOW_GRAVITY_CENTER,
1174
                           wnck.WINDOW_CHANGE_X | wnck.WINDOW_CHANGE_Y],
1175
        'move-to-right': [wnck.WINDOW_GRAVITY_EAST, wnck.WINDOW_CHANGE_X],
1176
        'move-to-bottom-left': [wnck.WINDOW_GRAVITY_SOUTHWEST,
1177
                                wnck.WINDOW_CHANGE_X | wnck.WINDOW_CHANGE_Y],
1178
        'move-to-bottom': [wnck.WINDOW_GRAVITY_SOUTH, wnck.WINDOW_CHANGE_Y],
1179
        'move-to-bottom-right': [wnck.WINDOW_GRAVITY_SOUTHEAST,
1180
                                 wnck.WINDOW_CHANGE_X | wnck.WINDOW_CHANGE_Y],
1181
    }
1182
1183
    @commands.add_many(MOVE_TO_COMMANDS)
1184
    def move_to_position(winman, win, state, gravity, gravity_mask):
1185
        """Move window to a position on the screen, preserving its dimensions."""
1186
        use_rect = state['usable_rect']
1187
1188
        grav_x, grav_y = winman.gravities[gravity]
1189
        dims = (int(use_rect.width * grav_x), int(use_rect.height * grav_y), 0, 0)
1190
        result = gtk.gdk.Rectangle(*dims)
1191
        logging.debug("Calling reposition() with %r gravity and dimensions %r",
1192
                      gravity, tuple(result))
1193
1194
        # pylint: disable=no-member
1195
        winman.reposition(win, result, use_rect, gravity=gravity,
1196
                geometry_mask=gravity_mask)
1197
1198
    @commands.add('bordered')
1199
    def toggle_decorated(winman, win, state):  # pylint: disable=unused-argument
1200
        """Toggle window state on the active window."""
1201
        win = gtk.gdk.window_foreign_new(win.get_xid())
1202
        win.set_decorations(not win.get_decorations())
1203
1204
    @commands.add('show-desktop')
1205
    def toggle_desktop(winman, win, state):  # pylint: disable=unused-argument
1206
        """Toggle "all windows minimized" to view the desktop"""
1207
        target = not winman.screen.get_showing_desktop()
1208
        winman.screen.toggle_showing_desktop(target)
1209
1210
    @commands.add('all-desktops', 'pin', 'is_pinned')
1211
    @commands.add('fullscreen', 'set_fullscreen', 'is_fullscreen', True)
1212
    @commands.add('vertical-maximize', 'maximize_vertically',
1213
                                       'is_maximized_vertically')
1214
    @commands.add('horizontal-maximize', 'maximize_horizontally',
1215
                                         'is_maximized_horizontally')
1216
    @commands.add('maximize', 'maximize', 'is_maximized')
1217
    @commands.add('minimize', 'minimize', 'is_minimized')
1218
    @commands.add('always-above', 'make_above', 'is_above')
1219
    @commands.add('always-below', 'make_below', 'is_below')
1220
    @commands.add('shade', 'shade', 'is_shaded')
1221
    # pylint: disable=unused-argument,too-many-arguments
1222
    def toggle_state(winman, win, state, command, check, takes_bool=False):
1223
        """Toggle window state on the active window.
1224
1225
        @param command: The C{wnck.Window} method name to be conditionally prefixed
1226
            with "un", resolved, and called.
1227
        @param check: The C{wnck.Window} method name to be called to check
1228
            whether C{command} should be prefixed with "un".
1229
        @param takes_bool: If C{True}, pass C{True} or C{False} to C{check} rather
1230
            thank conditionally prefixing it with C{un} before resolving.
1231
        @type command: C{str}
1232
        @type check: C{str}
1233
        @type takes_bool: C{bool}
1234
1235
        @todo 1.0.0: Rename C{vertical-maximize} and C{horizontal-maximize} to
1236
            C{maximize-vertical} and C{maximize-horizontal}. (API-breaking change)
1237
        """
1238
        target = not getattr(win, check)()
1239
1240
        logging.debug("Calling action '%s' with state '%s'", command, target)
1241
        if takes_bool:
1242
            getattr(win, command)(target)
1243
        else:
1244
            getattr(win, ('' if target else 'un') + command)()
1245
1246
    @commands.add('trigger-move', 'move')
1247
    @commands.add('trigger-resize', 'size')
1248
    # pylint: disable=unused-argument
1249
    def trigger_keyboard_action(winman, win, state, command):
1250
        """Ask the Window Manager to begin a keyboard-driven operation."""
1251
        getattr(win, 'keyboard_' + command)()
1252
1253
    @commands.add('workspace-go-next', 1)
1254
    @commands.add('workspace-go-prev', -1)
1255
    @commands.add('workspace-go-up', wnck.MOTION_UP)        # pylint: disable=E1101
1256
    @commands.add('workspace-go-down', wnck.MOTION_DOWN)    # pylint: disable=E1101
1257
    @commands.add('workspace-go-left', wnck.MOTION_LEFT)    # pylint: disable=E1101
1258
    @commands.add('workspace-go-right', wnck.MOTION_RIGHT)  # pylint: disable=E1101
1259
    def workspace_go(winman, win, state, motion):  # pylint: disable=W0613
1260
        """Switch the active workspace (next/prev wrap around)"""
1261
        target = winman.get_workspace(None, motion)
1262
        if not target:
1263
            return  # It's either pinned, on no workspaces, or there is no match
1264
        target.activate(int(time.time()))
1265
1266
    @commands.add('workspace-send-next', 1)
1267
    @commands.add('workspace-send-prev', -1)
1268
    @commands.add('workspace-send-up', wnck.MOTION_UP)      # pylint: disable=E1101
1269
    @commands.add('workspace-send-down', wnck.MOTION_DOWN)  # pylint: disable=E1101
1270
    @commands.add('workspace-send-left', wnck.MOTION_LEFT)  # pylint: disable=E1101
1271
    # pylint: disable=E1101
1272
    @commands.add('workspace-send-right', wnck.MOTION_RIGHT)
1273
    # pylint: disable=unused-argument
1274
    def workspace_send_window(winman, win, state, motion):
1275
        """Move the active window to another workspace (next/prev wrap around)"""
1276
        target = winman.get_workspace(win, motion)
1277
        if not target:
1278
            return  # It's either pinned, on no workspaces, or there is no match
1279
1280
        win.move_to_workspace(target)
1281
1282
    #}
1283
1284
    return commands
1285
1286
if __name__ == '__main__':
1287
    from optparse import OptionParser, OptionGroup
1288
    parser = OptionParser(usage="%prog [options] [action] ...",
1289
            version="%%prog v%s" % __version__)
1290
    parser.add_option('-d', '--daemonize', action="store_true",
1291
        dest="daemonize", default=False, help="Attempt to set up global "
1292
        "keybindings using python-xlib and a D-Bus service using dbus-python. "
1293
        "Exit if neither succeeds")
1294
    parser.add_option('-b', '--bindkeys', action="store_true",
1295
        dest="daemonize", default=False,
1296
        help="Deprecated alias for --daemonize")
1297
    parser.add_option('--debug', action="store_true", dest="debug",
1298
        default=False, help="Display debug messages")
1299
    parser.add_option('--no-workarea', action="store_true", dest="no_workarea",
1300
        default=False, help="Overlap panels but work better with "
1301
        "non-rectangular desktops")
1302
1303
    help_group = OptionGroup(parser, "Additional Help")
1304
    help_group.add_option('--show-bindings', action="store_true",
1305
        dest="show_binds", default=False, help="List all configured keybinds")
1306
    help_group.add_option('--show-actions', action="store_true",
1307
        dest="show_args", default=False, help="List valid arguments for use "
1308
        "without --daemonize")
1309
    parser.add_option_group(help_group)
1310
1311
    opts, args = parser.parse_args()
1312
1313
    if opts.debug:
1314
        logging.getLogger().setLevel(logging.DEBUG)
1315
1316
    # Load the config from file if present
1317
    # TODO: Refactor all this
1318
    cfg_path = os.path.join(XDG_CONFIG_DIR, 'quicktile.cfg')
1319
    first_run = not os.path.exists(cfg_path)
1320
1321
    config = RawConfigParser()
1322
    config.optionxform = str  # Make keys case-sensitive
1323
    # TODO: Maybe switch to two config files so I can have only the keys in the
1324
    #       keymap case-sensitive?
1325
    config.read(cfg_path)
1326
    dirty = False
1327
1328
    if not config.has_section('general'):
1329
        config.add_section('general')
1330
        # Change this if you make backwards-incompatible changes to the
1331
        # section and key naming in the config file.
1332
        config.set('general', 'cfg_schema', 1)
1333
        dirty = True
1334
1335
    for key, val in DEFAULTS['general'].items():
1336
        if not config.has_option('general', key):
1337
            config.set('general', key, str(val))
1338
            dirty = True
1339
1340
    mk_raw = modkeys = config.get('general', 'ModMask')
1341
    if ' ' in modkeys.strip() and '<' not in modkeys:
1342
        modkeys = '<%s>' % '><'.join(modkeys.strip().split())
1343
        logging.info("Updating modkeys format:\n %r --> %r", mk_raw, modkeys)
1344
        config.set('general', 'ModMask', modkeys)
1345
        dirty = True
1346
1347
    column_count = config.getint('general', 'ColumnCount')
1348
1349
    # Either load the keybindings or use and save the defaults
1350
    if config.has_section('keys'):
1351
        keymap = dict(config.items('keys'))
1352
    else:
1353
        keymap = DEFAULTS['keys']
1354
        config.add_section('keys')
1355
        for row in keymap.items():
1356
            config.set('keys', row[0], row[1])
1357
        dirty = True
1358
1359
    # Migrate from the deprecated syntax for punctuation keysyms
1360
    for key in keymap:
1361
        # Look up unrecognized shortkeys in a hardcoded dict and
1362
        # replace with valid names like ',' -> 'comma'
1363
        transKey = key
1364
        if key in KEYLOOKUP:
1365
            logging.warn("Updating config file from deprecated keybind syntax:"
1366
                    "\n\t%r --> %r", key, KEYLOOKUP[key])
1367
            transKey = KEYLOOKUP[key]
1368
            dirty = True
1369
1370
    if dirty:
1371
        cfg_file = file(cfg_path, 'wb')
1372
        config.write(cfg_file)
1373
        cfg_file.close()
1374
        if first_run:
1375
            logging.info("Wrote default config file to %s", cfg_path)
1376
1377
    ignore_workarea = ((not config.getboolean('general', 'UseWorkarea')) or
1378
                       opts.no_workarea)
1379
1380
    try:
1381
        winman = WindowManager(ignore_workarea=ignore_workarea)
1382
    except XInitError as err:
1383
        logging.critical(err)
1384
        sys.exit(1)
1385
1386
    commands = create_commands(column_count)
1387
1388
    app = QuickTileApp(winman, commands, keymap, modmask=modkeys)
1389
1390
    if opts.show_binds:
1391
        app.show_binds()
1392
    if opts.show_args:
1393
        print commands
1394
1395
    if opts.daemonize:
1396
        if not app.run():
1397
            logging.critical("Neither the Xlib nor the D-Bus backends were "
1398
                             "available")
1399
            sys.exit(errno.ENOENT)
1400
            # FIXME: What's the proper exit code for "library not found"?
1401
    elif not first_run:
1402
        if args:
1403
            winman.screen.force_update()
1404
1405
            for arg in args:
1406
                commands.call(arg, winman)
1407
            while gtk.events_pending():  # pylint: disable=no-member
1408
                gtk.main_iteration()  # pylint: disable=no-member
1409
        elif not opts.show_args and not opts.show_binds:
1410
            print commands
1411
            print "\nUse --help for a list of valid options."
1412
            sys.exit(errno.ENOENT)
1413
1414
# vim: set sw=4 sts=4 expandtab :
1415