Singleton w i3

Terminal jest jedną z tych aplikacji, które uruchamiam bardzo często, jednak chcę tak naprawdę mieć tylko jedną jego instancję, do której przeniosę się niezależnie od tego, gdzie się znajduję. Na podobnej zasadzie działają “wysuwane” konsole (inspirowane konsolą Quake’a), np. Guake czy tilda. Jak osiągnąć podobny efekt w i3?

Po wielu próbach przesuwania terminala między scratchpadem a bieżącym workspacem stwierdzam, że jest to pozbawione sensu, ponieważ musielibyśmy za każdym razem ustawiać pojawiające się okno terminala w tryb “pływający” (floating) lub ryzykować rozjechanie się całego layoutu okien. Znalazłem jednak workaround: pierwsze wciśnięcie $mod+Return powinno włączyć okno terminala, a każde następne przenieść nas do niego (czyli je uaktywnić), niezależnie od tego gdzie się w danej chwili znajdujemy.

Podstawą mojego rozwiazania jest możliwość ustawienia oknom w i3 tzw. markerów (marks), które działają analogicznie do swoich odpowiedników w Vime. Dlatego jeśli chcemy, aby pewna klasa okien posiadała tylko jedną instancję programu, musimy dopisać dla niej kilka linijek konfiguracji w pliku i3/config:

for_window [class="xterm"] mark t
bindsym $mod+Return exec "~/.local/bin/markorapp t xterm"

Powyższe spowoduje przypisanie każdemu nowemu oknu xterma markera o nazwie “t” (czyli każde nowe okno xterma ukradnie go swojemu poprzednikowi). Dlatego też musimy uruchamiać terminal przez wrapper “markorapp”, który przy pomocy programu i3-msg postara się wybrać oznaczone wcześniej okno, lub w przypadku jego braku, uruchomi nowe.

Markorapp jest tak naprawdę bardzo prostym skryptem Pythonowym, który przyjmuje na wejściu dwa argumenty: nazwę poszukiwanego markera oraz polecenie do wywołania w razie jego braku.

#!/usr/bin/env python3

import sys
import argparse
import subprocess
import json

def prepare_args():
    parser = argparse.ArgumentParser()
    parser.add_argument('mark')
    parser.add_argument('command', nargs=argparse.REMAINDER)
    return parser.parse_args()

def main():
    args = prepare_args()
    if not args.command:
        return 1

    out = subprocess.check_output(['i3-msg', '-t', 'get_marks']).decode('utf-8')
    marks = json.loads(out)

    if args.mark in marks:
        # check_output to not spam stdout with [{"success":true}]
        subprocess.check_output(['i3-msg', '[con_mark="%s"]' % args.mark, 'focus'])
    else:
        subprocess.Popen(args.command)

sys.exit(main())

Wadą powyższego jest to, że nie wykona bardziej skomplikowanych poleceń (czyli zawierających np. warunki logiczne, zagnieżdżone polecenia itp). Możnaby próbować zamienić wywołanie Popen na analog z parametrem shell=True, ale (pomijając kwestie bezpieczeństwa) lepszym i czytelniejszym wydaje mi się po prostu stworzenie skryptu startowego, który będzie zawierał skomplikowaną inwokację.

Przykładowo, ja uruchamiam terminal razem z sesją tmuxa (nową, lub dołączam się do istniejącej), a także nadaję mu unikalną klasę WM_CLASS:

#!/bin/bash
$HOME/.local/bin/st -c tmuxterm -e bash -c 'tmux -2 -q has-session && exec tmux -2 attach-session || exec tmux -2 new-session -n$USER -s$USER@$HOSTNAME'