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 destroy_foreign_toplevel_manager(self): 698self.foreign_toplevel_manager.destroy() 699self.wl_globals.remove(self.foreign_toplevel_manager) 700self.foreign_toplevel_manager = None 701 702def on_foreign_toplevel_manager_finish(self, manager: ZwlrForeignToplevelManagerV1): 703if self.foreign_toplevel_manager: 704self.destroy_foreign_toplevel_manager() 705 706def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1, 707handle: ZwlrForeignToplevelHandleV1): 708for panel in self.panels: 709panel.foreign_toplevel_new(manager, handle) 710handle.dispatcher["title"] = self.on_title_changed 711handle.dispatcher["app_id"] = self.on_app_id_changed 712handle.dispatcher["output_enter"] = self.on_output_enter 713handle.dispatcher["output_leave"] = self.on_output_leave 714handle.dispatcher["state"] = self.on_state_changed 715handle.dispatcher["closed"] = self.on_closed 716 717def on_output_enter(self, handle, output): 718for panel in self.panels: 719panel.foreign_toplevel_output_enter(handle, output) 720 721def on_output_leave(self, handle, output): 722for panel in self.panels: 723panel.foreign_toplevel_output_leave(handle, output) 724 725def on_title_changed(self, handle, title): 726for panel in self.panels: 727panel.foreign_toplevel_title(handle, title) 728 729def on_app_id_changed(self, handle, app_id): 730for panel in self.panels: 731panel.foreign_toplevel_app_id(handle, app_id) 732 733def on_state_changed(self, handle, state): 734for panel in self.panels: 735panel.foreign_toplevel_state(handle, state) 736 737def on_closed(self, handle): 738for panel in self.panels: 739panel.foreign_toplevel_closed(handle) 740 741def foreign_toplevel_refresh(self): 742for panel in self.panels: 743panel.foreign_toplevel_refresh() 744 745def receive_output_name(self, output: WlOutput, name: str): 746output.name = name 747 748def idle_proxy_cover(self, output_name): 749for monitor in self.display.get_monitors(): 750wl_output = gtk.gdk_wayland_monitor_get_wl_output(ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(monitor.__gpointer__, None))) 751found = False 752for proxy in self.output_proxies: 753if proxy._ptr == wl_output: 754found = True 755if wl_output and not found: 756print("Create proxy") 757output_proxy = WlOutputProxy(wl_output, self.wl_display) 758output_proxy.interface.registry[output_proxy._ptr] = output_proxy 759self.output_proxies.append(output_proxy) 760 761def on_global(self, registry, name, interface, version): 762if interface == "zwlr_foreign_toplevel_manager_v1": 763self.foreign_toplevel_manager_id = name 764self.foreign_toplevel_version = version 765elif interface == "wl_output": 766output = registry.bind(name, WlOutput, version) 767output.id = name 768self.wl_globals.append(output) 769print(f"global {name} {interface} {version}") 770output.global_name = name 771output.name = None 772output.dispatcher["name"] = self.receive_output_name 773while not output.name or not self.foreign_toplevel_version: 774self.wl_display.dispatch(block=True) 775print(output.name) 776self.wl_output_ids.add(output.global_name) 777if not output.name.startswith("live-preview"): 778self.generate_panels(output.name) 779for panel in self.panels: 780panel.wl_output_enter(output, output.name) 781if not output.name.startswith("live-preview"): 782if self.foreign_toplevel_manager: 783self.foreign_toplevel_manager.stop() 784self.wl_display.roundtrip() 785if self.foreign_toplevel_manager: 786self.destroy_foreign_toplevel_manager() 787self.foreign_toplevel_refresh() 788self.foreign_toplevel_manager = self.registry.bind(self.foreign_toplevel_manager_id, ZwlrForeignToplevelManagerV1, self.foreign_toplevel_version) 789self.foreign_toplevel_manager.id = self.foreign_toplevel_manager_id 790self.wl_globals.append(self.foreign_toplevel_manager) 791self.foreign_toplevel_manager.dispatcher["toplevel"] = self.on_new_toplevel 792self.foreign_toplevel_manager.dispatcher["finished"] = self.on_foreign_toplevel_manager_finish 793self.wl_display.roundtrip() 794print(f"Monitor {output.name} ({interface}) connected", file=sys.stderr) 795elif interface == "wl_seat": 796self.wl_seat = registry.bind(name, WlSeat, version) 797self.wl_seat.id = name 798self.wl_globals.append(self.wl_seat) 799elif interface == "wl_shm": 800self.wl_shm = registry.bind(name, WlShm, version) 801self.wl_shm.id = name 802self.wl_globals.append(self.wl_shm) 803elif interface == "zwlr_screencopy_manager_v1": 804self.screencopy_manager = registry.bind(name, ZwlrScreencopyManagerV1, version) 805self.screencopy_manager.id = name 806self.wl_globals.append(self.screencopy_manager) 807 808def on_global_remove(self, registry, name): 809if name in self.wl_output_ids: 810print(f"Monitor {name} disconnected", file=sys.stderr) 811self.wl_output_ids.discard(name) 812for g in self.wl_globals: 813if g.id == name: 814g.destroy() 815self.wl_globals.remove(g) 816break 817 818def generate_panels(self, output_name): 819print("Generating panels...", file=sys.stderr) 820self.exit_all_applets() 821 822for i, monitor in enumerate(self.monitors): 823geometry = monitor.get_geometry() 824print(f"Monitor {i}: {geometry.width}x{geometry.height} at {geometry.x},{geometry.y}") 825 826for panel in self.panels: 827if panel.monitor_name == output_name: 828panel.destroy() 829with open(get_config_file(), "r") as config_file: 830yaml_loader = yaml.YAML(typ="rt") 831yaml_file = yaml_loader.load(config_file) 832for panel_data in yaml_file["panels"]: 833if panel_data["monitor"] != output_name: 834continue 835position = PANEL_POSITIONS[panel_data["position"]] 836my_monitor = None 837for monitor in self.display.get_monitors(): 838if monitor.get_connector() == panel_data["monitor"]: 839my_monitor = monitor 840break 841if not my_monitor: 842continue 843GLib.idle_add(self.idle_proxy_cover, my_monitor.get_connector()) 844size = panel_data["size"] 845autohide = panel_data["autohide"] 846hide_time = panel_data["hide_time"] 847can_capture_keyboard = panel_data["can_capture_keyboard"] 848 849panel = Panel(self, my_monitor, position, size, autohide, hide_time, can_capture_keyboard) 850self.panels.append(panel) 851 852print(f"{size}px panel on {position}, autohide is {autohide} ({hide_time}ms)") 853 854for area_name, area in (("left", panel.left_area), ("centre", panel.centre_area), ("right", panel.right_area)): 855applet_list = panel_data["applets"].get(area_name) 856if applet_list is None: 857continue 858 859for applet in applet_list: 860item = list(applet.items())[0] 861AppletClass = self.applets_by_name[item[0]] 862options = item[1] 863applet_widget = AppletClass(orientation=panel.get_orientation(), config=options) 864applet_widget.connect("config-changed", self.save_config) 865applet_widget.set_panel_position(panel.position) 866 867area.append(applet_widget) 868 869panel.present() 870panel.realize() 871panel.monitor_name = my_monitor.get_connector() 872 873def do_startup(self): 874Gtk.Application.do_startup(self) 875 876ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p 877ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,) 878 879all_applets = list( 880chain.from_iterable(load_packages_from_dir(d) for d in get_applet_directories())) 881print("Applets:") 882subclasses = get_all_subclasses(panorama_panel.Applet) 883for subclass in subclasses: 884if subclass.__name__ in self.applets_by_name: 885print(f"Name conflict for applet {subclass.__name__}. Only one will be loaded.", 886file=sys.stderr) 887self.applets_by_name[subclass.__name__] = subclass 888 889self.wl_display = Display() 890wl_display_ptr = gtk.gdk_wayland_display_get_wl_display( 891ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.display.__gpointer__, None))) 892self.wl_display._ptr = wl_display_ptr 893 894self.foreign_toplevel_manager_id = None 895self.foreign_toplevel_version = None 896 897self.idle_proxy_cover(None) 898self.registry = self.wl_display.get_registry() 899self.registry.dispatcher["global"] = self.on_global 900self.registry.dispatcher["global_remove"] = self.on_global_remove 901self.wl_display.roundtrip() 902if self.foreign_toplevel_version: 903print("Foreign Toplevel Interface: Found") 904else: 905print("Foreign Toplevel Interface: Not Found") 906 907self.hold() 908 909def do_activate(self): 910Gio.Application.do_activate(self) 911 912def save_config(self, *args): 913print("Saving configuration file") 914with open(get_config_file(), "w") as config_file: 915yaml_writer = yaml.YAML(typ="rt") 916data = {"panels": []} 917for panel in self.panels: 918panel_data = { 919"position": PANEL_POSITIONS_REVERSE[panel.position], 920"monitor": panel.monitor_index, 921"size": panel.size, 922"autohide": panel.autohide, 923"hide_time": panel.hide_time, 924"can_capture_keyboard": panel.can_capture_keyboard, 925"applets": {} 926} 927 928for area_name, area in (("left", panel.left_area), ("centre", panel.centre_area), ("right", panel.right_area)): 929panel_data["applets"][area_name] = [] 930applet = area.get_first_child() 931while applet is not None: 932panel_data["applets"][area_name].append({ 933applet.__class__.__name__: applet.get_config(), 934}) 935 936applet = applet.get_next_sibling() 937 938data["panels"].append(panel_data) 939 940yaml_writer.dump(data, config_file) 941 942def exit_all_applets(self): 943for panel in self.panels: 944for area in (panel.left_area, panel.centre_area, panel.right_area): 945applet = area.get_first_child() 946while applet is not None: 947applet.shutdown(self) 948 949applet = applet.get_next_sibling() 950 951def do_shutdown(self): 952print("Shutting down") 953self.exit_all_applets() 954Gtk.Application.do_shutdown(self) 955 956def do_command_line(self, command_line: Gio.ApplicationCommandLine): 957options = command_line.get_options_dict() 958args = command_line.get_arguments()[1:] 959 960trigger_variant = options.lookup_value("trigger", GLib.VariantType("s")) 961if trigger_variant: 962action_name = trigger_variant.get_string() 963print(app.list_actions()) 964self.activate_action(action_name, None) 965print(f"Triggered {action_name}") 966 967return 0 968 969def set_edit_mode(self, value): 970self.edit_mode = value 971for panel in self.panels: 972panel.set_edit_mode(value) 973 974def reset_manager_window(self, *args): 975self.manager_window = None 976 977 978if __name__ == "__main__": 979app = PanoramaPanel() 980app.run(sys.argv) 981