Initial implementation and readme.

This commit is contained in:
redxef 2022-10-20 03:32:45 +02:00
commit 75087e5fdb
Signed by: redxef
GPG key ID: 7DAC3AA211CBD921
2 changed files with 324 additions and 0 deletions

32
README.md Normal file
View file

@ -0,0 +1,32 @@
# i3toolwait
Launch a program and move it to the correct workspace.
## Filtering
The program allows to match the window or container based on the returned IPC data.
Some programs might open multiple windows (looking at you, Discord).
In order to move the correct window to the desired workspace a filter can be defined.
The syntax for the filter is lisp-like. To view all spawned containers run the program
with `--debug --filter=False` which will not match any windows and print their properties.
It is then possible to construct a filter for any program.
Available Operators:
- and: `&`
- or: `|`
- eq: `=`
- neq: `!=`
- gt: `>`
- lt: `<`
The filter usually operates on the dictionary, and thus the *first* argument to every normal filter
is the dictionary element, in `.` notation, as might be customary in `jq`.
For example: `(> ".container.geometry.width" 300)` would match the first window where the width is greater than 300.
Multiple filters are combined via nesting: `(& (> ".container.geometry.width" 300) (= ".container.window_properties.class" "discord"))`.

292
i3toolwait Executable file
View file

@ -0,0 +1,292 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import enum
import functools
import itertools
import json
import click
import i3ipc
class Expression:
def __init__(self):
pass
def reduce(self, ipc_data):
return functools.reduce(self.reduce_function(ipc_data), self.children)
@property
def children(self):
raise NotImplemented('TODO: implement in subclass')
def reduce_function(self, ipc_data):
raise NotImplemented('TODO: implement in subclass')
class LiteralExpression(Expression):
def __init__(self, value):
self._value = value
def __repr__(self) -> str:
return f'"{self._value}"'
@property
def children(self):
return [self._value]
def reduce_function(self, ipc_data):
def reduce(a, b):
raise NotImplemented('I should never be called')
class IntLiteralExpression(LiteralExpression):
def __repr__(self) -> str:
return str(self._value)
class BoolLiteralExpression(LiteralExpression):
def __repr__(self) -> str:
return str(self._value)
class AndExpression(Expression):
def __init__(self, children, *args, **kwargs):
self._children = children
super().__init__(*args, **kwargs)
def __repr__(self) -> str:
cs = ' '.join([repr(c) for c in self.children])
return f'(& {cs})'
@property
def children(self):
return self._children
def reduce_function(self, ipc_data):
return lambda a, b: a.reduce(ipc_data) and b.reduce(ipc_data)
class OrExpression(Expression):
def __init__(self, children, *args, **kwargs):
self._children = children
super().__init__(*args, **kwargs)
def __repr__(self) -> str:
cs = ' '.join([repr(c) for c in self.children])
return f'(| {cs})'
@property
def children(self):
return self._children
def reduce_function(self, ipc_data):
return lambda a, b: a.reduce(ipc_data) or b.reduce(ipc_data)
class EqExpression(Expression):
def __init__(self, children, *args, **kwargs):
self._children = children
super().__init__(*args, **kwargs)
def __repr__(self) -> str:
cs = ' '.join([repr(c) for c in self.children])
return f'(= {cs})'
@property
def children(self):
return self._children
def reduce_function(self, ipc_data):
def reduce(key, value):
ipc_value = ipc_data
for k in key.reduce(ipc_data).strip('.').split('.'):
ipc_value = ipc_value[k]
return ipc_value == value.reduce(ipc_data)
return reduce
class NeqExpression(Expression):
def __init__(self, children, *args, **kwargs):
self._children = children
super().__init__(*args, **kwargs)
def __repr__(self) -> str:
cs = ' '.join([repr(c) for c in self.children])
return f'(!= {cs})'
@property
def children(self):
return self._children
def reduce_function(self, ipc_data):
def reduce(key, value):
ipc_value = ipc_data
for k in key.reduce(ipc_data).strip('.').split('.'):
ipc_value = ipc_value[k]
return ipc_value != value.reduce(ipc_data)
return reduce
class GtExpression(Expression):
def __init__(self, children, *args, **kwargs):
self._children = children
super().__init__(*args, **kwargs)
def __repr__(self) -> str:
cs = ' '.join([repr(c) for c in self.children])
return f'(> {cs})'
@property
def children(self):
return self._children
def reduce_function(self, ipc_data):
def reduce(key, value):
ipc_value = ipc_data
for k in key.reduce(ipc_data).strip('.').split('.'):
ipc_value = ipc_value[k]
return ipc_value > value.reduce(ipc_data)
return reduce
class LtExpression(Expression):
def __init__(self, children, *args, **kwargs):
self._children = children
super().__init__(*args, **kwargs)
def __repr__(self) -> str:
cs = ' '.join([repr(c) for c in self.children])
return f'(< {cs})'
@property
def children(self):
return self._children
def reduce_function(self, ipc_data):
def reduce(key, value):
ipc_value = ipc_data
for k in key.reduce(ipc_data).strip('.').split('.'):
ipc_value = ipc_value[k]
return ipc_value < value.reduce(ipc_data)
return reduce
expression_mapping = {
'&': AndExpression,
'|': OrExpression,
'=': EqExpression,
'!=': NeqExpression,
'>': GtExpression,
'<': LtExpression,
}
def group_tokens(tokens: list[str]):
groups = []
current_group = []
brace_count = 0
for token in tokens:
if token == '(':
brace_count += 1
elif token == ')':
brace_count -= 1
if brace_count == 0:
groups += [current_group]
current_group = []
elif brace_count == 0:
groups += [[token]]
else:
current_group += [token]
return groups
def build_expression(tokens: list[str]) -> Expression:
token_groups = group_tokens(tokens)
expressions = [build_expression(ts) for ts in token_groups[1:]] if len(token_groups) > 1 else []
root_expression = None
token = token_groups[0][0]
if token in expression_mapping:
root_expression = expression_mapping[token](expressions)
elif token.startswith('"'):
root_expression = LiteralExpression(token[1:-1])
elif token.isnumeric():
root_expression = IntLiteralExpression(int(token))
elif token in ('True', 'False'):
root_expression = BoolLiteralExpression(token == 'True')
assert isinstance(root_expression, Expression)
return root_expression
def take_space(s: str) -> tuple:
if s[0] in ' \n':
return None, s[1:]
return None, s
def take_operator(s: str) -> tuple:
token = ''
for c in s:
if c in ''.join(set(expression_mapping.keys())):
token += c
else:
break
if token == '':
return None, s
else:
return token, s[len(token):]
def take_brace(s: str) -> tuple:
if s[0] in '()':
return s[0], s[1:]
else:
return None, s
def take_literal(s: str) -> tuple:
token = '"'
if s[0] != '"':
return None, s
for c in s[1:]:
token += c
if c == '"':
break
if not token.endswith('"'):
raise ValueError('Missing closing quotes (`"`)')
return token, s[len(token):]
def take_int_literal(s: str) -> tuple:
token = ''
for c in s:
if not c.isnumeric():
break
token += c
if token == '':
return None, s
return token, s[len(token):]
def take_bool_literal(s: str) -> tuple:
if s.startswith('True'):
return 'True', s[len('True'):]
if s.startswith('False'):
return 'False', s[len('False'):]
return None, s
def tokenize(s: str) -> list[str]:
operator_extractors = [
take_operator,
take_brace,
take_literal,
take_int_literal,
take_bool_literal,
take_space,
]
tokens = []
while s != '':
previous_len = len(s)
for operator_extractor in operator_extractors:
token, s = operator_extractor(s)
if token is not None:
tokens += [token]
break
if len(s) == previous_len:
raise ValueError(f'Could not tokenize string {s}')
return tokens
def parse(s: str) -> Expression:
tokens = tokenize(s)
return build_expression(tokens)
def window_new(filter, *, workspace, debug):
def callback(ipc, e):
assert e.change == 'new'
if debug:
print(json.dumps(e.ipc_data))
if filter.reduce(e.ipc_data):
ipc.command(f'move container to workspace {workspace}')
ipc.main_quit()
return callback
@click.command()
@click.option('--filter', '-f', default='True', help="A filter expression for the raw ipc dictionary.")
@click.option('--debug', '-d', default=False, is_flag=True, help="Enable debug mode, will log ipc dictionary.")
@click.option('--workspace', '-w', required=True, help="The workspace to move to.")
@click.argument('program', nargs=-1)
def main(filter, debug, workspace, program):
"""
Start a program and move it's created window to the desired i3 workspace.
"""
filter = parse(filter)
ipc = i3ipc.Connection()
ipc.on('window::new', window_new(filter, workspace=workspace, debug=debug))
ipc.command(f'exec {program}')
ipc.main(timeout=10)
if __name__ == '__main__':
main()