diff --git a/README.md b/README.md index 1571906..2d2fa82 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,13 @@ The grid system divides your screen into 12 sections | A| S| D| F| | Z| X| C| V| +or, in dual screen mode (`-d` switch), into 12 sections each, with extended keybindings for the second screen: + +| T| Y| U| I| +|--|--|--|--| +| G| H| J| K| +| B| N| M| ,| + You can snap your window to any rectangle, of any arbitrary size, on this grid by specifying 2 corners. For example: ctl + alt + E + D @@ -43,6 +50,14 @@ which looks like ![screenshot from 2017-06-07 22-55-56](https://user-images.githubusercontent.com/5866348/26910417-b381baca-4bd4-11e7-9ff7-fff9262743e8.png) +### Fill +With the `-f` switch, filling is activated. On double press of any of the shortcuts, snaptile will try to find as many sections +around the specified one as possible without intersecting with other windows, and fill the largest match. This will still align to the 4x3 grid. +Windows which obstruct the initial section are excluded from intersection, so they might be partially or even completely occluded. + +For example, if there is a window between tiles Q and S, double pressing ctl + alt + X +will have the same outcome as Z and V. Double C, on the other hand, will result in E and V. + ## Requirements * Python3 diff --git a/geom_utils.py b/geom_utils.py new file mode 100644 index 0000000..1496807 --- /dev/null +++ b/geom_utils.py @@ -0,0 +1,28 @@ +def grid_to_coords(pos, monitor): + geom = monitor.get_geometry() + x = geom.x + (pos[1] % 4) * geom.width // 4 + y = geom.y + (pos[0] % 3) * geom.height // 3 + return (x, y, x + geom.width // 4, y + geom.height // 3) + + +def grid_to_xywh(pos, monitor): + coords = grid_to_coords(pos, monitor) + return ( + coords[0], + coords[1], + coords[2] - coords[0], + coords[3] - coords[1], + ) + + +def geom_to_tuple(geom): + return (geom.x, geom.y, geom.width, geom.height) + + +def overlaps(geom1, geom2): + return not ( + geom1[0] >= geom2[0] + geom2[2] or + geom1[0] + geom1[2] <= geom2[0] or + geom1[1] >= geom2[1] + geom2[3] or + geom1[1] + geom1[3] <= geom2[1] + ) diff --git a/snaptile.py b/snaptile.py index e74586b..5ee9ff6 100755 --- a/snaptile.py +++ b/snaptile.py @@ -9,10 +9,12 @@ import gi gi.require_version('Gtk', '3.0') -from gi.repository import Gtk, GObject -from window import position +from gi.repository import Gtk, GLib +from window import position, fill from keyutil import get_posmap, initkeys +import time + keymaps = { "qwerty": (['Q', 'W', 'E', 'R'], @@ -59,6 +61,9 @@ ['semicolon', 'Q', 'J', 'K', 'X', 'B', 'M', 'W']), } +# delay for double presses +fill_delay = 0.5 + def autodetectKeyboard(): @@ -92,21 +97,27 @@ def global_inital_states(): rt, { 'code': 0, - 'pressed': False + 'pressed': False, + 'window': None, }, get_posmap(keymap, displ) ) -global disp, root, lastkey_state, posmap; +global disp, root, lastkey_state, posmap, isDualMonitor, fillEnabled; def run(): mask = None - opts, args = getopt.getopt(sys.argv[1:], "hdWk:") + opts, args = getopt.getopt(sys.argv[1:], "hdWk:f") keyboardLayout = autodetectKeyboard() + + global isDualMonitor, fillEnabled + isDualMonitor = False - + + fillEnabled = False + for opt in opts: if opt[0] == '-h': print ('Snaptile.py') @@ -114,6 +125,7 @@ def run(): print ('-W use Windows key') print ('-h this help text') print ('-k to specify a keyboard layout (eg. qwerty)') + print ('-f enable filling available space on double press') sys.exit() elif opt[0] == '-d': isDualMonitor = True @@ -121,6 +133,8 @@ def run(): mask = 'Windows' elif opt[0] == '-k': keyboardLayout = opt[1] + elif opt[0] == '-f': + fillEnabled = True global keymap; keymapSource = keymaps @@ -138,45 +152,83 @@ def run(): initkeys(keymap, disp, root, mask) for _ in range(0, root.display.pending_events()): root.display.next_event() - GObject.io_add_watch(root.display, GObject.IO_IN, checkevt) + GLib.io_add_watch(root.display, GLib.IO_IN, checkevt) print('Snaptile running. Press CTRL+C to quit.') signal.signal(signal.SIGINT, signal.SIG_DFL) + signal.signal(signal.SIGTERM, signal.SIG_DFL) Gtk.main() +# counter for how many keys are currently pressed to fix xlib 'bug' +keys_pressed = 0 + def checkevt(_, __, handle=None): - global lastkey_state + global lastkey_state, keys_pressed handle = handle or root.display for _ in range(0, handle.pending_events()): event = handle.next_event() if event.type == X.KeyPress: - + keys_pressed += 1 if event.detail not in posmap: break + # prevent loosing double press release events + root.grab_keyboard(1, X.GrabModeAsync, X.GrabModeAsync, X.CurrentTime) + + win = None if not lastkey_state['pressed']: - handleevt(event.detail, event.detail) + if fillEnabled and \ + lastkey_state['code'] == event.detail and \ + time.time() - lastkey_state['time'] < fill_delay: + win = handle_fill( + event.detail, + lastkey_state['window'], + ) + else: + win = handleevt(event.detail, event.detail) else: - handleevt(lastkey_state['code'], event.detail) + win = handleevt( + lastkey_state['code'], + event.detail, + lastkey_state['window'], + ) lastkey_state = { 'code': event.detail, - 'pressed': True + 'pressed': True, + 'time': time.time(), + 'window': win, } if event.type == X.KeyRelease: + # prevent going under 0 since we get + # one last release event from the modifier key + keys_pressed = max(keys_pressed-1, 0) + if keys_pressed == 0: + # no more keys pressed, so ungrab keyboard + disp.flush() + disp.ungrab_keyboard(X.CurrentTime) + else: + # grab keyboard to avoid losing KeyRelease event + root.grab_keyboard(1, X.GrabModeAsync, X.GrabModeAsync, X.CurrentTime) + if event.detail == lastkey_state['code']: lastkey_state['pressed'] = False return True -def handleevt(startkey, endkey): - position( +def handleevt(startkey, endkey, window=None): + return position( posmap[startkey], - posmap[endkey] + posmap[endkey], + isDualMonitor, + window, ) +def handle_fill(key, window=None): + return fill(posmap[key], isDualMonitor, window) + if __name__ == '__main__': run() diff --git a/window.py b/window.py index a53faed..5b4a1da 100644 --- a/window.py +++ b/window.py @@ -1,52 +1,125 @@ from gi.repository import Gdk +from itertools import product +from geom_utils import grid_to_coords, grid_to_xywh, geom_to_tuple, overlaps -def position(startpos, endpos): - window, screen = active_window() +def position(startpos, endpos, dualMonitor, window=None): + if window is None: + window, screen = active_window() + else: + screen = Gdk.Screen.get_default() + if window is None: + return window.unmaximize() window.set_shadow_width(0, 0, 0, 0) - workarea = screen.get_monitor_workarea(screen.get_monitor_at_window(window)) - offx, offy = offsets(window) + display = Gdk.Display.get_default() + if dualMonitor: + monitor = get_target_monitor(display, startpos[1]) + workarea = monitor.get_workarea() + end_monitor = get_target_monitor(display, endpos[1]) + end_workarea = end_monitor.get_workarea() + else: + monitor = screen.get_monitor_at_window(window) + workarea = screen.get_monitor_workarea(monitor) + # same screen -> same workarea on both corners + end_workarea = workarea + w, h = (workarea.width / 4, workarea.height / 3) + end_w, end_h = (end_workarea.width / 4, end_workarea.height / 3) - pos = ( - min(startpos[0], endpos[0]), - min(startpos[1], endpos[1]) + # each contain top left and bottom right position of the respective cell + first_corner = ( + (startpos[1] % 4) * w + workarea.x, + startpos[0] * h + workarea.y, + (startpos[1] % 4 + 1) * w + workarea.x, + (startpos[0] + 1) * h + workarea.y, ) - dims = ( - max(abs(endpos[0] - startpos[0]) + 1, 1), - max(abs(endpos[1] - startpos[1]) + 1, 1) + second_corner = ( + (endpos[1] % 4) * end_w + end_workarea.x, + endpos[0] * end_h + end_workarea.y, + (endpos[1] % 4 + 1) * end_w + end_workarea.x, + (endpos[0] + 1) * end_h + end_workarea.y, ) + top_left, bottom_right = ( + # use top left corner of cells (0 & 1) + (min(first_corner[0], second_corner[0]), min(first_corner[1], second_corner[1])), + # use bottom right corner of cells (2 & 3) + (max(first_corner[2], second_corner[2]), max(first_corner[3], second_corner[3])), + ) - multiscreen_offset = get_multi_screen_offset(screen, window) + dims = ( + bottom_right[0] - top_left[0], + bottom_right[1] - top_left[1], + ) window.move_resize( - pos[1] * w + multiscreen_offset, - pos[0] * h, - w * dims[1] - (offx * 2), - h * dims[0]- (offx + offy) + *top_left, + *dims, ) + return window + + +def fill(pos, dualMonitor, window=None): + screen = Gdk.Screen.get_default() + display = Gdk.Display.get_default() + if window is None: + window = screen.get_active_window() + if dualMonitor: + monitor = get_target_monitor(display, pos[1]) + else: + monitor = display.get_monitor_at_window(window) + other_wins = [ + win for win in screen.get_window_stack() + if win.get_desktop() == window.get_desktop() + and win != window + ] + win_geometries = [win.get_frame_extents() for win in other_wins] + initial = grid_to_xywh(pos, monitor) + # filter out windows which overlap no matter what + win_geometries = [win for win in win_geometries if not overlaps(initial, geom_to_tuple(win))] + max_tiles = 0 + best_pos = () + # try all possibilities and choose the one with the most tiles + # at least one is valid (the single tile), since we filter out windows + # which conflict this tile above + for ylow, yhigh, xlow, xhigh in product( + range(0, pos[0] + 1), + range(pos[0], 3), + range(0, (pos[1] % 4) + 1), + range((pos[1] % 4), 4), + ): + tiles = (xhigh-xlow+1) * (yhigh-ylow+1) + if tiles < max_tiles: + continue + top_left = (ylow, xlow) + bot_right = (yhigh, xhigh) + abs_top_left = grid_to_coords(top_left, monitor)[:2] + abs_bot_right = grid_to_coords(bot_right, monitor)[2:] + abs_pos = ( + *abs_top_left, + abs_bot_right[0] - abs_top_left[0], + abs_bot_right[1] - abs_top_left[1], + ) + if not any(overlaps(abs_pos, geom_to_tuple(geom)) for geom in win_geometries): + max_tiles = tiles + best_pos = abs_pos + + window.unmaximize() + window.set_shadow_width(0, 0, 0, 0) + window.move_resize(*best_pos) + return window + def active_window(): screen = Gdk.Screen.get_default() window = screen.get_active_window() if no_window(screen, window): - return None + return None, None return (window, screen) -def get_multi_screen_offset(screen,window): - monitor = screen.get_monitor_at_window(window) - monitor_geometry = screen.get_monitor_geometry(monitor) - return monitor_geometry.x - -def offsets(window): - origin = window.get_origin() - root = window.get_root_origin() - return (origin.x - root.x, origin.y - root.y) - def no_window(screen, window): return ( @@ -58,3 +131,12 @@ def no_window(screen, window): ) or window.get_type_hint().value_name == 'GDK_WINDOW_TYPE_HINT_DESKTOP' ) + + +def get_target_monitor(display, x): + # NOTE: only works for up to 2 monitors!! + left_monitor = display.get_monitor_at_point(0, 0) + right_monitor = display.get_monitor(1) \ + if left_monitor == display.get_monitor(0) \ + else display.get_monitor(0) + return [left_monitor, right_monitor][x // 4]