import Clutter from 'gi://Clutter'; import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; import St from 'gi://St' import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import * as Util from 'resource:///org/gnome/shell/misc/util.js'; import * as Sensors from './sensors.js'; import {Extension, gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js'; import * as MessageTray from 'resource:///org/gnome/shell/ui/messageTray.js'; import * as Values from './values.js'; import * as Config from 'resource:///org/gnome/shell/misc/config.js'; import * as MenuItem from './menuItem.js'; let vitalsMenu; var VitalsMenuButton = GObject.registerClass({ GTypeName: 'VitalsMenuButton', }, class VitalsMenuButton extends PanelMenu.Button { _init(extensionObject) { super._init(Clutter.ActorAlign.FILL); this._extensionObject = extensionObject; this._settings = extensionObject.getSettings(); this._sensorIcons = { 'temperature' : { 'icon': 'temperature-symbolic.svg' }, 'voltage' : { 'icon': 'voltage-symbolic.svg' }, 'fan' : { 'icon': 'fan-symbolic.svg' }, 'memory' : { 'icon': 'memory-symbolic.svg' }, 'processor' : { 'icon': 'cpu-symbolic.svg' }, 'system' : { 'icon': 'system-symbolic.svg' }, 'network' : { 'icon': 'network-symbolic.svg', 'icon-rx': 'network-download-symbolic.svg', 'icon-tx': 'network-upload-symbolic.svg' }, 'storage' : { 'icon': 'storage-symbolic.svg' }, 'battery' : { 'icon': 'battery-symbolic.svg' } } this._warnings = []; this._sensorMenuItems = {}; this._hotLabels = {}; this._hotIcons = {}; this._groups = {}; this._widths = {}; this._last_query = new Date().getTime(); this._sensors = new Sensors.Sensors(this._settings, this._sensorIcons); this._values = new Values.Values(this._settings, this._sensorIcons); this._menuLayout = new St.BoxLayout({ vertical: false, clip_to_allocation: true, x_align: Clutter.ActorAlign.START, y_align: Clutter.ActorAlign.CENTER, reactive: true, x_expand: true, pack_start: false }); this._drawMenu(); this.add_actor(this._menuLayout); this._settingChangedSignals = []; this._refreshTimeoutId = null; this._addSettingChangedSignal('update-time', this._updateTimeChanged.bind(this)); this._addSettingChangedSignal('position-in-panel', this._positionInPanelChanged.bind(this)); this._addSettingChangedSignal('menu-centered', this._positionInPanelChanged.bind(this)); let settings = [ 'use-higher-precision', 'alphabetize', 'hide-zeros', 'fixed-widths', 'hide-icons', 'unit', 'memory-measurement', 'include-public-ip', 'network-speed-format', 'storage-measurement', 'include-static-info' ]; for (let setting of Object.values(settings)) this._addSettingChangedSignal(setting, this._redrawMenu.bind(this)); // add signals for show- preference based categories for (let sensor in this._sensorIcons) this._addSettingChangedSignal('show-' + sensor, this._showHideSensorsChanged.bind(this)); this._initializeMenu(); // start off with fresh sensors this._querySensors(); // start monitoring sensors this._initializeTimer(); } _initializeMenu() { // display sensor categories for (let sensor in this._sensorIcons) { // groups associated sensors under accordion menu if (sensor in this._groups) continue; this._groups[sensor] = new PopupMenu.PopupSubMenuMenuItem(_(this._ucFirst(sensor)), true); this._groups[sensor].icon.gicon = Gio.icon_new_for_string(this._extensionObject.path + '/icons/' + this._sensorIcons[sensor]['icon']); // hide menu items that user has requested to not include if (!this._settings.get_boolean('show-' + sensor)) this._groups[sensor].actor.hide(); if (!this._groups[sensor].status) { this._groups[sensor].status = this._defaultLabel(); this._groups[sensor].actor.insert_child_at_index(this._groups[sensor].status, 4); this._groups[sensor].status.text = _('No Data'); } this.menu.addMenuItem(this._groups[sensor]); } // add separator this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); let item = new PopupMenu.PopupBaseMenuItem({ reactive: false, style_class: 'vitals-menu-button-container' }); let customButtonBox = new St.BoxLayout({ style_class: 'vitals-button-box', vertical: false, clip_to_allocation: true, x_align: Clutter.ActorAlign.CENTER, y_align: Clutter.ActorAlign.CENTER, reactive: true, x_expand: true, pack_start: false }); // custom round refresh button let refreshButton = this._createRoundButton('view-refresh-symbolic', _('Refresh')); refreshButton.connect('clicked', (self) => { // force refresh by clearing history this._sensors.resetHistory(); this._values.resetHistory(); // make sure timer fires at next full interval this._updateTimeChanged(); // refresh sensors now this._querySensors(); }); customButtonBox.add_actor(refreshButton); // custom round monitor button let monitorButton = this._createRoundButton('org.gnome.SystemMonitor-symbolic', _('System Monitor')); monitorButton.connect('clicked', (self) => { this.menu._getTopMenu().close(); Util.spawn(this._settings.get_string('monitor-cmd').split(" ")); }); customButtonBox.add_actor(monitorButton); // custom round preferences button let prefsButton = this._createRoundButton('preferences-system-symbolic', _('Preferences')); prefsButton.connect('clicked', (self) => { this.menu._getTopMenu().close(); this._extensionObject.openPreferences(); }); customButtonBox.add_actor(prefsButton); // now add the buttons to the top bar item.actor.add_actor(customButtonBox); // add buttons this.menu.addMenuItem(item); // query sensors on menu open this._menuStateChangeId = this.menu.connect('open-state-changed', (self, isMenuOpen) => { if (isMenuOpen) { // make sure timer fires at next full interval this._updateTimeChanged(); // refresh sensors now this._querySensors(); } }); } _createRoundButton(iconName) { let button = new St.Button({ style_class: 'message-list-clear-button button vitals-button-action' }); button.child = new St.Icon({ icon_name: iconName }); return button; } _removeMissingHotSensors(hotSensors) { for (let i = hotSensors.length - 1; i >= 0; i--) { let sensor = hotSensors[i]; // make sure default icon (if any) stays visible if (sensor == '_default_icon_') continue; // removes sensors that are no longer available if (!this._sensorMenuItems[sensor]) { hotSensors.splice(i, 1); this._removeHotLabel(sensor); this._removeHotIcon(sensor); } } return hotSensors; } _saveHotSensors(hotSensors) { // removes any sensors that may not currently be available hotSensors = this._removeMissingHotSensors(hotSensors); this._settings.set_strv('hot-sensors', hotSensors.filter( function(item, pos) { return hotSensors.indexOf(item) == pos; } )); } _initializeTimer() { // used to query sensors and update display let update_time = this._settings.get_int('update-time'); this._refreshTimeoutId = GLib.timeout_add_seconds( GLib.PRIORITY_DEFAULT, update_time, (self) => { // only update menu if we have hot sensors if (Object.values(this._hotLabels).length > 0) this._querySensors(); // keep the timer running return GLib.SOURCE_CONTINUE; } ); } _createHotItem(key, value) { let icon = this._defaultIcon(key); this._hotIcons[key] = icon; this._menuLayout.add_actor(icon) // don't add a label when no sensors are in the panel if (key == '_default_icon_') return; let label = new St.Label({ style_class: 'vitals-panel-label', text: (value)?value:'\u2026', // ... y_expand: true, y_align: Clutter.ActorAlign.START }); // attempt to prevent ellipsizes label.get_clutter_text().ellipsize = 0; // keep track of label for removal later this._hotLabels[key] = label; // prevent "called on the widget" "which is not in the stage" errors by adding before width below this._menuLayout.add_actor(label); // support for fixed widths #55, save label (text) width this._widths[key] = label.width; } _showHideSensorsChanged(self, sensor) { this._sensors.resetHistory(); this._groups[sensor.substr(5)].visible = this._settings.get_boolean(sensor); } _positionInPanelChanged() { this.container.get_parent().remove_actor(this.container); let position = this._positionInPanel(); // allows easily addressable boxes let boxes = { left: Main.panel._leftBox, center: Main.panel._centerBox, right: Main.panel._rightBox }; // update position when changed from preferences boxes[position[0]].insert_child_at_index(this.container, position[1]); } _removeHotLabel(key) { if (key in this._hotLabels) { let label = this._hotLabels[key]; delete this._hotLabels[key]; // make sure set_label is not called on non existent actor label.destroy(); } } _removeHotLabels() { for (let key in this._hotLabels) this._removeHotLabel(key); } _removeHotIcon(key) { if (key in this._hotIcons) { this._hotIcons[key].destroy(); delete this._hotIcons[key]; } } _removeHotIcons() { for (let key in this._hotIcons) this._removeHotIcon(key); } _redrawMenu() { this._removeHotIcons(); this._removeHotLabels(); for (let key in this._sensorMenuItems) { if (key.includes('-group')) continue; this._sensorMenuItems[key].destroy(); delete this._sensorMenuItems[key]; } this._drawMenu(); this._sensors.resetHistory(); this._values.resetHistory(); this._querySensors(); } _drawMenu() { // grab list of selected menubar icons let hotSensors = this._settings.get_strv('hot-sensors'); for (let key of Object.values(hotSensors)) { // fixes issue #225 which started when _max_ was moved to the end if (key == '__max_network-download__') key = '__network-rx_max__'; if (key == '__max_network-upload__') key = '__network-tx_max__'; this._createHotItem(key); } } _destroyTimer() { // invalidate and reinitialize timer if (this._refreshTimeoutId != null) { GLib.Source.remove(this._refreshTimeoutId); this._refreshTimeoutId = null; } } _updateTimeChanged() { this._destroyTimer(); this._initializeTimer(); } _addSettingChangedSignal(key, callback) { this._settingChangedSignals.push(this._settings.connect('changed::' + key, callback)); } _updateDisplay(label, value, type, key) { // update sensor value in menubar if (this._hotLabels[key]) { this._hotLabels[key].set_text(value); // support for fixed widths #55 if (this._settings.get_boolean('fixed-widths')) { // grab text box width and see if new text is wider than old text let width2 = this._hotLabels[key].get_clutter_text().width; if (width2 > this._widths[key]) { this._hotLabels[key].set_width(width2); this._widths[key] = width2; } } } // have we added this sensor before? let item = this._sensorMenuItems[key]; if (item) { // update sensor value in the group item.value = value; } else if (type.includes('-group')) { // update text next to group header let group = type.split('-')[0]; if (this._groups[group]) { this._groups[group].status.text = value; this._sensorMenuItems[type] = this._groups[group]; } } else { // add item to group for the first time let sensor = { 'label': label, 'value': value, 'type': type } this._appendMenuItem(sensor, key); } } _appendMenuItem(sensor, key) { let split = sensor.type.split('-'); let type = split[0]; let icon = (split.length == 2)?'icon-' + split[1]:'icon'; let gicon = Gio.icon_new_for_string(this._extensionObject.path + '/icons/' + this._sensorIcons[type][icon]); let item = new MenuItem.MenuItem(gicon, key, sensor.label, sensor.value, this._hotLabels[key]); item.connect('toggle', (self) => { let hotSensors = this._settings.get_strv('hot-sensors'); if (self.checked) { // add selected sensor to panel hotSensors.push(self.key); this._createHotItem(self.key, self.value); } else { // remove selected sensor from panel hotSensors.splice(hotSensors.indexOf(self.key), 1); this._removeHotLabel(self.key); this._removeHotIcon(self.key); } if (hotSensors.length <= 0) { // add generic icon to panel when no sensors are selected hotSensors.push('_default_icon_'); this._createHotItem('_default_icon_'); } else { let defIconPos = hotSensors.indexOf('_default_icon_'); if (defIconPos >= 0) { // remove generic icon from panel when sensors are selected hotSensors.splice(defIconPos, 1); this._removeHotIcon('_default_icon_'); } } // this code is called asynchronously - make sure to save it for next round this._saveHotSensors(hotSensors); }); this._sensorMenuItems[key] = item; let i = Object.keys(this._sensorMenuItems[key]).length; // alphabetize the sensors for these categories if (this._settings.get_boolean('alphabetize')) { let menuItems = this._groups[type].menu._getMenuItems(); for (i = 0; i < menuItems.length; i++) // use natural sort order for system load, etc if (menuItems[i].label.localeCompare(item.label, undefined, { numeric: true, sensitivity: 'base' }) > 0) break; } this._groups[type].menu.addMenuItem(item, i); } _defaultLabel() { return new St.Label({ y_expand: true, y_align: Clutter.ActorAlign.CENTER }); } _defaultIcon(key) { let split = key.replaceAll('_', ' ').trim().split(' ')[0].split('-'); let type = split[0]; let icon = new St.Icon({ style_class: 'system-status-icon vitals-panel-icon-' + type, reactive: true }); // second condition prevents crash due to issue #225, which started when _max_ was moved to the end if (type == 'default' || !(type in this._sensorIcons)) { icon.gicon = Gio.icon_new_for_string(this._extensionObject.path + '/icons/' + this._sensorIcons['system']['icon']); } else if (!this._settings.get_boolean('hide-icons')) { // support for hide icons #80 let iconObj = (split.length == 2)?'icon-' + split[1]:'icon'; icon.gicon = Gio.icon_new_for_string(this._extensionObject.path + '/icons/' + this._sensorIcons[type][iconObj]); } return icon; } _ucFirst(string) { return string.charAt(0).toUpperCase() + string.slice(1); } _positionInPanel() { let alignment = ''; let gravity = 0; let arrow_pos = 0; switch (this._settings.get_int('position-in-panel')) { case 0: // left alignment = 'left'; gravity = -1; arrow_pos = 1; break; case 1: // center alignment = 'center'; gravity = -1; arrow_pos = 0.5; break; case 2: // right alignment = 'right'; gravity = 0; arrow_pos = 0; break; case 3: // far left alignment = 'left'; gravity = 0; arrow_pos = 1; break; case 4: // far right alignment = 'right'; gravity = -1; arrow_pos = 0; break; } let centered = this._settings.get_boolean('menu-centered') if (centered) arrow_pos = 0.5; // set arrow position when initializing and moving vitals this.menu._arrowAlignment = arrow_pos; return [alignment, gravity]; } _querySensors() { // figure out last run time let now = new Date().getTime(); let dwell = (now - this._last_query) / 1000; this._last_query = now; this._sensors.query((label, value, type, format) => { let key = '_' + type.replace('-group', '') + '_' + label.replace(' ', '_').toLowerCase() + '_'; // if a sensor is disabled, gray it out if (key in this._sensorMenuItems) { this._sensorMenuItems[key].setSensitive((value!='disabled')); // don't continue below, last known value is shown if (value == 'disabled') return; } let items = this._values.returnIfDifferent(dwell, label, value, type, format, key); for (let item of Object.values(items)) this._updateDisplay(_(item[0]), item[1], item[2], item[3]); }, dwell); if (this._warnings.length > 0) { this._notify('Vitals', this._warnings.join("\n"), 'folder-symbolic'); this._warnings = []; } } _notify(msg, details, icon) { let source = new MessageTray.Source('MyApp Information', icon); Main.messageTray.add(source); let notification = new MessageTray.Notification(source, msg, details); notification.setTransient(true); source.notify(notification); } destroy() { this._destroyTimer(); for (let signal of Object.values(this._settingChangedSignals)) this._settings.disconnect(signal); super.destroy(); } }); export default class VitalsExtension extends Extension { enable() { vitalsMenu = new VitalsMenuButton(this); let position = vitalsMenu._positionInPanel(); Main.panel.addToStatusArea('vitalsMenu', vitalsMenu, position[1], position[0]); } disable() { vitalsMenu.destroy(); vitalsMenu = null; } }