Skip to content

Instantly share code, notes, and snippets.

@larsyencken
Last active March 14, 2020 20:17
Show Gist options
  • Save larsyencken/1348e47ce864b3174e855e801602d9d9 to your computer and use it in GitHub Desktop.
Save larsyencken/1348e47ce864b3174e855e801602d9d9 to your computer and use it in GitHub Desktop.
Linux: move the active window to a fixed location on the screen
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# tile.py
# xfce-window-tiling
#
"""
A tiling script on top of wmctrl command.
"""
from dataclasses import dataclass
from typing import Tuple, List, Dict
import re
import sys
import sh
import click
# how much space do we want to avoid using
HORIZONTAL_MARGIN = 0.35
# how much gap is ok at the bottom of the screen
VERTICAL_TOLERANCE = 10
# how much of the screen to use when centering
CENTER_PROPORTION = 0.5
CENTER_PROPORTION_NARROW = 0.4
@click.command()
@click.argument('variant')
def tile(variant: str):
"""
Tile the currently active window either left, right or center.
"""
window = Window.get_active()
if variant == 'left':
tile_left(window)
elif variant == 'right':
tile_right(window)
elif variant == 'center':
tile_center(window, narrow=False)
elif variant == 'center-narrow':
tile_center(window, narrow=True)
else:
print('ERROR: tile variant must be left/right/center', file=sys.stderr)
sys.exit(1)
log(f'Exit ok')
@dataclass
class Desktop:
desktop_id: int
size: Tuple[int, int]
is_active: bool
@classmethod
def from_line(cls, l: str) -> "Desktop":
"""
Parse Desktop from wmctrl output, like:
"0 * DG: 3440x1440 VP: 0,0 WA: 0,0 3440x1403 Workspace 1"
"""
l_norm = l.replace(": ", ":").replace(" ", " ")
parts = l_norm.split()
assert len(parts) == 8
desktop_id = int(parts[0])
is_active = parts[1] == "*"
size = tuple(map(int, parts[5].split("x")))
return cls(desktop_id=desktop_id, is_active=is_active, size=size)
@staticmethod
def find_all() -> "List[Desktop]":
lines = sh.wmctrl("-d").stdout.decode().splitlines()
return [Desktop.from_line(l) for l in lines]
@staticmethod
def get_active() -> "Desktop":
(desktop,) = [d for d in Desktop.find_all() if d.is_active]
return desktop
@dataclass
class Geometry:
xmin: int
ymin: int
width: int
height: int
def vshrink(self, delta: int):
return Geometry(self.xmin, self.ymin, self.width, max(0, self.height - delta))
@dataclass
class Window:
window_id: int
desktop_id: int
name: str
geometry: Geometry
@classmethod
def from_line(cls, l):
l_norm = re.sub(" +", " ", l)
parts = l_norm.strip().split(" ", 7)
window_id_hex, desktop_id, xmin, ymin, width, height, _, name = parts
window_id = int(window_id_hex, 16)
geometry = Geometry(
xmin=int(xmin), ymin=int(ymin), width=int(width), height=int(height)
)
return cls(
window_id=window_id,
desktop_id=int(desktop_id),
name=name,
geometry=geometry,
)
@classmethod
def find_all(cls) -> "Dict[int, Window]":
lines = sh.wmctrl("-l", "-G").stdout.decode().splitlines()
return {w.window_id: w for w in [cls.from_line(l) for l in lines]}
@classmethod
def get_active(cls) -> "Window":
window_id = int(sh.xdotool("getwindowfocus").stdout.decode())
windows = cls.find_all()
return windows[window_id]
def move(self, target: Geometry):
mvarg = f"0,{target.xmin},{target.ymin},{target.width},{target.height}"
args = ("-R", self.window_id, "-i", "-e", mvarg)
sh.wmctrl(*args)
self.refresh()
def refresh(self):
windows = Window.find_all()
w_new = windows[self.window_id]
self.geometry = w_new.geometry
def tile_left(window: Window):
desktop_width, desktop_height = Desktop.get_active().size
# work out the x position
midpoint = desktop_width // 2
xmin = int(midpoint * HORIZONTAL_MARGIN)
# assume full height
target = Geometry(xmin=xmin, width=midpoint - xmin, ymin=0, height=desktop_height)
# attempt first move, might not be correct
automove(window, target, desktop_height)
def tile_right(window: Window):
desktop_width, desktop_height = Desktop.get_active().size
# work out the x position
midpoint = desktop_width // 2
xmin = midpoint
width = int(midpoint * (1 - HORIZONTAL_MARGIN))
# assume full height
target = Geometry(xmin=xmin, width=width, ymin=0, height=desktop_height)
automove(window, target, desktop_height)
def tile_center(window: Window, narrow: bool):
desktop_width, desktop_height = Desktop.get_active().size
proportion = CENTER_PROPORTION if not narrow else CENTER_PROPORTION_NARROW
# work out the x position
width = int(desktop_width * proportion)
xmin = int(desktop_width * (1 - proportion) / 2)
# assume full height
target = Geometry(xmin=xmin, width=width, ymin=0, height=desktop_height)
# attempt first move, might not be correct
automove(window, target, desktop_height)
def automove(window: Window, target: Geometry, desktop_height: int) -> None:
# attempt first move, might not be correct
window.move(target)
# see where we landed and adjust the height
new_ymin = window.geometry.ymin
if new_ymin + window.geometry.height >= desktop_height:
# the second move is the correct one
new_height = desktop_height - new_ymin // 2
target.height = new_height
target.ymin = 0
window.move(target)
if __name__ == "__main__":
tile()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment