#!/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]]))