Kalmat / PyWinCtl

Cross-Platform module to get info on and control windows on screen
Other
179 stars 19 forks source link

getClientFrame incorrect in Gnome #66

Closed roym899 closed 1 year ago

roym899 commented 1 year ago

In Gnome (I'm using Gnome 41 with Ubuntu 20.04) it seems like the standard window dimensions (i.e., what is retrieved with window.topleft, window.height, etc.) don't include the border / titlebar.

Looking at the code getClientFrame subtracts the border / titlebar and hence it cuts off parts of the window as it is right now.

roym899 commented 1 year ago

Also maybe you want to look into using the _NET_FRAME_EXTENTS property from xprop -id {window_id}. Instead of the current way of getting the border size. But at least on my system they agree with each other any way.

Kalmat commented 1 year ago

Hi Leonard! Thank you for your feedback.

This is something that really drove me crazy on GNOME. It doesn't seem to be using _NET_FRAME_EXTENTS as expected, but rather using it as the reserved space AROUND the window.

In my Ubuntu 20.04 installation, these are the results of the various methods I tried (without success, unfortunately):

WINDOW GEOMETRY
X 235 Y 33 WIDTH 786 HEIGHT 533

WINDOW ATTRIBUTES
X 235 Y 33 WIDTH 786 HEIGHT 533 BORDER 0

FRAME EXTENTS
GNOME <GetProperty serial = 20, data = {'sequence_number': 20, 'property_type': 6, 'bytes_after': 0, 'value': (32, array('I', [26, 26, 23, 29]))}, error = None>
OTHERS None

TKINTER
TITLE BAR / BORDERS (37, 0)

As you can see, FRAME_EXTENTS make no sense (as far as I can see, at least).

Apart from this, please let me ask which is your goal in this case. Do you need the exact dimensions of the window minus the title bar and the borders?

Perhaps you can try yourself by running this simple snippet and see what happens.

import pywinctl as pwc
import Xlib.display
import tkinter as tk
from ctypes import cdll, Structure, c_int32, c_ulong, c_uint32, byref, CDLL
from ctypes.util import find_library

display = Xlib.display.Display()

win = pwc.getActiveWindow()
xWin = win._xWin

_xlib = None
_xcomp = None

def _loadX11Library():
    global _xlib
    if _xlib is None:
        lib = -1
        try:
            libPath = find_library('X11')
            if libPath:
                lib = cdll.LoadLibrary(libPath)
        except:
            pass
        _xlib = lib
    return _xlib

class _XWindowAttributes(Structure):
    _fields_ = [('x', c_int32), ('y', c_int32),
                ('width', c_int32), ('height', c_int32), ('border_width', c_int32),
                ('depth', c_int32), ('visual', c_ulong), ('root', c_ulong),
                ('class', c_int32), ('bit_gravity', c_int32),
                ('win_gravity', c_int32), ('backing_store', c_int32),
                ('backing_planes', c_ulong), ('backing_pixel', c_ulong),
                ('save_under', c_int32), ('colourmap', c_ulong),
                ('mapinstalled', c_uint32), ('map_state', c_uint32),
                ('all_event_masks', c_ulong), ('your_event_mask', c_ulong),
                ('do_not_propagate_mask', c_ulong), ('override_redirect', c_int32), ('screen', c_ulong)]

def _XGetAttributes(winId: int, dpyName: str = ""):
    resOK: bool = False
    attr = _XWindowAttributes()

    xlib = _loadX11Library()

    if isinstance(xlib, CDLL):
        try:
            if not dpyName:
                dpyName = display.get_display_name()
            dpy: int = xlib.XOpenDisplay(dpyName.encode())
            xlib.XGetWindowAttributes(dpy, winId, byref(attr))
            xlib.XCloseDisplay(dpy)
            resOK = True
        except:
            pass
    return resOK, attr

def _getBorderSizes():

    # Didn't find a way to get title bar height using Xlib in GNOME
    # ret, a = self.XlibAttributes()  # -> Should return client area, but it doesn't...
    # if res:
    #     res = Rect(a.x, a.y, a.x + a.width, a.y + a.height)
    # else:
    #     res = self.getWindowRect()
    #
    # This works in Cinnamon, but not in GNOME
    # titleHeight = 0
    # extents = self._win.getFrameExtents()
    # if extents and len(extents) >= 4:
    #     titleHeight = extents[2]
    # geom = self._xWin.get_geometry()
    # borderWidth = geom.border_width
    # return titleHeight, borderWidth

    class App(tk.Tk):
        def __init__(self):
            super().__init__()
            self.geometry('0x0+200+200')
            self.update_idletasks()

            pos = self.geometry().split('+')
            self.bar_height = self.winfo_rooty() - int(pos[2])
            self.border_width = self.winfo_rootx() - int(pos[1])
            self.destroy()

        def getTitlebarHeight(self):
            return self.bar_height

        def getBorderWidth(self):
            return self.border_width

    app = App()
    # app.mainloop()
    return app.getTitlebarHeight(), app.getBorderWidth()

print("WINDOW GEOMETRY")
print("X", win.position.x, "Y", win.position.y, "WIDTH", win.size.width, "HEIGHT", win.size.height)
print()

print("WINDOW ATTRIBUTES")
res, attr = _XGetAttributes(win.getHandle(), display.get_display_name())
print("X", attr.x, "Y", attr.y, "WIDTH", attr.width, "HEIGHT", attr.height, "BORDER", attr.border_width)
print()

print("FRAME EXTENTS")
print("GNOME", xWin.get_full_property(display.get_atom("_GTK_FRAME_EXTENTS"), Xlib.X.AnyPropertyType, 10))
print("OTHERS", xWin.get_full_property(display.get_atom("_NET_FRAME_EXTENTS"), Xlib.X.AnyPropertyType, 10))
print()

print("TKINTER")
print("TITLE BAR / BORDERS", _getBorderSizes())

Any idea or help is really appreciated!!!

roym899 commented 1 year ago

Seems like it depends on the window for me. You can try it on different windows with xprop and clicking on the window to test.

In my case I could workaround the issue like this:

import pywinctl._pywinctl_linux as pwcl

title_height, border_width = pwcl._getBorderSizes()
if include_frame:
    x = window.topleft.x - border_width
    y = window.topleft.y - title_height
    width = window.width + 2 * border_width
    height = window.height + title_height + border_width
else:
    x, y = window.topleft.x, window.topleft.y
    width, height = window.width, window.height

This will only work for the standard titlebars, not for the default terminal though.

I think one approach could be to

  1. figure out if a window has a GTK headerbar or a standard narrow titlebar, for example, by checking if _NET_FRAME_EXTENTS is available
  2. compute the window size (with titlebar) depending on titlebar type
    • for standard narrow titlebar: current size is only the window content, need to add border to get consistent behavior
    • for GTK headerbar: maybe subtracting _GTK_FRAME_EXTENTS from geometry given by xwininfo could work?
  3. adjust getClientFrame to take different headerbar / titlebar into account
    • for standard narrow titlebar: current size is only the window content, need to add border to get consistent behavior
    • for GTK headerbar: don't know if it's possible to get this information and if this headerbar is always the same height (?), if it's not possible just return the same as the window size, i.e., subtract nothing
Kalmat commented 1 year ago

Totally right! I erroneously assumed all windows in GNOME were using _GTK_FRAME_EXTENTS, but testing it in some other windows as you suggested (e.g. Firefox), they use _NET_FRAME_EXTENTS instead!!!

So, regarding your approach:

  1. To identify which window is using what is very easy, since the "other" XXX_FRAME_EXTENTS will return None!
  2. It's totally OK for "standard" windows, but not for GTK, since _GTK_FRAME_EXTENTS values are the space AROUND the window reserved by the window manager (GNOME in this case). I was not able to find any other method to get the right values.
  3. For non-standard windows, the tkinter solution doesn't seem to be a general solution, since it's actually defining _NET_FRAME_EXTENTS, so _GTK_FRAME_EXTENTS returns None... Maybe the best solution is, as you propose, to return the window size without subtructing anything.

Just to be sure. In your workaround, include_frame identifies if the window is standard (using _NET_FRAME_EXTENTS) or not (using GTK_FRAME_EXTENTS), right?

Thank you so much for you help!!!

Kalmat commented 1 year ago

Hi! I have prepared a new version that hopefully solves this issue. If you want to give it a try, you can find it here. Anyway, I will upload it to PyPi in a few days, as soon as I finish all required, intensive tests.

Thank you again!

roym899 commented 1 year ago

Great, thanks for fixing it. I'll give it a try.