Skip to content

Commit

Permalink
Add boolean logic to Match objects
Browse files Browse the repository at this point in the history
This PR adds the ability to use bitwise operators on `Match` objects in
order to build more complex rules.

Match objects now support `|`, `&`, `~` and `^` operators.

Alternatively, users can use the wrapper classes directly:
`MatchAny`, `MatchAll`, `InvertMatch` and `MatchOnlyOne`.
  • Loading branch information
elParaguayo committed May 1, 2024
1 parent e6c8d6d commit 5438a80
Show file tree
Hide file tree
Showing 11 changed files with 289 additions and 18 deletions.
1 change: 1 addition & 0 deletions docs/manual/config/index.rst
Expand Up @@ -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:
Expand Down
85 changes: 85 additions & 0 deletions 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")))
2 changes: 1 addition & 1 deletion libqtile/backend/base/window.py
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
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
84 changes: 81 additions & 3 deletions libqtile/config.py
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 @@ -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
Expand Down
6 changes: 3 additions & 3 deletions libqtile/layout/floating.py
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
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
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
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
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

0 comments on commit 5438a80

Please sign in to comment.