Source code for qtile_expanded.extensions.popup
"""
Popup utilities for Qtile Expanded.
This module provides popup window functionality for displaying notifications
and other temporary UI elements.
Features:
- NotificationPopup: Full-screen popup for displaying notifications
- SimplePopup: General-purpose centered popup
- Button: Clickable elements within popups
- Layout helpers for organizing popup content
All popups inherit from BasePopup which provides:
- Automatic registration when shown
- Automatic unregistration when hidden
- External control via close_all_popups() function
"""
from libqtile import pangocffi
from libqtile.log_utils import logger
# Import BasePopup for inheritance
from .base_popup import BasePopup
[docs]
class NotificationPopup(BasePopup):
"""
A full-screen popup for displaying notifications.
This popup covers the entire screen and displays a list of notifications
with the ability to dismiss them individually or all at once.
Inherits from BasePopup, which automatically registers the popup when shown
and unregisters when hidden, enabling external control via close_all_popups().
Args:
qtile: The Qtile instance
notifications: List of notification dicts with keys:
- app_name: Application name
- summary: Notification summary
- body: Notification body text
- icon: Optional icon path
- urgency: Urgency level (0=low, 1=normal, 2=critical)
- timestamp: When the notification was received
on_close: Callback when popup is closed
on_clear: Callback when all notifications are cleared
on_dismiss: Callback when a specific notification is dismissed
"""
defaults = [
("background", "#1a1a2e", "Background color of the popup."),
("foreground", "#eeeeee", "Text color."),
("border_color", "#333333", "Border color."),
("border_width", 2, "Border width."),
("corner_radius", 8, "Corner radius for rounded corners."),
("opacity", 0.95, "Opacity of the popup (0.0 to 1.0)."),
("font", "sans", "Font family."),
("fontsize", 14, "Base font size."),
("title_fontsize", 18, "Font size for notification titles."),
("padding", 20, "Padding around the popup content."),
("item_padding", 15, "Padding between notification items."),
("max_width", 800, "Maximum width for notification content."),
("max_notifications", 10, "Maximum number of notifications to display."),
("trash_icon", "\u267b", "Unicode trash bin icon."),
("trash_size", 24, "Size of the trash icon."),
("close_on_click_outside", True, "Close popup when clicking outside."),
]
[docs]
def __init__(
self,
qtile,
notifications=None,
on_close=None,
on_clear=None,
on_dismiss=None,
**config
):
"""Initialize the notification popup."""
# Set up popup to cover full screen
screen = qtile.current_screen
super().__init__(
qtile,
x=0,
y=0,
width=screen.width,
height=screen.height,
**config
)
self.qtile = qtile
self.notifications = notifications or []
self.on_close = on_close
self.on_clear = on_clear
self.on_dismiss = on_dismiss
# Track which notification is being hovered
self._hovered_index = None
self._trash_hovered = False
# Set up button click handler
self.win.process_button_click = self._on_click
self.win.process_button_motion = self._on_motion
def _on_click(self, x, y, button):
"""Handle mouse clicks on the popup."""
if button == 1: # Left click
# Check if trash icon was clicked
trash_x = self.width - self.padding - self.trash_size
trash_y = self.padding
if (trash_x <= x <= trash_x + self.trash_size and
trash_y <= y <= trash_y + self.trash_size):
self._clear_all()
return
# Check if a notification was clicked
if self._hovered_index is not None:
self._dismiss_notification(self._hovered_index)
return
# Click outside - close popup
if self.close_on_click_outside:
self.hide()
if self.on_close:
self.on_close()
return True
def _on_motion(self, x, y, mask):
"""Handle mouse motion for hover detection."""
# Check trash icon hover
trash_x = self.width - self.padding - self.trash_size
trash_y = self.padding
self._trash_hovered = (trash_x <= x <= trash_x + self.trash_size and
trash_y <= y <= trash_y + self.trash_size)
# Check notification hover
y_pos = self.padding + self.title_fontsize + self.padding * 2
self._hovered_index = None
for i, notif in enumerate(self.notifications[:self.max_notifications]):
notif_height = self._calculate_notification_height(notif)
if y_pos <= y <= y_pos + notif_height:
self._hovered_index = i
break
y_pos += notif_height + self.item_padding
# Redraw to show hover states
self.draw()
return True
def _calculate_notification_height(self, notif):
"""Calculate the height needed for a notification."""
# Set up layout to measure text
self.layout.colour = self.foreground
self.layout.font = self.font
self.layout.fontsize = self.fontsize
# Calculate lines
summary_lines = self._count_text_lines(notif.get("summary", ""), self.max_width)
body_lines = self._count_text_lines(notif.get("body", ""), self.max_width)
line_height = self.layout.height
return (summary_lines + body_lines + 1) * line_height + self.item_padding
def _count_text_lines(self, text, max_width):
"""Count how many lines text will take given max width."""
if not text:
return 0
self.layout.text = pangocffi.markup_escape_text(text)
words = text.split()
if not words:
return 1
lines = 1
current_width = 0
for word in words:
self.layout.text = word
word_width = self.layout.width
if current_width + word_width > max_width and current_width > 0:
lines += 1
current_width = word_width
else:
current_width += word_width
return lines
def _clear_all(self):
"""Clear all notifications."""
if self.on_clear:
self.on_clear()
self.hide()
if self.on_close:
self.on_close()
def _dismiss_notification(self, index):
"""Dismiss a specific notification."""
if 0 <= index < len(self.notifications):
if self.on_dismiss:
self.on_dismiss(index)
self.hide()
if self.on_close:
self.on_close()
[docs]
def show(self, notifications=None):
"""Show the popup with the given notifications."""
if notifications is not None:
self.notifications = notifications
self._hovered_index = None
self._trash_hovered = False
self.place()
self.unhide()
self.draw()
[docs]
def update_notifications(self, notifications):
"""Update the list of notifications and redraw."""
self.notifications = notifications
if self.win.visible:
self.draw()
[docs]
def draw(self):
"""Draw the popup with all notifications."""
self.clear()
# Draw semi-transparent background
self.draw_box(
0, 0, self.width, self.height,
self.background + "80" # Add alpha to color
)
# Draw main content area
content_x = self.padding
content_y = self.padding
content_width = self.width - 2 * self.padding
content_height = self.height - 2 * self.padding
self.draw_box(
content_x, content_y, content_width, content_height,
self.background, self.border_color, self.border_width, self.corner_radius
)
# Draw title
self.layout.colour = self.foreground
self.layout.font = self.font
self.layout.fontsize = self.title_fontsize
self.layout.text = pangocffi.markup_escape_text("Notifications")
self.draw_text(content_x + self.padding, content_y + self.padding)
# Draw trash icon
trash_x = self.width - self.padding - self.trash_size
trash_y = self.padding
trash_color = "#ff4444" if self._trash_hovered else self.foreground
self.layout.colour = trash_color
self.layout.fontsize = self.trash_size
self.layout.text = self.trash_icon
self.draw_text(trash_x, trash_y)
# Draw notifications
y_pos = content_y + self.title_fontsize + self.padding * 2
for i, notif in enumerate(self.notifications[:self.max_notifications]):
notif_height = self._calculate_notification_height(notif)
# Check if this notification is hovered
is_hovered = (self._hovered_index == i)
# Draw notification background
notif_bg = "#2a2a3e" if is_hovered else "#1a1a2e"
self.draw_box(
content_x + self.padding, y_pos,
content_width - 2 * self.padding, notif_height,
notif_bg, None, 0, self.corner_radius // 2
)
# Draw notification content
self.layout.colour = self.foreground
self.layout.fontsize = self.title_fontsize
# Summary
summary = notif.get("summary", "")
if summary:
self.layout.text = pangocffi.markup_escape_text(summary)
self.draw_text(content_x + self.padding * 2, y_pos + self.padding)
# Body
self.layout.fontsize = self.fontsize
body = notif.get("body", "")
if body:
text_y = y_pos + self.title_fontsize + self.padding * 2
self.layout.text = pangocffi.markup_escape_text(body)
self.draw_text(content_x + self.padding * 2, text_y)
# App name (smaller, at bottom)
app_name = notif.get("app_name", "")
if app_name:
self.layout.fontsize = self.fontsize - 2
self.layout.colour = "#888888"
text_y = y_pos + notif_height - self.layout.height - self.padding
self.layout.text = pangocffi.markup_escape_text(app_name)
self.draw_text(content_x + self.padding * 2, text_y)
self.layout.colour = self.foreground
self.layout.fontsize = self.fontsize
y_pos += notif_height + self.item_padding
# Show message if no notifications
if not self.notifications:
self.layout.fontsize = self.title_fontsize
self.layout.colour = "#666666"
no_notif_text = "No notifications"
self.layout.text = no_notif_text
text_width = self.layout.width
text_x = content_x + (content_width - text_width) // 2
text_y = content_y + content_height // 2 - self.layout.height // 2
self.draw_text(text_x, text_y)
self.draw_border()
[docs]
class SimplePopup(BasePopup):
"""
A simple popup dialog for Qtile.
This is a more general-purpose popup that can be used for any purpose.
Inherits from BasePopup, which automatically registers the popup when shown
and unregisters when hidden, enabling external control via close_all_popups().
Args:
qtile: The Qtile instance
text: Text to display
**config: Additional Popup configuration
"""
defaults = [
("background", "#1a1a2e", "Background color."),
("foreground", "#eeeeee", "Text color."),
("border_color", "#333333", "Border color."),
("border_width", 2, "Border width."),
("corner_radius", 8, "Corner radius."),
("opacity", 0.95, "Opacity."),
("font", "sans", "Font family."),
("fontsize", 14, "Font size."),
("padding", 20, "Padding."),
("max_width", 400, "Maximum width."),
("timeout", None, "Auto-close timeout in seconds, or None."),
]
[docs]
def __init__(self, qtile, text="", **config):
"""Initialize the simple popup."""
screen = qtile.current_screen
super().__init__(
qtile,
x=(screen.width - config.get("width", 400)) // 2,
y=(screen.height - 200) // 2,
width=config.get("width", 400),
height=200,
**config
)
self.text = text
self.timeout = config.get("timeout")
self.win.process_button_click = self._on_click
def _on_click(self, x, y, button):
"""Handle clicks - close on any click."""
if button == 1:
self.hide()
return True
[docs]
def show(self, text=None):
"""Show the popup with optional new text."""
if text is not None:
self.text = text
self.place()
self.unhide()
self.draw()
if self.timeout:
self.qtile.call_later(self.timeout, self.hide)
[docs]
def draw(self):
"""Draw the popup."""
self.clear()
# Draw background
self.draw_box(
0, 0, self.width, self.height,
self.background + "80"
)
# Draw content area
self.draw_box(
self.border_width, self.border_width,
self.width - 2 * self.border_width,
self.height - 2 * self.border_width,
self.background, self.border_color, self.border_width, self.corner_radius
)
# Draw text
self.layout.colour = self.foreground
self.layout.font = self.font
self.layout.fontsize = self.fontsize
self.layout.text = pangocffi.markup_escape_text(self.text)
# Wrap text if needed
text_width = self.layout.width
if text_width > self.max_width:
# Simple word wrapping
words = self.text.split()
lines = []
current_line = []
current_width = 0
for word in words:
self.layout.text = word
word_w = self.layout.width
if current_width + word_w > self.max_width and current_width > 0:
lines.append(" ".join(current_line))
current_line = [word]
current_width = word_w
else:
current_line.append(word)
current_width += word_w
if current_line:
lines.append(" ".join(current_line))
# Draw wrapped text
line_height = self.layout.height
start_y = self.border_width + self.padding
for line in lines:
self.layout.text = pangocffi.markup_escape_text(line)
self.draw_text(self.border_width + self.padding, start_y)
start_y += line_height + 4
else:
self.draw_text(self.border_width + self.padding, self.border_width + self.padding)