From b8abcd6e060420eb1933e2aa7ca9caeb9ceeec64 Mon Sep 17 00:00:00 2001 From: Demi Marie Obenour Date: Mon, 25 Nov 2024 18:40:44 -0500 Subject: [PATCH] Create a fullscreen invisible window for mouse input This will hopefully allow menus to be dismissed by clicking elsewhere. --- qui/tray/disk_space.py | 3 + qui/tray/domains.py | 4 + qui/tray/gross_gtk3_bug_workaround.py | 120 ++++++++++++++++++++++++++ qui/tray/updates.py | 3 + 4 files changed, 130 insertions(+) create mode 100644 qui/tray/gross_gtk3_bug_workaround.py diff --git a/qui/tray/disk_space.py b/qui/tray/disk_space.py index 8d8c8094..0efd7c43 100644 --- a/qui/tray/disk_space.py +++ b/qui/tray/disk_space.py @@ -19,6 +19,7 @@ WARN_LEVEL = 0.9 URGENT_WARN_LEVEL = 0.95 +from .gross_gtk3_bug_workaround import DisgustingX11FullscreenWindowHack class VMUsage: def __init__(self, vm): @@ -338,6 +339,7 @@ class DiskSpace(Gtk.Application): def __init__(self, **properties): super().__init__(**properties) + self.disgusting_hack = DisgustingX11FullscreenWindowHack() self.pool_warned = False self.vms_warned = set() @@ -423,6 +425,7 @@ def make_menu(self, _unused, _event): vm_data = VMUsageData(self.qubes_app) menu = Gtk.Menu() + self.disgusting_hack.show_for_widget(self.menu) menu.append(self.make_top_box(pool_data)) diff --git a/qui/tray/domains.py b/qui/tray/domains.py index 1eae1310..32eff95b 100644 --- a/qui/tray/domains.py +++ b/qui/tray/domains.py @@ -28,6 +28,8 @@ t = gettext.translation("desktop-linux-manager", fallback=True) _ = t.gettext +from .gross_gtk3_bug_workaround import DisgustingX11FullscreenWindowHack + STATE_DICTIONARY = { 'domain-pre-start': 'Transient', 'domain-start': 'Running', @@ -537,6 +539,8 @@ def __init__(self, app_name, qapp, dispatcher, stats_dispatcher): _('Qubes Domains\nView and manage running domains.')) self.tray_menu = Gtk.Menu() + self.disgusting_hack = DisgustingX11FullscreenWindowHack() + self.disgusting_hack.show_for_widget(self.tray_menu) self.icon_cache = IconCache() diff --git a/qui/tray/gross_gtk3_bug_workaround.py b/qui/tray/gross_gtk3_bug_workaround.py new file mode 100644 index 00000000..6cf2b164 --- /dev/null +++ b/qui/tray/gross_gtk3_bug_workaround.py @@ -0,0 +1,120 @@ +#!/usr/bin/python3 -- +from collections import deque +import gi +from os import abort +import select +import sys +import time +import traceback +from typing import List +import xcffib +import xcffib.xproto +gi.require_version("GLib", "2.0") +from gi.repository import GLib, GObject + +class DisgustingX11FullscreenWindowHack(object): + """ + GTK3 menus have a horrible bug under Xwayland: if the user clicks on a native + Wayland surface, the menu is not dismissed. This class works around the problem + by using evil X11 hacks, such as a fullscreen input-only window. + """ + def __init__(self) -> None: + self.keep_going = False + self.runtime_cb = None + conn = self.conn = xcffib.connect() + + setup = conn.get_setup() + if setup.roots_len != 1: + raise RuntimeError(f"X server has {setup.roots_len} screens, this is not supported") + screen, = setup.roots + # This is not guaranteed to work, but assume it will. + depth_32, = (depth for depth in screen.allowed_depths if depth.depth == 32) + self.window_id = p = conn.generate_id() + proto = self.proto = xcffib.xproto.xprotoExtension(conn) + assert screen.width_in_pixels > 0 + assert screen.height_in_pixels > 0 + fullscreen = "_NET_WM_STATE_FULLSCREEN" + wm_state_fullscreen_cookie = proto.InternAtom(only_if_exists=False, + name_len=len(fullscreen), + name=fullscreen, + is_checked=True) + cookie1 = proto.CreateWindow(depth=0, + parent=screen.root, + x=0, + y=0, + wid=p, + width=screen.width_in_pixels, + height=screen.height_in_pixels, + border_width=0, + _class=xcffib.xproto.WindowClass.InputOnly, + visual=depth_32.visuals[0].visual_id, + value_mask=xcffib.xproto.CW.OverrideRedirect|xcffib.xproto.CW.EventMask, + value_list=[0,xcffib.xproto.EventMask.ButtonPress], + is_checked=True) + wm_state_fullscreen = wm_state_fullscreen_cookie.reply().atom + cookie2 = proto.ChangeProperty(mode=xcffib.xproto.PropMode.Replace, + window=p, + property=wm_state_fullscreen, + type=xcffib.xproto.Atom.ATOM, + format=32, + data_len=1, + data=[wm_state_fullscreen], + is_checked=True) + self.cookies = deque([cookie1, cookie2]) + source = GLib.unix_fd_source_new(conn.get_file_descriptor(), + GLib.IOCondition(GLib.IO_IN|GLib.IO_HUP|GLib.IO_PRI)) + GObject.source_set_closure(source, self.source_callback) + main_loop = GLib.main_context_ref_thread_default() + assert main_loop is not None + source_id = source.attach(main_loop) + self.cb() + def show_for_widget(self, widget) -> None: + widget.connect("unmap-event", lambda _unused: self.hide()) + widget.connect("map", lambda _unused: self.show(widget.hide)) + def show(self, cb) -> None: + if self.keep_going: + return + self.keep_going = True + self.cookies.appendleft(self.proto.MapWindow(self.window_id, is_checked=True)) + self.runtime_cb = cb + self.cb() + def unmap(self) -> None: + if self.keep_going: + self.cookies.appendleft(self.proto.UnmapWindow(self.window_id, is_checked=True)) + self.keep_going = False + def cb(self) -> None: + self.conn.flush() + while True: + ev = self.conn.poll_for_event() + if ev is None: + return + if self.cookies and self.cookies[-1].sequence == ev.sequence: + self.cookies.pop().check() + if isinstance(ev, xcffib.xproto.ButtonPressEvent): + if ev.event == self.window_id: + self.runtime_cb() + self.keep_going = False + def source_callback(self, fd, flags) -> int: + try: + assert fd == self.conn.get_file_descriptor() + try: + self.cb() + except xcffib.ConnectionException: + self.keep_going = False + return GLib.SOURCE_REMOVE + if flags & GLib.IO_HUP: + self.keep_going = False + return GLib.SOURCE_REMOVE + return GLib.SOURCE_CONTINUE + except BaseException: + try: + traceback.print_exc() + finally: + abort() + +if __name__ == '__main__': + a = DisgustingX11FullscreenWindowHack() + main_loop = GLib.main_context_ref_thread_default() + a.show(lambda *args, **kwargs: None) + while a.keep_going: + main_loop.iteration() diff --git a/qui/tray/updates.py b/qui/tray/updates.py index 8533dea3..045a3250 100644 --- a/qui/tray/updates.py +++ b/qui/tray/updates.py @@ -23,6 +23,7 @@ t = gettext.translation("desktop-linux-manager", fallback=True) _ = t.gettext +from .gross_gtk3_bug_workaround import DisgustingX11FullscreenWindowHack class TextItem(Gtk.MenuItem): def __init__(self, text): @@ -59,6 +60,7 @@ def __init__(self, app_name, qapp, dispatcher): super().__init__() self.name = app_name + self.disgusting_hack = DisgustingX11FullscreenWindowHack() self.dispatcher = dispatcher self.qapp = qapp @@ -76,6 +78,7 @@ def __init__(self, app_name, qapp, dispatcher): self.obsolete_vms = set() self.tray_menu = Gtk.Menu() + self.disgusting_hack.show_for_widget(self.tray_menu) def run(self): # pylint: disable=arguments-differ self.check_vms_needing_update()