Skip to content

Commit

Permalink
Merge 76807ef into 8d254f6
Browse files Browse the repository at this point in the history
  • Loading branch information
elParaguayo committed May 1, 2024
2 parents 8d254f6 + 76807ef commit 9320245
Show file tree
Hide file tree
Showing 9 changed files with 233 additions and 18 deletions.
2 changes: 1 addition & 1 deletion libqtile/backend/base/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion libqtile/backend/wayland/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
114 changes: 111 additions & 3 deletions libqtile/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<InvertMatch(%r)>" % 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 "<MatchAll(%r)>" % (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 "<MatchAny(%r)>" % (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 "<MatchOnlyOne(%r, %r)>" % (self.match1, self.match2)


class Match(_Match):
"""
Window properties to compare (match) with a window.
Expand Down Expand Up @@ -866,6 +944,36 @@ class Match:
wid:
Match against the window ID. This is a unique ID given to each window.
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)))
"""

def __init__(
Expand Down Expand Up @@ -1002,13 +1110,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
Expand Down
6 changes: 3 additions & 3 deletions libqtile/layout/floating.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"),
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions libqtile/layout/screensplit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)):
Expand All @@ -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.")
Expand Down
4 changes: 2 additions & 2 deletions libqtile/layout/tile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions libqtile/lazy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions libqtile/scratchpad.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
107 changes: 107 additions & 0 deletions test/test_match.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit 9320245

Please sign in to comment.