main.py
Python script, ASCII text executable
1""" 2Panorama panel: traditional desktop panel using Wayland layer-shell 3protocol. 4Copyright 2025, roundabout-host.com <vlad@roundabout-host.com> 5 6This program is free software: you can redistribute it and/or modify 7it under the terms of the GNU General Public Licence as published by 8the Free Software Foundation, either version 3 of the Licence, or 9(at your option) any later version. 10 11This program is distributed in the hope that it will be useful, 12but WITHOUT ANY WARRANTY; without even the implied warranty of 13MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14GNU General Public Licence for more details. 15 16You should have received a copy of the GNU General Public Licence 17along with this program. If not, see <https://www.gnu.org/licenses/>. 18""" 19 20from __future__ import annotations 21 22import os 23import sys 24import importlib 25import time 26import traceback 27import faulthandler 28import typing 29import locale 30from itertools import accumulate, chain 31from pathlib import Path 32import ruamel.yaml as yaml 33from pywayland.client import Display, EventQueue 34from pywayland.protocol.wayland.wl_output import WlOutputProxy 35from pywayland.protocol.wayland import WlRegistry, WlSeat, WlSurface, WlOutput, WlShm 36from pywayland.protocol.wlr_foreign_toplevel_management_unstable_v1 import ( 37ZwlrForeignToplevelManagerV1, 38ZwlrForeignToplevelHandleV1 39) 40from pywayland.protocol.wlr_screencopy_unstable_v1 import ( 41ZwlrScreencopyManagerV1, 42ZwlrScreencopyFrameV1 43) 44 45faulthandler.enable() 46 47os.environ["GI_TYPELIB_PATH"] = "/usr/local/lib/x86_64-linux-gnu/girepository-1.0" 48 49from ctypes import CDLL 50CDLL('libgtk4-layer-shell.so') 51 52import gi 53gi.require_version("Gtk", "4.0") 54gi.require_version("Gtk4LayerShell", "1.0") 55 56from gi.repository import Gtk, GLib, Gtk4LayerShell, Gdk, Gio, GObject 57from gi.events import GLibEventLoopPolicy 58 59import ctypes 60from cffi import FFI 61ffi = FFI() 62ffi.cdef(""" 63void * gdk_wayland_display_get_wl_display (void * display); 64void * gdk_wayland_surface_get_wl_surface (void * surface); 65void * gdk_wayland_monitor_get_wl_output (void * monitor); 66""") 67gtk = ffi.dlopen("libgtk-4.so.1") 68 69sys.path.insert(0, str((Path(__file__).parent / "shared").resolve())) 70 71locale.bindtextdomain("panorama-panel", "locale") 72locale.textdomain("panorama-panel") 73locale.setlocale(locale.LC_ALL) 74_ = locale.gettext 75 76import panorama_panel 77 78import asyncio 79asyncio.set_event_loop_policy(GLibEventLoopPolicy()) 80 81module_directory = Path(__file__).resolve().parent 82 83custom_css = """ 84.panel-flash { 85animation: flash 333ms ease-in-out 0s 2; 86} 87 88@keyframes flash { 890% { 90background-color: initial; 91} 9250% { 93background-color: #ffff0080; 94} 950% { 96background-color: initial; 97} 98} 99""" 100 101css_provider = Gtk.CssProvider() 102css_provider.load_from_data(custom_css) 103Gtk.StyleContext.add_provider_for_display( 104Gdk.Display.get_default(), 105css_provider, 106100 107) 108 109 110@Gtk.Template(filename=str(module_directory / "panel-configurator.ui")) 111class PanelConfigurator(Gtk.Frame): 112__gtype_name__ = "PanelConfigurator" 113 114panel_size_adjustment: Gtk.Adjustment = Gtk.Template.Child() 115monitor_number_adjustment: Gtk.Adjustment = Gtk.Template.Child() 116top_position_radio: Gtk.CheckButton = Gtk.Template.Child() 117bottom_position_radio: Gtk.CheckButton = Gtk.Template.Child() 118left_position_radio: Gtk.CheckButton = Gtk.Template.Child() 119right_position_radio: Gtk.CheckButton = Gtk.Template.Child() 120autohide_switch: Gtk.Switch = Gtk.Template.Child() 121 122def __init__(self, panel: Panel, **kwargs): 123super().__init__(**kwargs) 124self.panel = panel 125self.panel_size_adjustment.set_value(panel.size) 126 127match self.panel.position: 128case Gtk.PositionType.TOP: 129self.top_position_radio.set_active(True) 130case Gtk.PositionType.BOTTOM: 131self.bottom_position_radio.set_active(True) 132case Gtk.PositionType.LEFT: 133self.left_position_radio.set_active(True) 134case Gtk.PositionType.RIGHT: 135self.right_position_radio.set_active(True) 136 137self.top_position_radio.panel_position_target = Gtk.PositionType.TOP 138self.bottom_position_radio.panel_position_target = Gtk.PositionType.BOTTOM 139self.left_position_radio.panel_position_target = Gtk.PositionType.LEFT 140self.right_position_radio.panel_position_target = Gtk.PositionType.RIGHT 141self.autohide_switch.set_active(self.panel.autohide) 142 143@Gtk.Template.Callback() 144def update_panel_size(self, adjustment: Gtk.Adjustment): 145if not self.get_root(): 146return 147self.panel.set_size(int(adjustment.get_value())) 148 149self.panel.get_application().save_config() 150 151@Gtk.Template.Callback() 152def toggle_autohide(self, switch: Gtk.Switch, value: bool): 153if not self.get_root(): 154return 155self.panel.set_autohide(value, self.panel.hide_time) 156 157self.panel.get_application().save_config() 158 159@Gtk.Template.Callback() 160def move_panel(self, button: Gtk.CheckButton): 161if not self.get_root(): 162return 163if not button.get_active(): 164return 165 166self.panel.set_position(button.panel_position_target) 167self.update_panel_size(self.panel_size_adjustment) 168 169# Make the applets aware of the changed orientation 170for area in (self.panel.left_area, self.panel.centre_area, self.panel.right_area): 171applet = area.get_first_child() 172while applet: 173applet.set_orientation(self.panel.get_orientation()) 174applet.set_panel_position(self.panel.position) 175applet.queue_resize() 176applet = applet.get_next_sibling() 177 178self.panel.get_application().save_config() 179 180@Gtk.Template.Callback() 181def move_to_monitor(self, adjustment: Gtk.Adjustment): 182if not self.get_root(): 183return 184app: PanoramaPanel = self.get_root().get_application() 185monitor = app.monitors[int(self.monitor_number_adjustment.get_value())] 186self.panel.unmap() 187self.panel.monitor_index = int(self.monitor_number_adjustment.get_value()) 188Gtk4LayerShell.set_monitor(self.panel, monitor) 189self.panel.show() 190 191# Make the applets aware of the changed monitor 192for area in (self.panel.left_area, self.panel.centre_area, self.panel.right_area): 193applet = area.get_first_child() 194while applet: 195applet.output_changed() 196applet = applet.get_next_sibling() 197 198self.panel.get_application().save_config() 199 200 201PANEL_POSITIONS_HUMAN = { 202Gtk.PositionType.TOP: "top", 203Gtk.PositionType.BOTTOM: "bottom", 204Gtk.PositionType.LEFT: "left", 205Gtk.PositionType.RIGHT: "right", 206} 207 208 209@Gtk.Template(filename=str(module_directory / "panel-manager.ui")) 210class PanelManager(Gtk.Window): 211__gtype_name__ = "PanelManager" 212 213panel_editing_switch: Gtk.Switch = Gtk.Template.Child() 214panel_stack: Gtk.Stack = Gtk.Template.Child() 215current_panel: typing.Optional[Panel] = None 216 217def __init__(self, application: Gtk.Application, **kwargs): 218super().__init__(application=application, **kwargs) 219 220self.connect("close-request", lambda *args: self.destroy()) 221 222action_group = Gio.SimpleActionGroup() 223 224self.next_panel_action = Gio.SimpleAction(name="next-panel") 225action_group.add_action(self.next_panel_action) 226self.next_panel_action.connect("activate", lambda *args: self.panel_stack.set_visible_child(self.panel_stack.get_visible_child().get_next_sibling())) 227 228self.previous_panel_action = Gio.SimpleAction(name="previous-panel") 229action_group.add_action(self.previous_panel_action) 230self.previous_panel_action.connect("activate", lambda *args: self.panel_stack.set_visible_child(self.panel_stack.get_visible_child().get_prev_sibling())) 231 232self.insert_action_group("win", action_group) 233if isinstance(self.get_application(), PanoramaPanel): 234self.panel_editing_switch.set_active(application.edit_mode) 235self.panel_editing_switch.connect("state-set", self.set_edit_mode) 236 237self.connect("close-request", lambda *args: self.unflash_old_panel()) 238self.connect("close-request", lambda *args: self.destroy()) 239 240if isinstance(self.get_application(), PanoramaPanel): 241app: PanoramaPanel = self.get_application() 242for panel in app.panels: 243configurator = PanelConfigurator(panel) 244self.panel_stack.add_child(configurator) 245configurator.monitor_number_adjustment.set_upper(len(app.monitors)) 246 247self.panel_stack.set_visible_child(self.panel_stack.get_first_child()) 248self.panel_stack.connect("notify::visible-child", self.set_visible_panel) 249self.panel_stack.notify("visible-child") 250 251def unflash_old_panel(self): 252if self.current_panel: 253for area in (self.current_panel.left_area, self.current_panel.centre_area, self.current_panel.right_area): 254area.unflash() 255 256def set_visible_panel(self, stack: Gtk.Stack, pspec: GObject.ParamSpec): 257self.unflash_old_panel() 258 259panel: Panel = stack.get_visible_child().panel 260 261self.current_panel = panel 262self.next_panel_action.set_enabled(stack.get_visible_child().get_next_sibling() is not None) 263self.previous_panel_action.set_enabled(stack.get_visible_child().get_prev_sibling() is not None) 264 265# Start an animation to show the user what panel is being edited 266for area in (panel.left_area, panel.centre_area, panel.right_area): 267area.flash() 268 269def set_edit_mode(self, switch, value): 270if isinstance(self.get_application(), PanoramaPanel): 271self.get_application().set_edit_mode(value) 272 273@Gtk.Template.Callback() 274def save_settings(self, *args): 275if isinstance(self.get_application(), PanoramaPanel): 276self.get_application().save_config() 277 278 279def get_applet_directories(): 280data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local" / "share")) 281data_dirs = [Path(d) for d in os.getenv("XDG_DATA_DIRS", "/usr/local/share:/usr/share").split(":")] 282 283all_paths = [data_home / "panorama-panel" / "applets"] + [d / "panorama-panel" / "applets" for d in data_dirs] 284return [d for d in all_paths if d.is_dir()] 285 286 287def get_config_file(): 288config_home = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config")) 289 290return config_home / "panorama-panel" / "config.yaml" 291 292 293class AppletArea(Gtk.Box): 294def __init__(self, orientation=Gtk.Orientation.HORIZONTAL): 295super().__init__() 296 297self.drop_target = Gtk.DropTarget.new(GObject.TYPE_UINT64, Gdk.DragAction.MOVE) 298self.drop_target.set_gtypes([GObject.TYPE_UINT64]) 299self.drop_target.connect("drop", self.drop_applet) 300 301def drop_applet(self, drop_target: Gtk.DropTarget, value: int, x: float, y: float): 302applet = self.get_root().get_application().drags[value] 303old_area: AppletArea = applet.get_parent() 304old_area.remove(applet) 305# Find the position where to insert the applet 306child = self.get_first_child() 307while child: 308allocation = child.get_allocation() 309child_x, child_y = self.translate_coordinates(self, 0, 0) 310if self.get_orientation() == Gtk.Orientation.HORIZONTAL: 311midpoint = child_x + allocation.width / 2 312if x < midpoint: 313applet.insert_before(self, child) 314break 315elif self.get_orientation() == Gtk.Orientation.VERTICAL: 316midpoint = child_y + allocation.height / 2 317if y < midpoint: 318applet.insert_before(self, child) 319break 320child = child.get_next_sibling() 321else: 322self.append(applet) 323applet.show() 324 325self.get_root().get_application().drags.pop(value) 326self.get_root().get_application().save_config() 327 328return True 329 330def set_edit_mode(self, value): 331panel: Panel = self.get_root() 332child = self.get_first_child() 333while child is not None: 334if value: 335child.make_draggable() 336else: 337child.restore_drag() 338child.set_opacity(0.75 if value else 1) 339child = child.get_next_sibling() 340 341if value: 342self.add_controller(self.drop_target) 343if panel.get_orientation() == Gtk.Orientation.HORIZONTAL: 344self.set_size_request(48, 0) 345elif panel.get_orientation() == Gtk.Orientation.VERTICAL: 346self.set_size_request(0, 48) 347else: 348self.remove_controller(self.drop_target) 349self.set_size_request(0, 0) 350 351def flash(self): 352self.add_css_class("panel-flash") 353 354def unflash(self): 355self.remove_css_class("panel-flash") 356 357 358POSITION_TO_LAYER_SHELL_EDGE = { 359Gtk.PositionType.TOP: Gtk4LayerShell.Edge.TOP, 360Gtk.PositionType.BOTTOM: Gtk4LayerShell.Edge.BOTTOM, 361Gtk.PositionType.LEFT: Gtk4LayerShell.Edge.LEFT, 362Gtk.PositionType.RIGHT: Gtk4LayerShell.Edge.RIGHT, 363} 364 365 366class Panel(Gtk.Window): 367def __init__(self, application: Gtk.Application, monitor: Gdk.Monitor, position: Gtk.PositionType = Gtk.PositionType.TOP, size: int = 40, autohide: bool = False, hide_time: int = 0, can_capture_keyboard: bool = False): 368super().__init__(application=application) 369self.drop_motion_controller = None 370self.motion_controller = None 371self.set_decorated(False) 372self.position = None 373self.autohide = None 374self.monitor_name = monitor.get_connector() 375self.hide_time = None 376self.monitor_index = 0 377self.can_capture_keyboard = can_capture_keyboard 378self.open_popovers: set[int] = set() 379 380self.add_css_class("panorama-panel") 381 382Gtk4LayerShell.init_for_window(self) 383Gtk4LayerShell.set_namespace(self, "com.roundabout_host.panorama.panel") 384Gtk4LayerShell.set_monitor(self, monitor) 385 386Gtk4LayerShell.set_layer(self, Gtk4LayerShell.Layer.TOP) 387if can_capture_keyboard: 388Gtk4LayerShell.set_keyboard_mode(self, Gtk4LayerShell.KeyboardMode.ON_DEMAND) 389else: 390Gtk4LayerShell.set_keyboard_mode(self, Gtk4LayerShell.KeyboardMode.NONE) 391 392box = Gtk.CenterBox() 393 394self.left_area = AppletArea(orientation=box.get_orientation()) 395self.centre_area = AppletArea(orientation=box.get_orientation()) 396self.right_area = AppletArea(orientation=box.get_orientation()) 397 398box.set_start_widget(self.left_area) 399box.set_center_widget(self.centre_area) 400box.set_end_widget(self.right_area) 401 402self.set_child(box) 403self.set_position(position) 404self.set_size(size) 405self.set_autohide(autohide, hide_time) 406 407# Add a context menu 408menu = Gio.Menu() 409 410menu.append(_("Open _manager"), "panel.manager") 411 412self.context_menu = Gtk.PopoverMenu.new_from_model(menu) 413self.context_menu.set_has_arrow(False) 414self.context_menu.set_parent(self) 415self.context_menu.set_halign(Gtk.Align.START) 416self.context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED) 417panorama_panel.track_popover(self.context_menu) 418 419right_click_controller = Gtk.GestureClick() 420right_click_controller.set_button(3) 421right_click_controller.connect("pressed", self.show_context_menu) 422 423self.add_controller(right_click_controller) 424 425action_group = Gio.SimpleActionGroup() 426manager_action = Gio.SimpleAction.new("manager", None) 427manager_action.connect("activate", self.show_manager) 428action_group.add_action(manager_action) 429self.insert_action_group("panel", action_group) 430 431def reset_margins(self): 432for edge in POSITION_TO_LAYER_SHELL_EDGE.values(): 433Gtk4LayerShell.set_margin(self, edge, 0) 434 435def set_edit_mode(self, value): 436for area in (self.left_area, self.centre_area, self.right_area): 437area.set_edit_mode(value) 438 439def show_context_menu(self, gesture, n_presses, x, y): 440rect = Gdk.Rectangle() 441rect.x = int(x) 442rect.y = int(y) 443rect.width = 1 444rect.height = 1 445 446self.context_menu.set_pointing_to(rect) 447self.context_menu.popup() 448 449def slide_in(self): 450if not self.autohide: 451Gtk4LayerShell.set_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position], 0) 452return False 453 454if Gtk4LayerShell.get_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position]) >= 0: 455return False 456 457Gtk4LayerShell.set_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position], Gtk4LayerShell.get_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position]) + 1) 458return True 459 460def slide_out(self): 461if not self.autohide: 462Gtk4LayerShell.set_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position], 0) 463return False 464 465if Gtk4LayerShell.get_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position]) <= 1 - self.size: 466return False 467 468Gtk4LayerShell.set_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position], Gtk4LayerShell.get_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position]) - 1) 469return True 470 471def show_manager(self, _0=None, _1=None): 472if self.get_application(): 473if not self.get_application().manager_window: 474self.get_application().manager_window = PanelManager(self.get_application()) 475self.get_application().manager_window.connect("close-request", self.get_application().reset_manager_window) 476self.get_application().manager_window.present() 477 478def get_orientation(self): 479box = self.get_first_child() 480return box.get_orientation() 481 482def set_size(self, value: int): 483self.size = int(value) 484if self.get_orientation() == Gtk.Orientation.HORIZONTAL: 485self.set_size_request(800, self.size) 486self.set_default_size(800, self.size) 487else: 488self.set_size_request(self.size, 600) 489self.set_default_size(self.size, 600) 490 491def set_position(self, position: Gtk.PositionType): 492self.position = position 493self.reset_margins() 494match self.position: 495case Gtk.PositionType.TOP: 496Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, False) 497Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True) 498Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True) 499Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True) 500case Gtk.PositionType.BOTTOM: 501Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, False) 502Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True) 503Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True) 504Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True) 505case Gtk.PositionType.LEFT: 506Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, False) 507Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True) 508Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True) 509Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True) 510case Gtk.PositionType.RIGHT: 511Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, False) 512Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True) 513Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True) 514Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True) 515box = self.get_first_child() 516match self.position: 517case Gtk.PositionType.TOP | Gtk.PositionType.BOTTOM: 518box.set_orientation(Gtk.Orientation.HORIZONTAL) 519for area in (self.left_area, self.centre_area, self.right_area): 520area.set_orientation(Gtk.Orientation.HORIZONTAL) 521case Gtk.PositionType.LEFT | Gtk.PositionType.RIGHT: 522box.set_orientation(Gtk.Orientation.VERTICAL) 523for area in (self.left_area, self.centre_area, self.right_area): 524area.set_orientation(Gtk.Orientation.VERTICAL) 525 526if self.autohide: 527if not self.open_popovers: 528GLib.timeout_add(self.hide_time // (self.size - 1), self.slide_out) 529 530def set_autohide(self, autohide: bool, hide_time: int): 531self.autohide = autohide 532self.hide_time = hide_time 533if not self.autohide: 534self.reset_margins() 535Gtk4LayerShell.auto_exclusive_zone_enable(self) 536if self.motion_controller is not None: 537self.remove_controller(self.motion_controller) 538if self.drop_motion_controller is not None: 539self.remove_controller(self.drop_motion_controller) 540self.motion_controller = None 541self.drop_motion_controller = None 542self.reset_margins() 543else: 544Gtk4LayerShell.set_exclusive_zone(self, 0) 545# Only leave 1px of the window as a "mouse sensor" 546Gtk4LayerShell.set_margin(self, POSITION_TO_LAYER_SHELL_EDGE[self.position], 1 - self.size) 547self.motion_controller = Gtk.EventControllerMotion() 548self.add_controller(self.motion_controller) 549self.drop_motion_controller = Gtk.DropControllerMotion() 550self.add_controller(self.drop_motion_controller) 551 552self.motion_controller.connect("enter", lambda *args: GLib.timeout_add(self.hide_time // (self.size - 1), self.slide_in)) 553self.drop_motion_controller.connect("enter", lambda *args: GLib.timeout_add(self.hide_time // (self.size - 1), self.slide_in)) 554self.motion_controller.connect("leave", lambda *args: not self.open_popovers and GLib.timeout_add(self.hide_time // (self.size - 1), self.slide_out)) 555self.drop_motion_controller.connect("leave", lambda *args: not self.open_popovers and GLib.timeout_add(self.hide_time // (self.size - 1), self.slide_out)) 556 557def wl_output_enter(self, output, name): 558for area in (self.left_area, self.centre_area, self.right_area): 559applet = area.get_first_child() 560while applet: 561applet.wl_output_enter(output, name) 562applet = applet.get_next_sibling() 563 564def foreign_toplevel_new(self, manager, toplevel): 565for area in (self.left_area, self.centre_area, self.right_area): 566applet = area.get_first_child() 567while applet: 568applet.foreign_toplevel_new(manager, toplevel) 569applet = applet.get_next_sibling() 570 571def foreign_toplevel_output_enter(self, toplevel, output): 572for area in (self.left_area, self.centre_area, self.right_area): 573applet = area.get_first_child() 574while applet: 575applet.foreign_toplevel_output_enter(toplevel, output) 576applet = applet.get_next_sibling() 577 578def foreign_toplevel_output_leave(self, toplevel, output): 579for area in (self.left_area, self.centre_area, self.right_area): 580applet = area.get_first_child() 581while applet: 582applet.foreign_toplevel_output_leave(toplevel, output) 583applet = applet.get_next_sibling() 584 585def foreign_toplevel_app_id(self, toplevel, app_id): 586for area in (self.left_area, self.centre_area, self.right_area): 587applet = area.get_first_child() 588while applet: 589applet.foreign_toplevel_app_id(toplevel, app_id) 590applet = applet.get_next_sibling() 591 592def foreign_toplevel_title(self, toplevel, title): 593for area in (self.left_area, self.centre_area, self.right_area): 594applet = area.get_first_child() 595while applet: 596applet.foreign_toplevel_title(toplevel, title) 597applet = applet.get_next_sibling() 598 599def foreign_toplevel_state(self, toplevel, state): 600for area in (self.left_area, self.centre_area, self.right_area): 601applet = area.get_first_child() 602while applet: 603applet.foreign_toplevel_state(toplevel, state) 604applet = applet.get_next_sibling() 605 606def foreign_toplevel_closed(self, toplevel): 607for area in (self.left_area, self.centre_area, self.right_area): 608applet = area.get_first_child() 609while applet: 610applet.foreign_toplevel_closed(toplevel) 611applet = applet.get_next_sibling() 612 613def foreign_toplevel_refresh(self): 614for area in (self.left_area, self.centre_area, self.right_area): 615applet = area.get_first_child() 616while applet: 617applet.foreign_toplevel_refresh() 618applet = applet.get_next_sibling() 619 620 621def get_all_subclasses(klass: type) -> list[type]: 622subclasses = [] 623for subclass in klass.__subclasses__(): 624subclasses.append(subclass) 625subclasses += get_all_subclasses(subclass) 626 627return subclasses 628 629def load_packages_from_dir(dir_path: Path): 630loaded_modules = [] 631 632for path in dir_path.iterdir(): 633if path.name.startswith("_"): 634continue 635 636if path.is_dir() and (path / "__init__.py").exists(): 637try: 638module_name = path.name 639spec = importlib.util.spec_from_file_location(module_name, path / "__init__.py") 640module = importlib.util.module_from_spec(spec) 641spec.loader.exec_module(module) 642loaded_modules.append(module) 643except Exception as e: 644print(f"Failed to load applet module {path.name}: {e}", file=sys.stderr) 645else: 646continue 647 648return loaded_modules 649 650 651PANEL_POSITIONS = { 652"top": Gtk.PositionType.TOP, 653"bottom": Gtk.PositionType.BOTTOM, 654"left": Gtk.PositionType.LEFT, 655"right": Gtk.PositionType.RIGHT, 656} 657 658 659PANEL_POSITIONS_REVERSE = { 660Gtk.PositionType.TOP: "top", 661Gtk.PositionType.BOTTOM: "bottom", 662Gtk.PositionType.LEFT: "left", 663Gtk.PositionType.RIGHT: "right", 664} 665 666 667class PanoramaPanel(Gtk.Application): 668def __init__(self): 669super().__init__( 670application_id="com.roundabout_host.roundabout.PanoramaPanel", 671flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE 672) 673self.display = Gdk.Display.get_default() 674self.monitors = self.display.get_monitors() 675self.applets_by_name: dict[str, panorama_panel.Applet] = {} 676self.panels: list[Panel] = [] 677self.wl_globals = [] 678self.wl_display = None 679self.wl_seat = None 680self.wl_shm = None 681self.foreign_toplevel_manager = None 682self.screencopy_manager = None 683self.manager_window = None 684self.edit_mode = False 685self.output_proxies = [] 686self.drags = {} 687self.wl_output_ids = set() 688self.started = False 689self.all_handles: set[ZwlrForeignToplevelHandleV1] = set() 690self.obsolete_handles: set[ZwlrForeignToplevelHandleV1] = set() 691 692self.add_main_option( 693"trigger", 694ord("t"), 695GLib.OptionFlags.NONE, 696GLib.OptionArg.STRING, 697_("Trigger an action which can be processed by the applets"), 698_("NAME") 699) 700 701def destroy_foreign_toplevel_manager(self): 702self.foreign_toplevel_manager.destroy() 703self.wl_globals.remove(self.foreign_toplevel_manager) 704self.foreign_toplevel_manager = None 705self.foreign_toplevel_refresh() 706 707def on_foreign_toplevel_manager_finish(self, manager: ZwlrForeignToplevelManagerV1): 708if self.foreign_toplevel_manager: 709self.destroy_foreign_toplevel_manager() 710 711def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1, 712handle: ZwlrForeignToplevelHandleV1): 713self.all_handles.add(handle) 714 715for panel in self.panels: 716panel.foreign_toplevel_new(manager, handle) 717handle.dispatcher["title"] = self.on_title_changed 718handle.dispatcher["app_id"] = self.on_app_id_changed 719handle.dispatcher["output_enter"] = self.on_output_enter 720handle.dispatcher["output_leave"] = self.on_output_leave 721handle.dispatcher["state"] = self.on_state_changed 722handle.dispatcher["closed"] = self.on_closed 723 724def on_output_enter(self, handle, output): 725if handle in self.obsolete_handles: 726return 727for panel in self.panels: 728panel.foreign_toplevel_output_enter(handle, output) 729 730def on_output_leave(self, handle, output): 731if handle in self.obsolete_handles: 732return 733for panel in self.panels: 734panel.foreign_toplevel_output_leave(handle, output) 735 736def on_title_changed(self, handle, title): 737if handle in self.obsolete_handles: 738return 739for panel in self.panels: 740panel.foreign_toplevel_title(handle, title) 741 742def on_app_id_changed(self, handle, app_id): 743if handle in self.obsolete_handles: 744return 745for panel in self.panels: 746panel.foreign_toplevel_app_id(handle, app_id) 747 748def on_state_changed(self, handle, state): 749if handle in self.obsolete_handles: 750return 751for panel in self.panels: 752panel.foreign_toplevel_state(handle, state) 753 754def on_closed(self, handle): 755if handle in self.obsolete_handles: 756return 757for panel in self.panels: 758panel.foreign_toplevel_closed(handle) 759 760def foreign_toplevel_refresh(self): 761self.obsolete_handles = self.all_handles.copy() 762 763for panel in self.panels: 764panel.foreign_toplevel_refresh() 765 766def receive_output_name(self, output: WlOutput, name: str): 767output.name = name 768 769def idle_proxy_cover(self, output_name): 770for monitor in self.display.get_monitors(): 771wl_output = gtk.gdk_wayland_monitor_get_wl_output(ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(monitor.__gpointer__, None))) 772found = False 773for proxy in self.output_proxies: 774if proxy._ptr == wl_output: 775found = True 776if wl_output and not found: 777print("Create proxy") 778output_proxy = WlOutputProxy(wl_output, self.wl_display) 779output_proxy.interface.registry[output_proxy._ptr] = output_proxy 780self.output_proxies.append(output_proxy) 781 782def on_global(self, registry, name, interface, version): 783if interface == "zwlr_foreign_toplevel_manager_v1": 784self.foreign_toplevel_manager_id = name 785self.foreign_toplevel_version = version 786elif interface == "wl_output": 787output = registry.bind(name, WlOutput, version) 788output.id = name 789self.wl_globals.append(output) 790print(f"global {name} {interface} {version}") 791output.global_name = name 792output.name = None 793output.dispatcher["name"] = self.receive_output_name 794while not output.name or not self.foreign_toplevel_version: 795self.wl_display.dispatch(block=True) 796print(output.name) 797self.wl_output_ids.add(output.global_name) 798if not output.name.startswith("live-preview"): 799self.generate_panels(output.name) 800for panel in self.panels: 801panel.wl_output_enter(output, output.name) 802if not output.name.startswith("live-preview"): 803if self.foreign_toplevel_manager: 804self.foreign_toplevel_manager.stop() 805self.wl_display.roundtrip() 806if self.foreign_toplevel_manager: 807self.destroy_foreign_toplevel_manager() 808self.foreign_toplevel_manager = self.registry.bind(self.foreign_toplevel_manager_id, ZwlrForeignToplevelManagerV1, self.foreign_toplevel_version) 809self.foreign_toplevel_manager.id = self.foreign_toplevel_manager_id 810self.wl_globals.append(self.foreign_toplevel_manager) 811self.foreign_toplevel_manager.dispatcher["toplevel"] = self.on_new_toplevel 812self.foreign_toplevel_manager.dispatcher["finished"] = self.on_foreign_toplevel_manager_finish 813self.wl_display.roundtrip() 814print(f"Monitor {output.name} ({interface}) connected", file=sys.stderr) 815elif interface == "wl_seat": 816self.wl_seat = registry.bind(name, WlSeat, version) 817self.wl_seat.id = name 818self.wl_globals.append(self.wl_seat) 819elif interface == "wl_shm": 820self.wl_shm = registry.bind(name, WlShm, version) 821self.wl_shm.id = name 822self.wl_globals.append(self.wl_shm) 823elif interface == "zwlr_screencopy_manager_v1": 824self.screencopy_manager = registry.bind(name, ZwlrScreencopyManagerV1, version) 825self.screencopy_manager.id = name 826self.wl_globals.append(self.screencopy_manager) 827 828def on_global_remove(self, registry, name): 829if name in self.wl_output_ids: 830print(f"Monitor {name} disconnected", file=sys.stderr) 831self.wl_output_ids.discard(name) 832for g in self.wl_globals: 833if g.id == name: 834g.destroy() 835self.wl_globals.remove(g) 836break 837 838def generate_panels(self, output_name): 839print("Generating panels...", file=sys.stderr) 840self.exit_all_applets() 841 842for i, monitor in enumerate(self.monitors): 843geometry = monitor.get_geometry() 844print(f"Monitor {i}: {geometry.width}x{geometry.height} at {geometry.x},{geometry.y}") 845 846for panel in self.panels: 847if panel.monitor_name == output_name: 848panel.destroy() 849with open(get_config_file(), "r") as config_file: 850yaml_loader = yaml.YAML(typ="rt") 851yaml_file = yaml_loader.load(config_file) 852for panel_data in yaml_file["panels"]: 853if panel_data["monitor"] != output_name: 854continue 855position = PANEL_POSITIONS[panel_data["position"]] 856my_monitor = None 857for monitor in self.display.get_monitors(): 858if monitor.get_connector() == panel_data["monitor"]: 859my_monitor = monitor 860break 861if not my_monitor: 862continue 863GLib.idle_add(self.idle_proxy_cover, my_monitor.get_connector()) 864size = panel_data["size"] 865autohide = panel_data["autohide"] 866hide_time = panel_data["hide_time"] 867can_capture_keyboard = panel_data["can_capture_keyboard"] 868 869panel = Panel(self, my_monitor, position, size, autohide, hide_time, can_capture_keyboard) 870self.panels.append(panel) 871 872print(f"{size}px panel on {position}, autohide is {autohide} ({hide_time}ms)") 873 874for area_name, area in (("left", panel.left_area), ("centre", panel.centre_area), ("right", panel.right_area)): 875applet_list = panel_data["applets"].get(area_name) 876if applet_list is None: 877continue 878 879for applet in applet_list: 880item = list(applet.items())[0] 881AppletClass = self.applets_by_name[item[0]] 882options = item[1] 883applet_widget = AppletClass(orientation=panel.get_orientation(), config=options) 884applet_widget.connect("config-changed", self.save_config) 885applet_widget.set_panel_position(panel.position) 886 887area.append(applet_widget) 888 889panel.present() 890panel.realize() 891panel.monitor_name = my_monitor.get_connector() 892 893def do_startup(self): 894Gtk.Application.do_startup(self) 895 896ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p 897ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,) 898 899all_applets = list( 900chain.from_iterable(load_packages_from_dir(d) for d in get_applet_directories())) 901print("Applets:") 902subclasses = get_all_subclasses(panorama_panel.Applet) 903for subclass in subclasses: 904if subclass.__name__ in self.applets_by_name: 905print(f"Name conflict for applet {subclass.__name__}. Only one will be loaded.", 906file=sys.stderr) 907self.applets_by_name[subclass.__name__] = subclass 908 909self.wl_display = Display() 910wl_display_ptr = gtk.gdk_wayland_display_get_wl_display( 911ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.display.__gpointer__, None))) 912self.wl_display._ptr = wl_display_ptr 913 914self.foreign_toplevel_manager_id = None 915self.foreign_toplevel_version = None 916 917self.idle_proxy_cover(None) 918self.registry = self.wl_display.get_registry() 919self.registry.dispatcher["global"] = self.on_global 920self.registry.dispatcher["global_remove"] = self.on_global_remove 921self.wl_display.roundtrip() 922if self.foreign_toplevel_version: 923print("Foreign Toplevel Interface: Found") 924else: 925print("Foreign Toplevel Interface: Not Found") 926 927self.hold() 928 929def do_activate(self): 930Gio.Application.do_activate(self) 931 932def save_config(self, *args): 933print("Saving configuration file") 934with open(get_config_file(), "w") as config_file: 935yaml_writer = yaml.YAML(typ="rt") 936data = {"panels": []} 937for panel in self.panels: 938panel_data = { 939"position": PANEL_POSITIONS_REVERSE[panel.position], 940"monitor": panel.monitor_name, 941"size": panel.size, 942"autohide": panel.autohide, 943"hide_time": panel.hide_time, 944"can_capture_keyboard": panel.can_capture_keyboard, 945"applets": {} 946} 947 948for area_name, area in (("left", panel.left_area), ("centre", panel.centre_area), ("right", panel.right_area)): 949panel_data["applets"][area_name] = [] 950applet = area.get_first_child() 951while applet is not None: 952panel_data["applets"][area_name].append({ 953applet.__class__.__name__: applet.get_config(), 954}) 955 956applet = applet.get_next_sibling() 957 958data["panels"].append(panel_data) 959 960yaml_writer.dump(data, config_file) 961 962def exit_all_applets(self): 963for panel in self.panels: 964for area in (panel.left_area, panel.centre_area, panel.right_area): 965applet = area.get_first_child() 966while applet is not None: 967applet.shutdown(self) 968 969applet = applet.get_next_sibling() 970 971def do_shutdown(self): 972print("Shutting down") 973self.exit_all_applets() 974Gtk.Application.do_shutdown(self) 975 976def do_command_line(self, command_line: Gio.ApplicationCommandLine): 977options = command_line.get_options_dict() 978args = command_line.get_arguments()[1:] 979 980trigger_variant = options.lookup_value("trigger", GLib.VariantType("s")) 981if trigger_variant: 982action_name = trigger_variant.get_string() 983print(app.list_actions()) 984self.activate_action(action_name, None) 985print(f"Triggered {action_name}") 986 987return 0 988 989def set_edit_mode(self, value): 990self.edit_mode = value 991for panel in self.panels: 992panel.set_edit_mode(value) 993 994def reset_manager_window(self, *args): 995self.manager_window = None 996 997 998if __name__ == "__main__": 999app = PanoramaPanel() 1000app.run(sys.argv) 1001