| 
 | 1 | +import os  | 
 | 2 | +import sys  | 
 | 3 | +from typing import Optional  | 
 | 4 | + | 
 | 5 | +# If gi.override.Gdk has been imported, the GDK  | 
 | 6 | +# backend has already been set and it is too late  | 
 | 7 | +# to override it.  | 
 | 8 | +assert (  | 
 | 9 | +    "gi.override.Gdk" not in sys.modules  | 
 | 10 | +), "must import this module before loading GDK"  | 
 | 11 | + | 
 | 12 | +# Modifying the environment while multiple threads  | 
 | 13 | +# are running leads to use-after-free in glibc, so  | 
 | 14 | +# ensure that only one thread is running.  | 
 | 15 | +assert (  | 
 | 16 | +    len(os.listdir("/proc/self/task")) == 1  | 
 | 17 | +), "multiple threads already running"  | 
 | 18 | + | 
 | 19 | +# Only the X11 backend is supported  | 
 | 20 | +os.environ["GDK_BACKEND"] = "x11"  | 
 | 21 | + | 
 | 22 | +import gi  | 
 | 23 | + | 
 | 24 | +gi.require_version("Gdk", "3.0")  | 
 | 25 | +gi.require_version("Gtk", "3.0")  | 
 | 26 | +from gi.repository import Gtk, Gdk  | 
 | 27 | + | 
 | 28 | + | 
 | 29 | +is_xwayland = "WAYLAND_DISPLAY" in os.environ  | 
 | 30 | + | 
 | 31 | + | 
 | 32 | +class X11FullscreenWindowHack:  | 
 | 33 | +    """  | 
 | 34 | +    No-op implementation of the hack, for use on stock X11.  | 
 | 35 | +    """  | 
 | 36 | + | 
 | 37 | +    def clear_widget(self, /) -> None:  | 
 | 38 | +        pass  | 
 | 39 | + | 
 | 40 | +    def show_for_widget(self, _widget: Gtk.Widget, /) -> None:  | 
 | 41 | +        pass  | 
 | 42 | + | 
 | 43 | + | 
 | 44 | +class X11FullscreenWindowHackXWayland(X11FullscreenWindowHack):  | 
 | 45 | +    """  | 
 | 46 | +    GTK3 menus have a horrible bug under Xwayland: if the user clicks on a  | 
 | 47 | +    native Wayland surface, the menu is not dismissed.  This class works around  | 
 | 48 | +    the problem by using evil X11 hacks, such as a fullscreen override-redirect  | 
 | 49 | +    window that is made transparent.  | 
 | 50 | +    """  | 
 | 51 | + | 
 | 52 | +    _window: Gtk.Window  | 
 | 53 | +    _widget: Optional[Gtk.Widget]  | 
 | 54 | +    _unmap_signal_id: int  | 
 | 55 | +    _map_signal_id: int  | 
 | 56 | + | 
 | 57 | +    def __init__(self) -> None:  | 
 | 58 | +        self._widget = None  | 
 | 59 | +        # Get the default GDK screen.  | 
 | 60 | +        screen = Gdk.Screen.get_default()  | 
 | 61 | +        # This is deprecated, but it gets the total width and height  | 
 | 62 | +        # of all screens, which is what we want.  It will go away in  | 
 | 63 | +        # GTK4, but this code will never be ported to GTK4.  | 
 | 64 | +        width = screen.get_width()  | 
 | 65 | +        height = screen.get_height()  | 
 | 66 | +        # Create a window that will fill the screen.  | 
 | 67 | +        window = self._window = Gtk.Window()  | 
 | 68 | +        # Move that window to the top left.  | 
 | 69 | +        # pylint: disable=no-member  | 
 | 70 | +        window.move(0, 0)  | 
 | 71 | +        # Make the window fill the whole screen.  | 
 | 72 | +        # pylint: disable=no-member  | 
 | 73 | +        window.resize(width, height)  | 
 | 74 | +        # Request that the window not be decorated by the window manager.  | 
 | 75 | +        window.set_decorated(False)  | 
 | 76 | +        # Connect a signal so that the window and menu can be  | 
 | 77 | +        # unmapped (no longer shown on screen) once clicked.  | 
 | 78 | +        window.connect("button-press-event", self.on_button_press)  | 
 | 79 | +        # When the window is created, mark it as override-redirect  | 
 | 80 | +        # (invisible to the window manager) and transparent.  | 
 | 81 | +        window.connect("realize", self._on_realize)  | 
 | 82 | +        self._unmap_signal_id = self._map_signal_id = 0  | 
 | 83 | + | 
 | 84 | +    def clear_widget(self, /) -> None:  | 
 | 85 | +        """  | 
 | 86 | +        Clears the connected widget.  Automatically called by  | 
 | 87 | +        show_for_widget().  | 
 | 88 | +        """  | 
 | 89 | +        widget = self._widget  | 
 | 90 | +        map_signal_id = self._map_signal_id  | 
 | 91 | +        unmap_signal_id = self._unmap_signal_id  | 
 | 92 | + | 
 | 93 | +        # Double-disconnect is C-level undefined behavior, so ensure  | 
 | 94 | +        # it cannot happen.  It is better to leak memory if an exception  | 
 | 95 | +        # is thrown here.  GObject.disconnect_by_func() is buggy  | 
 | 96 | +        # (https://gitlab.gnome.org/GNOME/pygobject/-/issues/106),  | 
 | 97 | +        # so avoid it.  | 
 | 98 | +        if widget is not None:  | 
 | 99 | +            if map_signal_id != 0:  | 
 | 100 | +                # Clear the signal ID to avoid double-disconnect  | 
 | 101 | +                # if this method is interrupted and then called again.  | 
 | 102 | +                self._map_signal_id = 0  | 
 | 103 | +                widget.disconnect(map_signal_id)  | 
 | 104 | +            if unmap_signal_id != 0:  | 
 | 105 | +                # Clear the signal ID to avoid double-disconnect  | 
 | 106 | +                # if this method is interrupted and then called again.  | 
 | 107 | +                self._unmap_signal_id = 0  | 
 | 108 | +                widget.disconnect(unmap_signal_id)  | 
 | 109 | +        self._widget = None  | 
 | 110 | + | 
 | 111 | +    def show_for_widget(self, widget: Gtk.Widget, /) -> None:  | 
 | 112 | +        # Clear any existing connections.  | 
 | 113 | +        self.clear_widget()  | 
 | 114 | +        # Store the new widget.  | 
 | 115 | +        self._widget = widget  | 
 | 116 | +        # Connect map and unmap signals.  | 
 | 117 | +        self._unmap_signal_id = widget.connect("unmap", self._hide)  | 
 | 118 | +        self._map_signal_id = widget.connect("map", self._show)  | 
 | 119 | + | 
 | 120 | +    @staticmethod  | 
 | 121 | +    def _on_realize(window: Gtk.Window, /) -> None:  | 
 | 122 | +        window.set_opacity(0)  | 
 | 123 | +        window.get_window().set_override_redirect(True)  | 
 | 124 | + | 
 | 125 | +    def _show(self, widget: Gtk.Widget, /) -> None:  | 
 | 126 | +        assert widget is self._widget, "signal not properly disconnected"  | 
 | 127 | +        # pylint: disable=no-member  | 
 | 128 | +        self._window.show_all()  | 
 | 129 | + | 
 | 130 | +    def _hide(self, widget: Gtk.Widget, /) -> None:  | 
 | 131 | +        assert widget is self._widget, "signal not properly disconnected"  | 
 | 132 | +        self._window.hide()  | 
 | 133 | + | 
 | 134 | +    # pylint: disable=line-too-long  | 
 | 135 | +    def on_button_press(  | 
 | 136 | +        self, window: Gtk.Window, _event: Gdk.EventButton, /  | 
 | 137 | +    ) -> None:  | 
 | 138 | +        # Hide the window and the widget.  | 
 | 139 | +        window.hide()  | 
 | 140 | +        self._widget.hide()  | 
 | 141 | + | 
 | 142 | + | 
 | 143 | +def get_fullscreen_window_hack() -> X11FullscreenWindowHack:  | 
 | 144 | +    if is_xwayland:  | 
 | 145 | +        return X11FullscreenWindowHackXWayland()  | 
 | 146 | +    return X11FullscreenWindowHack()  | 
0 commit comments