#!/usr/bin/env python3 import argparse import copy import fcntl import json import os import shlex import subprocess import sys from contextlib import contextmanager, suppress from pathlib import Path import i3ipc DEFAULT_CONF = { "menu": "rofi -dmenu -p 'quickterm: ' -no-custom -auto-select", "term": "urxvt", "history": "{$HOME}/.cache/i3/i3-quickterm.order", "ratio": 0.25, "pos": "top", "shells": { "haskell": "ghci", "js": "node", "python": "ipython3 --no-banner", "shell": "{$SHELL}" } } MARK_QT_PATTERN = 'quickterm_.*' MARK_QT = 'quickterm_{}' def TERM(executable, execopt='-e', execfmt='expanded', titleopt='-T'): """Helper to declare a terminal in the hardcoded list""" if execfmt not in ('expanded', 'string'): raise RuntimeError('Invalid execfmt') fmt = executable if titleopt is not None: fmt += ' ' + titleopt + ' {title}' fmt += ' {} {{{}}}'.format(execopt, execfmt) return fmt TERMS = { 'alacritty': TERM('alacritty', titleopt='-t'), 'gnome-terminal': TERM('gnome-terminal', execopt='--', titleopt=None), 'roxterm': TERM('roxterm'), 'st': TERM('st'), 'termite': TERM('termite', execfmt='string', titleopt='-t'), 'urxvt': TERM('urxvt'), 'xfce4-terminal': TERM('xfce4-terminal', execfmt='string'), 'xterm': TERM('xterm'), } def conf_path(): home_dir = os.environ['HOME'] xdg_dir = os.environ.get('XDG_CONFIG_DIR', '{}/.config'.format(home_dir)) return xdg_dir + '/i3/i3-quickterm.json' def read_conf(fn): try: with open(fn, 'r') as f: c = json.load(f) return c except Exception as e: print('invalid config file: {}'.format(e), file=sys.stderr) return {} @contextmanager def get_history_file(conf): if conf['history'] is None: yield None return p = Path(expand_command(conf['history'])[0]) os.makedirs(str(p.parent), exist_ok=True) f = open(str(p), 'a+') fcntl.lockf(f, fcntl.LOCK_EX) try: f.seek(0) yield f finally: fcntl.lockf(f, fcntl.LOCK_UN) f.close() def expand_command(cmd, **rplc_map): d = {'$' + k: v for k, v in os.environ.items()} d.update(rplc_map) return shlex.split(cmd.format(**d)) def move_back(conn, selector): conn.command('{} floating enable, move scratchpad' .format(selector)) def pop_it(conn, mark_name, pos='top', ratio=0.25): ws, _ = get_current_workspace(conn) wx, wy = ws['rect']['x'], ws['rect']['y'] wwidth, wheight = ws['rect']['width'], ws['rect']['height'] width = wwidth height = int(wheight*ratio) posx = wx if pos == 'bottom': margin = 6 posy = wy + wheight - height - margin else: # pos == 'top' posy = wy conn.command('[con_mark={mark}],' 'resize set {width} px {height} px,' 'move absolute position {posx}px {posy}px,' 'move scratchpad,' 'scratchpad show' ''.format(mark=mark_name, posx=posx, posy=posy, width=width, height=height)) def get_current_workspace(conn): ws = [w for w in conn.get_workspaces() if w['focused']][0] tree = conn.get_tree() # wname = workspace['name'] ws_tree = [c for c in tree.descendents() if c.type == 'workspace' and c.name == ws['name']][0] return ws, ws_tree def toggle_quickterm_select(conf, hist=None): """Hide a quickterm visible on current workspace or prompt the user for a shell type""" conn = i3ipc.Connection() ws, ws_tree = get_current_workspace(conn) # is there a quickterm opened in the current workspace? qt = ws_tree.find_marked(MARK_QT_PATTERN) if qt: qt = qt[0] move_back(conn, '[con_id={}]'.format(qt.id)) return with get_history_file(conf) as hist: # compute the list from conf + (maybe) history hist_list = None if hist is not None: with suppress(Exception): hist_list = json.load(hist) # invalidate if different set from the configured shells if set(hist_list) != set(conf['shells'].keys()): hist_list = None shells = hist_list or sorted(conf['shells'].keys()) proc = subprocess.Popen(expand_command(conf['menu']), stdin=subprocess.PIPE, stdout=subprocess.PIPE) for r in shells: proc.stdin.write((r + '\n').encode()) stdout, _ = proc.communicate() shell = stdout.decode().strip() if shell not in conf['shells']: return if hist is not None: # put the selected shell on top shells = [shell] + [s for s in shells if s != shell] hist.truncate(0) json.dump(shells, hist) toggle_quickterm(conf, shell) def quoted(s): return "'" + s + "'" def term_title(shell): return '{} - i3-quickterm'.format(shell) def toggle_quickterm(conf, shell): conn = i3ipc.Connection() tree = conn.get_tree() shell_mark = MARK_QT.format(shell) qt = tree.find_marked(shell_mark) # does it exist already? if len(qt) == 0: term = TERMS.get(conf['term'], conf['term']) qt_cmd = "{} -i {}".format(sys.argv[0], shell) term_cmd = expand_command(term, title=quoted(term_title(shell)), expanded=qt_cmd, string=quoted(qt_cmd)) os.execvp(term_cmd[0], term_cmd) else: qt = qt[0] ws, ws_tree = get_current_workspace(conn) move_back(conn, '[con_id={}]'.format(qt.id)) if qt.workspace().name != ws.name: pop_it(conn, shell_mark, conf['pos'], conf['ratio']) def launch_inplace(conf, shell): conn = i3ipc.Connection() shell_mark = MARK_QT.format(shell) conn.command('mark {}'.format(shell_mark)) move_back(conn, '[con_mark={}]'.format(shell_mark)) pop_it(conn, shell_mark, conf['pos'], conf['ratio']) prog_cmd = expand_command(conf['shells'][shell]) os.execvp(prog_cmd[0], prog_cmd) if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('-i', '--in-place', dest='in_place', action='store_true') parser.add_argument('shell', metavar='SHELL', nargs='?') args = parser.parse_args() conf = copy.deepcopy(DEFAULT_CONF) conf.update(read_conf(conf_path())) if args.shell is None: toggle_quickterm_select(conf) sys.exit(0) if args.shell not in conf['shells']: print('unknown shell: {}'.format(args.shell), file=sys.stderr) sys.exit(1) if args.in_place: launch_inplace(conf, args.shell) else: toggle_quickterm(conf, args.shell)