tabbed-casilda/tabbed_casilda.py
Andrew Hamilton 258bb9eea3 Insert new tabs next to their creator and focus them
Each tab carries a unique TABBED_CASILDA_TAB_ID; tabbed-run echoes it to
the host so the new page is inserted right of the creating tab. New tabs
are selected and focused, and the initial tab is focused on startup.
2026-08-13 09:25:20 +10:00

142 lines
4.4 KiB
Python

#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11,<3.14"
# dependencies = [
# "PyGObject",
# ]
# ///
import argparse
import atexit
import os
import shlex
import shutil
import sys
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Casilda", "1.0")
from gi.repository import Gio, GLib, Gtk, Casilda
class TabbedCasilda(Gtk.Application):
def __init__(self, commands):
super().__init__(application_id="local.tabbed-casilda")
self.win = None
self.commands = commands
self.notebook = None
self.tab_ids = {}
self.next_tab_id = 0
self.ctrl_path = os.path.join(
os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "tabbed-casilda.sock"
)
def add_tab(self, command, ctrl_spawn=False, creator_id=None):
tab_id = self.next_tab_id
self.next_tab_id += 1
compositor = Casilda.Compositor()
compositor.props.maximized = True
label = Gtk.Label(label=command)
position = -1
if creator_id is not None:
creator = self.tab_ids.get(creator_id)
if creator is not None:
page = self.notebook.page_num(creator)
if page != -1:
position = page + 1
self.notebook.insert_page(compositor, label, position)
self.tab_ids[tab_id] = compositor
self.focus_tab(tab_id)
argv = shlex.split(command)
if not argv:
return
argv[0] = shutil.which(argv[0]) or argv[0]
repo_dir = os.path.dirname(os.path.abspath(__file__))
env = dict(os.environ)
env["PATH"] = f"{repo_dir}:{env.get('PATH', '')}"
env["TABBED_CASILDA_CTRL"] = self.ctrl_path
env["TABBED_CASILDA_TAB_ID"] = str(tab_id)
if ctrl_spawn:
env["G_APPLICATION_NON_UNIQUE"] = "1"
envp = [f"{k}={v}" for k, v in env.items()]
compositor.spawn_async(None, argv, envp, GLib.SpawnFlags.DEFAULT)
def focus_tab(self, tab_id):
compositor = self.tab_ids.get(tab_id)
if compositor is None:
return
page = self.notebook.page_num(compositor)
if page != -1:
self.notebook.set_current_page(page)
compositor.grab_focus()
def start_control_channel(self):
try:
os.unlink(self.ctrl_path)
except FileNotFoundError:
pass
service = Gio.SocketService()
address = Gio.UnixSocketAddress.new(self.ctrl_path)
service.add_address(
address, Gio.SocketType.STREAM, Gio.SocketProtocol.DEFAULT, None
)
service.connect("incoming", self.on_incoming)
service.start()
self.ctrl_service = service
atexit.register(self.cleanup_socket)
def cleanup_socket(self):
try:
os.unlink(self.ctrl_path)
except FileNotFoundError:
pass
def on_incoming(self, service, connection, source_object):
stream = Gio.DataInputStream.new(connection.get_input_stream())
creator_line, _ = stream.read_line()
command_line, _ = stream.read_line()
if command_line:
creator_id = None
if creator_line:
creator_id = creator_line.decode().strip()
if not creator_id.isdigit():
creator_id = None
else:
creator_id = int(creator_id)
self.add_tab(command_line.decode(), ctrl_spawn=True, creator_id=creator_id)
return False
def do_activate(self):
if self.win:
self.win.present()
return
win = Gtk.ApplicationWindow(
application=self,
title="tabbed casilda",
default_width=1280,
default_height=720,
)
nb = Gtk.Notebook()
win.set_child(nb)
self.notebook = nb
self.start_control_channel()
for command in self.commands:
self.add_tab(command)
win.present()
self.focus_tab(0)
self.win = win
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="GTK4 notebook, one tab per embedded Wayland terminal",
)
parser.add_argument(
"commands",
nargs="+",
metavar="COMMAND",
help='quoted command, e.g. "foot -T htop"',
)
args = parser.parse_args(sys.argv[1:])
app = TabbedCasilda(args.commands)
sys.exit(app.run([sys.argv[0]]))