indicator.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  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', () => this._updateIndicator(),
  211. 'enable-battery-completed', (driver, battery, error) => {
  212. if (!error) {
  213. this._notifyEnabled(
  214. // 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)
  215. _('Battery (%s) charge thresholds enabled at %d/%d %%').format(
  216. battery.name, battery.startValue || 0, battery.endValue || 100
  217. )
  218. );
  219. } else {
  220. this._notifyError(
  221. // TRANSLATORS: The first %s is the name of the battery. The second %s is the error message. \n is new line.
  222. _('Failed to enable thresholds on battery %s. \nError: %s').format(
  223. battery.name, error.message
  224. )
  225. );
  226. }
  227. },
  228. 'disable-battery-completed', (driver, battery, error) => {
  229. if (!error) {
  230. this._notifyDisabled(
  231. // TRANSLATORS: %s is the name of the battery.
  232. _('Battery (%s) charge thresholds disabled').format(
  233. battery.name
  234. )
  235. );
  236. } else {
  237. this._notifyError(
  238. // TRANSLATORS: The first %s is the name of the battery. The second %s is the error message. \n is new line.
  239. _('Failed to disable thresholds on battery %s. \nError: %s').format(
  240. battery.name, error.message
  241. )
  242. );
  243. }
  244. },
  245. 'enable-all-completed', (driver, error) => {
  246. if (!error) {
  247. this._notifyEnabled(_('Thresholds enabled for all batteries'))
  248. } else {
  249. this._notifyError(
  250. // TRANSLATORS: %s is the error message. \n is new line.
  251. _('Failed to enable thresholds for all batteries. \nError: %s').format(
  252. error.message
  253. )
  254. );
  255. }
  256. },
  257. 'disable-all-completed', (driver, error) => {
  258. if (!error) {
  259. this._notifyDisabled(_('Thresholds disabled for all batteries'));
  260. } else {
  261. this._notifyError(
  262. // TRANSLATORS: %s is the error message. \n is new line.
  263. _('Failed to disable thresholds for all batteries. \nError: %s').format(
  264. error.message
  265. )
  266. );
  267. }
  268. },
  269. this
  270. );
  271. // Settings signals
  272. this._settings.connectObject(
  273. 'changed::color-mode', () => {
  274. this._updateIndicator();
  275. },
  276. 'changed::indicator-mode', () => {
  277. this._updateIndicator();
  278. },
  279. this
  280. );
  281. this.connect('destroy', () => {
  282. this.quickSettingsItems.forEach(item => item.destroy());
  283. this._settings.disconnectObject(this);
  284. this._settings = null;
  285. this._driver.disconnectObject(this);
  286. this._driver.destroy();
  287. this._driver = null;
  288. this._extension = null;
  289. });
  290. // Pending changes alert
  291. this._driver.batteries.every(battery => {
  292. if (battery.pendingChanges && battery.isActive) {
  293. this._notify(_('Battery Threshold'), _('The currently set thresholds do not match the configured ones'), 'threshold-active-warning', false);
  294. return false;
  295. }
  296. return true;
  297. });
  298. }
  299. /**
  300. * Update indicator (tray-icon)
  301. */
  302. _updateIndicator() {
  303. const colorMode = this._settings.get_boolean('color-mode');
  304. if (this._driver.isAvailable) {
  305. if (this._driver.isActive) {
  306. if (this._driver.pendingChanges) {
  307. this._indicator.gicon = getIcon('threshold-active-warning', colorMode);
  308. } else {
  309. this._indicator.gicon = getIcon('threshold-active', colorMode);
  310. }
  311. } else {
  312. this._indicator.gicon = getIcon('threshold-inactive', colorMode);
  313. }
  314. } else {
  315. this._indicator.gicon = getIcon('threshold-unknown', colorMode);
  316. }
  317. const indicatorMode = this._settings.get_enum('indicator-mode');
  318. switch (indicatorMode) {
  319. case 0: // Active
  320. this._indicator.visible = this._driver.isActive;
  321. break;
  322. case 1: // Inactive (or pending changes)
  323. this._indicator.visible = !this._driver.isActive || this._driver.pendingChanges;
  324. break;
  325. case 2: // Always
  326. this._indicator.visible = true;
  327. break;
  328. case 3: // Never (or pending changes)
  329. this._indicator.visible = this._driver.pendingChanges;
  330. break;
  331. default:
  332. this._indicator.visible = true;
  333. break;
  334. }
  335. }
  336. /**
  337. * Show notification.
  338. *
  339. * @param {string} msg Title
  340. * @param {string} details Message
  341. * @param {string} iconName Icon name
  342. * @param {boolean} [transient=true] Transient notification
  343. */
  344. _notify(msg, details, iconName, transient = true) {
  345. if (!this._settings.get_boolean('show-notifications')) return;
  346. if (SHELL_VERSION === 45) {
  347. const source = new MessageTray.Source(this._name);
  348. Main.messageTray.add(source);
  349. const notification = new MessageTray.Notification(
  350. source,
  351. msg,
  352. details,
  353. { gicon: getIcon(iconName, true) }
  354. );
  355. notification.setTransient(transient);
  356. source.showNotification(notification);
  357. } else {
  358. const source = new MessageTray.Source({ 'title': this._name });
  359. Main.messageTray.add(source);
  360. const notification = new MessageTray.Notification({
  361. source: source,
  362. title: msg,
  363. body: details,
  364. isTransient: transient,
  365. gicon: getIcon(iconName, true)
  366. });
  367. source.addNotification(notification);
  368. }
  369. }
  370. /**
  371. * Show error notification
  372. *
  373. * @param {string} message Message
  374. */
  375. _notifyError(message) {
  376. this._notify(_('Battery Threshold'), message, 'threshold-error', false);
  377. }
  378. /**
  379. * Show enabled notification
  380. *
  381. * @param {string} message Message
  382. */
  383. _notifyEnabled(message) {
  384. this._notify(_('Battery Threshold'), message, 'threshold-active');
  385. }
  386. /**
  387. * Show disabled notification
  388. *
  389. * @param {string} message Message
  390. */
  391. _notifyDisabled(message) {
  392. this._notify(_('Battery Threshold'), message, 'threshold-inactive');
  393. }
  394. });