From 50cbcb1ab9f7c6622dd44dbcb6cd1878465834fa Mon Sep 17 00:00:00 2001 From: elParaguayo Date: Sun, 11 Dec 2022 14:03:04 +0000 Subject: [PATCH] x11: Add z-layer stacking This commit implements z layer stacking for the x11 backend. See the discussion at https://github.com/qtile/qtile/pull/3409 for more details about the commit. Window stacking is implemented by providing windows with two key functions: `get_layering_information` and `change_layer`. `get_layering_information` returns details about which layer the window should be placed. This is based on the WM spec (https://specifications.freedesktop.org/wm-spec/1.3/ar01s07.html#STACKINGORDER) but with some additions for qtile (e.g. scratchpads are placed at the top). `change_layer` is the method that places the window based on these rules. Windows can be moved up and down the stack and pinned to certain layers. Floating windows should be placed above tiled windows by default. There will undoubtedly be some bugs but we should merge this for now so we can get it out into hands of more users. --- CHANGELOG | 6 + docs/manual/config/index.rst | 3 + docs/manual/config/lazy.rst | 17 ++ libqtile/backend/base.py | 57 +++- libqtile/backend/x11/core.py | 53 +++- libqtile/backend/x11/window.py | 428 +++++++++++++++++++++++++-- libqtile/backend/x11/xcbq.py | 36 --- libqtile/bar.py | 8 +- libqtile/confreader.py | 1 + libqtile/core/manager.py | 4 +- libqtile/group.py | 2 + libqtile/layout/max.py | 10 +- libqtile/resources/default_config.py | 1 + test/backend/x11/test_window.py | 215 ++++++++++++++ test/backend/x11/test_xcore.py | 1 + test/layouts/test_max.py | 96 +++++- 16 files changed, 854 insertions(+), 84 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 0ddf69ed7e..bec91514f5 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -53,6 +53,12 @@ Qtile x.xx.x, released XXXX-XX-XX: - Add ability to set custom "Undefined" status key value to `Mpd2Widget`. - `Mpd2Widget` now searches for artist name in all similar keys (i.e `albumartist`, `performer`, etc.). - Add svg support to `CustomLayoutIcon` + - added layering controls for X11 (Wayland support coming soon!): + - `lazy.window.keep_above()/keep_below()` marks windows to be kept above/below other windows permanently. + Calling the functions with no arguments toggles the state, otherwise pass `enable=True` or `enable=False`. + - `lazy.window.move_up()/move_down()` moves windows up and down the z axis. + - added `only_focused` setting to Max layout, allowing to draw multiple clients on top of each other when + set to False * bugfixes - Fix bug where Window.center() centers window on the wrong screen when using multiple monitors. - Fix `Notify` bug when apps close notifications. diff --git a/docs/manual/config/index.rst b/docs/manual/config/index.rst index 9f530eb171..910815ed29 100644 --- a/docs/manual/config/index.rst +++ b/docs/manual/config/index.rst @@ -106,6 +106,9 @@ configuration variables that control specific aspects of Qtile's behavior: custom floating rules among other things if you wish. See the configuration file for the default `float_rules`. + * - ``floats_kept_above`` + - ``True`` + - Floating windows are kept above tiled windows (Currently x11 only. Wayland support coming soon.) * - ``focus_on_window_activation`` - ``'smart'`` - Behavior of the _NET_ACTIVATE_WINDOW message sent by applications diff --git a/docs/manual/config/lazy.rst b/docs/manual/config/lazy.rst index bb9392f121..419b29f0ae 100644 --- a/docs/manual/config/lazy.rst +++ b/docs/manual/config/lazy.rst @@ -124,6 +124,23 @@ Window functions - Put the focused window to/from floating mode * - ``lazy.window.toggle_fullscreen()`` - Put the focused window to/from fullscreen mode + * - ``lazy.window.move_up()`` + - Move the window above the next window in the stack. + * - ``lazy.window.move_down()`` + - Move the window below the previous window in the stack. + * - ``lazy.window.move_to_top()`` + - Move the window above all other windows with similar priority + (i.e. a "normal" window will not be moved above a ``kept_above`` window). + * - ``lazy.window.move_to_bottom()`` + - Move the window below all other windows with similar priority + (i.e. a "normal" window will not be moved below a ``kept_below`` window). + * - ``lazy.window.keep_above()`` + - Keep window above other windows. + * - ``lazy.window.keep_below()`` + - Keep window below other windows. + * - ``lazy.window.bring_to_front()`` + - Bring window above all other windows. Ignores ``kept_above`` priority. + Screen functions ---------------- diff --git a/libqtile/backend/base.py b/libqtile/backend/base.py index 006a92c12d..b579a2c373 100644 --- a/libqtile/backend/base.py +++ b/libqtile/backend/base.py @@ -95,9 +95,6 @@ def on_config_load(self, initial: bool) -> None: def warp_pointer(self, x: int, y: int) -> None: """Warp the pointer to the given coordinates relative.""" - def update_client_list(self, windows_map: dict[int, WindowType]) -> None: - """Update the list of windows being managed""" - @contextlib.contextmanager def masked(self): """A context manager to suppress window events while operating on many windows.""" @@ -253,6 +250,60 @@ def info(self) -> dict[str, Any]: """ return {} + @expose_command() + def keep_above(self, enable: bool | None = None): + """Keep this window above all others""" + + @expose_command() + def keep_below(self, enable: bool | None = None): + """Keep this window below all others""" + + @expose_command() + def move_up(self, force: bool = False) -> None: + """ + Move this window above the next window along the z axis. + + Will not raise a "normal" window (i.e. one that is not "kept_above/below") + above a window that is marked as "kept_above". + + Will not raise a window where "keep_below" is True unless + force is set to True. + """ + + @expose_command() + def move_down(self, force: bool = False) -> None: + """ + Move this window below the previous window along the z axis. + + Will not lower a "normal" window (i.e. one that is not "kept_above/below") + below a window that is marked as "kept_below". + + Will not lower a window where "keep_above" is True unless + force is set to True. + """ + + @expose_command() + def move_to_top(self) -> None: + """ + Move this window above all windows in the current layer + e.g. if you have 3 windows all with "keep_above" set, calling + this method will move the window to the top of those three windows. + + Calling this on a "normal" window will not raise it above a "kept_above" + window. + """ + + @expose_command() + def move_to_bottom(self) -> None: + """ + Move this window below all windows in the current layer + e.g. if you have 3 windows all with "keep_above" set, calling + this method will move the window to the bottom of those three windows. + + Calling this on a "normal" window will not raise it below a "kept_below" + window. + """ + class Window(_Window, metaclass=ABCMeta): """ diff --git a/libqtile/backend/x11/core.py b/libqtile/backend/x11/core.py index ddad11ca40..fd44ffb5ca 100644 --- a/libqtile/backend/x11/core.py +++ b/libqtile/backend/x11/core.py @@ -32,7 +32,7 @@ import xcffib.render import xcffib.xproto import xcffib.xtest -from xcffib.xproto import EventMask, StackMode +from xcffib.xproto import EventMask from libqtile import config, hook, utils from libqtile.backend import base @@ -167,6 +167,8 @@ def __init__(self, display_name: str | None = None) -> None: # The last motion notify event that we still need to handle self._motion_notify: xcffib.Event | None = None + self.last_focused: base.Window | None = None + @property def name(self): return "x11" @@ -232,6 +234,9 @@ def on_config_load(self, initial) -> None: """Assign windows to groups""" assert self.qtile is not None + # Ensure that properties are initialised at startup + self.update_client_lists() + if not initial: # We are just reloading config for win in self.qtile.windows_map.values(): @@ -240,8 +245,8 @@ def on_config_load(self, initial) -> None: return # Qtile just started - scan for clients - _, _, children = self._root.query_tree() - for item in children: + for wid in self._root.query_tree(): + item = window.XWindow(self.conn, wid) try: attrs = item.get_attributes() state = item.get_wm_state() @@ -276,6 +281,9 @@ def on_config_load(self, initial) -> None: self.qtile.manage(win) + self.update_client_lists() + win.change_layer() + def warp_pointer(self, x, y): self._root.warp_pointer(x, y) self._root.set_input_focus() @@ -441,17 +449,28 @@ def display_name(self) -> str: """The name of the connected display""" return self._display_name - def update_client_list(self, windows_map: dict[int, base.WindowType]) -> None: - """Updates the client stack list + def update_client_lists(self) -> None: + """Updates the _NET_CLIENT_LIST and _NET_CLIENT_LIST_STACKING properties This is needed for third party tasklists and drag and drop of tabs in chrome """ + assert self.qtile # Regular top-level managed windows, i.e. excluding Static, Internal and Systray Icons - wids = [wid for wid, c in windows_map.items() if isinstance(c, window.Window)] + wids = [wid for wid, c in self.qtile.windows_map.items() if isinstance(c, window.Window)] self._root.set_property("_NET_CLIENT_LIST", wids) - # TODO: check stack order - self._root.set_property("_NET_CLIENT_LIST_STACKING", wids) + + # We rely on the stacking order from the X server + stacked_wids = [] + for wid in self._root.query_tree(): + win = self.qtile.windows_map.get(wid) + if not win: + continue + + if isinstance(win, window.Window) and win.group: + stacked_wids.append(wid) + + self._root.set_property("_NET_CLIENT_LIST_STACKING", stacked_wids) def update_desktops(self, groups, index: int) -> None: """Set the current desktops of the window manager @@ -738,11 +757,14 @@ def handle_MapRequest(self, event) -> None: # noqa: N802 if not win.group or not win.group.screen: return win.unhide() + self.update_client_lists() + win.change_layer() def handle_DestroyNotify(self, event) -> None: # noqa: N802 assert self.qtile is not None self.qtile.unmanage(event.window) + self.update_client_lists() if self.qtile.current_window is None: self.conn.fixup_focus() @@ -769,6 +791,7 @@ def handle_UnmapNotify(self, event) -> None: # noqa: N802 win.wid, win.window.conn.atoms["_NET_WM_DESKTOP"] ) self.qtile.unmanage(event.window) + self.update_client_lists() if self.qtile.current_window is None: self.conn.fixup_focus() @@ -837,9 +860,7 @@ def focus_by_click(self, e, window=None): qtile.config.bring_front_click != "floating_only" or getattr(window, "floating", False) ): - self.conn.conn.core.ConfigureWindow( - window.wid, xcffib.xproto.ConfigWindow.StackMode, [StackMode.Above] - ) + window.bring_to_front() try: if window.group.screen is not qtile.current_screen: @@ -908,3 +929,13 @@ def get_mouse_position(self) -> tuple[int, int]: def keysym_from_name(self, name: str) -> int: """Get the keysym for a key from its name""" return keysyms[name.lower()] + + def check_stacking(self, win: base.Window) -> None: + """Triggers restacking if a fullscreen window loses focus.""" + if win is self.last_focused: + return + + if self.last_focused and self.last_focused.fullscreen: + self.last_focused.change_layer() + + self.last_focused = win diff --git a/libqtile/backend/x11/window.py b/libqtile/backend/x11/window.py index d2be7b89da..45a56eb139 100644 --- a/libqtile/backend/x11/window.py +++ b/libqtile/backend/x11/window.py @@ -10,15 +10,16 @@ import xcffib import xcffib.xproto from xcffib.wrappers import GContextID, PixmapID -from xcffib.xproto import EventMask, SetMode, StackMode +from xcffib.xproto import EventMask, SetMode -from libqtile import hook, utils +from libqtile import bar, hook, utils from libqtile.backend import base from libqtile.backend.base import FloatStates from libqtile.backend.x11 import xcbq from libqtile.backend.x11.drawer import Drawer from libqtile.command.base import CommandError, expose_command from libqtile.log_utils import logger +from libqtile.scratchpad import ScratchPad if TYPE_CHECKING: from libqtile.command.base import ItemT @@ -412,14 +413,7 @@ def get_attributes(self): return self.conn.conn.core.GetWindowAttributes(self.wid).reply() def query_tree(self): - q = self.conn.conn.core.QueryTree(self.wid).reply() - root = None - parent = None - if q.root: - root = XWindow(self.conn, q.root) - if q.parent: - parent = XWindow(self.conn, q.parent) - return root, parent, [XWindow(self.conn, i) for i in q.children] + return self.conn.conn.core.QueryTree(self.wid).reply().children def paint_borders(self, depth, colors, borderwidth, width, height): """ @@ -507,6 +501,13 @@ def __init__(self, window, qtile): self._float_width: int = self._width self._float_height: int = self._height + # We use `previous_layer` to see if a window has moved up or down a "layer" + # The layers are defined in the spec: + # https://specifications.freedesktop.org/wm-spec/1.3/ar01s07.html#STACKINGORDER + # We assume a window starts off in the layer for "normal" windows, i.e. ones that + # don't match the requirements to be in any of the other layers. + self.previous_layer = (False, False, True, False, False, False) + self.bordercolor = None self.state = NormalState self._float_state = FloatStates.NOT_FLOATING @@ -662,7 +663,10 @@ def update_state(self): self._reconfigure_floating(new_float_state=FloatStates.FULLSCREEN) for s in triggered: - setattr(self, s, (s in state)) + attr = s + val = s in state + if getattr(self, attr) != val: + setattr(self, attr, val) @property def urgent(self): @@ -903,21 +907,256 @@ def place( self.width = width self.height = height - kwarg = dict( - x=x, - y=y, - width=width, - height=height, - ) + self.window.configure(x=x, y=y, width=width, height=height) + if above: - kwarg["stackmode"] = StackMode.Above + self.change_layer(up=True) - self.window.configure(**kwarg) self.paint_borders(bordercolor, borderwidth) if send_notify: self.send_configure_notify(x, y, width, height) + def get_layering_information(self) -> tuple[bool, bool, bool, bool, bool, bool]: + """ + Get layer-related EMWH-flags + https://specifications.freedesktop.org/wm-spec/1.3/ar01s07.html#STACKINGORDER + + Copied here: + + To obtain good interoperability between different Desktop Environments, + the following layered stacking order is recommended, from the bottom: + + - windows of type _NET_WM_TYPE_DESKTOP + - windows having state _NET_WM_STATE_BELOW + - windows not belonging in any other layer + - windows of type _NET_WM_TYPE_DOCK (unless they have state + _NET_WM_TYPE_BELOW) and windows having state _NET_WM_STATE_ABOVE + - focused windows having state _NET_WM_STATE_FULLSCREEN + + Windows that are transient for another window should be kept above this window. + + The window manager may choose to put some windows in different stacking positions, + for example to allow the user to bring currently a active window to the top and return + it back when the window loses focus. To this end, qtile adds an additional layer so that + scratchpad windows are placed above all others, always. + """ + state = self.window.get_net_wm_state() + _type = self.window.get_wm_type() or "" + + # Check if this window is focused + active_window = self.qtile.core._root.get_property( + "_NET_ACTIVE_WINDOW", "WINDOW", unpack=int + ) + if active_window and active_window[0] == self.window.wid: + focus = True + else: + focus = False + + desktop = _type == "desktop" + below = "_NET_WM_STATE_BELOW" in state + dock = _type == "dock" + above = "_NET_WM_STATE_ABOVE" in state + full = ( + "fullscreen" in state + ) # get_net_wm_state translates this state so we don't use _NET_WM name + is_scratchpad = isinstance(self.qtile.groups_map.get(self.group), ScratchPad) + + # sort the flags from bottom to top, True meaning further below than False at each step + states = [desktop, below, above or (dock and not below), full and focus, is_scratchpad] + other = not any(states) + states.insert(2, other) + + # If we're a desktop, this should always be the lowest layer... + if desktop: + # mypy can't work out that this gives us tuple[bool, bool, bool, bool, bool, bool]... + # (True, False, False, False, False, False) + return tuple(not i for i in range(6)) # type: ignore + + # ...otherwise, we set to the highest matching layer. + # Look for the highest matching level and then set all other levels to False + highest = max(i for i, state in enumerate(states) if state) + + # mypy can't work out that this gives us tuple[bool, bool, bool, bool, bool, bool]... + return tuple(i == highest for i in range(6)) # type: ignore + + def change_layer(self, up=True, top_bottom=False): + """Raise a window above its peers or move it below them, depending on 'up'. + Raising a normal window will not lift it above pinned windows etc. + + There are a few important things to take note of when relaying windows: + 1. If a window has a defined parent, it should not be moved underneath it. + In case children are blocking, this could leave an application in an unusable state. + 2. If a window has children, they should be moved along with it. + 3. If a window has a defined parent, either move the parent or do nothing at all. + 4. EMWH-flags follow strict layering rules: + https://specifications.freedesktop.org/wm-spec/1.3/ar01s07.html#STACKINGORDER + """ + if len(self.qtile.windows_map) < 2: + return + + if self.group is None: + return + + parent = self.window.get_wm_transient_for() + if parent is not None and not up: + return + + layering = self.get_layering_information() + + # Comparison of layer states: -1 if window is now in a lower state group, + # 0 if it's in the same group and 1 if it's in a higher group + moved = (self.previous_layer > layering) - (layering > self.previous_layer) + self.previous_layer = layering + + stack = list(self.qtile.core._root.query_tree()) + if self.wid not in stack or len(stack) < 2: + return + + group_windows = self.group.windows + if self.group.screen is not None: + group_bars = [gap for gap in self.group.screen.gaps if isinstance(gap, bar.Bar)] + else: + group_bars = [] + + # Get list of windows that are in the stack and managed by qtile + # List of tuples (XWindow object, transient_for, layering_information) + windows = list( + map( + lambda w: ( + w.window, + w.window.get_wm_transient_for(), + w.get_layering_information(), + ), + group_windows, + ) + ) + + # Sort this list to match stacking order reported by server + windows.sort(key=lambda w: stack.index(w[0].wid)) + + # Get lists of windows on lower, higher or same "layer" as window + lower = [w[0].wid for w in windows if w[1] is None and w[2] > layering] + higher = [w[0].wid for w in windows if w[1] is None and w[2] < layering] + same = [w[0].wid for w in windows if w[1] is None and w[2] == layering] + + # We now need to identify the new position in the stack + + # If the window has a parent, the window should just be put above it + if parent: + sibling = parent + above = True + + # Now we just check whether the window has changed layer. + + # If we're forcing to top or bottom of current layer... + elif top_bottom: + if up: + sibling = same[-1] + above = True + else: + sibling = same[0] + above = False + + # There are no windows in the desired layer (should never happen) or + # we've moved to a new layer and are the only window in that layer + elif not same or (len(same) == 1 and moved != 0): + # Try to put it above the last window in the lower layers + if lower: + sibling = lower[-1] + above = True + + # Or below the first window in the higher layers + elif higher: + sibling = higher[0] + above = False + + # Don't think we should end up here but, if we do... + else: + # Put the window above the highest window if we're raising it + if up: + sibling = stack[-1] + above = True + + # or below the lowest window if we're lowering the window + else: + sibling = stack[0] + above = False + + else: + # Window has moved to a lower layer state + if moved < 0: + if self.kept_below: + sibling = same[0] + above = False + else: + sibling = same[-1] + above = True + + # Window is in same layer state + elif moved == 0: + try: + pos = same.index(self.wid) + except ValueError: + pos = len(same) if up else 0 + if not up: + pos = max(0, pos - 1) + else: + pos = min(pos + 1, len(same) - 1) + sibling = same[pos] + above = up + + # Window is in a higher layer + else: + if self.kept_above: + sibling = same[-1] + above = True + else: + sibling = same[0] + above = False + + # If the sibling is the current window then we just check if any windows in lower/higher layers are + # stacked incorrectly and, if so, restack them. However, we don't need to configure stacking for this + # window + if sibling == self.wid: + index = stack.index(self.wid) + + # We need to make sure the bars are included so add them now + if group_bars: + for group_bar in group_bars: + bar_layer = group_bar.window.get_layering_information() + if bar_layer > layering: + lower.append(group_bar.window.wid) + elif bar_layer < layering: + higher.append(group_bar.window.wid) + + # Sort the list to match the server's stacking order + lower.sort(key=lambda wid: stack.index(wid)) + higher.sort(key=lambda wid: stack.index(wid)) + + for wid in [w for w in lower if stack.index(w) > index]: + self.qtile.windows_map[wid].window.configure( + stackmode=xcffib.xproto.StackMode.Below, sibling=same[0] + ) + + # We reverse higher as each window will be placed above the last item in the current layer + # this means the last item we stack will be just above the current layer. + for wid in [w for w in higher[::-1] if stack.index(w) < index]: + self.qtile.windows_map[wid].window.configure( + stackmode=xcffib.xproto.StackMode.Above, sibling=same[-1] + ) + + return + + # Window needs new stacking info. We tell the server to stack the window + # above or below a given "sibling" + self.window.configure( + stackmode=xcffib.xproto.StackMode.Above if above else xcffib.xproto.StackMode.Below, + sibling=sibling, + ) + # TODO: also move our children if we were moved upwards + self.qtile.core.update_client_lists() + def paint_borders(self, color, width): self.borderwidth = width self.bordercolor = color @@ -1004,9 +1243,6 @@ def focus(self, warp: bool = True) -> None: did_focus = self._do_focus() if not did_focus: return - if isinstance(self, base.Internal): - # self._do_focus is enough for internal windows - return # now, do all the other WM stuff since the focus actually changed if warp and self.qtile.config.cursor_warp: @@ -1041,6 +1277,15 @@ def focus(self, warp: bool = True) -> None: if self.group: self.group.current_window = self + + # See https://github.com/qtile/qtile/pull/3409#discussion_r1117952134 for discussion + # on mypy error here + if self.fullscreen and not self.previous_layer[4]: # type: ignore + self.change_layer() + + # Check if we need to restack a previously focused fullscreen window + self.qtile.core.check_stacking(self) + hook.fire("client_focus", self) @expose_command() @@ -1102,6 +1347,98 @@ def inspect(self): float_info=float_info, ) + @expose_command() + def keep_above(self, enable: bool | None = None): + if enable is None: + self.kept_above = not self.kept_above + else: + self.kept_above = enable + + self.change_layer(top_bottom=True, up=True) + + @expose_command() + def keep_below(self, enable: bool | None = None): + if enable is None: + self.kept_below = not self.kept_below + else: + self.kept_below = enable + + self.change_layer(top_bottom=True, up=False) + + @expose_command() + def move_up(self, force=False): + if self.kept_below and force: + self.kept_below = False + with self.qtile.core.masked(): + # Disable masks so that moving windows along the Z axis doesn't trigger + # focus change events (i.e. due to `follow_mouse_focus`) + self.change_layer() + + @expose_command() + def move_down(self, force=False): + if self.kept_above and force: + self.kept_above = False + with self.qtile.core.masked(): + self.change_layer(up=False) + + @expose_command() + def move_to_top(self, force=False): + if self.kept_below and force: + self.kept_below = False + with self.qtile.core.masked(): + self.change_layer(top_bottom=True) + + @expose_command() + def move_to_bottom(self, force=False): + if self.kept_above and force: + self.kept_above = False + with self.qtile.core.masked(): + self.change_layer(up=False, top_bottom=True) + + @property + def kept_above(self): + reply = list(self.window.get_property("_NET_WM_STATE", "ATOM", unpack=int)) + atom = self.qtile.core.conn.atoms["_NET_WM_STATE_ABOVE"] + return atom in reply + + @kept_above.setter + def kept_above(self, value): + reply = list(self.window.get_property("_NET_WM_STATE", "ATOM", unpack=int)) + atom = self.qtile.core.conn.atoms["_NET_WM_STATE_ABOVE"] + if value and atom not in reply: + reply.append(atom) + elif not value and atom in reply: + reply.remove(atom) + else: + return + atom = self.qtile.core.conn.atoms["_NET_WM_STATE_BELOW"] + if atom in reply: + reply.remove(atom) + self.window.set_property("_NET_WM_STATE", reply) + self.change_layer() + + @property + def kept_below(self): + reply = list(self.window.get_property("_NET_WM_STATE", "ATOM", unpack=int)) + atom = self.qtile.core.conn.atoms["_NET_WM_STATE_BELOW"] + return atom in reply + + @kept_below.setter + def kept_below(self, value): + reply = list(self.window.get_property("_NET_WM_STATE", "ATOM", unpack=int)) + atom = self.qtile.core.conn.atoms["_NET_WM_STATE_BELOW"] + if value and atom not in reply: + reply.append(atom) + elif not value and atom in reply: + reply.remove(atom) + else: + return + atom = self.qtile.core.conn.atoms["_NET_WM_STATE_ABOVE"] + if atom in reply: + reply.remove(atom) + self.window.set_property("_NET_WM_STATE", reply) + self.change_layer(up=False) + class Internal(_Window, base.Internal): """An internal window, that should not be managed by qtile""" @@ -1171,6 +1508,11 @@ def info(self): id=self.window.wid, ) + @expose_command() + def focus(self, warp: bool = True) -> None: + """Focuses the window.""" + self._do_focus() + class Static(_Window, base.Static): """An static window, belonging to a screen rather than a group""" @@ -1270,7 +1612,8 @@ def handle_PropertyNotify(self, e): # noqa: N802 @expose_command() def bring_to_front(self): - self.window.configure(stackmode=StackMode.Above) + if self.get_wm_type() != "desktop": + self.window.configure(stackmode=xcffib.xproto.StackMode.Above) class Window(_Window, base.Window): @@ -1316,6 +1659,9 @@ def floating(self): @floating.setter def floating(self, do_float): + stack = self.qtile.core._root.query_tree() + tiled = [win.window.wid for win in (self.group.tiled_windows if self.group else [])] + tiled_stack = [wid for wid in stack if wid in tiled and wid != self.window.wid] if do_float and self._float_state == FloatStates.NOT_FLOATING: if self.group and self.group.screen: screen = self.group.screen @@ -1325,9 +1671,21 @@ def floating(self, do_float): self._float_width, self._float_height, ) + + # Make sure floating window is placed above tiled windows + if tiled_stack and (not self.kept_above or self.qtile.config.floats_kept_above): + stack_list = list(stack) + highest_tile = tiled_stack[-1] + if stack_list.index(self.window.wid) < stack_list.index(highest_tile): + self.window.configure( + stackmode=xcffib.xproto.StackMode.Above, sibling=highest_tile + ) else: # if we are setting floating early, e.g. from a hook, we don't have a screen yet self._float_state = FloatStates.FLOATING + if not self.kept_above and self.qtile.config.floats_kept_above: + self.keep_above(enable=True) + elif (not do_float) and self._float_state != FloatStates.NOT_FLOATING: self.update_fullscreen_wm_state(False) if self._float_state == FloatStates.FLOATING: @@ -1336,6 +1694,10 @@ def floating(self, do_float): self._float_height = self.height self._float_state = FloatStates.NOT_FLOATING self.group.mark_floating(self, False) + if tiled_stack: + self.window.configure( + stackmode=xcffib.xproto.StackMode.Above, sibling=tiled_stack[-1] + ) hook.fire("float_change") @property @@ -1375,6 +1737,7 @@ def fullscreen(self): @fullscreen.setter def fullscreen(self, do_full): if do_full: + needs_change = self._float_state != FloatStates.FULLSCREEN screen = self.group.screen or self.qtile.find_closest_screen(self.x, self.y) bw = self.group.floating_layout.fullscreen_border_width @@ -1385,10 +1748,14 @@ def fullscreen(self, do_full): screen.height - 2 * bw, new_float_state=FloatStates.FULLSCREEN, ) + # Only restack layers if floating state has changed + if needs_change: + self.change_layer() return if self._float_state == FloatStates.FULLSCREEN: self.floating = False + self.change_layer() return @property @@ -1458,7 +1825,7 @@ def static( self.group.remove(self) s = Static(self.window, self.qtile, screen, x, y, width, height) self.qtile.windows_map[self.window.wid] = s - self.qtile.core.update_client_list(self.qtile.windows_map) + self.qtile.core.update_client_lists() hook.fire("client_managed", s) def tweak_float(self, x=None, y=None, dx=0, dy=0, w=None, h=None, dw=0, dh=0): @@ -1514,13 +1881,18 @@ def _reconfigure_floating(self, new_float_state=FloatStates.FLOATING): self.height, self.borderwidth, self.bordercolor, - above=True, + above=False, respect_hints=True, ) if self._float_state != new_float_state: self._float_state = new_float_state if self.group: # may be not, if it's called from hook self.group.mark_floating(self, True) + if new_float_state == FloatStates.FLOATING: + if self.qtile.config.floats_kept_above: + self.keep_above(enable=True) + elif new_float_state == FloatStates.MAXIMIZED: + self.move_to_top() hook.fire("float_change") def _enablefloating( @@ -1851,10 +2223,8 @@ def disable_fullscreen(self): @expose_command() def bring_to_front(self): - if self.floating: - self.window.configure(stackmode=StackMode.Above) - else: - self._reconfigure_floating() # atomatically above + if self.get_wm_type() != "desktop": + self.window.configure(stackmode=xcffib.xproto.StackMode.Above) def _is_in_window(self, x, y, window): return window.edges[0] <= x <= window.edges[2] and window.edges[1] <= y <= window.edges[3] diff --git a/libqtile/backend/x11/xcbq.py b/libqtile/backend/x11/xcbq.py index 6e0d4556d4..1f61ed2b86 100644 --- a/libqtile/backend/x11/xcbq.py +++ b/libqtile/backend/x11/xcbq.py @@ -448,42 +448,6 @@ def select_selection_input(self, window, selection="PRIMARY"): self.conn.xfixes.ext.SelectSelectionInput(window.wid, _selection, self.selection_mask) -class NetWmState: - """NetWmState is a descriptor for _NET_WM_STATE_* properties""" - - def __init__(self, prop_name): - self.prop_name = prop_name - - def __get__(self, xcbq_win, cls): - try: - atom = self.atom - except AttributeError: - atom = xcbq_win.conn.atoms[self.prop_name] - self.atom = atom - reply = xcbq_win.get_property("_NET_WM_STATE", "ATOM", unpack=int) - if atom in reply: - return True - return False - - def __set__(self, xcbq_win, value): - try: - atom = self.atom - except AttributeError: - atom = xcbq_win.conn.atoms[self.prop_name] - self.atom = atom - - value = bool(value) - reply = list(xcbq_win.get_property("_NET_WM_STATE", "ATOM", unpack=int)) - is_set = atom in reply - if is_set and not value: - reply.remove(atom) - xcbq_win.set_property("_NET_WM_STATE", reply) - elif value and not is_set: - reply.append(atom) - xcbq_win.set_property("_NET_WM_STATE", reply) - return - - class Connection: _extmap = { "xinerama": Xinerama, diff --git a/libqtile/bar.py b/libqtile/bar.py index 3d7cfd4257..8e19a19468 100644 --- a/libqtile/bar.py +++ b/libqtile/bar.py @@ -23,7 +23,7 @@ import typing from collections import defaultdict -from libqtile import configurable +from libqtile import configurable, hook from libqtile.command.base import CommandObject, expose_command from libqtile.log_utils import logger from libqtile.utils import has_transparency, rgb @@ -308,6 +308,9 @@ def _configure(self, qtile, screen, reconfigure=False): ) self.qtile.renamed_widgets.clear() + hook.subscribe.setgroup(self.keep_below) + hook.subscribe.startup_complete(self.keep_below) + self._remove_crashed_widgets() self.draw() self._resize(self.length, self.widgets) @@ -678,5 +681,8 @@ def fake_button_press(self, screen, position, x, y, button=1): """ self.process_button_click(x, y, button) + def keep_below(self): + self.window.keep_below(enable=True) + BarType = typing.Union[Bar, Gap] diff --git a/libqtile/confreader.py b/libqtile/confreader.py index f02ff4bcaa..1a505917ad 100644 --- a/libqtile/confreader.py +++ b/libqtile/confreader.py @@ -67,6 +67,7 @@ class Config: widget_defaults: dict[str, Any] extension_defaults: dict[str, Any] bring_front_click: bool | Literal["floating_only"] + floats_kept_above: bool reconfigure_screens: bool wmname: str auto_minimize: bool diff --git a/libqtile/core/manager.py b/libqtile/core/manager.py index 5c62f74070..3e37a803c8 100644 --- a/libqtile/core/manager.py +++ b/libqtile/core/manager.py @@ -675,7 +675,7 @@ def manage(self, win: base.WindowType) -> None: # Window may have been bound to a group in the hook. if not win.group and self.current_screen.group: self.current_screen.group.add(win, focus=win.can_steal_focus) - self.core.update_client_list(self.windows_map) + hook.fire("client_managed", win) def unmanage(self, wid: int) -> None: @@ -690,7 +690,7 @@ def unmanage(self, wid: int) -> None: group = c.group c.group.remove(c) del self.windows_map[wid] - self.core.update_client_list(self.windows_map) + if isinstance(c, base.Window): # Put the group back on the window so hooked functions can access it. c.group = group diff --git a/libqtile/group.py b/libqtile/group.py index c49d566c07..03415b30ab 100644 --- a/libqtile/group.py +++ b/libqtile/group.py @@ -256,6 +256,8 @@ def add(self, win, focus=True, force=False): win._float_state = FloatStates.FULLSCREEN elif self.floating_layout.match(win) and not win.fullscreen: win._float_state = FloatStates.FLOATING + if self.qtile.config.floats_kept_above: + win.keep_above(enable=True) if win.floating and not win.fullscreen: self.floating_layout.add_client(win) if not win.floating or win.fullscreen: diff --git a/libqtile/layout/max.py b/libqtile/layout/max.py index fd847302e4..65bb12b012 100644 --- a/libqtile/layout/max.py +++ b/libqtile/layout/max.py @@ -37,6 +37,7 @@ class Max(_SimpleLayoutBase): ("border_focus", "#0000ff", "Border colour(s) for the window when focused"), ("border_normal", "#000000", "Border colour(s) for the window when not focused"), ("border_width", 0, "Border width."), + ("only_focused", True, "Only draw the focused window"), ] def __init__(self, **config): @@ -47,7 +48,7 @@ def add_client(self, client): return super().add_client(client, 1) def configure(self, client, screen_rect): - if self.clients and client is self.clients.current_client: + if not self.only_focused or (self.clients and client is self.clients.current_client): client.place( screen_rect.x, screen_rect.y, @@ -58,6 +59,13 @@ def configure(self, client, screen_rect): margin=self.margin, ) client.unhide() + if ( + not self.only_focused + and self.clients + and client is self.clients.current_client + and len(self.clients) > 1 + ): + client.move_to_top() else: client.hide() diff --git a/libqtile/resources/default_config.py b/libqtile/resources/default_config.py index 37e212e514..269375f727 100644 --- a/libqtile/resources/default_config.py +++ b/libqtile/resources/default_config.py @@ -169,6 +169,7 @@ dgroups_app_rules = [] # type: list follow_mouse_focus = True bring_front_click = False +floats_kept_above = True cursor_warp = False floating_layout = layout.Floating( float_rules=[ diff --git a/test/backend/x11/test_window.py b/test/backend/x11/test_window.py index 9638ad4072..fdc1466b17 100644 --- a/test/backend/x11/test_window.py +++ b/test/backend/x11/test_window.py @@ -752,3 +752,218 @@ def assert_state_focused(wid, has_state): xmanager.kill_window(two) assert_state_focused(wid1, True) xmanager.kill_window(one) + + +@manager_config +def test_window_stacking_order(xmanager): + """Test basic window stacking controls.""" + conn = xcbq.Connection(xmanager.display) + + def _wnd(name): + return xmanager.c.window[{w["name"]: w["id"] for w in xmanager.c.windows()}[name]] + + def _clients(): + root = conn.default_screen.root.wid + q = conn.conn.core.QueryTree(root).reply() + stack = list(q.children) + wins = [(w["name"], stack.index(w["id"])) for w in xmanager.c.windows()] + wins.sort(key=lambda x: x[1]) + return [x[0] for x in wins] + + xmanager.test_window("one") + xmanager.test_window("two") + xmanager.test_window("three") + xmanager.test_window("four") + xmanager.test_window("five") + + # We're testing 3 "layers" + # BELOW, 'everything else', ABOVE + + # New windows added on top of each other + assert _clients() == ["one", "two", "three", "four", "five"] + + # Moving above/below moves above/below next client in the layer + _wnd("one").move_up() + assert _clients() == ["two", "one", "three", "four", "five"] + _wnd("four").move_up() + assert _clients() == ["two", "one", "three", "five", "four"] + _wnd("one").move_down() + assert _clients() == ["one", "two", "three", "five", "four"] + + # Keeping above/below moves client to ABOVE/BELOW layer + # When moving to ABOVE, client will be placed at top of that layer + # When moving to BELOW, client will be placed at bottom of layer + + # BELOW: None, ABOVE: two + _wnd("two").keep_above() + assert _clients() == ["one", "three", "five", "four", "two"] + _wnd("five").move_up() + assert _clients() == ["one", "three", "four", "five", "two"] + + # BELOW: three, ABOVE: two + _wnd("three").keep_below() + assert _clients() == ["three", "one", "four", "five", "two"] + _wnd("four").move_down() + assert _clients() == ["three", "four", "one", "five", "two"] + + # BELOW: four, three, ABOVE: two + _wnd("four").keep_below() + assert _clients() == ["four", "three", "one", "five", "two"] + + # BELOW: four, three, ABOVE: two, one + _wnd("one").keep_above() + assert _clients() == ["four", "three", "five", "two", "one"] + _wnd("five").move_up() + assert _clients() == ["four", "three", "five", "two", "one"] + _wnd("five").move_down() + assert _clients() == ["four", "three", "five", "two", "one"] + + # BELOW: two, four, three, ABOVE: one + _wnd("two").keep_below() + assert _clients() == ["two", "four", "three", "five", "one"] + + # BELOW: two, three, ABOVE: one, four + _wnd("four").keep_above() + assert _clients() == ["two", "three", "five", "one", "four"] + + # BELOW: two, three, ABOVE: one + _wnd("four").keep_above() + assert _clients() == ["two", "three", "five", "four", "one"] + _wnd("five").move_up() + assert _clients() == ["two", "three", "four", "five", "one"] + + # BELOW: two, three, ABOVE: None + _wnd("one").keep_above() + assert _clients() == ["two", "three", "four", "five", "one"] + + # BELOW: two, ABOVE: None + _wnd("three").keep_below() + assert _clients() == ["two", "three", "four", "five", "one"] + _wnd("one").move_down() + assert _clients() == ["two", "three", "four", "one", "five"] + + # BELOW: None ABOVE: None + _wnd("two").keep_below() + assert _clients() == ["two", "three", "four", "one", "five"] + + # BELOW: three, ABOVE: None + _wnd("three").keep_below() + assert _clients() == ["three", "two", "four", "one", "five"] + _wnd("two").move_down() + assert _clients() == ["three", "two", "four", "one", "five"] + _wnd("one").move_down() + assert _clients() == ["three", "two", "one", "four", "five"] + + _wnd("two").move_to_top() + assert _clients() == ["three", "one", "four", "five", "two"] + + # three is kept_below so moving to bottom is still above that + _wnd("five").move_to_bottom() + assert _clients() == ["three", "five", "one", "four", "two"] + + # three is the only window kept_below so this will have no effect + _wnd("three").move_to_top() + assert _clients() == ["three", "five", "one", "four", "two"] + + # Keep three above everything else + _wnd("three").keep_above() + assert _clients() == ["five", "one", "four", "two", "three"] + + # This should have no effect as it's the only window kept_above + _wnd("three").move_to_bottom() + assert _clients() == ["five", "one", "four", "two", "three"] + + +@manager_config +def test_floats_kept_above(xmanager): + """Test config option to pin floats to a higher level.""" + conn = xcbq.Connection(xmanager.display) + + def _wnd(name): + return xmanager.c.window[{w["name"]: w["id"] for w in xmanager.c.windows()}[name]] + + def _clients(): + root = conn.default_screen.root.wid + q = conn.conn.core.QueryTree(root).reply() + stack = list(q.children) + wins = [(w["name"], stack.index(w["id"])) for w in xmanager.c.windows()] + wins.sort(key=lambda x: x[1]) + return [x[0] for x in wins] + + xmanager.test_window("one", floating=True) + xmanager.test_window("two") + + # Confirm floating window is above window that was opened later + assert _clients() == ["two", "one"] + + # Open a different floating window. This should be above the first floating one. + xmanager.test_window("three", floating=True) + assert _clients() == ["two", "one", "three"] + + +@manager_config +def test_fullscreen_on_top(xmanager): + """Test fullscreen, focused windows are on top.""" + conn = xcbq.Connection(xmanager.display) + + def _wnd(name): + return xmanager.c.window[{w["name"]: w["id"] for w in xmanager.c.windows()}[name]] + + def _clients(): + root = conn.default_screen.root.wid + q = conn.conn.core.QueryTree(root).reply() + stack = list(q.children) + wins = [(w["name"], stack.index(w["id"])) for w in xmanager.c.windows()] + wins.sort(key=lambda x: x[1]) + return [x[0] for x in wins] + + xmanager.test_window("one", floating=True) + xmanager.test_window("two") + + # window "one" is kept_above, "two" is norm + assert _clients() == ["two", "one"] + + # A fullscreen, focused window should display above windows that are "kept above" + _wnd("two").enable_fullscreen() + _wnd("two").focus() + assert _clients() == ["one", "two"] + + # Focusing the other window should cause the fullscreen window to drop from the highest layer + _wnd("one").focus() + assert _clients() == ["two", "one"] + + # Disabling fullscreen will put the window below the "kept above" window, even if it has focus + _wnd("two").focus() + _wnd("two").toggle_fullscreen() + assert _clients() == ["two", "one"] + + +class UnpinFloatsConfig(ManagerConfig): + # New floating windows not set to "keep_above" + floats_kept_above = False + + +# Floating windows should be moved above tiled windows when first floated, regardless +# of whether `floats_kept_above` is True +@pytest.mark.parametrize("xmanager", [ManagerConfig, UnpinFloatsConfig], indirect=True) +def test_move_float_above_tiled(xmanager): + conn = xcbq.Connection(xmanager.display) + + def _wnd(name): + return xmanager.c.window[{w["name"]: w["id"] for w in xmanager.c.windows()}[name]] + + def _clients(): + root = conn.default_screen.root.wid + q = conn.conn.core.QueryTree(root).reply() + stack = list(q.children) + wins = [(w["name"], stack.index(w["id"])) for w in xmanager.c.windows()] + wins.sort(key=lambda x: x[1]) + return [x[0] for x in wins] + + xmanager.test_window("one") + xmanager.test_window("two") + xmanager.test_window("three") + assert _clients() == ["one", "two", "three"] + + _wnd("two").toggle_floating() + assert _clients() == ["one", "three", "two"] diff --git a/test/backend/x11/test_xcore.py b/test/backend/x11/test_xcore.py index 5016b180f8..ba706f9603 100644 --- a/test/backend/x11/test_xcore.py +++ b/test/backend/x11/test_xcore.py @@ -31,6 +31,7 @@ def assert_clients(number): assert len(clients) == number # ManagerConfig has a Bar, which should not appear in _NET_CLIENT_LIST + xmanager.c.eval("self.core.update_client_lists()") assert_clients(0) one = xmanager.test_window("one") assert_clients(1) diff --git a/test/layouts/test_max.py b/test/layouts/test_max.py index 114e0b4381..eb892b023f 100644 --- a/test/layouts/test_max.py +++ b/test/layouts/test_max.py @@ -56,12 +56,52 @@ class MaxConfig(Config): max_config = pytest.mark.parametrize("manager", [MaxConfig], indirect=True) +class MaxLayeredConfig(Config): + auto_fullscreen = True + groups = [ + libqtile.config.Group("a"), + libqtile.config.Group("b"), + libqtile.config.Group("c"), + libqtile.config.Group("d"), + ] + layouts = [layout.Max(only_focused=False)] + floating_layout = libqtile.layout.floating.Floating() + keys = [] + mouse = [] + screens = [] + + +maxlayered_config = pytest.mark.parametrize("manager", [MaxLayeredConfig], indirect=True) + + +def assert_z_stack(manager, windows): + if manager.backend.name != "x11": + # TODO: Test wayland backend when proper Z-axis is implemented there + return + stack = manager.backend.get_all_windows() + wins = [(w["name"], stack.index(w["id"])) for w in manager.c.windows()] + wins.sort(key=lambda x: x[1]) + assert [x[0] for x in wins] == windows + + @max_config def test_max_simple(manager): manager.test_window("one") assert manager.c.layout.info()["clients"] == ["one"] + assert_z_stack(manager, ["one"]) + manager.test_window("two") + assert manager.c.layout.info()["clients"] == ["one", "two"] + assert_z_stack(manager, ["one", "two"]) + + +@maxlayered_config +def test_max_layered(manager): + manager.test_window("one") + assert manager.c.layout.info()["clients"] == ["one"] + assert_z_stack(manager, ["one"]) manager.test_window("two") assert manager.c.layout.info()["clients"] == ["one", "two"] + assert_z_stack(manager, ["one", "two"]) @max_config @@ -70,19 +110,47 @@ def test_max_updown(manager): manager.test_window("two") manager.test_window("three") assert manager.c.layout.info()["clients"] == ["one", "two", "three"] + assert_z_stack(manager, ["one", "two", "three"]) manager.c.layout.up() assert manager.c.get_groups()["a"]["focus"] == "two" manager.c.layout.down() assert manager.c.get_groups()["a"]["focus"] == "three" + assert_z_stack(manager, ["one", "two", "three"]) + manager.c.layout.down() + assert manager.c.get_groups()["a"]["focus"] == "one" + assert_z_stack(manager, ["one", "two", "three"]) -@max_config +@maxlayered_config +def test_layered_max_updown(manager): + manager.test_window("one") + manager.test_window("two") + manager.test_window("three") + assert manager.c.layout.info()["clients"] == ["one", "two", "three"] + assert_z_stack(manager, ["one", "two", "three"]) + manager.c.layout.up() + assert manager.c.get_groups()["a"]["focus"] == "two" + assert_z_stack(manager, ["one", "three", "two"]) + manager.c.layout.up() + assert manager.c.get_groups()["a"]["focus"] == "one" + assert_z_stack(manager, ["three", "two", "one"]) + manager.c.layout.down() + assert manager.c.get_groups()["a"]["focus"] == "two" + assert_z_stack(manager, ["three", "one", "two"]) + manager.c.layout.down() + assert manager.c.get_groups()["a"]["focus"] == "three" + assert_z_stack(manager, ["one", "two", "three"]) + + +@pytest.mark.parametrize("manager", [MaxConfig, MaxLayeredConfig], indirect=True) def test_max_remove(manager): manager.test_window("one") two = manager.test_window("two") assert manager.c.layout.info()["clients"] == ["one", "two"] + assert_z_stack(manager, ["one", "two"]) manager.kill_window(two) assert manager.c.layout.info()["clients"] == ["one"] + assert_z_stack(manager, ["one"]) @max_config @@ -98,6 +166,32 @@ def test_max_window_focus_cycle(manager): # test preconditions assert manager.c.layout.info()["clients"] == ["one", "two", "three"] + + # Floats are kept above in stacking order + assert_z_stack(manager, ["one", "two", "three", "float1", "float2"]) + # last added window has focus + assert_focused(manager, "three") + + # assert window focus cycle, according to order in layout + assert_focus_path(manager, "float1", "float2", "one", "two", "three") + + +@maxlayered_config +def test_layered_max_window_focus_cycle(manager): + # setup 3 tiled and two floating clients + manager.test_window("one") + manager.test_window("two") + manager.test_window("float1") + manager.c.window.toggle_floating() + manager.test_window("float2") + manager.c.window.toggle_floating() + manager.test_window("three") + + # test preconditions + assert manager.c.layout.info()["clients"] == ["one", "two", "three"] + + # Floats kept above by default + assert_z_stack(manager, ["one", "two", "three", "float1", "float2"]) # last added window has focus assert_focused(manager, "three")