// This file is part of the AppIndicator/KStatusNotifierItem GNOME Shell extension // // This program is free software; you can redistribute it and/or // modify it under the terms of the GNU General Public License // as published by the Free Software Foundation; either version 2 // of the License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program; if not, write to the Free Software // Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. import Clutter from 'gi://Clutter'; import Gio from 'gi://Gio'; import GObject from 'gi://GObject'; import St from 'gi://St'; import * as AppDisplay from 'resource:///org/gnome/shell/ui/appDisplay.js'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import * as Panel from 'resource:///org/gnome/shell/ui/panel.js'; import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; import * as AppIndicator from './appIndicator.js'; import * as PromiseUtils from './promiseUtils.js'; import * as SettingsManager from './settingsManager.js'; import * as Util from './util.js'; import * as DBusMenu from './dbusMenu.js'; const DEFAULT_ICON_SIZE = Panel.PANEL_ICON_SIZE || 16; export function addIconToPanel(statusIcon) { if (!(statusIcon instanceof BaseStatusIcon)) throw TypeError(`Unexpected icon type: ${statusIcon}`); const settings = SettingsManager.getDefaultGSettings(); const indicatorId = `appindicator-${statusIcon.uniqueId}`; const currentIcon = Main.panel.statusArea[indicatorId]; if (currentIcon) { if (currentIcon !== statusIcon) currentIcon.destroy(); Main.panel.statusArea[indicatorId] = null; } Main.panel.addToStatusArea(indicatorId, statusIcon, 1, settings.get_string('tray-pos')); Util.connectSmart(settings, 'changed::tray-pos', statusIcon, () => addIconToPanel(statusIcon)); } export function getTrayIcons() { return Object.values(Main.panel.statusArea).filter( i => i instanceof IndicatorStatusTrayIcon); } export function getAppIndicatorIcons() { return Object.values(Main.panel.statusArea).filter( i => i instanceof IndicatorStatusIcon); } export const BaseStatusIcon = GObject.registerClass( class IndicatorBaseStatusIcon extends PanelMenu.Button { _init(menuAlignment, nameText, iconActor, dontCreateMenu) { super._init(menuAlignment, nameText, dontCreateMenu); const settings = SettingsManager.getDefaultGSettings(); Util.connectSmart(settings, 'changed::icon-opacity', this, this._updateOpacity); this.connect('notify::hover', () => this._onHoverChanged()); if (!super._onDestroy) this.connect('destroy', () => this._onDestroy()); this._box = new St.BoxLayout({style_class: 'panel-status-indicators-box'}); this.add_child(this._box); this._setIconActor(iconActor); this._showIfReady(); } _setIconActor(icon) { if (!(icon instanceof Clutter.Actor)) throw new Error(`${icon} is not a valid actor`); if (this._icon && this._icon !== icon) this._icon.destroy(); this._icon = icon; this._updateEffects(); this._monitorIconEffects(); if (this._icon) { this._box.add_child(this._icon); const id = this._icon.connect('destroy', () => { this._icon.disconnect(id); this._icon = null; this._monitorIconEffects(); }); } } _onDestroy() { if (this._icon) this._icon.destroy(); if (super._onDestroy) super._onDestroy(); } isReady() { throw new GObject.NotImplementedError('isReady() in %s'.format(this.constructor.name)); } get icon() { return this._icon; } get uniqueId() { throw new GObject.NotImplementedError('uniqueId in %s'.format(this.constructor.name)); } _showIfReady() { this.visible = this.isReady(); } _onHoverChanged() { if (this.hover) { this.opacity = 255; if (this._icon) this._icon.remove_effect_by_name('desaturate'); } else { this._updateEffects(); } } _updateOpacity() { const settings = SettingsManager.getDefaultGSettings(); const userValue = settings.get_user_value('icon-opacity'); if (userValue) this.opacity = userValue.unpack(); else this.opacity = 255; } _updateEffects() { this._updateOpacity(); if (this._icon) { this._updateSaturation(); this._updateBrightnessContrast(); } } _monitorIconEffects() { const settings = SettingsManager.getDefaultGSettings(); const monitoring = !!this._iconSaturationIds; if (!this._icon && monitoring) { Util.disconnectSmart(settings, this, this._iconSaturationIds); delete this._iconSaturationIds; Util.disconnectSmart(settings, this, this._iconBrightnessIds); delete this._iconBrightnessIds; Util.disconnectSmart(settings, this, this._iconContrastIds); delete this._iconContrastIds; } else if (this._icon && !monitoring) { this._iconSaturationIds = Util.connectSmart(settings, 'changed::icon-saturation', this, this._updateSaturation); this._iconBrightnessIds = Util.connectSmart(settings, 'changed::icon-brightness', this, this._updateBrightnessContrast); this._iconContrastIds = Util.connectSmart(settings, 'changed::icon-contrast', this, this._updateBrightnessContrast); } } _updateSaturation() { const settings = SettingsManager.getDefaultGSettings(); const desaturationValue = settings.get_double('icon-saturation'); let desaturateEffect = this._icon.get_effect('desaturate'); if (desaturationValue > 0) { if (!desaturateEffect) { desaturateEffect = new Clutter.DesaturateEffect(); this._icon.add_effect_with_name('desaturate', desaturateEffect); } desaturateEffect.set_factor(desaturationValue); } else if (desaturateEffect) { this._icon.remove_effect(desaturateEffect); } } _updateBrightnessContrast() { const settings = SettingsManager.getDefaultGSettings(); const brightnessValue = settings.get_double('icon-brightness'); const contrastValue = settings.get_double('icon-contrast'); let brightnessContrastEffect = this._icon.get_effect('brightness-contrast'); if (brightnessValue !== 0 | contrastValue !== 0) { if (!brightnessContrastEffect) { brightnessContrastEffect = new Clutter.BrightnessContrastEffect(); this._icon.add_effect_with_name('brightness-contrast', brightnessContrastEffect); } brightnessContrastEffect.set_brightness(brightnessValue); brightnessContrastEffect.set_contrast(contrastValue); } else if (brightnessContrastEffect) { this._icon.remove_effect(brightnessContrastEffect); } } }); /* * IndicatorStatusIcon implements an icon in the system status area */ export const IndicatorStatusIcon = GObject.registerClass( class IndicatorStatusIcon extends BaseStatusIcon { _init(indicator) { super._init(0.5, indicator.accessibleName, new AppIndicator.IconActor(indicator, DEFAULT_ICON_SIZE)); this._indicator = indicator; this._lastClickTime = -1; this._lastClickX = -1; this._lastClickY = -1; this._box.add_style_class_name('appindicator-box'); Util.connectSmart(this._indicator, 'ready', this, this._showIfReady); Util.connectSmart(this._indicator, 'menu', this, this._updateMenu); Util.connectSmart(this._indicator, 'label', this, this._updateLabel); Util.connectSmart(this._indicator, 'status', this, this._updateStatus); Util.connectSmart(this._indicator, 'reset', this, () => { this._updateStatus(); this._updateLabel(); }); Util.connectSmart(this._indicator, 'accessible-name', this, () => this.set_accessible_name(this._indicator.accessibleName)); Util.connectSmart(this._indicator, 'destroy', this, () => this.destroy()); this.connect('notify::visible', () => this._updateMenu()); this._showIfReady(); } _onDestroy() { if (this._menuClient) { this._menuClient.disconnect(this._menuReadyId); this._menuClient.destroy(); this._menuClient = null; } super._onDestroy(); } get uniqueId() { return this._indicator.uniqueId; } isReady() { return this._indicator && this._indicator.isReady; } _updateLabel() { const {label} = this._indicator; if (label) { if (!this._label || !this._labelBin) { this._labelBin = new St.Bin({ yAlign: Clutter.ActorAlign.CENTER, }); this._label = new St.Label(); this._labelBin.add_actor(this._label); this._box.add_actor(this._labelBin); } this._label.set_text(label); if (!this._box.contains(this._labelBin)) this._box.add_actor(this._labelBin); // FIXME: why is it suddenly necessary? } else if (this._label) { this._labelBin.destroy_all_children(); this._box.remove_actor(this._labelBin); this._labelBin.destroy(); delete this._labelBin; delete this._label; } } _updateStatus() { const wasVisible = this.visible; this.visible = this._indicator.status !== AppIndicator.SNIStatus.PASSIVE; if (this.visible !== wasVisible) this._indicator.checkAlive().catch(logError); } _updateMenu() { if (this._menuClient) { this._menuClient.disconnect(this._menuReadyId); this._menuClient.destroy(); this._menuClient = null; this.menu.removeAll(); } if (this.visible && this._indicator.menuPath) { this._menuClient = new DBusMenu.Client(this._indicator.busName, this._indicator.menuPath, this._indicator); if (this._menuClient.isReady) this._menuClient.attachToMenu(this.menu); this._menuReadyId = this._menuClient.connect('ready-changed', () => { if (this._menuClient.isReady) this._menuClient.attachToMenu(this.menu); else this._updateMenu(); }); } } _showIfReady() { if (!this.isReady()) return; this._updateLabel(); this._updateStatus(); this._updateMenu(); } _updateClickCount(event) { const [x, y] = event.get_coords(); const time = event.get_time(); const {doubleClickDistance, doubleClickTime} = Clutter.Settings.get_default(); if (time > (this._lastClickTime + doubleClickTime) || (Math.abs(x - this._lastClickX) > doubleClickDistance) || (Math.abs(y - this._lastClickY) > doubleClickDistance)) this._clickCount = 0; this._lastClickTime = time; this._lastClickX = x; this._lastClickY = y; this._clickCount = (this._clickCount % 2) + 1; return this._clickCount; } _maybeHandleDoubleClick(event) { if (this._indicator.supportsActivation === false) return Clutter.EVENT_PROPAGATE; if (event.get_button() !== Clutter.BUTTON_PRIMARY) return Clutter.EVENT_PROPAGATE; if (this._updateClickCount(event) === 2) { this._indicator.open(...event.get_coords(), event.get_time()); return Clutter.EVENT_STOP; } return Clutter.EVENT_PROPAGATE; } async _waitForDoubleClick() { const {doubleClickTime} = Clutter.Settings.get_default(); this._waitDoubleClickPromise = new PromiseUtils.TimeoutPromise( doubleClickTime); try { await this._waitDoubleClickPromise; this.menu.toggle(); } catch (e) { if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) throw e; } finally { delete this._waitDoubleClickPromise; } } vfunc_event(event) { if (this.menu.numMenuItems && event.type() === Clutter.EventType.TOUCH_BEGIN) this.menu.toggle(); return Clutter.EVENT_PROPAGATE; } vfunc_button_press_event(event) { if (this._waitDoubleClickPromise) this._waitDoubleClickPromise.cancel(); // if middle mouse button clicked send SecondaryActivate dbus event and do not show appindicator menu if (event.get_button() === Clutter.BUTTON_MIDDLE) { if (Main.panel.menuManager.activeMenu) Main.panel.menuManager._closeMenu(true, Main.panel.menuManager.activeMenu); this._indicator.secondaryActivate(event.get_time(), ...event.get_coords()); return Clutter.EVENT_STOP; } if (event.get_button() === Clutter.BUTTON_SECONDARY) { this.menu.toggle(); return Clutter.EVENT_PROPAGATE; } const doubleClickHandled = this._maybeHandleDoubleClick(event); if (doubleClickHandled === Clutter.EVENT_PROPAGATE && event.get_button() === Clutter.BUTTON_PRIMARY && this.menu.numMenuItems) { if (this._indicator.supportsActivation !== false) this._waitForDoubleClick().catch(logError); else this.menu.toggle(); } return Clutter.EVENT_PROPAGATE; } vfunc_button_release_event(event) { if (!this._indicator.supportsActivation) return this._maybeHandleDoubleClick(event); return Clutter.EVENT_PROPAGATE; } vfunc_scroll_event(event) { // Since Clutter 1.10, clutter will always send a smooth scrolling event // with explicit deltas, no matter what input device is used // In fact, for every scroll there will be a smooth and non-smooth scroll // event, and we can choose which one we interpret. if (event.get_scroll_direction() === Clutter.ScrollDirection.SMOOTH) { const [dx, dy] = event.get_scroll_delta(); this._indicator.scroll(dx, dy); return Clutter.EVENT_STOP; } return Clutter.EVENT_PROPAGATE; } }); export const IndicatorStatusTrayIcon = GObject.registerClass( class IndicatorTrayIcon extends BaseStatusIcon { _init(icon) { super._init(0.5, icon.wm_class, icon, {dontCreateMenu: true}); Util.Logger.debug(`Adding legacy tray icon ${this.uniqueId}`); this._box.add_style_class_name('appindicator-trayicons-box'); this.add_style_class_name('appindicator-icon'); this.add_style_class_name('tray-icon'); this.connect('button-press-event', (_actor, _event) => { this.add_style_pseudo_class('active'); return Clutter.EVENT_PROPAGATE; }); this.connect('button-release-event', (_actor, event) => { this._icon.click(event); this.remove_style_pseudo_class('active'); return Clutter.EVENT_PROPAGATE; }); this.connect('key-press-event', (_actor, event) => { this.add_style_pseudo_class('active'); this._icon.click(event); return Clutter.EVENT_PROPAGATE; }); this.connect('key-release-event', (_actor, event) => { this._icon.click(event); this.remove_style_pseudo_class('active'); return Clutter.EVENT_PROPAGATE; }); Util.connectSmart(this._icon, 'destroy', this, () => { icon.clear_effects(); this.destroy(); }); const settings = SettingsManager.getDefaultGSettings(); Util.connectSmart(settings, 'changed::icon-size', this, this._updateIconSize); const themeContext = St.ThemeContext.get_for_stage(global.stage); Util.connectSmart(themeContext, 'notify::scale-factor', this, () => this._updateIconSize()); this._updateIconSize(); } _onDestroy() { Util.Logger.debug(`Destroying legacy tray icon ${this.uniqueId}`); if (this._waitDoubleClickPromise) this._waitDoubleClickPromise.cancel(); super._onDestroy(); } isReady() { return !!this._icon; } get uniqueId() { return `legacy:${this._icon.wm_class}:${this._icon.pid}`; } vfunc_navigate_focus(from, direction) { this.grab_key_focus(); return super.vfunc_navigate_focus(from, direction); } _getSimulatedButtonEvent(touchEvent) { const event = Clutter.Event.new(Clutter.EventType.BUTTON_RELEASE); event.set_button(1); event.set_time(touchEvent.get_time()); event.set_flags(touchEvent.get_flags()); event.set_stage(global.stage); event.set_source(touchEvent.get_source()); event.set_coords(...touchEvent.get_coords()); event.set_state(touchEvent.get_state()); return event; } vfunc_touch_event(event) { // Under X11 we rely on emulated pointer events if (!imports.gi.Meta.is_wayland_compositor()) return Clutter.EVENT_PROPAGATE; const slot = event.get_event_sequence().get_slot(); if (!this._touchPressSlot && event.get_type() === Clutter.EventType.TOUCH_BEGIN) { this.add_style_pseudo_class('active'); this._touchButtonEvent = this._getSimulatedButtonEvent(event); this._touchPressSlot = slot; this._touchDelayPromise = new PromiseUtils.TimeoutPromise( AppDisplay.MENU_POPUP_TIMEOUT); this._touchDelayPromise.then(() => { delete this._touchDelayPromise; delete this._touchPressSlot; this._touchButtonEvent.set_button(3); this._icon.click(this._touchButtonEvent); this.remove_style_pseudo_class('active'); }); } else if (event.get_type() === Clutter.EventType.TOUCH_END && this._touchPressSlot === slot) { delete this._touchPressSlot; delete this._touchButtonEvent; if (this._touchDelayPromise) { this._touchDelayPromise.cancel(); delete this._touchDelayPromise; } this._icon.click(this._getSimulatedButtonEvent(event)); this.remove_style_pseudo_class('active'); } else if (event.get_type() === Clutter.EventType.TOUCH_UPDATE && this._touchPressSlot === slot) { this.add_style_pseudo_class('active'); this._touchButtonEvent = this._getSimulatedButtonEvent(event); } return Clutter.EVENT_PROPAGATE; } vfunc_leave_event(event) { this.remove_style_pseudo_class('active'); if (this._touchDelayPromise) { this._touchDelayPromise.cancel(); delete this._touchDelayPromise; } return super.vfunc_leave_event(event); } _updateIconSize() { const settings = SettingsManager.getDefaultGSettings(); const {scaleFactor} = St.ThemeContext.get_for_stage(global.stage); let iconSize = settings.get_int('icon-size'); if (iconSize <= 0) iconSize = DEFAULT_ICON_SIZE; this.height = -1; this._icon.set({ width: iconSize * scaleFactor, height: iconSize * scaleFactor, xAlign: Clutter.ActorAlign.CENTER, yAlign: Clutter.ActorAlign.CENTER, }); } });