From fb65e0b55dc8a57407dd8ae09360ebfc3425d7bc Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Thu, 7 Aug 2025 11:25:42 +0545 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(controlcenter):=20implement=20?= =?UTF-8?q?battery=20control=20widget=20with=20power=20profiles=20and=20ga?= =?UTF-8?q?me=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.css | 1 + modules/controlcenter/battery.py | 367 +++++++++++++++++++++++++ modules/panel/components/indicators.py | 75 +++-- services/battery.py | 81 +++++- styles/battery-widget.css | 182 ++++++++++++ styles/controlcenter.css | 3 +- 6 files changed, 676 insertions(+), 33 deletions(-) create mode 100644 modules/controlcenter/battery.py create mode 100644 styles/battery-widget.css diff --git a/main.css b/main.css index ab97009..d07a2d5 100644 --- a/main.css +++ b/main.css @@ -12,6 +12,7 @@ @import url("./styles/player.css"); @import url("./styles/widgets.css"); @import url("./styles/tray.css"); +@import url("./styles/battery-widget.css"); * { all: unset; diff --git a/modules/controlcenter/battery.py b/modules/controlcenter/battery.py new file mode 100644 index 0000000..6ab06c9 --- /dev/null +++ b/modules/controlcenter/battery.py @@ -0,0 +1,367 @@ +from fabric.utils import get_relative_path +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.label import Label +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.svg import Svg +from fabric.widgets.image import Image +from services.battery import Battery +import subprocess +import os + + +class PowerProfileButton(Button): + def __init__(self, profile_name: str, display_name: str, battery_service: Battery, parent, **kwargs): + self.profile_name = profile_name + self.battery_service = battery_service + self.parent = parent + + # Get symbolic icon for profile + profile_icons = { + "power-saver": "power-profile-power-saver-symbolic", + "powersave": "power-profile-power-saver-symbolic", + "power_saver": "power-profile-power-saver-symbolic", + "balanced": "power-profile-balanced-symbolic", + "balance": "power-profile-balanced-symbolic", + "performance": "power-profile-performance-symbolic", + "performance-mode": "power-profile-performance-symbolic" + } + + icon_name = profile_icons.get(profile_name, "preferences-system-symbolic") + + # Create symbolic icon + from fabric.widgets.image import Image + self.profile_icon = Image( + icon_name=icon_name, + size=20, + style_classes="battery-profile-icon" + ) + + self.profile_label = Label( + label=display_name, + style_classes="battery-profile-label" + ) + + # Create vertical layout for icon and label + content = Box( + orientation="vertical", + spacing=2, + children=[self.profile_icon, self.profile_label], + h_align="center" + ) + + super().__init__( + child=content, + on_clicked=self.on_clicked, + style_classes="battery-profile-button", + **kwargs + ) + + # Update initial state + self.update_state() + + def on_clicked(self, *args): + """Handle profile selection""" + success = self.battery_service.change_power_profile(self.profile_name) + if success: + # Update all profile buttons in parent + self.parent.update_profile_buttons() + + def update_state(self): + """Update the visual state based on current active profile""" + is_active = self.battery_service.power_profile == self.profile_name + + if is_active: + self.add_style_class("active") + else: + self.remove_style_class("active") + + +class GameModeButton(Button): + def __init__(self, **kwargs): + self.script_path = get_relative_path("../../scripts/gamemode.sh") + self.gamemode_active = self.check_gamemode_status() + + # Create icon for gamemode + self.gamemode_icon = Image( + icon_name="applications-games-symbolic" if self.gamemode_active else "applications-games-symbolic", + size=20, + style_classes="battery-gamemode-icon" + ) + + self.gamemode_label = Label( + label="Game Mode", + style_classes="battery-gamemode-label" + ) + + # Create vertical layout for icon and label + content = Box( + orientation="vertical", + spacing=2, + children=[self.gamemode_icon, self.gamemode_label], + h_align="center" + ) + + super().__init__( + child=content, + on_clicked=self.toggle_gamemode, + style_classes="battery-gamemode-button", + **kwargs + ) + + # Update initial state + self.update_state() + + def check_gamemode_status(self): + """Check if gamemode is currently active""" + try: + result = subprocess.run( + [self.script_path, "check"], + capture_output=True, + text=True, + timeout=5 + ) + return result.stdout.strip() == "t" + except Exception: + return False + + def toggle_gamemode(self, *args): + """Toggle gamemode state""" + try: + # Make script executable if it isn't + os.chmod(self.script_path, 0o755) + + # Run the toggle script + subprocess.run([self.script_path], timeout=10) + + # Update state after toggle + self.gamemode_active = self.check_gamemode_status() + self.update_state() + except Exception as e: + print(f"Failed to toggle gamemode: {e}") + + def update_state(self): + """Update the visual state based on gamemode status""" + if self.gamemode_active: + self.add_style_class("active") + self.gamemode_icon.set_property("style-classes", "battery-gamemode-icon active") + else: + self.remove_style_class("active") + self.gamemode_icon.set_property("style-classes", "battery-gamemode-icon") + + +class BatteryControl(Box): + def __init__(self, parent, **kwargs): + super().__init__( + spacing=0, + orientation="vertical", + style="margin: 0px", + **kwargs, + ) + self.set_size_request(240, -1) + + self.parent = parent + self.battery_service = Battery() + self.profile_buttons = [] + + # Main title + self.title = Label( + label="Battery", + style_classes="battery-main-title", + h_align="start" + ) + + # Battery status information + self.battery_percentage = Label( + label="100%", + style_classes="battery-info-item", + h_align="start" + ) + + self.battery_state = Label( + label="Fully Charged", + style_classes="battery-info-item", + h_align="start" + ) + + self.battery_capacity = Label( + label="", + style_classes="battery-info-item", + h_align="start" + ) + + self.battery_time_to_full = Label( + label="", + style_classes="battery-info-item", + h_align="start", + visible=False + ) + + self.battery_time_to_empty = Label( + label="", + style_classes="battery-info-item", + h_align="start", + visible=False + ) + + self.battery_temperature = Label( + label="", + style_classes="battery-info-item", + h_align="start" + ) + + # Power profiles section + self.profiles_container = Box( + orientation="horizontal", + spacing=8, + style_classes="battery-profiles-container", + h_align="center" + ) + + # Add all sections with proper spacing + self.add(self.title) + + # Add battery information + self.add(self.battery_percentage) + self.add(self.battery_state) + self.add(self.battery_capacity) + self.add(self.battery_time_to_full) + self.add(self.battery_time_to_empty) + self.add(self.battery_temperature) + + # Add separator before power profiles + separator = Box(style_classes="battery-separator") + self.add(separator) + + self.add(self.profiles_container) + + # Add separator before gamemode button + gamemode_separator = Box(style_classes="battery-separator") + self.add(gamemode_separator) + + # Add gamemode button + self.gamemode_button = GameModeButton() + gamemode_container = Box( + orientation="horizontal", + spacing=8, + style_classes="battery-gamemode-container", + h_align="center", + children=[self.gamemode_button] + ) + self.add(gamemode_container) + + # Connect to battery service signals + self.battery_service.connect("changed", self.on_battery_changed) + self.battery_service.connect("profile_changed", self.on_profile_changed) + + # Initialize display + self.update_battery_info() + self.create_profile_buttons() + + + + def create_profile_buttons(self): + """Create buttons for available power profiles""" + # Clear existing buttons + for button in self.profile_buttons: + button.destroy() + self.profile_buttons.clear() + + # Get available profiles + available_profiles = self.battery_service.available_profiles + + if not available_profiles: + no_profiles_label = Label( + label="No power profiles available", + style_classes="battery-no-profiles", + h_align="center" + ) + self.profiles_container.add(no_profiles_label) + return + + # Create button for each profile + for profile in available_profiles: + display_name = Battery.get_profile_display_name(profile) + button = PowerProfileButton( + profile_name=profile, + display_name=display_name, + battery_service=self.battery_service, + parent=self + ) + self.profile_buttons.append(button) + self.profiles_container.add(button) + + def update_profile_buttons(self): + """Update the state of all profile buttons""" + for button in self.profile_buttons: + button.update_state() + + def update_battery_info(self): + """Update battery information display""" + if not self.battery_service.is_present: + self.battery_percentage.set_label("No Battery") + self.battery_state.set_label("Not Present") + self.battery_capacity.set_label("") + self.battery_time_to_full.set_visible(False) + self.battery_time_to_empty.set_visible(False) + self.battery_temperature.set_label("") + return + + # Update percentage + percentage = self.battery_service.percentage + self.battery_percentage.set_label(f"Charge: {percentage}%") + + # Update state + state = self.battery_service.state + state_text = state.replace("_", " ").title() + self.battery_state.set_label(f"Status: {state_text}") + + # Update capacity + capacity = getattr(self.battery_service, 'capacity', None) + if capacity is not None: + try: + # Try to convert to float if it's a string + capacity_value = float(capacity) + self.battery_capacity.set_label(f"Capacity: {capacity_value:.1f} Wh") + except (ValueError, TypeError): + # If conversion fails, display as string + self.battery_capacity.set_label(f"Capacity: {capacity}") + else: + self.battery_capacity.set_label("Capacity: N/A") + + # Update time to full + time_to_full = self.battery_service.time_to_full + if time_to_full != "N/A" and state == "CHARGING": + self.battery_time_to_full.set_label(f"Time to full: {time_to_full}") + self.battery_time_to_full.set_visible(True) + else: + self.battery_time_to_full.set_visible(False) + + # Update time to empty + time_to_empty = self.battery_service.time_to_empty + if time_to_empty != "N/A" and state == "DISCHARGING": + self.battery_time_to_empty.set_label(f"Time remaining: {time_to_empty}") + self.battery_time_to_empty.set_visible(True) + else: + self.battery_time_to_empty.set_visible(False) + + # Update temperature + temperature = getattr(self.battery_service, 'temperature', None) + if temperature is not None: + try: + # Try to convert to float and convert from tenths of degrees Celsius to Celsius + temp_value = float(temperature) / 10.0 + self.battery_temperature.set_label(f"Temperature: {temp_value:.1f}°C") + except (ValueError, TypeError): + # If conversion fails, display as string + self.battery_temperature.set_label(f"Temperature: {temperature}") + else: + self.battery_temperature.set_label("Temperature: N/A") + + def on_battery_changed(self, *args): + """Handle battery service changes""" + self.update_battery_info() + + def on_profile_changed(self, service, new_profile): + """Handle power profile changes""" + self.update_profile_buttons() diff --git a/modules/panel/components/indicators.py b/modules/panel/components/indicators.py index 02831c4..a27e905 100644 --- a/modules/panel/components/indicators.py +++ b/modules/panel/components/indicators.py @@ -8,6 +8,7 @@ from services.network import NetworkClient from utils.roam import modus_service from modules.controlcenter.wifi import WifiConnections from modules.controlcenter.bluetooth import BluetoothConnections +from modules.controlcenter.battery import BatteryControl from widgets.mousecapture import DropDownMouseCapture from widgets.wayland import WaylandWindow as Window @@ -324,9 +325,34 @@ class BatteryIndicator(Box): ), ) - self.battery_button = Button(name="battery-button", child=self.battery_icon) + self.battery_button = Button( + name="battery-button", + child=self.battery_icon, + on_clicked=self.on_battery_clicked + ) self.add(self.battery_button) + + # Create Battery control center widget + self.battery_window = Window( + layer="overlay", + title="modus", + anchor="top right", + margin="2px 200px 0px 0px", + exclusivity="auto", + keyboard_mode="on-demand", + name="battery-control-window", + visible=False, + ) + + self.battery_widget = BatteryControl(self, show_back_button=False) + self.battery_window.children = [self.battery_widget] + + # Create mouse capture for Battery widget + self.battery_mousecapture = DropDownMouseCapture( + layer="top", child_window=self.battery_window + ) + modus_service.connect("battery-changed", self.on_battery_changed) self.battery_service.connect("changed", self.on_battery_direct_changed) @@ -360,32 +386,7 @@ class BatteryIndicator(Box): modus_service.battery = battery_state - def get_battery_icon_level(self, percentage): - if percentage >= 90: - return "100" - elif percentage >= 80: - return "090" - elif percentage >= 70: - return "080" - elif percentage >= 60: - return "070" - elif percentage >= 50: - return "060" - elif percentage >= 40: - return "050" - elif percentage >= 30: - return "040" - elif percentage >= 20: - return "030" - elif percentage >= 10: - return "020" - else: - return "010" - def get_battery_icon_file(self, percentage, is_charging): - level = self.get_battery_icon_level(percentage) - suffix = "-charging" if is_charging else "" - return f"battery/battery-{level}{suffix}.svg" def get_battery_tooltip(self, percentage, state): tooltip = f"Battery: {percentage}%" @@ -413,11 +414,25 @@ class BatteryIndicator(Box): state = self.battery_service.state is_charging = state in ["CHARGING", "FULLY_CHARGED"] - icon_file = self.get_battery_icon_file(percentage, is_charging) + icon_file = Battery.get_battery_icon_file( + percentage, + is_charging, + base_path="../../../config/assets/icons/" + ) tooltip = self.get_battery_tooltip(percentage, state) # Update icon and tooltip - self.battery_icon.set_from_file( - get_relative_path(f"../../../config/assets/icons/{icon_file}") - ) + self.battery_icon.set_from_file(get_relative_path(icon_file)) self.battery_button.set_tooltip_text(tooltip) + + def on_battery_clicked(self, *args): + """Handle Battery indicator click""" + self.battery_mousecapture.toggle_mousecapture() + + def close_battery(self, *args): + """Close Battery control center""" + self.battery_mousecapture.hide_child_window() + + def hide_controlcenter(self, *args): + """Hide Battery control center""" + self.battery_mousecapture.hide_child_window() diff --git a/services/battery.py b/services/battery.py index a2a1cf6..51975ff 100644 --- a/services/battery.py +++ b/services/battery.py @@ -22,6 +22,51 @@ class Battery(Service): minutes = (seconds % 3600) // 60 return f"{hours}h {minutes}m" if hours else f"{minutes}m" + @staticmethod + def get_battery_icon_level(percentage): + """Get battery icon level based on percentage""" + if percentage >= 90: + return "100" + elif percentage >= 80: + return "090" + elif percentage >= 70: + return "080" + elif percentage >= 60: + return "070" + elif percentage >= 50: + return "060" + elif percentage >= 40: + return "050" + elif percentage >= 30: + return "040" + elif percentage >= 20: + return "030" + elif percentage >= 10: + return "020" + else: + return "010" + + @staticmethod + def get_battery_icon_file(percentage, is_charging, base_path=""): + """Get battery icon file path""" + level = Battery.get_battery_icon_level(percentage) + suffix = "-charging" if is_charging else "" + return f"{base_path}battery/battery-{level}{suffix}.svg" + + @staticmethod + def get_profile_display_name(profile: str) -> str: + """Get user-friendly display name for power profile""" + profile_names = { + "power-saver": "Power Saver", + "powersave": "Power Saver", + "power_saver": "Power Saver", + "balanced": "Balanced", + "balance": "Balanced", + "performance": "Performance", + "performance-mode": "Performance" + } + return profile_names.get(profile, profile.title()) + @Signal def changed(self) -> None: ... @@ -99,11 +144,43 @@ class Battery(Service): return self._profile_proxy.ActiveProfile return None - def set_power_profile(self, profile: str) -> bool: + @Property(list, "readable") + def available_profiles(self): + if self._profile_proxy: + try: + profiles = [] + for p in self._profile_proxy.Profiles: + if hasattr(p, 'Profile'): + profiles.append(p.Profile) + elif isinstance(p, dict) and 'Profile' in p: + profiles.append(p['Profile']) + elif isinstance(p, str): + profiles.append(p) + return profiles + except Exception: + return [] + return [] + + def change_power_profile(self, profile: str) -> bool: if not self._profile_proxy: return False - if profile not in [p.Profile for p in self._profile_proxy.Profiles]: + + # Get available profiles using the same logic as available_profiles property + available_profiles = [] + try: + for p in self._profile_proxy.Profiles: + if hasattr(p, 'Profile'): + available_profiles.append(p.Profile) + elif isinstance(p, dict) and 'Profile' in p: + available_profiles.append(p['Profile']) + elif isinstance(p, str): + available_profiles.append(p) + except Exception: return False + + if profile not in available_profiles: + return False + try: self._profile_proxy.ActiveProfile = profile self.profile_changed.emit(profile) diff --git a/styles/battery-widget.css b/styles/battery-widget.css new file mode 100644 index 0000000..ddd7c38 --- /dev/null +++ b/styles/battery-widget.css @@ -0,0 +1,182 @@ +.battery-main-title { + font-size: 1em; + font-weight: 600; + color: rgba(255, 255, 255, 0.95); + margin: 6px 10px; +} + +.battery-power-source { + font-size: 0.9em; + color: rgba(255, 255, 255, 0.8); + margin: 4px 12px; +} + +.battery-separator { + min-height: 1px; + background: rgba(255, 255, 255, 0.1); + margin: 4px 0; +} + +.battery-section-title { + font-size: 0.9em; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + margin: 8px 12px 4px 12px; +} + +.battery-energy-item { + margin: 4px 12px; + padding: 4px 0; +} + +.battery-energy-indicator { + font-size: 0.8em; + margin-right: 4px; +} + +.battery-energy-app { + font-size: 0.9em; + color: rgba(255, 255, 255, 0.8); +} + +.battery-settings-button { + background: transparent; + border: none; + padding: 8px 12px; + margin: 4px 0; + border-radius: 0; +} + +.battery-settings-button:hover { + background: rgba(255, 255, 255, 0.1); +} + +.battery-info-item { + font-size: 0.85em; + color: rgba(255, 255, 255, 0.8); + margin: 2px 10px; + padding: 1px 0; +} + +.battery-status-section { + background: rgba(255, 255, 255, 0.06); + border-radius: 12px; + padding: 16px; + margin: 8px 0; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.battery-percentage { + font-size: 1.4em; + font-weight: 600; + color: rgba(255, 255, 255, 0.95); +} + +.battery-state { + font-size: 0.9em; + color: rgba(255, 255, 255, 0.8); + margin-top: 4px; +} + +.battery-time { + font-size: 0.8em; + color: rgba(255, 255, 255, 0.6); + margin-top: 2px; +} + +.battery-capacity { + font-size: 0.8em; + color: rgba(255, 255, 255, 0.7); + margin-top: 2px; +} + +.battery-temperature { + font-size: 0.8em; + color: rgba(255, 255, 255, 0.7); + margin-top: 2px; +} + +.battery-section-title { + font-size: 0.95em; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + margin: 16px 0 8px 0; +} + +.battery-profiles-container { + background: transparent; + padding: 2px 8px; + margin: 2px 0; +} + +.battery-profile-button { + background: transparent; + border: none; + border-radius: 6px; + padding: 4px 6px; + margin: 0 1px; + transition: all 0.2s ease; + min-width: 45px; +} + +.battery-profile-button:hover { + background: rgba(255, 255, 255, 0.1); +} + +.battery-profile-button.active { + background: rgba(0, 122, 255, 0.2); +} + +.battery-profile-icon { + color: rgba(255, 255, 255, 0.8); + margin-bottom: 2px; +} + +.battery-profile-button.active .battery-profile-icon { + color: rgba(0, 122, 255, 1); +} + +.battery-no-profiles { + font-size: 0.85em; + color: rgba(255, 255, 255, 0.6); + padding: 16px; +} + +/* Gamemode Button Styles */ +.battery-gamemode-container { + background: transparent; + padding: 2px 8px; + margin: 2px 0; +} + +.battery-gamemode-button { + background: transparent; + border: none; + border-radius: 6px; + padding: 4px 6px; + margin: 0 1px; + transition: all 0.2s ease; + min-width: 45px; +} + +.battery-gamemode-button:hover { + background: rgba(255, 255, 255, 0.1); +} + +.battery-gamemode-button.active { + background: rgba(255, 69, 0, 0.2); +} + +.battery-gamemode-icon { + color: rgba(255, 255, 255, 0.8); + margin-bottom: 2px; +} + +.battery-gamemode-button.active .battery-gamemode-icon { + color: rgba(255, 69, 0, 1); +} + +.battery-gamemode-label { + font-size: 0.8em; + color: rgba(255, 255, 255, 0.8); +} diff --git a/styles/controlcenter.css b/styles/controlcenter.css index 99f0bbe..74a6c66 100644 --- a/styles/controlcenter.css +++ b/styles/controlcenter.css @@ -6,7 +6,8 @@ } #bluetooth-control-window, -#wifi-control-window { +#wifi-control-window, +#battery-control-window { margin: 6px; background-color: alpha(#fff, 0.09); border: 1px solid alpha(#111, 0.3);