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)