From 8d2ebd7b516306f23da9d958bff4de83a61ccbad Mon Sep 17 00:00:00 2001 From: Demi Marie Obenour Date: Sat, 30 Nov 2024 18:14:53 -0500 Subject: [PATCH] Wayland support via wlr-layer-shell This adds Wayland support on compositors that support the wlr-layer-shell protocol, which includes KWin, Sway, COSMIC, niri, Mir, GameScope, and Jay. The only major compositors without support for wlr-layer-shell are Mutter, which is generally only used by GNOME, and Weston, which is not a general-purpose desktop compositor. --- .gitlab-ci.yml | 4 +- debian/control | 3 +- qubes_menu/appmenu.py | 115 +++++++++++++++++----- rpm_spec/qubes-desktop-linux-menu.spec.in | 1 + 4 files changed, 95 insertions(+), 28 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index de776e5..2767048 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -10,7 +10,7 @@ checks:pylint: stage: checks before_script: - sudo dnf install -y python3-gobject gtk3 xorg-x11-server-Xvfb - python3-pip python3-mypy + python3-pip python3-mypy gtk-layer-shell - pip3 install --quiet -r ci/requirements.txt - git clone https://github.com/QubesOS/qubes-core-admin-client ~/core-admin-client script: @@ -25,7 +25,7 @@ checks:tests: - "PATH=$PATH:$HOME/.local/bin" - sudo dnf install -y python3-gobject gtk3 python3-pytest python3-pytest-asyncio python3-coverage xorg-x11-server-Xvfb python3-inotify sequoia-sqv - python3-pip + python3-pip gtk-layer-shell - pip3 install --quiet -r ci/requirements.txt - git clone https://github.com/QubesOS/qubes-core-admin-client ~/core-admin-client - git clone https://github.com/QubesOS/qubes-desktop-linux-manager ~/desktop-linux-manager diff --git a/debian/control b/debian/control index b0af4e8..64c12c0 100644 --- a/debian/control +++ b/debian/control @@ -13,7 +13,8 @@ Build-Depends: qubes-desktop-linux-manager, python3-gi, gobject-introspection, - gir1.2-gtk-3.0 + gir1.2-gtk-3.0, + gir1.2-gtklayershell-0.1, Standards-Version: 3.9.5 Homepage: https://www.qubes-os.org/ X-Python3-Version: >= 3.5 diff --git a/qubes_menu/appmenu.py b/qubes_menu/appmenu.py index df1b36e..f7102ca 100644 --- a/qubes_menu/appmenu.py +++ b/qubes_menu/appmenu.py @@ -28,7 +28,8 @@ import gi gi.require_version('Gtk', '3.0') -from gi.repository import Gtk, Gdk, GLib, Gio +gi.require_version('GtkLayerShell', '0.1') +from gi.repository import Gtk, Gdk, GLib, Gio, GtkLayerShell import gbulb gbulb.install() @@ -93,10 +94,12 @@ def __init__(self, qapp, dispatcher): self.initial_page = "app_page" self.sort_running = False self.start_in_background = False + self.kde = "KDE" in os.getenv("XDG_CURRENT_DESKTOP", "").split(":") self._add_cli_options() self.builder: Optional[Gtk.Builder] = None + self.layer_shell: bool = False self.main_window: Optional[Gtk.Window] = None self.main_notebook: Optional[Gtk.Notebook] = None @@ -168,16 +171,13 @@ def parse_options(self, options: Dict[str, Any]): if "background" in options: self.start_in_background = True - @staticmethod - def _do_power_button(_widget): + def _do_power_button(self, _widget): """ Run xfce4's default logout button. Possible enhancement would be providing our own tiny program. """ # pylint: disable=consider-using-with - current_environs = os.environ.get('XDG_CURRENT_DESKTOP', '').split(':') - - if 'KDE' in current_environs: + if self.kde: dbus = Gio.bus_get_sync(Gio.BusType.SESSION, None) proxy = Gio.DBusProxy.new_sync( dbus, # dbus @@ -203,21 +203,67 @@ def reposition(self): assert self.main_window match self.appmenu_position: case 'top-left': - self.main_window.move(0, 0) + if self.layer_shell: + GtkLayerShell.set_anchor(self.main_window, + GtkLayerShell.Edge.LEFT, True) + GtkLayerShell.set_anchor(self.main_window, + GtkLayerShell.Edge.TOP, True) + else: + self.main_window.move(0, 0) case 'top-right': - self.main_window.move( - self.main_window.get_screen().get_width() - \ - self.main_window.get_size().width, 0) + if self.layer_shell: + GtkLayerShell.set_anchor(self.main_window, + GtkLayerShell.Edge.RIGHT, True) + GtkLayerShell.set_anchor(self.main_window, + GtkLayerShell.Edge.TOP, True) + else: + self.main_window.move( + self.main_window.get_screen().get_width() - + self.main_window.get_size().width, 0) case 'bottom-left': - self.main_window.move(0, - self.main_window.get_screen().get_height() - \ - self.main_window.get_size().height) + if self.layer_shell: + GtkLayerShell.set_anchor(self.main_window, + GtkLayerShell.Edge.LEFT, True) + GtkLayerShell.set_anchor(self.main_window, + GtkLayerShell.Edge.BOTTOM, True) + else: + self.main_window.move(0, + self.main_window.get_screen().get_height() - + self.main_window.get_size().height) case 'bottom-right': - self.main_window.move( - self.main_window.get_screen().get_width() - \ - self.main_window.get_size().width, - self.main_window.get_screen().get_height() - \ - self.main_window.get_size().height) + if self.layer_shell: + GtkLayerShell.set_anchor(self.main_window, + GtkLayerShell.Edge.RIGHT, True) + GtkLayerShell.set_anchor(self.main_window, + GtkLayerShell.Edge.BOTTOM, True) + else: + self.main_window.move( + self.main_window.get_screen().get_width() - + self.main_window.get_size().width, + self.main_window.get_screen().get_height() - + self.main_window.get_size().height) + + def __present(self) -> None: + assert self.main_window is not None + self.reposition() + self.main_window.present() + if not self.layer_shell: + return + # Under Wayland, the window size must be re-requested + # every time the window is shown. + current_width = self.main_window.get_allocated_width() + current_height = self.main_window.get_allocated_height() + # set size if too big + max_height = int(self.main_window.get_screen().get_height() * 0.9) + assert max_height > 0 + # The default for layer shell is no keyboard input. + # Explicitly request exclusive access to the keyboard. + GtkLayerShell.set_keyboard_mode(self.main_window, + GtkLayerShell.KeyboardMode.EXCLUSIVE) + # Work around https://github.com/wmww/gtk-layer-shell/issues/167 + # by explicitly setting the window size. + self.main_window.set_size_request(current_width, + min(current_height, max_height)) def do_activate(self, *args, **kwargs): """ @@ -234,12 +280,24 @@ def do_activate(self, *args, **kwargs): self.reposition() self.main_window.show_all() self.initialize_state() - # set size if too big + current_width = self.main_window.get_allocated_width() current_height = self.main_window.get_allocated_height() - max_height = self.main_window.get_screen().get_height() * 0.9 - if current_height > max_height: - self.main_window.resize(self.main_window.get_allocated_width(), - int(max_height)) + # set size if too big + max_height = int(self.main_window.get_screen().get_height() * 0.9) + assert max_height > 0 + if self.layer_shell: + if not self.start_in_background: + # The default for layer shell is no keyboard input. + # Explicitly request exclusive access to the keyboard. + GtkLayerShell.set_keyboard_mode(self.main_window, + GtkLayerShell.KeyboardMode.EXCLUSIVE) + # Work around https://github.com/wmww/gtk-layer-shell/issues/167 + # by explicitly setting the window size. + self.main_window.set_size_request( + current_width, + min(current_height, max_height)) + elif current_height > max_height: + self.main_window.resize(current_height, max_height) # grab a focus on the initially selected page so that keyboard # navigation works @@ -261,8 +319,7 @@ def do_activate(self, *args, **kwargs): if self.main_window.is_visible() and not self.keep_visible: self.main_window.hide() else: - self.reposition() - self.main_window.present() + self.__present() def hide_menu(self): """ @@ -331,6 +388,7 @@ def perform_setup(self): self.builder.add_from_file(str(path)) self.main_window = self.builder.get_object('main_window') + self.layer_shell = GtkLayerShell.is_supported() self.main_notebook = self.builder.get_object('main_notebook') self.main_window.set_events(Gdk.EventMask.FOCUS_CHANGE_MASK) @@ -375,6 +433,10 @@ def perform_setup(self): 'domain-feature-delete:' + feature, self._update_settings) + if self.layer_shell: + GtkLayerShell.init_for_window(self.main_window) + GtkLayerShell.set_exclusive_zone(self.main_window, 0) + def load_style(self, *_args): """Load appropriate CSS stylesheet and associated properties.""" light_ref = (importlib.resources.files('qubes_menu') / @@ -415,6 +477,9 @@ def load_settings(self): position = local_vm.features.get(POSITION_FEATURE, "mouse") if position not in POSITION_LIST: position = "mouse" + if position == "mouse" and self.layer_shell: + # "mouse" unsupported under Wayland + position = "bottom-left" if self.kde else "top-left" self.appmenu_position = position for handler in self.handlers.values(): diff --git a/rpm_spec/qubes-desktop-linux-menu.spec.in b/rpm_spec/qubes-desktop-linux-menu.spec.in index a5cbe15..916f486 100644 --- a/rpm_spec/qubes-desktop-linux-menu.spec.in +++ b/rpm_spec/qubes-desktop-linux-menu.spec.in @@ -49,6 +49,7 @@ BuildRequires: gettext Requires: python%{python3_pkgversion}-setuptools Requires: python%{python3_pkgversion}-gbulb Requires: gtk3 +Requires: gtk-layer-shell Requires: python%{python3_pkgversion}-qubesadmin >= 4.1.8 Requires: qubes-artwork >= 4.1.5 Requires: qubes-desktop-linux-manager