From faaa99bd24c4f16fe8cbcb91af6c294e6ea14a42 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Tue, 21 Jul 2026 09:21:29 +0545 Subject: [PATCH] refactor(battery): consolidate button styles, refactor service, remove ddcutil - Merge game-mode/charge-limit CSS into shared selectors - Rewrite Battery service with IntEnum states and charge-limit support - Add ChargeLimitButton with active-state styling - Drop ddcutil from dependencies (unused) --- README.md | 2 +- install.sh | 1 - src/assets/icons/zap.svg | 1 + src/services/battery.py | 672 ++++++++++++++++++++++++++++++---- src/styles/window/battery.css | 64 +--- 5 files changed, 617 insertions(+), 123 deletions(-) create mode 100644 src/assets/icons/zap.svg diff --git a/README.md b/README.md index 799b011..be3c877 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ The interactive installer handles dependencies, builds C-extensions, and configu 1. **Install dependencies:** ```bash - paru -S uv fabric-cli-git uwsm cliphist slurp grim swappy wl-clipboard wtype libnotify playerctl matugen-bin hypridle hyprsunset hyprpicker hyprshot gtk-session-lock awww otf-apple-sf-pro webp-pixbuf-loader cinnamon-desktop libmediaart acpi brightnessctl power-profiles-daemon ddcutil at-spi2-core networkmanager network-manager-applet blueman pipewire libpulse gcc make pkgconf meson ninja wayland-protocols gobject-introspection gtk-layer-shell librsvg libqalculate appmenu-gtk-module libdbusmenu-gtk3 libdbusmenu-qt5 pciutils wf-recorder ffmpeg --needed + paru -S uv fabric-cli-git uwsm cliphist slurp grim swappy wl-clipboard wtype libnotify playerctl matugen-bin hypridle hyprsunset hyprpicker hyprshot gtk-session-lock awww otf-apple-sf-pro webp-pixbuf-loader cinnamon-desktop libmediaart acpi brightnessctl power-profiles-daemon at-spi2-core networkmanager network-manager-applet blueman pipewire libpulse gcc make pkgconf meson ninja wayland-protocols gobject-introspection gtk-layer-shell librsvg libqalculate appmenu-gtk-module libdbusmenu-gtk3 libdbusmenu-qt5 pciutils wf-recorder ffmpeg --needed ``` 2. **Clone and sync:** diff --git a/install.sh b/install.sh index 549f459..674b13a 100755 --- a/install.sh +++ b/install.sh @@ -66,7 +66,6 @@ PACKAGES=( acpi brightnessctl power-profiles-daemon - ddcutil at-spi2-core networkmanager network-manager-applet diff --git a/src/assets/icons/zap.svg b/src/assets/icons/zap.svg new file mode 100644 index 0000000..21f59dc --- /dev/null +++ b/src/assets/icons/zap.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/services/battery.py b/src/services/battery.py index 1d18b61..aae3d6c 100644 --- a/src/services/battery.py +++ b/src/services/battery.py @@ -1,24 +1,172 @@ +from enum import IntEnum + from fabric.core.service import Property, Service, Signal from fabric.utils import Gio, GLib, logger -BATTERY_BUS_NAME = "org.freedesktop.UPower" -BATTERY_BUS_PATH = "/org/freedesktop/UPower/devices/DisplayDevice" -BATTERY_INTERFACE = "org.freedesktop.UPower.Device" +UPOWER_BUS = "org.freedesktop.UPower" +UPOWER_PATH = "/org/freedesktop/UPower" +DEVICE_IFACE = "org.freedesktop.UPower.Device" +DEVICE_DISPLAY_PATH = "/org/freedesktop/UPower/devices/DisplayDevice" -POWER_PROFILE_BUS_NAME = "net.hadess.PowerProfiles" -POWER_PROFILE_BUS_PATH = "/net/hadess/PowerProfiles" -POWER_PROFILE_INTERFACE = "net.hadess.PowerProfiles" +POWER_PROFILE_BUS = "org.freedesktop.UPower.PowerProfiles" +POWER_PROFILE_PATH = "/org/freedesktop/UPower/PowerProfiles" -DEVICE_STATE = { - 0: "UNKNOWN", - 1: "CHARGING", - 2: "DISCHARGING", - 3: "EMPTY", - 4: "FULLY_CHARGED", - 5: "PENDING_CHARGE", - 6: "PENDING_DISCHARGE", + +class DeviceState(IntEnum): + UNKNOWN = 0 + CHARGING = 1 + DISCHARGING = 2 + EMPTY = 3 + FULLY_CHARGED = 4 + PENDING_CHARGE = 5 + PENDING_DISCHARGE = 6 + + +class UpDeviceKind(IntEnum): + UNKNOWN = 0 + LINE_POWER = 1 + BATTERY = 2 + UPS = 3 + MONITOR = 4 + MOUSE = 5 + KEYBOARD = 6 + PDA = 7 + PHONE = 8 + MEDIA_PLAYER = 9 + TABLET = 10 + COMPUTER = 11 + GAMING_INPUT = 12 + PEN = 13 + TOUCHPAD = 14 + HEADSET = 17 + SPEAKERS = 18 + HEADPHONES = 19 + + def is_peripheral(self) -> bool: + return self in ( + self.MOUSE, + self.KEYBOARD, + self.GAMING_INPUT, + self.HEADSET, + self.HEADPHONES, + self.TOUCHPAD, + ) + + def is_power_source(self) -> bool: + return self == self.BATTERY + + +class PeripheralDeviceKind(IntEnum): + KEYBOARD = 0 + MOUSE = 1 + HEADPHONES = 2 + GAMEPAD = 3 + + def __str__(self) -> str: + return _PERIPHERAL_NAMES[self] + + +_PERIPHERAL_NAMES = { + PeripheralDeviceKind.KEYBOARD: "Keyboard", + PeripheralDeviceKind.MOUSE: "Mouse", + PeripheralDeviceKind.HEADPHONES: "Headphones", + PeripheralDeviceKind.GAMEPAD: "Gamepad", } +_DEVICE_KIND_TO_PERIPHERAL = { + UpDeviceKind.MOUSE: PeripheralDeviceKind.MOUSE, + UpDeviceKind.TOUCHPAD: PeripheralDeviceKind.MOUSE, + UpDeviceKind.KEYBOARD: PeripheralDeviceKind.KEYBOARD, + UpDeviceKind.HEADPHONES: PeripheralDeviceKind.HEADPHONES, + UpDeviceKind.HEADSET: PeripheralDeviceKind.HEADPHONES, + UpDeviceKind.GAMING_INPUT: PeripheralDeviceKind.GAMEPAD, +} + + +class BatteryData: + __slots__ = ("capacity", "is_discharging", "status") + + def __init__(self, capacity: int, status: "BatteryStatus", is_discharging: bool): + self.capacity = capacity + self.status = status + self.is_discharging = is_discharging + + def get_icon_name(self) -> str: + if self.status == BatteryStatus.CHARGING: + return "battery-level-04-charging-symbolic" + if self.status == BatteryStatus.FULL: + return "battery-full-charged-symbolic" + return _level_icon(self.capacity) + + +class BatteryStatus(IntEnum): + UNKNOWN = 0 + CHARGING = 1 + DISCHARGING = 2 + NOT_CHARGING = 3 + FULL = 4 + + def __str__(self) -> str: + return _STATUS_NAMES[self] + + +_STATUS_NAMES = { + BatteryStatus.UNKNOWN: "UNKNOWN", + BatteryStatus.CHARGING: "CHARGING", + BatteryStatus.DISCHARGING: "DISCHARGING", + BatteryStatus.NOT_CHARGING: "NOT_CHARGING", + BatteryStatus.FULL: "FULLY_CHARGED", +} + + +def _level_icon(capacity: int) -> str: + if capacity < 20: + return "battery-level-00-symbolic" + if capacity < 40: + return "battery-level-02-symbolic" + if capacity < 60: + return "battery-level-04-symbolic" + if capacity < 80: + return "battery-level-06-symbolic" + return "battery-level-08-symbolic" + + +def _state_from_raw(state: int) -> BatteryStatus: + return { + DeviceState.CHARGING: BatteryStatus.CHARGING, + DeviceState.DISCHARGING: BatteryStatus.DISCHARGING, + DeviceState.FULLY_CHARGED: BatteryStatus.FULL, + DeviceState.PENDING_CHARGE: BatteryStatus.NOT_CHARGING, + DeviceState.PENDING_DISCHARGE: BatteryStatus.NOT_CHARGING, + }.get(state, BatteryStatus.UNKNOWN) + + +def _device_kind_from_u32(value: int) -> UpDeviceKind: + try: + return UpDeviceKind(value) + except ValueError: + return UpDeviceKind.UNKNOWN + + +class Peripheral: + __slots__ = ("data", "kind", "name", "path") + + def __init__( + self, name: str, kind: PeripheralDeviceKind, data: BatteryData, path: str + ): + self.name = name + self.kind = kind + self.data = data + self.path = path + + +class ChargeLimit: + __slots__ = ("device_path", "enabled") + + def __init__(self, enabled: bool, device_path: str): + self.enabled = enabled + self.device_path = device_path + class Battery(Service): """A service for interacting with the battery's DBus and Power Profiles. @@ -42,157 +190,489 @@ class Battery(Service): @Property(bool, "readable", default_value=False) def available(self) -> bool: - return self.do_get_cached_property("IsPresent") or False + if self._system_battery is not None: + return True + return self._do_get_cached_property("IsPresent") or False @Property(str, "readable", default_value="") def vendor(self) -> str: - return self.do_get_cached_property("Vendor") or "" + return self._do_get_cached_property("Vendor") or "" @Property(int, "readable", default_value=0) def percent(self) -> int: - return self.do_get_cached_property("Percentage") or 0 + if self._system_battery is not None: + return self._system_battery.capacity + return self._do_get_cached_property("Percentage") or 0 @Property(bool, "readable", default_value=False) def charging(self) -> bool: - val = self.do_get_cached_property("State") - if val is None: - return False - return val == DEVICE_STATE.get("CHARGING", 1) + if self._system_battery is not None: + return self._system_battery.status == BatteryStatus.CHARGING + return self._raw_state() == DeviceState.CHARGING @Property(bool, "readable", default_value=False) def discharging(self) -> bool: - val = self.do_get_cached_property("State") - if val is None: - return False - return val == DEVICE_STATE.get("DISCHARGING", 2) + if self._system_battery is not None: + return self._system_battery.status == BatteryStatus.DISCHARGING + return self._raw_state() == DeviceState.DISCHARGING @Property(bool, "readable", default_value=False) def charged(self) -> bool: - val = self.do_get_cached_property("State") - if val is None: - return False - return val == DEVICE_STATE.get("FULLY_CHARGED", 4) + if self._system_battery is not None: + return self._system_battery.status == BatteryStatus.FULL + return self._raw_state() == DeviceState.FULLY_CHARGED @Property(str, "readable", default_value="") def icon_name(self) -> str: - return self.do_get_cached_property("IconName") or "" + if self._system_battery is not None: + return self._system_battery.get_icon_name() + return self._do_get_cached_property("IconName") or "" @Property(int, "readable", default_value=0) def time_remaining(self) -> int: - return self.do_get_cached_property("TimeToEmpty") or 0 + return self._do_get_cached_property("TimeToEmpty") or 0 @Property(int, "readable", default_value=0) def time_to_full(self) -> int: - return self.do_get_cached_property("TimeToFull") or 0 + return self._do_get_cached_property("TimeToFull") or 0 @Property(float, "readable", default_value=0.0) def temperature(self) -> float: - return self.do_get_cached_property("Temperature") or 0.0 + return self._do_get_cached_property("Temperature") or 0.0 def __init__(self, **kwargs): super().__init__(**kwargs) self._bus: Gio.DBusConnection | None = None - self._proxy = None - self._power_profile_proxy = None - # _prop_cache stores ONLY properties received via PropertiesChanged. - # For fresh reads we always fall through to the GDBus proxy cache. + self._display_proxy: Gio.DBusProxy | None = None + self._power_profile_proxy: Gio.DBusProxy | None = None self._prop_cache: dict[str, object] = {} - self.do_register() + self._device_proxies: dict[str, Gio.DBusProxy] = {} + self._system_battery: BatteryData | None = None + self._system_battery_paths: list[str] = [] + self._peripherals: list[Peripheral] = [] + self._charge_limit: ChargeLimit | None = None + self._power_profile: str = "" + self._toggling_charge_limit: bool = False + self._do_register() - def do_register(self) -> None: + def _raw_state(self) -> DeviceState: + val = self._do_get_cached_property("State") + if val is None: + return DeviceState.UNKNOWN + try: + return DeviceState(val) + except ValueError: + return DeviceState.UNKNOWN + + def _do_register(self) -> None: self._bus = Gio.bus_get_sync(Gio.BusType.SYSTEM) - self._proxy = Gio.DBusProxy.new_sync( + + self._display_proxy = Gio.DBusProxy.new_sync( self._bus, Gio.DBusProxyFlags.NONE, None, - BATTERY_BUS_NAME, - BATTERY_BUS_PATH, - BATTERY_INTERFACE, + UPOWER_BUS, + DEVICE_DISPLAY_PATH, + DEVICE_IFACE, None, ) - - logger.info("[Battery] Proxy initialized") + logger.info("[Battery] DisplayDevice proxy initialized") self._bus.signal_subscribe( - BATTERY_BUS_NAME, + UPOWER_BUS, "org.freedesktop.DBus.Properties", "PropertiesChanged", - BATTERY_BUS_PATH, + DEVICE_DISPLAY_PATH, None, Gio.DBusSignalFlags.NONE, - self.do_handle_property_change, + self._do_handle_display_device_change, ) + self._do_enumerate_devices() + self._do_setup_upower_signals() + self._do_setup_power_profile() + + def _do_enumerate_devices(self) -> None: + try: + result = self._bus.call_sync( + UPOWER_BUS, + UPOWER_PATH, + UPOWER_BUS, + "EnumerateDevices", + None, + None, + Gio.DBusCallFlags.NONE, + -1, + None, + ) + device_paths = result.unpack()[0] + except Exception as e: + logger.warning(f"[Battery] Failed to enumerate devices: {e}") + return + + for path in device_paths: + self._do_add_device(path) + + self._do_recalculate_system_battery() + + def _do_add_device(self, path: str) -> None: + try: + proxy = Gio.DBusProxy.new_sync( + self._bus, + Gio.DBusProxyFlags.NONE, + None, + UPOWER_BUS, + path, + DEVICE_IFACE, + None, + ) + self._device_proxies[path] = proxy + + self._bus.signal_subscribe( + UPOWER_BUS, + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + path, + None, + Gio.DBusSignalFlags.NONE, + self._do_handle_device_change, + ) + except Exception as e: + logger.warning(f"[Battery] Failed to create proxy for {path}: {e}") + + def _do_setup_upower_signals(self) -> None: + try: + self._bus.signal_subscribe( + UPOWER_BUS, + UPOWER_BUS, + "DeviceAdded", + UPOWER_PATH, + None, + Gio.DBusSignalFlags.NONE, + self._do_handle_device_added, + ) + self._bus.signal_subscribe( + UPOWER_BUS, + UPOWER_BUS, + "DeviceRemoved", + UPOWER_PATH, + None, + Gio.DBusSignalFlags.NONE, + self._do_handle_device_removed, + ) + logger.info("[Battery] UPower device signals subscribed") + except Exception as e: + logger.warning(f"[Battery] Failed to setup UPower signals: {e}") + + def _do_setup_power_profile(self) -> None: try: self._power_profile_proxy = Gio.DBusProxy.new_sync( self._bus, Gio.DBusProxyFlags.NONE, None, - POWER_PROFILE_BUS_NAME, - POWER_PROFILE_BUS_PATH, - POWER_PROFILE_INTERFACE, + POWER_PROFILE_BUS, + POWER_PROFILE_PATH, + POWER_PROFILE_BUS, None, ) + result = self._power_profile_proxy.get_cached_property("ActiveProfile") + if result is not None: + self._power_profile = result.unpack() + self._bus.signal_subscribe( - POWER_PROFILE_BUS_NAME, + POWER_PROFILE_BUS, "org.freedesktop.DBus.Properties", "PropertiesChanged", - POWER_PROFILE_BUS_PATH, + POWER_PROFILE_PATH, None, Gio.DBusSignalFlags.NONE, - self.do_handle_power_profile_change, + self._do_handle_power_profile_change, ) - logger.info("[Battery] Power Profiles Proxy initialized") + logger.info("[Battery] Power Profiles proxy initialized") except Exception as e: logger.warning(f"[Battery] Power Profiles daemon not available: {e}") - def do_handle_property_change( + def _do_handle_display_device_change( self, _connection: Gio.DBusConnection, - _sender_name: str, - _object_path: str, - _interface_name: str, - _signal_name: str, + _sender: str, + _path: str, + _interface: str, + _signal: str, parameters: GLib.Variant, ): + if self._toggling_charge_limit: + return try: _iface, changed_props, _invalidated = parameters.unpack() for prop_name, variant in changed_props.items(): self._prop_cache[prop_name] = variant.unpack() except Exception as e: - logger.warning(f"[Battery] Failed to parse PropertiesChanged: {e}") + logger.warning( + f"[Battery] Failed to parse DisplayDevice PropertiesChanged: {e}" + ) self.emit("changed") - def do_handle_power_profile_change(self, *_): + def _do_handle_device_change(self, *_args): + if self._toggling_charge_limit: + return + self._do_recalculate_system_battery() + self._do_recalculate_peripherals() + self.emit("changed") + + def _do_handle_device_added(self, *_args): + try: + result = self._bus.call_sync( + UPOWER_BUS, + UPOWER_PATH, + UPOWER_BUS, + "EnumerateDevices", + None, + None, + Gio.DBusCallFlags.NONE, + -1, + None, + ) + for path in result.unpack()[0]: + if path not in self._device_proxies: + self._do_add_device(path) + self._do_recalculate_system_battery() + self._do_recalculate_peripherals() + self.emit("changed") + except Exception as e: + logger.warning(f"[Battery] Failed to handle DeviceAdded: {e}") + + def _do_handle_device_removed(self, *_args): + try: + result = self._bus.call_sync( + UPOWER_BUS, + UPOWER_PATH, + UPOWER_BUS, + "EnumerateDevices", + None, + None, + Gio.DBusCallFlags.NONE, + -1, + None, + ) + current_paths = set(result.unpack()[0]) + removed = [p for p in self._device_proxies if p not in current_paths] + for path in removed: + del self._device_proxies[path] + if removed: + self._do_recalculate_system_battery() + self._do_recalculate_peripherals() + self.emit("changed") + except Exception as e: + logger.warning(f"[Battery] Failed to handle DeviceRemoved: {e}") + + def _do_recalculate_system_battery(self) -> None: + charging = False + discharging = False + pending_charge = False + pending_discharge = False + fully_charged_count = 0 + total_devices = 0 + paths = [] + + for path, proxy in self._device_proxies.items(): + kind_raw = proxy.get_cached_property("Type") + power_supply = proxy.get_cached_property("PowerSupply") + if kind_raw is None or power_supply is None: + continue + kind = _device_kind_from_u32(kind_raw.unpack()) + if not kind.is_power_source() or not power_supply.unpack(): + continue + + energy_full_variant = proxy.get_cached_property("EnergyFull") + if energy_full_variant is not None and energy_full_variant.unpack() == 0.0: + continue + + state_variant = proxy.get_cached_property("State") + if state_variant is None: + continue + + try: + state = DeviceState(state_variant.unpack()) + except ValueError: + continue + + total_devices += 1 + paths.append(path) + if state == DeviceState.CHARGING: + charging = True + elif state == DeviceState.DISCHARGING: + discharging = True + elif state == DeviceState.FULLY_CHARGED: + fully_charged_count += 1 + elif state == DeviceState.PENDING_CHARGE: + pending_charge = True + elif state == DeviceState.PENDING_DISCHARGE: + pending_discharge = True + + self._system_battery_paths = paths + + if total_devices == 0: + self._system_battery = None + self._charge_limit = None + return + + if fully_charged_count == total_devices: + merged_state = DeviceState.FULLY_CHARGED + elif charging: + merged_state = DeviceState.CHARGING + elif discharging: + merged_state = DeviceState.DISCHARGING + elif pending_charge: + merged_state = DeviceState.PENDING_CHARGE + elif pending_discharge: + merged_state = DeviceState.PENDING_DISCHARGE + else: + merged_state = DeviceState.UNKNOWN + + if len(paths) == 1: + proxy = self._device_proxies[paths[0]] + pct_variant = proxy.get_cached_property("Percentage") + if pct_variant is None: + self._system_battery = None + return + capacity = int(pct_variant.unpack()) + else: + energy = 0.0 + energy_full = 0.0 + for path in paths: + proxy = self._device_proxies[path] + e = proxy.get_cached_property("Energy") + ef = proxy.get_cached_property("EnergyFull") + if e is not None: + energy += e.unpack() + if ef is not None: + energy_full += ef.unpack() + if energy_full == 0.0: + self._system_battery = None + return + capacity = int(energy / energy_full * 100.0) + + self._system_battery = BatteryData( + capacity=capacity, + status=_state_from_raw(merged_state), + is_discharging=merged_state == DeviceState.DISCHARGING, + ) + + self._do_recalculate_charge_limit() + + def _do_recalculate_charge_limit(self) -> None: + self._charge_limit = None + for path in self._system_battery_paths: + proxy = self._device_proxies.get(path) + if proxy is None: + continue + try: + introspection = proxy.call_sync( + "org.freedesktop.DBus.Introspectable.Introspect", + None, + Gio.DBusCallFlags.NONE, + -1, + None, + ) + xml = introspection.unpack()[0] + if "EnableChargeThreshold" not in xml: + continue + + supported_variant = proxy.get_cached_property( + "ChargeThresholdSupported" + ) + if supported_variant is None or not supported_variant.unpack(): + continue + + enabled_variant = proxy.get_cached_property("ChargeThresholdEnabled") + if enabled_variant is None: + continue + + self._charge_limit = ChargeLimit( + enabled=enabled_variant.unpack(), + device_path=path, + ) + return + except Exception as e: + logger.warning(f"[Battery] Failed to check charge limit on {path}: {e}") + + def _do_recalculate_peripherals(self) -> None: + peripherals = [] + for path, proxy in self._device_proxies.items(): + kind_raw = proxy.get_cached_property("Type") + power_supply = proxy.get_cached_property("PowerSupply") + if kind_raw is None or power_supply is None: + continue + kind = _device_kind_from_u32(kind_raw.unpack()) + if not kind.is_peripheral() or power_supply.unpack(): + continue + + peripheral_kind = _DEVICE_KIND_TO_PERIPHERAL.get(kind) + if peripheral_kind is None: + continue + + model_variant = proxy.get_cached_property("Model") + name = ( + model_variant.unpack() + if model_variant is not None + else str(peripheral_kind) + ) + + state_variant = proxy.get_cached_property("State") + state = ( + DeviceState(state_variant.unpack()) + if state_variant is not None + else DeviceState.UNKNOWN + ) + + pct_variant = proxy.get_cached_property("Percentage") + capacity = int(pct_variant.unpack()) if pct_variant is not None else 0 + + peripherals.append( + Peripheral( + name=name, + kind=peripheral_kind, + data=BatteryData( + capacity=capacity, + status=_state_from_raw(state), + is_discharging=state == DeviceState.DISCHARGING, + ), + path=path, + ) + ) + self._peripherals = peripherals + + def _do_handle_power_profile_change(self, *_args): + if self._power_profile_proxy is not None: + result = self._power_profile_proxy.get_cached_property("ActiveProfile") + if result is not None: + self._power_profile = result.unpack() self.emit("power_profile_changed") - def do_get_cached_property(self, property_name): - """Read from the GDBus proxy cache, which DBus keeps up-to-date.""" - result = self._proxy.get_cached_property(property_name) + def _do_get_cached_property(self, property_name: str): + result = self._display_proxy.get_cached_property(property_name) if result is not None: return result.unpack() - # Fallback: value not in proxy cache yet (e.g. very early in startup) return self._prop_cache.get(property_name) def get_power_profile(self) -> str | None: - """Get the current active power profile.""" if not self._power_profile_proxy: return None result = self._power_profile_proxy.get_cached_property("ActiveProfile") return result.unpack() if result is not None else None def set_power_profile(self, profile: str) -> bool: - """Set the active power profile.""" if not self._power_profile_proxy: return False try: parameters = GLib.Variant( "(ssv)", - (POWER_PROFILE_INTERFACE, "ActiveProfile", GLib.Variant("s", profile)), + (POWER_PROFILE_BUS, "ActiveProfile", GLib.Variant("s", profile)), ) self._bus.call_sync( - POWER_PROFILE_BUS_NAME, - POWER_PROFILE_BUS_PATH, + POWER_PROFILE_BUS, + POWER_PROFILE_PATH, "org.freedesktop.DBus.Properties", "Set", parameters, @@ -207,15 +687,53 @@ class Battery(Service): return False def get_available_power_profiles(self) -> list | None: - """Get list of available power profiles.""" if not self._power_profile_proxy: return None result = self._power_profile_proxy.get_cached_property("Profiles") if result is not None: profiles_data = result.unpack() - profiles = [] - for profile_dict in profiles_data: - if "Profile" in profile_dict: - profiles.append(profile_dict["Profile"]) - return profiles + return [p["Profile"] for p in profiles_data if "Profile" in p] return None + + @property + def charge_limit(self) -> ChargeLimit | None: + return self._charge_limit + + @property + def peripherals(self) -> list[Peripheral]: + return self._peripherals + + @property + def system_battery(self) -> BatteryData | None: + return self._system_battery + + def toggle_charge_limit(self) -> bool: + if self._charge_limit is None: + return False + try: + proxy = self._device_proxies.get(self._charge_limit.device_path) + if proxy is None: + return False + target = not self._charge_limit.enabled + self._toggling_charge_limit = True + try: + proxy.call_sync( + "EnableChargeThreshold", + GLib.Variant("(b)", (target,)), + Gio.DBusCallFlags.NONE, + -1, + None, + ) + finally: + GLib.idle_add(self._clear_toggling_flag) + self._charge_limit = ChargeLimit( + enabled=target, device_path=self._charge_limit.device_path + ) + return True + except Exception as e: + logger.error(f"[Battery] Failed to toggle charge limit: {e}") + return False + + def _clear_toggling_flag(self) -> bool: + self._toggling_charge_limit = False + return False diff --git a/src/styles/window/battery.css b/src/styles/window/battery.css index 60e03f4..46841e2 100644 --- a/src/styles/window/battery.css +++ b/src/styles/window/battery.css @@ -35,7 +35,9 @@ color: @glass-text; } -#energy-mode-button-clickable { +#energy-mode-button-clickable, +#charge-limit-button-clickable, +#game-mode-button-clickable{ background: transparent; border: none; border-radius: apply(radius-sm); @@ -43,15 +45,21 @@ margin: 1px 0; } -#energy-mode-button-clickable:hover { +#energy-mode-button-clickable:hover, +#charge-limit-button-clickable:hover, +#game-mode-button-clickable:hover { background: @glass-hover; } -#energy-mode-button-clickable:hover #energy-mode-icon { +#energy-mode-button-clickable:hover #energy-mode-icon, +#game-mode-button-clickable:hover #game-mode-icon, +#charge-limit-button-clickable:hover #charge-limit-icon { background-color: @glass-active; } -#energy-mode-icon { +#energy-mode-icon, +#game-mode-icon, +#charge-limit-icon{ border-radius: apply(radius-full); padding: 4px; margin-top: -2px; @@ -67,11 +75,13 @@ margin-bottom: -4px; color: @glass-text-secondary; } + .battery-section-title { font-size: apply(font-base); color: @glass-text-secondary; margin: 2px 0 0 8px; } + .battery-power-mode { font-size: apply(font-md); } @@ -82,66 +92,32 @@ min-height: 30px; border-radius: apply(radius-sm); } + .battery-settings-button { margin-top: -5px; min-height: 30px; border-radius: apply(radius-sm); } + .battery-settings-button label { font-weight: 500; color: @glass-text; } + #energy-mode-button:first-child { margin-top: 0; } + #energy-mode-button { margin-top: -8px; margin-bottom: 4px; color: @glass-text-secondary; } -.gamemode-button { - font-size: apply(font-sm); - margin-left: -6px; - color: @glass-text-secondary; -} -#game-mode-button { - margin: 2px 0; -} -#game-mode-icon { - margin-left: -6px; -} -#game-mode-button-clickable { - background: transparent; - border: none; - border-radius: apply(radius-sm); - padding: 6px 12px; - margin: 1px 0; -} - -#game-mode-button-clickable:hover { - background: @glass-hover; -} - -#game-mode-button-clickable:hover #game-mode-icon { - background-color: @glass-active; -} - -#game-mode-icon { - border-radius: apply(radius-full); - padding: 4px; - margin-right: 8px; - background-color: @glass-hover; - color: @glass-text-secondary; -} - -#energy-mode-icon.connected { +#energy-mode-icon.connected, +#charge-limit-icon.connected { background-color: @glass-active; color: @glass-text; } -#game-mode-icon.connected { - background-color: @glass-active; - color: @glass-text; -}