from __future__ import annotations
import contextlib
import enum
import math
import typing
from abc import ABCMeta, abstractmethod
import cairocffi
from libqtile import drawer, pangocffi, utils
from libqtile.command.base import CommandError, CommandObject
from libqtile.log_utils import logger
if typing.TYPE_CHECKING:
from typing import Any
from libqtile import config
from libqtile.command.base import ItemT
from libqtile.core.manager import Qtile
from libqtile.group import _Group
from libqtile.utils import ColorsType
class Core(CommandObject, metaclass=ABCMeta):
painter: Any
supports_restarting: bool = True
@property
@abstractmethod
def name(self) -> str:
"""The name of the backend"""
pass
def _items(self, name: str) -> ItemT:
return None
def _select(self, name, sel):
return None
@abstractmethod
def finalize(self):
"""Destructor/Clean up resources"""
@property
@abstractmethod
def display_name(self) -> str:
pass
@abstractmethod
def setup_listener(self, qtile: Qtile) -> None:
"""Setup a listener for the given qtile instance"""
@abstractmethod
def remove_listener(self) -> None:
"""Setup a listener for the given qtile instance"""
def update_desktops(self, groups: list[_Group], index: int) -> None:
"""Set the current desktops of the window manager"""
@abstractmethod
def get_screen_info(self) -> list[tuple[int, int, int, int]]:
"""Get the screen information"""
@abstractmethod
def grab_key(self, key: config.Key | config.KeyChord) -> tuple[int, int]:
"""Configure the backend to grab the key event"""
@abstractmethod
def ungrab_key(self, key: config.Key | config.KeyChord) -> tuple[int, int]:
"""Release the given key event"""
@abstractmethod
def ungrab_keys(self) -> None:
"""Release the grabbed key events"""
@abstractmethod
def grab_button(self, mouse: config.Mouse) -> int:
"""Configure the backend to grab the mouse event"""
@abstractmethod
def ungrab_buttons(self) -> None:
"""Release the grabbed button events"""
@abstractmethod
def grab_pointer(self) -> None:
"""Configure the backend to grab mouse events"""
@abstractmethod
def ungrab_pointer(self) -> None:
"""Release grabbed pointer events"""
def on_config_load(self, initial: bool) -> None:
"""
Respond to config loading. `initial` will be `True` if Qtile just started.
"""
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."""
yield
def create_internal(self, x: int, y: int, width: int, height: int) -> Internal:
"""Create an internal window controlled by Qtile."""
raise NotImplementedError # Only error when called, not when instantiating class
def flush(self) -> None:
"""If needed, flush the backend's event queue."""
def graceful_shutdown(self):
"""Try to close windows gracefully before exiting"""
def simulate_keypress(self, modifiers: list[str], key: str) -> None:
"""Simulate a keypress with given modifiers"""
def keysym_from_name(self, name: str) -> int:
"""Get the keysym for a key from its name"""
raise NotImplementedError
[docs] def cmd_info(self) -> dict:
"""Get basic information about the running backend."""
return {"backend": self.name, "display_name": self.display_name}
@enum.unique
class FloatStates(enum.Enum):
NOT_FLOATING = 1
FLOATING = 2
MAXIMIZED = 3
FULLSCREEN = 4
TOP = 5
MINIMIZED = 6
class _Window(CommandObject, metaclass=ABCMeta):
def __init__(self):
self.borderwidth: int = 0
self.name: str = "<no name>"
self.reserved_space: tuple[int, int, int, int] | None = None
# Window.cmd_static sets this in case it is hooked to client_new to stop the
# Window object from being managed, now that a Static is being used instead
self.defunct: bool = False
@property
@abstractmethod
def wid(self) -> int:
"""The unique window ID"""
@abstractmethod
def hide(self) -> None:
"""Hide the window"""
@abstractmethod
def unhide(self) -> None:
"""Unhide the window"""
def cmd_is_visible(self) -> bool:
"""Is this window visible (i.e. not hidden)?"""
return False
@abstractmethod
def kill(self) -> None:
"""Kill the window"""
def get_wm_class(self) -> list | None:
"""Return the class(es) of the window"""
return None
def get_wm_type(self) -> str | None:
"""Return the type of the window"""
return None
def get_wm_role(self) -> str | None:
"""Return the role of the window"""
return None
@property
def can_steal_focus(self):
"""Is it OK for this window to steal focus?"""
return True
def has_fixed_ratio(self) -> bool:
"""Does this window want a fixed aspect ratio?"""
return False
def has_fixed_size(self) -> bool:
"""Does this window want a fixed size?"""
return False
@property
def urgent(self):
"""Whether this window urgently wants focus"""
return False
@property
def opacity(self) -> float:
"""The opacity of this window from 0 (transparent) to 1 (opaque)."""
return self._opacity
@opacity.setter
def opacity(self, opacity: float) -> None:
"""Opacity setter."""
self._opacity = opacity
@abstractmethod
def place(
self,
x,
y,
width,
height,
borderwidth,
bordercolor,
above=False,
margin=None,
respect_hints=False,
):
"""Place the window in the given position."""
def _items(self, name: str) -> ItemT:
return None
def _select(self, name, sel):
return None
@abstractmethod
def info(self) -> dict[str, Any]:
"""
Return information on this window.
Mimimum required keys are:
- name
- x
- y
- width
- height
- group
- id
- wm_class
"""
return {}
def cmd_info(self) -> dict:
"""Return a dictionary of info."""
return self.info()
[docs]class Window(_Window, metaclass=ABCMeta):
"""
A regular Window belonging to a client.
Abstract methods are required to be defined as part of a specific backend's
implementation. Non-abstract methods have default implementations here to be shared
across backends.
"""
qtile: Qtile
# If float_x or float_y are None, the window has never floated
float_x: int | None
float_y: int | None
def __repr__(self):
return "%s(name=%r, wid=%i)" % (self.__class__.__name__, self.name, self.wid)
@property
@abstractmethod
def group(self) -> _Group | None:
"""The group to which this window belongs."""
@group.setter
def group(self, group: _Group | None) -> None:
"""Set the group."""
@property
def floating(self) -> bool:
"""Whether this window is floating."""
return False
@floating.setter
def floating(self, do_float: bool) -> None:
raise NotImplementedError
@property
def maximized(self) -> bool:
"""Whether this window is maximized."""
return False
@maximized.setter
def maximized(self, do_maximize: bool) -> None:
raise NotImplementedError
@property
def minimized(self) -> bool:
"""Whether this window is minimized."""
return False
@minimized.setter
def minimized(self, do_minimize: bool) -> None:
raise NotImplementedError
@property
def fullscreen(self) -> bool:
"""Whether this window is fullscreened."""
return False
@fullscreen.setter
def fullscreen(self, do_full: bool) -> None:
raise NotImplementedError
@property
def wants_to_fullscreen(self) -> bool:
"""Does this window want to be fullscreen?"""
return False
def match(self, match: config.Match) -> bool:
"""Compare this window against a Match instance."""
return match.compare(self)
@abstractmethod
def focus(self, warp: bool) -> None:
"""Focus this window and optional warp the pointer to it."""
@abstractmethod
def togroup(
self, group_name: str | None = None, *, switch_group: bool = False, toggle: bool = False
) -> None:
"""Move window to a specified group
Also switch to that group if switch_group is True.
If `toggle` is True and and the specified group is already on the screen,
use the last used group as target instead.
"""
@property
def has_focus(self):
return self == self.qtile.current_window
def has_user_set_position(self) -> bool:
"""Whether this window has user-defined geometry"""
return False
def is_transient_for(self) -> WindowType | None:
"""What window is this window a transient window for?"""
return None
@abstractmethod
def get_pid(self) -> int:
"""Return the PID that owns the window."""
def paint_borders(self, color: ColorsType, width: int) -> None:
"""Paint the window borders with the given color(s) and width"""
[docs] @abstractmethod
def cmd_focus(self, warp: bool = True) -> None:
"""Focuses the window."""
[docs] def cmd_match(self, *args, **kwargs) -> bool:
return self.match(*args, **kwargs)
[docs] @abstractmethod
def cmd_get_position(self) -> tuple[int, int]:
"""Get the (x, y) of the window"""
[docs] @abstractmethod
def cmd_get_size(self) -> tuple[int, int]:
"""Get the (width, height) of the window"""
[docs] @abstractmethod
def cmd_move_floating(self, dx: int, dy: int) -> None:
"""Move window by dx and dy"""
[docs] @abstractmethod
def cmd_resize_floating(self, dw: int, dh: int) -> None:
"""Add dw and dh to size of window"""
[docs] @abstractmethod
def cmd_set_position_floating(self, x: int, y: int) -> None:
"""Move window to x and y"""
[docs] @abstractmethod
def cmd_set_position(self, x: int, y: int) -> None:
"""
Move floating window to x and y; swap tiling window with the window under the
pointer.
"""
[docs] @abstractmethod
def cmd_set_size_floating(self, w: int, h: int) -> None:
"""Set window dimensions to w and h"""
[docs] @abstractmethod
def cmd_place(
self, x, y, width, height, borderwidth, bordercolor, above=False, margin=None
) -> None:
"""Place the window with the given position and geometry."""
[docs] @abstractmethod
def cmd_toggle_floating(self) -> None:
"""Toggle the floating state of the window."""
[docs] @abstractmethod
def cmd_enable_floating(self) -> None:
"""Float the window."""
[docs] @abstractmethod
def cmd_disable_floating(self) -> None:
"""Tile the window."""
[docs] @abstractmethod
def cmd_toggle_maximize(self) -> None:
"""Toggle the maximize state of the window."""
[docs] @abstractmethod
def cmd_toggle_minimize(self) -> None:
"""Toggle the minimize state of the window."""
[docs] @abstractmethod
def cmd_toggle_fullscreen(self) -> None:
"""Toggle the fullscreen state of the window."""
[docs] @abstractmethod
def cmd_enable_fullscreen(self) -> None:
"""Fullscreen the window"""
[docs] @abstractmethod
def cmd_disable_fullscreen(self) -> None:
"""Un-fullscreen the window"""
[docs] @abstractmethod
def cmd_bring_to_front(self) -> None:
"""Bring the window to the front"""
[docs] def cmd_togroup(
self,
group_name: str | None = None,
groupName: str | None = None, # Deprecated # noqa: N803
switch_group: bool = False,
toggle: bool = False,
) -> None:
"""Move window to a specified group
Also switch to that group if `switch_group` is True.
If `toggle` is True and and the specified group is already on the screen,
use the last used group as target instead.
`groupName` is deprecated and will be dropped soon. Please use `group_name`
instead.
"""
if groupName is not None:
logger.warning("Window.cmd_togroup's groupName is deprecated; use group_name")
group_name = groupName
self.togroup(group_name, switch_group=switch_group, toggle=toggle)
[docs] def cmd_toscreen(self, index: int | None = None) -> None:
"""Move window to a specified screen.
If index is not specified, we assume the current screen
Examples
========
Move window to current screen::
toscreen()
Move window to screen 0::
toscreen(0)
"""
if index is None:
screen = self.qtile.current_screen
else:
try:
screen = self.qtile.screens[index]
except IndexError:
raise CommandError("No such screen: %d" % index)
self.togroup(screen.group.name)
[docs] def cmd_opacity(self, opacity: float) -> None:
"""Set the window's opacity.
The value must be between 0 and 1 inclusive.
"""
if opacity < 0.1:
self.opacity = 0.1
elif opacity > 1:
self.opacity = 1
else:
self.opacity = opacity
[docs] def cmd_down_opacity(self) -> None:
"""Decrease the window's opacity by 10%."""
if self.opacity > 0.2:
# don't go completely clear
self.opacity -= 0.1
else:
self.opacity = 0.1
[docs] def cmd_up_opacity(self) -> None:
"""Increase the window's opacity by 10%."""
if self.opacity < 0.9:
self.opacity += 0.1
else:
self.opacity = 1
[docs] @abstractmethod
def cmd_kill(self) -> None:
"""Kill the window. Try to be polite."""
[docs] @abstractmethod
def cmd_static(
self,
screen: int | None = None,
x: int | None = None,
y: int | None = None,
width: int | None = None,
height: int | None = None,
) -> None:
"""Makes this window a static window, attached to a Screen.
Values left unspecified are taken from the existing window state.
"""
self.defunct = True
[docs] def cmd_center(self) -> None:
"""Centers a floating window on the screen."""
if not self.floating:
return
if not (self.group and self.group.screen):
return
screen = self.group.screen
x = (screen.width - self.width) // 2 # type: ignore
y = (screen.height - self.height) // 2 # type: ignore
self.place(
x,
y,
self.width, # type: ignore
self.height, # type: ignore
self.borderwidth,
self.bordercolor, # type: ignore
above=True,
respect_hints=True,
)
class Internal(_Window, metaclass=ABCMeta):
"""An Internal window belonging to Qtile."""
def __repr__(self):
return "Internal(wid=%s)" % self.wid
@abstractmethod
def create_drawer(self, width: int, height: int) -> Drawer:
"""Create a Drawer that draws to this window."""
def process_window_expose(self) -> None:
"""Respond to the window being exposed. Required by X11 backend."""
def process_button_click(self, x: int, y: int, button: int) -> None:
"""Handle a pointer button click."""
def process_button_release(self, x: int, y: int, button: int) -> None:
"""Handle a pointer button release."""
def process_pointer_enter(self, x: int, y: int) -> None:
"""Handle the pointer entering the window."""
def process_pointer_leave(self, x: int, y: int) -> None:
"""Handle the pointer leaving the window."""
def process_pointer_motion(self, x: int, y: int) -> None:
"""Handle pointer motion within the window."""
def process_key_press(self, keycode: int) -> None:
"""Handle a key press."""
class Static(_Window, metaclass=ABCMeta):
"""A window bound to a screen rather than a group."""
screen: config.Screen
x: Any
y: Any
width: Any
height: Any
def __repr__(self):
return "%s(name=%r, wid=%i)" % (self.__class__.__name__, self.name, self.wid)
def info(self) -> dict:
"""Return a dictionary of info."""
return dict(
name=self.name,
wm_class=self.get_wm_class(),
x=self.x,
y=self.y,
width=self.width,
height=self.height,
id=self.wid,
)
@abstractmethod
def cmd_bring_to_front(self) -> None:
"""Bring the window to the front"""
WindowType = typing.Union[Window, Internal, Static]
class Drawer:
"""A helper class for drawing to Internal windows.
We stage drawing operations locally in memory using a cairo RecordingSurface before
finally drawing all operations to a backend-specific target.
"""
# We need to track extent of drawing to know when to redraw.
previous_rect: tuple[int, int, int | None, int | None]
current_rect: tuple[int, int, int | None, int | None]
def __init__(self, qtile: Qtile, win: Internal, width: int, height: int):
self.qtile = qtile
self._win = win
self._width = width
self._height = height
self.surface: cairocffi.RecordingSurface
self.last_surface: cairocffi.RecordingSurface
self.ctx: cairocffi.Context
self._reset_surface()
self._has_mirrors = False
self.current_rect = (0, 0, 0, 0)
self.previous_rect = (-1, -1, -1, -1)
self._enabled = True
def finalize(self):
"""Destructor/Clean up resources"""
self.surface = None
self.ctx = None
@property
def has_mirrors(self):
return self._has_mirrors
@has_mirrors.setter
def has_mirrors(self, value):
if value and not self._has_mirrors:
self._create_last_surface()
self._has_mirrors = value
@property
def width(self) -> int:
return self._width
@width.setter
def width(self, width: int):
self._width = width
@property
def height(self) -> int:
return self._height
@height.setter
def height(self, height: int):
self._height = height
def _reset_surface(self):
"""This creates a fresh surface and cairo context."""
self.surface = cairocffi.RecordingSurface(
cairocffi.CONTENT_COLOR_ALPHA,
None,
)
self.ctx = self.new_ctx()
def _create_last_surface(self):
"""Creates a separate RecordingSurface for mirrors to access."""
self.last_surface = cairocffi.RecordingSurface(cairocffi.CONTENT_COLOR_ALPHA, None)
@property
def needs_update(self) -> bool:
# We can't test for the surface's ink_extents here on its own as a completely
# transparent background would not show any extents but we may still need to
# redraw (e.g. if a Spacer widget has changed position and/or size)
# Check if the size of the area being drawn has changed
rect_changed = self.current_rect != self.previous_rect
# Check if draw has content (would be False for completely transparent drawer)
ink_changed = any(not math.isclose(0.0, i) for i in self.surface.ink_extents())
return ink_changed or rect_changed
def paint_to(self, drawer: Drawer) -> None:
drawer.ctx.set_source_surface(self.last_surface)
drawer.ctx.paint()
def _rounded_rect(self, x, y, width, height, linewidth):
aspect = 1.0
corner_radius = height / 10.0
radius = corner_radius / aspect
degrees = math.pi / 180.0
self.ctx.new_sub_path()
delta = radius + linewidth / 2
self.ctx.arc(x + width - delta, y + delta, radius, -90 * degrees, 0 * degrees)
self.ctx.arc(x + width - delta, y + height - delta, radius, 0 * degrees, 90 * degrees)
self.ctx.arc(x + delta, y + height - delta, radius, 90 * degrees, 180 * degrees)
self.ctx.arc(x + delta, y + delta, radius, 180 * degrees, 270 * degrees)
self.ctx.close_path()
def rounded_rectangle(self, x: int, y: int, width: int, height: int, linewidth: int):
self._rounded_rect(x, y, width, height, linewidth)
self.ctx.set_line_width(linewidth)
self.ctx.stroke()
def rounded_fillrect(self, x: int, y: int, width: int, height: int, linewidth: int):
self._rounded_rect(x, y, width, height, linewidth)
self.ctx.fill()
def rectangle(self, x: int, y: int, width: int, height: int, linewidth: int = 2):
self.ctx.set_line_width(linewidth)
self.ctx.rectangle(x, y, width, height)
self.ctx.stroke()
def fillrect(self, x: int, y: int, width: int, height: int, linewidth: int = 2):
self.ctx.set_line_width(linewidth)
self.ctx.rectangle(x, y, width, height)
self.ctx.fill()
self.ctx.stroke()
def enable(self):
"""Enable drawing of surface to Internal window."""
self._enabled = True
def disable(self):
"""Disable drawing of surface to Internal window."""
self._enabled = False
def draw(
self,
offsetx: int = 0,
offsety: int = 0,
width: int | None = None,
height: int | None = None,
):
"""
A wrapper for the draw operation.
This draws our cached operations to the Internal window.
If Drawer has been disabled then the RecordingSurface will
be cleared if no mirrors are waiting to copy its contents.
Parameters
==========
offsetx :
the X offset to start drawing at.
offsety :
the Y offset to start drawing at.
width :
the X portion of the canvas to draw at the starting point.
height :
the Y portion of the canvas to draw at the starting point.
"""
if self._enabled:
self._draw(offsetx, offsety, width, height)
if self.has_mirrors:
self._create_last_surface()
ctx = cairocffi.Context(self.last_surface)
ctx.set_source_surface(self.surface)
ctx.paint()
self._reset_surface()
def _draw(
self,
offsetx: int = 0,
offsety: int = 0,
width: int | None = None,
height: int | None = None,
):
"""
This draws our cached operations to the Internal window.
Parameters
==========
offsetx :
the X offset to start drawing at.
offsety :
the Y offset to start drawing at.
width :
the X portion of the canvas to draw at the starting point.
height :
the Y portion of the canvas to draw at the starting point.
"""
def new_ctx(self):
return pangocffi.patch_cairo_context(cairocffi.Context(self.surface))
def set_source_rgb(self, colour: ColorsType, ctx: cairocffi.Context | None = None):
# If an alternate context is not provided then we draw to the
# drawer's default context
if ctx is None:
ctx = self.ctx
if isinstance(colour, list):
if len(colour) == 0:
# defaults to black
ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0)
elif len(colour) == 1:
ctx.set_source_rgba(*utils.rgb(colour[0]))
else:
linear = cairocffi.LinearGradient(0.0, 0.0, 0.0, self.height)
step_size = 1.0 / (len(colour) - 1)
step = 0.0
for c in colour:
linear.add_color_stop_rgba(step, *utils.rgb(c))
step += step_size
ctx.set_source(linear)
else:
ctx.set_source_rgba(*utils.rgb(colour))
def clear(self, colour):
self.set_source_rgb(colour)
self.ctx.rectangle(0, 0, self.width, self.height)
self.ctx.fill()
def textlayout(self, text, colour, font_family, font_size, font_shadow, markup=False, **kw):
"""Get a text layout"""
textlayout = drawer.TextLayout(
self, text, colour, font_family, font_size, font_shadow, markup=markup, **kw
)
return textlayout
def max_layout_size(self, texts, font_family, font_size):
sizelayout = self.textlayout("", "ffffff", font_family, font_size, None)
widths, heights = [], []
for i in texts:
sizelayout.text = i
widths.append(sizelayout.width)
heights.append(sizelayout.height)
return max(widths), max(heights)
def text_extents(self, text):
return self.ctx.text_extents(utils.scrub_to_utf8(text))
def font_extents(self):
return self.ctx.font_extents()
def fit_fontsize(self, heightlimit):
"""Try to find a maximum font size that fits any strings within the height"""
self.ctx.set_font_size(heightlimit)
asc, desc, height, _, _ = self.font_extents()
self.ctx.set_font_size(int(heightlimit * heightlimit / height))
return self.font_extents()
def fit_text(self, strings, heightlimit):
"""Try to find a maximum font size that fits all strings within the height"""
self.ctx.set_font_size(heightlimit)
_, _, _, maxheight, _, _ = self.ctx.text_extents("".join(strings))
if not maxheight:
return 0, 0
self.ctx.set_font_size(int(heightlimit * heightlimit / maxheight))
maxwidth, maxheight = 0, 0
for i in strings:
_, _, x, y, _, _ = self.ctx.text_extents(i)
maxwidth = max(maxwidth, x)
maxheight = max(maxheight, y)
return maxwidth, maxheight
def draw_vbar(self, color, x, y1, y2, linewidth=1):
self.set_source_rgb(color)
self.ctx.move_to(x, y1)
self.ctx.line_to(x, y2)
self.ctx.set_line_width(linewidth)
self.ctx.stroke()
def draw_hbar(self, color, x1, x2, y, linewidth=1):
self.set_source_rgb(color)
self.ctx.move_to(x1, y)
self.ctx.line_to(x2, y)
self.ctx.set_line_width(linewidth)
self.ctx.stroke()