'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();
this._notificationSource = new MessageTray.Source({
'title': this._name,
'icon': getIcon('threshold-app', false)
});
Main.messageTray.add(this._notificationSource);
// Driver signals
this._driver.connectObject(
'notify', () => 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._notificationSource.destroy();
this._notificationSource = null;
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._pendingChangesAlert();
}
/**
* 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} details Message
* @param {string} iconName Icon name
* @param {boolean} [transient=true] Transient notification
*/
_notify(details, iconName, transient = true) {
if (!this._settings.get_boolean('show-notifications')) return;
const notification = new MessageTray.Notification({
source: this._notificationSource,
title: _('Battery Threshold'),
body: details,
isTransient: transient,
gicon: getIcon(iconName, true)
});
this._notificationSource.addNotification(notification);
}
/**
* Show error notification
*
* @param {string} message Message
*/
_notifyError(message) {
this._notify(message, 'threshold-error', false)
}
/**
* Show enabled notification
*
* @param {string} message Message
*/
_notifyEnabled(message) {
this._notify(message, 'threshold-active')
}
/**
* Show disabled notification
*
* @param {string} message Message
*/
_notifyDisabled(message) {
this._notify(message, 'threshold-inactive')
}
/**
* Show alert when are pending values changes
*/
_pendingChangesAlert() {
let notify = false;
this._driver.batteries.every(battery => {
if (battery.pendingChanges && battery.isActive) {
notify = true;
}
return true;
});
if (notify) {
let message = _(`The currently configured thresholds don't match those set on the system.`);
if (SHELL_VERSION >= 48) {
// TRANSLATORS: The strings "" and "" are part of the markup language and indicate that the text between the tags will be rendered in "bold"
// TRANSLATORS: This chain joins another so it is a good idea to leave a space in front of it.
// TRANSLATORS: "Preserve battery health" is an option in Gnome settings.
message += _(` Is the Preserve battery health option enabled in Gnome settings? This mode restores its own values.`);
}
const notification = new MessageTray.Notification({
source: this._notificationSource,
title: _('Battery Threshold'),
body: message,
gicon: getIcon('threshold-active-warning', true),
resident: true,
urgency: MessageTray.Urgency.CRITICAL,
'use-body-markup': true
});
notification.addAction(_('Keep detected'), () => {
this._driver.batteries.every(battery => {
if (battery.pendingChanges && battery.isActive) {
this._settings.set_int(`start-${battery.name.toLowerCase()}`, battery.startValue);
this._settings.set_int(`end-${battery.name.toLowerCase()}`, battery.endValue);
}
return true;
});
notification.destroy();
});
notification.addAction(_('Set configured'), () => {
this._driver.enableAll();
notification.destroy();
});
this._notificationSource.addNotification(notification);
}
}
});