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 .util import fmt_table |
13
|
|
|
|
14
|
|
|
class CommandRegistry(object): |
15
|
|
|
"""Handles lookup and boilerplate for window management commands. |
16
|
|
|
|
17
|
|
|
Separated from WindowManager so its lifecycle is not tied to a specific |
18
|
|
|
GDK Screen object. |
19
|
|
|
""" |
20
|
|
|
|
21
|
|
|
def __init__(self): |
22
|
|
|
self.commands = {} |
23
|
|
|
self.help = {} |
24
|
|
|
|
25
|
|
|
def __iter__(self): |
26
|
|
|
for x in self.commands: |
|
|
|
|
27
|
|
|
yield x |
28
|
|
|
|
29
|
|
|
def __str__(self): |
30
|
|
|
return fmt_table(self.help, ('Known Commands', 'desc'), group_by=1) |
31
|
|
|
|
32
|
|
|
def add(self, name, *p_args, **p_kwargs): |
33
|
|
|
"""Decorator to wrap a function in boilerplate and add it to the |
34
|
|
|
command registry under the given name. |
35
|
|
|
|
36
|
|
|
@param name: The name to know the command by. |
37
|
|
|
@param p_args: Positional arguments to prepend to all calls made |
38
|
|
|
via C{name}. |
39
|
|
|
@param p_kwargs: Keyword arguments to prepend to all calls made |
40
|
|
|
via C{name}. |
41
|
|
|
|
42
|
|
|
@type name: C{str} |
43
|
|
|
""" |
44
|
|
|
|
45
|
|
|
def decorate(func): |
46
|
|
|
"""Closure used to allow decorator to take arguments""" |
47
|
|
|
@wraps(func) |
48
|
|
|
# pylint: disable=missing-docstring |
49
|
|
|
def wrapper(winman, window=None, *args, **kwargs): |
50
|
|
|
|
51
|
|
|
# Get Wnck and GDK window objects |
52
|
|
|
window = window or winman.screen.get_active_window() |
53
|
|
|
if isinstance(window, gtk.gdk.Window): |
54
|
|
|
win = wnck.window_get(window.xid) # pylint: disable=E1101 |
55
|
|
|
else: |
56
|
|
|
win = window |
57
|
|
|
|
58
|
|
|
# pylint: disable=no-member |
59
|
|
|
if not win: |
60
|
|
|
logging.debug("Received no window object to manipulate.") |
61
|
|
|
return None |
62
|
|
|
elif win.get_window_type() == wnck.WINDOW_DESKTOP: |
63
|
|
|
logging.debug("Received desktop window object. Ignoring.") |
64
|
|
|
return None |
65
|
|
|
else: |
66
|
|
|
# FIXME: Make calls to win.get_* lazy in case --debug |
67
|
|
|
# wasn't passed. |
68
|
|
|
logging.debug("Operating on window 0x%x with title \"%s\" " |
69
|
|
|
"and geometry %r", |
70
|
|
|
win.get_xid(), win.get_name(), |
71
|
|
|
win.get_geometry()) |
72
|
|
|
|
73
|
|
|
monitor_id, monitor_geom = winman.get_monitor(window) |
74
|
|
|
|
75
|
|
|
use_area, use_rect = winman.get_workarea( |
76
|
|
|
monitor_geom, winman.ignore_workarea) |
77
|
|
|
|
78
|
|
|
# TODO: Replace this MPlayer safety hack with a properly |
79
|
|
|
# comprehensive exception catcher. |
80
|
|
|
if not use_rect: |
81
|
|
|
logging.debug("Received a worthless value for largest " |
82
|
|
|
"rectangular subset of desktop (%r). Doing " |
83
|
|
|
"nothing.", use_rect) |
84
|
|
|
return None |
85
|
|
|
|
86
|
|
|
state = { |
87
|
|
|
"cmd_name": name, |
88
|
|
|
"monitor_id": monitor_id, |
89
|
|
|
"monitor_geom": monitor_geom, |
90
|
|
|
"usable_region": use_area, |
91
|
|
|
"usable_rect": use_rect, |
92
|
|
|
} |
93
|
|
|
|
94
|
|
|
args, kwargs = p_args + args, dict(p_kwargs, **kwargs) |
95
|
|
|
func(winman, win, state, *args, **kwargs) |
96
|
|
|
|
97
|
|
|
if name in self.commands: |
98
|
|
|
logging.warn("Redefining existing command: %s", name) |
99
|
|
|
self.commands[name] = wrapper |
100
|
|
|
|
101
|
|
|
help_str = func.__doc__.strip().split('\n')[0].split('. ')[0] |
102
|
|
|
self.help[name] = help_str.strip('.') |
103
|
|
|
|
104
|
|
|
# Return the unwrapped function so decorators can be stacked |
105
|
|
|
# to define multiple commands using the same code with different |
106
|
|
|
# arguments |
107
|
|
|
return func |
108
|
|
|
return decorate |
109
|
|
|
|
110
|
|
|
def add_many(self, command_map): |
111
|
|
|
"""Convenience decorator to allow many commands to be defined from |
112
|
|
|
the same function with different arguments. |
113
|
|
|
|
114
|
|
|
@param command_map: A dict mapping command names to argument lists. |
115
|
|
|
@type command_map: C{dict} |
116
|
|
|
""" |
117
|
|
|
def decorate(func): |
118
|
|
|
"""Closure used to allow decorator to take arguments""" |
119
|
|
|
for cmd, arglist in command_map.items(): |
120
|
|
|
self.add(cmd, *arglist)(func) |
121
|
|
|
return func |
122
|
|
|
return decorate |
123
|
|
|
|
124
|
|
|
def call(self, command, winman, *args, **kwargs): |
125
|
|
|
"""Resolve a textual positioning command and execute it.""" |
126
|
|
|
cmd = self.commands.get(command, None) |
127
|
|
|
|
128
|
|
|
if cmd: |
129
|
|
|
logging.debug("Executing command '%s' with arguments %r, %r", |
130
|
|
|
command, args, kwargs) |
131
|
|
|
cmd(winman, *args, **kwargs) |
132
|
|
|
else: |
133
|
|
|
logging.error("Unrecognized command: %s", command) |
134
|
|
|
|
135
|
|
|
|
136
|
|
|
#: The instance of L{CommandRegistry} to be used in 99.9% of use cases. |
137
|
|
|
commands = CommandRegistry() |
|
|
|
|
138
|
|
|
|
139
|
|
|
def cycle_dimensions(winman, win, state, *dimensions): |
140
|
|
|
"""Cycle the active window through a list of positions and shapes. |
141
|
|
|
|
142
|
|
|
Takes one step each time this function is called. |
143
|
|
|
|
144
|
|
|
If the window's dimensions are not within 100px (by euclidean distance) |
145
|
|
|
of an entry in the list, set them to the first list entry. |
146
|
|
|
|
147
|
|
|
@param dimensions: A list of tuples representing window geometries as |
148
|
|
|
floating-point values between 0 and 1, inclusive. |
149
|
|
|
@type dimensions: C{[(x, y, w, h), ...]} |
150
|
|
|
@type win: C{gtk.gdk.Window} |
151
|
|
|
|
152
|
|
|
@returns: The new window dimensions. |
153
|
|
|
@rtype: C{gtk.gdk.Rectangle} |
154
|
|
|
""" |
155
|
|
|
win_geom = winman.get_geometry_rel(win, state['monitor_geom']) |
156
|
|
|
usable_region = state['usable_region'] |
157
|
|
|
|
158
|
|
|
# Get the bounding box for the usable region (overlaps panels which |
159
|
|
|
# don't fill 100% of their edge of the screen) |
160
|
|
|
clip_box = usable_region.get_clipbox() |
161
|
|
|
|
162
|
|
|
logging.debug("Selected preset sequence:\n\t%r", dimensions) |
163
|
|
|
|
164
|
|
|
# Resolve proportional (eg. 0.5) and preserved (None) coordinates |
165
|
|
|
dims = [] |
166
|
|
|
for tup in dimensions: |
167
|
|
|
current_dim = [] |
168
|
|
|
for pos, val in enumerate(tup): |
169
|
|
|
if val is None: |
170
|
|
|
current_dim.append(tuple(win_geom)[pos]) |
171
|
|
|
else: |
172
|
|
|
# FIXME: This is a bit of an ugly way to get (w, h, w, h) |
173
|
|
|
# from clip_box. |
174
|
|
|
current_dim.append(int(val * tuple(clip_box)[2 + pos % 2])) |
175
|
|
|
|
176
|
|
|
dims.append(current_dim) |
177
|
|
|
|
178
|
|
|
if not dims: |
179
|
|
|
return None |
180
|
|
|
|
181
|
|
|
logging.debug("Selected preset sequence resolves to these monitor-relative" |
182
|
|
|
" pixel dimensions:\n\t%r", dims) |
183
|
|
|
|
184
|
|
|
# Calculate euclidean distances between the window's current geometry |
185
|
|
|
# and all presets and store them in a min heap. |
186
|
|
|
euclid_distance = [] |
187
|
|
|
for pos, val in enumerate(dims): |
188
|
|
|
distance = sum([(wg - vv) ** 2 for (wg, vv) |
189
|
|
|
in zip(tuple(win_geom), tuple(val))]) ** 0.5 |
190
|
|
|
heappush(euclid_distance, (distance, pos)) |
191
|
|
|
|
192
|
|
|
# If the window is already on one of the configured geometries, advance |
193
|
|
|
# to the next configuration. Otherwise, use the first configuration. |
194
|
|
|
min_distance = heappop(euclid_distance) |
195
|
|
|
if float(min_distance[0]) / tuple(clip_box)[2] < 0.1: |
196
|
|
|
pos = (min_distance[1] + 1) % len(dims) |
197
|
|
|
else: |
198
|
|
|
pos = 0 |
199
|
|
|
result = gtk.gdk.Rectangle(*dims[pos]) |
200
|
|
|
|
201
|
|
|
logging.debug("Target preset is %s relative to monitor %s", |
202
|
|
|
result, clip_box) |
203
|
|
|
result.x += clip_box.x |
204
|
|
|
result.y += clip_box.y |
205
|
|
|
|
206
|
|
|
# If we're overlapping a panel, fall back to a monitor-specific |
207
|
|
|
# analogue to _NET_WORKAREA to prevent overlapping any panels and |
208
|
|
|
# risking the WM potentially meddling with the result of resposition() |
209
|
|
|
if not usable_region.rect_in(result) == gtk.gdk.OVERLAP_RECTANGLE_IN: |
210
|
|
|
result = result.intersect(state['usable_rect']) |
211
|
|
|
logging.debug("Result exceeds usable (non-rectangular) region of " |
212
|
|
|
"desktop. (overlapped a non-fullwidth panel?) Reducing " |
213
|
|
|
"to within largest usable rectangle: %s", |
214
|
|
|
state['usable_rect']) |
215
|
|
|
|
216
|
|
|
logging.debug("Calling reposition() with default gravity and dimensions " |
217
|
|
|
"%r", tuple(result)) |
218
|
|
|
winman.reposition(win, result) |
219
|
|
|
return result |
220
|
|
|
|
221
|
|
|
@commands.add('monitor-switch') |
222
|
|
|
@commands.add('monitor-next', 1) |
223
|
|
|
@commands.add('monitor-prev', -1) |
224
|
|
|
def cycle_monitors(winman, win, state, step=1): |
225
|
|
|
"""Cycle the active window between monitors while preserving position. |
226
|
|
|
|
227
|
|
|
@todo 1.0.0: Remove C{monitor-switch} in favor of C{monitor-next} |
228
|
|
|
(API-breaking change) |
229
|
|
|
""" |
230
|
|
|
mon_id = state['monitor_id'] |
231
|
|
|
new_mon_id = (mon_id + step) % winman.gdk_screen.get_n_monitors() |
232
|
|
|
|
233
|
|
|
new_mon_geom = winman.gdk_screen.get_monitor_geometry(new_mon_id) |
234
|
|
|
logging.debug("Moving window to monitor %s, which has geometry %s", |
235
|
|
|
new_mon_id, new_mon_geom) |
236
|
|
|
|
237
|
|
|
winman.reposition(win, None, new_mon_geom, keep_maximize=True) |
238
|
|
|
|
239
|
|
|
@commands.add('monitor-prev-all', -1) |
240
|
|
|
@commands.add('monitor-next-all', 1) |
241
|
|
|
def cycle_monitors_all(winman, win, state, step=1): |
242
|
|
|
"""Cycle all windows between monitors while preserving position.""" |
243
|
|
|
n_monitors = winman.gdk_screen.get_n_monitors() |
244
|
|
|
curr_workspace = win.get_workspace() |
245
|
|
|
|
246
|
|
|
if not curr_workspace: |
247
|
|
|
logging.debug("get_workspace() returned None") |
248
|
|
|
return |
249
|
|
|
|
250
|
|
|
for window in winman.screen.get_windows(): |
251
|
|
|
# Skip windows on other virtual desktops for intuitiveness |
252
|
|
|
if not window.is_on_workspace(curr_workspace): |
253
|
|
|
logging.debug("Skipping window on other workspace") |
254
|
|
|
continue |
255
|
|
|
|
256
|
|
|
# Don't cycle elements of the desktop |
257
|
|
|
if window.get_window_type() in [ |
258
|
|
|
wnck.WINDOW_DESKTOP, wnck.WINDOW_DOCK]: # pylint: disable=E1101 |
259
|
|
|
logging.debug("Skipping desktop/dock window") |
260
|
|
|
continue |
261
|
|
|
|
262
|
|
|
gdkwin = gtk.gdk.window_foreign_new(window.get_xid()) |
263
|
|
|
mon_id = winman.gdk_screen.get_monitor_at_window(gdkwin) |
264
|
|
|
new_mon_id = (mon_id + step) % n_monitors |
265
|
|
|
|
266
|
|
|
new_mon_geom = winman.gdk_screen.get_monitor_geometry(new_mon_id) |
267
|
|
|
logging.debug( |
268
|
|
|
"Moving window %s to monitor 0x%d, which has geometry %s", |
269
|
|
|
hex(window.get_xid()), new_mon_id, new_mon_geom) |
270
|
|
|
|
271
|
|
|
winman.reposition(window, None, new_mon_geom, keep_maximize=True) |
272
|
|
|
|
273
|
|
|
# pylint: disable=no-member |
274
|
|
|
MOVE_TO_COMMANDS = { |
275
|
|
|
'move-to-top-left': [wnck.WINDOW_GRAVITY_NORTHWEST, |
276
|
|
|
wnck.WINDOW_CHANGE_X | wnck.WINDOW_CHANGE_Y], |
277
|
|
|
'move-to-top': [wnck.WINDOW_GRAVITY_NORTH, wnck.WINDOW_CHANGE_Y], |
278
|
|
|
'move-to-top-right': [wnck.WINDOW_GRAVITY_NORTHEAST, |
279
|
|
|
wnck.WINDOW_CHANGE_X | wnck.WINDOW_CHANGE_Y], |
280
|
|
|
'move-to-left': [wnck.WINDOW_GRAVITY_WEST, wnck.WINDOW_CHANGE_X], |
281
|
|
|
'move-to-center': [wnck.WINDOW_GRAVITY_CENTER, |
282
|
|
|
wnck.WINDOW_CHANGE_X | wnck.WINDOW_CHANGE_Y], |
283
|
|
|
'move-to-right': [wnck.WINDOW_GRAVITY_EAST, wnck.WINDOW_CHANGE_X], |
284
|
|
|
'move-to-bottom-left': [wnck.WINDOW_GRAVITY_SOUTHWEST, |
285
|
|
|
wnck.WINDOW_CHANGE_X | wnck.WINDOW_CHANGE_Y], |
286
|
|
|
'move-to-bottom': [wnck.WINDOW_GRAVITY_SOUTH, wnck.WINDOW_CHANGE_Y], |
287
|
|
|
'move-to-bottom-right': [wnck.WINDOW_GRAVITY_SOUTHEAST, |
288
|
|
|
wnck.WINDOW_CHANGE_X | wnck.WINDOW_CHANGE_Y], |
289
|
|
|
} |
290
|
|
|
|
291
|
|
|
@commands.add_many(MOVE_TO_COMMANDS) |
292
|
|
|
def move_to_position(winman, win, state, gravity, gravity_mask): |
293
|
|
|
"""Move window to a position on the screen, preserving its dimensions.""" |
294
|
|
|
use_rect = state['usable_rect'] |
295
|
|
|
|
296
|
|
|
grav_x, grav_y = winman.gravities[gravity] |
297
|
|
|
dims = (int(use_rect.width * grav_x), int(use_rect.height * grav_y), 0, 0) |
298
|
|
|
result = gtk.gdk.Rectangle(*dims) |
299
|
|
|
logging.debug("Calling reposition() with %r gravity and dimensions %r", |
300
|
|
|
gravity, tuple(result)) |
301
|
|
|
|
302
|
|
|
# pylint: disable=no-member |
303
|
|
|
winman.reposition(win, result, use_rect, gravity=gravity, |
304
|
|
|
geometry_mask=gravity_mask) |
305
|
|
|
|
306
|
|
|
@commands.add('bordered') |
307
|
|
|
def toggle_decorated(winman, win, state): # pylint: disable=unused-argument |
308
|
|
|
"""Toggle window state on the active window.""" |
309
|
|
|
win = gtk.gdk.window_foreign_new(win.get_xid()) |
310
|
|
|
win.set_decorations(not win.get_decorations()) |
311
|
|
|
|
312
|
|
|
@commands.add('show-desktop') |
313
|
|
|
def toggle_desktop(winman, win, state): # pylint: disable=unused-argument |
314
|
|
|
"""Toggle "all windows minimized" to view the desktop""" |
315
|
|
|
target = not winman.screen.get_showing_desktop() |
316
|
|
|
winman.screen.toggle_showing_desktop(target) |
317
|
|
|
|
318
|
|
|
@commands.add('all-desktops', 'pin', 'is_pinned') |
319
|
|
|
@commands.add('fullscreen', 'set_fullscreen', 'is_fullscreen', True) |
320
|
|
|
@commands.add('vertical-maximize', 'maximize_vertically', |
321
|
|
|
'is_maximized_vertically') |
322
|
|
|
@commands.add('horizontal-maximize', 'maximize_horizontally', |
323
|
|
|
'is_maximized_horizontally') |
324
|
|
|
@commands.add('maximize', 'maximize', 'is_maximized') |
325
|
|
|
@commands.add('minimize', 'minimize', 'is_minimized') |
326
|
|
|
@commands.add('always-above', 'make_above', 'is_above') |
327
|
|
|
@commands.add('always-below', 'make_below', 'is_below') |
328
|
|
|
@commands.add('shade', 'shade', 'is_shaded') |
329
|
|
|
# pylint: disable=unused-argument,too-many-arguments |
330
|
|
|
def toggle_state(winman, win, state, command, check, takes_bool=False): |
331
|
|
|
"""Toggle window state on the active window. |
332
|
|
|
|
333
|
|
|
@param command: The C{wnck.Window} method name to be conditionally prefixed |
334
|
|
|
with "un", resolved, and called. |
335
|
|
|
@param check: The C{wnck.Window} method name to be called to check |
336
|
|
|
whether C{command} should be prefixed with "un". |
337
|
|
|
@param takes_bool: If C{True}, pass C{True} or C{False} to C{check} rather |
338
|
|
|
thank conditionally prefixing it with C{un} before resolving. |
339
|
|
|
@type command: C{str} |
340
|
|
|
@type check: C{str} |
341
|
|
|
@type takes_bool: C{bool} |
342
|
|
|
|
343
|
|
|
@todo 1.0.0: Rename C{vertical-maximize} and C{horizontal-maximize} to |
344
|
|
|
C{maximize-vertical} and C{maximize-horizontal}. (API-breaking change) |
345
|
|
|
""" |
346
|
|
|
target = not getattr(win, check)() |
347
|
|
|
|
348
|
|
|
logging.debug("Calling action '%s' with state '%s'", command, target) |
349
|
|
|
if takes_bool: |
350
|
|
|
getattr(win, command)(target) |
351
|
|
|
else: |
352
|
|
|
getattr(win, ('' if target else 'un') + command)() |
353
|
|
|
|
354
|
|
|
@commands.add('trigger-move', 'move') |
355
|
|
|
@commands.add('trigger-resize', 'size') |
356
|
|
|
# pylint: disable=unused-argument |
357
|
|
|
def trigger_keyboard_action(winman, win, state, command): |
358
|
|
|
"""Ask the Window Manager to begin a keyboard-driven operation.""" |
359
|
|
|
getattr(win, 'keyboard_' + command)() |
360
|
|
|
|
361
|
|
|
@commands.add('workspace-go-next', 1) |
362
|
|
|
@commands.add('workspace-go-prev', -1) |
363
|
|
|
@commands.add('workspace-go-up', wnck.MOTION_UP) # pylint: disable=E1101 |
364
|
|
|
@commands.add('workspace-go-down', wnck.MOTION_DOWN) # pylint: disable=E1101 |
365
|
|
|
@commands.add('workspace-go-left', wnck.MOTION_LEFT) # pylint: disable=E1101 |
366
|
|
|
@commands.add('workspace-go-right', wnck.MOTION_RIGHT) # pylint: disable=E1101 |
367
|
|
|
def workspace_go(winman, win, state, motion): # pylint: disable=W0613 |
368
|
|
|
"""Switch the active workspace (next/prev wrap around)""" |
369
|
|
|
target = winman.get_workspace(None, motion) |
370
|
|
|
if not target: |
371
|
|
|
return # It's either pinned, on no workspaces, or there is no match |
372
|
|
|
target.activate(int(time.time())) |
373
|
|
|
|
374
|
|
|
@commands.add('workspace-send-next', 1) |
375
|
|
|
@commands.add('workspace-send-prev', -1) |
376
|
|
|
@commands.add('workspace-send-up', wnck.MOTION_UP) # pylint: disable=E1101 |
377
|
|
|
@commands.add('workspace-send-down', wnck.MOTION_DOWN) # pylint: disable=E1101 |
378
|
|
|
@commands.add('workspace-send-left', wnck.MOTION_LEFT) # pylint: disable=E1101 |
379
|
|
|
# pylint: disable=E1101 |
380
|
|
|
@commands.add('workspace-send-right', wnck.MOTION_RIGHT) |
381
|
|
|
# pylint: disable=unused-argument |
382
|
|
|
def workspace_send_window(winman, win, state, motion): |
383
|
|
|
"""Move the active window to another workspace (next/prev wrap around)""" |
384
|
|
|
target = winman.get_workspace(win, motion) |
385
|
|
|
if not target: |
386
|
|
|
return # It's either pinned, on no workspaces, or there is no match |
387
|
|
|
|
388
|
|
|
win.move_to_workspace(target) |
389
|
|
|
|
390
|
|
|
# vim: set sw=4 sts=4 expandtab : |
391
|
|
|
|
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.