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