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, GObject 20 21sys.path.insert(0, str((Path(__file__).parent / "shared").resolve())) 22 23import panorama_panel 24 25 26@Gtk.Template(filename="panel-configurator.ui") 27class PanelConfigurator(Gtk.Frame): 28__gtype_name__ = "PanelConfigurator" 29 30panel_size_adjustment: Gtk.Adjustment = Gtk.Template.Child() 31monitor_number_adjustment: Gtk.Adjustment = Gtk.Template.Child() 32top_position_radio: Gtk.CheckButton = Gtk.Template.Child() 33bottom_position_radio: Gtk.CheckButton = Gtk.Template.Child() 34left_position_radio: Gtk.CheckButton = Gtk.Template.Child() 35right_position_radio: Gtk.CheckButton = Gtk.Template.Child() 36 37def __init__(self, panel: Panel, **kwargs): 38super().__init__(**kwargs) 39self.panel = panel 40self.panel_size_adjustment.set_value(panel.size) 41 42match self.panel.position: 43case Gtk.PositionType.TOP: 44self.top_position_radio.set_active(True) 45case Gtk.PositionType.BOTTOM: 46self.bottom_position_radio.set_active(True) 47case Gtk.PositionType.LEFT: 48self.left_position_radio.set_active(True) 49case Gtk.PositionType.RIGHT: 50self.right_position_radio.set_active(True) 51 52self.top_position_radio.panel_position_target = Gtk.PositionType.TOP 53self.bottom_position_radio.panel_position_target = Gtk.PositionType.BOTTOM 54self.left_position_radio.panel_position_target = Gtk.PositionType.LEFT 55self.right_position_radio.panel_position_target = Gtk.PositionType.RIGHT 56 57@Gtk.Template.Callback() 58def update_panel_size(self, adjustment: Gtk.Adjustment): 59if not self.get_root(): 60return 61self.panel.set_size(int(adjustment.get_value())) 62 63@Gtk.Template.Callback() 64def move_panel(self, button: Gtk.CheckButton): 65if not self.get_root(): 66return 67if not button.get_active(): 68return 69print("Moving panel") 70self.panel.set_position(button.panel_position_target) 71self.update_panel_size(self.panel_size_adjustment) 72 73# Make the applets aware of the changed orientation 74for area in (self.panel.left_area, self.panel.centre_area, self.panel.right_area): 75applet = area.get_first_child() 76while applet: 77applet.set_orientation(self.panel.get_orientation()) 78applet.set_panel_position(self.panel.position) 79applet.queue_resize() 80applet = applet.get_next_sibling() 81 82@Gtk.Template.Callback() 83def move_to_monitor(self, adjustment: Gtk.Adjustment): 84if not self.get_root(): 85return 86app: PanoramaPanel = self.get_root().get_application() 87monitor = app.monitors[int(self.monitor_number_adjustment.get_value())] 88self.panel.unmap() 89Gtk4LayerShell.set_monitor(self.panel, monitor) 90self.panel.show() 91 92 93PANEL_POSITIONS_HUMAN = { 94Gtk.PositionType.TOP: "top", 95Gtk.PositionType.BOTTOM: "bottom", 96Gtk.PositionType.LEFT: "left", 97Gtk.PositionType.RIGHT: "right", 98} 99 100 101@Gtk.Template(filename="panel-manager.ui") 102class PanelManager(Gtk.Window): 103__gtype_name__ = "PanelManager" 104 105panel_editing_switch: Gtk.Switch = Gtk.Template.Child() 106panel_stack: Gtk.Stack = Gtk.Template.Child() 107 108def __init__(self, application: Gtk.Application, **kwargs): 109super().__init__(application=application, **kwargs) 110 111self.connect("close-request", lambda *args: self.destroy()) 112 113action_group = Gio.SimpleActionGroup() 114 115self.next_panel_action = Gio.SimpleAction(name="next-panel") 116action_group.add_action(self.next_panel_action) 117self.next_panel_action.connect("activate", lambda *args: self.panel_stack.set_visible_child(self.panel_stack.get_visible_child().get_next_sibling())) 118 119self.previous_panel_action = Gio.SimpleAction(name="previous-panel") 120action_group.add_action(self.previous_panel_action) 121self.previous_panel_action.connect("activate", lambda *args: self.panel_stack.set_visible_child(self.panel_stack.get_visible_child().get_prev_sibling())) 122 123self.insert_action_group("win", action_group) 124if isinstance(self.get_application(), PanoramaPanel): 125self.panel_editing_switch.set_active(application.edit_mode) 126self.panel_editing_switch.connect("state-set", self.set_edit_mode) 127 128self.connect("close-request", lambda *args: self.destroy()) 129 130if isinstance(self.get_application(), PanoramaPanel): 131app: PanoramaPanel = self.get_application() 132for panel in app.panels: 133configurator = PanelConfigurator(panel) 134self.panel_stack.add_child(configurator) 135configurator.monitor_number_adjustment.set_upper(len(app.monitors)) 136 137self.panel_stack.set_visible_child(self.panel_stack.get_first_child()) 138self.panel_stack.connect("notify::visible-child", self.set_visible_panel) 139self.panel_stack.notify("visible-child") 140 141def set_visible_panel(self, stack: Gtk.Stack, pspec: GObject.ParamSpec): 142panel: Panel = stack.get_visible_child().panel 143 144self.next_panel_action.set_enabled(stack.get_visible_child().get_next_sibling() is not None) 145self.previous_panel_action.set_enabled(stack.get_visible_child().get_prev_sibling() is not None) 146 147def set_edit_mode(self, switch, value): 148if isinstance(self.get_application(), PanoramaPanel): 149self.get_application().set_edit_mode(value) 150 151@Gtk.Template.Callback() 152def save_settings(self, *args): 153if isinstance(self.get_application(), PanoramaPanel): 154print("Saving settings as user requested") 155self.get_application().save_config() 156 157 158def get_applet_directories(): 159data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local" / "share")) 160data_dirs = [Path(d) for d in os.getenv("XDG_DATA_DIRS", "/usr/local/share:/usr/share").split(":")] 161 162all_paths = [data_home / "panorama-panel" / "applets"] + [d / "panorama-panel" / "applets" for d in data_dirs] 163return [d for d in all_paths if d.is_dir()] 164 165 166def get_config_file(): 167config_home = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config")) 168 169return config_home / "panorama-panel" / "config.yaml" 170 171 172class AppletArea(Gtk.Box): 173def __init__(self, orientation=Gtk.Orientation.HORIZONTAL): 174super().__init__() 175 176self.drop_target = Gtk.DropTarget.new(GObject.TYPE_UINT64, Gdk.DragAction.MOVE) 177self.drop_target.set_gtypes([GObject.TYPE_UINT64]) 178self.drop_target.connect("drop", self.drop_applet) 179 180def drop_applet(self, drop_target: Gtk.DropTarget, value: int, x: float, y: float): 181print(f"Dropping applet: {value}") 182applet: panorama_panel.Applet = self.get_root().get_application().drags.pop(value) 183print(f"Type: {applet.__class__.__name__}") 184old_area: AppletArea = applet.get_parent() 185old_area.remove(applet) 186self.append(applet) 187return True 188 189def set_edit_mode(self, value): 190child = self.get_first_child() 191while child is not None: 192if value: 193child.make_draggable() 194else: 195child.restore_drag() 196child.set_opacity(0.75 if value else 1) 197child = child.get_next_sibling() 198 199if value: 200self.add_controller(self.drop_target) 201else: 202self.remove_controller(self.drop_target) 203 204 205class Panel(Gtk.Window): 206def __init__(self, application: Gtk.Application, monitor: Gdk.Monitor, position: Gtk.PositionType = Gtk.PositionType.TOP, size: int = 40, monitor_index: int = 0): 207super().__init__(application=application) 208self.set_decorated(False) 209self.position = None 210self.monitor_index = monitor_index 211 212Gtk4LayerShell.init_for_window(self) 213Gtk4LayerShell.set_monitor(self, monitor) 214 215Gtk4LayerShell.set_layer(self, Gtk4LayerShell.Layer.TOP) 216 217Gtk4LayerShell.auto_exclusive_zone_enable(self) 218 219box = Gtk.CenterBox() 220 221self.set_child(box) 222self.set_position(position) 223self.set_size(size) 224 225self.left_area = AppletArea(orientation=box.get_orientation()) 226self.centre_area = AppletArea(orientation=box.get_orientation()) 227self.right_area = AppletArea(orientation=box.get_orientation()) 228 229box.set_start_widget(self.left_area) 230box.set_center_widget(self.centre_area) 231box.set_end_widget(self.right_area) 232 233# Add a context menu 234menu = Gio.Menu() 235 236menu.append("Open _manager", "panel.manager") 237 238self.context_menu = Gtk.PopoverMenu.new_from_model(menu) 239self.context_menu.set_has_arrow(False) 240self.context_menu.set_parent(self) 241self.context_menu.set_halign(Gtk.Align.START) 242self.context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED) 243 244right_click_controller = Gtk.GestureClick() 245right_click_controller.set_button(3) 246right_click_controller.connect("pressed", self.show_context_menu) 247 248self.add_controller(right_click_controller) 249 250action_group = Gio.SimpleActionGroup() 251manager_action = Gio.SimpleAction.new("manager", None) 252manager_action.connect("activate", self.show_manager) 253action_group.add_action(manager_action) 254self.insert_action_group("panel", action_group) 255 256def set_edit_mode(self, value): 257for area in (self.left_area, self.centre_area, self.right_area): 258area.set_edit_mode(value) 259 260def show_context_menu(self, gesture, n_presses, x, y): 261rect = Gdk.Rectangle() 262rect.x = int(x) 263rect.y = int(y) 264rect.width = 1 265rect.height = 1 266 267self.context_menu.set_pointing_to(rect) 268self.context_menu.popup() 269 270def show_manager(self, _0=None, _1=None): 271print("Showing manager") 272if self.get_application(): 273if not self.get_application().manager_window: 274self.get_application().manager_window = PanelManager(self.get_application()) 275self.get_application().manager_window.connect("close-request", self.get_application().reset_manager_window) 276self.get_application().manager_window.present() 277 278def get_orientation(self): 279box = self.get_first_child() 280return box.get_orientation() 281 282def set_size(self, value: int): 283self.size = int(value) 284if self.get_orientation() == Gtk.Orientation.HORIZONTAL: 285self.set_size_request(800, self.size) 286self.set_default_size(800, self.size) 287else: 288self.set_size_request(self.size, 600) 289self.set_default_size(self.size, 600) 290 291def set_position(self, position: Gtk.PositionType): 292self.position = position 293match self.position: 294case Gtk.PositionType.TOP: 295Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, False) 296Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True) 297Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True) 298Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True) 299case Gtk.PositionType.BOTTOM: 300Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, False) 301Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True) 302Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True) 303Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True) 304case Gtk.PositionType.LEFT: 305Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, False) 306Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True) 307Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True) 308Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True) 309case Gtk.PositionType.RIGHT: 310Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, False) 311Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True) 312Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True) 313Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True) 314box = self.get_first_child() 315match self.position: 316case Gtk.PositionType.TOP | Gtk.PositionType.BOTTOM: 317box.set_orientation(Gtk.Orientation.HORIZONTAL) 318case Gtk.PositionType.LEFT | Gtk.PositionType.RIGHT: 319box.set_orientation(Gtk.Orientation.VERTICAL) 320 321def get_all_subclasses(klass: type) -> list[type]: 322subclasses = [] 323for subclass in klass.__subclasses__(): 324subclasses.append(subclass) 325subclasses += get_all_subclasses(subclass) 326 327return subclasses 328 329def load_packages_from_dir(dir_path: Path): 330loaded_modules = [] 331 332for path in dir_path.iterdir(): 333if path.name.startswith("_"): 334continue 335 336if path.is_dir() and (path / "__init__.py").exists(): 337module_name = path.name 338spec = importlib.util.spec_from_file_location(module_name, path / "__init__.py") 339module = importlib.util.module_from_spec(spec) 340spec.loader.exec_module(module) 341loaded_modules.append(module) 342else: 343continue 344 345return loaded_modules 346 347 348PANEL_POSITIONS = { 349"top": Gtk.PositionType.TOP, 350"bottom": Gtk.PositionType.BOTTOM, 351"left": Gtk.PositionType.LEFT, 352"right": Gtk.PositionType.RIGHT, 353} 354 355 356PANEL_POSITIONS_REVERSE = { 357Gtk.PositionType.TOP: "top", 358Gtk.PositionType.BOTTOM: "bottom", 359Gtk.PositionType.LEFT: "left", 360Gtk.PositionType.RIGHT: "right", 361} 362 363 364class PanoramaPanel(Gtk.Application): 365def __init__(self): 366super().__init__(application_id="com.roundabout_host.panorama.panel") 367self.display = Gdk.Display.get_default() 368self.monitors = self.display.get_monitors() 369self.applets_by_name: dict[str, panorama_panel.Applet] = {} 370self.panels: list[Panel] = [] 371self.manager_window = None 372self.edit_mode = False 373self.drags = {} 374 375def do_startup(self): 376Gtk.Application.do_startup(self) 377for i, monitor in enumerate(self.monitors): 378geometry = monitor.get_geometry() 379print(f"Monitor {i}: {geometry.width}x{geometry.height} at {geometry.x},{geometry.y}") 380 381all_applets = list(chain.from_iterable(load_packages_from_dir(d) for d in get_applet_directories())) 382print("Applets:") 383subclasses = get_all_subclasses(panorama_panel.Applet) 384for subclass in subclasses: 385if subclass.__name__ in self.applets_by_name: 386print(f"Name conflict for applet {subclass.__name__}. Only one will be loaded.", file=sys.stderr) 387self.applets_by_name[subclass.__name__] = subclass 388 389with open(get_config_file(), "r") as config_file: 390yaml_loader = yaml.YAML(typ="rt") 391yaml_file = yaml_loader.load(config_file) 392for panel_data in yaml_file["panels"]: 393position = PANEL_POSITIONS[panel_data["position"]] 394monitor_index = panel_data["monitor"] 395if monitor_index >= len(self.monitors): 396continue 397monitor = self.monitors[monitor_index] 398size = panel_data["size"] 399 400panel = Panel(self, monitor, position, size, monitor_index) 401self.panels.append(panel) 402panel.show() 403 404print(f"{size}px panel on {position} edge of monitor {monitor_index}") 405 406for area_name, area in (("left", panel.left_area), ("centre", panel.centre_area), ("right", panel.right_area)): 407applet_list = panel_data["applets"].get(area_name) 408if applet_list is None: 409continue 410 411for applet in applet_list: 412item = list(applet.items())[0] 413AppletClass = self.applets_by_name[item[0]] 414options = item[1] 415applet_widget = AppletClass(orientation=panel.get_orientation(), config=options) 416applet_widget.set_panel_position(panel.position) 417 418area.append(applet_widget) 419 420def do_activate(self): 421Gio.Application.do_activate(self) 422 423def save_config(self): 424with open(get_config_file(), "w") as config_file: 425yaml_writer = yaml.YAML(typ="rt") 426data = {"panels": []} 427for panel in self.panels: 428panel_data = { 429"position": PANEL_POSITIONS_REVERSE[panel.position], 430"monitor": panel.monitor_index, 431"size": panel.size, 432"applets": {} 433} 434 435for area_name, area in (("left", panel.left_area), ("centre", panel.centre_area), ("right", panel.right_area)): 436panel_data["applets"][area_name] = [] 437applet = area.get_first_child() 438while applet is not None: 439panel_data["applets"][area_name].append({ 440applet.__class__.__name__: applet.get_config(), 441}) 442 443applet = applet.get_next_sibling() 444 445data["panels"].append(panel_data) 446 447yaml_writer.dump(data, config_file) 448 449def do_shutdown(self): 450print("Shutting down") 451Gtk.Application.do_shutdown(self) 452self.save_config() 453 454def set_edit_mode(self, value): 455self.edit_mode = value 456for panel in self.panels: 457panel.set_edit_mode(value) 458 459def reset_manager_window(self, *args): 460self.manager_window = None 461 462 463if __name__ == "__main__": 464app = PanoramaPanel() 465app.run(sys.argv) 466