Add some configs from generic Ubuntu

This commit is contained in:
2020-05-11 05:16:27 -04:00
parent f32c1048d1
commit 754f64f135
16037 changed files with 205635 additions and 137 deletions

View File

@@ -0,0 +1,268 @@
'use strict';
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;
const St = imports.gi.St;
/*
* 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">
<property name="Text" type="s" access="readwrite"/>
</interface>
</node>
`);
const DBUS_INFO = DBUS_NODE.lookup_interface(DBUS_NAME);
/* GSConnectShellClipboard:
*
* A simple clipboard portal, especially useful on Wayland where GtkClipboard
* doesn't work correctly.
*/
var Clipboard = GObject.registerClass({
GTypeName: 'GSConnectShellClipboard',
Properties: {
'text': GObject.ParamSpec.string(
'text',
'Text Content',
'The current text content of the clipboard',
GObject.ParamFlags.READWRITE,
''
)
}
}, class GSConnectShellClipboard extends GjsPrivate.DBusImplementation {
_init(params = {}) {
super._init({
g_interface_info: DBUS_INFO
});
this._text = '';
this._transferring = false;
// Get the current clipboard content
this.clipboard.get_text(
St.ClipboardType.CLIPBOARD,
this._onTextReceived.bind(this)
);
// Watch global selection
this._selection = global.display.get_selection();
this._ownerChangedId = this._selection.connect(
'owner-changed',
this._onOwnerChanged.bind(this)
);
// Prepare DBus interface
this._handlePropertyGetId = this.connect(
'handle-property-get',
this._onHandlePropertyGet.bind(this)
);
this._handlePropertySetId = this.connect(
'handle-property-set',
this._onHandlePropertySet.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)
);
}
get clipboard() {
if (this._clipboard === undefined) {
this._clipboard = St.Clipboard.get_default();
}
return this._clipboard;
}
get text() {
if (this._text === undefined) {
this._text = '';
}
return this._text;
}
set text(content) {
if (typeof content !== 'string')
return;
if (this._text !== content) {
this._text = content;
this.notify('text');
this.emit_property_changed(
'Text',
GLib.Variant.new('s', content)
);
}
}
_onTextReceived(clipboard, text) {
try {
this.text = text;
} catch (e) {
logError(e);
} finally {
this._transferring = false;
}
}
_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 transfer call 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, () => {
this.clipboard.get_text(
St.ClipboardType.CLIPBOARD,
this._onTextReceived.bind(this)
);
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);
}
}
_onHandlePropertyGet(iface, name) {
if (name !== 'Text') return;
try {
return new GLib.Variant('s', this.text);
} catch (e) {
logError(e);
}
}
_onHandlePropertySet(iface, name, value) {
if (name !== 'Text') return;
try {
let content = value.unpack();
if (typeof content !== 'string')
return;
if (this._text !== content) {
this._text = content;
this.notify('text');
this.clipboard.set_text(
St.ClipboardType.CLIPBOARD,
content
);
}
} catch (e) {
logError(e);
}
}
destroy() {
if (this.__disposed === undefined) {
this._selection.disconnect(this._ownerChangedId);
this._selection = null;
Gio.bus_unown_name(this._nameId);
this.flush();
this.unexport();
this.disconnect(this._handlePropertyGetId);
this.disconnect(this._handlePropertySetId);
this.run_dispose();
}
}
});
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;
}
if (_portal !== null) {
_portal.destroy();
_portal = null;
}
}

View File

@@ -0,0 +1,249 @@
'use strict';
const Clutter = imports.gi.Clutter;
const Gio = imports.gi.Gio;
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 _ = gsconnect._;
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: this.battery_label
});
// 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');
// Refresh when mapped
this._mappedId = this.connect('notify::mapped', this._sync.bind(this));
// Cleanup
this.connect('destroy', this._onDestroy);
}
_onActionChanged(action_group, action_name) {
if (action_name === 'battery') {
if (action_group.has_action('battery')) {
let value = action_group.get_action_state('battery');
let [charging, icon_name, level, time] = value.deepUnpack();
this.battery = {
Charging: charging,
IconName: icon_name,
Level: level,
Time: time
};
} else {
this.battery = null;
}
this._sync();
}
}
_onStateChanged(action_group, action_name, value) {
if (action_name === 'battery') {
let [charging, icon_name, level, time] = value.deepUnpack();
this.battery = {
Charging: charging,
IconName: icon_name,
Level: level,
Time: time
};
}
}
get battery_label() {
if (!this.battery) return null;
let {Charging, Level, Time} = this.battery;
if (Level === 100) {
// TRANSLATORS: When the battery level is 100%
return _('Fully Charged');
} else if (Time === 0) {
// TRANSLATORS: When no time estimate for the battery is available
// EXAMPLE: 42% (Estimating…)
return _('%d%% (Estimating…)').format(Level);
}
Time = Time / 60;
let minutes = Math.floor(Time % 60);
let hours = Math.floor(Time / 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);
actor.disconnect(actor._mappedId);
}
_sync() {
this.visible = (this.battery);
if (this.visible && this.mapped) {
this.icon.icon_name = this.battery.IconName;
this.label.text = (this.battery.Level > -1) ? `${this.battery.Level}%` : '';
this.tooltip.text = this.battery_label;
}
}
});
/**
* 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 -> 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: gsconnect.getIcon(this.device.icon_name),
style_class: 'system-status-icon gsconnect-device-indicator'
});
this.add_child(this._icon);
// Menu
let menu = new Menu({
device: this.device,
menu_type: 'icon'
});
this.menu.addMenuItem(menu);
}
update_icon(icon_name) {
this._icon.gicon = gsconnect.getIcon(icon_name);
}
});

View File

@@ -0,0 +1,652 @@
'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) {
let info = {
target: null,
links: []
};
//
let iter = model.iterate_item_attributes(index);
while (iter.next()) {
let name = iter.get_name();
let value = iter.get_value();
switch (name) {
case 'icon':
value = Gio.Icon.deserialize(value);
if (value instanceof Gio.ThemedIcon)
value = gsconnect.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) {
let 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) {
// TODO: Use an image menu item if there's an icon?
let 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) {
let section = new ListBox({
model: model,
action_group: this.action_group
});
this.addMenuItem(section);
}
_addGMenuSubmenu(model, item) {
// Add an expander arrow to the item
let 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++) {
let info = getItemInfo(this.model, i);
let item;
// A regular item
if (info.hasOwnProperty('label')) {
item = this._addGMenuItem(info);
}
for (let 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
let prev = new PopupMenu.PopupMenuItem(this.submenu_for.label.text);
prev.label.style = 'font-weight: bold;';
let 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
let allocation = this.actor.allocation;
let 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
});
}
if (params.info.hasOwnProperty('icon')) {
this.child = new St.Icon({gicon: params.info.icon});
}
// Submenu
for (let 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.connect('clicked', this._onClicked);
}
// 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
_onClicked(button, clicked_button) {
// Unless this has submenu activate the action and close
if (!button.toggle_mode) {
button._parent._getTopMenu().close();
button.action_group.activate_action(
button.action_name,
button.action_target
);
// StButton.checked has already been toggled so we're opening
} else if (button.checked) {
button._parent.submenu = button.submenu;
// If this is the active submenu being closed, animate-close it
} else if (button._parent.submenu === button.submenu) {
button._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 (let 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;
this.box.get_children().map(button => button.checked = false);
this.sub.get_children().map(submenu => submenu.hide());
}
}
_onActionChanged(group, name, enabled) {
let 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) {
let button = this.box.get_child_at_index(position);
let action_name = button.action_name;
(button.submenu) ? button.submenu.destroy() : null;
button.destroy();
this._menu_items.delete(action_name);
removed--;
}
// Add items
for (let i = 0; i < added; i++) {
let index = position + i;
// Create an iconic button
let button = new IconButton({
action_group: this.action_group,
info: getItemInfo(model, index),
// TODO: 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) {
let menu = actor._delegate;
menu.box.get_children().map(button => {
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());
}
};

View File

@@ -0,0 +1,107 @@
'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 {
let 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 {
let action = Meta.KeyBindingAction.NONE;
action = global.display.grab_accelerator(accelerator, 0);
if (action !== Meta.KeyBindingAction.NONE) {
let name = Meta.external_binding_name_for_action(action);
Main.wm.allowKeybinding(name, Shell.ActionMode.ALL);
this._keybindings.set(action, {name: name, callback: callback});
} else {
throw new Error(`Failed to add keybinding: '${accelerator}'`);
}
return action;
} catch (e) {
logError(e);
}
}
/**
* Remove a keybinding
*
* @param {Number} accelerator - A non-zero action id returned by add()
*/
remove(action) {
try {
let 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 (let action of this._keybindings.keys()) {
this.remove(action);
}
}
/**
* Destroy the keybinding manager and remove all keybindings
*/
destroy() {
global.display.disconnect(this._acceleratorActivatedId);
this.removeAll();
}
};

View File

@@ -0,0 +1,423 @@
'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;
// eslint-disable-next-line no-redeclare
const _ = gsconnect._;
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 = /^([^|]+)\|(.+)$/;
// requestReplyId Pattern (<device-id>|<remote-id>)|<reply-id>)
const REPLY_REGEX = /^([^|]+)\|(.+)\|([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
let 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 (let 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
let text = clutter_text.text;
clutter_text.text = '';
let {deviceId, requestReplyId} = this.notification;
let target = new GLib.Variant('(ssbv)', [
deviceId,
'replyNotification',
true,
new GLib.Variant('(ssa{ss})', [requestReplyId, text, {}])
]);
let 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) {
return;
}
notification._remoteClosed = true;
let target = new GLib.Variant('(ssbv)', [
notification.deviceId,
'closeNotification',
true,
new GLib.Variant('s', notification.remoteId)
]);
let 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) {
let idMatch, deviceId, requestReplyId, remoteId, localId;
// Check if it's a repliable device notification
if ((idMatch = notificationId.match(REPLY_REGEX))) {
[idMatch, deviceId, remoteId, requestReplyId] = idMatch;
localId = `${deviceId}|${remoteId}`;
// Check if it's a device notification
} else if ((idMatch = notificationId.match(DEVICE_REGEX))) {
[idMatch, deviceId, remoteId] = idMatch;
localId = `${deviceId}|${remoteId}`;
// Must be a service notification
} else {
localId = notificationId;
}
//
this._notificationPending = true;
let notification = this._notifications[localId];
// Check if @notificationParams represents an exact repeat
let repeat = (
notification &&
notification.title === notificationParams.title.unpack() &&
notification.bannerBodyText === notificationParams.body.unpack()
);
// If it's a repeat, we still update the metadata
if (repeat) {
notification.deviceId = deviceId;
notification.remoteId = remoteId;
notification.requestReplyId = requestReplyId;
// 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 && !repeat)
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() {
let 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 (let notification of Object.values(source._notifications)) {
let _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;
const _ensureAppSource = function(appId) {
let 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
let addNotification = function(notificationId, notificationParams, showBanner) {
this._notificationPending = true;
if (this._notifications[notificationId])
this._notifications[notificationId].destroy(MessageTray.NotificationDestroyedReason.REPLACED);
let 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;
};
let _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
let target = new GLib.Variant('(ssbv)', [
'*',
'withdrawNotification',
true,
new GLib.Variant('s', `gtk|${this._appId}|${id}`)
]);
let 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;
}

View File

@@ -0,0 +1,306 @@
'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;
const Tweener = imports.ui.tweener;
/**
* 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._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;
this._update();
}
get gicon() {
if (this._gicon === undefined) {
this._gicon = null;
}
return this._gicon;
}
set gicon(gicon) {
this._gicon = gicon;
this._update();
}
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;
this._update();
}
get text() {
if (this._text === undefined) {
this._text = null;
}
return this._text;
}
set text(text) {
this._markup = null;
this._text = text;
this._update();
}
get x_offset() {
return (this._x_offset === undefined) ? 0 : this._x_offset;
}
set x_offset(offset) {
this._x_offset = (Number.isInteger(offset)) ? offset : 0;
}
get y_offset() {
return (this._y_offset === undefined) ? 0 : this._y_offset;
}
set y_offset(offset) {
this._y_offset = (Number.isInteger(offset)) ? offset : 0;
}
_update() {
if (this._showing) {
this._show();
}
}
_show() {
if (!this.text && !this.markup) {
this._hide();
return;
}
if (!this.bin) {
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) {
Tweener.addTween(this.bin, {
x: x,
y: y,
time: 0.15,
transition: 'easeOutQuad'
});
} else {
this.bin.set_position(x, y);
Tweener.addTween(this.bin, {
opacity: 232,
time: 0.15,
transition: 'easeOutQuad'
});
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) {
Tweener.addTween(this.bin, {
opacity: 0,
time: 0.10,
transition: 'easeOutQuad',
onComplete: () => {
Main.layoutManager.uiGroup.remove_actor(this.bin);
if (this.custom) {
this.bin.remove_child(this.custom);
}
this.bin.destroy();
delete this.bin;
}
});
}
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;
}
}
};