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