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