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 687self.all_handles: set[ZwlrForeignToplevelHandleV1] = set() 688self.obsolete_handles: set[ZwlrForeignToplevelHandleV1] = set() 689 690self.add_main_option( 691"trigger", 692ord("t"), 693GLib.OptionFlags.NONE, 694GLib.OptionArg.STRING, 695_("Trigger an action which can be processed by the applets"), 696_("NAME") 697) 698 699def destroy_foreign_toplevel_manager(self): 700self.foreign_toplevel_manager.destroy() 701self.wl_globals.remove(self.foreign_toplevel_manager) 702self.foreign_toplevel_manager = None 703self.foreign_toplevel_refresh() 704 705def on_foreign_toplevel_manager_finish(self, manager: ZwlrForeignToplevelManagerV1): 706if self.foreign_toplevel_manager: 707self.destroy_foreign_toplevel_manager() 708 709def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1, 710handle: ZwlrForeignToplevelHandleV1): 711self.all_handles.add(handle) 712 713for panel in self.panels: 714panel.foreign_toplevel_new(manager, handle) 715handle.dispatcher["title"] = self.on_title_changed 716handle.dispatcher["app_id"] = self.on_app_id_changed 717handle.dispatcher["output_enter"] = self.on_output_enter 718handle.dispatcher["output_leave"] = self.on_output_leave 719handle.dispatcher["state"] = self.on_state_changed 720handle.dispatcher["closed"] = self.on_closed 721 722def on_output_enter(self, handle, output): 723if handle in self.obsolete_handles: 724return 725for panel in self.panels: 726panel.foreign_toplevel_output_enter(handle, output) 727 728def on_output_leave(self, handle, output): 729if handle in self.obsolete_handles: 730return 731for panel in self.panels: 732panel.foreign_toplevel_output_leave(handle, output) 733 734def on_title_changed(self, handle, title): 735if handle in self.obsolete_handles: 736return 737for panel in self.panels: 738panel.foreign_toplevel_title(handle, title) 739 740def on_app_id_changed(self, handle, app_id): 741if handle in self.obsolete_handles: 742return 743for panel in self.panels: 744panel.foreign_toplevel_app_id(handle, app_id) 745 746def on_state_changed(self, handle, state): 747if handle in self.obsolete_handles: 748return 749for panel in self.panels: 750panel.foreign_toplevel_state(handle, state) 751 752def on_closed(self, handle): 753if handle in self.obsolete_handles: 754return 755for panel in self.panels: 756panel.foreign_toplevel_closed(handle) 757 758def foreign_toplevel_refresh(self): 759self.obsolete_handles = self.all_handles.copy() 760 761for panel in self.panels: 762panel.foreign_toplevel_refresh() 763 764def receive_output_name(self, output: WlOutput, name: str): 765output.name = name 766 767def idle_proxy_cover(self, output_name): 768for monitor in self.display.get_monitors(): 769wl_output = gtk.gdk_wayland_monitor_get_wl_output(ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(monitor.__gpointer__, None))) 770found = False 771for proxy in self.output_proxies: 772if proxy._ptr == wl_output: 773found = True 774if wl_output and not found: 775print("Create proxy") 776output_proxy = WlOutputProxy(wl_output, self.wl_display) 777output_proxy.interface.registry[output_proxy._ptr] = output_proxy 778self.output_proxies.append(output_proxy) 779 780def on_global(self, registry, name, interface, version): 781if interface == "zwlr_foreign_toplevel_manager_v1": 782self.foreign_toplevel_manager_id = name 783self.foreign_toplevel_version = version 784elif interface == "wl_output": 785output = registry.bind(name, WlOutput, version) 786output.id = name 787self.wl_globals.append(output) 788print(f"global {name} {interface} {version}") 789output.global_name = name 790output.name = None 791output.dispatcher["name"] = self.receive_output_name 792while not output.name or not self.foreign_toplevel_version: 793self.wl_display.dispatch(block=True) 794print(output.name) 795self.wl_output_ids.add(output.global_name) 796if not output.name.startswith("live-preview"): 797self.generate_panels(output.name) 798for panel in self.panels: 799panel.wl_output_enter(output, output.name) 800if not output.name.startswith("live-preview"): 801if self.foreign_toplevel_manager: 802self.foreign_toplevel_manager.stop() 803self.wl_display.roundtrip() 804if self.foreign_toplevel_manager: 805self.destroy_foreign_toplevel_manager() 806self.foreign_toplevel_manager = self.registry.bind(self.foreign_toplevel_manager_id, ZwlrForeignToplevelManagerV1, self.foreign_toplevel_version) 807self.foreign_toplevel_manager.id = self.foreign_toplevel_manager_id 808self.wl_globals.append(self.foreign_toplevel_manager) 809self.foreign_toplevel_manager.dispatcher["toplevel"] = self.on_new_toplevel 810self.foreign_toplevel_manager.dispatcher["finished"] = self.on_foreign_toplevel_manager_finish 811self.wl_display.roundtrip() 812print(f"Monitor {output.name} ({interface}) connected", file=sys.stderr) 813elif interface == "wl_seat": 814self.wl_seat = registry.bind(name, WlSeat, version) 815self.wl_seat.id = name 816self.wl_globals.append(self.wl_seat) 817elif interface == "wl_shm": 818self.wl_shm = registry.bind(name, WlShm, version) 819self.wl_shm.id = name 820self.wl_globals.append(self.wl_shm) 821elif interface == "zwlr_screencopy_manager_v1": 822self.screencopy_manager = registry.bind(name, ZwlrScreencopyManagerV1, version) 823self.screencopy_manager.id = name 824self.wl_globals.append(self.screencopy_manager) 825 826def on_global_remove(self, registry, name): 827if name in self.wl_output_ids: 828print(f"Monitor {name} disconnected", file=sys.stderr) 829self.wl_output_ids.discard(name) 830for g in self.wl_globals: 831if g.id == name: 832g.destroy() 833self.wl_globals.remove(g) 834break 835 836def generate_panels(self, output_name): 837print("Generating panels...", file=sys.stderr) 838self.exit_all_applets() 839 840for i, monitor in enumerate(self.monitors): 841geometry = monitor.get_geometry() 842print(f"Monitor {i}: {geometry.width}x{geometry.height} at {geometry.x},{geometry.y}") 843 844for panel in self.panels: 845if panel.monitor_name == output_name: 846panel.destroy() 847with open(get_config_file(), "r") as config_file: 848yaml_loader = yaml.YAML(typ="rt") 849yaml_file = yaml_loader.load(config_file) 850for panel_data in yaml_file["panels"]: 851if panel_data["monitor"] != output_name: 852continue 853position = PANEL_POSITIONS[panel_data["position"]] 854my_monitor = None 855for monitor in self.display.get_monitors(): 856if monitor.get_connector() == panel_data["monitor"]: 857my_monitor = monitor 858break 859if not my_monitor: 860continue 861GLib.idle_add(self.idle_proxy_cover, my_monitor.get_connector()) 862size = panel_data["size"] 863autohide = panel_data["autohide"] 864hide_time = panel_data["hide_time"] 865can_capture_keyboard = panel_data["can_capture_keyboard"] 866 867panel = Panel(self, my_monitor, position, size, autohide, hide_time, can_capture_keyboard) 868self.panels.append(panel) 869 870print(f"{size}px panel on {position}, autohide is {autohide} ({hide_time}ms)") 871 872for area_name, area in (("left", panel.left_area), ("centre", panel.centre_area), ("right", panel.right_area)): 873applet_list = panel_data["applets"].get(area_name) 874if applet_list is None: 875continue 876 877for applet in applet_list: 878item = list(applet.items())[0] 879AppletClass = self.applets_by_name[item[0]] 880options = item[1] 881applet_widget = AppletClass(orientation=panel.get_orientation(), config=options) 882applet_widget.connect("config-changed", self.save_config) 883applet_widget.set_panel_position(panel.position) 884 885area.append(applet_widget) 886 887panel.present() 888panel.realize() 889panel.monitor_name = my_monitor.get_connector() 890 891def do_startup(self): 892Gtk.Application.do_startup(self) 893 894ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p 895ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,) 896 897all_applets = list( 898chain.from_iterable(load_packages_from_dir(d) for d in get_applet_directories())) 899print("Applets:") 900subclasses = get_all_subclasses(panorama_panel.Applet) 901for subclass in subclasses: 902if subclass.__name__ in self.applets_by_name: 903print(f"Name conflict for applet {subclass.__name__}. Only one will be loaded.", 904file=sys.stderr) 905self.applets_by_name[subclass.__name__] = subclass 906 907self.wl_display = Display() 908wl_display_ptr = gtk.gdk_wayland_display_get_wl_display( 909ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.display.__gpointer__, None))) 910self.wl_display._ptr = wl_display_ptr 911 912self.foreign_toplevel_manager_id = None 913self.foreign_toplevel_version = None 914 915self.idle_proxy_cover(None) 916self.registry = self.wl_display.get_registry() 917self.registry.dispatcher["global"] = self.on_global 918self.registry.dispatcher["global_remove"] = self.on_global_remove 919self.wl_display.roundtrip() 920if self.foreign_toplevel_version: 921print("Foreign Toplevel Interface: Found") 922else: 923print("Foreign Toplevel Interface: Not Found") 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