Completed
Push — master ( 8fbc56...65fc05 )
by Stephan
9s
created

move_to_position()   A

Complexity

Conditions 1

Size

Total Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

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