from __future__ import annotations
import asyncio
import typing
from collections import defaultdict
from typing import Any
from libqtile import configurable, hook
from libqtile.command.base import CommandObject, ItemT, expose_command
from libqtile.log_utils import logger
from libqtile.utils import ColorsType, has_transparency, is_valid_colors
if typing.TYPE_CHECKING:
from libqtile.backend.base import Drawer, Internal, Window
from libqtile.config import Screen
from libqtile.core.manager import Qtile
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:
self.length: int = 0 # width of a horizontal gap or the height of a vertical gap
self.size: int = size # height of a horizontal gap or the width of a vertical gap
self.fullsize: int = size # sum of 'size' and margins
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.fullsize = self.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.size
self.horizontal = True
self.fullsize += 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.size
self.horizontal = True
self.fullsize += 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.size
self.height = self.length
self.horizontal = False
self.fullsize += 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.size
self.height = self.length
self.horizontal = False
self.fullsize += 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 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)
def has_keyboard(self) -> bool:
return False
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: Window | 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):
self.margin = [self.margin] * 4
self.border_width: list[int]
if isinstance(self.border_width, int):
self.border_width = [self.border_width] * 4
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.fullsize += 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.fullsize += 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
if qtile.core.name == "x11":
if has_transparency(self.background):
depth = 32
else:
depth = qtile.core.conn.default_screen.root_depth # type: ignore[attr-defined]
else:
depth = 32 # This could be anything as it's not needed for wayland.
self.window = qtile.core.create_internal(self.x, self.y, width, height, depth)
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):
if not i.hide_crash:
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()
for widget in self.widgets:
if not widget.finalized:
widget.finalize()
if hasattr(self, "drawer"):
self.drawer.finalize()
del self.drawer
if self.window:
self.window.kill()
self.window = None
def _resize(self, length: int, widgets: list[_Widget]) -> None:
"""
Resize stretch widgets to fill bar:
1 block of spacers uses all available space
2 blocks of spacers centre text between blocks
3 or more blocks evenly space widgets between blocks.
"""
# 1) Identify stretch group heads and their followers
stretches: list[_Widget] = []
consecutive_stretches: defaultdict[_Widget, list[_Widget]] = defaultdict(list)
prev_stretch: _Widget | None = None
for w in widgets:
if w.length_type == STRETCH:
if prev_stretch is None:
stretches.append(w) # start of a new stretch group
prev_stretch = w
else:
consecutive_stretches[prev_stretch].append(w) # follower
else:
prev_stretch = None
# 2) If there are stretch groups, allocate space to them
if stretches:
# Total space available to all stretch groups
fixed_total = sum(w.length for w in widgets if w.length_type != STRETCH)
stretchspace = max(length - fixed_total, 0)
def assign_group_allocation(head: _Widget, group_allocation: int) -> None:
"""Distribute group_allocation among head + its followers evenly; head keeps any remainder."""
followers = consecutive_stretches.get(head, [])
count = 1 + len(followers)
each = group_allocation // count
leftover = group_allocation - each * count
head.length = each + leftover
for f in followers:
f.length = each
num_groups = len(stretches)
blocks: list[int] = []
if num_groups == 1:
# Single group gets all available space
assign_group_allocation(stretches[0], stretchspace)
elif num_groups == 2:
# Special centering: center the fixed content between groups within the bar.
acc = 0
for w in widgets:
if w.length_type != STRETCH:
acc += w.length
elif w in stretches: # only group heads
blocks.append(acc)
acc = 0
blocks.append(acc)
start = blocks[0] if blocks else 0
end = blocks[-1] if blocks else 0
# L + R = stretchspace, and start + L == R + end => L = (stretchspace + end - start) // 2
left_alloc = (stretchspace + end - start) // 2
left_alloc = max(0, min(stretchspace, left_alloc))
right_alloc = stretchspace - left_alloc
assign_group_allocation(stretches[0], left_alloc)
assign_group_allocation(stretches[1], right_alloc)
else:
# 3+ groups: block-aware distribution
# Centres of blocks of non-stretch widgets are spaced evenly
# 1) Compute fixed-width blocks between group heads (and before first / after last)
acc = 0
for w in widgets:
if w.length_type != STRETCH:
acc += w.length
elif w in stretches: # count heads only, not followers
blocks.append(acc)
acc = 0
blocks.append(acc) # trailing block after last head
# 2) Tentative sizes using full bar interval minus adjacent block penalties
interval = length // num_groups
group_sizes: list[int] = []
for idx in range(num_groups):
if idx == 0:
size = interval - blocks[0] - (blocks[1] // 2 if len(blocks) > 1 else 0)
elif idx == num_groups - 1:
size = interval - blocks[-1] - (blocks[-2] // 2 if len(blocks) > 1 else 0)
else:
# halves around the middle groups;
left_half = blocks[idx] / 2 if idx < len(blocks) else 0
right_half = blocks[idx + 1] / 2 if idx + 1 < len(blocks) else 0
size = int(interval - left_half - right_half)
group_sizes.append(max(0, size))
# 3) Remainder from integer math goes to first and last
remainder = stretchspace - sum(group_sizes)
if remainder:
add_left = remainder // 2
add_right = remainder - add_left
group_sizes[0] += add_left
group_sizes[-1] += add_right
# 4) Distribute each group to head + followers
for head, alloc in zip(stretches, group_sizes):
assign_group_allocation(head, alloc)
# 3) Set offsets
if self.horizontal:
offset = self.border_width[3]
for w in widgets:
w.offsetx = offset
offset += w.length
else:
offset = self.border_width[0]
for w in widgets:
w.offsety = offset
offset += w.length
def get_widget_in_position(self, x: int, y: int) -> _Widget | None:
if self.horizontal:
if self.border_width[3] <= y < self.size:
for i in self.widgets:
if x < i.offsetx + i.length:
return i
else:
if self.border_width[0] <= x < self.size:
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:
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.
"""
assert self.qtile is not None
if self._saved_focus is not None and self._saved_focus.wid in self.qtile.windows_map:
self._saved_focus.focus(False)
self._saved_focus = None
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:
try:
i.draw()
except Exception:
logger.exception("Widget failed to 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]
widget_end = i.offsetx + i.length
else:
bar_end = self.length + self.border_width[0]
widget_end = i.offsety + 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(
length=self.length,
size=self.size,
fullsize=self.fullsize,
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.fullsize != 0
def show(self, is_show: bool = True) -> None:
if is_show != self.is_show():
if is_show:
self.fullsize = self._saved_size
if self.window:
self.window.unhide()
else:
self._saved_size = self.fullsize
self.fullsize = 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.fullsize:
# is this necessary?
self.fullsize = self.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)
def has_keyboard(self) -> bool:
return self._has_keyboard is not None
BarType = Bar | Gap