# Copyright (c) 2008, Aldo Cortesi. All rights reserved.
#
# 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.
from __future__ import annotations
import typing
from collections import defaultdict
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, is_valid_colors
if typing.TYPE_CHECKING:
import asyncio
from typing import Any
from libqtile.backend.base import Drawer, Internal, WindowType
from libqtile.command.base import ItemT
from libqtile.config import Screen
from libqtile.core.manager import Qtile
from libqtile.utils import ColorsType
from libqtile.widget.base import _Widget
NESW = ("top", "right", "bottom", "left")
[docs]class Gap:
"""A gap placed along one of the edges of the screen
Qtile will avoid covering gaps with windows.
Parameters
==========
size :
The "thickness" of the gap, i.e. the height of a horizontal gap, or the
width of a vertical gap.
"""
def __init__(self, size: int) -> None:
# 'size' corresponds to the height of a horizontal gap, or the width
# of a vertical gap
self._size = size
self._initial_size = size
# '_length' corresponds to the width of a horizontal gap, or the height
# of a vertical gap
self._length: int = 0
self.qtile: Qtile | None = None
self.screen: Screen | None = None
self.x: int = 0
self.y: int = 0
self.width: int = 0
self.height: int = 0
self.horizontal: bool = False
# Additional reserved around the gap/bar, used when space is dynamically
# reserved e.g. by third-party bars.
self.margin: list[int] = [0, 0, 0, 0] # [N, E, S, W]
def _configure(self, qtile: Qtile, screen: Screen, reconfigure: bool = False) -> None:
self.qtile = qtile
self.screen = screen
self._size = self._initial_size
# If both horizontal and vertical gaps are present, screen corners are
# given to the horizontal ones
if screen.top is self:
self.x = screen.x + self.margin[3]
self.y = screen.y + self.margin[0]
self._length = screen.width - self.margin[1] - self.margin[3]
self.width = self._length
self.height = self._initial_size
self.horizontal = True
self._size += self.margin[0] + self.margin[2]
elif screen.bottom is self:
self.x = screen.x + self.margin[3]
self.y = screen.dy + screen.dheight - self.margin[2]
self._length = screen.width - self.margin[1] - self.margin[3]
self.width = self._length
self.height = self._initial_size
self.horizontal = True
self._size += self.margin[0] + self.margin[2]
elif screen.left is self:
self.x = screen.x + self.margin[3]
self.y = screen.dy + self.margin[0]
self._length = screen.dheight - self.margin[0] - self.margin[2]
self.width = self._initial_size
self.height = self._length
self.horizontal = False
self._size += self.margin[1] + self.margin[3]
else: # right
self.x = screen.dx + screen.dwidth - self.margin[1]
self.y = screen.dy + self.margin[0]
self._length = screen.dheight - self.margin[0] - self.margin[2]
self.width = self._initial_size
self.height = self._length
self.horizontal = False
self._size += self.margin[1] + self.margin[3]
def draw(self) -> None:
pass
def finalize(self) -> None:
pass
def geometry(self) -> tuple[int, int, int, int]:
return (self.x, self.y, self.width, self.height)
@property
def size(self) -> int:
# Enforce immutability of gap.size/bar.size
return self._size
@property
def position(self) -> str:
for i in NESW:
if getattr(self.screen, i) is self:
return i
assert False, "Not reached"
def adjust_reserved_space(self, size: int) -> None:
for i, side in enumerate(NESW):
if getattr(self.screen, side) is self:
self.margin[i] += size
if self.margin[i] < 0:
raise ValueError("Gap/Bar can't reserve negative space.")
@expose_command()
def info(self) -> dict[str, Any]:
"""
Info for this object.
"""
return dict(position=self.position)
class Obj:
def __init__(self, name: str) -> None:
self.name = name
def __str__(self) -> str:
return self.name
def __repr__(self) -> str:
return self.name
STRETCH = Obj("STRETCH")
CALCULATED = Obj("CALCULATED")
STATIC = Obj("STATIC")
[docs]class Bar(Gap, configurable.Configurable, CommandObject):
"""A bar, which can contain widgets
Parameters
==========
widgets :
A list of widget objects.
size :
The "thickness" of the bar, i.e. the height of a horizontal bar, or the
width of a vertical bar.
"""
defaults = [
("background", "#000000", "Background colour."),
("opacity", 1, "Bar window opacity."),
("margin", 0, "Space around bar as int or list of ints [N E S W]."),
("border_color", "#000000", "Border colour as str or list of str [N E S W]"),
("border_width", 0, "Width of border as int of list of ints [N E S W]"),
(
"reserve",
True,
"Reserve screen space (when set to 'False', bar will be drawn above windows).",
),
]
def __init__(self, widgets: list[_Widget], size: int, **config: Any) -> None:
Gap.__init__(self, size)
configurable.Configurable.__init__(self, **config)
self.add_defaults(Bar.defaults)
# We need to create a new widget list here as users may have the same list for multiple
# screens. In that scenario, if we replace the widget with a mirror, it replaces it in every
# bar as python is referring to the same list.
self.widgets = widgets.copy()
self.window: Internal | None = None
self.drawer: Drawer
self._configured = False
self._draw_queued = False
self.future: asyncio.Handle | None = None
# The part of the margins that was reserved by clients
self._reserved_space: list[int] = [0, 0, 0, 0] # [N, E, S, W]
self._reserved_space_updated = False
# Size saved when hiding the bar
self._saved_size = 0
# Previous window when the bar grabs the keyboard
self._saved_focus: WindowType | None = None
# Track widgets that are receiving input
self._has_cursor: _Widget | None = None
self._has_keyboard: _Widget | None = None
# Because Gap.__init__ also sets self.margin
self.margin = config.get("margin", self.margin)
# Hacky solution that shows limitations of typing Configurable. We want the
# option to accept `int | list[int]` but the attribute to be `list[int]`.
self.margin: list[int]
if isinstance(self.margin, int): # type: ignore [unreachable]
self.margin = [self.margin] * 4 # type: ignore [unreachable]
self.border_width: list[int]
if isinstance(self.border_width, int): # type: ignore [unreachable]
self.border_width = [self.border_width] * 4 # type: ignore [unreachable]
self.border_color: ColorsType
# Check if colours are valid but don't convert to rgba here
if is_valid_colors(self.border_color):
if not isinstance(self.border_color, list):
self.border_color = [self.border_color] * 4
else:
logger.warning("Invalid border_color specified. Borders will not be displayed.")
self.border_width = [0, 0, 0, 0]
def _configure(self, qtile: Qtile, screen: Screen, reconfigure: bool = False) -> None:
"""
Configure the bar. `reconfigure` is set to True when screen dimensions
change, forcing a recalculation of the bar's dimensions.
"""
# We only want to adjust margin sizes once unless there's new space being
# reserved or we're reconfiguring the bar because the screen has changed
if not self._configured or self._reserved_space_updated or reconfigure:
Gap._configure(self, qtile, screen)
if any(self.margin) or any(self.border_width) or self._reserved_space_updated:
# Increase the margin size for the border. The border will be drawn
# in this space so the empty space will just be the margin.
margin = [b + s for b, s in zip(self.border_width, self._reserved_space)]
if self.horizontal:
self.x += margin[3] - self.border_width[3]
self.width -= margin[1] + margin[3]
self._length = self.width
self._size += margin[0] + margin[2]
if screen.top is self:
self.y += margin[0] - self.border_width[0]
else:
self.y -= margin[2] + self.border_width[2]
else:
self.y += margin[0] - self.border_width[0]
self.height -= margin[0] + margin[2]
self._length = self.height
self._size += margin[1] + margin[3]
if screen.left is self:
self.x += margin[3] - self.border_width[3]
else:
self.x -= margin[1] + self.border_width[1]
if screen.bottom is self and not self.reserve:
self.y -= self.height + self.margin[2]
elif screen.right is self and not self.reserve:
self.x -= self.width + self.margin[1]
self._reserved_space_updated = False
width = self.width + (self.border_width[1] + self.border_width[3])
height = self.height + (self.border_width[0] + self.border_width[2])
if self.window:
# We get _configure()-ed with an existing window when screens are getting
# reconfigured but this screen is present both before and after
self.window.place(self.x, self.y, width, height, 0, None)
else:
# Whereas we won't have a window if we're startup up for the first time or
# the window has been killed by us no longer using the bar's screen
# X11 only:
# To preserve correct display of SysTray widget, we need a 24-bit
# window where the user requests an opaque bar.
if qtile.core.name == "x11":
depth = (
32
if has_transparency(self.background)
else qtile.core.conn.default_screen.root_depth
)
self.window = qtile.core.create_internal( # type: ignore [call-arg]
self.x, self.y, width, height, depth
)
else:
self.window = qtile.core.create_internal(self.x, self.y, width, height)
self.window.opacity = self.opacity
self.window.unhide()
self.window.process_window_expose = self.draw
self.window.process_button_click = self.process_button_click
self.window.process_button_release = self.process_button_release
self.window.process_pointer_enter = self.process_pointer_enter
self.window.process_pointer_leave = self.process_pointer_leave
self.window.process_pointer_motion = self.process_pointer_motion
self.window.process_key_press = self.process_key_press
if hasattr(self, "drawer"):
self.drawer.width = width
self.drawer.height = height
else:
self.drawer = self.window.create_drawer(width, height)
self.drawer.clear(self.background)
crashed_widgets: set[_Widget] = set()
qtile.renamed_widgets = []
if self._configured:
for i in self.widgets:
if not self._configure_widget(i):
crashed_widgets.add(i)
else:
for idx, i in enumerate(self.widgets):
# Create a mirror if this widget is already configured but isn't a Mirror
# We don't do isinstance(i, Mirror) because importing Mirror (at the top)
# would give a circular import as libqtile.widget.base imports lbqtile.bar
if i.configured and i.__class__.__name__ != "Mirror":
i = i.create_mirror()
self.widgets[idx] = i
if self._configure_widget(i):
qtile.register_widget(i)
else:
crashed_widgets.add(i)
# Alert the user that we've renamed some widgets
if qtile.renamed_widgets:
logger.info(
"The following widgets were renamed in qtile.widgets_map: %s "
"To bind commands, rename the widget or use lazy.widget[new_name].",
", ".join(qtile.renamed_widgets),
)
qtile.renamed_widgets.clear()
hook.subscribe.setgroup(self.set_layer)
hook.subscribe.startup_complete(self.set_layer)
self._remove_crashed_widgets(crashed_widgets)
self.draw()
self._resize(self._length, self.widgets)
self._configured = True
def _configure_widget(self, widget: _Widget) -> bool:
assert self.qtile is not None
if widget.supported_backends and (self.qtile.core.name not in widget.supported_backends):
logger.warning(
"Widget removed: %s does not support %s.",
widget.__class__.__name__,
self.qtile.core.name,
)
return False
try:
widget._configure(self.qtile, self)
if self.horizontal:
widget.offsety = self.border_width[0]
else:
widget.offsetx = self.border_width[3]
widget.configured = True
except Exception:
logger.exception(
"%s widget crashed during _configure with error:", widget.__class__.__name__
)
return False
return True
def _remove_crashed_widgets(self, crashed_widgets: set[_Widget]) -> None:
if not crashed_widgets:
return
assert self.qtile is not None
from libqtile.widget.config_error import ConfigErrorWidget
for i in crashed_widgets:
index = self.widgets.index(i)
# Widgets that aren't available on the current backend should not
# be shown as "crashed" as the behaviour is expected. Only notify
# for genuine crashes.
if not i.supported_backends or (self.qtile.core.name in i.supported_backends):
crash = ConfigErrorWidget(widget=i)
crash._configure(self.qtile, self)
if self.horizontal:
crash.offsety = self.border_width[0]
else:
crash.offsetx = self.border_width[3]
self.widgets.insert(index, crash)
self.widgets.remove(i)
def _items(self, name: str) -> ItemT:
if name == "screen" and self.screen is not None:
return True, []
elif name == "widget" and self.widgets:
return False, [w.name for w in self.widgets]
return None
def _select(self, name: str, sel: str | int | None) -> CommandObject | None:
if name == "screen":
return self.screen
elif name == "widget":
for widget in self.widgets:
if widget.name == sel:
return widget
return None
def finalize(self) -> None:
if self.future:
self.future.cancel()
self.drawer.finalize()
if self.window:
self.window.kill()
self.window = None
self.widgets.clear()
def _resize(self, length: int, widgets: list[_Widget]) -> None:
# We want consecutive stretch widgets to split one 'block' of space between them
stretches = []
consecutive_stretches: defaultdict[_Widget, list[_Widget]] = defaultdict(list)
prev_stretch: _Widget | None = None
for widget in widgets:
if widget.length_type == STRETCH:
if prev_stretch:
consecutive_stretches[prev_stretch].append(widget)
else:
stretches.append(widget)
prev_stretch = widget
else:
prev_stretch = None
if stretches:
stretchspace = length - sum(i.length for i in widgets if i.length_type != STRETCH)
stretchspace = max(stretchspace, 0)
num_stretches = len(stretches)
if num_stretches == 1:
stretches[0].length = stretchspace
else:
block = 0
blocks = []
for i in widgets:
if i.length_type != STRETCH:
block += i.length
elif i in stretches: # False for consecutive_stretches
blocks.append(block)
block = 0
if block:
blocks.append(block)
interval = length // num_stretches
for idx, i in enumerate(stretches):
if idx == 0:
i.length = interval - blocks[0] - blocks[1] // 2
elif idx == num_stretches - 1:
i.length = interval - blocks[-1] - blocks[-2] // 2
else:
i.length = int(interval - blocks[idx] / 2 - blocks[idx + 1] / 2)
stretchspace -= i.length
stretches[0].length += stretchspace // 2
stretches[-1].length += stretchspace - stretchspace // 2
for i, followers in consecutive_stretches.items():
length = i.length // (len(followers) + 1)
rem = i.length - length
i.length = length
for f in followers:
f.length = length
rem -= length
i.length += rem
if self.horizontal:
offset = self.border_width[3]
for i in widgets:
i.offsetx = offset
offset += i.length
else:
offset = self.border_width[0]
for i in widgets:
i.offsety = offset
offset += i.length
def get_widget_in_position(self, x: int, y: int) -> _Widget | None:
if self.horizontal:
for i in self.widgets:
if x < i.offsetx + i.length:
return i
else:
for i in self.widgets:
if y < i.offsety + i.length:
return i
return None
def process_button_click(self, x: int, y: int, button: int) -> None:
assert self.qtile is not None
# If we're clicking on a bar that's not on the current screen, focus that screen
if self.screen and self.screen is not self.qtile.current_screen:
if self.qtile.core.name == "x11" and self.qtile.current_window:
self.qtile.current_window._grab_click()
index = self.qtile.screens.index(self.screen)
self.qtile.focus_screen(index, warp=False)
widget = self.get_widget_in_position(x, y)
if widget:
widget.button_press(
x - widget.offsetx,
y - widget.offsety,
button,
)
def process_button_release(self, x: int, y: int, button: int) -> None:
widget = self.get_widget_in_position(x, y)
if widget:
widget.button_release(
x - widget.offsetx,
y - widget.offsety,
button,
)
def process_pointer_enter(self, x: int, y: int) -> None:
widget = self.get_widget_in_position(x, y)
if widget:
widget.mouse_enter(
x - widget.offsetx,
y - widget.offsety,
)
self._has_cursor = widget
def process_pointer_leave(self, x: int, y: int) -> None:
if self._has_cursor:
self._has_cursor.mouse_leave(
x - self._has_cursor.offsetx,
y - self._has_cursor.offsety,
)
self._has_cursor = None
def process_pointer_motion(self, x: int, y: int) -> None:
widget = self.get_widget_in_position(x, y)
if widget and self._has_cursor and widget is not self._has_cursor:
self._has_cursor.mouse_leave(
x - self._has_cursor.offsetx,
y - self._has_cursor.offsety,
)
widget.mouse_enter(
x - widget.offsetx,
y - widget.offsety,
)
self._has_cursor = widget
def process_key_press(self, keycode: int) -> None:
if self._has_keyboard:
self._has_keyboard.process_key_press(keycode)
def widget_grab_keyboard(self, widget: _Widget) -> None:
"""
A widget can call this method to grab the keyboard focus
and receive keyboard messages. When done,
widget_ungrab_keyboard() must be called.
"""
assert self.qtile is not None
self._has_keyboard = widget
self._saved_focus = self.qtile.current_window
if self.window:
self.window.focus(False)
def widget_ungrab_keyboard(self) -> None:
"""
Removes keyboard focus from the widget.
"""
if self._saved_focus is not None:
self._saved_focus.focus(False)
self._has_keyboard = None
def draw(self) -> None:
assert self.qtile is not None
if not self.widgets:
return # calling self._actual_draw in this case would cause a NameError.
if not self._draw_queued:
# Delay actually drawing the bar until the event loop is idle, and only once
# even if this method is called multiple times during the same task.
self.future = self.qtile.call_soon(self._actual_draw)
self._draw_queued = True
def _actual_draw(self) -> None:
self._draw_queued = False
self._resize(self._length, self.widgets)
# We draw the border before the widgets
if any(self.border_width):
# The border is drawn "outside" of the bar (i.e. not in the space that the
# widgets occupy) so we need to add the additional space
width = self.width + self.border_width[1] + self.border_width[3]
height = self.height + self.border_width[0] + self.border_width[2]
# line_opts is a list of tuples where each tuple represents the borders
# in the order N, E, S, W. The border tuple contains two pairs of
# co-ordinates for the start and end of the border.
rects = [
(0, 0, width, self.border_width[0]),
(
width - (self.border_width[1]),
self.border_width[0],
self.border_width[1],
height - self.border_width[0] - self.border_width[2],
),
(0, height - self.border_width[2], width, self.border_width[2]),
(
0,
self.border_width[0],
self.border_width[3],
height - self.border_width[0] - self.border_width[2],
),
]
for border_width, colour, rect in zip(self.border_width, self.border_color, rects):
if not border_width:
continue
# Draw the border
self.drawer.clear_rect(*rect)
self.drawer.ctx.rectangle(*rect)
self.drawer.set_source_rgb(colour) # type: ignore[arg-type]
self.drawer.ctx.fill()
src_x, src_y, width, height = rect
self.drawer.draw(
offsetx=src_x,
offsety=src_y,
width=width,
height=height,
src_x=src_x,
src_y=src_y,
)
for i in self.widgets:
i.draw()
# We need to check if there is any unoccupied space in the bar
# This can happen where there are no SPACER-type widgets to fill
# empty space.
# In that scenario, we fill the empty space with the bar background colour
# We do this, instead of just filling the bar completely at the start of this
# method to avoid flickering.
# Widgets are offset by the top/left border but this is not included in self._length
# so we adjust the end of the bar area for this offset
if self.horizontal:
bar_end = self._length + self.border_width[3]
else:
bar_end = self._length + self.border_width[0]
widget_end = i.offset + i.length
if widget_end < bar_end:
# Defines a rectangle for the area enclosed by the bar's borders and the end of the
# last widget.
if self.horizontal:
rect = (widget_end, self.border_width[0], bar_end - widget_end, self.height)
else:
rect = (self.border_width[3], widget_end, self.width, bar_end - widget_end)
# Clear that area (i.e. don't clear borders) and fill with background colour
self.drawer.clear_rect(*rect)
self.drawer.ctx.rectangle(*rect)
self.drawer.set_source_rgb(self.background)
self.drawer.ctx.fill()
x, y, w, h = rect
self.drawer.draw(offsetx=x, offsety=y, height=h, width=w, src_x=x, src_y=y)
[docs] @expose_command()
def info(self) -> dict[str, Any]:
return dict(
size=self._size,
length=self._length,
width=self.width,
height=self.height,
position=self.position,
widgets=[i.info() for i in self.widgets],
window=self.window.wid if self.window else None,
)
def is_show(self) -> bool:
return self._size != 0
def show(self, is_show: bool = True) -> None:
if is_show != self.is_show():
if is_show:
self._size = self._saved_size
if self.window:
self.window.unhide()
else:
self._saved_size = self._size
self._size = 0
if self.window:
self.window.hide()
if self.screen and self.screen.group:
self.screen.group.layout_all()
def adjust_reserved_space(self, size: int) -> None:
if self._size:
# is this necessary?
self._size = self._initial_size
for i, side in enumerate(NESW):
if getattr(self.screen, side) is self:
self._reserved_space[i] += size
if self._reserved_space[i] < 0:
raise ValueError("Gap/Bar can't reserve negative space.")
self._reserved_space_updated = True
def set_layer(self) -> None:
if self.window:
if self.reserve:
self.window.keep_below(enable=True)
else:
# Bar is not reserving screen space so let's keep above other windows
self.window.keep_above(enable=True)
BarType = Bar | Gap