diff --git a/modules/dock/components/battery.py b/modules/dock/components/battery.py index 8c0ea79..bb87506 100644 --- a/modules/dock/components/battery.py +++ b/modules/dock/components/battery.py @@ -1,12 +1,383 @@ 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, + ) + + 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.revealer.reveal() + self.grab_focus() + + def _get_popup_anchor(self, dock_position): + """Get popup anchor based on dock position""" + anchor_map = { + "Top": "top", + "Bottom": "bottom right", + "Left": "left", + "Right": "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": "10px 10px 10px 60px", + "Right": "10px 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.unreveal() + # Hide window after transition completes + GLib.timeout_add(350, lambda: self.set_visible(False)) class Battery(Box): @@ -36,14 +407,16 @@ class Battery(Box): child=self.circle, ) - self.circle_button.add_events( - Gdk.EventMask.ENTER_NOTIFY_MASK - | Gdk.EventMask.LEAVE_NOTIFY_MASK - | Gdk.EventMask.BUTTON_PRESS_MASK + # Create event box for hover detection + self.event_box = EventBox( + events=["enter-notify", "leave-notify"], + child=self.circle_button, ) - self.circle_button.connect("enter-notify-event", self.on_button_enter) - self.circle_button.connect("leave-notify-event", self.on_button_leave) - self.circle_button.connect("button-press-event", self.on_button_press) + + # 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", @@ -55,121 +428,71 @@ class Battery(Box): name="battery-box", orientation=orientation, spacing=0, - children=[self.circle_button], + children=[self.event_box], ) if not data.VERTICAL: self.battery_box.add(self.level) - self.circle_button.set_has_tooltip(True) - self.battery_box.set_has_tooltip(True) - self.level.set_has_tooltip(True) - - self.circle_button.connect("query-tooltip", self.on_query_tooltip) - self.battery_box.connect("query-tooltip", self.on_query_tooltip) - self.level.connect("query-tooltip", self.on_query_tooltip) - self.add(self.battery_box) + self.popup = None + + # 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_button_enter(self, widget, event): - window = widget.get_window() - if window: - window.set_cursor(Gdk.Cursor(Gdk.CursorType.HAND2)) + 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 - def on_button_leave(self, widget, event): - window = widget.get_window() - if window: - window.set_cursor(None) + # Clean up popup + if hasattr(self, 'popup') and self.popup: + try: + self.popup.destroy() + self.popup = None + except Exception: + pass - def on_button_press(self, widget, event): - if event.button == 1: # Left click - try: - if ( - not hasattr(self._battery, "_profile_proxy") - or not self._battery._profile_proxy - ): - return False + except Exception: + pass - current_profile = self._battery._profile_proxy.ActiveProfile - profiles = self._battery._profile_proxy.Profiles - profile_names = [] + 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 - for profile in profiles: - if isinstance(profile, dict) and "Profile" in profile: - profile_names.append(profile["Profile"]) - elif hasattr(profile, "Profile"): - profile_names.append(profile.Profile) - elif isinstance(profile, str): - profile_names.append(profile) - - if profile_names and current_profile in profile_names: - next_index = (profile_names.index(current_profile) + 1) % len( - profile_names - ) - next_profile = profile_names[next_index] - self._battery._profile_proxy.ActiveProfile = next_profile - else: - return False - except Exception: - return False - return True + # Show popup immediately on hover + if not self.popup or not self.popup.get_visible(): + # Create popup on-demand if it doesn't exist + if not self.popup: + self.popup = BatteryPopup(dock_component=self) + self.popup.show_popup() return False - def on_query_tooltip(self, widget, x, y, keyboard_mode, tooltip): - tooltip.set_markup(self.get_tooltip_text()) - return True - - def get_tooltip_text(self): - if not self._battery.is_present: - return "No battery detected" - - percentage = self._battery.percentage - state = self._battery.state - capacity = int(float(self._battery.capacity.rstrip("%"))) - tooltip_points = [] - - if state == "FULLY_CHARGED": - status = f"{icons.bat_full} Fully Charged" - elif state == "CHARGING": - status = f"{icons.bat_charging} Charging" - elif percentage <= 15 and state == "DISCHARGING": - status = f"{icons.bat_low} Low Battery" - elif state == "DISCHARGING": - status = f"{icons.bat_discharging} Discharging" - else: - status = "Battery" - tooltip_points.append(f"• Status: {status}") - - tooltip_points.append(f"• Level: {int(percentage)}%") - tooltip_points.append(f"• Battery Health: {capacity}%") - - power_profile = self._battery.power_profile - if power_profile: - if power_profile == "power-saver": - profile_icon = icons.power_saving - elif power_profile == "performance": - profile_icon = icons.power_performance - else: # balanced - profile_icon = icons.power_balanced - tooltip_points.append(f"• Profile: {profile_icon} {power_profile}") - - temp = self._battery.temperature - if temp != "N/A": - tooltip_points.append(f"• Temperature: {temp}") - - if state == "CHARGING": - time_to_full = self._battery.time_to_full - if time_to_full: - tooltip_points.append(f"• Time until full: {time_to_full}") - elif state == "DISCHARGING": - time_to_empty = self._battery.time_to_empty - if time_to_empty: - tooltip_points.append(f"• Time remaining: {time_to_empty}") - - return "\n".join(tooltip_points) + 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) @@ -178,7 +501,6 @@ class Battery(Box): percentage = self._battery.percentage state = self._battery.state charging = state in ["CHARGING", "FULLY_CHARGED"] - power_profile = self._battery.power_profile self.circle.set_value(percentage / 100.0) @@ -188,50 +510,19 @@ class Battery(Box): else: self.level.set_visible(False) - self.circle.remove_style_class("power-saver") - self.circle.remove_style_class("performance") - self.circle.remove_style_class("balanced") - self.circle.remove_style_class("discharging") - self.circle.remove_style_class("discharging-low") - self.circle.remove_style_class("discharging-critical") + self.icon.remove_style_class("discharging") self.icon.remove_style_class("discharging-low") self.icon.remove_style_class("discharging-critical") - if power_profile: - self.circle.add_style_class(power_profile) - if power_profile == "power-saver": - self.icon.set_markup(icons.power_saving) - elif power_profile == "performance": - self.icon.set_markup(icons.power_performance) - else: # balanced - # In balanced mode, show discharging icons when discharging - if state == "DISCHARGING": - if percentage <= 15: - self.icon.set_markup(icons.bat_low) - self.circle.add_style_class("discharging-critical") - self.icon.add_style_class("discharging-critical") - elif percentage <= 30: - self.icon.set_markup(icons.alert) - self.circle.add_style_class("discharging-low") - self.icon.add_style_class("discharging-low") - else: - self.icon.set_markup(icons.bat_discharging) - self.circle.add_style_class("discharging") - self.icon.add_style_class("discharging") - elif state == "CHARGING": - self.icon.set_markup(icons.charging) - else: - self.icon.set_markup(icons.battery) + 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: - 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) + self.icon.set_markup(icons.battery) if percentage <= 15 and not charging: self.icon.add_style_class("alert") diff --git a/services/battery.py b/services/battery.py index 19e01ed..828222d 100644 --- a/services/battery.py +++ b/services/battery.py @@ -55,7 +55,7 @@ class Battery(Service): @Property(str, "readable") def capacity(self): - return f"{self._battery.Capacity}%" + return f"{int(self._battery.Capacity)}%" @Property(bool, "readable", default_value=False) def is_present(self): diff --git a/styles/components.css b/styles/components.css index 0e8ac3b..42eb671 100644 --- a/styles/components.css +++ b/styles/components.css @@ -106,6 +106,80 @@ color: var(--red-dim); } +/* + * ── battery popup ───────────────────────────────────────────────────── +*/ + +#battery-popup-content { + background-color: var(--shadow); + border: 1px solid var(--surface); + border-radius: 12px; + padding: 16px; + margin: 4px; +} + +.power-profiles { + padding-top: 12px; + margin-top: 8px; +} + +#bat-icon { + font-size: 18px; +} + +.power-profile-btn { + background-color: var(--surface); + border-radius: 8px; + padding: 10px 20px; + transition: all 0.2s ease; +} +.power-profile-btn:hover, +.power-profile-btn:focus { + background-color: var(--surface-bright); +} + +.power-profile-btn.active { + background-color: var(--primary); +} + +.power-profile-btn.active #bat-icon { + color: var(--shadow); +} + +/* Battery info styles */ +.battery-percentage { + font-size: 24px; + font-weight: bold; + color: var(--primary); +} + +.battery-state { + font-size: 14px; + color: var(--text-dim); +} + +.battery-label { + font-size: 12px; + color: var(--text-dim); + min-width: 80px; +} + +.battery-value { + font-size: 12px; + color: var(--text); + font-weight: 500; +} + +#percentage-state-box { + margin-bottom: 8px; +} + +#capacity-box, +#time-box, +#profile-label-box { + margin-bottom: 4px; +} + /* * ── metrics ─────────────────────────────────────────────────────────── */