diff --git a/docs/manual/config/index.rst b/docs/manual/config/index.rst index b93ee4e54a..3c148c6fd1 100644 --- a/docs/manual/config/index.rst +++ b/docs/manual/config/index.rst @@ -58,6 +58,7 @@ mix!) mouse screens hooks + match In addition to the above variables, there are several other boolean configuration variables that control specific aspects of Qtile's behavior: diff --git a/docs/manual/config/match.rst b/docs/manual/config/match.rst new file mode 100644 index 0000000000..c959dd8a0a --- /dev/null +++ b/docs/manual/config/match.rst @@ -0,0 +1,85 @@ +.. _match: + +================ +Matching windows +================ + +Qtile's config provides a number of situations where the behaviour depends +on whether the relevant window matches some specified criteria. + +These situations include: + + - Defining which windows should be floated by default + - Assigning windows to specific groups + - Assigning window to a master section of a layout + +In each instance, the criteria are defined via a ``Match`` object. The properties +of the object will be compared to a :class:`~libqtile.base.Window` to determine if +its properties *match*. It can match by title, wm_class, role, wm_type, +wm_instance_class, net_wm_pid, or wid. Additionally, a function may be +passed, which takes in the :class:`~libqtile.base.Window` to be compared +against and returns a boolean. + +A basic rule would therefore look something like: + +.. code:: python + + Match(wm_class="mpv") + +This would match against any window whose class was ``mpv``. + +Where a string is provided as an argument then the value must match exactly. More +flexibility can be achieved by using regular expressions. For example: + +.. code:: python + + import re + + Match(wm_class=re.compile(r"mpv")) + +This would still match a window whose class was ``mpv`` but it would also match +any class starting with ``mpv`` e.g. ``mpvideo``. + +.. note:: + + When providing a regular expression, qtile applies the ``.match`` method. + This matches from the start of the string so, if you want to match any substring, + you will need to adapt the regular expression accordingly e.g. + + .. code:: python + + import re + + Match(wm_class=re.compile(r".*mpv")) + + This would match any string containing ``mpv`` + +Creating advanced rules +======================= + +While the ``func`` parameter allows users to create more complex matches, this requires +a knowledge of qtile's internal objects. An alternative is to combine Match objects using +logical operators ``&`` (and), ``|`` (or), ``~`` (not) and ``^`` (xor). + +For example, to create rule that matches all windows with a fixed aspect ratio except for +mpv windows, you would provide the following: + +.. code:: python + + Match(func=lambda c: c.has_fixed_ratio()) & ~Match(wm_class="mpv") + +It is also possible to use wrappers for ``Match`` objects if you do not want to use the +operators. The following wrappers are available: + + - ``MatchAll(Match(...), ...)`` equivalent to "and" test. All matches must match. + - ``MatchAny(Match(...), ...)`` equivalent to "or" test. At least one match must match. + - ``MatchOnlyOne(Match(...), Match(...))`` equivalent to "xor". Only one match must match. + - ``InvertMatch(Match(...))`` equivalent to "not". Inverts the result of the match. + +So, to recreate the above rule using the wrappers, you would write the following: + +.. code:: python + + from libqtile.config import InvertMatch, Match, MatchAll + + MatchAll(Match(func=lambda c: c.has_fixed_ratio()), InvertMatch(Match(wm_class="mpv"))) diff --git a/libqtile/backend/base/window.py b/libqtile/backend/base/window.py index 19ec1d8373..d46ab727c9 100644 --- a/libqtile/backend/base/window.py +++ b/libqtile/backend/base/window.py @@ -286,7 +286,7 @@ def wants_to_fullscreen(self) -> bool: """Does this window want to be fullscreen?""" return False - def match(self, match: config.Match) -> bool: + def match(self, match: config._Match) -> bool: """Compare this window against a Match instance.""" return match.compare(self) diff --git a/libqtile/backend/wayland/window.py b/libqtile/backend/wayland/window.py index 14acf536f3..3503c4351e 100644 --- a/libqtile/backend/wayland/window.py +++ b/libqtile/backend/wayland/window.py @@ -604,7 +604,7 @@ def info(self) -> dict: fullscreen=self._float_state == FloatStates.FULLSCREEN, ) - def match(self, match: config.Match) -> bool: + def match(self, match: config._Match) -> bool: return match.compare(self) def add_idle_inhibitor( diff --git a/libqtile/config.py b/libqtile/config.py index 1abef1799d..79fbb82d61 100644 --- a/libqtile/config.py +++ b/libqtile/config.py @@ -832,7 +832,85 @@ def convert_deprecated_list(vals: list[str], name: str) -> re.Pattern: return re.compile(regex_input) -class Match: +class _Match: + """Base class to implement bitwise logic methods for Match objects.""" + + def compare(self, client: base.Window) -> bool: + return True + + def __invert__(self) -> InvertMatch: + return InvertMatch(self) + + def __and__(self, other: _Match) -> MatchAll: + if not isinstance(other, _Match): + raise TypeError + + return MatchAll(self, other) + + def __or__(self, other: _Match) -> MatchAny: + if not isinstance(other, _Match): + raise TypeError + + return MatchAny(self, other) + + def __xor__(self, other: _Match) -> MatchOnlyOne: + if not isinstance(other, _Match): + raise TypeError + + return MatchOnlyOne(self, other) + + +class InvertMatch(_Match): + """Wrapper to invert the result of the comparison.""" + + def __init__(self, match: _Match): + self.match = match + + def compare(self, client: base.Window) -> bool: + return not self.match.compare(client) + + def __repr__(self) -> str: + return "" % self.match + + +class MatchAll(_Match): + """Wrapper to check if all comparisons return True.""" + + def __init__(self, *matches: _Match): + self.matches = matches + + def compare(self, client: base.Window) -> bool: + return all(m.compare(client) for m in self.matches) + + def __repr__(self) -> str: + return "" % (self.matches,) + + +class MatchAny(MatchAll): + """Wrapper to check if at least one of the comparisons returns True.""" + + def compare(self, client: base.Window) -> bool: + return any(m.compare(client) for m in self.matches) + + def __repr__(self) -> str: + return "" % (self.matches,) + + +class MatchOnlyOne(_Match): + """Wrapper to check if only one of the two comparisons returns True.""" + + def __init__(self, match1: _Match, match2: _Match): + self.match1 = match1 + self.match2 = match2 + + def compare(self, client: base.Window) -> bool: + return self.match1.compare(client) != self.match2.compare(client) + + def __repr__(self) -> str: + return "" % (self.match1, self.match2) + + +class Match(_Match): """ Window properties to compare (match) with a window. @@ -1002,13 +1080,13 @@ class Rule: def __init__( self, - match: Match | list[Match], + match: _Match | list[_Match], group: _Group | None = None, float: bool = False, intrusive: bool = False, break_on_match: bool = True, ) -> None: - if isinstance(match, Match): + if isinstance(match, _Match): self.matchlist = [match] else: self.matchlist = match diff --git a/libqtile/layout/floating.py b/libqtile/layout/floating.py index 8a8ee773c2..b8b585c8f5 100644 --- a/libqtile/layout/floating.py +++ b/libqtile/layout/floating.py @@ -32,7 +32,7 @@ from typing import TYPE_CHECKING from libqtile.command.base import expose_command -from libqtile.config import Match +from libqtile.config import Match, _Match from libqtile.layout.base import Layout if TYPE_CHECKING: @@ -47,7 +47,7 @@ class Floating(Layout): Floating layout, which does nothing with windows but handles focus order """ - default_float_rules = [ + default_float_rules: list[_Match] = [ Match(wm_type="utility"), Match(wm_type="notification"), Match(wm_type="toolbar"), @@ -74,7 +74,7 @@ class Floating(Layout): ] def __init__( - self, float_rules: list[Match] | None = None, no_reposition_rules=None, **config + self, float_rules: list[_Match] | None = None, no_reposition_rules=None, **config ): """ If you have certain apps that you always want to float you can provide diff --git a/libqtile/layout/screensplit.py b/libqtile/layout/screensplit.py index 46aedc346d..3bdf0dfb98 100644 --- a/libqtile/layout/screensplit.py +++ b/libqtile/layout/screensplit.py @@ -23,7 +23,7 @@ from libqtile import hook from libqtile.command.base import expose_command -from libqtile.config import Match, ScreenRect +from libqtile.config import ScreenRect, _Match from libqtile.layout import Columns, Max from libqtile.layout.base import Layout from libqtile.log_utils import logger @@ -39,7 +39,7 @@ class Split: def __init__( - self, *, name: str, rect: Rect, layout: Layout, matches: list[Match] = list() + self, *, name: str, rect: Rect, layout: Layout, matches: list[_Match] = list() ) -> None: # Check that rect is correctly defined if not isinstance(rect, (tuple, list)): @@ -53,7 +53,7 @@ def __init__( if matches: if isinstance(matches, list): - if not all(isinstance(m, Match) for m in matches): + if not all(isinstance(m, _Match) for m in matches): raise ValueError("Invalid object in 'matches'.") else: raise ValueError("'matches' must be a list of 'Match' objects.") diff --git a/libqtile/layout/tile.py b/libqtile/layout/tile.py index a35baead4b..a9de753b61 100644 --- a/libqtile/layout/tile.py +++ b/libqtile/layout/tile.py @@ -34,7 +34,7 @@ from typing import TYPE_CHECKING from libqtile.command.base import expose_command -from libqtile.config import Match +from libqtile.config import _Match from libqtile.layout.base import _SimpleLayoutBase if TYPE_CHECKING: @@ -150,7 +150,7 @@ def reset_master(self, match=None): return if self.clients: master_match = match or self.master_match - if isinstance(master_match, Match): + if isinstance(master_match, _Match): master_match = [master_match] masters = [] for c in self.clients: diff --git a/libqtile/lazy.py b/libqtile/lazy.py index 302b2d1885..b3c14f945e 100644 --- a/libqtile/lazy.py +++ b/libqtile/lazy.py @@ -30,7 +30,7 @@ from typing import Callable, Iterable from libqtile.command.graph import SelectorType - from libqtile.config import Match + from libqtile.config import _Match class LazyCall: @@ -50,7 +50,7 @@ def __init__(self, call: CommandGraphCall, args: tuple, kwargs: dict) -> None: self._args = args self._kwargs = kwargs - self._focused: Match | None = None + self._focused: _Match | None = None self._if_no_focused: bool = False self._layouts: set[str] = set() self._when_floating = True @@ -96,7 +96,7 @@ def kwargs(self) -> dict: def when( self, - focused: Match | None = None, + focused: _Match | None = None, if_no_focused: bool = False, layout: Iterable[str] | str | None = None, when_floating: bool = True, diff --git a/libqtile/scratchpad.py b/libqtile/scratchpad.py index c7c4c0760f..84be859919 100644 --- a/libqtile/scratchpad.py +++ b/libqtile/scratchpad.py @@ -25,7 +25,7 @@ from libqtile import config, group, hook from libqtile.backend.base import FloatStates from libqtile.command.base import expose_command -from libqtile.config import Match +from libqtile.config import Match, _Match if TYPE_CHECKING: from libqtile.backend.base import Window @@ -243,7 +243,7 @@ def __init__( group._Group.__init__(self, name, label=label) self._dropdownconfig = {dd.name: dd for dd in dropdowns} if dropdowns is not None else {} self.dropdowns: dict[str, DropDownToggler] = {} - self._spawned: dict[str, Match] = {} + self._spawned: dict[str, _Match] = {} self._to_hide: list[str] = [] self._single = single diff --git a/test/test_match.py b/test/test_match.py new file mode 100644 index 0000000000..39581dec26 --- /dev/null +++ b/test/test_match.py @@ -0,0 +1,107 @@ +# Copyright (c) 2024 elParaguayo +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import re + +import pytest + +from libqtile import layout +from libqtile.config import Match, Screen +from libqtile.confreader import Config + + +@pytest.fixture(scope="function") +def manager(manager_nospawn, request): + class MatchConfig(Config): + rules = getattr(request, "param", list()) + if not isinstance(rules, (list, tuple)): + rules = [rules] + + screens = [Screen()] + floating_layout = layout.Floating(float_rules=[*rules]) + + manager_nospawn.start(MatchConfig) + + yield manager_nospawn + + +def configure_rules(*args): + return pytest.mark.parametrize("manager", [args], indirect=True) + + +def assert_float(manager, name, floating=True): + manager.test_window(name) + assert manager.c.window.info()["floating"] is floating + manager.c.window.kill() + + +@configure_rules(Match(title="floatme")) +@pytest.mark.parametrize( + "name,result", [("normal", False), ("floatme", True), ("floatmetoo", False)] +) +def test_single_rule(manager, name, result): + """Single string must be exact match""" + assert_float(manager, name, result) + + +@configure_rules(Match(title=re.compile(r"floatme"))) +@pytest.mark.parametrize( + "name,result", [("normal", False), ("floatme", True), ("floatmetoo", True)] +) +def test_single_regex_rule(manager, name, result): + """Regex to match substring""" + assert_float(manager, name, result) + + +@configure_rules(~Match(title="floatme")) +@pytest.mark.parametrize( + "name,result", [("normal", True), ("floatme", False), ("floatmetoo", True)] +) +def test_not_rule(manager, name, result): + """Invert match rule""" + assert_float(manager, name, result) + + +@configure_rules(Match(title="floatme") | Match(title="floating")) +@pytest.mark.parametrize( + "name,result", + [("normal", False), ("floatme", True), ("floating", True), ("floatmetoo", False)], +) +def test_or_rule(manager, name, result): + """Invert match rule""" + assert_float(manager, name, result) + + +@configure_rules(Match(title=re.compile(r"^floatme")) & Match(title=re.compile(r".*too$"))) +@pytest.mark.parametrize( + "name,result", [("normal", False), ("floatme", False), ("floatmetoo", True)] +) +def test_and_rule(manager, name, result): + """Combine match rules""" + assert_float(manager, name, result) + + +@configure_rules(Match(title=re.compile(r"^floatme")) ^ Match(title=re.compile(r".*too$"))) +@pytest.mark.parametrize( + "name,result", + [("normal", False), ("floatme", True), ("floatmetoo", False), ("thisfloatstoo", True)], +) +def test_xor_rule(manager, name, result): + """Combine match rules""" + assert_float(manager, name, result)