indicator.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. 'use strict';
  2. import Clutter from 'gi://Clutter';
  3. import St from 'gi://St';
  4. import GObject from 'gi://GObject';
  5. import GLib from 'gi://GLib';
  6. import Gio from 'gi://Gio';
  7. import {gettext as _, ngettext} from 'resource:///org/gnome/shell/extensions/extension.js';
  8. import * as Main from 'resource:///org/gnome/shell/ui/main.js';
  9. import * as MessageTray from 'resource:///org/gnome/shell/ui/messageTray.js';
  10. import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
  11. import * as QuickSettings from 'resource:///org/gnome/shell/ui/quickSettings.js';
  12. import * as Config from 'resource://org/gnome/shell/misc/config.js';
  13. import { ThinkPad } from './driver.js';
  14. const ICONS_PATH = GLib.Uri.resolve_relative(import.meta.url, '../icons', GLib.UriFlags.NONE);
  15. const SHELL_VERSION = Number(Config.PACKAGE_VERSION.split('.', 1));
  16. /**
  17. * Get icon
  18. *
  19. * @param {string} iconName Icon name
  20. * @param {boolean} colorMode Color mode or symbolic
  21. * @returns {Gio.Icon}
  22. */
  23. function getIcon(iconName, colorMode = false) {
  24. return Gio.icon_new_for_string(`${ICONS_PATH}/${iconName}${colorMode ? '' : '-symbolic'}.svg`);
  25. }
  26. const BatteryItem = GObject.registerClass({
  27. GTypeName: 'BatteryItem',
  28. }, class BatteryItem extends PopupMenu.PopupImageMenuItem {
  29. constructor(battery, settings) {
  30. super('', null);
  31. this._settings = settings;
  32. this._battery = battery;
  33. const box = new St.BoxLayout({
  34. 'opacity': 128,
  35. 'x_expand': true,
  36. 'x_align': Clutter.ActorAlign.END,
  37. 'style': 'spacing: 5px;',
  38. });
  39. this.add_child(box);
  40. // Reload icon
  41. this._reload = new St.Icon({
  42. 'icon-size': 16,
  43. 'reactive': true,
  44. 'icon-name': 'view-refresh-symbolic',
  45. });
  46. this._reload.connectObject(
  47. 'button-press-event', () => {
  48. this._battery.enable();
  49. return true;
  50. },
  51. this
  52. );
  53. box.add_child(this._reload);
  54. this._valuesLabel = new St.Label({
  55. 'y_align': Clutter.ActorAlign.CENTER,
  56. 'style': 'font-size: 0.75em;',
  57. });
  58. box.add_child(this._valuesLabel);
  59. // Battery signals
  60. this._battery.connectObject(
  61. 'notify', () => {
  62. this._update();
  63. },
  64. this
  65. );
  66. // Settings changes
  67. this._settings.connectObject(
  68. 'changed', () => {
  69. this._update();
  70. },
  71. this
  72. );
  73. // Menu item action
  74. this.connectObject(
  75. 'activate', () => {
  76. this._battery.toggle();
  77. },
  78. 'destroy', () => {
  79. this._settings.disconnectObject(this);
  80. this._battery.disconnectObject(this);
  81. this._reload.disconnectObject(this);
  82. this.disconnectObject(this);
  83. this._valuesLabel.destroy();
  84. this._valuesLabel = null;
  85. this._reload.destroy();
  86. this._reload = null;
  87. this._battery = null;
  88. this._settings = null;
  89. },
  90. this
  91. );
  92. this._update();
  93. }
  94. /**
  95. * Update UI
  96. */
  97. _update() {
  98. const colorMode = this._settings.get_boolean('color-mode');
  99. // Menu text and icon
  100. if (this._battery.isActive) {
  101. // TRANSLATORS: %s is the name of the battery.
  102. this.label.text = _('Disable thresholds (%s)').format(this._battery.name);
  103. if (this._battery.pendingChanges) {
  104. this.setIcon(getIcon('threshold-active-warning', colorMode));
  105. } else {
  106. this.setIcon(getIcon('threshold-active', colorMode));
  107. }
  108. // Status text
  109. const showCurrentValues = this._settings.get_boolean('show-current-values');
  110. if (showCurrentValues) {
  111. // TRANSLATORS: %d/%d are the [start/end] threshold values. The string %% is the percent symbol (may need to be escaped depending on the language)
  112. this._valuesLabel.text = _('%d/%d %%').format(this._battery.startValue || 0, this._battery.endValue || 100);
  113. this._valuesLabel.visible = true;
  114. } else {
  115. this._valuesLabel.visible = false;
  116. }
  117. } else {
  118. // TRANSLATORS: %s is the name of the battery.
  119. this.label.text = _('Enable thresholds (%s)').format(this._battery.name);
  120. this.setIcon(getIcon('threshold-inactive', colorMode ));
  121. this._valuesLabel.visible = false;
  122. }
  123. // Reload 'button'
  124. this._reload.visible = this._battery.pendingChanges && this._battery.isActive;
  125. // Menu item visibility
  126. this.visible = this._battery.isAvailable;
  127. }
  128. });
  129. const ThresholdToggle = GObject.registerClass({
  130. GTypeName: 'ThresholdToggle',
  131. }, class ThresholdToggle extends QuickSettings.QuickMenuToggle {
  132. constructor(driver, extensionObject) {
  133. super({
  134. 'title': _('Thresholds'),
  135. 'gicon': getIcon('threshold-app'),
  136. 'toggle-mode': false,
  137. //'subtitle': 'subtitle'
  138. });
  139. // Header
  140. this.menu.setHeader(
  141. getIcon('threshold-app'), // Icon
  142. _('Battery Threshold'), // Title
  143. driver.environment.productVersion ? driver.environment.productVersion : _('Unknown model')// Subtitle
  144. );
  145. // Unavailable
  146. this.unavailableMenuItem = new PopupMenu.PopupImageMenuItem(_('Thresholds not available'), getIcon('threshold-unknown'));
  147. this.unavailableMenuItem.sensitive = false;
  148. this.unavailableMenuItem.visible = false;
  149. this.menu.addMenuItem(this.unavailableMenuItem);
  150. // Batteries
  151. driver.batteries.forEach(battery => {
  152. // Battery menu item
  153. const item = new BatteryItem(battery, extensionObject.getSettings());
  154. this.menu.addMenuItem(item);
  155. });
  156. // Unavailable status
  157. this.unavailableMenuItem.visible = !driver.isAvailable;
  158. // Checked status
  159. this.checked = driver.isActive;
  160. // Driver signals
  161. driver.connectObject(
  162. 'notify::is-active', () => {
  163. this.checked = driver.isActive;
  164. },
  165. 'notify::is-available', () => {
  166. this.unavailableMenuItem.visible = !driver.isAvailable;
  167. },
  168. this
  169. );
  170. // Signals
  171. this.connectObject(
  172. 'clicked', () => {
  173. if (driver.isActive) {
  174. driver.disableAll();
  175. } else {
  176. driver.enableAll();
  177. }
  178. },
  179. 'destroy', () => {
  180. this.disconnectObject(this);
  181. driver.disconnectObject(this);
  182. this.menu.removeAll();
  183. },
  184. this
  185. );
  186. // Add an entry-point for more getSettings()
  187. this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
  188. const settingsItem = this.menu.addAction(_('Thresholds settings'),
  189. () => extensionObject.openPreferences());
  190. // Ensure the getSettings() are unavailable when the screen is locked
  191. settingsItem.visible = Main.sessionMode.allowSettings;
  192. this.menu._settingsActions[extensionObject.uuid] = settingsItem;
  193. }
  194. });
  195. export const ThresholdIndicator = GObject.registerClass({
  196. GTypeName: 'ThresholdIndicator',
  197. }, class ThresholdIndicator extends QuickSettings.SystemIndicator {
  198. constructor(extensionObject) {
  199. super();
  200. this._settings = extensionObject.getSettings();
  201. this._driver = new ThinkPad({'settings': this._settings})
  202. this._name = extensionObject.metadata.name;
  203. this._indicator = this._addIndicator();
  204. this._indicator.gicon = getIcon('threshold-unknown');
  205. this.quickSettingsItems.push(new ThresholdToggle(this._driver, extensionObject));
  206. Main.panel.statusArea.quickSettings.addExternalIndicator(this);
  207. this._updateIndicator();
  208. // Driver signals
  209. this._driver.connectObject(
  210. 'notify::is-available', () => {
  211. this._updateIndicator();
  212. },
  213. 'notify::is-active', () => {
  214. this._updateIndicator();
  215. },
  216. 'notify::pending-changes', () => {
  217. this._updateIndicator();
  218. },
  219. 'enable-battery-completed', (driver, battery, error) => {
  220. if (!error) {
  221. this._notifyEnabled(
  222. // 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)
  223. _('Battery (%s) charge thresholds enabled at %d/%d %%').format(
  224. battery.name, battery.startValue || 0, battery.endValue || 100
  225. )
  226. );
  227. } else {
  228. this._notifyError(
  229. // TRANSLATORS: The first %s is the name of the battery. The second %s is the error message. \n is new line.
  230. _('Failed to enable thresholds on battery %s. \nError: %s').format(
  231. battery.name, error.message
  232. )
  233. );
  234. }
  235. },
  236. 'disable-battery-completed', (driver, battery, error) => {
  237. if (!error) {
  238. this._notifyDisabled(
  239. // TRANSLATORS: %s is the name of the battery.
  240. _('Battery (%s) charge thresholds disabled').format(
  241. battery.name
  242. )
  243. );
  244. } else {
  245. this._notifyError(
  246. // TRANSLATORS: The first %s is the name of the battery. The second %s is the error message. \n is new line.
  247. _('Failed to disable thresholds on battery %s. \nError: %s').format(
  248. battery.name, error.message
  249. )
  250. );
  251. }
  252. },
  253. 'enable-all-completed', (driver, error) => {
  254. if (!error) {
  255. this._notifyEnabled(_('Thresholds enabled for all batteries'))
  256. } else {
  257. this._notifyError(
  258. // TRANSLATORS: %s is the error message. \n is new line.
  259. _('Failed to enable thresholds for all batteries. \nError: %s').format(
  260. error.message
  261. )
  262. );
  263. }
  264. },
  265. 'disable-all-completed', (driver, error) => {
  266. if (!error) {
  267. this._notifyDisabled(_('Thresholds disabled for all batteries'));
  268. } else {
  269. this._notifyError(
  270. // TRANSLATORS: %s is the error message. \n is new line.
  271. _('Failed to disable thresholds for all batteries. \nError: %s').format(
  272. error.message
  273. )
  274. );
  275. }
  276. },
  277. this
  278. );
  279. // Settings signals
  280. this._settings.connectObject(
  281. 'changed::color-mode', () => {
  282. this._updateIndicator();
  283. },
  284. 'changed::indicator-mode', () => {
  285. this._updateIndicator();
  286. },
  287. this
  288. );
  289. this.connect('destroy', () => {
  290. this.quickSettingsItems.forEach(item => item.destroy());
  291. this._settings.disconnectObject(this);
  292. this._settings = null;
  293. this._driver.disconnectObject(this);
  294. this._driver.destroy();
  295. this._driver = null;
  296. this._extension = null;
  297. });
  298. // Pending changes alert
  299. this._driver.batteries.every(battery => {
  300. if (battery.pendingChanges && battery.isActive) {
  301. this._notify(_('Battery Threshold'), _('The currently set thresholds do not match the configured ones'), 'threshold-active-warning', false);
  302. return false;
  303. }
  304. return true;
  305. });
  306. }
  307. /**
  308. * Update indicator (tray-icon)
  309. */
  310. _updateIndicator() {
  311. const colorMode = this._settings.get_boolean('color-mode');
  312. if (this._driver.isAvailable) {
  313. if (this._driver.isActive) {
  314. if (this._driver.pendingChanges) {
  315. this._indicator.gicon = getIcon('threshold-active-warning', colorMode);
  316. } else {
  317. this._indicator.gicon = getIcon('threshold-active', colorMode);
  318. }
  319. } else {
  320. this._indicator.gicon = getIcon('threshold-inactive', colorMode);
  321. }
  322. } else {
  323. this._indicator.gicon = getIcon('threshold-unknown', colorMode);
  324. }
  325. const indicatorMode = this._settings.get_enum('indicator-mode');
  326. switch (indicatorMode) {
  327. case 0: // Active
  328. this._indicator.visible = this._driver.isActive;
  329. break;
  330. case 1: // Inactive (or pending changes)
  331. this._indicator.visible = !this._driver.isActive || this._driver.pendingChanges;
  332. break;
  333. case 2: // Always
  334. this._indicator.visible = true;
  335. break;
  336. case 3: // Never (or pending changes)
  337. this._indicator.visible = this._driver.pendingChanges;
  338. break;
  339. default:
  340. this._indicator.visible = true;
  341. break;
  342. }
  343. }
  344. /**
  345. * Show notification.
  346. *
  347. * @param {string} msg Title
  348. * @param {string} details Message
  349. * @param {string} iconName Icon name
  350. * @param {boolean} [transient=true] Transient notification
  351. */
  352. _notify(msg, details, iconName, transient=true) {
  353. if (!this._settings.get_boolean('show-notifications')) return;
  354. if (SHELL_VERSION === 45) {
  355. const source = new MessageTray.Source(this._name);
  356. Main.messageTray.add(source);
  357. const notification = new MessageTray.Notification(
  358. source,
  359. msg,
  360. details,
  361. {gicon: getIcon(iconName, true)}
  362. );
  363. notification.setTransient(transient);
  364. source.showNotification(notification);
  365. } else {
  366. const source = new MessageTray.Source({'title': this._name});
  367. Main.messageTray.add(source);
  368. const notification = new MessageTray.Notification({
  369. source: source,
  370. title: msg,
  371. body: details,
  372. isTransient: transient,
  373. gicon: getIcon(iconName, true)
  374. });
  375. source.addNotification(notification);
  376. }
  377. }
  378. /**
  379. * Show error notification
  380. *
  381. * @param {string} message Message
  382. */
  383. _notifyError(message) {
  384. this._notify(_('Battery Threshold'), message, 'threshold-error', false);
  385. }
  386. /**
  387. * Show enabled notification
  388. *
  389. * @param {string} message Message
  390. */
  391. _notifyEnabled(message) {
  392. this._notify(_('Battery Threshold'), message, 'threshold-active');
  393. }
  394. /**
  395. * Show disabled notification
  396. *
  397. * @param {string} message Message
  398. */
  399. _notifyDisabled(message) {
  400. this._notify(_('Battery Threshold'), message, 'threshold-inactive');
  401. }
  402. });