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")