mirror of
https://gitlab.com/thebiblelover7/dotfiles.git
synced 2025-09-18 09:33:49 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
'use strict';
|
||||
|
||||
const Gettext = imports.gettext;
|
||||
|
||||
const GLib = imports.gi.GLib;
|
||||
const Gio = imports.gi.Gio;
|
||||
const Gtk = imports.gi.Gtk;
|
||||
|
||||
const Extension = imports.misc.extensionUtils.getCurrentExtension();
|
||||
const Config = Extension.imports.config;
|
||||
Config.PACKAGE_DATADIR = Extension.path;
|
||||
|
||||
|
||||
// Ensure config.js is setup properly
|
||||
const userDir = GLib.build_filenamev([GLib.get_user_data_dir(), 'gnome-shell']);
|
||||
|
||||
if (Config.PACKAGE_DATADIR.startsWith(userDir)) {
|
||||
Config.IS_USER = true;
|
||||
|
||||
Config.GSETTINGS_SCHEMA_DIR = `${Extension.path}/schemas`;
|
||||
Config.PACKAGE_LOCALEDIR = `${Extension.path}/locale`;
|
||||
}
|
||||
|
||||
|
||||
// Init Gettext
|
||||
Gettext.bindtextdomain(Config.APP_ID, Config.PACKAGE_LOCALEDIR);
|
||||
Extension._ = GLib.dgettext.bind(null, Config.APP_ID);
|
||||
Extension.ngettext = GLib.dngettext.bind(null, Config.APP_ID);
|
||||
|
||||
|
||||
// Init GResources
|
||||
Gio.Resource.load(
|
||||
GLib.build_filenamev([Config.PACKAGE_DATADIR, `${Config.APP_ID}.gresource`])
|
||||
)._register();
|
||||
|
||||
|
||||
// Init GSchema
|
||||
Config.GSCHEMA = Gio.SettingsSchemaSource.new_from_directory(
|
||||
Config.GSETTINGS_SCHEMA_DIR,
|
||||
Gio.SettingsSchemaSource.get_default(),
|
||||
false
|
||||
);
|
||||
|
@@ -0,0 +1,380 @@
|
||||
'use strict';
|
||||
|
||||
const ByteArray = imports.byteArray;
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GjsPrivate = imports.gi.GjsPrivate;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
|
||||
const Meta = imports.gi.Meta;
|
||||
|
||||
|
||||
/*
|
||||
* DBus Interface Info
|
||||
*/
|
||||
const DBUS_NAME = 'org.gnome.Shell.Extensions.GSConnect.Clipboard';
|
||||
const DBUS_PATH = '/org/gnome/Shell/Extensions/GSConnect/Clipboard';
|
||||
const DBUS_NODE = Gio.DBusNodeInfo.new_for_xml(`
|
||||
<node>
|
||||
<interface name="org.gnome.Shell.Extensions.GSConnect.Clipboard">
|
||||
<!-- Methods -->
|
||||
<method name="GetMimetypes">
|
||||
<arg direction="out" type="as" name="mimetypes"/>
|
||||
</method>
|
||||
<method name="GetText">
|
||||
<arg direction="out" type="s" name="text"/>
|
||||
</method>
|
||||
<method name="SetText">
|
||||
<arg direction="in" type="s" name="text"/>
|
||||
</method>
|
||||
<method name="GetValue">
|
||||
<arg direction="in" type="s" name="mimetype"/>
|
||||
<arg direction="out" type="ay" name="value"/>
|
||||
</method>
|
||||
<method name="SetValue">
|
||||
<arg direction="in" type="ay" name="value"/>
|
||||
<arg direction="in" type="s" name="mimetype"/>
|
||||
</method>
|
||||
|
||||
<!-- Signals -->
|
||||
<signal name="OwnerChange"/>
|
||||
</interface>
|
||||
</node>
|
||||
`);
|
||||
const DBUS_INFO = DBUS_NODE.lookup_interface(DBUS_NAME);
|
||||
|
||||
|
||||
/*
|
||||
* Text Mimetypes
|
||||
*/
|
||||
const TEXT_MIMETYPES = [
|
||||
'text/plain;charset=utf-8',
|
||||
'UTF8_STRING',
|
||||
'text/plain',
|
||||
'STRING',
|
||||
];
|
||||
|
||||
|
||||
/* GSConnectClipboardPortal:
|
||||
*
|
||||
* A simple clipboard portal, especially useful on Wayland where GtkClipboard
|
||||
* doesn't work in the background.
|
||||
*/
|
||||
var Clipboard = GObject.registerClass({
|
||||
GTypeName: 'GSConnectShellClipboard',
|
||||
}, class GSConnectShellClipboard extends GjsPrivate.DBusImplementation {
|
||||
|
||||
_init(params = {}) {
|
||||
super._init({
|
||||
g_interface_info: DBUS_INFO,
|
||||
});
|
||||
|
||||
this._transferring = false;
|
||||
|
||||
// Watch global selection
|
||||
this._selection = global.display.get_selection();
|
||||
this._ownerChangedId = this._selection.connect(
|
||||
'owner-changed',
|
||||
this._onOwnerChanged.bind(this)
|
||||
);
|
||||
|
||||
// Prepare DBus interface
|
||||
this._handleMethodCallId = this.connect(
|
||||
'handle-method-call',
|
||||
this._onHandleMethodCall.bind(this)
|
||||
);
|
||||
|
||||
this._nameId = Gio.DBus.own_name(
|
||||
Gio.BusType.SESSION,
|
||||
DBUS_NAME,
|
||||
Gio.BusNameOwnerFlags.NONE,
|
||||
this._onBusAcquired.bind(this),
|
||||
null,
|
||||
this._onNameLost.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
_onOwnerChanged(selection, type, source) {
|
||||
/* We're only interested in the standard clipboard */
|
||||
if (type !== Meta.SelectionType.SELECTION_CLIPBOARD)
|
||||
return;
|
||||
|
||||
/* In Wayland an intermediate GMemoryOutputStream is used which triggers
|
||||
* a second ::owner-changed emission, so we need to ensure we ignore
|
||||
* that while the transfer is resolving.
|
||||
*/
|
||||
if (this._transferring)
|
||||
return;
|
||||
|
||||
this._transferring = true;
|
||||
|
||||
/* We need to put our signal emission in an idle callback to ensure that
|
||||
* Mutter's internal calls have finished resolving in the loop, or else
|
||||
* we'll end up with the previous selection's content.
|
||||
*/
|
||||
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
|
||||
this.emit_signal('OwnerChange', null);
|
||||
this._transferring = false;
|
||||
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
}
|
||||
|
||||
_onBusAcquired(connection, name) {
|
||||
try {
|
||||
this.export(connection, DBUS_PATH);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
_onNameLost(connection, name) {
|
||||
try {
|
||||
this.unexport();
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async _onHandleMethodCall(iface, name, parameters, invocation) {
|
||||
let retval;
|
||||
|
||||
try {
|
||||
const args = parameters.recursiveUnpack();
|
||||
|
||||
retval = await this[name](...args);
|
||||
} catch (e) {
|
||||
if (e instanceof GLib.Error) {
|
||||
invocation.return_gerror(e);
|
||||
} else {
|
||||
if (!e.name.includes('.'))
|
||||
e.name = `org.gnome.gjs.JSError.${e.name}`;
|
||||
|
||||
invocation.return_dbus_error(e.name, e.message);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (retval === undefined)
|
||||
retval = new GLib.Variant('()', []);
|
||||
|
||||
try {
|
||||
if (!(retval instanceof GLib.Variant)) {
|
||||
const args = DBUS_INFO.lookup_method(name).out_args;
|
||||
retval = new GLib.Variant(
|
||||
`(${args.map(arg => arg.signature).join('')})`,
|
||||
(args.length === 1) ? [retval] : retval
|
||||
);
|
||||
}
|
||||
|
||||
invocation.return_value(retval);
|
||||
|
||||
// Without a response, the client will wait for timeout
|
||||
} catch (e) {
|
||||
invocation.return_dbus_error(
|
||||
'org.gnome.gjs.JSError.ValueError',
|
||||
'Service implementation returned an incorrect value type'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available mimetypes of the current clipboard content
|
||||
*
|
||||
* @return {Promise<string[]>} A list of mime-types
|
||||
*/
|
||||
GetMimetypes() {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const mimetypes = this._selection.get_mimetypes(
|
||||
Meta.SelectionType.SELECTION_CLIPBOARD
|
||||
);
|
||||
|
||||
resolve(mimetypes);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the text content of the clipboard
|
||||
*
|
||||
* @return {Promise<string>} Text content of the clipboard
|
||||
*/
|
||||
GetText() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const mimetypes = this._selection.get_mimetypes(
|
||||
Meta.SelectionType.SELECTION_CLIPBOARD);
|
||||
|
||||
const mimetype = mimetypes.find(type => TEXT_MIMETYPES.includes(type));
|
||||
|
||||
if (mimetype !== undefined) {
|
||||
const stream = Gio.MemoryOutputStream.new_resizable();
|
||||
|
||||
this._selection.transfer_async(
|
||||
Meta.SelectionType.SELECTION_CLIPBOARD,
|
||||
mimetype, -1,
|
||||
stream, null,
|
||||
(selection, res) => {
|
||||
try {
|
||||
selection.transfer_finish(res);
|
||||
|
||||
const bytes = stream.steal_as_bytes();
|
||||
const bytearray = bytes.get_data();
|
||||
|
||||
resolve(ByteArray.toString(bytearray));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
reject(new Error('text not available'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the text content of the clipboard
|
||||
*
|
||||
* @param {string} text - text content to set
|
||||
* @return {Promise} A promise for the operation
|
||||
*/
|
||||
SetText(text) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
if (typeof text !== 'string') {
|
||||
throw new Gio.DBusError({
|
||||
code: Gio.DBusError.INVALID_ARGS,
|
||||
message: 'expected string',
|
||||
});
|
||||
}
|
||||
|
||||
const source = Meta.SelectionSourceMemory.new(
|
||||
'text/plain;charset=utf-8', GLib.Bytes.new(text));
|
||||
|
||||
this._selection.set_owner(
|
||||
Meta.SelectionType.SELECTION_CLIPBOARD, source);
|
||||
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content of the clipboard with the type @mimetype.
|
||||
*
|
||||
* @param {string} mimetype - the mimetype to request
|
||||
* @return {Promise<Uint8Array>} The content of the clipboard
|
||||
*/
|
||||
GetValue(mimetype) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const stream = Gio.MemoryOutputStream.new_resizable();
|
||||
|
||||
this._selection.transfer_async(
|
||||
Meta.SelectionType.SELECTION_CLIPBOARD,
|
||||
mimetype, -1,
|
||||
stream, null,
|
||||
(selection, res) => {
|
||||
try {
|
||||
selection.transfer_finish(res);
|
||||
|
||||
const bytes = stream.steal_as_bytes();
|
||||
|
||||
resolve(bytes.get_data());
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the content of the clipboard to @value with the type @mimetype.
|
||||
*
|
||||
* @param {Uint8Array} value - the value to set
|
||||
* @param {string} mimetype - the mimetype of the value
|
||||
* @return {Promise} - A promise for the operation
|
||||
*/
|
||||
SetValue(value, mimetype) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const source = Meta.SelectionSourceMemory.new(mimetype,
|
||||
GLib.Bytes.new(value));
|
||||
|
||||
this._selection.set_owner(
|
||||
Meta.SelectionType.SELECTION_CLIPBOARD, source);
|
||||
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._selection && this._ownerChangedId > 0) {
|
||||
this._selection.disconnect(this._ownerChangedId);
|
||||
this._ownerChangedId = 0;
|
||||
}
|
||||
|
||||
if (this._nameId > 0) {
|
||||
Gio.bus_unown_name(this._nameId);
|
||||
this._nameId = 0;
|
||||
}
|
||||
|
||||
if (this._handleMethodCallId > 0) {
|
||||
this.disconnect(this._handleMethodCallId);
|
||||
this._handleMethodCallId = 0;
|
||||
this.unexport();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
var _portal = null;
|
||||
var _portalId = 0;
|
||||
|
||||
/**
|
||||
* Watch for the service to start and export the clipboard portal when it does.
|
||||
*/
|
||||
function watchService() {
|
||||
if (GLib.getenv('XDG_SESSION_TYPE') !== 'wayland')
|
||||
return;
|
||||
|
||||
if (_portalId > 0)
|
||||
return;
|
||||
|
||||
_portalId = Gio.bus_watch_name(
|
||||
Gio.BusType.SESSION,
|
||||
'org.gnome.Shell.Extensions.GSConnect',
|
||||
Gio.BusNameWatcherFlags.NONE,
|
||||
() => {
|
||||
if (_portal === null)
|
||||
_portal = new Clipboard();
|
||||
},
|
||||
() => {
|
||||
if (_portal !== null) {
|
||||
_portal.destroy();
|
||||
_portal = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching the service and export the portal if currently running.
|
||||
*/
|
||||
function unwatchService() {
|
||||
if (_portalId > 0) {
|
||||
Gio.bus_unwatch_name(_portalId);
|
||||
_portalId = 0;
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,379 @@
|
||||
'use strict';
|
||||
|
||||
const Clutter = imports.gi.Clutter;
|
||||
const GObject = imports.gi.GObject;
|
||||
const St = imports.gi.St;
|
||||
|
||||
const PanelMenu = imports.ui.panelMenu;
|
||||
const PopupMenu = imports.ui.popupMenu;
|
||||
|
||||
const Extension = imports.misc.extensionUtils.getCurrentExtension();
|
||||
|
||||
// eslint-disable-next-line no-redeclare
|
||||
const _ = Extension._;
|
||||
const GMenu = Extension.imports.shell.gmenu;
|
||||
const Tooltip = Extension.imports.shell.tooltip;
|
||||
|
||||
|
||||
/**
|
||||
* A battery widget with an icon, text percentage and time estimate tooltip
|
||||
*/
|
||||
var Battery = GObject.registerClass({
|
||||
GTypeName: 'GSConnectShellDeviceBattery',
|
||||
}, class Battery extends St.BoxLayout {
|
||||
|
||||
_init(params) {
|
||||
super._init({
|
||||
reactive: true,
|
||||
style_class: 'gsconnect-device-battery',
|
||||
track_hover: true,
|
||||
});
|
||||
Object.assign(this, params);
|
||||
|
||||
// Percent Label
|
||||
this.label = new St.Label({
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
});
|
||||
this.label.clutter_text.ellipsize = 0;
|
||||
this.add_child(this.label);
|
||||
|
||||
// Battery Icon
|
||||
this.icon = new St.Icon({
|
||||
fallback_icon_name: 'battery-missing-symbolic',
|
||||
icon_size: 16,
|
||||
});
|
||||
this.add_child(this.icon);
|
||||
|
||||
// Battery Estimate
|
||||
this.tooltip = new Tooltip.Tooltip({
|
||||
parent: this,
|
||||
text: null,
|
||||
});
|
||||
|
||||
// Battery GAction
|
||||
this._actionAddedId = this.device.action_group.connect(
|
||||
'action-added',
|
||||
this._onActionChanged.bind(this)
|
||||
);
|
||||
this._actionRemovedId = this.device.action_group.connect(
|
||||
'action-removed',
|
||||
this._onActionChanged.bind(this)
|
||||
);
|
||||
this._actionStateChangedId = this.device.action_group.connect(
|
||||
'action-state-changed',
|
||||
this._onStateChanged.bind(this)
|
||||
);
|
||||
|
||||
this._onActionChanged(this.device.action_group, 'battery');
|
||||
|
||||
// Cleanup on destroy
|
||||
this.connect('destroy', this._onDestroy);
|
||||
}
|
||||
|
||||
_onActionChanged(action_group, action_name) {
|
||||
if (action_name !== 'battery')
|
||||
return;
|
||||
|
||||
if (action_group.has_action('battery')) {
|
||||
const value = action_group.get_action_state('battery');
|
||||
const [charging, icon_name, level, time] = value.deepUnpack();
|
||||
|
||||
this._state = {
|
||||
charging: charging,
|
||||
icon_name: icon_name,
|
||||
level: level,
|
||||
time: time,
|
||||
};
|
||||
} else {
|
||||
this._state = null;
|
||||
}
|
||||
|
||||
this._sync();
|
||||
}
|
||||
|
||||
_onStateChanged(action_group, action_name, value) {
|
||||
if (action_name !== 'battery')
|
||||
return;
|
||||
|
||||
const [charging, icon_name, level, time] = value.deepUnpack();
|
||||
|
||||
this._state = {
|
||||
charging: charging,
|
||||
icon_name: icon_name,
|
||||
level: level,
|
||||
time: time,
|
||||
};
|
||||
|
||||
this._sync();
|
||||
}
|
||||
|
||||
_getBatteryLabel() {
|
||||
if (!this._state)
|
||||
return null;
|
||||
|
||||
const {charging, level, time} = this._state;
|
||||
|
||||
if (level === 100)
|
||||
// TRANSLATORS: When the battery level is 100%
|
||||
return _('Fully Charged');
|
||||
|
||||
if (time === 0)
|
||||
// TRANSLATORS: When no time estimate for the battery is available
|
||||
// EXAMPLE: 42% (Estimating…)
|
||||
return _('%d%% (Estimating…)').format(level);
|
||||
|
||||
const total = time / 60;
|
||||
const minutes = Math.floor(total % 60);
|
||||
const hours = Math.floor(total / 60);
|
||||
|
||||
if (charging) {
|
||||
// TRANSLATORS: Estimated time until battery is charged
|
||||
// EXAMPLE: 42% (1:15 Until Full)
|
||||
return _('%d%% (%d\u2236%02d Until Full)').format(
|
||||
level,
|
||||
hours,
|
||||
minutes
|
||||
);
|
||||
} else {
|
||||
// TRANSLATORS: Estimated time until battery is empty
|
||||
// EXAMPLE: 42% (12:15 Remaining)
|
||||
return _('%d%% (%d\u2236%02d Remaining)').format(
|
||||
level,
|
||||
hours,
|
||||
minutes
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_onDestroy(actor) {
|
||||
actor.device.action_group.disconnect(actor._actionAddedId);
|
||||
actor.device.action_group.disconnect(actor._actionRemovedId);
|
||||
actor.device.action_group.disconnect(actor._actionStateChangedId);
|
||||
}
|
||||
|
||||
_sync() {
|
||||
this.visible = !!this._state;
|
||||
|
||||
if (!this.visible)
|
||||
return;
|
||||
|
||||
this.icon.icon_name = this._state.icon_name;
|
||||
this.label.text = (this._state.level > -1) ? `${this._state.level}%` : '';
|
||||
this.tooltip.text = this._getBatteryLabel();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* A cell signal strength widget with two icons
|
||||
*/
|
||||
var SignalStrength = GObject.registerClass({
|
||||
GTypeName: 'GSConnectShellDeviceSignalStrength',
|
||||
}, class SignalStrength extends St.BoxLayout {
|
||||
|
||||
_init(params) {
|
||||
super._init({
|
||||
reactive: true,
|
||||
style_class: 'gsconnect-device-signal-strength',
|
||||
track_hover: true,
|
||||
});
|
||||
Object.assign(this, params);
|
||||
|
||||
// Network Type Icon
|
||||
this.networkTypeIcon = new St.Icon({
|
||||
fallback_icon_name: 'network-cellular-symbolic',
|
||||
icon_size: 16,
|
||||
});
|
||||
this.add_child(this.networkTypeIcon);
|
||||
|
||||
// Signal Strength Icon
|
||||
this.signalStrengthIcon = new St.Icon({
|
||||
fallback_icon_name: 'network-cellular-offline-symbolic',
|
||||
icon_size: 16,
|
||||
});
|
||||
this.add_child(this.signalStrengthIcon);
|
||||
|
||||
// Network Type Text
|
||||
this.tooltip = new Tooltip.Tooltip({
|
||||
parent: this,
|
||||
text: null,
|
||||
});
|
||||
|
||||
// ConnectivityReport GAction
|
||||
this._actionAddedId = this.device.action_group.connect(
|
||||
'action-added',
|
||||
this._onActionChanged.bind(this)
|
||||
);
|
||||
this._actionRemovedId = this.device.action_group.connect(
|
||||
'action-removed',
|
||||
this._onActionChanged.bind(this)
|
||||
);
|
||||
this._actionStateChangedId = this.device.action_group.connect(
|
||||
'action-state-changed',
|
||||
this._onStateChanged.bind(this)
|
||||
);
|
||||
|
||||
this._onActionChanged(this.device.action_group, 'connectivityReport');
|
||||
|
||||
// Cleanup on destroy
|
||||
this.connect('destroy', this._onDestroy);
|
||||
}
|
||||
|
||||
_onActionChanged(action_group, action_name) {
|
||||
if (action_name !== 'connectivityReport')
|
||||
return;
|
||||
|
||||
if (action_group.has_action('connectivityReport')) {
|
||||
const value = action_group.get_action_state('connectivityReport');
|
||||
const [
|
||||
cellular_network_type,
|
||||
cellular_network_type_icon,
|
||||
cellular_network_strength,
|
||||
cellular_network_strength_icon,
|
||||
hotspot_name,
|
||||
hotspot_bssid,
|
||||
] = value.deepUnpack();
|
||||
|
||||
this._state = {
|
||||
cellular_network_type: cellular_network_type,
|
||||
cellular_network_type_icon: cellular_network_type_icon,
|
||||
cellular_network_strength: cellular_network_strength,
|
||||
cellular_network_strength_icon: cellular_network_strength_icon,
|
||||
hotspot_name: hotspot_name,
|
||||
hotspot_bssid: hotspot_bssid,
|
||||
};
|
||||
} else {
|
||||
this._state = null;
|
||||
}
|
||||
|
||||
this._sync();
|
||||
}
|
||||
|
||||
_onStateChanged(action_group, action_name, value) {
|
||||
if (action_name !== 'connectivityReport')
|
||||
return;
|
||||
|
||||
const [
|
||||
cellular_network_type,
|
||||
cellular_network_type_icon,
|
||||
cellular_network_strength,
|
||||
cellular_network_strength_icon,
|
||||
hotspot_name,
|
||||
hotspot_bssid,
|
||||
] = value.deepUnpack();
|
||||
|
||||
this._state = {
|
||||
cellular_network_type: cellular_network_type,
|
||||
cellular_network_type_icon: cellular_network_type_icon,
|
||||
cellular_network_strength: cellular_network_strength,
|
||||
cellular_network_strength_icon: cellular_network_strength_icon,
|
||||
hotspot_name: hotspot_name,
|
||||
hotspot_bssid: hotspot_bssid,
|
||||
};
|
||||
|
||||
this._sync();
|
||||
}
|
||||
|
||||
_onDestroy(actor) {
|
||||
actor.device.action_group.disconnect(actor._actionAddedId);
|
||||
actor.device.action_group.disconnect(actor._actionRemovedId);
|
||||
actor.device.action_group.disconnect(actor._actionStateChangedId);
|
||||
}
|
||||
|
||||
_sync() {
|
||||
this.visible = !!this._state;
|
||||
|
||||
if (!this.visible)
|
||||
return;
|
||||
|
||||
this.networkTypeIcon.icon_name = this._state.cellular_network_type_icon;
|
||||
this.signalStrengthIcon.icon_name = this._state.cellular_network_strength_icon;
|
||||
this.tooltip.text = this._state.cellular_network_type;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* A PopupMenu used as an information and control center for a device
|
||||
*/
|
||||
var Menu = class Menu extends PopupMenu.PopupMenuSection {
|
||||
|
||||
constructor(params) {
|
||||
super();
|
||||
Object.assign(this, params);
|
||||
|
||||
this.actor.add_style_class_name('gsconnect-device-menu');
|
||||
|
||||
// Title
|
||||
this._title = new PopupMenu.PopupSeparatorMenuItem(this.device.name);
|
||||
this.addMenuItem(this._title);
|
||||
|
||||
// Title -> Name
|
||||
this._title.label.style_class = 'gsconnect-device-name';
|
||||
this._title.label.clutter_text.ellipsize = 0;
|
||||
this.device.bind_property(
|
||||
'name',
|
||||
this._title.label,
|
||||
'text',
|
||||
GObject.BindingFlags.SYNC_CREATE
|
||||
);
|
||||
|
||||
// Title -> Cellular Signal Strength
|
||||
this._signalStrength = new SignalStrength({device: this.device});
|
||||
this._title.actor.add_child(this._signalStrength);
|
||||
|
||||
// Title -> Battery
|
||||
this._battery = new Battery({device: this.device});
|
||||
this._title.actor.add_child(this._battery);
|
||||
|
||||
// Actions
|
||||
let actions;
|
||||
|
||||
if (this.menu_type === 'icon') {
|
||||
actions = new GMenu.IconBox({
|
||||
action_group: this.device.action_group,
|
||||
model: this.device.menu,
|
||||
});
|
||||
} else if (this.menu_type === 'list') {
|
||||
actions = new GMenu.ListBox({
|
||||
action_group: this.device.action_group,
|
||||
model: this.device.menu,
|
||||
});
|
||||
}
|
||||
|
||||
this.addMenuItem(actions);
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* An indicator representing a Device in the Status Area
|
||||
*/
|
||||
var Indicator = GObject.registerClass({
|
||||
GTypeName: 'GSConnectDeviceIndicator',
|
||||
}, class Indicator extends PanelMenu.Button {
|
||||
|
||||
_init(params) {
|
||||
super._init(0.0, `${params.device.name} Indicator`, false);
|
||||
Object.assign(this, params);
|
||||
|
||||
// Device Icon
|
||||
this._icon = new St.Icon({
|
||||
gicon: Extension.getIcon(this.device.icon_name),
|
||||
style_class: 'system-status-icon gsconnect-device-indicator',
|
||||
});
|
||||
this.add_child(this._icon);
|
||||
|
||||
// Menu
|
||||
const menu = new Menu({
|
||||
device: this.device,
|
||||
menu_type: 'icon',
|
||||
});
|
||||
this.menu.addMenuItem(menu);
|
||||
}
|
||||
});
|
||||
|
@@ -0,0 +1,649 @@
|
||||
'use strict';
|
||||
|
||||
const Atk = imports.gi.Atk;
|
||||
const Clutter = imports.gi.Clutter;
|
||||
const Gio = imports.gi.Gio;
|
||||
const GObject = imports.gi.GObject;
|
||||
const St = imports.gi.St;
|
||||
|
||||
const PopupMenu = imports.ui.popupMenu;
|
||||
|
||||
const Extension = imports.misc.extensionUtils.getCurrentExtension();
|
||||
|
||||
const Tooltip = Extension.imports.shell.tooltip;
|
||||
|
||||
|
||||
/**
|
||||
* Get a dictionary of a GMenuItem's attributes
|
||||
*
|
||||
* @param {Gio.MenuModel} model - The menu model containing the item
|
||||
* @param {number} index - The index of the item in @model
|
||||
* @return {Object} A dictionary of the item's attributes
|
||||
*/
|
||||
function getItemInfo(model, index) {
|
||||
const info = {
|
||||
target: null,
|
||||
links: [],
|
||||
};
|
||||
|
||||
//
|
||||
let iter = model.iterate_item_attributes(index);
|
||||
|
||||
while (iter.next()) {
|
||||
const name = iter.get_name();
|
||||
let value = iter.get_value();
|
||||
|
||||
switch (name) {
|
||||
case 'icon':
|
||||
value = Gio.Icon.deserialize(value);
|
||||
|
||||
if (value instanceof Gio.ThemedIcon)
|
||||
value = Extension.getIcon(value.names[0]);
|
||||
|
||||
info[name] = value;
|
||||
break;
|
||||
|
||||
case 'target':
|
||||
info[name] = value;
|
||||
break;
|
||||
|
||||
default:
|
||||
info[name] = value.unpack();
|
||||
}
|
||||
}
|
||||
|
||||
// Submenus & Sections
|
||||
iter = model.iterate_item_links(index);
|
||||
|
||||
while (iter.next()) {
|
||||
info.links.push({
|
||||
name: iter.get_name(),
|
||||
value: iter.get_value(),
|
||||
});
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
var ListBox = class ListBox extends PopupMenu.PopupMenuSection {
|
||||
|
||||
constructor(params) {
|
||||
super();
|
||||
Object.assign(this, params);
|
||||
|
||||
// Main Actor
|
||||
this.actor = new St.BoxLayout({
|
||||
x_expand: true,
|
||||
clip_to_allocation: true,
|
||||
});
|
||||
this.actor._delegate = this;
|
||||
|
||||
// Item Box
|
||||
this.box.clip_to_allocation = true;
|
||||
this.box.x_expand = true;
|
||||
this.box.add_style_class_name('gsconnect-list-box');
|
||||
this.box.set_pivot_point(1, 1);
|
||||
this.actor.add_child(this.box);
|
||||
|
||||
// Submenu Container
|
||||
this.sub = new St.BoxLayout({
|
||||
clip_to_allocation: true,
|
||||
vertical: false,
|
||||
visible: false,
|
||||
x_expand: true,
|
||||
});
|
||||
this.sub.set_pivot_point(1, 1);
|
||||
this.sub._delegate = this;
|
||||
this.actor.add_child(this.sub);
|
||||
|
||||
// Handle transitions
|
||||
this._boxTransitionsCompletedId = this.box.connect(
|
||||
'transitions-completed',
|
||||
this._onTransitionsCompleted.bind(this)
|
||||
);
|
||||
|
||||
this._subTransitionsCompletedId = this.sub.connect(
|
||||
'transitions-completed',
|
||||
this._onTransitionsCompleted.bind(this)
|
||||
);
|
||||
|
||||
// Handle keyboard navigation
|
||||
this._submenuCloseKeyId = this.sub.connect(
|
||||
'key-press-event',
|
||||
this._onSubmenuCloseKey.bind(this)
|
||||
);
|
||||
|
||||
// Refresh the menu when mapped
|
||||
this._mappedId = this.actor.connect(
|
||||
'notify::mapped',
|
||||
this._onMapped.bind(this)
|
||||
);
|
||||
|
||||
// Watch the model for changes
|
||||
this._itemsChangedId = this.model.connect(
|
||||
'items-changed',
|
||||
this._onItemsChanged.bind(this)
|
||||
);
|
||||
this._onItemsChanged();
|
||||
}
|
||||
|
||||
_onMapped(actor) {
|
||||
if (actor.mapped) {
|
||||
this._onItemsChanged();
|
||||
|
||||
// We use this instead of close() to avoid touching finalized objects
|
||||
} else {
|
||||
this.box.set_opacity(255);
|
||||
this.box.set_width(-1);
|
||||
this.box.set_height(-1);
|
||||
this.box.visible = true;
|
||||
|
||||
this._submenu = null;
|
||||
this.sub.set_opacity(0);
|
||||
this.sub.set_width(0);
|
||||
this.sub.set_height(0);
|
||||
this.sub.visible = false;
|
||||
this.sub.get_children().map(menu => menu.hide());
|
||||
}
|
||||
}
|
||||
|
||||
_onSubmenuCloseKey(actor, event) {
|
||||
if (this.submenu && event.get_key_symbol() === Clutter.KEY_Left) {
|
||||
this.submenu.submenu_for.setActive(true);
|
||||
this.submenu = null;
|
||||
return Clutter.EVENT_STOP;
|
||||
}
|
||||
|
||||
return Clutter.EVENT_PROPAGATE;
|
||||
}
|
||||
|
||||
_onSubmenuOpenKey(actor, event) {
|
||||
const item = actor._delegate;
|
||||
|
||||
if (item.submenu && event.get_key_symbol() === Clutter.KEY_Right) {
|
||||
this.submenu = item.submenu;
|
||||
item.submenu.firstMenuItem.setActive(true);
|
||||
}
|
||||
|
||||
return Clutter.EVENT_PROPAGATE;
|
||||
}
|
||||
|
||||
_onGMenuItemActivate(item, event) {
|
||||
this.emit('activate', item);
|
||||
|
||||
if (item.submenu) {
|
||||
this.submenu = item.submenu;
|
||||
} else if (item.action_name) {
|
||||
this.action_group.activate_action(
|
||||
item.action_name,
|
||||
item.action_target
|
||||
);
|
||||
this.itemActivated();
|
||||
}
|
||||
}
|
||||
|
||||
_addGMenuItem(info) {
|
||||
const item = new PopupMenu.PopupMenuItem(info.label);
|
||||
this.addMenuItem(item);
|
||||
|
||||
if (info.action !== undefined) {
|
||||
item.action_name = info.action.split('.')[1];
|
||||
item.action_target = info.target;
|
||||
|
||||
item.actor.visible = this.action_group.get_action_enabled(
|
||||
item.action_name
|
||||
);
|
||||
}
|
||||
|
||||
// Modify the ::activate callback to invoke the GAction or submenu
|
||||
item.disconnect(item._activateId);
|
||||
item._activateId = item.connect(
|
||||
'activate',
|
||||
this._onGMenuItemActivate.bind(this)
|
||||
);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
_addGMenuSection(model) {
|
||||
const section = new ListBox({
|
||||
model: model,
|
||||
action_group: this.action_group,
|
||||
});
|
||||
this.addMenuItem(section);
|
||||
}
|
||||
|
||||
_addGMenuSubmenu(model, item) {
|
||||
// Add an expander arrow to the item
|
||||
const arrow = PopupMenu.arrowIcon(St.Side.RIGHT);
|
||||
arrow.x_align = Clutter.ActorAlign.END;
|
||||
arrow.x_expand = true;
|
||||
item.actor.add_child(arrow);
|
||||
|
||||
// Mark it as an expandable and open on right-arrow
|
||||
item.actor.add_accessible_state(Atk.StateType.EXPANDABLE);
|
||||
|
||||
item.actor.connect(
|
||||
'key-press-event',
|
||||
this._onSubmenuOpenKey.bind(this)
|
||||
);
|
||||
|
||||
// Create the submenu
|
||||
item.submenu = new ListBox({
|
||||
model: model,
|
||||
action_group: this.action_group,
|
||||
submenu_for: item,
|
||||
_parent: this,
|
||||
});
|
||||
item.submenu.actor.hide();
|
||||
|
||||
// Add to the submenu container
|
||||
this.sub.add_child(item.submenu.actor);
|
||||
}
|
||||
|
||||
_onItemsChanged(model, position, removed, added) {
|
||||
// Clear the menu
|
||||
this.removeAll();
|
||||
this.sub.get_children().map(child => child.destroy());
|
||||
|
||||
for (let i = 0, len = this.model.get_n_items(); i < len; i++) {
|
||||
const info = getItemInfo(this.model, i);
|
||||
let item;
|
||||
|
||||
// A regular item
|
||||
if (info.hasOwnProperty('label'))
|
||||
item = this._addGMenuItem(info);
|
||||
|
||||
for (const link of info.links) {
|
||||
// Submenu
|
||||
if (link.name === 'submenu') {
|
||||
this._addGMenuSubmenu(link.value, item);
|
||||
|
||||
// Section
|
||||
} else if (link.name === 'section') {
|
||||
this._addGMenuSection(link.value);
|
||||
|
||||
// len is length starting at 1
|
||||
if (i + 1 < len)
|
||||
this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a submenu of another item...
|
||||
if (this.submenu_for) {
|
||||
// Prepend an "<= Go Back" item, bold with a unicode arrow
|
||||
const prev = new PopupMenu.PopupMenuItem(this.submenu_for.label.text);
|
||||
prev.label.style = 'font-weight: bold;';
|
||||
const prevArrow = PopupMenu.arrowIcon(St.Side.LEFT);
|
||||
prev.replace_child(prev._ornamentLabel, prevArrow);
|
||||
this.addMenuItem(prev, 0);
|
||||
|
||||
// Modify the ::activate callback to close the submenu
|
||||
prev.disconnect(prev._activateId);
|
||||
|
||||
prev._activateId = prev.connect('activate', (item, event) => {
|
||||
this.emit('activate', item);
|
||||
this._parent.submenu = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_onTransitionsCompleted(actor) {
|
||||
if (this.submenu) {
|
||||
this.box.visible = false;
|
||||
} else {
|
||||
this.sub.visible = false;
|
||||
this.sub.get_children().map(menu => menu.hide());
|
||||
}
|
||||
}
|
||||
|
||||
get submenu() {
|
||||
return this._submenu || null;
|
||||
}
|
||||
|
||||
set submenu(submenu) {
|
||||
// Get the current allocation to hold the menu width
|
||||
const allocation = this.actor.allocation;
|
||||
const width = Math.max(0, allocation.x2 - allocation.x1);
|
||||
|
||||
// Prepare the appropriate child for tweening
|
||||
if (submenu) {
|
||||
this.sub.set_opacity(0);
|
||||
this.sub.set_width(0);
|
||||
this.sub.set_height(0);
|
||||
this.sub.visible = true;
|
||||
} else {
|
||||
this.box.set_opacity(0);
|
||||
this.box.set_width(0);
|
||||
this.sub.set_height(0);
|
||||
this.box.visible = true;
|
||||
}
|
||||
|
||||
// Setup the animation
|
||||
this.box.save_easing_state();
|
||||
this.box.set_easing_mode(Clutter.AnimationMode.EASE_IN_OUT_CUBIC);
|
||||
this.box.set_easing_duration(250);
|
||||
|
||||
this.sub.save_easing_state();
|
||||
this.sub.set_easing_mode(Clutter.AnimationMode.EASE_IN_OUT_CUBIC);
|
||||
this.sub.set_easing_duration(250);
|
||||
|
||||
if (submenu) {
|
||||
submenu.actor.show();
|
||||
|
||||
this.sub.set_opacity(255);
|
||||
this.sub.set_width(width);
|
||||
this.sub.set_height(-1);
|
||||
|
||||
this.box.set_opacity(0);
|
||||
this.box.set_width(0);
|
||||
this.box.set_height(0);
|
||||
} else {
|
||||
this.box.set_opacity(255);
|
||||
this.box.set_width(width);
|
||||
this.box.set_height(-1);
|
||||
|
||||
this.sub.set_opacity(0);
|
||||
this.sub.set_width(0);
|
||||
this.sub.set_height(0);
|
||||
}
|
||||
|
||||
// Reset the animation
|
||||
this.box.restore_easing_state();
|
||||
this.sub.restore_easing_state();
|
||||
|
||||
//
|
||||
this._submenu = submenu;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.actor.disconnect(this._mappedId);
|
||||
this.box.disconnect(this._boxTransitionsCompletedId);
|
||||
this.sub.disconnect(this._subTransitionsCompletedId);
|
||||
this.sub.disconnect(this._submenuCloseKeyId);
|
||||
this.model.disconnect(this._itemsChangedId);
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* A St.Button subclass for iconic GMenu items
|
||||
*/
|
||||
var IconButton = GObject.registerClass({
|
||||
GTypeName: 'GSConnectShellIconButton',
|
||||
}, class Button extends St.Button {
|
||||
|
||||
_init(params) {
|
||||
super._init({
|
||||
style_class: 'gsconnect-icon-button',
|
||||
can_focus: true,
|
||||
});
|
||||
Object.assign(this, params);
|
||||
|
||||
// Item attributes
|
||||
if (params.info.hasOwnProperty('action'))
|
||||
this.action_name = params.info.action.split('.')[1];
|
||||
|
||||
if (params.info.hasOwnProperty('target'))
|
||||
this.action_target = params.info.target;
|
||||
|
||||
if (params.info.hasOwnProperty('label')) {
|
||||
this.tooltip = new Tooltip.Tooltip({
|
||||
parent: this,
|
||||
markup: params.info.label,
|
||||
});
|
||||
|
||||
this.accessible_name = params.info.label;
|
||||
}
|
||||
|
||||
if (params.info.hasOwnProperty('icon'))
|
||||
this.child = new St.Icon({gicon: params.info.icon});
|
||||
|
||||
// Submenu
|
||||
for (const link of params.info.links) {
|
||||
if (link.name === 'submenu') {
|
||||
this.add_accessible_state(Atk.StateType.EXPANDABLE);
|
||||
this.toggle_mode = true;
|
||||
this.connect('notify::checked', this._onChecked);
|
||||
|
||||
this.submenu = new ListBox({
|
||||
model: link.value,
|
||||
action_group: this.action_group,
|
||||
_parent: this._parent,
|
||||
});
|
||||
|
||||
this.submenu.actor.style_class = 'popup-sub-menu';
|
||||
this.submenu.actor.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This is (reliably?) emitted before ::clicked
|
||||
_onChecked(button) {
|
||||
if (button.checked) {
|
||||
button.add_accessible_state(Atk.StateType.EXPANDED);
|
||||
button.add_style_pseudo_class('active');
|
||||
} else {
|
||||
button.remove_accessible_state(Atk.StateType.EXPANDED);
|
||||
button.remove_style_pseudo_class('active');
|
||||
}
|
||||
}
|
||||
|
||||
// This is (reliably?) emitted after notify::checked
|
||||
vfunc_clicked(clicked_button) {
|
||||
// Unless this has a submenu, activate the action and close the menu
|
||||
if (!this.toggle_mode) {
|
||||
this._parent._getTopMenu().close();
|
||||
|
||||
this.action_group.activate_action(
|
||||
this.action_name,
|
||||
this.action_target
|
||||
);
|
||||
|
||||
// StButton.checked has already been toggled so we're opening
|
||||
} else if (this.checked) {
|
||||
this._parent.submenu = this.submenu;
|
||||
|
||||
// If this is the active submenu being closed, animate-close it
|
||||
} else if (this._parent.submenu === this.submenu) {
|
||||
this._parent.submenu = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
var IconBox = class IconBox extends PopupMenu.PopupMenuSection {
|
||||
|
||||
constructor(params) {
|
||||
super();
|
||||
Object.assign(this, params);
|
||||
|
||||
// Main Actor
|
||||
this.actor = new St.BoxLayout({
|
||||
vertical: true,
|
||||
x_expand: true,
|
||||
});
|
||||
this.actor._delegate = this;
|
||||
|
||||
// Button Box
|
||||
this.box._delegate = this;
|
||||
this.box.style_class = 'gsconnect-icon-box';
|
||||
this.box.vertical = false;
|
||||
this.actor.add_child(this.box);
|
||||
|
||||
// Submenu Container
|
||||
this.sub = new St.BoxLayout({
|
||||
clip_to_allocation: true,
|
||||
vertical: true,
|
||||
x_expand: true,
|
||||
});
|
||||
this.sub.connect('transitions-completed', this._onTransitionsCompleted);
|
||||
this.sub._delegate = this;
|
||||
this.actor.add_child(this.sub);
|
||||
|
||||
// Track menu items so we can use ::items-changed
|
||||
this._menu_items = new Map();
|
||||
|
||||
// PopupMenu
|
||||
this._mappedId = this.actor.connect(
|
||||
'notify::mapped',
|
||||
this._onMapped.bind(this)
|
||||
);
|
||||
|
||||
// GMenu
|
||||
this._itemsChangedId = this.model.connect(
|
||||
'items-changed',
|
||||
this._onItemsChanged.bind(this)
|
||||
);
|
||||
|
||||
// GActions
|
||||
this._actionAddedId = this.action_group.connect(
|
||||
'action-added',
|
||||
this._onActionChanged.bind(this)
|
||||
);
|
||||
this._actionEnabledChangedId = this.action_group.connect(
|
||||
'action-enabled-changed',
|
||||
this._onActionChanged.bind(this)
|
||||
);
|
||||
this._actionRemovedId = this.action_group.connect(
|
||||
'action-removed',
|
||||
this._onActionChanged.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.actor.disconnect(this._mappedId);
|
||||
this.model.disconnect(this._itemsChangedId);
|
||||
this.action_group.disconnect(this._actionAddedId);
|
||||
this.action_group.disconnect(this._actionEnabledChangedId);
|
||||
this.action_group.disconnect(this._actionRemovedId);
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
|
||||
get submenu() {
|
||||
return this._submenu || null;
|
||||
}
|
||||
|
||||
set submenu(submenu) {
|
||||
if (submenu) {
|
||||
for (const button of this.box.get_children()) {
|
||||
if (button.submenu && this._submenu && button.submenu !== submenu) {
|
||||
button.checked = false;
|
||||
button.submenu.actor.hide();
|
||||
}
|
||||
}
|
||||
|
||||
this.sub.set_height(0);
|
||||
submenu.actor.show();
|
||||
}
|
||||
|
||||
this.sub.save_easing_state();
|
||||
this.sub.set_easing_duration(250);
|
||||
this.sub.set_easing_mode(Clutter.AnimationMode.EASE_IN_OUT_CUBIC);
|
||||
|
||||
this.sub.set_height(submenu ? submenu.actor.get_preferred_size()[1] : 0);
|
||||
this.sub.restore_easing_state();
|
||||
|
||||
this._submenu = submenu;
|
||||
}
|
||||
|
||||
_onMapped(actor) {
|
||||
if (!actor.mapped) {
|
||||
this._submenu = null;
|
||||
|
||||
for (const button of this.box.get_children())
|
||||
button.checked = false;
|
||||
|
||||
for (const submenu of this.sub.get_children())
|
||||
submenu.hide();
|
||||
}
|
||||
}
|
||||
|
||||
_onActionChanged(group, name, enabled) {
|
||||
const menuItem = this._menu_items.get(name);
|
||||
|
||||
if (menuItem !== undefined)
|
||||
menuItem.visible = group.get_action_enabled(name);
|
||||
}
|
||||
|
||||
_onItemsChanged(model, position, removed, added) {
|
||||
// Remove items
|
||||
while (removed > 0) {
|
||||
const button = this.box.get_child_at_index(position);
|
||||
const action_name = button.action_name;
|
||||
|
||||
if (button.submenu)
|
||||
button.submenu.destroy();
|
||||
|
||||
button.destroy();
|
||||
|
||||
this._menu_items.delete(action_name);
|
||||
removed--;
|
||||
}
|
||||
|
||||
// Add items
|
||||
for (let i = 0; i < added; i++) {
|
||||
const index = position + i;
|
||||
|
||||
// Create an iconic button
|
||||
const button = new IconButton({
|
||||
action_group: this.action_group,
|
||||
info: getItemInfo(model, index),
|
||||
// NOTE: Because this doesn't derive from a PopupMenu class
|
||||
// it lacks some things its parent will expect from it
|
||||
_parent: this,
|
||||
_delegate: null,
|
||||
});
|
||||
|
||||
// Set the visibility based on the enabled state
|
||||
if (button.action_name !== undefined) {
|
||||
button.visible = this.action_group.get_action_enabled(
|
||||
button.action_name
|
||||
);
|
||||
}
|
||||
|
||||
// If it has a submenu, add it as a sibling
|
||||
if (button.submenu)
|
||||
this.sub.add_child(button.submenu.actor);
|
||||
|
||||
// Track the item if it has an action
|
||||
if (button.action_name !== undefined)
|
||||
this._menu_items.set(button.action_name, button);
|
||||
|
||||
// Insert it in the box at the defined position
|
||||
this.box.insert_child_at_index(button, index);
|
||||
}
|
||||
}
|
||||
|
||||
_onTransitionsCompleted(actor) {
|
||||
const menu = actor._delegate;
|
||||
|
||||
for (const button of menu.box.get_children()) {
|
||||
if (button.submenu && button.submenu !== menu.submenu) {
|
||||
button.checked = false;
|
||||
button.submenu.actor.hide();
|
||||
}
|
||||
}
|
||||
|
||||
menu.sub.set_height(-1);
|
||||
}
|
||||
|
||||
// PopupMenu.PopupMenuBase overrides
|
||||
isEmpty() {
|
||||
return (this.box.get_children().length === 0);
|
||||
}
|
||||
|
||||
_setParent(parent) {
|
||||
super._setParent(parent);
|
||||
this._onItemsChanged(this.model, 0, 0, this.model.get_n_items());
|
||||
}
|
||||
};
|
||||
|
@@ -0,0 +1,102 @@
|
||||
'use strict';
|
||||
|
||||
const Config = imports.misc.config;
|
||||
const Main = imports.ui.main;
|
||||
const Meta = imports.gi.Meta;
|
||||
const Shell = imports.gi.Shell;
|
||||
|
||||
|
||||
/**
|
||||
* Keybindings.Manager is a simple convenience class for managing keyboard
|
||||
* shortcuts in GNOME Shell. You bind a shortcut using add(), which on success
|
||||
* will return a non-zero action id that can later be used with remove() to
|
||||
* unbind the shortcut.
|
||||
*
|
||||
* Accelerators are accepted in the form returned by Gtk.accelerator_name() and
|
||||
* callbacks are invoked directly, so should be complete closures.
|
||||
*
|
||||
* References:
|
||||
* https://developer.gnome.org/gtk3/stable/gtk3-Keyboard-Accelerators.html
|
||||
* https://developer.gnome.org/meta/stable/MetaDisplay.html
|
||||
* https://developer.gnome.org/meta/stable/meta-MetaKeybinding.html
|
||||
* https://gitlab.gnome.org/GNOME/gnome-shell/blob/master/js/ui/windowManager.js#L1093-1112
|
||||
*/
|
||||
var Manager = class Manager {
|
||||
|
||||
constructor() {
|
||||
this._keybindings = new Map();
|
||||
|
||||
this._acceleratorActivatedId = global.display.connect(
|
||||
'accelerator-activated',
|
||||
this._onAcceleratorActivated.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
_onAcceleratorActivated(display, action, inputDevice, timestamp) {
|
||||
try {
|
||||
const binding = this._keybindings.get(action);
|
||||
|
||||
if (binding !== undefined)
|
||||
binding.callback();
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a keybinding with callback
|
||||
*
|
||||
* @param {string} accelerator - An accelerator in the form '<Control>q'
|
||||
* @param {Function} callback - A callback for the accelerator
|
||||
* @return {number} A non-zero action id on success, or 0 on failure
|
||||
*/
|
||||
add(accelerator, callback) {
|
||||
try {
|
||||
const action = global.display.grab_accelerator(accelerator, 0);
|
||||
|
||||
if (action === Meta.KeyBindingAction.NONE)
|
||||
throw new Error(`Failed to add keybinding: '${accelerator}'`);
|
||||
|
||||
const name = Meta.external_binding_name_for_action(action);
|
||||
Main.wm.allowKeybinding(name, Shell.ActionMode.ALL);
|
||||
this._keybindings.set(action, {name: name, callback: callback});
|
||||
|
||||
return action;
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a keybinding
|
||||
*
|
||||
* @param {number} action - A non-zero action id returned by add()
|
||||
*/
|
||||
remove(action) {
|
||||
try {
|
||||
const binding = this._keybindings.get(action);
|
||||
global.display.ungrab_accelerator(action);
|
||||
Main.wm.allowKeybinding(binding.name, Shell.ActionMode.NONE);
|
||||
this._keybindings.delete(action);
|
||||
} catch (e) {
|
||||
logError(new Error(`Failed to remove keybinding: ${e.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all keybindings
|
||||
*/
|
||||
removeAll() {
|
||||
for (const action of this._keybindings.keys())
|
||||
this.remove(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the keybinding manager and remove all keybindings
|
||||
*/
|
||||
destroy() {
|
||||
global.display.disconnect(this._acceleratorActivatedId);
|
||||
this.removeAll();
|
||||
}
|
||||
};
|
||||
|
@@ -0,0 +1,439 @@
|
||||
'use strict';
|
||||
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const GObject = imports.gi.GObject;
|
||||
const St = imports.gi.St;
|
||||
|
||||
const Main = imports.ui.main;
|
||||
const MessageTray = imports.ui.messageTray;
|
||||
const NotificationDaemon = imports.ui.notificationDaemon;
|
||||
|
||||
const Extension = imports.misc.extensionUtils.getCurrentExtension();
|
||||
|
||||
// eslint-disable-next-line no-redeclare
|
||||
const _ = Extension._;
|
||||
const APP_ID = 'org.gnome.Shell.Extensions.GSConnect';
|
||||
const APP_PATH = '/org/gnome/Shell/Extensions/GSConnect';
|
||||
|
||||
|
||||
// deviceId Pattern (<device-id>|<remote-id>)
|
||||
const DEVICE_REGEX = new RegExp(/^([^|]+)\|([\s\S]+)$/);
|
||||
|
||||
// requestReplyId Pattern (<device-id>|<remote-id>)|<reply-id>)
|
||||
const REPLY_REGEX = new RegExp(/^([^|]+)\|([\s\S]+)\|([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/, 'i');
|
||||
|
||||
|
||||
/**
|
||||
* A slightly modified Notification Banner with an entry field
|
||||
*/
|
||||
const NotificationBanner = GObject.registerClass({
|
||||
GTypeName: 'GSConnectNotificationBanner',
|
||||
}, class NotificationBanner extends MessageTray.NotificationBanner {
|
||||
|
||||
_init(notification) {
|
||||
super._init(notification);
|
||||
|
||||
if (notification.requestReplyId !== undefined)
|
||||
this._addReplyAction();
|
||||
}
|
||||
|
||||
_addReplyAction() {
|
||||
if (!this._buttonBox) {
|
||||
this._buttonBox = new St.BoxLayout({
|
||||
style_class: 'notification-actions',
|
||||
x_expand: true,
|
||||
});
|
||||
this.setActionArea(this._buttonBox);
|
||||
global.focus_manager.add_group(this._buttonBox);
|
||||
}
|
||||
|
||||
// Reply Button
|
||||
const button = new St.Button({
|
||||
style_class: 'notification-button',
|
||||
label: _('Reply'),
|
||||
x_expand: true,
|
||||
can_focus: true,
|
||||
});
|
||||
|
||||
button.connect(
|
||||
'clicked',
|
||||
this._onEntryRequested.bind(this)
|
||||
);
|
||||
|
||||
this._buttonBox.add_child(button);
|
||||
|
||||
// Reply Entry
|
||||
this._replyEntry = new St.Entry({
|
||||
can_focus: true,
|
||||
hint_text: _('Type a message'),
|
||||
style_class: 'chat-response',
|
||||
x_expand: true,
|
||||
visible: false,
|
||||
});
|
||||
|
||||
this._buttonBox.add_child(this._replyEntry);
|
||||
}
|
||||
|
||||
_onEntryRequested(button) {
|
||||
this.focused = true;
|
||||
|
||||
for (const child of this._buttonBox.get_children())
|
||||
child.visible = (child === this._replyEntry);
|
||||
|
||||
// Release the notification focus with the entry focus
|
||||
this._replyEntry.connect(
|
||||
'key-focus-out',
|
||||
this._onEntryDismissed.bind(this)
|
||||
);
|
||||
|
||||
this._replyEntry.clutter_text.connect(
|
||||
'activate',
|
||||
this._onEntryActivated.bind(this)
|
||||
);
|
||||
|
||||
this._replyEntry.grab_key_focus();
|
||||
}
|
||||
|
||||
_onEntryDismissed(entry) {
|
||||
this.focused = false;
|
||||
this.emit('unfocused');
|
||||
}
|
||||
|
||||
_onEntryActivated(clutter_text) {
|
||||
// Refuse to send empty replies
|
||||
if (clutter_text.text === '')
|
||||
return;
|
||||
|
||||
// Copy the text, then clear the entry
|
||||
const text = clutter_text.text;
|
||||
clutter_text.text = '';
|
||||
|
||||
const {deviceId, requestReplyId} = this.notification;
|
||||
|
||||
const target = new GLib.Variant('(ssbv)', [
|
||||
deviceId,
|
||||
'replyNotification',
|
||||
true,
|
||||
new GLib.Variant('(ssa{ss})', [requestReplyId, text, {}]),
|
||||
]);
|
||||
const platformData = NotificationDaemon.getPlatformData();
|
||||
|
||||
Gio.DBus.session.call(
|
||||
APP_ID,
|
||||
APP_PATH,
|
||||
'org.freedesktop.Application',
|
||||
'ActivateAction',
|
||||
GLib.Variant.new('(sava{sv})', ['device', [target], platformData]),
|
||||
null,
|
||||
Gio.DBusCallFlags.NO_AUTO_START,
|
||||
-1,
|
||||
null,
|
||||
(connection, res) => {
|
||||
try {
|
||||
connection.call_finish(res);
|
||||
} catch (e) {
|
||||
// Silence errors
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* A custom notification source for spawning notifications and closing device
|
||||
* notifications. This source isn't actually used, but it's methods are patched
|
||||
* into existing sources.
|
||||
*/
|
||||
const Source = GObject.registerClass({
|
||||
GTypeName: 'GSConnectNotificationSource',
|
||||
}, class Source extends NotificationDaemon.GtkNotificationDaemonAppSource {
|
||||
|
||||
_closeGSConnectNotification(notification, reason) {
|
||||
if (reason !== MessageTray.NotificationDestroyedReason.DISMISSED)
|
||||
return;
|
||||
|
||||
// Avoid sending the request multiple times
|
||||
if (notification._remoteClosed || notification.remoteId === undefined)
|
||||
return;
|
||||
|
||||
notification._remoteClosed = true;
|
||||
|
||||
const target = new GLib.Variant('(ssbv)', [
|
||||
notification.deviceId,
|
||||
'closeNotification',
|
||||
true,
|
||||
new GLib.Variant('s', notification.remoteId),
|
||||
]);
|
||||
const platformData = NotificationDaemon.getPlatformData();
|
||||
|
||||
Gio.DBus.session.call(
|
||||
APP_ID,
|
||||
APP_PATH,
|
||||
'org.freedesktop.Application',
|
||||
'ActivateAction',
|
||||
GLib.Variant.new('(sava{sv})', ['device', [target], platformData]),
|
||||
null,
|
||||
Gio.DBusCallFlags.NO_AUTO_START,
|
||||
-1,
|
||||
null,
|
||||
(connection, res) => {
|
||||
try {
|
||||
connection.call_finish(res);
|
||||
} catch (e) {
|
||||
// If we fail, reset in case we can try again
|
||||
notification._remoteClosed = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Override to control notification spawning
|
||||
*/
|
||||
addNotification(notificationId, notificationParams, showBanner) {
|
||||
this._notificationPending = true;
|
||||
|
||||
// Parse the id to determine if it's a repliable notification, device
|
||||
// notification or a regular local notification
|
||||
let idMatch, deviceId, requestReplyId, remoteId, localId;
|
||||
|
||||
if ((idMatch = REPLY_REGEX.exec(notificationId))) {
|
||||
[, deviceId, remoteId, requestReplyId] = idMatch;
|
||||
localId = `${deviceId}|${remoteId}`;
|
||||
|
||||
} else if ((idMatch = DEVICE_REGEX.exec(notificationId))) {
|
||||
[, deviceId, remoteId] = idMatch;
|
||||
localId = `${deviceId}|${remoteId}`;
|
||||
|
||||
} else {
|
||||
localId = notificationId;
|
||||
}
|
||||
|
||||
// Fix themed icons
|
||||
if (notificationParams.icon) {
|
||||
let gicon = Gio.Icon.deserialize(notificationParams.icon);
|
||||
|
||||
if (gicon instanceof Gio.ThemedIcon) {
|
||||
gicon = Extension.getIcon(gicon.names[0]);
|
||||
notificationParams.icon = gicon.serialize();
|
||||
}
|
||||
}
|
||||
|
||||
let notification = this._notifications[localId];
|
||||
|
||||
// Check if this is a repeat
|
||||
if (notification) {
|
||||
notification.requestReplyId = requestReplyId;
|
||||
|
||||
// Bail early If @notificationParams represents an exact repeat
|
||||
const title = notificationParams.title.unpack();
|
||||
const body = notificationParams.body
|
||||
? notificationParams.body.unpack()
|
||||
: null;
|
||||
|
||||
if (notification.title === title &&
|
||||
notification.bannerBodyText === body) {
|
||||
this._notificationPending = false;
|
||||
return;
|
||||
}
|
||||
|
||||
notification.title = title;
|
||||
notification.bannerBodyText = body;
|
||||
|
||||
// Device Notification
|
||||
} else if (idMatch) {
|
||||
notification = this._createNotification(notificationParams);
|
||||
|
||||
notification.deviceId = deviceId;
|
||||
notification.remoteId = remoteId;
|
||||
notification.requestReplyId = requestReplyId;
|
||||
|
||||
notification.connect('destroy', (notification, reason) => {
|
||||
this._closeGSConnectNotification(notification, reason);
|
||||
delete this._notifications[localId];
|
||||
});
|
||||
|
||||
this._notifications[localId] = notification;
|
||||
|
||||
// Service Notification
|
||||
} else {
|
||||
notification = this._createNotification(notificationParams);
|
||||
notification.connect('destroy', (notification, reason) => {
|
||||
delete this._notifications[localId];
|
||||
});
|
||||
this._notifications[localId] = notification;
|
||||
}
|
||||
|
||||
if (showBanner)
|
||||
this.showNotification(notification);
|
||||
else
|
||||
this.pushNotification(notification);
|
||||
|
||||
this._notificationPending = false;
|
||||
}
|
||||
|
||||
/*
|
||||
* Override to raise the usual notification limit (3)
|
||||
*/
|
||||
pushNotification(notification) {
|
||||
if (this.notifications.includes(notification))
|
||||
return;
|
||||
|
||||
while (this.notifications.length >= 10)
|
||||
this.notifications.shift().destroy(MessageTray.NotificationDestroyedReason.EXPIRED);
|
||||
|
||||
notification.connect('destroy', this._onNotificationDestroy.bind(this));
|
||||
notification.connect('notify::acknowledged', this.countUpdated.bind(this));
|
||||
this.notifications.push(notification);
|
||||
this.emit('notification-added', notification);
|
||||
|
||||
this.countUpdated();
|
||||
}
|
||||
|
||||
createBanner(notification) {
|
||||
return new NotificationBanner(notification);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* If there is an active GtkNotificationDaemonAppSource for GSConnect when the
|
||||
* extension is loaded, it has to be patched in place.
|
||||
*/
|
||||
function patchGSConnectNotificationSource() {
|
||||
const source = Main.notificationDaemon._gtkNotificationDaemon._sources[APP_ID];
|
||||
|
||||
if (source !== undefined) {
|
||||
// Patch in the subclassed methods
|
||||
source._closeGSConnectNotification = Source.prototype._closeGSConnectNotification;
|
||||
source.addNotification = Source.prototype.addNotification;
|
||||
source.pushNotification = Source.prototype.pushNotification;
|
||||
source.createBanner = Source.prototype.createBanner;
|
||||
|
||||
// Connect to existing notifications
|
||||
for (const notification of Object.values(source._notifications)) {
|
||||
|
||||
const _id = notification.connect('destroy', (notification, reason) => {
|
||||
source._closeGSConnectNotification(notification, reason);
|
||||
notification.disconnect(_id);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Wrap GtkNotificationDaemon._ensureAppSource() to patch GSConnect's app source
|
||||
* https://gitlab.gnome.org/GNOME/gnome-shell/blob/master/js/ui/notificationDaemon.js#L742-755
|
||||
*/
|
||||
const __ensureAppSource = NotificationDaemon.GtkNotificationDaemon.prototype._ensureAppSource;
|
||||
|
||||
// eslint-disable-next-line func-style
|
||||
const _ensureAppSource = function (appId) {
|
||||
const source = __ensureAppSource.call(this, appId);
|
||||
|
||||
if (source._appId === APP_ID) {
|
||||
source._closeGSConnectNotification = Source.prototype._closeGSConnectNotification;
|
||||
source.addNotification = Source.prototype.addNotification;
|
||||
source.pushNotification = Source.prototype.pushNotification;
|
||||
source.createBanner = Source.prototype.createBanner;
|
||||
}
|
||||
|
||||
return source;
|
||||
};
|
||||
|
||||
|
||||
function patchGtkNotificationDaemon() {
|
||||
NotificationDaemon.GtkNotificationDaemon.prototype._ensureAppSource = _ensureAppSource;
|
||||
}
|
||||
|
||||
|
||||
function unpatchGtkNotificationDaemon() {
|
||||
NotificationDaemon.GtkNotificationDaemon.prototype._ensureAppSource = __ensureAppSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* We patch other Gtk notification sources so we can notify remote devices when
|
||||
* notifications have been closed locally.
|
||||
*/
|
||||
const _addNotification = NotificationDaemon.GtkNotificationDaemonAppSource.prototype.addNotification;
|
||||
|
||||
function patchGtkNotificationSources() {
|
||||
// This should diverge as little as possible from the original
|
||||
// eslint-disable-next-line func-style
|
||||
const addNotification = function (notificationId, notificationParams, showBanner) {
|
||||
this._notificationPending = true;
|
||||
|
||||
if (this._notifications[notificationId])
|
||||
this._notifications[notificationId].destroy(MessageTray.NotificationDestroyedReason.REPLACED);
|
||||
|
||||
const notification = this._createNotification(notificationParams);
|
||||
notification.connect('destroy', (notification, reason) => {
|
||||
this._withdrawGSConnectNotification(notification, reason);
|
||||
delete this._notifications[notificationId];
|
||||
});
|
||||
this._notifications[notificationId] = notification;
|
||||
|
||||
if (showBanner)
|
||||
this.showNotification(notification);
|
||||
else
|
||||
this.pushNotification(notification);
|
||||
|
||||
this._notificationPending = false;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line func-style
|
||||
const _withdrawGSConnectNotification = function (id, notification, reason) {
|
||||
if (reason !== MessageTray.NotificationDestroyedReason.DISMISSED)
|
||||
return;
|
||||
|
||||
// Avoid sending the request multiple times
|
||||
if (notification._remoteWithdrawn)
|
||||
return;
|
||||
|
||||
notification._remoteWithdrawn = true;
|
||||
|
||||
// Recreate the notification id as it would've been sent
|
||||
const target = new GLib.Variant('(ssbv)', [
|
||||
'*',
|
||||
'withdrawNotification',
|
||||
true,
|
||||
new GLib.Variant('s', `gtk|${this._appId}|${id}`),
|
||||
]);
|
||||
const platformData = NotificationDaemon.getPlatformData();
|
||||
|
||||
Gio.DBus.session.call(
|
||||
APP_ID,
|
||||
APP_PATH,
|
||||
'org.freedesktop.Application',
|
||||
'ActivateAction',
|
||||
GLib.Variant.new('(sava{sv})', ['device', [target], platformData]),
|
||||
null,
|
||||
Gio.DBusCallFlags.NO_AUTO_START,
|
||||
-1,
|
||||
null,
|
||||
(connection, res) => {
|
||||
try {
|
||||
connection.call_finish(res);
|
||||
} catch (e) {
|
||||
// If we fail, reset in case we can try again
|
||||
notification._remoteWithdrawn = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
NotificationDaemon.GtkNotificationDaemonAppSource.prototype.addNotification = addNotification;
|
||||
NotificationDaemon.GtkNotificationDaemonAppSource.prototype._withdrawGSConnectNotification = _withdrawGSConnectNotification;
|
||||
}
|
||||
|
||||
|
||||
function unpatchGtkNotificationSources() {
|
||||
NotificationDaemon.GtkNotificationDaemonAppSource.prototype.addNotification = _addNotification;
|
||||
delete NotificationDaemon.GtkNotificationDaemonAppSource.prototype._withdrawGSConnectNotification;
|
||||
}
|
||||
|
@@ -0,0 +1,302 @@
|
||||
'use strict';
|
||||
|
||||
const Clutter = imports.gi.Clutter;
|
||||
const Gio = imports.gi.Gio;
|
||||
const GLib = imports.gi.GLib;
|
||||
const Pango = imports.gi.Pango;
|
||||
const St = imports.gi.St;
|
||||
|
||||
const Main = imports.ui.main;
|
||||
|
||||
|
||||
/**
|
||||
* An StTooltip for ClutterActors
|
||||
*
|
||||
* Adapted from: https://github.com/RaphaelRochet/applications-overview-tooltip
|
||||
* See also: https://github.com/GNOME/gtk/blob/master/gtk/gtktooltip.c
|
||||
*/
|
||||
var TOOLTIP_BROWSE_ID = 0;
|
||||
var TOOLTIP_BROWSE_MODE = false;
|
||||
|
||||
var Tooltip = class Tooltip {
|
||||
|
||||
constructor(params) {
|
||||
Object.assign(this, params);
|
||||
|
||||
this._bin = null;
|
||||
this._hoverTimeoutId = 0;
|
||||
this._showing = false;
|
||||
|
||||
this._destroyId = this.parent.connect(
|
||||
'destroy',
|
||||
this.destroy.bind(this)
|
||||
);
|
||||
|
||||
this._hoverId = this.parent.connect(
|
||||
'notify::hover',
|
||||
this._onHover.bind(this)
|
||||
);
|
||||
|
||||
this._buttonPressEventId = this.parent.connect(
|
||||
'button-press-event',
|
||||
this._hide.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
get custom() {
|
||||
if (this._custom === undefined)
|
||||
this._custom = null;
|
||||
|
||||
return this._custom;
|
||||
}
|
||||
|
||||
set custom(actor) {
|
||||
this._custom = actor;
|
||||
this._markup = null;
|
||||
this._text = null;
|
||||
|
||||
if (this._showing)
|
||||
this._show();
|
||||
}
|
||||
|
||||
get gicon() {
|
||||
if (this._gicon === undefined)
|
||||
this._gicon = null;
|
||||
|
||||
return this._gicon;
|
||||
}
|
||||
|
||||
set gicon(gicon) {
|
||||
this._gicon = gicon;
|
||||
|
||||
if (this._showing)
|
||||
this._show();
|
||||
}
|
||||
|
||||
get icon() {
|
||||
return (this.gicon) ? this.gicon.name : null;
|
||||
}
|
||||
|
||||
set icon(icon_name) {
|
||||
if (!icon_name)
|
||||
this.gicon = null;
|
||||
else
|
||||
this.gicon = new Gio.ThemedIcon({name: icon_name});
|
||||
}
|
||||
|
||||
get markup() {
|
||||
if (this._markup === undefined)
|
||||
this._markup = null;
|
||||
|
||||
return this._markup;
|
||||
}
|
||||
|
||||
set markup(text) {
|
||||
this._markup = text;
|
||||
this._text = null;
|
||||
|
||||
if (this._showing)
|
||||
this._show();
|
||||
}
|
||||
|
||||
get text() {
|
||||
if (this._text === undefined)
|
||||
this._text = null;
|
||||
|
||||
return this._text;
|
||||
}
|
||||
|
||||
set text(text) {
|
||||
this._markup = null;
|
||||
this._text = text;
|
||||
|
||||
if (this._showing)
|
||||
this._show();
|
||||
}
|
||||
|
||||
get x_offset() {
|
||||
if (this._x_offset === undefined)
|
||||
this._x_offset = 0;
|
||||
|
||||
return this._x_offset;
|
||||
}
|
||||
|
||||
set x_offset(offset) {
|
||||
this._x_offset = (Number.isInteger(offset)) ? offset : 0;
|
||||
}
|
||||
|
||||
get y_offset() {
|
||||
if (this._y_offset === undefined)
|
||||
this._y_offset = 0;
|
||||
|
||||
return this._y_offset;
|
||||
}
|
||||
|
||||
set y_offset(offset) {
|
||||
this._y_offset = (Number.isInteger(offset)) ? offset : 0;
|
||||
}
|
||||
|
||||
_show() {
|
||||
if (this.text === null && this.markup === null)
|
||||
return this._hide();
|
||||
|
||||
if (this._bin === null) {
|
||||
this._bin = new St.Bin({
|
||||
style_class: 'osd-window gsconnect-tooltip',
|
||||
opacity: 232,
|
||||
});
|
||||
|
||||
if (this.custom) {
|
||||
this._bin.child = this.custom;
|
||||
} else {
|
||||
this._bin.child = new St.BoxLayout({vertical: false});
|
||||
|
||||
if (this.gicon) {
|
||||
this._bin.child.icon = new St.Icon({
|
||||
gicon: this.gicon,
|
||||
y_align: St.Align.START,
|
||||
});
|
||||
this._bin.child.icon.set_y_align(Clutter.ActorAlign.START);
|
||||
this._bin.child.add_child(this._bin.child.icon);
|
||||
}
|
||||
|
||||
this.label = new St.Label({text: this.markup || this.text});
|
||||
this.label.clutter_text.line_wrap = true;
|
||||
this.label.clutter_text.line_wrap_mode = Pango.WrapMode.WORD;
|
||||
this.label.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
|
||||
this.label.clutter_text.use_markup = (this.markup);
|
||||
this._bin.child.add_child(this.label);
|
||||
}
|
||||
|
||||
Main.layoutManager.uiGroup.add_child(this._bin);
|
||||
Main.layoutManager.uiGroup.set_child_above_sibling(this._bin, null);
|
||||
} else if (this.custom) {
|
||||
this._bin.child = this.custom;
|
||||
} else {
|
||||
if (this._bin.child.icon)
|
||||
this._bin.child.icon.destroy();
|
||||
|
||||
if (this.gicon) {
|
||||
this._bin.child.icon = new St.Icon({gicon: this.gicon});
|
||||
this._bin.child.insert_child_at_index(this._bin.child.icon, 0);
|
||||
}
|
||||
|
||||
this.label.clutter_text.text = this.markup || this.text;
|
||||
this.label.clutter_text.use_markup = (this.markup);
|
||||
}
|
||||
|
||||
// Position tooltip
|
||||
let [x, y] = this.parent.get_transformed_position();
|
||||
x = (x + (this.parent.width / 2)) - Math.round(this._bin.width / 2);
|
||||
|
||||
x += this.x_offset;
|
||||
y += this.y_offset;
|
||||
|
||||
// Show tooltip
|
||||
if (this._showing) {
|
||||
this._bin.ease({
|
||||
x: x,
|
||||
y: y,
|
||||
time: 0.15,
|
||||
transition: Clutter.AnimationMode.EASE_OUT_QUAD,
|
||||
});
|
||||
} else {
|
||||
this._bin.set_position(x, y);
|
||||
this._bin.ease({
|
||||
opacity: 232,
|
||||
time: 0.15,
|
||||
transition: Clutter.AnimationMode.EASE_OUT_QUAD,
|
||||
});
|
||||
|
||||
this._showing = true;
|
||||
}
|
||||
|
||||
// Enable browse mode
|
||||
TOOLTIP_BROWSE_MODE = true;
|
||||
|
||||
if (TOOLTIP_BROWSE_ID) {
|
||||
GLib.source_remove(TOOLTIP_BROWSE_ID);
|
||||
TOOLTIP_BROWSE_ID = 0;
|
||||
}
|
||||
|
||||
if (this._hoverTimeoutId) {
|
||||
GLib.source_remove(this._hoverTimeoutId);
|
||||
this._hoverTimeoutId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
_hide() {
|
||||
if (this._bin) {
|
||||
this._bin.ease({
|
||||
opacity: 0,
|
||||
time: 0.10,
|
||||
transition: Clutter.AnimationMode.EASE_OUT_QUAD,
|
||||
onComplete: () => {
|
||||
Main.layoutManager.uiGroup.remove_actor(this._bin);
|
||||
|
||||
if (this.custom)
|
||||
this._bin.remove_child(this.custom);
|
||||
|
||||
this._bin.destroy();
|
||||
this._bin = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => {
|
||||
TOOLTIP_BROWSE_MODE = false;
|
||||
TOOLTIP_BROWSE_ID = 0;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (this._hoverTimeoutId) {
|
||||
GLib.source_remove(this._hoverTimeoutId);
|
||||
this._hoverTimeoutId = 0;
|
||||
}
|
||||
|
||||
this._showing = false;
|
||||
this._hoverTimeoutId = 0;
|
||||
}
|
||||
|
||||
_onHover() {
|
||||
if (this.parent.hover) {
|
||||
if (!this._hoverTimeoutId) {
|
||||
if (this._showing) {
|
||||
this._show();
|
||||
} else {
|
||||
this._hoverTimeoutId = GLib.timeout_add(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
(TOOLTIP_BROWSE_MODE) ? 60 : 500,
|
||||
() => {
|
||||
this._show();
|
||||
this._hoverTimeoutId = 0;
|
||||
return false;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this._hide();
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.parent.disconnect(this._destroyId);
|
||||
this.parent.disconnect(this._hoverId);
|
||||
this.parent.disconnect(this._buttonPressEventId);
|
||||
|
||||
if (this.custom)
|
||||
this.custom.destroy();
|
||||
|
||||
if (this._bin) {
|
||||
Main.layoutManager.uiGroup.remove_actor(this._bin);
|
||||
this._bin.destroy();
|
||||
}
|
||||
|
||||
if (this._hoverTimeoutId) {
|
||||
GLib.source_remove(this._hoverTimeoutId);
|
||||
this._hoverTimeoutId = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@@ -0,0 +1,218 @@
|
||||
'use strict';
|
||||
|
||||
const ByteArray = imports.byteArray;
|
||||
|
||||
const GLib = imports.gi.GLib;
|
||||
const Gio = imports.gi.Gio;
|
||||
|
||||
const Extension = imports.misc.extensionUtils.getCurrentExtension();
|
||||
const Config = Extension.imports.config;
|
||||
|
||||
|
||||
/**
|
||||
* Get a themed icon, using fallbacks from GSConnect's GResource when necessary.
|
||||
*
|
||||
* @param {string} name - A themed icon name
|
||||
* @return {Gio.Icon} A themed icon
|
||||
*/
|
||||
function getIcon(name) {
|
||||
if (getIcon._resource === undefined) {
|
||||
// Setup the desktop icons
|
||||
const settings = imports.gi.St.Settings.get();
|
||||
getIcon._desktop = new imports.gi.Gtk.IconTheme();
|
||||
getIcon._desktop.set_custom_theme(settings.gtk_icon_theme);
|
||||
settings.connect('notify::gtk-icon-theme', (settings_, key_) => {
|
||||
getIcon._desktop.set_custom_theme(settings_.gtk_icon_theme);
|
||||
});
|
||||
|
||||
// Preload our fallbacks
|
||||
const iconPath = 'resource://org/gnome/Shell/Extensions/GSConnect/icons';
|
||||
const iconNames = [
|
||||
'org.gnome.Shell.Extensions.GSConnect',
|
||||
'org.gnome.Shell.Extensions.GSConnect-symbolic',
|
||||
'computer-symbolic',
|
||||
'laptop-symbolic',
|
||||
'smartphone-symbolic',
|
||||
'tablet-symbolic',
|
||||
'tv-symbolic',
|
||||
'phonelink-ring-symbolic',
|
||||
'sms-symbolic',
|
||||
];
|
||||
|
||||
getIcon._resource = {};
|
||||
|
||||
for (const iconName of iconNames) {
|
||||
getIcon._resource[iconName] = new Gio.FileIcon({
|
||||
file: Gio.File.new_for_uri(`${iconPath}/${iconName}.svg`),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check the desktop icon theme
|
||||
if (getIcon._desktop.has_icon(name))
|
||||
return new Gio.ThemedIcon({name: name});
|
||||
|
||||
// Check our GResource
|
||||
if (getIcon._resource[name] !== undefined)
|
||||
return getIcon._resource[name];
|
||||
|
||||
// Fallback to hoping it's in the theme somewhere
|
||||
return new Gio.ThemedIcon({name: name});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the contents of a GResource file, replacing `@PACKAGE_DATADIR@` where
|
||||
* necessary.
|
||||
*
|
||||
* @param {string} relativePath - A path relative to GSConnect's resource path
|
||||
* @return {string} The file contents as a string
|
||||
*/
|
||||
function getResource(relativePath) {
|
||||
try {
|
||||
const bytes = Gio.resources_lookup_data(
|
||||
GLib.build_filenamev([Config.APP_PATH, relativePath]),
|
||||
Gio.ResourceLookupFlags.NONE
|
||||
);
|
||||
|
||||
const source = ByteArray.toString(bytes.toArray());
|
||||
|
||||
return source.replace('@PACKAGE_DATADIR@', Config.PACKAGE_DATADIR);
|
||||
} catch (e) {
|
||||
logError(e, 'GSConnect');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Install file contents, to an absolute directory path.
|
||||
*
|
||||
* @param {string} dirname - An absolute directory path
|
||||
* @param {string} basename - The file name
|
||||
* @param {string} contents - The file contents
|
||||
* @return {boolean} A success boolean
|
||||
*/
|
||||
function _installFile(dirname, basename, contents) {
|
||||
try {
|
||||
const filename = GLib.build_filenamev([dirname, basename]);
|
||||
GLib.mkdir_with_parents(dirname, 0o755);
|
||||
|
||||
return GLib.file_set_contents(filename, contents);
|
||||
} catch (e) {
|
||||
logError(e, 'GSConnect');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install file contents from a GResource, to an absolute directory path.
|
||||
*
|
||||
* @param {string} dirname - An absolute directory path
|
||||
* @param {string} basename - The file name
|
||||
* @param {string} relativePath - A path relative to GSConnect's resource path
|
||||
* @return {boolean} A success boolean
|
||||
*/
|
||||
function _installResource(dirname, basename, relativePath) {
|
||||
try {
|
||||
const contents = getResource(relativePath);
|
||||
|
||||
return _installFile(dirname, basename, contents);
|
||||
} catch (e) {
|
||||
logError(e, 'GSConnect');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Install the files necessary for the GSConnect service to run.
|
||||
*/
|
||||
function installService() {
|
||||
const confDir = GLib.get_user_config_dir();
|
||||
const dataDir = GLib.get_user_data_dir();
|
||||
const homeDir = GLib.get_home_dir();
|
||||
|
||||
// DBus Service
|
||||
const dbusDir = GLib.build_filenamev([dataDir, 'dbus-1', 'services']);
|
||||
const dbusFile = `${Config.APP_ID}.service`;
|
||||
|
||||
// Desktop Entry
|
||||
const appDir = GLib.build_filenamev([dataDir, 'applications']);
|
||||
const appFile = `${Config.APP_ID}.desktop`;
|
||||
const appPrefsFile = `${Config.APP_ID}.Preferences.desktop`;
|
||||
|
||||
// Application Icon
|
||||
const iconDir = GLib.build_filenamev([dataDir, 'icons', 'hicolor', 'scalable', 'apps']);
|
||||
const iconFull = `${Config.APP_ID}.svg`;
|
||||
const iconSym = `${Config.APP_ID}-symbolic.svg`;
|
||||
|
||||
// File Manager Extensions
|
||||
const fileManagers = [
|
||||
[`${dataDir}/nautilus-python/extensions`, 'nautilus-gsconnect.py'],
|
||||
[`${dataDir}/nemo-python/extensions`, 'nemo-gsconnect.py'],
|
||||
];
|
||||
|
||||
// WebExtension Manifests
|
||||
const manifestFile = 'org.gnome.shell.extensions.gsconnect.json';
|
||||
const google = getResource(`webextension/${manifestFile}.google.in`);
|
||||
const mozilla = getResource(`webextension/${manifestFile}.mozilla.in`);
|
||||
const manifests = [
|
||||
[`${confDir}/chromium/NativeMessagingHosts/`, google],
|
||||
[`${confDir}/google-chrome/NativeMessagingHosts/`, google],
|
||||
[`${confDir}/google-chrome-beta/NativeMessagingHosts/`, google],
|
||||
[`${confDir}/google-chrome-unstable/NativeMessagingHosts/`, google],
|
||||
[`${confDir}/BraveSoftware/Brave-Browser/NativeMessagingHosts/`, google],
|
||||
[`${homeDir}/.mozilla/native-messaging-hosts/`, mozilla],
|
||||
[`${homeDir}/.config/microsoft-edge-dev/NativeMessagingHosts`, google],
|
||||
[`${homeDir}/.config/microsoft-edge-beta/NativeMessagingHosts`, google],
|
||||
];
|
||||
|
||||
// If running as a user extension, ensure the DBus service, desktop entry,
|
||||
// file manager scripts, and WebExtension manifests are installed.
|
||||
if (Config.IS_USER) {
|
||||
// DBus Service
|
||||
if (!_installResource(dbusDir, dbusFile, `${dbusFile}.in`))
|
||||
throw Error('GSConnect: Failed to install DBus Service');
|
||||
|
||||
// Desktop Entries
|
||||
_installResource(appDir, appFile, appFile);
|
||||
_installResource(appDir, appPrefsFile, appPrefsFile);
|
||||
|
||||
// Application Icon
|
||||
_installResource(iconDir, iconFull, `icons/${iconFull}`);
|
||||
_installResource(iconDir, iconSym, `icons/${iconSym}`);
|
||||
|
||||
// File Manager Extensions
|
||||
const target = `${Config.PACKAGE_DATADIR}/nautilus-gsconnect.py`;
|
||||
|
||||
for (const [dir, name] of fileManagers) {
|
||||
const script = Gio.File.new_for_path(GLib.build_filenamev([dir, name]));
|
||||
|
||||
if (!script.query_exists(null)) {
|
||||
GLib.mkdir_with_parents(dir, 0o755);
|
||||
script.make_symbolic_link(target, null);
|
||||
}
|
||||
}
|
||||
|
||||
// WebExtension Manifests
|
||||
for (const [dirname, contents] of manifests)
|
||||
_installFile(dirname, manifestFile, contents);
|
||||
|
||||
// Otherwise, if running as a system extension, ensure anything previously
|
||||
// installed when running as a user extension is removed.
|
||||
} else {
|
||||
GLib.unlink(GLib.build_filenamev([dbusDir, dbusFile]));
|
||||
GLib.unlink(GLib.build_filenamev([appDir, appFile]));
|
||||
GLib.unlink(GLib.build_filenamev([appDir, appPrefsFile]));
|
||||
GLib.unlink(GLib.build_filenamev([iconDir, iconFull]));
|
||||
GLib.unlink(GLib.build_filenamev([iconDir, iconSym]));
|
||||
|
||||
for (const [dir, name] of fileManagers)
|
||||
GLib.unlink(GLib.build_filenamev([dir, name]));
|
||||
|
||||
for (const manifest of manifests)
|
||||
GLib.unlink(GLib.build_filenamev([manifest[0], manifestFile]));
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user