✨ feat(controlcenter): implement battery control widget with power profiles and game mode

This commit is contained in:
sankalp committed 2025-08-07 11:25:42 +05:45
1 parent e7fac41852
commit fb65e0b55d
6 files changed
+676 -33

No files matched your search

+1
View File
@@ -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;
+367
View File
@@ -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()
+45 -30
View File
@@ -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()
+79 -2
View File
@@ -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)
+182
View File
@@ -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);
}
+2 -1
View File
@@ -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);