Files
Modus/modules/dock/components/battery.py
T

546 lines
19 KiB
Python

from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.circularprogressbar import CircularProgressBar
from fabric.widgets.eventbox import EventBox
from fabric.widgets.label import Label
from fabric.widgets.revealer import Revealer
from gi.repository import Gdk, GLib
import config.data as data
import utils.icons as icons
from services.battery import Battery as BatteryService
from utils.wayland import WaylandWindow as Window
class BatteryPopup(Window):
"""Popup window that shows battery information and power profile switcher"""
def __init__(self, dock_component=None, **kwargs):
# Get dock position for proper anchoring
dock_position = data.DOCK_POSITION
popup_anchor = self._get_popup_anchor(dock_position)
margin = self._get_popup_margin(dock_position)
super().__init__(
name="battery-popup",
layer="top",
anchor=popup_anchor,
margin=margin,
exclusive=False,
keyboard_mode="on-demand",
visible=False,
all_visible=False,
**kwargs,
)
self.dock_component = dock_component
# Get transition type based on dock position
transition_type = self._get_revealer_transition(dock_position)
# Create main content box
self.content_box = Box(
name="battery-popup-content",
orientation="v",
spacing=12,
style_classes=["battery-popup"],
)
# Create battery info section
self.info_box = Box(
name="battery-info",
orientation="v",
spacing=6,
)
# Create power profile switcher section
self.profile_box = Box(
name="power-profile-switcher",
orientation="h",
spacing=8,
style_classes=["power-profiles"],
)
# Create power profile buttons
# We'll determine the actual profile names dynamically
self.power_saver_btn = Button(
name="power-saver-btn",
child=Label(markup=icons.power_saving,name="bat-icon"),
on_clicked=lambda *_: self._set_power_profile_by_type("power-saver"),
style_classes=["power-profile-btn"],
)
self.balanced_btn = Button(
name="balanced-btn",
child=Label(markup=icons.power_balanced,name="bat-icon"),
on_clicked=lambda *_: self._set_power_profile_by_type("balanced"),
style_classes=["power-profile-btn"],
)
self.performance_btn = Button(
name="performance-btn",
child=Label(markup=icons.power_performance,name="bat-icon"),
on_clicked=lambda *_: self._set_power_profile_by_type("performance"),
style_classes=["power-profile-btn"],
)
# Add profile buttons to profile box
self.profile_box.add(self.power_saver_btn)
self.profile_box.add(self.balanced_btn)
self.profile_box.add(self.performance_btn)
# Add sections to content box
self.content_box.add(self.info_box)
self.content_box.add(self.profile_box)
# Create revealer for slide transition
self.revealer = Revealer(
transition_type=transition_type,
transition_duration=300,
child=self.content_box,
reveal_child=False,
)
self.add(self.revealer)
# Connect to escape key to close
self.connect("key-press-event", self.on_key_press)
self.connect("button-press-event", self.on_button_press)
self.connect("enter-notify-event", self.on_enter_notify)
self.connect("leave-notify-event", self.on_leave_notify)
self.set_can_focus(True)
def _set_power_profile_by_type(self, profile_type):
"""Set power profile by type (maps to actual profile names)"""
if self.dock_component and hasattr(self.dock_component, '_battery'):
try:
battery_service = self.dock_component._battery
# Check if profile proxy is available
if not hasattr(battery_service, '_profile_proxy') or not battery_service._profile_proxy:
print("Power profile proxy not available")
return
# Get available profiles
profiles = battery_service._profile_proxy.Profiles
available_profiles = []
for p in profiles:
if isinstance(p, dict) and "Profile" in p:
available_profiles.append(p["Profile"])
elif hasattr(p, "Profile"):
available_profiles.append(p.Profile)
elif isinstance(p, str):
available_profiles.append(p)
# Map profile types to actual profile names
profile_mapping = {
"power-saver": ["power-saver", "powersave", "power_saver"],
"balanced": ["balanced", "balance"],
"performance": ["performance", "performance-mode"]
}
# Find the actual profile name
actual_profile = None
if profile_type in profile_mapping:
for candidate in profile_mapping[profile_type]:
if candidate in available_profiles:
actual_profile = candidate
break
if actual_profile:
battery_service._profile_proxy.ActiveProfile = actual_profile
self.update_profile_buttons()
self.dock_component.update_battery()
print(f"Successfully set power profile to {actual_profile}")
else:
print(f"No matching profile found for type '{profile_type}' in available profiles: {available_profiles}")
except Exception as e:
print(f"Error setting power profile: {e}")
def _set_power_profile(self, profile):
"""Set the power profile"""
if self.dock_component and hasattr(self.dock_component, '_battery'):
try:
battery_service = self.dock_component._battery
# Check if profile proxy is available
if not hasattr(battery_service, '_profile_proxy') or not battery_service._profile_proxy:
print("Power profile proxy not available")
return
# Get available profiles for debugging
try:
profiles = battery_service._profile_proxy.Profiles
available_profiles = []
for p in profiles:
if isinstance(p, dict) and "Profile" in p:
available_profiles.append(p["Profile"])
elif hasattr(p, "Profile"):
available_profiles.append(p.Profile)
elif isinstance(p, str):
available_profiles.append(p)
print(f"Available profiles: {available_profiles}")
print(f"Trying to set profile: {profile}")
# Try to set the profile
if profile in available_profiles:
battery_service._profile_proxy.ActiveProfile = profile
self.update_profile_buttons()
# Update the dock component as well
self.dock_component.update_battery()
print(f"Successfully set power profile to {profile}")
else:
print(f"Profile '{profile}' not found in available profiles: {available_profiles}")
except Exception as e:
print(f"Error accessing profiles: {e}")
except Exception as e:
print(f"Error setting power profile: {e}")
def update_content(self):
"""Update the popup content with current battery information"""
if not self.dock_component:
return
# Clear existing info
for child in self.info_box.get_children():
self.info_box.remove(child)
battery = self.dock_component._battery
# Battery percentage and state
percentage_label = Label(
name="battery-percentage",
label=f"{battery.percentage}%",
style_classes=["battery-percentage"],
)
state_label = Label(
name="battery-state",
label=battery.state,
style_classes=["battery-state"],
)
# Create percentage and state row
percentage_state_box = Box(
name="percentage-state-box",
orientation="h",
spacing=8,
children=[percentage_label, state_label],
)
# Battery capacity (health)
capacity_box = Box(
name="capacity-box",
orientation="h",
spacing=8,
children=[
Label(label="Battery Health:", style_classes=["battery-label"]),
Label(label=battery.capacity, style_classes=["battery-value"]),
],
)
# Time information based on charging state
time_box = None
if battery.state == "CHARGING":
time_to_full = battery.time_to_full
if time_to_full and time_to_full != "0m":
time_box = Box(
name="time-box",
orientation="h",
spacing=8,
children=[
Label(label="Time to full:", style_classes=["battery-label"]),
Label(label=time_to_full, style_classes=["battery-value"]),
],
)
else: # DISCHARGING or other states
time_to_empty = battery.time_to_empty
if time_to_empty and time_to_empty != "0m":
time_box = Box(
name="time-box",
orientation="h",
spacing=8,
children=[
Label(label="Time remaining:", style_classes=["battery-label"]),
Label(label=time_to_empty, style_classes=["battery-value"]),
],
)
# Power profile information
profile_label_box = Box(
name="profile-label-box",
orientation="h",
spacing=8,
children=[
Label(label="Power Profile:", style_classes=["battery-label"]),
Label(label=battery.power_profile.title(), style_classes=["battery-value"]),
],
)
# Add all info to the info box
self.info_box.add(percentage_state_box)
self.info_box.add(capacity_box)
if time_box:
self.info_box.add(time_box)
self.info_box.add(profile_label_box)
# Update profile button states
self.update_profile_buttons()
def update_profile_buttons(self):
"""Update the active state of profile buttons"""
if not self.dock_component or not hasattr(self.dock_component, '_battery'):
return
current_profile = self.dock_component._battery.power_profile
# Remove active class from all buttons
self.power_saver_btn.remove_style_class("active")
self.balanced_btn.remove_style_class("active")
self.performance_btn.remove_style_class("active")
# Add active class to current profile button
if current_profile == "power-saver":
self.power_saver_btn.add_style_class("active")
elif current_profile == "performance":
self.performance_btn.add_style_class("active")
else: # balanced or default
self.balanced_btn.add_style_class("active")
def on_key_press(self, widget, event):
"""Handle key press events"""
if event.keyval == Gdk.KEY_Escape:
self.hide_popup()
return True
return False
def show_popup(self):
"""Show the battery popup"""
self.update_content()
self.set_visible(True)
self.show_all()
# Use GLib.idle_add to ensure the window is shown before revealing
GLib.idle_add(self._reveal_popup)
def _reveal_popup(self):
"""Reveal the popup after window is shown"""
self.revealer.set_reveal_child(True)
self.grab_focus()
return False # Don't repeat
def _get_popup_anchor(self, dock_position):
"""Get popup anchor based on dock position"""
anchor_map = {
"Top": "top",
"Bottom": "bottom right",
"Left": "top left",
"Right": "top right",
}
return anchor_map.get(dock_position, "bottom")
def _get_popup_margin(self, dock_position):
"""Get popup margin based on dock position"""
margin_map = {
"Top": "60px 10px 10px 10px",
"Bottom": "10px 650px 60px 10px",
"Left": "600px 10px 10px 60px",
"Right": "600px 60px 10px 10px",
}
return margin_map.get(dock_position, "10px 10px 60px 10px")
def _get_revealer_transition(self, dock_position):
"""Get revealer transition type based on dock position"""
transition_map = {
"Top": "slide-down",
"Bottom": "slide-up",
"Left": "slide-right",
"Right": "slide-left",
}
return transition_map.get(dock_position, "slide-up")
def on_button_press(self, widget, event):
"""Handle button press events"""
return False
def on_enter_notify(self, widget, event):
"""Handle mouse entering popup"""
# Cancel any pending hide from dock component
if self.dock_component and hasattr(self.dock_component, '_hover_timeout'):
if self.dock_component._hover_timeout:
GLib.source_remove(self.dock_component._hover_timeout)
self.dock_component._hover_timeout = None
return False
def on_leave_notify(self, widget, event):
"""Handle mouse leaving popup"""
# Hide popup when mouse leaves
self.hide_popup()
return False
def hide_popup(self):
"""Hide the battery popup"""
self.revealer.set_reveal_child(False)
# Hide window after transition completes
GLib.timeout_add(350, lambda: self.set_visible(False))
class Battery(Box):
def __init__(self, **kwargs):
orientation = "v" if data.VERTICAL else "h"
super().__init__(
name="battery", spacing=0, orientation=orientation, visible=True, **kwargs
)
self._battery = BatteryService()
self._battery.changed.connect(self.update_battery)
self.icon = Label(name="battery-icon", markup=icons.battery)
self.circle = CircularProgressBar(
name="battery-circle",
value=0,
size=26,
line_width=2,
start_angle=150,
end_angle=390,
style_classes="battery",
child=self.icon,
)
self.circle_button = Button(
name="battery-button",
child=self.circle,
)
# Create event box for hover detection
self.event_box = EventBox(
events=["enter-notify", "leave-notify"],
child=self.circle_button,
)
# Connect hover events
self.event_box.connect("enter-notify-event", self.on_enter_notify)
self.event_box.connect("leave-notify-event", self.on_leave_notify)
self.event_box.add_events(Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK)
self.level = Label(
name="battery-level",
style_classes="battery",
label="0%",
)
self.battery_box = Box(
name="battery-box",
orientation=orientation,
spacing=0,
children=[self.event_box],
)
if not data.VERTICAL:
self.battery_box.add(self.level)
self.add(self.battery_box)
# Pre-create popup for immediate display on hover
self.popup = BatteryPopup(dock_component=self)
# Hover timeout for debouncing
self._hover_timeout = None
# Connect to destroy signal for cleanup
self.connect("destroy", self.on_destroy)
GLib.idle_add(self.update_battery)
def on_destroy(self, widget):
"""Clean up popup when component is destroyed"""
try:
# Remove hover timeout
if hasattr(self, "_hover_timeout") and self._hover_timeout:
GLib.source_remove(self._hover_timeout)
self._hover_timeout = None
# Clean up popup (popup is always created now)
if self.popup:
try:
self.popup.destroy()
self.popup = None
except Exception:
pass
except Exception:
pass
def on_enter_notify(self, widget, event):
"""Handle mouse entering the battery component"""
# Cancel any pending hover timeout
if self._hover_timeout:
GLib.source_remove(self._hover_timeout)
self._hover_timeout = None
# Show popup immediately on hover (popup is pre-created)
if not self.popup.get_visible():
self.popup.show_popup()
return False
def on_leave_notify(self, widget, event):
"""Handle mouse leaving the battery component"""
# Hide popup with a small delay to prevent flickering
self._hover_timeout = GLib.timeout_add(100, self._hide_popup_delayed)
return False
def _hide_popup_delayed(self):
"""Hide popup after delay"""
if self.popup and self.popup.get_visible():
self.popup.hide_popup()
self._hover_timeout = None
return False # Don't repeat timeout
def update_battery(self, *args):
if not self._battery.is_present:
self.set_visible(False)
return True
percentage = self._battery.percentage
state = self._battery.state
charging = state in ["CHARGING", "FULLY_CHARGED"]
self.circle.set_value(percentage / 100.0)
if percentage < 100:
self.level.set_label(f"{int(percentage)}%")
self.level.set_visible(True)
else:
self.level.set_visible(False)
self.icon.remove_style_class("discharging")
self.icon.remove_style_class("discharging-low")
self.icon.remove_style_class("discharging-critical")
if state == "FULLY_CHARGED":
self.icon.set_markup(icons.battery)
elif state == "CHARGING":
self.icon.set_markup(icons.charging)
elif state == "DISCHARGING":
self.icon.set_markup(icons.discharging)
else:
self.icon.set_markup(icons.battery)
if percentage <= 15 and not charging:
self.icon.add_style_class("alert")
self.circle.add_style_class("alert")
else:
self.icon.remove_style_class("alert")
self.circle.remove_style_class("alert")
# Only show if enabled in configuration
if data.DOCK_COMPONENTS_VISIBILITY.get("battery", True):
self.set_visible(True)
else:
self.set_visible(False)
return True