'use strict'; import Clutter from 'gi://Clutter'; import St from 'gi://St'; import GObject from 'gi://GObject'; import GLib from 'gi://GLib'; import Gio from 'gi://Gio'; import {gettext as _, ngettext} from 'resource:///org/gnome/shell/extensions/extension.js'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import * as MessageTray from 'resource:///org/gnome/shell/ui/messageTray.js'; import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; import * as QuickSettings from 'resource:///org/gnome/shell/ui/quickSettings.js'; import * as Config from 'resource://org/gnome/shell/misc/config.js'; import { ThinkPad } from './driver.js'; const ICONS_PATH = GLib.Uri.resolve_relative(import.meta.url, '../icons', GLib.UriFlags.NONE); const SHELL_VERSION = Number(Config.PACKAGE_VERSION.split('.', 1)); /** * Get icon * * @param {string} iconName Icon name * @param {boolean} colorMode Color mode or symbolic * @returns {Gio.Icon} */ function getIcon(iconName, colorMode = false) { return Gio.icon_new_for_string(`${ICONS_PATH}/${iconName}${colorMode ? '' : '-symbolic'}.svg`); } const BatteryItem = GObject.registerClass({ GTypeName: 'BatteryItem', }, class BatteryItem extends PopupMenu.PopupImageMenuItem { constructor(battery, settings) { super('', null); this._settings = settings; this._battery = battery; const box = new St.BoxLayout({ 'opacity': 128, 'x_expand': true, 'x_align': Clutter.ActorAlign.END, 'style': 'spacing: 5px;', }); this.add_child(box); // Reload icon this._reload = new St.Icon({ 'icon-size': 16, 'reactive': true, 'icon-name': 'view-refresh-symbolic', }); this._reload.connectObject( 'button-press-event', () => { this._battery.enable(); return true; }, this ); box.add_child(this._reload); this._valuesLabel = new St.Label({ 'y_align': Clutter.ActorAlign.CENTER, 'style': 'font-size: 0.75em;', }); box.add_child(this._valuesLabel); // Battery signals this._battery.connectObject( 'notify', () => { this._update(); }, this ); // Settings changes this._settings.connectObject( 'changed', () => { this._update(); }, this ); // Menu item action this.connectObject( 'activate', () => { this._battery.toggle(); }, 'destroy', () => { this._settings.disconnectObject(this); this._battery.disconnectObject(this); this._reload.disconnectObject(this); this.disconnectObject(this); this._valuesLabel.destroy(); this._valuesLabel = null; this._reload.destroy(); this._reload = null; this._battery = null; this._settings = null; }, this ); this._update(); } /** * Update UI */ _update() { const colorMode = this._settings.get_boolean('color-mode'); // Menu text and icon if (this._battery.isActive) { // TRANSLATORS: %s is the name of the battery. this.label.text = _('Disable thresholds (%s)').format(this._battery.name); if (this._battery.pendingChanges) { this.setIcon(getIcon('threshold-active-warning', colorMode)); } else { this.setIcon(getIcon('threshold-active', colorMode)); } // Status text const showCurrentValues = this._settings.get_boolean('show-current-values'); if (showCurrentValues) { // TRANSLATORS: %d/%d are the [start/end] threshold values. The string %% is the percent symbol (may need to be escaped depending on the language) this._valuesLabel.text = _('%d/%d %%').format(this._battery.startValue || 0, this._battery.endValue || 100); this._valuesLabel.visible = true; } else { this._valuesLabel.visible = false; } } else { // TRANSLATORS: %s is the name of the battery. this.label.text = _('Enable thresholds (%s)').format(this._battery.name); this.setIcon(getIcon('threshold-inactive', colorMode )); this._valuesLabel.visible = false; } // Reload 'button' this._reload.visible = this._battery.pendingChanges && this._battery.isActive; // Menu item visibility this.visible = this._battery.isAvailable; } }); const ThresholdToggle = GObject.registerClass({ GTypeName: 'ThresholdToggle', }, class ThresholdToggle extends QuickSettings.QuickMenuToggle { constructor(driver, extensionObject) { super({ 'title': _('Thresholds'), 'gicon': getIcon('threshold-app'), 'toggle-mode': false, //'subtitle': 'subtitle' }); // Header this.menu.setHeader( getIcon('threshold-app'), // Icon _('Battery Threshold'), // Title driver.environment.productVersion ? driver.environment.productVersion : _('Unknown model')// Subtitle ); // Unavailable this.unavailableMenuItem = new PopupMenu.PopupImageMenuItem(_('Thresholds not available'), getIcon('threshold-unknown')); this.unavailableMenuItem.sensitive = false; this.unavailableMenuItem.visible = false; this.menu.addMenuItem(this.unavailableMenuItem); // Batteries driver.batteries.forEach(battery => { // Battery menu item const item = new BatteryItem(battery, extensionObject.getSettings()); this.menu.addMenuItem(item); }); // Unavailable status this.unavailableMenuItem.visible = !driver.isAvailable; // Checked status this.checked = driver.isActive; // Driver signals driver.connectObject( 'notify::is-active', () => { this.checked = driver.isActive; }, 'notify::is-available', () => { this.unavailableMenuItem.visible = !driver.isAvailable; }, this ); // Signals this.connectObject( 'clicked', () => { if (driver.isActive) { driver.disableAll(); } else { driver.enableAll(); } }, 'destroy', () => { this.disconnectObject(this); driver.disconnectObject(this); this.menu.removeAll(); }, this ); // Add an entry-point for more getSettings() this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); const settingsItem = this.menu.addAction(_('Thresholds settings'), () => extensionObject.openPreferences()); // Ensure the getSettings() are unavailable when the screen is locked settingsItem.visible = Main.sessionMode.allowSettings; this.menu._settingsActions[extensionObject.uuid] = settingsItem; } }); export const ThresholdIndicator = GObject.registerClass({ GTypeName: 'ThresholdIndicator', }, class ThresholdIndicator extends QuickSettings.SystemIndicator { constructor(extensionObject) { super(); this._settings = extensionObject.getSettings(); this._driver = new ThinkPad({'settings': this._settings}) this._name = extensionObject.metadata.name; this._indicator = this._addIndicator(); this._indicator.gicon = getIcon('threshold-unknown'); this.quickSettingsItems.push(new ThresholdToggle(this._driver, extensionObject)); Main.panel.statusArea.quickSettings.addExternalIndicator(this); this._updateIndicator(); // Driver signals this._driver.connectObject( 'notify::is-available', () => { this._updateIndicator(); }, 'notify::is-active', () => { this._updateIndicator(); }, 'notify::pending-changes', () => { this._updateIndicator(); }, 'enable-battery-completed', (driver, battery, error) => { if (!error) { this._notifyEnabled( // TRANSLATORS: %s is the name of the battery. %d/%d are the [start/end] threshold values. The string %% is the percent symbol (may need to be escaped depending on the language) _('Battery (%s) charge thresholds enabled at %d/%d %%').format( battery.name, battery.startValue || 0, battery.endValue || 100 ) ); } else { this._notifyError( // TRANSLATORS: The first %s is the name of the battery. The second %s is the error message. \n is new line. _('Failed to enable thresholds on battery %s. \nError: %s').format( battery.name, error.message ) ); } }, 'disable-battery-completed', (driver, battery, error) => { if (!error) { this._notifyDisabled( // TRANSLATORS: %s is the name of the battery. _('Battery (%s) charge thresholds disabled').format( battery.name ) ); } else { this._notifyError( // TRANSLATORS: The first %s is the name of the battery. The second %s is the error message. \n is new line. _('Failed to disable thresholds on battery %s. \nError: %s').format( battery.name, error.message ) ); } }, 'enable-all-completed', (driver, error) => { if (!error) { this._notifyEnabled(_('Thresholds enabled for all batteries')) } else { this._notifyError( // TRANSLATORS: %s is the error message. \n is new line. _('Failed to enable thresholds for all batteries. \nError: %s').format( error.message ) ); } }, 'disable-all-completed', (driver, error) => { if (!error) { this._notifyDisabled(_('Thresholds disabled for all batteries')); } else { this._notifyError( // TRANSLATORS: %s is the error message. \n is new line. _('Failed to disable thresholds for all batteries. \nError: %s').format( error.message ) ); } }, this ); // Settings signals this._settings.connectObject( 'changed::color-mode', () => { this._updateIndicator(); }, 'changed::indicator-mode', () => { this._updateIndicator(); }, this ); this.connect('destroy', () => { this.quickSettingsItems.forEach(item => item.destroy()); this._settings.disconnectObject(this); this._settings = null; this._driver.disconnectObject(this); this._driver.destroy(); this._driver = null; this._extension = null; }); // Pending changes alert this._driver.batteries.every(battery => { if (battery.pendingChanges && battery.isActive) { this._notify(_('Battery Threshold'), _('The currently set thresholds do not match the configured ones'), 'threshold-active-warning', false); return false; } return true; }); } /** * Update indicator (tray-icon) */ _updateIndicator() { const colorMode = this._settings.get_boolean('color-mode'); if (this._driver.isAvailable) { if (this._driver.isActive) { if (this._driver.pendingChanges) { this._indicator.gicon = getIcon('threshold-active-warning', colorMode); } else { this._indicator.gicon = getIcon('threshold-active', colorMode); } } else { this._indicator.gicon = getIcon('threshold-inactive', colorMode); } } else { this._indicator.gicon = getIcon('threshold-unknown', colorMode); } const indicatorMode = this._settings.get_enum('indicator-mode'); switch (indicatorMode) { case 0: // Active this._indicator.visible = this._driver.isActive; break; case 1: // Inactive (or pending changes) this._indicator.visible = !this._driver.isActive || this._driver.pendingChanges; break; case 2: // Always this._indicator.visible = true; break; case 3: // Never (or pending changes) this._indicator.visible = this._driver.pendingChanges; break; default: this._indicator.visible = true; break; } } /** * Show notification. * * @param {string} msg Title * @param {string} details Message * @param {string} iconName Icon name * @param {boolean} [transient=true] Transient notification */ _notify(msg, details, iconName, transient=true) { if (!this._settings.get_boolean('show-notifications')) return; if (SHELL_VERSION === 45) { const source = new MessageTray.Source(this._name); Main.messageTray.add(source); const notification = new MessageTray.Notification( source, msg, details, {gicon: getIcon(iconName, true)} ); notification.setTransient(transient); source.showNotification(notification); } else { const source = new MessageTray.Source({'title': this._name}); Main.messageTray.add(source); const notification = new MessageTray.Notification({ source: source, title: msg, body: details, isTransient: transient, gicon: getIcon(iconName, true) }); source.addNotification(notification); } } /** * Show error notification * * @param {string} message Message */ _notifyError(message) { this._notify(_('Battery Threshold'), message, 'threshold-error', false); } /** * Show enabled notification * * @param {string} message Message */ _notifyEnabled(message) { this._notify(_('Battery Threshold'), message, 'threshold-active'); } /** * Show disabled notification * * @param {string} message Message */ _notifyDisabled(message) { this._notify(_('Battery Threshold'), message, 'threshold-inactive'); } });