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

+### 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]