main.py
Python script, ASCII text executable
1from __future__ import annotations 2 3import os 4import sys 5import importlib 6from itertools import accumulate, chain 7from pathlib import Path 8import ruamel.yaml as yaml 9 10os.environ["GI_TYPELIB_PATH"] = "/usr/local/lib/x86_64-linux-gnu/girepository-1.0" 11 12from ctypes import CDLL 13CDLL('libgtk4-layer-shell.so') 14 15import gi 16gi.require_version("Gtk", "4.0") 17gi.require_version("Gtk4LayerShell", "1.0") 18 19from gi.repository import Gtk, GLib, Gtk4LayerShell, Gdk, Gio 20 21sys.path.insert(0, str((Path(__file__).parent / "shared").resolve())) 22 23import panorama_panel 24 25 26@Gtk.Template(filename="panel-manager.ui") 27class PanelManager(Gtk.Window): 28__gtype_name__ = "PanelManager" 29 30panel_editing_switch = Gtk.Template.Child() 31 32def __init__(self, application: Gtk.Application, **kwargs): 33super().__init__(application=application, **kwargs) 34 35self.connect("close-request", lambda *args: self.destroy()) 36 37action_group = Gio.SimpleActionGroup() 38 39toggle_edit_mode_action = Gio.SimpleAction(name="toggle-edit-mode-switch") 40action_group.add_action(toggle_edit_mode_action) 41toggle_edit_mode_action.connect("activate", lambda *args: self.panel_editing_switch.set_active(not self.panel_editing_switch.get_active())) 42 43self.insert_action_group("win", action_group) 44if isinstance(self.get_application(), PanoramaPanel): 45self.panel_editing_switch.set_active(application.edit_mode) 46self.panel_editing_switch.connect("state-set", self.set_edit_mode) 47 48self.connect("close-request", lambda *args: self.destroy()) 49 50def set_edit_mode(self, switch, value): 51if isinstance(self.get_application(), PanoramaPanel): 52self.get_application().set_edit_mode(value) 53 54@Gtk.Template.Callback() 55def save_settings(self, *args): 56if isinstance(self.get_application(), PanoramaPanel): 57print("Saving settings as user requested") 58self.get_application().save_config() 59 60 61def get_applet_directories(): 62data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local" / "share")) 63data_dirs = [Path(d) for d in os.getenv("XDG_DATA_DIRS", "/usr/local/share:/usr/share").split(":")] 64 65all_paths = [data_home / "panorama-panel" / "applets"] + [d / "panorama-panel" / "applets" for d in data_dirs] 66return [d for d in all_paths if d.is_dir()] 67 68 69def get_config_file(): 70config_home = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config")) 71 72return config_home / "panorama-panel" / "config.yaml" 73 74 75class AppletArea(Gtk.Box): 76def __init__(self, orientation=Gtk.Orientation.HORIZONTAL): 77super().__init__() 78 79def set_edit_mode(self, value): 80child = self.get_first_child() 81while child is not None: 82child.set_sensitive(not value) 83child.set_opacity(0.75 if value else 1) 84child = child.get_next_sibling() 85 86 87class Panel(Gtk.Window): 88def __init__(self, application: Gtk.Application, monitor: Gdk.Monitor, position: Gtk.PositionType = Gtk.PositionType.TOP, size: int = 40, monitor_index: int = 0): 89super().__init__(application=application) 90self.set_default_size(800, size) 91self.set_decorated(False) 92self.position = position 93self.monitor_index = monitor_index 94self.size = size 95 96Gtk4LayerShell.init_for_window(self) 97 98Gtk4LayerShell.set_layer(self, Gtk4LayerShell.Layer.TOP) 99 100Gtk4LayerShell.auto_exclusive_zone_enable(self) 101Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True) 102Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True) 103Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True) 104 105box = Gtk.CenterBox() 106 107match position: 108case Gtk.PositionType.TOP | Gtk.PositionType.BOTTOM: 109box.set_orientation(Gtk.Orientation.HORIZONTAL) 110case Gtk.PositionType.LEFT | Gtk.PositionType.RIGHT: 111box.set_orientation(Gtk.Orientation.VERTICAL) 112 113self.set_child(box) 114 115self.left_area = AppletArea(orientation=box.get_orientation()) 116self.centre_area = AppletArea(orientation=box.get_orientation()) 117self.right_area = AppletArea(orientation=box.get_orientation()) 118 119box.set_start_widget(self.left_area) 120box.set_center_widget(self.centre_area) 121box.set_end_widget(self.right_area) 122 123# Add a context menu 124menu = Gio.Menu() 125 126menu.append("Open _manager", "panel.manager") 127 128self.context_menu = Gtk.PopoverMenu.new_from_model(menu) 129self.context_menu.set_has_arrow(False) 130self.context_menu.set_parent(self) 131self.context_menu.set_halign(Gtk.Align.START) 132self.context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED) 133 134right_click_controller = Gtk.GestureClick() 135right_click_controller.set_button(3) 136right_click_controller.connect("pressed", self.show_context_menu) 137 138self.add_controller(right_click_controller) 139 140action_group = Gio.SimpleActionGroup() 141manager_action = Gio.SimpleAction.new("manager", None) 142manager_action.connect("activate", self.show_manager) 143action_group.add_action(manager_action) 144self.insert_action_group("panel", action_group) 145 146def set_edit_mode(self, value): 147for area in (self.left_area, self.centre_area, self.right_area): 148area.set_edit_mode(value) 149 150def show_context_menu(self, gesture, n_presses, x, y): 151rect = Gdk.Rectangle() 152rect.x = int(x) 153rect.y = int(y) 154rect.width = 1 155rect.height = 1 156 157self.context_menu.set_pointing_to(rect) 158self.context_menu.popup() 159 160def show_manager(self, _0=None, _1=None): 161print("Showing manager") 162if self.get_application(): 163if not self.get_application().manager_window: 164self.get_application().manager_window = PanelManager(self.get_application()) 165self.get_application().manager_window.connect("close-request", self.get_application().reset_manager_window) 166self.get_application().manager_window.present() 167 168def get_orientation(self): 169box = self.get_first_child() 170return box.get_orientation() 171 172 173def get_all_subclasses(klass: type) -> list[type]: 174subclasses = [] 175for subclass in klass.__subclasses__(): 176subclasses.append(subclass) 177subclasses += get_all_subclasses(subclass) 178 179return subclasses 180 181def load_packages_from_dir(dir_path: Path): 182loaded_modules = [] 183 184for path in dir_path.iterdir(): 185if path.name.startswith("_"): 186continue 187 188if path.is_dir() and (path / "__init__.py").exists(): 189module_name = path.name 190spec = importlib.util.spec_from_file_location(module_name, path / "__init__.py") 191module = importlib.util.module_from_spec(spec) 192spec.loader.exec_module(module) 193loaded_modules.append(module) 194else: 195continue 196 197return loaded_modules 198 199 200PANEL_POSITIONS = { 201"top": Gtk.PositionType.TOP, 202"bottom": Gtk.PositionType.BOTTOM, 203"left": Gtk.PositionType.LEFT, 204"right": Gtk.PositionType.RIGHT, 205} 206 207 208PANEL_POSITIONS_REVERSE = { 209Gtk.PositionType.TOP: "top", 210Gtk.PositionType.BOTTOM: "bottom", 211Gtk.PositionType.LEFT: "left", 212Gtk.PositionType.RIGHT: "right", 213} 214 215 216class PanoramaPanel(Gtk.Application): 217def __init__(self): 218super().__init__(application_id="com.roundabout_host.panorama.panel") 219self.display = Gdk.Display.get_default() 220self.monitors = self.display.get_monitors() 221self.applets_by_name = {} 222self.panels = [] 223self.manager_window = None 224self.edit_mode = False 225 226def do_startup(self): 227Gtk.Application.do_startup(self) 228for i, monitor in enumerate(self.monitors): 229geometry = monitor.get_geometry() 230print(f"Monitor {i}: {geometry.width}x{geometry.height} at {geometry.x},{geometry.y}") 231 232all_applets = list(chain.from_iterable(load_packages_from_dir(d) for d in get_applet_directories())) 233print("Applets:") 234subclasses = get_all_subclasses(panorama_panel.Applet) 235for subclass in subclasses: 236if subclass.__name__ in self.applets_by_name: 237print(f"Name conflict for applet {subclass.__name__}. Only one will be loaded.", file=sys.stderr) 238self.applets_by_name[subclass.__name__] = subclass 239 240with open(get_config_file(), "r") as config_file: 241yaml_loader = yaml.YAML(typ="rt") 242yaml_file = yaml_loader.load(config_file) 243for panel_data in yaml_file["panels"]: 244position = PANEL_POSITIONS[panel_data["position"]] 245monitor_index = panel_data["monitor"] 246monitor = self.monitors[monitor_index] 247size = panel_data["size"] 248 249panel = Panel(self, monitor, position, size, monitor_index) 250self.panels.append(panel) 251panel.show() 252 253print(f"{size}px panel on {position} edge of monitor {monitor_index}") 254 255for area_name, area in (("left", panel.left_area), ("centre", panel.centre_area), ("right", panel.right_area)): 256applet_list = panel_data["applets"].get(area_name) 257if applet_list is None: 258continue 259 260for applet in applet_list: 261item = list(applet.items())[0] 262AppletClass = self.applets_by_name[item[0]] 263options = item[1] 264applet_widget = AppletClass(orientation=panel.get_orientation(), config=options) 265 266area.append(applet_widget) 267 268def do_activate(self): 269Gio.Application.do_activate(self) 270 271def save_config(self): 272with open(get_config_file(), "w") as config_file: 273yaml_writer = yaml.YAML(typ="rt") 274data = {"panels": []} 275for panel in self.panels: 276panel_data = { 277"position": PANEL_POSITIONS_REVERSE[panel.position], 278"monitor": panel.monitor_index, 279"size": panel.size, 280"applets": {} 281} 282 283for area_name, area in (("left", panel.left_area), ("centre", panel.centre_area), ("right", panel.right_area)): 284panel_data["applets"][area_name] = [] 285applet = area.get_first_child() 286while applet is not None: 287panel_data["applets"][area_name].append({ 288applet.__class__.__name__: applet.get_config(), 289}) 290 291applet = applet.get_next_sibling() 292 293data["panels"].append(panel_data) 294 295yaml_writer.dump(data, config_file) 296 297def do_shutdown(self): 298print("Shutting down") 299Gtk.Application.do_shutdown(self) 300self.save_config() 301 302def set_edit_mode(self, value): 303self.edit_mode = value 304for panel in self.panels: 305panel.set_edit_mode(value) 306 307def reset_manager_window(self, *args): 308self.manager_window = None 309 310 311if __name__ == "__main__": 312app = PanoramaPanel() 313app.run(sys.argv) 314