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, WlCompositor, 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 wl_output_leave(self, output, name): 563for area in (self.left_area, self.centre_area, self.right_area): 564applet = area.get_first_child() 565while applet: 566applet.wl_output_leave(output, name) 567applet = applet.get_next_sibling() 568 569def foreign_toplevel_new(self, manager, toplevel): 570for area in (self.left_area, self.centre_area, self.right_area): 571applet = area.get_first_child() 572while applet: 573applet.foreign_toplevel_new(manager, toplevel) 574applet = applet.get_next_sibling() 575 576def foreign_toplevel_output_enter(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_enter(toplevel, output) 581applet = applet.get_next_sibling() 582 583def foreign_toplevel_output_leave(self, toplevel, output): 584for area in (self.left_area, self.centre_area, self.right_area): 585applet = area.get_first_child() 586while applet: 587applet.foreign_toplevel_output_leave(toplevel, output) 588applet = applet.get_next_sibling() 589 590def foreign_toplevel_app_id(self, toplevel, app_id): 591for area in (self.left_area, self.centre_area, self.right_area): 592applet = area.get_first_child() 593while applet: 594applet.foreign_toplevel_app_id(toplevel, app_id) 595applet = applet.get_next_sibling() 596 597def foreign_toplevel_title(self, toplevel, title): 598for area in (self.left_area, self.centre_area, self.right_area): 599applet = area.get_first_child() 600while applet: 601applet.foreign_toplevel_title(toplevel, title) 602applet = applet.get_next_sibling() 603 604def foreign_toplevel_state(self, toplevel, state): 605for area in (self.left_area, self.centre_area, self.right_area): 606applet = area.get_first_child() 607while applet: 608applet.foreign_toplevel_state(toplevel, state) 609applet = applet.get_next_sibling() 610 611def foreign_toplevel_closed(self, toplevel): 612for area in (self.left_area, self.centre_area, self.right_area): 613applet = area.get_first_child() 614while applet: 615applet.foreign_toplevel_closed(toplevel) 616applet = applet.get_next_sibling() 617 618def foreign_toplevel_refresh(self): 619for area in (self.left_area, self.centre_area, self.right_area): 620applet = area.get_first_child() 621while applet: 622applet.foreign_toplevel_refresh() 623applet = applet.get_next_sibling() 624 625 626def get_all_subclasses(klass: type) -> list[type]: 627subclasses = [] 628for subclass in klass.__subclasses__(): 629subclasses.append(subclass) 630subclasses += get_all_subclasses(subclass) 631 632return subclasses 633 634def load_packages_from_dir(dir_path: Path): 635loaded_modules = [] 636 637for path in dir_path.iterdir(): 638if path.name.startswith("_"): 639continue 640 641if path.is_dir() and (path / "__init__.py").exists(): 642try: 643module_name = path.name 644spec = importlib.util.spec_from_file_location(module_name, path / "__init__.py") 645module = importlib.util.module_from_spec(spec) 646spec.loader.exec_module(module) 647loaded_modules.append(module) 648except Exception as e: 649print(f"Failed to load applet module {path.name}: {e}", file=sys.stderr) 650else: 651continue 652 653return loaded_modules 654 655 656PANEL_POSITIONS = { 657"top": Gtk.PositionType.TOP, 658"bottom": Gtk.PositionType.BOTTOM, 659"left": Gtk.PositionType.LEFT, 660"right": Gtk.PositionType.RIGHT, 661} 662 663 664PANEL_POSITIONS_REVERSE = { 665Gtk.PositionType.TOP: "top", 666Gtk.PositionType.BOTTOM: "bottom", 667Gtk.PositionType.LEFT: "left", 668Gtk.PositionType.RIGHT: "right", 669} 670 671 672class PanoramaPanel(Gtk.Application): 673def __init__(self): 674super().__init__( 675application_id="com.roundabout_host.roundabout.PanoramaPanel", 676flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE 677) 678self.display = Gdk.Display.get_default() 679self.monitors = self.display.get_monitors() 680for monitor in self.monitors: 681monitor.output_proxy = None 682self.applets_by_name: dict[str, panorama_panel.Applet] = {} 683self.panels: list[Panel] = [] 684self.wl_globals = [] 685self.wl_display = None 686self.compositor = None 687self.seat = None 688self.output = None 689self.wl_shm = None 690self.manager = None 691self.screencopy_manager = None 692self.manager_window = None 693self.edit_mode = False 694self.output_proxies = [] 695self.drags = {} 696self.wl_output_ids = set() 697self.started = False 698 699self.add_main_option( 700"trigger", 701ord("t"), 702GLib.OptionFlags.NONE, 703GLib.OptionArg.STRING, 704_("Trigger an action which can be processed by the applets"), 705_("NAME") 706) 707 708def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1, 709handle: ZwlrForeignToplevelHandleV1): 710print("on_new_toplevel") 711for panel in self.panels: 712panel.foreign_toplevel_new(manager, handle) 713handle.dispatcher["title"] = self.on_title_changed 714handle.dispatcher["app_id"] = self.on_app_id_changed 715handle.dispatcher["output_enter"] = self.on_output_enter 716handle.dispatcher["output_leave"] = self.on_output_leave 717handle.dispatcher["state"] = self.on_state_changed 718handle.dispatcher["closed"] = self.on_closed 719 720def on_output_leave(self, handle, output): 721for panel in self.panels: 722panel.foreign_toplevel_output_leave(handle, output) 723 724def on_output_enter(self, handle, output): 725for panel in self.panels: 726panel.foreign_toplevel_output_enter(handle, output) 727 728def on_title_changed(self, handle, title): 729for panel in self.panels: 730panel.foreign_toplevel_title(handle, title) 731 732def on_app_id_changed(self, handle, app_id): 733for panel in self.panels: 734panel.foreign_toplevel_app_id(handle, app_id) 735 736def on_state_changed(self, handle, state): 737for panel in self.panels: 738panel.foreign_toplevel_state(handle, state) 739 740def on_closed(self, handle): 741for panel in self.panels: 742panel.foreign_toplevel_closed(handle) 743 744def receive_output_name(self, output: WlOutput, name: str): 745output.name = name 746 747def idle_proxy_cover(self, output_name): 748for monitor in self.display.get_monitors(): 749wl_output = gtk.gdk_wayland_monitor_get_wl_output(ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(monitor.__gpointer__, None))) 750found = False 751for proxy in self.output_proxies: 752if proxy._ptr == wl_output: 753found = True 754if wl_output and not found: 755print("Create proxy") 756output_proxy = WlOutputProxy(wl_output, self.wl_display) 757output_proxy.interface.registry[output_proxy._ptr] = output_proxy 758self.output_proxies.append(output_proxy) 759 760def on_global(self, registry, name, interface, version): 761if interface == "zwlr_foreign_toplevel_manager_v1": 762print("Interface registered") 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.manager: 783self.manager.destroy() 784for panel in self.panels: 785panel.foreign_toplevel_refresh() 786self.wl_globals.remove(self.manager) 787self.manager = self.registry.bind(self.foreign_toplevel_manager_id, ZwlrForeignToplevelManagerV1, self.foreign_toplevel_version) 788self.manager.id = self.foreign_toplevel_manager_id 789self.wl_globals.append(self.manager) 790self.manager.dispatcher["toplevel"] = self.on_new_toplevel 791self.manager.dispatcher["finished"] = lambda *a: print("Toplevel manager finished") 792self.wl_display.roundtrip() 793self.panels_generated = True 794print(f"Monitor {name} ({interface}) connected", file=sys.stderr) 795elif interface == "wl_seat": 796print("Seat found") 797self.seat = registry.bind(name, WlSeat, version) 798self.seat.id = name 799self.wl_globals.append(self.seat) 800elif interface == "wl_shm": 801self.wl_shm = registry.bind(name, WlShm, version) 802self.wl_shm.id = name 803self.wl_globals.append(self.wl_shm) 804elif interface == "wl_compositor": 805self.compositor = registry.bind(name, WlCompositor, version) 806self.compositor.id = name 807self.wl_globals.append(self.compositor) 808elif interface == "zwlr_screencopy_manager_v1": 809self.screencopy_manager = registry.bind(name, ZwlrScreencopyManagerV1, version) 810self.screencopy_manager.id = name 811self.wl_globals.append(self.screencopy_manager) 812 813def on_global_remove(self, registry, name): 814if name in self.wl_output_ids: 815print(f"Monitor {name} disconnected", file=sys.stderr) 816#self.generate_panels() 817#for g in self.wl_globals: 818# if g.id == name: 819# for panel in self.panels: 820# panel.wl_output_leave(g, g.name) 821# break 822self.wl_output_ids.discard(name) 823for g in self.wl_globals: 824if g.id == name: 825g.destroy() 826self.wl_globals.remove(g) 827break 828 829def generate_panels(self, output_name): 830print("Generating panels...", file=sys.stderr) 831self.exit_all_applets() 832 833for i, monitor in enumerate(self.monitors): 834geometry = monitor.get_geometry() 835print(f"Monitor {i}: {geometry.width}x{geometry.height} at {geometry.x},{geometry.y}") 836 837for panel in self.panels: 838if panel.monitor_name == output_name: 839panel.destroy() 840with open(get_config_file(), "r") as config_file: 841yaml_loader = yaml.YAML(typ="rt") 842yaml_file = yaml_loader.load(config_file) 843for panel_data in yaml_file["panels"]: 844print(f"comparing {panel_data["monitor"]} =? {output_name}") 845if panel_data["monitor"] != output_name: 846continue 847position = PANEL_POSITIONS[panel_data["position"]] 848my_monitor = None 849for monitor in self.display.get_monitors(): 850if monitor.get_connector() == panel_data["monitor"]: 851my_monitor = monitor 852break 853if not my_monitor: 854continue 855GLib.idle_add(self.idle_proxy_cover, my_monitor.get_connector()) 856size = panel_data["size"] 857autohide = panel_data["autohide"] 858hide_time = panel_data["hide_time"] 859can_capture_keyboard = panel_data["can_capture_keyboard"] 860 861panel = Panel(self, my_monitor, position, size, autohide, hide_time, can_capture_keyboard) 862self.panels.append(panel) 863 864print(f"{size}px panel on {position}, autohide is {autohide} ({hide_time}ms)") 865 866for area_name, area in (("left", panel.left_area), ("centre", panel.centre_area), ("right", panel.right_area)): 867applet_list = panel_data["applets"].get(area_name) 868if applet_list is None: 869continue 870 871for applet in applet_list: 872item = list(applet.items())[0] 873AppletClass = self.applets_by_name[item[0]] 874options = item[1] 875applet_widget = AppletClass(orientation=panel.get_orientation(), config=options) 876applet_widget.connect("config-changed", self.save_config) 877applet_widget.set_panel_position(panel.position) 878 879area.append(applet_widget) 880 881panel.present() 882panel.realize() 883panel.monitor_name = my_monitor.get_connector() 884 885def prepare(self): 886self.release() 887 888print("started", file=sys.stderr) 889 890def do_startup(self): 891Gtk.Application.do_startup(self) 892 893ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p 894ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,) 895 896all_applets = list( 897chain.from_iterable(load_packages_from_dir(d) for d in get_applet_directories())) 898print("Applets:") 899subclasses = get_all_subclasses(panorama_panel.Applet) 900for subclass in subclasses: 901if subclass.__name__ in self.applets_by_name: 902print(f"Name conflict for applet {subclass.__name__}. Only one will be loaded.", 903file=sys.stderr) 904self.applets_by_name[subclass.__name__] = subclass 905 906self.wl_display = Display() 907wl_display_ptr = gtk.gdk_wayland_display_get_wl_display( 908ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.display.__gpointer__, None))) 909self.wl_display._ptr = wl_display_ptr 910 911# Intentionally commented: the display is already connected by GTK 912# self.display.connect() # Iterate through monitors and get their Wayland output (wl_output) 913 914self.panels_generated = False 915self.got_output_name = False 916self.foreign_toplevel_manager_id = None 917self.foreign_toplevel_version = None 918 919self.idle_proxy_cover(None) 920self.registry = self.wl_display.get_registry() 921self.registry.dispatcher["global"] = self.on_global 922self.registry.dispatcher["global_remove"] = self.on_global_remove 923self.wl_display.roundtrip() 924 925self.hold() 926 927def do_activate(self): 928Gio.Application.do_activate(self) 929 930def save_config(self, *args): 931print("Saving configuration file") 932with open(get_config_file(), "w") as config_file: 933yaml_writer = yaml.YAML(typ="rt") 934data = {"panels": []} 935for panel in self.panels: 936panel_data = { 937"position": PANEL_POSITIONS_REVERSE[panel.position], 938"monitor": panel.monitor_index, 939"size": panel.size, 940"autohide": panel.autohide, 941"hide_time": panel.hide_time, 942"can_capture_keyboard": panel.can_capture_keyboard, 943"applets": {} 944} 945 946for area_name, area in (("left", panel.left_area), ("centre", panel.centre_area), ("right", panel.right_area)): 947panel_data["applets"][area_name] = [] 948applet = area.get_first_child() 949while applet is not None: 950panel_data["applets"][area_name].append({ 951applet.__class__.__name__: applet.get_config(), 952}) 953 954applet = applet.get_next_sibling() 955 956data["panels"].append(panel_data) 957 958yaml_writer.dump(data, config_file) 959 960def exit_all_applets(self): 961for panel in self.panels: 962for area in (panel.left_area, panel.centre_area, panel.right_area): 963applet = area.get_first_child() 964while applet is not None: 965applet.shutdown(self) 966 967applet = applet.get_next_sibling() 968 969def do_shutdown(self): 970print("Shutting down") 971self.exit_all_applets() 972Gtk.Application.do_shutdown(self) 973 974def do_command_line(self, command_line: Gio.ApplicationCommandLine): 975options = command_line.get_options_dict() 976args = command_line.get_arguments()[1:] 977 978trigger_variant = options.lookup_value("trigger", GLib.VariantType("s")) 979if trigger_variant: 980action_name = trigger_variant.get_string() 981print(app.list_actions()) 982self.activate_action(action_name, None) 983print(f"Triggered {action_name}") 984 985return 0 986 987def set_edit_mode(self, value): 988self.edit_mode = value 989for panel in self.panels: 990panel.set_edit_mode(value) 991 992def reset_manager_window(self, *args): 993self.manager_window = None 994 995 996if __name__ == "__main__": 997app = PanoramaPanel() 998app.run(sys.argv) 999