Blog: Grid-based Tiling Window Management, Mark II: quicktile

File quicktile, 23.1 KB (added by retracile, 20 months ago)

quicktile

Line 
1#!/usr/bin/python3
2# -*- coding: utf-8 -*-
3import sys
4import os
5import subprocess
6import argparse
7import re
8import time
9import traceback
10from collections import namedtuple
11
12# Log actions to ~/quicktile.log
13ENABLE_LOGGING = False
14
15# In the face of multiple screens with wildly different resolutions, there are
16# essentially two ways to approach the 'grid size'.
17# One is to take each screen and divide it into the same number of parts; so
18# you have a laptop screen, and it gets divided into a 4x4 gride, and you have
19# a 4K monitor, and it gets divided into a 4x4 grid.  This gives you the same
20# size grid on both screens.
21# The other is to divide the laptop screen into a 2x2 grid, and the 4K monitor
22# into a 4x4 grid; this gives you similar size grid _cells_ on both screens.
23# On my laptop monitor, I find a 4x4 grid to be awkwardly small.  So I think
24# aiming for similar sized grid cells is going to be the better approach.  But
25# then again, I could see a 3x3 grid on the laptop being reasonable, and is a
26# significant improvement in flexibility.
27#
28# So.  I think this boils down to 'we need a configuration file'... though I'm
29# not seeing a nice clean way to configure that...
30# screen geometry -> AxB grid
31#
32# We want windows to snap to the grid for their location, and for their size.
33# And we don't want windows to straddle screen boundaries.
34# So... we could generate all possible grid entities, then search them to find
35# the best target.
36
37# Eventually, I'll move this to a configuration file, likely YAML, but since
38# it's still in flux, just keep it here.
39# I might want to support doing a 3x3 grid when it's the laptop screen only,
40# but a 2x2 grid when it's with the 4k monitor
41CONFIG = {
42    'screen-grids': {
43        # For a given screen, what grid to chop it into
44        (1920, 1080): (2, 2), # Full HD
45        #(1920, 1080): (3, 3), # Full HD # Testing
46        (3840, 2160): (4, 4), # 4K UHD
47    }
48}
49
50# Install prerequisites
51# dnf install wmctrl xdotool
52
53LOG_FILE = open(os.path.expanduser('~/quicktile.log'), 'a')
54
55if ENABLE_LOGGING:
56    def LOG(message):
57        message = message.rstrip()
58        LOG_FILE.write("%s: %s\n" % (time.asctime(), message))
59        LOG_FILE.flush()
60else:
61    def LOG(message):
62        pass
63
64def LOG_ERROR(message):
65    message = message.rstrip()
66    LOG_FILE.write("%s: ERROR: %s\n" % (time.asctime(), message))
67    LOG_FILE.flush()
68
69
70def get_active_window_id():
71    """gives the window ID of the currently active window"""
72    task = subprocess.Popen(['xdotool', 'getactivewindow'], stdout=subprocess.PIPE)
73    stdout, stderr = task.communicate()
74    return int(stdout)
75
76
77class Point(namedtuple('Point', ('X', 'Y'))):
78    def distance_squared(self, other):
79        return (self.X-other.X)**2 + (self.Y-other.Y)**2
80
81    def __repr__(self):
82        return "P(%s,%s)" % (self.X, self.Y)
83
84    def __add__(self, other):
85        summed = [s+o for s, o in zip(self, other)]
86        return Point(*summed)
87
88    def __sub__(self, other):
89        diff = [s-o for s, o in zip(self, other)]
90        return Point(*diff)
91
92    def __mul__(self, factor):
93        mul = [s * factor for s in self]
94        return Point(*mul)
95
96    def __rmul__(self, factor):
97        return self * factor
98
99
100class Geometry(namedtuple('Geometry', ('X', 'Y', 'W', 'H'))):
101    def distance_squared(self, other):
102        return (self.X-other.X)**2 + (self.Y-other.Y)**2
103
104    def location_difference_squared(self, other):
105        return (self.X-other.X)**2 + (self.Y-other.Y)**2
106
107    def size_difference_squared(self, other):
108        return (self.W-other.W)**2 + (self.H-other.H)**2
109
110    def location_size_difference_squared(self, other):
111        return (self.location_difference_squared(other), self.size_difference_squared(other))
112
113    def size_location_difference_squared(self, other):
114        """size is more important than distance"""
115        return (self.size_difference_squared(other), self.location_difference_squared(other))
116
117    def difference_squared(self, other):
118        """returns square of distance between centers plus square of difference in size
119        """
120        return self.center().distance_squared(other.center()) + self.size_difference_squared(other)
121
122    def __repr__(self):
123        return "G(X=%s,Y=%s,W=%s,H=%s)" % (self.X, self.Y, self.W, self.H)
124
125    def __add__(self, other):
126        summed = [s+o for s, o in zip(self, other)]
127        return Geometry(*summed)
128
129    def __sub__(self, other):
130        diff = [s-o for s, o in zip(self, other)]
131        return Geometry(*diff)
132
133    def __mul__(self, factor):
134        mul = [s * factor for s in self]
135        return Geometry(*mul)
136
137    def __rmul__(self, factor):
138        return self * factor
139
140    def nw(self):
141        return Point(self.X, self.Y)
142
143    def se(self):
144        return Point(self.X+self.W, self.Y+self.H)
145
146    def center(self):
147        return Point(self.X+self.W//2, self.Y+self.H//2)
148
149    def left_center(self):
150        return Point(self.X, self.Y + self.H // 2)
151
152    def right_center(self):
153        return Point(self.X + self.W, self.Y + self.H // 2)
154
155    def top_center(self):
156        return Point(self.X + self.W // 2, self.Y)
157
158    def bottom_center(self):
159        return Point(self.X + self.W // 2, self.Y + self.H)
160
161
162class Window(object):
163    def __init__(self, id, desktop, X, Y, W, H, client, title):
164        self.id = int(id, 16)
165        self.desktop = int(desktop, 10)
166        self.X = int(X, 10)
167        self.Y = int(Y, 10)
168        self.W = int(W, 10)
169        self.H = int(H, 10)
170        self.client = client
171        self.title = title
172
173    def _geometry_frame_offset(self):
174        """Returns a Geometry for adjusting for the window frame.
175        """
176        # Normal maximized:
177        #_KDE_NET_WM_FRAME_STRUT(CARDINAL) = 0, 0, 24, 0
178        #_NET_FRAME_EXTENTS(CARDINAL) = 0, 0, 24, 0
179        # Not maximized:
180        #_KDE_NET_WM_FRAME_STRUT(CARDINAL) = 4, 4, 28, 4
181        #_NET_FRAME_EXTENTS(CARDINAL) = 4, 4, 28, 4
182        # As of 20220704, I'm seeing title bar is 35px
183        # left border, right border, title bar, bottom border
184        task = subprocess.Popen(['xprop', '-id', str(self.id), '_NET_FRAME_EXTENTS'], stdout=subprocess.PIPE)
185        stdout, stderr = task.communicate()
186        left_border, right_border, title_bar, bottom_border = [int(v, 10) for v in stdout.decode().split('=')[-1].strip().split(', ')]
187        return Geometry(-left_border, -title_bar, left_border+right_border, title_bar+bottom_border)
188
189    def set_geometry(self, geometry):
190        """Place a window at the given geometry, adjusting for window manager offsets.
191        """
192        orig_geometry = self.geometry()
193        if geometry == orig_geometry: # Avoid work if it's already where we want it
194            LOG( "Geometry already at %s" % (geometry, ))
195        else:
196            LOG( "Setting geometry to %s" % (geometry, ))
197            # NOTE: If the window is maximized, the xdotool will not be able to
198            # move/resize the window, and will hang for 15 seconds.
199
200            # We can detect a normal, maximized window:
201            # _NET_WM_STATE(ATOM) =  _NET_WM_STATE_MAXIMIZED_VERT, _NET_WM_STATE_MAXIMIZED_HORZ
202            # Windows can have just one of those set, so we detect either
203            task = subprocess.Popen(['xprop', '-id', str(self.id), '_NET_WM_STATE'], stdout=subprocess.PIPE)
204            stdout, stderr = task.communicate()
205            flags = set(stdout.decode().split('=')[-1].strip().split(', '))
206            LOG("flags=%r" % flags)
207            if flags.intersection(('_NET_WM_STATE_MAXIMIZED_VERT', '_NET_WM_STATE_MAXIMIZED_HORZ')):
208                LOG("DETECTED MAXIMIZED WINDOW")
209                subprocess.check_call(['wmctrl', '-i', '-r', hex(self.id), '-b', 'remove,maximized_vert,maximized_horz'])
210                time.sleep(0.10) # Give it a (longer) moment to resize
211            # But KDE's "Quick Tile" feature does not set anything that xprop
212            # displays, so it won't detect windows that have been "quick tiled".
213
214            frame_offset = self._geometry_frame_offset()
215            LOG("frame_offset=%s" % (frame_offset,))
216            offset_geometry = geometry - frame_offset
217            # offset_geometry does not include the frame
218            # Sometime between Fedora 29 and Fedora 32, clients are no longer
219            # being listed as N/A; they're all listed as the hostname.
220            #if self.client != 'N/A': # Bug workaround
221            #    # For windows that have the hostname as the client instead of
222            #    # 'N/A', when we set the geometry we have to provide the NW
223            #    # corner _including_ the frame, but the width and height
224            #    # _without_ the frame.
225            #    offset_geometry += Geometry(frame_offset.X, frame_offset.Y, 0, 0)
226            #LOG("frame_offset fixup=%s" % (frame_offset,))
227            LOG(f"Setting geometry to {geometry} using {offset_geometry}")
228            self._set_geometry(offset_geometry)
229            new_geometry = self.get_geometry()
230            if new_geometry == orig_geometry:
231                # It didn't move.  One of the ways this can happen is when KDE's
232                # native quick tiling or maximizing is in use on a window.
233                LOG( "Geometry unchanged; attempting to unmaximize")
234                self.unmaximize()
235                self._set_geometry(offset_geometry)
236                new_geometry = self.get_geometry()
237            if new_geometry != geometry: # The window manager is being a real pain
238                LOG( "\007Failed to set geometry to %s using %s; wound up with %s instead" % (geometry, offset_geometry, new_geometry))
239                # For Fedora 32, try to adjust by how much we missed and try again
240                error_correction = (geometry - new_geometry)
241                LOG(f"Error correction: {error_correction}")
242                retry_offset_geometry = offset_geometry + error_correction
243                self._set_geometry(retry_offset_geometry)
244                new_geometry = self.get_geometry()
245                if new_geometry != geometry: # The window manager is being a _real_ pain
246                    LOG( "\007Failed AGAIN to set geometry to %s using %s; wound up with %s instead" % (geometry, retry_offset_geometry, new_geometry))
247
248    def _set_geometry(self, geometry):
249        """Directly calls an xdotool command to size and move the window to the given coordinates.
250        """
251        # Move vs size order matters.  If shrinking, size then move.  If growing, move then size.
252        if geometry.W > self.geometry().W or geometry.H > self.geometry().H: # Growing
253            cmd = ['xdotool', 'windowmove', '--sync', str(self.id), str(geometry.X), str(geometry.Y), 'windowsize', '--sync', str(self.id), str(geometry.W), str(geometry.H)]
254        else: # Shrinking
255            cmd = ['xdotool', 'windowsize', '--sync', str(self.id), str(geometry.W), str(geometry.H), 'windowmove', '--sync', str(self.id), str(geometry.X), str(geometry.Y)]
256        LOG(f"Running command {cmd}")
257        start = time.time()
258        subprocess.check_call(cmd)
259        end = time.time()
260        if end-start > 1:
261            LOG_ERROR(f"\007_set_geometry for \"{self.title}\" took {end-start:0.2f}s")
262        time.sleep(0.05) # Without this sleep, xdotool will _sometimes_ fail to set the geometry.
263
264    def geometry(self):
265        return self._get_geometry() + self._geometry_frame_offset()
266
267    def get_geometry(self):
268        return self.geometry()
269
270    def _get_geometry(self):
271        task = subprocess.Popen(['xdotool', 'getwindowgeometry', str(self.id)], stdout=subprocess.PIPE)
272        stdout, stderr = task.communicate()
273        geoRE = re.compile('Window .*Position: (?P<X>-?[0-9]+),(?P<Y>-?[0-9]+) .*Geometry: (?P<W>[0-9]+)x(?P<H>[0-9]+)', re.DOTALL)
274        match = geoRE.match(stdout.decode('utf-8'))
275        result = dict((k, int(v)) for k, v in match.groupdict().items())
276        return Geometry(**result)
277
278    def unmaximize(self):
279        # Removing maximization is not sufficient; we have to add it and then
280        # remove it to get it to un-maximize.  Has the annoying side effect
281        # when a window is 'KDE quicktiled' to a half or quarter of the screen
282        # of maximizing the window for a brief flash before resizing to the
283        # desired size.
284        subprocess.check_call(['wmctrl', '-i', '-r', hex(self.id), '-b', 'add,maximized_vert,maximized_horz'])
285        subprocess.check_call(['wmctrl', '-i', '-r', hex(self.id), '-b', 'remove,maximized_vert,maximized_horz'])
286        time.sleep(0.05)
287
288    def __repr__(self):
289        return 'W(%s,%s,%s,%s,%s,%s,%s,%s)' % (self.id, self.desktop, self.X, self.Y, self.W, self.H, self.client, self.title)
290
291
292def get_current_windows():
293    task = subprocess.Popen(['wmctrl', '-l', '-G'], stdout=subprocess.PIPE)
294    stdout, stderr = task.communicate()
295    # Apparently, a window title can embed a newline; so we have to iterate
296    # through matches, not iterate over lines to find matches.
297    entry = re.compile('(?P<id>0x[0-9a-f]{8})\\s+(?P<desktop>[-0-9]+)\\s+'
298        '(?P<X>-?[0-9]+)\\s+'
299        '(?P<Y>-?[0-9]+)\\s+'
300        '(?P<W>[0-9]+)\\s+'
301        '(?P<H>[0-9]+)\\s+'
302        '(?P<client>[^ ]+)\\s(?P<title>.*)', re.M)
303    windows = []
304    for match in entry.finditer(stdout.decode('utf-8')):
305        windows.append(Window(**match.groupdict()))
306    return windows
307
308
309class Controller(object):
310    def __init__(self):
311        """Where grid is the number of cells in each direction on each desktop.
312        KDE's default quick tiling is equivalent to grid=2.
313        """
314        self.query_window_manager()
315        self._calculate_geometries()
316
317    def query_window_manager(self):
318        self.current_windows = get_current_windows()
319        self.windows_by_id = dict((w.id, w) for w in self.current_windows)
320        self.active = get_active_window_id()
321
322    def _generate_grid_cells_for_desktop(self, geometry, grid_x, grid_y):
323        """Returns a set of Geometry objects for all possible grid placements
324        on the given geometry, when divided into a grid_x-by-grid_y grid.
325        """
326        grid_cells = []
327        for grid_left in range(grid_x):
328            for grid_right in range(grid_left, grid_x):
329                for grid_top in range(grid_y):
330                    for grid_bottom in range(grid_top, grid_y):
331                        left = geometry.X + geometry.W * grid_left // grid_x
332                        right = geometry.X + geometry.W * (grid_right+1) // grid_x
333                        top = geometry.Y + geometry.H * grid_top // grid_y
334                        bottom = geometry.Y + geometry.H * (grid_bottom+1) // grid_y
335                        width = right - left
336                        height = bottom - top
337                        grid = Geometry(X=left,
338                                        Y=top,
339                                        W=width,
340                                        H=height)
341                        grid_cells.append(grid)
342        return grid_cells
343
344    def _splits_for_screen_size(self, width, height):
345        _, closest = min(((width-w)**2 + (height-h)**2, (w, h)) for w, h in CONFIG['screen-grids'].keys())
346        splits = CONFIG['screen-grids'].get(closest)
347        if not splits:
348            splits = (2, 2)
349            LOG("No split info found for %sx%s screen, defaulting to %sx%s" % (width, height, splits[0], splits[1]))
350        return splits
351
352    def _calculate_geometries(self):
353        self.plasma_windows = [w for w in self.current_windows if w.desktop == -1]
354        menubar = [w for w in self.plasma_windows if w.title == 'Plasma'][0]
355        desktops = [w for w in self.plasma_windows if w.title.startswith('Desktop')]
356        # KDE's logic for moving windows into a given location seems to think
357        # that the menubar is on all desktops, and will refuse to move a window
358        # down far enough to cover it, and will move the window if it is
359        # resized enough to cover it.
360        if False:
361            # For now, I'm going to assume the menu bar is on the bottom of the screen.
362            menubar_desktop = [w for w in desktops if w.X == menubar.X][0]
363            other_desktops = [w for w in desktops if w != menubar_desktop]
364            self.desktop_geometries = [
365                Geometry(menubar_desktop.X, menubar_desktop.Y, menubar_desktop.W, menubar_desktop.H-menubar.H),
366            ]
367            self.desktop_geometries.extend(Geometry(w.X, w.Y, w.W, w.H) for w in other_desktops)
368        else:
369            # So we're going to simply give up on the bottom 28px of the non-menubar screen, and act like it exists on all screens
370            self.desktop_geometries = [Geometry(d.X, d.Y, d.W, d.H-menubar.H) for d in desktops]
371        #LOG("Desktop geometries: %s\n" % self.desktop_geometries) # DEBUG
372
373        self.grid_tiles = []
374        for desktop_geometry in self.desktop_geometries:
375            x_splits, y_splits = self._splits_for_screen_size(desktop_geometry.W, desktop_geometry.H)
376            self.grid_tiles.extend(self._generate_grid_cells_for_desktop(desktop_geometry, x_splits, y_splits))
377        #LOG("GRID_TILES: %s" % self.grid_tiles) # DEBUG
378
379    def get_window(self, id=None):
380        if id is None:
381            id = self.active
382        return self.windows_by_id[self.active]
383
384    def active_window(self):
385        return self.windows_by_id[self.active]
386
387    def snap_all_windows(self, desktop):
388        for window in self.windows_by_id.values():
389            if window.desktop == desktop:
390                self.window_action('snap', 'here', window)
391
392    def window_action(self, action, direction, window=None):
393        if window is None:
394            window = self.active_window()
395        LOG(f"window: {window}")
396        original_window_geometry = window.geometry()
397        LOG("Starting geometry = %s" % (original_window_geometry, ))
398        _, window_geometry = min([(original_window_geometry.location_size_difference_squared(g), g) for g in self.grid_tiles])
399        LOG("Snapped geometry = %s" % (window_geometry, ))
400        # Need to figure out the window's nearest grid-granular size
401        command = (action, direction)
402        # Move
403        # Only consider cells that overlap the area directly in the direction
404        # of the desired motion.  This means that a window at the top of one
405        # screen, when pushed up, won't move horizontally to another screen
406        # that is 'higher'.
407        if command == ('move', 'left'):
408            grids = [(window_geometry.right_center().distance_squared(g.right_center()) + window_geometry.size_difference_squared(g), g)
409                for g in self.grid_tiles if g.nw().X < window_geometry.nw().X and g.se().X < window_geometry.se().X
410                    and g.nw().Y < window_geometry.se().Y and g.se().Y > window_geometry.nw().Y]
411        elif command == ('move', 'right'):
412            grids = [(window_geometry.left_center().distance_squared(g.left_center()) + window_geometry.size_difference_squared(g), g)
413                for g in self.grid_tiles if g.nw().X > window_geometry.nw().X and g.se().X > window_geometry.se().X
414                    and g.nw().Y < window_geometry.se().Y and g.se().Y > window_geometry.nw().Y]
415        elif command == ('move', 'up'):
416            grids = [(window_geometry.bottom_center().distance_squared(g.bottom_center()) + window_geometry.size_difference_squared(g), g)
417                for g in self.grid_tiles if g.nw().Y < window_geometry.nw().Y and g.se().Y < window_geometry.se().Y
418                    and g.nw().X < window_geometry.se().X and g.se().X > window_geometry.nw().X]
419        elif command == ('move', 'down'):
420            grids = [(window_geometry.top_center().distance_squared(g.top_center()) + window_geometry.size_difference_squared(g), g)
421                for g in self.grid_tiles if g.nw().Y > window_geometry.nw().Y and g.se().Y > window_geometry.se().Y
422                    and g.nw().X < window_geometry.se().X and g.se().X > window_geometry.nw().X]
423        # Grow
424        elif command == ('grow', 'left'):
425            grids = [(window_geometry.X - g.X, g) for g in self.grid_tiles if g.H == window_geometry.H and g.W > window_geometry.W and g.se() == window_geometry.se()]
426        elif command == ('grow', 'right'):
427            grids = [(g.se().X - window_geometry.se().X, g) for g in self.grid_tiles if g.H == window_geometry.H and g.W > window_geometry.W and g.nw() == window_geometry.nw()]
428        elif command == ('grow', 'up'):
429            grids = [(window_geometry.Y - g.Y, g) for g in self.grid_tiles if g.H > window_geometry.H and g.W == window_geometry.W and g.se() == window_geometry.se()]
430        elif command == ('grow', 'down'):
431            grids = [(g.se().Y - window_geometry.se().Y, g) for g in self.grid_tiles if g.H > window_geometry.H and g.W == window_geometry.W and g.nw() == window_geometry.nw()]
432        # Shrink
433        elif command == ('shrink', 'left'):
434            grids = [(window_geometry.se().X - g.se().X, g) for g in self.grid_tiles if g.H == window_geometry.H and g.W < window_geometry.W and g.nw() == window_geometry.nw()]
435        elif command == ('shrink', 'right'):
436            grids = [(g.X - window_geometry.X, g) for g in self.grid_tiles if g.H == window_geometry.H and g.W < window_geometry.W and g.se() == window_geometry.se()]
437        elif command == ('shrink', 'up'):
438            grids = [(window_geometry.se().Y - g.se().Y, g) for g in self.grid_tiles if g.H < window_geometry.H and g.W == window_geometry.W and g.nw() == window_geometry.nw()]
439        elif command == ('shrink', 'down'):
440            grids = [(g.Y - window_geometry.Y, g) for g in self.grid_tiles if g.H < window_geometry.H and g.W == window_geometry.W and g.se() == window_geometry.se()]
441        # Snap
442        elif command == ('snap', 'here'):
443            grids = [(window_geometry.location_size_difference_squared(g), g) for g in self.grid_tiles]
444        elif command == ('snap', 'all'):
445            # Snap all windows on the current desktop to their nearest grid.
446            # This is useful when you have a messy desktop and you just want
447            # things at a reasonable starting point.
448            self.snap_all_windows(desktop=window.desktop)
449            return # This command doesn't really fit the pattern
450        else:
451            raise Exception("Bad command %s %s" % (action, direction))
452
453        if not grids:
454            LOG("No target identified, finding closest tile.")
455            grids = [(window_geometry.difference_squared(g), g) for g in self.grid_tiles]
456        else:
457            LOG("Sorted qualified grids: %s" % sorted(grids))
458        _difference, grid = min(grids)
459        window.set_geometry(grid)
460
461
462def main(argv):
463    parser = argparse.ArgumentParser()
464    parser.add_argument('--window', help='override which window to affect')
465    parser.add_argument('action', choices=['move', 'grow', 'shrink', 'snap'])
466    parser.add_argument('direction', choices=['left', 'right', 'up', 'down', 'here', 'all'])
467    args = parser.parse_args(argv[1:])
468
469    LOG("start %s %s" % (args.action, args.direction))
470    start = time.time()
471    try:
472        controller = Controller()
473        window = controller.get_window(args.window)
474        controller.window_action(args.action, args.direction, window=window)
475    except Exception as error:
476        LOG_ERROR(f"Error occurred executing {argv}")
477        LOG_ERROR(error)
478        LOG_ERROR(traceback.format_exc())
479        raise
480    end = time.time()
481    if end-start > 1:
482        LOG_ERROR(f"Slow execution; {argv} took {end-start:%0.2f}s")
483    LOG("end %s %s" % (args.action, args.direction))
484
485    return 0
486
487
488if __name__ == '__main__':
489    sys.exit(main(sys.argv))