indicator.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  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. this._notificationSource = new MessageTray.Source({
  209. 'title': this._name,
  210. 'icon': getIcon('threshold-app', false)
  211. });
  212. Main.messageTray.add(this._notificationSource);
  213. // Driver signals
  214. this._driver.connectObject(
  215. 'notify', () => this._updateIndicator(),
  216. 'enable-battery-completed', (driver, battery, error) => {
  217. if (!error) {
  218. this._notifyEnabled(
  219. // 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)
  220. _('Battery (%s) charge thresholds enabled at %d/%d %%').format(
  221. battery.name, battery.startValue || 0, battery.endValue || 100
  222. )
  223. );
  224. } else {
  225. this._notifyError(
  226. // TRANSLATORS: The first %s is the name of the battery. The second %s is the error message. \n is new line.
  227. _('Failed to enable thresholds on battery %s. \nError: %s').format(
  228. battery.name, error.message
  229. )
  230. );
  231. }
  232. },
  233. 'disable-battery-completed', (driver, battery, error) => {
  234. if (!error) {
  235. this._notifyDisabled(
  236. // TRANSLATORS: %s is the name of the battery.
  237. _('Battery (%s) charge thresholds disabled').format(
  238. battery.name
  239. )
  240. );
  241. } else {
  242. this._notifyError(
  243. // TRANSLATORS: The first %s is the name of the battery. The second %s is the error message. \n is new line.
  244. _('Failed to disable thresholds on battery %s. \nError: %s').format(
  245. battery.name, error.message
  246. )
  247. );
  248. }
  249. },
  250. 'enable-all-completed', (driver, error) => {
  251. if (!error) {
  252. this._notifyEnabled(_('Thresholds enabled for all batteries'))
  253. } else {
  254. this._notifyError(
  255. // TRANSLATORS: %s is the error message. \n is new line.
  256. _('Failed to enable thresholds for all batteries. \nError: %s').format(
  257. error.message
  258. )
  259. );
  260. }
  261. },
  262. 'disable-all-completed', (driver, error) => {
  263. if (!error) {
  264. this._notifyDisabled(_('Thresholds disabled for all batteries'));
  265. } else {
  266. this._notifyError(
  267. // TRANSLATORS: %s is the error message. \n is new line.
  268. _('Failed to disable thresholds for all batteries. \nError: %s').format(
  269. error.message
  270. )
  271. );
  272. }
  273. },
  274. this
  275. );
  276. // Settings signals
  277. this._settings.connectObject(
  278. 'changed::color-mode', () => {
  279. this._updateIndicator();
  280. },
  281. 'changed::indicator-mode', () => {
  282. this._updateIndicator();
  283. },
  284. this
  285. );
  286. this.connect('destroy', () => {
  287. this._notificationSource.destroy();
  288. this._notificationSource = null;
  289. this.quickSettingsItems.forEach(item => item.destroy());
  290. this._settings.disconnectObject(this);
  291. this._settings = null;
  292. this._driver.disconnectObject(this);
  293. this._driver.destroy();
  294. this._driver = null;
  295. this._extension = null;
  296. });
  297. // Pending changes alert
  298. this._pendingChangesAlert();
  299. }
  300. /**
  301. * Update indicator (tray-icon)
  302. */
  303. _updateIndicator() {
  304. const colorMode = this._settings.get_boolean('color-mode');
  305. if (this._driver.isAvailable) {
  306. if (this._driver.isActive) {
  307. if (this._driver.pendingChanges) {
  308. this._indicator.gicon = getIcon('threshold-active-warning', colorMode);
  309. } else {
  310. this._indicator.gicon = getIcon('threshold-active', colorMode);
  311. }
  312. } else {
  313. this._indicator.gicon = getIcon('threshold-inactive', colorMode);
  314. }
  315. } else {
  316. this._indicator.gicon = getIcon('threshold-unknown', colorMode);
  317. }
  318. const indicatorMode = this._settings.get_enum('indicator-mode');
  319. switch (indicatorMode) {
  320. case 0: // Active
  321. this._indicator.visible = this._driver.isActive;
  322. break;
  323. case 1: // Inactive (or pending changes)
  324. this._indicator.visible = !this._driver.isActive || this._driver.pendingChanges;
  325. break;
  326. case 2: // Always
  327. this._indicator.visible = true;
  328. break;
  329. case 3: // Never (or pending changes)
  330. this._indicator.visible = this._driver.pendingChanges;
  331. break;
  332. default:
  333. this._indicator.visible = true;
  334. break;
  335. }
  336. }
  337. /**
  338. * Show notification.
  339. *
  340. * @param {string} details Message
  341. * @param {string} iconName Icon name
  342. * @param {boolean} [transient=true] Transient notification
  343. */
  344. _notify(details, iconName, transient = true) {
  345. if (!this._settings.get_boolean('show-notifications')) return;
  346. const notification = new MessageTray.Notification({
  347. source: this._notificationSource,
  348. title: _('Battery Threshold'),
  349. body: details,
  350. isTransient: transient,
  351. gicon: getIcon(iconName, true)
  352. });
  353. this._notificationSource.addNotification(notification);
  354. }
  355. /**
  356. * Show error notification
  357. *
  358. * @param {string} message Message
  359. */
  360. _notifyError(message) {
  361. this._notify(message, 'threshold-error', false)
  362. }
  363. /**
  364. * Show enabled notification
  365. *
  366. * @param {string} message Message
  367. */
  368. _notifyEnabled(message) {
  369. this._notify(message, 'threshold-active')
  370. }
  371. /**
  372. * Show disabled notification
  373. *
  374. * @param {string} message Message
  375. */
  376. _notifyDisabled(message) {
  377. this._notify(message, 'threshold-inactive')
  378. }
  379. /**
  380. * Show alert when are pending values changes
  381. */
  382. _pendingChangesAlert() {
  383. let notify = false;
  384. this._driver.batteries.every(battery => {
  385. if (battery.pendingChanges && battery.isActive) {
  386. notify = true;
  387. }
  388. return true;
  389. });
  390. if (notify) {
  391. let message = _(`The currently configured thresholds don't match those set on the system.`);
  392. if (SHELL_VERSION >= 48) {
  393. // TRANSLATORS: The strings "<b>" and "</b>" are part of the markup language and indicate that the text between the tags will be rendered in "bold"
  394. // TRANSLATORS: This chain joins another so it is a good idea to leave a space in front of it.
  395. // TRANSLATORS: "Preserve battery health" is an option in Gnome settings.
  396. message += _(` Is the <b>Preserve battery health</b> option enabled in Gnome settings? This mode restores its own values.`);
  397. }
  398. const notification = new MessageTray.Notification({
  399. source: this._notificationSource,
  400. title: _('Battery Threshold'),
  401. body: message,
  402. gicon: getIcon('threshold-active-warning', true),
  403. resident: true,
  404. urgency: MessageTray.Urgency.CRITICAL,
  405. 'use-body-markup': true
  406. });
  407. notification.addAction(_('Keep detected'), () => {
  408. this._driver.batteries.every(battery => {
  409. if (battery.pendingChanges && battery.isActive) {
  410. this._settings.set_int(`start-${battery.name.toLowerCase()}`, battery.startValue);
  411. this._settings.set_int(`end-${battery.name.toLowerCase()}`, battery.endValue);
  412. }
  413. return true;
  414. });
  415. notification.destroy();
  416. });
  417. notification.addAction(_('Set configured'), () => {
  418. this._driver.enableAll();
  419. notification.destroy();
  420. });
  421. this._notificationSource.addNotification(notification);
  422. }
  423. }
  424. });