Skip to content
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ The grid system divides your screen into 12 sections
| <kbd>A</kbd>| <kbd>S</kbd>| <kbd>D</kbd>| <kbd>F</kbd>|
| <kbd>Z</kbd>| <kbd>X</kbd>| <kbd>C</kbd>| <kbd>V</kbd>|

or, in dual screen mode (`-d` switch), into 12 sections each, with extended keybindings for the second screen:

| <kbd>T</kbd>| <kbd>Y</kbd>| <kbd>U</kbd>| <kbd>I</kbd>|
|--|--|--|--|
| <kbd>G</kbd>| <kbd>H</kbd>| <kbd>J</kbd>| <kbd>K</kbd>|
| <kbd>B</kbd>| <kbd>N</kbd>| <kbd>M</kbd>| <kbd>,</kbd>|

You can snap your window to any rectangle, of any arbitrary size, on this grid by specifying 2 corners. For example:

<kbd>ctl</kbd> + <kbd>alt</kbd> + <kbd>E</kbd> + <kbd>D</kbd>
Expand Down Expand Up @@ -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 <kbd>Q</kbd> and <kbd>S</kbd>, double pressing <kbd>ctl</kbd> + <kbd>alt</kbd> + <kbd>X</kbd>
will have the same outcome as <kbd>Z</kbd> and <kbd>V</kbd>. Double <kbd>C</kbd>, on the other hand, will result in <kbd>E</kbd> and <kbd>V</kbd>.


## Requirements
* Python3
Expand Down
28 changes: 28 additions & 0 deletions geom_utils.py
Original file line number Diff line number Diff line change
@@ -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]
)
82 changes: 67 additions & 15 deletions snaptile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -59,6 +61,9 @@
['semicolon', 'Q', 'J', 'K', 'X', 'B', 'M', 'W']),
}

# delay for double presses
fill_delay = 0.5



def autodetectKeyboard():
Expand Down Expand Up @@ -92,35 +97,44 @@ 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')
print ('-d expanded dual-monitor keybinds')
print ('-W use Windows key')
print ('-h this help text')
print ('-k <keymap> to specify a keyboard layout (eg. qwerty)')
print ('-f enable filling available space on double press')
sys.exit()
elif opt[0] == '-d':
isDualMonitor = True
elif opt[0] == '-W':
mask = 'Windows'
elif opt[0] == '-k':
keyboardLayout = opt[1]
elif opt[0] == '-f':
fillEnabled = True

global keymap;
keymapSource = keymaps
Expand All @@ -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()
134 changes: 108 additions & 26 deletions window.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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]