Completed
Push — master ( f72b23...6ec079 )
by Stephan
01:04
created

  A

Complexity

Total Complexity 11

Size/Duplication

Total Lines 76
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 11
dl 0
loc 76
rs 10
c 0
b 0
f 0

6 Methods

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