Completed
Push — master ( 31b814...90c146 )
by Stephan
32s
created

toggle_state()   B

Complexity

Conditions 3

Size

Total Lines 35

Duplication

Lines 0
Ratio 0 %

Importance

Changes 8
Bugs 1 Features 1
Metric Value
cc 3
c 8
b 1
f 1
dl 0
loc 35
rs 8.8571
1
"""Available window-management commands"""
2
3
__author__ = "Stephan Sokolow (deitarion/SSokolow)"
4
__license__ = "GNU GPL 2.0 or later"
5
6
import logging, time
7
from functools import wraps
8
from heapq import heappop, heappush
9
10
import gtk.gdk, wnck  # pylint: disable=import-error
11
12
from .wm import GRAVITY
13
from .util import clamp_idx, fmt_table
14
15
# Allow MyPy to work without depending on the `typing` package
16
# (And silence complaints from only using the imported types in comments)
17
try:
18
    # pylint: disable=unused-import
19
    from typing import (Any, Callable, Dict, Iterable, Iterator, List,  # NOQA
20
                        Optional, Sequence, Tuple, TYPE_CHECKING)
21
    from mypy_extensions import VarArg, KwArg  # NOQA
22
23
    if TYPE_CHECKING:
24
        from .wm import WindowManager  # NOQA
25
        from .util import CommandCB
26
27
    # FIXME: Replace */** with a dict so I can be strict here
28
    CommandCBWrapper = Callable[..., Any]
1 ignored issue
show
Coding Style Naming introduced by
The name CommandCBWrapper does not conform to the constant naming conventions ((([A-Z_][A-Z0-9_]*)|(__.*__))$).

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

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

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

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

Loading history...
29
except:  # pylint: disable=bare-except
30
    pass
0 ignored issues
show
Unused Code introduced by
This except handler seems to be unused and could be removed.

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

try:
    raises_exception()
except:  # Could be removed
    pass
Loading history...
31
32
class CommandRegistry(object):
33
    """Handles lookup and boilerplate for window management commands.
34
35
    Separated from WindowManager so its lifecycle is not tied to a specific
36
    GDK Screen object.
37
    """
38
39
    extra_state = {}  # type: Dict[str, Any]
40
41
    def __init__(self):     # type: () -> None
42
        self.commands = {}  # type: Dict[str, CommandCBWrapper]
43
        self.help = {}      # type: Dict[str, str]
44
45
    def __iter__(self):  # type: () -> Iterator[str]
46
        for x in self.commands:
0 ignored issues
show
Coding Style Naming introduced by
The name x does not conform to the variable naming conventions ([a-z_][a-z0-9_]{2,30}$).

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

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

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

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

Loading history...
47
            yield x
48
49
    def __str__(self):   # type: () -> str
50
        return fmt_table(self.help, ('Known Commands', 'desc'), group_by=1)
51
52
    def add(self, name, *p_args, **p_kwargs):
53
        # type: (str, *Any, **Any) -> Callable[[CommandCB], CommandCB]
54
        # TODO: Rethink the return value of the command function.
55
        """Decorator to wrap a function in boilerplate and add it to the
56
            command registry under the given name.
57
58
            @param name: The name to know the command by.
59
            @param p_args: Positional arguments to prepend to all calls made
60
                via C{name}.
61
            @param p_kwargs: Keyword arguments to prepend to all calls made
62
                via C{name}.
63
64
            @type name: C{str}
65
            """
66
67
        def decorate(func):  # type: (CommandCB) -> CommandCB
68
            """Closure used to allow decorator to take arguments"""
69
            @wraps(func)
70
            # pylint: disable=missing-docstring
71
            def wrapper(winman, window=None, *args, **kwargs):
72
                # TODO: Add a MyPy type signature
73
74
                # Get Wnck and GDK window objects
75
                window = window or winman.screen.get_active_window()
76
                if isinstance(window, gtk.gdk.Window):
77
                    win = wnck.window_get(window.xid)  # pylint: disable=E1101
78
                else:
79
                    win = window
80
81
                # pylint: disable=no-member
82
                if not win:
83
                    logging.debug("Received no window object to manipulate.")
84
                    return None
85
                elif win.get_window_type() == wnck.WINDOW_DESKTOP:
86
                    logging.debug("Received desktop window object. Ignoring.")
87
                    return None
88
                else:
89
                    # FIXME: Make calls to win.get_* lazy in case --debug
90
                    #        wasn't passed.
91
                    logging.debug("Operating on window 0x%x with title \"%s\" "
92
                                  "and geometry %r",
93
                                  win.get_xid(), win.get_name(),
94
                                  win.get_geometry())
95
96
                monitor_id, monitor_geom = winman.get_monitor(window)
97
98
                use_area, use_rect = winman.workarea.get(monitor_geom)
99
100
                # TODO: Replace this MPlayer safety hack with a properly
101
                #       comprehensive exception catcher.
102
                if not use_rect:
103
                    logging.debug("Received a worthless value for largest "
104
                                  "rectangular subset of desktop (%r). Doing "
105
                                  "nothing.", use_rect)
106
                    return None
107
108
                state = {}
109
                state.update(self.extra_state)
110
                state.update({
111
                    "cmd_name": name,
112
                    "monitor_id": monitor_id,
113
                    "monitor_geom": monitor_geom,
114
                    "usable_region": use_area,
115
                    "usable_rect": use_rect,
116
                })
117
118
                args, kwargs = p_args + args, dict(p_kwargs, **kwargs)
119
                func(winman, win, state, *args, **kwargs)
120
121
            if name in self.commands:
122
                logging.warn("Redefining existing command: %s", name)
123
            self.commands[name] = wrapper
124
125
            assert func.__doc__, ("Command must have a docstring: %r" % func)
126
            help_str = func.__doc__.strip().split('\n')[0].split('. ')[0]
127
            self.help[name] = help_str.strip('.')
128
129
            # Return the unwrapped function so decorators can be stacked
130
            # to define multiple commands using the same code with different
131
            # arguments
132
            return func
133
        return decorate
134
135
    def add_many(self, command_map):
136
        # type: (Dict[str, List[Any]]) -> Callable[[CommandCB], CommandCB]
137
        # TODO: Make this type signature more strict
138
        """Convenience decorator to allow many commands to be defined from
139
           the same function with different arguments.
140
141
           @param command_map: A dict mapping command names to argument lists.
142
           @type command_map: C{dict}
143
           """
144
        # TODO: Refactor and redesign for better maintainability
145
        def decorate(func):
146
            """Closure used to allow decorator to take arguments"""
147
            for cmd, arglist in command_map.items():
148
                self.add(cmd, *arglist)(func)
149
            return func
150
        return decorate
151
152
    def call(self, command, winman, *args, **kwargs):
153
        # type: (str, WindowManager, *Any, **Any) -> Any
154
        # TODO: Decide what to do about return values
155
        """Resolve a textual positioning command and execute it."""
156
        cmd = self.commands.get(command, None)
157
158
        if cmd:
159
            logging.debug("Executing command '%s' with arguments %r, %r",
160
                          command, args, kwargs)
161
            cmd(winman, *args, **kwargs)
162
        else:
163
            logging.error("Unrecognized command: %s", command)
164
165
166
#: The instance of L{CommandRegistry} to be used in 99.9% of use cases.
167
commands = CommandRegistry()
1 ignored issue
show
Coding Style Naming introduced by
The name commands does not conform to the constant naming conventions ((([A-Z_][A-Z0-9_]*)|(__.*__))$).

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

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

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

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

Loading history...
168
169
def cycle_dimensions(winman,      # type: WindowManager
170
                     win,         # type: Any  # TODO: Consistent Window type
171
                     state,       # type: Dict[str, Any]
172
                     *dimensions  # type: Any
173
                     ):  # type: (...) -> Optional[gtk.gdk.Rectangle]
174
    # type: (WindowManager, Any, Dict[str, Any], *Tuple[...]) ->
175
    # TODO: Standardize on what kind of window object to pass around
176
    """Cycle the active window through a list of positions and shapes.
177
178
    Takes one step each time this function is called.
179
180
    If the window's dimensions are not within 100px (by euclidean distance)
181
    of an entry in the list, set them to the first list entry.
182
183
    @param dimensions: A list of tuples representing window geometries as
184
        floating-point values between 0 and 1, inclusive.
185
    @type dimensions: C{[(x, y, w, h), ...]}
186
    @type win: C{gtk.gdk.Window}
187
188
    @returns: The new window dimensions.
189
    @rtype: C{gtk.gdk.Rectangle}
190
    """
191
    win_geom = winman.get_geometry_rel(win, state['monitor_geom'])
192
    usable_region = state['usable_region']
193
194
    # Get the bounding box for the usable region (overlaps panels which
195
    # don't fill 100% of their edge of the screen)
196
    clip_box = usable_region.get_clipbox()
197
198
    logging.debug("Selected preset sequence:\n\t%r", dimensions)
199
200
    # Resolve proportional (eg. 0.5) and preserved (None) coordinates
201
    dims = []
202
    for tup in dimensions:
203
        current_dim = []
204
        for pos, val in enumerate(tup):
205
            if val is None:
206
                current_dim.append(tuple(win_geom)[pos])
207
            else:
208
                # FIXME: This is a bit of an ugly way to get (w, h, w, h)
209
                # from clip_box.
210
                current_dim.append(int(val * tuple(clip_box)[2 + pos % 2]))
211
212
        dims.append(current_dim)
213
214
    if not dims:
215
        return None
216
217
    logging.debug("Selected preset sequence resolves to these monitor-relative"
218
                  " pixel dimensions:\n\t%r", dims)
219
220
    # Calculate euclidean distances between the window's current geometry
221
    # and all presets and store them in a min heap.
222
    euclid_distance = []  # type: List[Tuple[int, int]]
223
    for pos, val in enumerate(dims):
224
        distance = sum([(wg - vv) ** 2 for (wg, vv)
225
                        in zip(tuple(win_geom), tuple(val))]) ** 0.5
226
        heappush(euclid_distance, (distance, pos))
227
228
    # If the window is already on one of the configured geometries, advance
229
    # to the next configuration. Otherwise, use the first configuration.
230
    min_distance = heappop(euclid_distance)
231
    if float(min_distance[0]) / tuple(clip_box)[2] < 0.1:
232
        pos = (min_distance[1] + 1) % len(dims)
233
    else:
234
        pos = 0
235
    result = gtk.gdk.Rectangle(*dims[pos])
236
237
    logging.debug("Target preset is %s relative to monitor %s",
238
                  result, clip_box)
239
    result.x += clip_box.x
240
    result.y += clip_box.y
241
242
    # If we're overlapping a panel, fall back to a monitor-specific
243
    # analogue to _NET_WORKAREA to prevent overlapping any panels and
244
    # risking the WM potentially meddling with the result of resposition()
245
    if not usable_region.rect_in(result) == gtk.gdk.OVERLAP_RECTANGLE_IN:
246
        result = result.intersect(state['usable_rect'])
247
        logging.debug("Result exceeds usable (non-rectangular) region of "
248
                      "desktop. (overlapped a non-fullwidth panel?) Reducing "
249
                      "to within largest usable rectangle: %s",
250
                      state['usable_rect'])
251
252
    logging.debug("Calling reposition() with default gravity and dimensions "
253
                  "%r", tuple(result))
254
    winman.reposition(win, result)
255
    return result
256
257
@commands.add('monitor-switch', force_wrap=True)
258
@commands.add('monitor-next', 1)
259
@commands.add('monitor-prev', -1)
260
def cycle_monitors(winman, win, state, step=1, force_wrap=False):
261
    # type: (WindowManager, Any, Dict[str, Any], int, bool) -> None
262
    """Cycle the active window between monitors while preserving position.
263
264
    @todo 1.0.0: Remove C{monitor-switch} in favor of C{monitor-next}
265
        (API-breaking change)
266
    """
267
    mon_id = state['monitor_id']
268
    n_monitors = winman.gdk_screen.get_n_monitors()
269
270
    new_mon_id = clamp_idx(mon_id + step, n_monitors,
271
        state['config'].getboolean('general', 'MovementsWrap') or
272
        force_wrap)
273
274
    new_mon_geom = winman.gdk_screen.get_monitor_geometry(new_mon_id)
275
    logging.debug("Moving window to monitor %s, which has geometry %s",
276
                  new_mon_id, new_mon_geom)
277
278
    winman.reposition(win, None, new_mon_geom, keep_maximize=True)
279
280
@commands.add('monitor-switch-all', force_wrap=True)
281
@commands.add('monitor-prev-all', -1)
282
@commands.add('monitor-next-all', 1)
283
def cycle_monitors_all(winman, win, state, step=1, force_wrap=False):
284
    # type: (WindowManager, wnck.Window, Dict[str, Any], int, bool) -> None
285
    """Cycle all windows between monitors while preserving position."""
286
    n_monitors = winman.gdk_screen.get_n_monitors()
287
    curr_workspace = win.get_workspace()
288
289
    if not curr_workspace:
290
        logging.debug("get_workspace() returned None")
291
        return
292
293
    for window in winman.screen.get_windows():
294
        # Skip windows on other virtual desktops for intuitiveness
295
        if not window.is_on_workspace(curr_workspace):
296
            logging.debug("Skipping window on other workspace")
297
            continue
298
299
        # Don't cycle elements of the desktop
300
        if window.get_window_type() in [
301
              wnck.WINDOW_DESKTOP, wnck.WINDOW_DOCK]:  # pylint: disable=E1101
302
            logging.debug("Skipping desktop/dock window")
303
            continue
304
305
        gdkwin = gtk.gdk.window_foreign_new(window.get_xid())
306
        mon_id = winman.gdk_screen.get_monitor_at_window(gdkwin)
307
308
        # TODO: deduplicate cycle_monitors and cycle_monitors_all
309
        new_mon_id = clamp_idx(mon_id + step, n_monitors,
310
            state['config'].getboolean('general', 'MovementsWrap') or
311
            force_wrap)
312
313
        new_mon_geom = winman.gdk_screen.get_monitor_geometry(new_mon_id)
314
        logging.debug(
315
            "Moving window %s to monitor 0x%d, which has geometry %s",
316
            hex(window.get_xid()), new_mon_id, new_mon_geom)
317
318
        winman.reposition(window, None, new_mon_geom, keep_maximize=True)
319
320
# pylint: disable=no-member
321
MOVE_TO_COMMANDS = {
322
    'move-to-top-left': [wnck.WINDOW_GRAVITY_NORTHWEST,
323
                         wnck.WINDOW_CHANGE_X | wnck.WINDOW_CHANGE_Y],
324
    'move-to-top': [wnck.WINDOW_GRAVITY_NORTH, wnck.WINDOW_CHANGE_Y],
325
    'move-to-top-right': [wnck.WINDOW_GRAVITY_NORTHEAST,
326
                          wnck.WINDOW_CHANGE_X | wnck.WINDOW_CHANGE_Y],
327
    'move-to-left': [wnck.WINDOW_GRAVITY_WEST, wnck.WINDOW_CHANGE_X],
328
    'move-to-center': [wnck.WINDOW_GRAVITY_CENTER,
329
                       wnck.WINDOW_CHANGE_X | wnck.WINDOW_CHANGE_Y],
330
    'move-to-right': [wnck.WINDOW_GRAVITY_EAST, wnck.WINDOW_CHANGE_X],
331
    'move-to-bottom-left': [wnck.WINDOW_GRAVITY_SOUTHWEST,
332
                            wnck.WINDOW_CHANGE_X | wnck.WINDOW_CHANGE_Y],
333
    'move-to-bottom': [wnck.WINDOW_GRAVITY_SOUTH, wnck.WINDOW_CHANGE_Y],
334
    'move-to-bottom-right': [wnck.WINDOW_GRAVITY_SOUTHEAST,
335
                             wnck.WINDOW_CHANGE_X | wnck.WINDOW_CHANGE_Y],
336
}
337
338
@commands.add_many(MOVE_TO_COMMANDS)
339
def move_to_position(winman,       # type: WindowManager
340
                     win,          # type: Any  # TODO: Make this specific
341
                     state,        # type: Dict[str, Any]
342
                     gravity,      # type: Any  # TODO: Make this specific
343
                     gravity_mask  # type: wnck.WindowMoveResizeMask
344
                     ):  # type: (...) -> None  # TODO: Decide on a return type
345
    """Move window to a position on the screen, preserving its dimensions."""
346
    use_rect = state['usable_rect']
347
348
    grav_x, grav_y = GRAVITY[gravity]
349
    dims = (int(use_rect.width * grav_x), int(use_rect.height * grav_y), 0, 0)
350
    result = gtk.gdk.Rectangle(*dims)
351
    logging.debug("Calling reposition() with %r gravity and dimensions %r",
352
                  gravity, tuple(result))
353
354
    # pylint: disable=no-member
355
    winman.reposition(win, result, use_rect, gravity=gravity,
356
            geometry_mask=gravity_mask)
357
358
@commands.add('bordered')
359
def toggle_decorated(winman, win, state):  # pylint: disable=unused-argument
360
    # type: (WindowManager, wnck.Window, Any) -> None
361
    """Toggle window state on the active window."""
362
    win = gtk.gdk.window_foreign_new(win.get_xid())
363
    win.set_decorations(not win.get_decorations())
364
365
@commands.add('show-desktop')
366
def toggle_desktop(winman, win, state):  # pylint: disable=unused-argument
367
    # type: (WindowManager, Any, Any) -> None
368
    """Toggle "all windows minimized" to view the desktop"""
369
    target = not winman.screen.get_showing_desktop()
370
    winman.screen.toggle_showing_desktop(target)
371
372
@commands.add('all-desktops', 'pin', 'is_pinned')
373
@commands.add('fullscreen', 'set_fullscreen', 'is_fullscreen', True)
374
@commands.add('vertical-maximize', 'maximize_vertically',
375
                                   'is_maximized_vertically')
376
@commands.add('horizontal-maximize', 'maximize_horizontally',
377
                                     'is_maximized_horizontally')
378
@commands.add('maximize', 'maximize', 'is_maximized')
379
@commands.add('minimize', 'minimize', 'is_minimized')
380
@commands.add('always-above', 'make_above', 'is_above')
381
@commands.add('always-below', 'make_below', 'is_below')
382
@commands.add('shade', 'shade', 'is_shaded')
383
# pylint: disable=unused-argument,too-many-arguments
384
def toggle_state(winman, win, state, command, check, takes_bool=False):
385
    # type: (WindowManager, wnck.Window, Any, str, str, bool) -> None
386
    """Toggle window state on the active window.
387
388
    @param command: The C{wnck.Window} method name to be conditionally prefixed
389
        with "un", resolved, and called.
390
    @param check: The C{wnck.Window} method name to be called to check
391
        whether C{command} should be prefixed with "un".
392
    @param takes_bool: If C{True}, pass C{True} or C{False} to C{check} rather
393
        thank conditionally prefixing it with C{un} before resolving.
394
    @type command: C{str}
395
    @type check: C{str}
396
    @type takes_bool: C{bool}
397
398
    @todo 1.0.0: Rename C{vertical-maximize} and C{horizontal-maximize} to
399
        C{maximize-vertical} and C{maximize-horizontal}. (API-breaking change)
400
    """
401
    target = not getattr(win, check)()
402
403
    logging.debug("Calling action '%s' with state '%s'", command, target)
404
    if takes_bool:
405
        getattr(win, command)(target)
406
    else:
407
        getattr(win, ('' if target else 'un') + command)()
408
409
@commands.add('trigger-move', 'move')
410
@commands.add('trigger-resize', 'size')
411
# pylint: disable=unused-argument
412
def trigger_keyboard_action(winman, win, state, command):
413
    # type: (WindowManager, wnck.Window, Any, str) -> None
414
    """Ask the Window Manager to begin a keyboard-driven operation."""
415
    getattr(win, 'keyboard_' + command)()
416
417
@commands.add('workspace-go-next', 1)
418
@commands.add('workspace-go-prev', -1)
419
@commands.add('workspace-go-up', wnck.MOTION_UP)        # pylint: disable=E1101
420
@commands.add('workspace-go-down', wnck.MOTION_DOWN)    # pylint: disable=E1101
421
@commands.add('workspace-go-left', wnck.MOTION_LEFT)    # pylint: disable=E1101
422
@commands.add('workspace-go-right', wnck.MOTION_RIGHT)  # pylint: disable=E1101
423
def workspace_go(winman, win, state, motion):  # pylint: disable=W0613
424
    # type: (WindowManager, wnck.Window, Any, wnck.MotionDirection) -> None
425
    """Switch the active workspace (next/prev wrap around)"""
426
    target = winman.get_workspace(None, motion,
427
        wrap_around=state['config'].getboolean('general', 'MovementsWrap'))
428
    if not target:
429
        return  # It's either pinned, on no workspaces, or there is no match
430
    target.activate(int(time.time()))
431
432
@commands.add('workspace-send-next', 1)
433
@commands.add('workspace-send-prev', -1)
434
@commands.add('workspace-send-up', wnck.MOTION_UP)      # pylint: disable=E1101
435
@commands.add('workspace-send-down', wnck.MOTION_DOWN)  # pylint: disable=E1101
436
@commands.add('workspace-send-left', wnck.MOTION_LEFT)  # pylint: disable=E1101
437
# pylint: disable=E1101
438
@commands.add('workspace-send-right', wnck.MOTION_RIGHT)
439
# pylint: disable=unused-argument
440
def workspace_send_window(winman, win, state, motion):
441
    # type: (WindowManager, wnck.Window, Any, wnck.MotionDirection) -> None
442
    """Move the active window to another workspace (next/prev wrap around)"""
443
    target = winman.get_workspace(win, motion,
444
        wrap_around=state['config'].getboolean('general', 'MovementsWrap'))
445
    if not target:
446
        return  # It's either pinned, on no workspaces, or there is no match
447
448
    win.move_to_workspace(target)
449
450
# vim: set sw=4 sts=4 expandtab :
451