summaryrefslogtreecommitdiffstats
path: root/js/ui
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--js/ui/accessDialog.js156
-rw-r--r--js/ui/altTab.js1116
-rw-r--r--js/ui/animation.js201
-rw-r--r--js/ui/appDisplay.js2998
-rw-r--r--js/ui/appFavorites.js211
-rw-r--r--js/ui/audioDeviceSelection.js199
-rw-r--r--js/ui/background.js798
-rw-r--r--js/ui/backgroundMenu.js69
-rw-r--r--js/ui/barLevel.js234
-rw-r--r--js/ui/boxpointer.js652
-rw-r--r--js/ui/calendar.js1033
-rw-r--r--js/ui/checkBox.js40
-rw-r--r--js/ui/closeDialog.js210
-rw-r--r--js/ui/components/__init__.js58
-rw-r--r--js/ui/components/automountManager.js259
-rw-r--r--js/ui/components/autorunManager.js359
-rw-r--r--js/ui/components/keyring.js229
-rw-r--r--js/ui/components/networkAgent.js809
-rw-r--r--js/ui/components/polkitAgent.js477
-rw-r--r--js/ui/components/telepathyClient.js1018
-rw-r--r--js/ui/ctrlAltTab.js192
-rw-r--r--js/ui/dash.js914
-rw-r--r--js/ui/dateMenu.js923
-rw-r--r--js/ui/dialog.js368
-rw-r--r--js/ui/dnd.js800
-rw-r--r--js/ui/edgeDragAction.js77
-rw-r--r--js/ui/endSessionDialog.js810
-rw-r--r--js/ui/environment.js400
-rw-r--r--js/ui/extensionDownloader.js248
-rw-r--r--js/ui/extensionSystem.js658
-rw-r--r--js/ui/focusCaretTracker.js89
-rw-r--r--js/ui/grabHelper.js332
-rw-r--r--js/ui/ibusCandidatePopup.js325
-rw-r--r--js/ui/iconGrid.js1741
-rw-r--r--js/ui/inhibitShortcutsDialog.js163
-rw-r--r--js/ui/kbdA11yDialog.js73
-rw-r--r--js/ui/keyboard.js1967
-rw-r--r--js/ui/layout.js1409
-rw-r--r--js/ui/lightbox.js293
-rw-r--r--js/ui/locatePointer.js39
-rw-r--r--js/ui/lookingGlass.js1373
-rw-r--r--js/ui/magnifier.js1989
-rw-r--r--js/ui/magnifierDBus.js351
-rw-r--r--js/ui/main.js856
-rw-r--r--js/ui/messageList.js737
-rw-r--r--js/ui/messageTray.js1481
-rw-r--r--js/ui/modalDialog.js264
-rw-r--r--js/ui/mpris.js300
-rw-r--r--js/ui/notificationDaemon.js785
-rw-r--r--js/ui/osdMonitorLabeler.js114
-rw-r--r--js/ui/osdWindow.js250
-rw-r--r--js/ui/overview.js705
-rw-r--r--js/ui/overviewControls.js519
-rw-r--r--js/ui/padOsd.js993
-rw-r--r--js/ui/pageIndicators.js194
-rw-r--r--js/ui/panel.js1175
-rw-r--r--js/ui/panelMenu.js228
-rw-r--r--js/ui/pointerA11yTimeout.js134
-rw-r--r--js/ui/pointerWatcher.js125
-rw-r--r--js/ui/popupMenu.js1411
-rw-r--r--js/ui/remoteSearch.js335
-rw-r--r--js/ui/ripples.js104
-rw-r--r--js/ui/runDialog.js256
-rw-r--r--js/ui/screenShield.js650
-rw-r--r--js/ui/screenshot.js631
-rw-r--r--js/ui/scripting.js351
-rw-r--r--js/ui/search.js955
-rw-r--r--js/ui/sessionMode.js198
-rw-r--r--js/ui/shellDBus.js401
-rw-r--r--js/ui/shellEntry.js209
-rw-r--r--js/ui/shellMountOperation.js752
-rw-r--r--js/ui/slider.js213
-rw-r--r--js/ui/status/accessibility.js200
-rw-r--r--js/ui/status/bluetooth.js158
-rw-r--r--js/ui/status/brightness.js73
-rw-r--r--js/ui/status/dwellClick.js86
-rw-r--r--js/ui/status/keyboard.js1079
-rw-r--r--js/ui/status/location.js387
-rw-r--r--js/ui/status/network.js2101
-rw-r--r--js/ui/status/nightLight.js70
-rw-r--r--js/ui/status/power.js155
-rw-r--r--js/ui/status/remoteAccess.js97
-rw-r--r--js/ui/status/rfkill.js112
-rw-r--r--js/ui/status/system.js178
-rw-r--r--js/ui/status/thunderbolt.js340
-rw-r--r--js/ui/status/volume.js430
-rw-r--r--js/ui/swipeTracker.js666
-rw-r--r--js/ui/switchMonitor.js97
-rw-r--r--js/ui/switcherPopup.js674
-rw-r--r--js/ui/unlockDialog.js901
-rw-r--r--js/ui/userWidget.js250
-rw-r--r--js/ui/viewSelector.js609
-rw-r--r--js/ui/windowAttentionHandler.js106
-rw-r--r--js/ui/windowManager.js2254
-rw-r--r--js/ui/windowMenu.js238
-rw-r--r--js/ui/windowPreview.js773
-rw-r--r--js/ui/workspace.js1353
-rw-r--r--js/ui/workspaceSwitcherPopup.js229
-rw-r--r--js/ui/workspaceThumbnail.js1362
-rw-r--r--js/ui/workspacesView.js805
-rw-r--r--js/ui/xdndHandler.js118
101 files changed, 57887 insertions, 0 deletions
diff --git a/js/ui/accessDialog.js b/js/ui/accessDialog.js
new file mode 100644
index 0000000..0e417bd
--- /dev/null
+++ b/js/ui/accessDialog.js
@@ -0,0 +1,156 @@
+/* exported AccessDialogDBus */
+const { Clutter, Gio, GLib, GObject, Shell, St } = imports.gi;
+
+const CheckBox = imports.ui.checkBox;
+const Dialog = imports.ui.dialog;
+const ModalDialog = imports.ui.modalDialog;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+const RequestIface = loadInterfaceXML('org.freedesktop.impl.portal.Request');
+const AccessIface = loadInterfaceXML('org.freedesktop.impl.portal.Access');
+
+var DialogResponse = {
+ OK: 0,
+ CANCEL: 1,
+ CLOSED: 2,
+};
+
+var AccessDialog = GObject.registerClass(
+class AccessDialog extends ModalDialog.ModalDialog {
+ _init(invocation, handle, title, description, body, options) {
+ super._init({ styleClass: 'access-dialog' });
+
+ this._invocation = invocation;
+ this._handle = handle;
+
+ this._requestExported = false;
+ this._request = Gio.DBusExportedObject.wrapJSObject(RequestIface, this);
+
+ for (let option in options)
+ options[option] = options[option].deep_unpack();
+
+ this._buildLayout(title, description, body, options);
+ }
+
+ _buildLayout(title, description, body, options) {
+ // No support for non-modal system dialogs, so ignore the option
+ // let modal = options['modal'] || true;
+ let denyLabel = options['deny_label'] || _("Deny Access");
+ let grantLabel = options['grant_label'] || _("Grant Access");
+ let choices = options['choices'] || [];
+
+ let content = new Dialog.MessageDialogContent({ title, description });
+ this.contentLayout.add_actor(content);
+
+ this._choices = new Map();
+
+ for (let i = 0; i < choices.length; i++) {
+ let [id, name, opts, selected] = choices[i];
+ if (opts.length > 0)
+ continue; // radio buttons, not implemented
+
+ let check = new CheckBox.CheckBox();
+ check.getLabelActor().text = name;
+ check.checked = selected == "true";
+ content.add_child(check);
+
+ this._choices.set(id, check);
+ }
+
+ let bodyLabel = new St.Label({
+ text: body,
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+ content.add_child(bodyLabel);
+
+ this.addButton({ label: denyLabel,
+ action: () => {
+ this._sendResponse(DialogResponse.CANCEL);
+ },
+ key: Clutter.KEY_Escape });
+ this.addButton({ label: grantLabel,
+ action: () => {
+ this._sendResponse(DialogResponse.OK);
+ } });
+ }
+
+ open() {
+ super.open();
+
+ let connection = this._invocation.get_connection();
+ this._requestExported = this._request.export(connection, this._handle);
+ }
+
+ CloseAsync(invocation, _params) {
+ if (this._invocation.get_sender() != invocation.get_sender()) {
+ invocation.return_error_literal(Gio.DBusError,
+ Gio.DBusError.ACCESS_DENIED,
+ '');
+ return;
+ }
+
+ this._sendResponse(DialogResponse.CLOSED);
+ }
+
+ _sendResponse(response) {
+ if (this._requestExported)
+ this._request.unexport();
+ this._requestExported = false;
+
+ let results = {};
+ if (response == DialogResponse.OK) {
+ for (let [id, check] of this._choices) {
+ let checked = check.checked ? 'true' : 'false';
+ results[id] = new GLib.Variant('s', checked);
+ }
+ }
+
+ // Delay actual response until the end of the close animation (if any)
+ this.connect('closed', () => {
+ this._invocation.return_value(new GLib.Variant('(ua{sv})',
+ [response, results]));
+ });
+ this.close();
+ }
+});
+
+var AccessDialogDBus = class {
+ constructor() {
+ this._accessDialog = null;
+
+ this._windowTracker = Shell.WindowTracker.get_default();
+
+ this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(AccessIface, this);
+ this._dbusImpl.export(Gio.DBus.session, '/org/freedesktop/portal/desktop');
+
+ Gio.DBus.session.own_name('org.freedesktop.impl.portal.desktop.gnome', Gio.BusNameOwnerFlags.REPLACE, null, null);
+ }
+
+ AccessDialogAsync(params, invocation) {
+ if (this._accessDialog) {
+ invocation.return_error_literal(Gio.DBusError,
+ Gio.DBusError.LIMITS_EXCEEDED,
+ 'Already showing a system access dialog');
+ return;
+ }
+
+ let [handle, appId, parentWindow_, title, description, body, options] = params;
+ // We probably want to use parentWindow and global.display.focus_window
+ // for this check in the future
+ if (appId && '%s.desktop'.format(appId) != this._windowTracker.focus_app.id) {
+ invocation.return_error_literal(Gio.DBusError,
+ Gio.DBusError.ACCESS_DENIED,
+ 'Only the focused app is allowed to show a system access dialog');
+ return;
+ }
+
+ let dialog = new AccessDialog(
+ invocation, handle, title, description, body, options);
+ dialog.open();
+
+ dialog.connect('closed', () => (this._accessDialog = null));
+
+ this._accessDialog = dialog;
+ }
+};
diff --git a/js/ui/altTab.js b/js/ui/altTab.js
new file mode 100644
index 0000000..25683b5
--- /dev/null
+++ b/js/ui/altTab.js
@@ -0,0 +1,1116 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported AppSwitcherPopup, GroupCyclerPopup, WindowSwitcherPopup,
+ WindowCyclerPopup */
+
+const { Atk, Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi;
+
+const Main = imports.ui.main;
+const SwitcherPopup = imports.ui.switcherPopup;
+
+var APP_ICON_HOVER_TIMEOUT = 200; // milliseconds
+
+var THUMBNAIL_DEFAULT_SIZE = 256;
+var THUMBNAIL_POPUP_TIME = 500; // milliseconds
+var THUMBNAIL_FADE_TIME = 100; // milliseconds
+
+var WINDOW_PREVIEW_SIZE = 128;
+var APP_ICON_SIZE = 96;
+var APP_ICON_SIZE_SMALL = 48;
+
+const baseIconSizes = [96, 64, 48, 32, 22];
+
+var AppIconMode = {
+ THUMBNAIL_ONLY: 1,
+ APP_ICON_ONLY: 2,
+ BOTH: 3,
+};
+
+function _createWindowClone(window, size) {
+ let [width, height] = window.get_size();
+ let scale = Math.min(1.0, size / width, size / height);
+ return new Clutter.Clone({ source: window,
+ width: width * scale,
+ height: height * scale,
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER,
+ // usual hack for the usual bug in ClutterBinLayout...
+ x_expand: true,
+ y_expand: true });
+}
+
+function getWindows(workspace) {
+ // We ignore skip-taskbar windows in switchers, but if they are attached
+ // to their parent, their position in the MRU list may be more appropriate
+ // than the parent; so start with the complete list ...
+ let windows = global.display.get_tab_list(Meta.TabList.NORMAL_ALL,
+ workspace);
+ // ... map windows to their parent where appropriate ...
+ return windows.map(w => {
+ return w.is_attached_dialog() ? w.get_transient_for() : w;
+ // ... and filter out skip-taskbar windows and duplicates
+ }).filter((w, i, a) => !w.skip_taskbar && a.indexOf(w) == i);
+}
+
+var AppSwitcherPopup = GObject.registerClass(
+class AppSwitcherPopup extends SwitcherPopup.SwitcherPopup {
+ _init() {
+ super._init();
+
+ this._thumbnails = null;
+ this._thumbnailTimeoutId = 0;
+ this._currentWindow = -1;
+
+ this.thumbnailsVisible = false;
+
+ let apps = Shell.AppSystem.get_default().get_running();
+
+ this._switcherList = new AppSwitcher(apps, this);
+ this._items = this._switcherList.icons;
+ }
+
+ vfunc_allocate(box) {
+ super.vfunc_allocate(box);
+
+ // Allocate the thumbnails
+ // We try to avoid overflowing the screen so we base the resulting size on
+ // those calculations
+ if (this._thumbnails) {
+ let childBox = this._switcherList.get_allocation_box();
+ let primary = Main.layoutManager.primaryMonitor;
+
+ let leftPadding = this.get_theme_node().get_padding(St.Side.LEFT);
+ let rightPadding = this.get_theme_node().get_padding(St.Side.RIGHT);
+ let bottomPadding = this.get_theme_node().get_padding(St.Side.BOTTOM);
+ let hPadding = leftPadding + rightPadding;
+
+ let icon = this._items[this._selectedIndex];
+ let [posX] = icon.get_transformed_position();
+ let thumbnailCenter = posX + icon.width / 2;
+ let [, childNaturalWidth] = this._thumbnails.get_preferred_width(-1);
+ childBox.x1 = Math.max(primary.x + leftPadding, Math.floor(thumbnailCenter - childNaturalWidth / 2));
+ if (childBox.x1 + childNaturalWidth > primary.x + primary.width - hPadding) {
+ let offset = childBox.x1 + childNaturalWidth - primary.width + hPadding;
+ childBox.x1 = Math.max(primary.x + leftPadding, childBox.x1 - offset - hPadding);
+ }
+
+ let spacing = this.get_theme_node().get_length('spacing');
+
+ childBox.x2 = childBox.x1 + childNaturalWidth;
+ if (childBox.x2 > primary.x + primary.width - rightPadding)
+ childBox.x2 = primary.x + primary.width - rightPadding;
+ childBox.y1 = this._switcherList.allocation.y2 + spacing;
+ this._thumbnails.addClones(primary.y + primary.height - bottomPadding - childBox.y1);
+ let [, childNaturalHeight] = this._thumbnails.get_preferred_height(-1);
+ childBox.y2 = childBox.y1 + childNaturalHeight;
+ this._thumbnails.allocate(childBox);
+ }
+ }
+
+ _initialSelection(backward, binding) {
+ if (binding == 'switch-group') {
+ if (backward)
+ this._select(0, this._items[0].cachedWindows.length - 1);
+ else if (this._items[0].cachedWindows.length > 1)
+ this._select(0, 1);
+ else
+ this._select(0, 0);
+ } else if (binding == 'switch-group-backward') {
+ this._select(0, this._items[0].cachedWindows.length - 1);
+ } else if (binding == 'switch-applications-backward') {
+ this._select(this._items.length - 1);
+ } else if (this._items.length == 1) {
+ this._select(0);
+ } else if (backward) {
+ this._select(this._items.length - 1);
+ } else {
+ this._select(1);
+ }
+ }
+
+ _nextWindow() {
+ // We actually want the second window if we're in the unset state
+ if (this._currentWindow == -1)
+ this._currentWindow = 0;
+ return SwitcherPopup.mod(this._currentWindow + 1,
+ this._items[this._selectedIndex].cachedWindows.length);
+ }
+
+ _previousWindow() {
+ // Also assume second window here
+ if (this._currentWindow == -1)
+ this._currentWindow = 1;
+ return SwitcherPopup.mod(this._currentWindow - 1,
+ this._items[this._selectedIndex].cachedWindows.length);
+ }
+
+ _closeAppWindow(appIndex, windowIndex) {
+ let appIcon = this._items[appIndex];
+ if (!appIcon)
+ return;
+
+ let window = appIcon.cachedWindows[windowIndex];
+ if (!window)
+ return;
+
+ window.delete(global.get_current_time());
+ }
+
+ _quitApplication(appIndex) {
+ let appIcon = this._items[appIndex];
+ if (!appIcon)
+ return;
+
+ appIcon.app.request_quit();
+ }
+
+ _keyPressHandler(keysym, action) {
+ if (action == Meta.KeyBindingAction.SWITCH_GROUP) {
+ if (!this._thumbnailsFocused)
+ this._select(this._selectedIndex, 0);
+ else
+ this._select(this._selectedIndex, this._nextWindow());
+ } else if (action == Meta.KeyBindingAction.SWITCH_GROUP_BACKWARD) {
+ this._select(this._selectedIndex, this._previousWindow());
+ } else if (action == Meta.KeyBindingAction.SWITCH_APPLICATIONS) {
+ this._select(this._next());
+ } else if (action == Meta.KeyBindingAction.SWITCH_APPLICATIONS_BACKWARD) {
+ this._select(this._previous());
+ } else if (keysym == Clutter.KEY_q || keysym === Clutter.KEY_Q) {
+ this._quitApplication(this._selectedIndex);
+ } else if (this._thumbnailsFocused) {
+ if (keysym === Clutter.KEY_Left)
+ this._select(this._selectedIndex, this._previousWindow());
+ else if (keysym === Clutter.KEY_Right)
+ this._select(this._selectedIndex, this._nextWindow());
+ else if (keysym === Clutter.KEY_Up)
+ this._select(this._selectedIndex, null, true);
+ else if (keysym === Clutter.KEY_w || keysym === Clutter.KEY_W || keysym === Clutter.KEY_F4)
+ this._closeAppWindow(this._selectedIndex, this._currentWindow);
+ else
+ return Clutter.EVENT_PROPAGATE;
+ } else if (keysym == Clutter.KEY_Left) {
+ this._select(this._previous());
+ } else if (keysym == Clutter.KEY_Right) {
+ this._select(this._next());
+ } else if (keysym == Clutter.KEY_Down) {
+ this._select(this._selectedIndex, 0);
+ } else {
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ return Clutter.EVENT_STOP;
+ }
+
+ _scrollHandler(direction) {
+ if (direction == Clutter.ScrollDirection.UP) {
+ if (this._thumbnailsFocused) {
+ if (this._currentWindow == 0 || this._currentWindow == -1)
+ this._select(this._previous());
+ else
+ this._select(this._selectedIndex, this._previousWindow());
+ } else {
+ let nwindows = this._items[this._selectedIndex].cachedWindows.length;
+ if (nwindows > 1)
+ this._select(this._selectedIndex, nwindows - 1);
+ else
+ this._select(this._previous());
+ }
+ } else if (direction == Clutter.ScrollDirection.DOWN) {
+ if (this._thumbnailsFocused) {
+ if (this._currentWindow == this._items[this._selectedIndex].cachedWindows.length - 1)
+ this._select(this._next());
+ else
+ this._select(this._selectedIndex, this._nextWindow());
+ } else {
+ let nwindows = this._items[this._selectedIndex].cachedWindows.length;
+ if (nwindows > 1)
+ this._select(this._selectedIndex, 0);
+ else
+ this._select(this._next());
+ }
+ }
+ }
+
+ _itemActivatedHandler(n) {
+ // If the user clicks on the selected app, activate the
+ // selected window; otherwise (eg, they click on an app while
+ // !mouseActive) activate the clicked-on app.
+ if (n == this._selectedIndex && this._currentWindow >= 0)
+ this._select(n, this._currentWindow);
+ else
+ this._select(n);
+ }
+
+ _itemEnteredHandler(n) {
+ this._select(n);
+ }
+
+ _windowActivated(thumbnailSwitcher, n) {
+ let appIcon = this._items[this._selectedIndex];
+ Main.activateWindow(appIcon.cachedWindows[n]);
+ this.fadeAndDestroy();
+ }
+
+ _windowEntered(thumbnailSwitcher, n) {
+ if (!this.mouseActive)
+ return;
+
+ this._select(this._selectedIndex, n);
+ }
+
+ _windowRemoved(thumbnailSwitcher, n) {
+ let appIcon = this._items[this._selectedIndex];
+ if (!appIcon)
+ return;
+
+ if (appIcon.cachedWindows.length > 0) {
+ let newIndex = Math.min(n, appIcon.cachedWindows.length - 1);
+ this._select(this._selectedIndex, newIndex);
+ }
+ }
+
+ _finish(timestamp) {
+ let appIcon = this._items[this._selectedIndex];
+ if (this._currentWindow < 0)
+ appIcon.app.activate_window(appIcon.cachedWindows[0], timestamp);
+ else if (appIcon.cachedWindows[this._currentWindow])
+ Main.activateWindow(appIcon.cachedWindows[this._currentWindow], timestamp);
+
+ super._finish(timestamp);
+ }
+
+ _onDestroy() {
+ if (this._thumbnailTimeoutId != 0)
+ GLib.source_remove(this._thumbnailTimeoutId);
+
+ super._onDestroy();
+ }
+
+ /**
+ * _select:
+ * @param {number} app: index of the app to select
+ * @param {number=} window: index of which of @app's windows to select
+ * @param {bool} forceAppFocus: optional flag, see below
+ *
+ * Selects the indicated @app, and optional @window, and sets
+ * this._thumbnailsFocused appropriately to indicate whether the
+ * arrow keys should act on the app list or the thumbnail list.
+ *
+ * If @app is specified and @window is unspecified or %null, then
+ * the app is highlighted (ie, given a light background), and the
+ * current thumbnail list, if any, is destroyed. If @app has
+ * multiple windows, and @forceAppFocus is not %true, then a
+ * timeout is started to open a thumbnail list.
+ *
+ * If @app and @window are specified (and @forceAppFocus is not),
+ * then @app will be outlined, a thumbnail list will be created
+ * and focused (if it hasn't been already), and the @window'th
+ * window in it will be highlighted.
+ *
+ * If @app and @window are specified and @forceAppFocus is %true,
+ * then @app will be highlighted, and @window outlined, and the
+ * app list will have the keyboard focus.
+ */
+ _select(app, window, forceAppFocus) {
+ if (app != this._selectedIndex || window == null) {
+ if (this._thumbnails)
+ this._destroyThumbnails();
+ }
+
+ if (this._thumbnailTimeoutId != 0) {
+ GLib.source_remove(this._thumbnailTimeoutId);
+ this._thumbnailTimeoutId = 0;
+ }
+
+ this._thumbnailsFocused = (window != null) && !forceAppFocus;
+
+ this._selectedIndex = app;
+ this._currentWindow = window ? window : -1;
+ this._switcherList.highlight(app, this._thumbnailsFocused);
+
+ if (window != null) {
+ if (!this._thumbnails)
+ this._createThumbnails();
+ this._currentWindow = window;
+ this._thumbnails.highlight(window, forceAppFocus);
+ } else if (this._items[this._selectedIndex].cachedWindows.length > 1 &&
+ !forceAppFocus) {
+ this._thumbnailTimeoutId = GLib.timeout_add(
+ GLib.PRIORITY_DEFAULT,
+ THUMBNAIL_POPUP_TIME,
+ this._timeoutPopupThumbnails.bind(this));
+ GLib.Source.set_name_by_id(this._thumbnailTimeoutId, '[gnome-shell] this._timeoutPopupThumbnails');
+ }
+ }
+
+ _timeoutPopupThumbnails() {
+ if (!this._thumbnails)
+ this._createThumbnails();
+ this._thumbnailTimeoutId = 0;
+ this._thumbnailsFocused = false;
+ return GLib.SOURCE_REMOVE;
+ }
+
+ _destroyThumbnails() {
+ let thumbnailsActor = this._thumbnails;
+ this._thumbnails.ease({
+ opacity: 0,
+ duration: THUMBNAIL_FADE_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ thumbnailsActor.destroy();
+ this.thumbnailsVisible = false;
+ },
+ });
+ this._thumbnails = null;
+ this._switcherList.removeAccessibleState(this._selectedIndex, Atk.StateType.EXPANDED);
+ }
+
+ _createThumbnails() {
+ this._thumbnails = new ThumbnailSwitcher(this._items[this._selectedIndex].cachedWindows);
+ this._thumbnails.connect('item-activated', this._windowActivated.bind(this));
+ this._thumbnails.connect('item-entered', this._windowEntered.bind(this));
+ this._thumbnails.connect('item-removed', this._windowRemoved.bind(this));
+ this._thumbnails.connect('destroy', () => {
+ this._thumbnails = null;
+ this._thumbnailsFocused = false;
+ });
+
+ this.add_actor(this._thumbnails);
+
+ // Need to force an allocation so we can figure out whether we
+ // need to scroll when selecting
+ this._thumbnails.get_allocation_box();
+
+ this._thumbnails.opacity = 0;
+ this._thumbnails.ease({
+ opacity: 255,
+ duration: THUMBNAIL_FADE_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ this.thumbnailsVisible = true;
+ },
+ });
+
+ this._switcherList.addAccessibleState(this._selectedIndex, Atk.StateType.EXPANDED);
+ }
+});
+
+var CyclerHighlight = GObject.registerClass(
+class CyclerHighlight extends St.Widget {
+ _init() {
+ super._init({ layout_manager: new Clutter.BinLayout() });
+ this._window = null;
+
+ this._clone = new Clutter.Clone();
+ this.add_actor(this._clone);
+
+ this._highlight = new St.Widget({ style_class: 'cycler-highlight' });
+ this.add_actor(this._highlight);
+
+ let coordinate = Clutter.BindCoordinate.ALL;
+ let constraint = new Clutter.BindConstraint({ coordinate });
+ this._clone.bind_property('source', constraint, 'source', 0);
+
+ this.add_constraint(constraint);
+
+ this.connect('notify::allocation', this._onAllocationChanged.bind(this));
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ set window(w) {
+ if (this._window == w)
+ return;
+
+ this._window = w;
+
+ if (this._clone.source)
+ this._clone.source.sync_visibility();
+
+ let windowActor = this._window
+ ? this._window.get_compositor_private() : null;
+
+ if (windowActor)
+ windowActor.hide();
+
+ this._clone.source = windowActor;
+ }
+
+ _onAllocationChanged() {
+ if (!this._window) {
+ this._highlight.set_size(0, 0);
+ this._highlight.hide();
+ } else {
+ let [x, y] = this.allocation.get_origin();
+ let rect = this._window.get_frame_rect();
+ this._highlight.set_size(rect.width, rect.height);
+ this._highlight.set_position(rect.x - x, rect.y - y);
+ this._highlight.show();
+ }
+ }
+
+ _onDestroy() {
+ this.window = null;
+ }
+});
+
+// We don't show an actual popup, so just provide what SwitcherPopup
+// expects instead of inheriting from SwitcherList
+var CyclerList = GObject.registerClass({
+ Signals: { 'item-activated': { param_types: [GObject.TYPE_INT] },
+ 'item-entered': { param_types: [GObject.TYPE_INT] },
+ 'item-removed': { param_types: [GObject.TYPE_INT] },
+ 'item-highlighted': { param_types: [GObject.TYPE_INT] } },
+}, class CyclerList extends St.Widget {
+ highlight(index, _justOutline) {
+ this.emit('item-highlighted', index);
+ }
+});
+
+var CyclerPopup = GObject.registerClass({
+ GTypeFlags: GObject.TypeFlags.ABSTRACT,
+}, class CyclerPopup extends SwitcherPopup.SwitcherPopup {
+ _init() {
+ super._init();
+
+ this._items = this._getWindows();
+
+ this._highlight = new CyclerHighlight();
+ global.window_group.add_actor(this._highlight);
+
+ this._switcherList = new CyclerList();
+ this._switcherList.connect('item-highlighted', (list, index) => {
+ this._highlightItem(index);
+ });
+ }
+
+ _highlightItem(index, _justOutline) {
+ this._highlight.window = this._items[index];
+ global.window_group.set_child_above_sibling(this._highlight, null);
+ }
+
+ _finish() {
+ let window = this._items[this._selectedIndex];
+ let ws = window.get_workspace();
+ let workspaceManager = global.workspace_manager;
+ let activeWs = workspaceManager.get_active_workspace();
+
+ if (window.minimized) {
+ Main.wm.skipNextEffect(window.get_compositor_private());
+ window.unminimize();
+ }
+
+ if (activeWs == ws) {
+ Main.activateWindow(window);
+ } else {
+ // If the selected window is on a different workspace, we don't
+ // want it to disappear, then slide in with the workspace; instead,
+ // always activate it on the active workspace ...
+ activeWs.activate_with_focus(window, global.get_current_time());
+
+ // ... then slide it over to the original workspace if necessary
+ Main.wm.actionMoveWindow(window, ws);
+ }
+
+ super._finish();
+ }
+
+ _onDestroy() {
+ this._highlight.destroy();
+
+ super._onDestroy();
+ }
+});
+
+
+var GroupCyclerPopup = GObject.registerClass(
+class GroupCyclerPopup extends CyclerPopup {
+ _init() {
+ this._settings = new Gio.Settings({ schema_id: 'org.gnome.shell.app-switcher' });
+ super._init();
+ }
+
+ _getWindows() {
+ let app = Shell.WindowTracker.get_default().focus_app;
+ let appWindows = app ? app.get_windows() : [];
+
+ if (this._settings.get_boolean('current-workspace-only')) {
+ const workspaceManager = global.workspace_manager;
+ const workspace = workspaceManager.get_active_workspace();
+ appWindows = appWindows.filter(
+ window => window.located_on_workspace(workspace));
+ }
+
+ return appWindows;
+ }
+
+ _keyPressHandler(keysym, action) {
+ if (action == Meta.KeyBindingAction.CYCLE_GROUP)
+ this._select(this._next());
+ else if (action == Meta.KeyBindingAction.CYCLE_GROUP_BACKWARD)
+ this._select(this._previous());
+ else
+ return Clutter.EVENT_PROPAGATE;
+
+ return Clutter.EVENT_STOP;
+ }
+});
+
+var WindowSwitcherPopup = GObject.registerClass(
+class WindowSwitcherPopup extends SwitcherPopup.SwitcherPopup {
+ _init() {
+ super._init();
+ this._settings = new Gio.Settings({ schema_id: 'org.gnome.shell.window-switcher' });
+
+ let windows = this._getWindowList();
+
+ let mode = this._settings.get_enum('app-icon-mode');
+ this._switcherList = new WindowSwitcher(windows, mode);
+ this._items = this._switcherList.icons;
+ }
+
+ _getWindowList() {
+ let workspace = null;
+
+ if (this._settings.get_boolean('current-workspace-only')) {
+ let workspaceManager = global.workspace_manager;
+
+ workspace = workspaceManager.get_active_workspace();
+ }
+
+ return getWindows(workspace);
+ }
+
+ _closeWindow(windowIndex) {
+ let windowIcon = this._items[windowIndex];
+ if (!windowIcon)
+ return;
+
+ windowIcon.window.delete(global.get_current_time());
+ }
+
+ _keyPressHandler(keysym, action) {
+ if (action == Meta.KeyBindingAction.SWITCH_WINDOWS)
+ this._select(this._next());
+ else if (action == Meta.KeyBindingAction.SWITCH_WINDOWS_BACKWARD)
+ this._select(this._previous());
+ else if (keysym == Clutter.KEY_Left)
+ this._select(this._previous());
+ else if (keysym == Clutter.KEY_Right)
+ this._select(this._next());
+ else if (keysym === Clutter.KEY_w || keysym === Clutter.KEY_W || keysym === Clutter.KEY_F4)
+ this._closeWindow(this._selectedIndex);
+ else
+ return Clutter.EVENT_PROPAGATE;
+
+ return Clutter.EVENT_STOP;
+ }
+
+ _finish() {
+ Main.activateWindow(this._items[this._selectedIndex].window);
+
+ super._finish();
+ }
+});
+
+var WindowCyclerPopup = GObject.registerClass(
+class WindowCyclerPopup extends CyclerPopup {
+ _init() {
+ this._settings = new Gio.Settings({ schema_id: 'org.gnome.shell.window-switcher' });
+ super._init();
+ }
+
+ _getWindows() {
+ let workspace = null;
+
+ if (this._settings.get_boolean('current-workspace-only')) {
+ let workspaceManager = global.workspace_manager;
+
+ workspace = workspaceManager.get_active_workspace();
+ }
+
+ return getWindows(workspace);
+ }
+
+ _keyPressHandler(keysym, action) {
+ if (action == Meta.KeyBindingAction.CYCLE_WINDOWS)
+ this._select(this._next());
+ else if (action == Meta.KeyBindingAction.CYCLE_WINDOWS_BACKWARD)
+ this._select(this._previous());
+ else
+ return Clutter.EVENT_PROPAGATE;
+
+ return Clutter.EVENT_STOP;
+ }
+});
+
+var AppIcon = GObject.registerClass(
+class AppIcon extends St.BoxLayout {
+ _init(app) {
+ super._init({ style_class: 'alt-tab-app',
+ vertical: true });
+
+ this.app = app;
+ this.icon = null;
+ this._iconBin = new St.Bin();
+
+ this.add_child(this._iconBin);
+ this.label = new St.Label({
+ text: this.app.get_name(),
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+ this.add_child(this.label);
+ }
+
+ // eslint-disable-next-line camelcase
+ set_size(size) {
+ this.icon = this.app.create_icon_texture(size);
+ this._iconBin.child = this.icon;
+ }
+});
+
+var AppSwitcher = GObject.registerClass(
+class AppSwitcher extends SwitcherPopup.SwitcherList {
+ _init(apps, altTabPopup) {
+ super._init(true);
+
+ this.icons = [];
+ this._arrows = [];
+
+ let windowTracker = Shell.WindowTracker.get_default();
+ let settings = new Gio.Settings({ schema_id: 'org.gnome.shell.app-switcher' });
+
+ let workspace = null;
+ if (settings.get_boolean('current-workspace-only')) {
+ let workspaceManager = global.workspace_manager;
+
+ workspace = workspaceManager.get_active_workspace();
+ }
+
+ let allWindows = global.display.get_tab_list(Meta.TabList.NORMAL, workspace);
+
+ // Construct the AppIcons, add to the popup
+ for (let i = 0; i < apps.length; i++) {
+ let appIcon = new AppIcon(apps[i]);
+ // Cache the window list now; we don't handle dynamic changes here,
+ // and we don't want to be continually retrieving it
+ appIcon.cachedWindows = allWindows.filter(
+ w => windowTracker.get_window_app(w) === appIcon.app);
+ if (appIcon.cachedWindows.length > 0)
+ this._addIcon(appIcon);
+ }
+
+ this._curApp = -1;
+ this._altTabPopup = altTabPopup;
+ this._mouseTimeOutId = 0;
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ _onDestroy() {
+ if (this._mouseTimeOutId != 0)
+ GLib.source_remove(this._mouseTimeOutId);
+
+ this.icons.forEach(icon => {
+ icon.app.disconnect(icon._stateChangedId);
+ });
+ }
+
+ _setIconSize() {
+ let j = 0;
+ while (this._items.length > 1 && this._items[j].style_class != 'item-box')
+ j++;
+
+ let themeNode = this._items[j].get_theme_node();
+ this._list.ensure_style();
+
+ let iconPadding = themeNode.get_horizontal_padding();
+ let iconBorder = themeNode.get_border_width(St.Side.LEFT) + themeNode.get_border_width(St.Side.RIGHT);
+ let [, labelNaturalHeight] = this.icons[j].label.get_preferred_height(-1);
+ let iconSpacing = labelNaturalHeight + iconPadding + iconBorder;
+ let totalSpacing = this._list.spacing * (this._items.length - 1);
+
+ // We just assume the whole screen here due to weirdness happening with the passed width
+ let primary = Main.layoutManager.primaryMonitor;
+ let parentPadding = this.get_parent().get_theme_node().get_horizontal_padding();
+ let availWidth = primary.width - parentPadding - this.get_theme_node().get_horizontal_padding();
+
+ let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ let iconSizes = baseIconSizes.map(s => s * scaleFactor);
+ let iconSize = baseIconSizes[0];
+
+ if (this._items.length > 1) {
+ for (let i = 0; i < baseIconSizes.length; i++) {
+ iconSize = baseIconSizes[i];
+ let height = iconSizes[i] + iconSpacing;
+ let w = height * this._items.length + totalSpacing;
+ if (w <= availWidth)
+ break;
+ }
+ }
+
+ this._iconSize = iconSize;
+
+ for (let i = 0; i < this.icons.length; i++) {
+ if (this.icons[i].icon != null)
+ break;
+ this.icons[i].set_size(iconSize);
+ }
+ }
+
+ vfunc_get_preferred_height(forWidth) {
+ this._setIconSize();
+ return super.vfunc_get_preferred_height(forWidth);
+ }
+
+ vfunc_allocate(box) {
+ // Allocate the main list items
+ super.vfunc_allocate(box);
+
+ let contentBox = this.get_theme_node().get_content_box(box);
+
+ let arrowHeight = Math.floor(this.get_theme_node().get_padding(St.Side.BOTTOM) / 3);
+ let arrowWidth = arrowHeight * 2;
+
+ // Now allocate each arrow underneath its item
+ let childBox = new Clutter.ActorBox();
+ for (let i = 0; i < this._items.length; i++) {
+ let itemBox = this._items[i].allocation;
+ childBox.x1 = contentBox.x1 + Math.floor(itemBox.x1 + (itemBox.x2 - itemBox.x1 - arrowWidth) / 2);
+ childBox.x2 = childBox.x1 + arrowWidth;
+ childBox.y1 = contentBox.y1 + itemBox.y2 + arrowHeight;
+ childBox.y2 = childBox.y1 + arrowHeight;
+ this._arrows[i].allocate(childBox);
+ }
+ }
+
+ // We override SwitcherList's _onItemEnter method to delay
+ // activation when the thumbnail list is open
+ _onItemEnter(item) {
+ const index = this._items.indexOf(item);
+
+ if (this._mouseTimeOutId != 0)
+ GLib.source_remove(this._mouseTimeOutId);
+ if (this._altTabPopup.thumbnailsVisible) {
+ this._mouseTimeOutId = GLib.timeout_add(
+ GLib.PRIORITY_DEFAULT,
+ APP_ICON_HOVER_TIMEOUT,
+ () => {
+ this._enterItem(index);
+ this._mouseTimeOutId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._mouseTimeOutId, '[gnome-shell] this._enterItem');
+ } else {
+ this._itemEntered(index);
+ }
+ }
+
+ _enterItem(index) {
+ let [x, y] = global.get_pointer();
+ let pickedActor = global.stage.get_actor_at_pos(Clutter.PickMode.ALL, x, y);
+ if (this._items[index].contains(pickedActor))
+ this._itemEntered(index);
+ }
+
+ // We override SwitcherList's highlight() method to also deal with
+ // the AppSwitcher->ThumbnailSwitcher arrows. Apps with only 1 window
+ // will hide their arrows by default, but show them when their
+ // thumbnails are visible (ie, when the app icon is supposed to be
+ // in justOutline mode). Apps with multiple windows will normally
+ // show a dim arrow, but show a bright arrow when they are
+ // highlighted.
+ highlight(n, justOutline) {
+ if (this.icons[this._curApp]) {
+ if (this.icons[this._curApp].cachedWindows.length == 1)
+ this._arrows[this._curApp].hide();
+ else
+ this._arrows[this._curApp].remove_style_pseudo_class('highlighted');
+ }
+
+ super.highlight(n, justOutline);
+ this._curApp = n;
+
+ if (this._curApp != -1) {
+ if (justOutline && this.icons[this._curApp].cachedWindows.length == 1)
+ this._arrows[this._curApp].show();
+ else
+ this._arrows[this._curApp].add_style_pseudo_class('highlighted');
+ }
+ }
+
+ _addIcon(appIcon) {
+ this.icons.push(appIcon);
+ let item = this.addItem(appIcon, appIcon.label);
+
+ appIcon._stateChangedId = appIcon.app.connect('notify::state', app => {
+ if (app.state != Shell.AppState.RUNNING)
+ this._removeIcon(app);
+ });
+
+ let arrow = new St.DrawingArea({ style_class: 'switcher-arrow' });
+ arrow.connect('repaint', () => SwitcherPopup.drawArrow(arrow, St.Side.BOTTOM));
+ this.add_actor(arrow);
+ this._arrows.push(arrow);
+
+ if (appIcon.cachedWindows.length == 1)
+ arrow.hide();
+ else
+ item.add_accessible_state(Atk.StateType.EXPANDABLE);
+ }
+
+ _removeIcon(app) {
+ let index = this.icons.findIndex(icon => {
+ return icon.app == app;
+ });
+ if (index === -1)
+ return;
+
+ this._arrows[index].destroy();
+ this._arrows.splice(index, 1);
+
+ this.icons.splice(index, 1);
+ this.removeItem(index);
+ }
+});
+
+var ThumbnailSwitcher = GObject.registerClass(
+class ThumbnailSwitcher extends SwitcherPopup.SwitcherList {
+ _init(windows) {
+ super._init(false);
+
+ this._labels = [];
+ this._thumbnailBins = [];
+ this._clones = [];
+ this._windows = windows;
+
+ for (let i = 0; i < windows.length; i++) {
+ let box = new St.BoxLayout({ style_class: 'thumbnail-box',
+ vertical: true });
+
+ let bin = new St.Bin({ style_class: 'thumbnail' });
+
+ box.add_actor(bin);
+ this._thumbnailBins.push(bin);
+
+ let title = windows[i].get_title();
+ if (title) {
+ let name = new St.Label({
+ text: title,
+ // St.Label doesn't support text-align
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+ this._labels.push(name);
+ box.add_actor(name);
+
+ this.addItem(box, name);
+ } else {
+ this.addItem(box, null);
+ }
+
+ }
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ addClones(availHeight) {
+ if (!this._thumbnailBins.length)
+ return;
+ let totalPadding = this._items[0].get_theme_node().get_horizontal_padding() + this._items[0].get_theme_node().get_vertical_padding();
+ totalPadding += this.get_theme_node().get_horizontal_padding() + this.get_theme_node().get_vertical_padding();
+ let [, labelNaturalHeight] = this._labels[0].get_preferred_height(-1);
+ let spacing = this._items[0].child.get_theme_node().get_length('spacing');
+ let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ let thumbnailSize = THUMBNAIL_DEFAULT_SIZE * scaleFactor;
+
+ availHeight = Math.min(availHeight - labelNaturalHeight - totalPadding - spacing, thumbnailSize);
+ let binHeight = availHeight + this._items[0].get_theme_node().get_vertical_padding() + this.get_theme_node().get_vertical_padding() - spacing;
+ binHeight = Math.min(thumbnailSize, binHeight);
+
+ for (let i = 0; i < this._thumbnailBins.length; i++) {
+ let mutterWindow = this._windows[i].get_compositor_private();
+ if (!mutterWindow)
+ continue;
+
+ let clone = _createWindowClone(mutterWindow, thumbnailSize);
+ this._thumbnailBins[i].set_height(binHeight);
+ this._thumbnailBins[i].add_actor(clone);
+
+ clone._destroyId = mutterWindow.connect('destroy', source => {
+ this._removeThumbnail(source, clone);
+ });
+ this._clones.push(clone);
+ }
+
+ // Make sure we only do this once
+ this._thumbnailBins = [];
+ }
+
+ _removeThumbnail(source, clone) {
+ let index = this._clones.indexOf(clone);
+ if (index === -1)
+ return;
+
+ this._clones.splice(index, 1);
+ this._windows.splice(index, 1);
+ this._labels.splice(index, 1);
+ this.removeItem(index);
+
+ if (this._clones.length > 0)
+ this.highlight(SwitcherPopup.mod(index, this._clones.length));
+ else
+ this.destroy();
+ }
+
+ _onDestroy() {
+ this._clones.forEach(clone => {
+ if (clone.source)
+ clone.source.disconnect(clone._destroyId);
+ });
+ }
+});
+
+var WindowIcon = GObject.registerClass(
+class WindowIcon extends St.BoxLayout {
+ _init(window, mode) {
+ super._init({ style_class: 'alt-tab-app',
+ vertical: true });
+
+ this.window = window;
+
+ this._icon = new St.Widget({ layout_manager: new Clutter.BinLayout() });
+
+ this.add_child(this._icon);
+ this.label = new St.Label({ text: window.get_title() });
+
+ let tracker = Shell.WindowTracker.get_default();
+ this.app = tracker.get_window_app(window);
+
+ let mutterWindow = this.window.get_compositor_private();
+ let size;
+
+ this._icon.destroy_all_children();
+
+ let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+
+ switch (mode) {
+ case AppIconMode.THUMBNAIL_ONLY:
+ size = WINDOW_PREVIEW_SIZE;
+ this._icon.add_actor(_createWindowClone(mutterWindow, size * scaleFactor));
+ break;
+
+ case AppIconMode.BOTH:
+ size = WINDOW_PREVIEW_SIZE;
+ this._icon.add_actor(_createWindowClone(mutterWindow, size * scaleFactor));
+
+ if (this.app) {
+ this._icon.add_actor(this._createAppIcon(this.app,
+ APP_ICON_SIZE_SMALL));
+ }
+ break;
+
+ case AppIconMode.APP_ICON_ONLY:
+ size = APP_ICON_SIZE;
+ this._icon.add_actor(this._createAppIcon(this.app, size));
+ }
+
+ this._icon.set_size(size * scaleFactor, size * scaleFactor);
+ }
+
+ _createAppIcon(app, size) {
+ let appIcon = app
+ ? app.create_icon_texture(size)
+ : new St.Icon({ icon_name: 'icon-missing', icon_size: size });
+ appIcon.x_expand = appIcon.y_expand = true;
+ appIcon.x_align = appIcon.y_align = Clutter.ActorAlign.END;
+
+ return appIcon;
+ }
+});
+
+var WindowSwitcher = GObject.registerClass(
+class WindowSwitcher extends SwitcherPopup.SwitcherList {
+ _init(windows, mode) {
+ super._init(true);
+
+ this._label = new St.Label({ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER });
+ this.add_actor(this._label);
+
+ this.windows = windows;
+ this.icons = [];
+
+ for (let i = 0; i < windows.length; i++) {
+ let win = windows[i];
+ let icon = new WindowIcon(win, mode);
+
+ this.addItem(icon, icon.label);
+ this.icons.push(icon);
+
+ icon._unmanagedSignalId = icon.window.connect('unmanaged', window => {
+ this._removeWindow(window);
+ });
+ }
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ _onDestroy() {
+ this.icons.forEach(icon => {
+ icon.window.disconnect(icon._unmanagedSignalId);
+ });
+ }
+
+ vfunc_get_preferred_height(forWidth) {
+ let [minHeight, natHeight] = super.vfunc_get_preferred_height(forWidth);
+
+ let spacing = this.get_theme_node().get_padding(St.Side.BOTTOM);
+ let [labelMin, labelNat] = this._label.get_preferred_height(-1);
+
+ minHeight += labelMin + spacing;
+ natHeight += labelNat + spacing;
+
+ return [minHeight, natHeight];
+ }
+
+ vfunc_allocate(box) {
+ let themeNode = this.get_theme_node();
+ let contentBox = themeNode.get_content_box(box);
+ const labelHeight = this._label.height;
+ const totalLabelHeight =
+ labelHeight + themeNode.get_padding(St.Side.BOTTOM);
+
+ box.y2 -= totalLabelHeight;
+ super.vfunc_allocate(box);
+
+ // Hooking up the parent vfunc will call this.set_allocation() with
+ // the height without the label height, so call it again with the
+ // correct size here.
+ box.y2 += totalLabelHeight;
+ this.set_allocation(box);
+
+ const childBox = new Clutter.ActorBox();
+ childBox.x1 = contentBox.x1;
+ childBox.x2 = contentBox.x2;
+ childBox.y2 = contentBox.y2;
+ childBox.y1 = childBox.y2 - labelHeight;
+ this._label.allocate(childBox);
+ }
+
+ highlight(index, justOutline) {
+ super.highlight(index, justOutline);
+
+ this._label.set_text(index == -1 ? '' : this.icons[index].label.text);
+ }
+
+ _removeWindow(window) {
+ let index = this.icons.findIndex(icon => {
+ return icon.window == window;
+ });
+ if (index === -1)
+ return;
+
+ this.icons.splice(index, 1);
+ this.removeItem(index);
+ }
+});
diff --git a/js/ui/animation.js b/js/ui/animation.js
new file mode 100644
index 0000000..5cb3a83
--- /dev/null
+++ b/js/ui/animation.js
@@ -0,0 +1,201 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Animation, AnimatedIcon, Spinner */
+
+const { Clutter, GLib, GObject, Gio, St } = imports.gi;
+
+const Params = imports.misc.params;
+
+var ANIMATED_ICON_UPDATE_TIMEOUT = 16;
+var SPINNER_ANIMATION_TIME = 300;
+var SPINNER_ANIMATION_DELAY = 1000;
+
+var Animation = GObject.registerClass(
+class Animation extends St.Bin {
+ _init(file, width, height, speed) {
+ const themeContext = St.ThemeContext.get_for_stage(global.stage);
+
+ super._init({
+ style: `width: ${width}px; height: ${height}px;`,
+ });
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ this.connect('resource-scale-changed',
+ this._loadFile.bind(this, file, width, height));
+
+ this._scaleChangedId = themeContext.connect('notify::scale-factor',
+ () => {
+ this._loadFile(file, width, height);
+ this.set_size(width * themeContext.scale_factor, height * themeContext.scale_factor);
+ });
+
+ this._speed = speed;
+
+ this._isLoaded = false;
+ this._isPlaying = false;
+ this._timeoutId = 0;
+ this._frame = 0;
+
+ this._loadFile(file, width, height);
+ }
+
+ play() {
+ if (this._isLoaded && this._timeoutId == 0) {
+ if (this._frame == 0)
+ this._showFrame(0);
+
+ this._timeoutId = GLib.timeout_add(GLib.PRIORITY_LOW, this._speed, this._update.bind(this));
+ GLib.Source.set_name_by_id(this._timeoutId, '[gnome-shell] this._update');
+ }
+
+ this._isPlaying = true;
+ }
+
+ stop() {
+ if (this._timeoutId > 0) {
+ GLib.source_remove(this._timeoutId);
+ this._timeoutId = 0;
+ }
+
+ this._isPlaying = false;
+ }
+
+ _loadFile(file, width, height) {
+ const resourceScale = this.get_resource_scale();
+ let wasPlaying = this._isPlaying;
+
+ if (this._isPlaying)
+ this.stop();
+
+ this._isLoaded = false;
+ this.destroy_all_children();
+
+ let textureCache = St.TextureCache.get_default();
+ let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ this._animations = textureCache.load_sliced_image(file, width, height,
+ scaleFactor, resourceScale,
+ this._animationsLoaded.bind(this));
+ this._animations.set({
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ this.set_child(this._animations);
+
+ if (wasPlaying)
+ this.play();
+ }
+
+ _showFrame(frame) {
+ let oldFrameActor = this._animations.get_child_at_index(this._frame);
+ if (oldFrameActor)
+ oldFrameActor.hide();
+
+ this._frame = frame % this._animations.get_n_children();
+
+ let newFrameActor = this._animations.get_child_at_index(this._frame);
+ if (newFrameActor)
+ newFrameActor.show();
+ }
+
+ _update() {
+ this._showFrame(this._frame + 1);
+ return GLib.SOURCE_CONTINUE;
+ }
+
+ _syncAnimationSize() {
+ if (!this._isLoaded)
+ return;
+
+ let [width, height] = this.get_size();
+
+ for (let i = 0; i < this._animations.get_n_children(); ++i)
+ this._animations.get_child_at_index(i).set_size(width, height);
+ }
+
+ _animationsLoaded() {
+ this._isLoaded = this._animations.get_n_children() > 0;
+
+ this._syncAnimationSize();
+
+ if (this._isPlaying)
+ this.play();
+ }
+
+ _onDestroy() {
+ this.stop();
+
+ let themeContext = St.ThemeContext.get_for_stage(global.stage);
+ if (this._scaleChangedId)
+ themeContext.disconnect(this._scaleChangedId);
+ this._scaleChangedId = 0;
+ }
+});
+
+var AnimatedIcon = GObject.registerClass(
+class AnimatedIcon extends Animation {
+ _init(file, size) {
+ super._init(file, size, size, ANIMATED_ICON_UPDATE_TIMEOUT);
+ }
+});
+
+var Spinner = GObject.registerClass(
+class Spinner extends AnimatedIcon {
+ _init(size, params) {
+ params = Params.parse(params, {
+ animate: false,
+ hideOnStop: false,
+ });
+ let file = Gio.File.new_for_uri('resource:///org/gnome/shell/theme/process-working.svg');
+ super._init(file, size);
+
+ this.opacity = 0;
+ this._animate = params.animate;
+ this._hideOnStop = params.hideOnStop;
+ this.visible = !this._hideOnStop;
+ }
+
+ _onDestroy() {
+ this._animate = false;
+ super._onDestroy();
+ }
+
+ play() {
+ this.remove_all_transitions();
+ this.show();
+
+ if (this._animate) {
+ super.play();
+ this.ease({
+ opacity: 255,
+ delay: SPINNER_ANIMATION_DELAY,
+ duration: SPINNER_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.LINEAR,
+ });
+ } else {
+ this.opacity = 255;
+ super.play();
+ }
+ }
+
+ stop() {
+ this.remove_all_transitions();
+
+ if (this._animate) {
+ this.ease({
+ opacity: 0,
+ duration: SPINNER_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.LINEAR,
+ onComplete: () => {
+ super.stop();
+ if (this._hideOnStop)
+ this.hide();
+ },
+ });
+ } else {
+ this.opacity = 0;
+ super.stop();
+
+ if (this._hideOnStop)
+ this.hide();
+ }
+ }
+});
diff --git a/js/ui/appDisplay.js b/js/ui/appDisplay.js
new file mode 100644
index 0000000..399ab54
--- /dev/null
+++ b/js/ui/appDisplay.js
@@ -0,0 +1,2998 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported AppDisplay, AppSearchProvider */
+
+const { Clutter, Gio, GLib, GObject, Graphene, Meta, Shell, St } = imports.gi;
+const Signals = imports.signals;
+
+const AppFavorites = imports.ui.appFavorites;
+const DND = imports.ui.dnd;
+const GrabHelper = imports.ui.grabHelper;
+const IconGrid = imports.ui.iconGrid;
+const Layout = imports.ui.layout;
+const Main = imports.ui.main;
+const PageIndicators = imports.ui.pageIndicators;
+const ParentalControlsManager = imports.misc.parentalControlsManager;
+const PopupMenu = imports.ui.popupMenu;
+const Search = imports.ui.search;
+const SwipeTracker = imports.ui.swipeTracker;
+const Params = imports.misc.params;
+const SystemActions = imports.misc.systemActions;
+
+var MENU_POPUP_TIMEOUT = 600;
+var POPDOWN_DIALOG_TIMEOUT = 500;
+
+var FOLDER_SUBICON_FRACTION = .4;
+
+var VIEWS_SWITCH_TIME = 400;
+var VIEWS_SWITCH_ANIMATION_DELAY = 100;
+
+var SCROLL_TIMEOUT_TIME = 150;
+
+var APP_ICON_SCALE_IN_TIME = 500;
+var APP_ICON_SCALE_IN_DELAY = 700;
+
+const FOLDER_DIALOG_ANIMATION_TIME = 200;
+
+const OVERSHOOT_THRESHOLD = 20;
+const OVERSHOOT_TIMEOUT = 1000;
+
+const DELAYED_MOVE_TIMEOUT = 200;
+
+const DIALOG_SHADE_NORMAL = Clutter.Color.from_pixel(0x000000cc);
+const DIALOG_SHADE_HIGHLIGHT = Clutter.Color.from_pixel(0x00000055);
+
+let discreteGpuAvailable = false;
+
+function _getCategories(info) {
+ let categoriesStr = info.get_categories();
+ if (!categoriesStr)
+ return [];
+ return categoriesStr.split(';');
+}
+
+function _listsIntersect(a, b) {
+ for (let itemA of a) {
+ if (b.includes(itemA))
+ return true;
+ }
+ return false;
+}
+
+function _getFolderName(folder) {
+ let name = folder.get_string('name');
+
+ if (folder.get_boolean('translate')) {
+ let translated = Shell.util_get_translated_folder_name(name);
+ if (translated !== null)
+ return translated;
+ }
+
+ return name;
+}
+
+function _getViewFromIcon(icon) {
+ for (let parent = icon.get_parent(); parent; parent = parent.get_parent()) {
+ if (parent instanceof BaseAppView)
+ return parent;
+ }
+ return null;
+}
+
+function _findBestFolderName(apps) {
+ let appInfos = apps.map(app => app.get_app_info());
+
+ let categoryCounter = {};
+ let commonCategories = [];
+
+ appInfos.reduce((categories, appInfo) => {
+ for (let category of _getCategories(appInfo)) {
+ if (!(category in categoryCounter))
+ categoryCounter[category] = 0;
+
+ categoryCounter[category] += 1;
+
+ // If a category is present in all apps, its counter will
+ // reach appInfos.length
+ if (category.length > 0 &&
+ categoryCounter[category] == appInfos.length)
+ categories.push(category);
+ }
+ return categories;
+ }, commonCategories);
+
+ for (let category of commonCategories) {
+ const directory = '%s.directory'.format(category);
+ const translated = Shell.util_get_translated_folder_name(directory);
+ if (translated !== null)
+ return translated;
+ }
+
+ return null;
+}
+
+var BaseAppView = GObject.registerClass({
+ GTypeFlags: GObject.TypeFlags.ABSTRACT,
+ Properties: {
+ 'use-pagination': GObject.ParamSpec.boolean(
+ 'use-pagination', 'use-pagination', 'use-pagination',
+ GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
+ false),
+ },
+ Signals: {
+ 'view-loaded': {},
+ },
+}, class BaseAppView extends St.Widget {
+ _init(params = {}, orientation = Clutter.Orientation.VERTICAL) {
+ super._init(params);
+
+ this._grid = this._createGrid();
+ this._grid._delegate = this;
+ // Standard hack for ClutterBinLayout
+ this._grid.x_expand = true;
+ this._grid.connect('pages-changed', () => {
+ this._adjustment.value = 0;
+ this.goToPage(this._grid.currentPage);
+ this._pageIndicators.setNPages(this._grid.nPages);
+ this._pageIndicators.setCurrentPosition(this._grid.currentPage);
+ });
+
+ const vertical = orientation === Clutter.Orientation.VERTICAL;
+
+ // Scroll View
+ this._scrollView = new St.ScrollView({
+ clip_to_allocation: true,
+ x_expand: true,
+ y_expand: true,
+ reactive: true,
+ });
+ this._scrollView.set_policy(
+ vertical ? St.PolicyType.NEVER : St.PolicyType.EXTERNAL,
+ vertical ? St.PolicyType.EXTERNAL : St.PolicyType.NEVER);
+
+ this._canScroll = true; // limiting scrolling speed
+ this._scrollTimeoutId = 0;
+ this._scrollView.connect('scroll-event', this._onScroll.bind(this));
+
+ this._scrollView.add_actor(this._grid);
+
+ const scroll = vertical ? this._scrollView.vscroll : this._scrollView.hscroll;
+ this._adjustment = scroll.adjustment;
+ this._adjustment.connect('notify::value', adj => {
+ this._pageIndicators.setCurrentPosition(adj.value / adj.page_size);
+ });
+
+ // Page Indicators
+ if (vertical)
+ this._pageIndicators = new PageIndicators.AnimatedPageIndicators();
+ else
+ this._pageIndicators = new PageIndicators.PageIndicators(orientation);
+
+ this._pageIndicators.y_expand = vertical;
+ this._pageIndicators.connect('page-activated',
+ (indicators, pageIndex) => {
+ this.goToPage(pageIndex);
+ });
+ this._pageIndicators.connect('scroll-event', (actor, event) => {
+ this._scrollView.event(event, false);
+ });
+
+ // Swipe
+ this._swipeTracker = new SwipeTracker.SwipeTracker(this._scrollView,
+ Shell.ActionMode.OVERVIEW | Shell.ActionMode.POPUP);
+ this._swipeTracker.orientation = orientation;
+ this._swipeTracker.connect('begin', this._swipeBegin.bind(this));
+ this._swipeTracker.connect('update', this._swipeUpdate.bind(this));
+ this._swipeTracker.connect('end', this._swipeEnd.bind(this));
+
+ this._availWidth = 0;
+ this._availHeight = 0;
+ this._orientation = orientation;
+
+ this._items = new Map();
+ this._orderedItems = [];
+
+ this._animateLaterId = 0;
+ this._viewLoadedHandlerId = 0;
+ this._viewIsReady = false;
+
+ // Filter the apps through the user’s parental controls.
+ this._parentalControlsManager = ParentalControlsManager.getDefault();
+ this._appFilterChangedId =
+ this._parentalControlsManager.connect('app-filter-changed', () => {
+ this._redisplay();
+ });
+
+ // Drag n' Drop
+ this._lastOvershoot = -1;
+ this._lastOvershootTimeoutId = 0;
+ this._delayedMoveData = null;
+
+ this._dragBeginId = 0;
+ this._dragEndId = 0;
+ this._dragCancelledId = 0;
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ _onDestroy() {
+ if (this._appFilterChangedId > 0) {
+ this._parentalControlsManager.disconnect(this._appFilterChangedId);
+ this._appFilterChangedId = 0;
+ }
+
+ if (this._swipeTracker) {
+ this._swipeTracker.destroy();
+ delete this._swipeTracker;
+ }
+
+ this._removeDelayedMove();
+ this._disconnectDnD();
+ }
+
+ _createGrid() {
+ return new IconGrid.IconGrid({ allow_incomplete_pages: true });
+ }
+
+ _onScroll(actor, event) {
+ if (this._swipeTracker.canHandleScrollEvent(event))
+ return Clutter.EVENT_PROPAGATE;
+
+ if (!this._canScroll)
+ return Clutter.EVENT_STOP;
+
+ const rtl = this.get_text_direction() === Clutter.TextDirection.RTL;
+ const vertical = this._orientation === Clutter.Orientation.VERTICAL;
+
+ let nextPage = this._grid.currentPage;
+ switch (event.get_scroll_direction()) {
+ case Clutter.ScrollDirection.UP:
+ nextPage -= 1;
+ break;
+
+ case Clutter.ScrollDirection.DOWN:
+ nextPage += 1;
+ break;
+
+ case Clutter.ScrollDirection.LEFT:
+ if (vertical)
+ return Clutter.EVENT_STOP;
+ nextPage += rtl ? 1 : -1;
+ break;
+
+ case Clutter.ScrollDirection.RIGHT:
+ if (vertical)
+ return Clutter.EVENT_STOP;
+ nextPage += rtl ? -1 : 1;
+ break;
+
+ default:
+ return Clutter.EVENT_STOP;
+ }
+
+ this.goToPage(nextPage);
+
+ this._canScroll = false;
+ this._scrollTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT,
+ SCROLL_TIMEOUT_TIME, () => {
+ this._canScroll = true;
+ this._scrollTimeoutId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+
+ return Clutter.EVENT_STOP;
+ }
+
+ _swipeBegin(tracker, monitor) {
+ if (monitor !== Main.layoutManager.primaryIndex)
+ return;
+
+ const adjustment = this._adjustment;
+ adjustment.remove_transition('value');
+
+ const progress = adjustment.value / adjustment.page_size;
+ const points = Array.from({ length: this._grid.nPages }, (v, i) => i);
+ const size = tracker.orientation === Clutter.Orientation.VERTICAL
+ ? this._scrollView.height : this._scrollView.width;
+
+ tracker.confirmSwipe(size, points, progress, Math.round(progress));
+ }
+
+ _swipeUpdate(tracker, progress) {
+ const adjustment = this._adjustment;
+ adjustment.value = progress * adjustment.page_size;
+ }
+
+ _swipeEnd(tracker, duration, endProgress) {
+ const adjustment = this._adjustment;
+ const value = endProgress * adjustment.page_size;
+
+ adjustment.ease(value, {
+ mode: Clutter.AnimationMode.EASE_OUT_CUBIC,
+ duration,
+ onComplete: () => this.goToPage(endProgress, false),
+ });
+ }
+
+ _connectDnD() {
+ this._dragBeginId =
+ Main.overview.connect('item-drag-begin', this._onDragBegin.bind(this));
+ this._dragEndId =
+ Main.overview.connect('item-drag-end', this._onDragEnd.bind(this));
+ this._dragCancelledId =
+ Main.overview.connect('item-drag-cancelled', this._onDragCancelled.bind(this));
+ }
+
+ _disconnectDnD() {
+ if (this._dragBeginId > 0) {
+ Main.overview.disconnect(this._dragBeginId);
+ this._dragBeginId = 0;
+ }
+
+ if (this._dragEndId > 0) {
+ Main.overview.disconnect(this._dragEndId);
+ this._dragEndId = 0;
+ }
+
+ if (this._dragCancelledId > 0) {
+ Main.overview.disconnect(this._dragCancelledId);
+ this._dragCancelledId = 0;
+ }
+
+ if (this._dragMonitor) {
+ DND.removeDragMonitor(this._dragMonitor);
+ this._dragMonitor = null;
+ }
+ }
+
+ _maybeMoveItem(dragEvent) {
+ const [success, x, y] =
+ this._grid.transform_stage_point(dragEvent.x, dragEvent.y);
+
+ if (!success)
+ return;
+
+ const { source } = dragEvent;
+ const [page, position, dragLocation] =
+ this._getDropTarget(x, y, source);
+ const item = position !== -1
+ ? this._grid.getItemAt(page, position) : null;
+
+
+ // Dragging over invalid parts of the grid cancels the timeout
+ if (item === source ||
+ dragLocation === IconGrid.DragLocation.INVALID ||
+ dragLocation === IconGrid.DragLocation.ON_ICON) {
+ this._removeDelayedMove();
+ return;
+ }
+
+ if (!this._delayedMoveData ||
+ this._delayedMoveData.page !== page ||
+ this._delayedMoveData.position !== position) {
+ // Update the item with a small delay
+ this._removeDelayedMove();
+ this._delayedMoveData = {
+ page,
+ position,
+ source,
+ destroyId: source.connect('destroy', () => this._removeDelayedMove()),
+ timeoutId: GLib.timeout_add(GLib.PRIORITY_DEFAULT,
+ DELAYED_MOVE_TIMEOUT, () => {
+ this._moveItem(source, page, position);
+ this._delayedMoveData.timeoutId = 0;
+ this._removeDelayedMove();
+ return GLib.SOURCE_REMOVE;
+ }),
+ };
+ }
+ }
+
+ _removeDelayedMove() {
+ if (!this._delayedMoveData)
+ return;
+
+ const { source, destroyId, timeoutId } = this._delayedMoveData;
+
+ if (timeoutId > 0)
+ GLib.source_remove(timeoutId);
+
+ if (destroyId > 0)
+ source.disconnect(destroyId);
+
+ this._delayedMoveData = null;
+ }
+
+ _resetOvershoot() {
+ if (this._lastOvershootTimeoutId)
+ GLib.source_remove(this._lastOvershootTimeoutId);
+ this._lastOvershootTimeoutId = 0;
+ this._lastOvershoot = -1;
+ }
+
+ _handleDragOvershoot(dragEvent) {
+ const [gridX, gridY] = this.get_transformed_position();
+ const [gridWidth, gridHeight] = this.get_transformed_size();
+
+ const vertical = this._orientation === Clutter.Orientation.VERTICAL;
+ const gridStart = vertical ? gridY : gridX;
+ const gridEnd = vertical
+ ? gridY + gridHeight - OVERSHOOT_THRESHOLD
+ : gridX + gridWidth - OVERSHOOT_THRESHOLD;
+
+ // Already animating
+ if (this._adjustment.get_transition('value') !== null)
+ return;
+
+ // Within the grid boundaries
+ const dragPosition = vertical ? dragEvent.y : dragEvent.x;
+ if (dragPosition > gridStart && dragPosition < gridEnd) {
+ // Check whether we moved out the area of the last switch
+ if (Math.abs(this._lastOvershoot - dragPosition) > OVERSHOOT_THRESHOLD)
+ this._resetOvershoot();
+
+ return;
+ }
+
+ // Still in the area of the previous page switch
+ if (this._lastOvershoot >= 0)
+ return;
+
+ const currentPosition = this._adjustment.value;
+ const maxPosition = this._adjustment.upper - this._adjustment.page_size;
+
+ if (dragPosition <= gridStart && currentPosition > 0)
+ this.goToPage(this._grid.currentPage - 1);
+ else if (dragPosition >= gridEnd && currentPosition < maxPosition)
+ this.goToPage(this._grid.currentPage + 1);
+ else
+ return; // don't go beyond first/last page
+
+ this._lastOvershoot = dragPosition;
+
+ if (this._lastOvershootTimeoutId > 0)
+ GLib.source_remove(this._lastOvershootTimeoutId);
+
+ this._lastOvershootTimeoutId =
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT, OVERSHOOT_TIMEOUT, () => {
+ this._resetOvershoot();
+ this._handleDragOvershoot(dragEvent);
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._lastOvershootTimeoutId,
+ '[gnome-shell] this._lastOvershootTimeoutId');
+ }
+
+ _onDragBegin() {
+ this._dragMonitor = {
+ dragMotion: this._onDragMotion.bind(this),
+ };
+ DND.addDragMonitor(this._dragMonitor);
+ }
+
+ _onDragMotion(dragEvent) {
+ if (!(dragEvent.source instanceof AppViewItem))
+ return DND.DragMotionResult.CONTINUE;
+
+ const appIcon = dragEvent.source;
+
+ // Handle the drag overshoot. When dragging to above the
+ // icon grid, move to the page above; when dragging below,
+ // move to the page below.
+ if (appIcon instanceof AppViewItem)
+ this._handleDragOvershoot(dragEvent);
+
+ this._maybeMoveItem(dragEvent);
+
+ return DND.DragMotionResult.CONTINUE;
+ }
+
+ _onDragEnd() {
+ if (this._dragMonitor) {
+ DND.removeDragMonitor(this._dragMonitor);
+ this._dragMonitor = null;
+ }
+
+ this._resetOvershoot();
+ }
+
+ _onDragCancelled() {
+ // At this point, the positions aren't stored yet, thus _redisplay()
+ // will move all items to their original positions
+ this._redisplay();
+ }
+
+ _canAccept(source) {
+ return source instanceof AppViewItem;
+ }
+
+ handleDragOver(source) {
+ if (!this._canAccept(source))
+ return DND.DragMotionResult.NO_DROP;
+
+ return DND.DragMotionResult.MOVE_DROP;
+ }
+
+ acceptDrop(source) {
+ if (!this._canAccept(source))
+ return false;
+
+ // Dropped before the icon was moved
+ if (this._delayedMoveData) {
+ const { page, position } = this._delayedMoveData;
+
+ this._moveItem(source, page, position);
+ this._removeDelayedMove();
+ }
+
+ return true;
+ }
+
+ _findBestPageToAppend(startPage = 1) {
+ for (let i = startPage; i < this._grid.nPages; i++) {
+ const pageItems =
+ this._grid.getItemsAtPage(i).filter(c => c.visible);
+
+ if (pageItems.length < this._grid.itemsPerPage)
+ return i;
+ }
+
+ return -1;
+ }
+
+ _getLinearPosition(page, position) {
+ let itemIndex = 0;
+
+ if (this._grid.nPages > 0) {
+ const realPage = page === -1 ? this._grid.nPages - 1 : page;
+
+ itemIndex = position === -1
+ ? this._grid.getItemsAtPage(realPage).filter(c => c.visible).length - 1
+ : position;
+
+ for (let i = 0; i < realPage; i++) {
+ const pageItems = this._grid.getItemsAtPage(i).filter(c => c.visible);
+ itemIndex += pageItems.length;
+ }
+ }
+
+ return itemIndex;
+ }
+
+ _addItem(item, page, position) {
+ // Append icons to the first page with empty slot, starting from
+ // the second page
+ if (this._grid.nPages > 1 && page === -1 && position === -1)
+ page = this._findBestPageToAppend();
+
+ const itemIndex = this._getLinearPosition(page, position);
+
+ this._orderedItems.splice(itemIndex, 0, item);
+ this._items.set(item.id, item);
+ this._grid.addItem(item, page, position);
+ }
+
+ _removeItem(item) {
+ const iconIndex = this._orderedItems.indexOf(item);
+
+ this._orderedItems.splice(iconIndex, 1);
+ this._items.delete(item.id);
+ this._grid.removeItem(item);
+ }
+
+ _getItemPosition(item) {
+ const { itemsPerPage } = this._grid;
+
+ let iconIndex = this._orderedItems.indexOf(item);
+ if (iconIndex === -1)
+ iconIndex = this._orderedItems.length - 1;
+
+ const page = Math.floor(iconIndex / itemsPerPage);
+ const position = iconIndex % itemsPerPage;
+
+ return [page, position];
+ }
+
+ _redisplay() {
+ let oldApps = this._orderedItems.slice();
+ let oldAppIds = oldApps.map(icon => icon.id);
+
+ let newApps = this._loadApps().sort(this._compareItems.bind(this));
+ let newAppIds = newApps.map(icon => icon.id);
+
+ let addedApps = newApps.filter(icon => !oldAppIds.includes(icon.id));
+ let removedApps = oldApps.filter(icon => !newAppIds.includes(icon.id));
+
+ // Remove old app icons
+ removedApps.forEach(icon => {
+ this._removeItem(icon);
+ icon.destroy();
+ });
+
+ // Add new app icons, or move existing ones
+ newApps.forEach(icon => {
+ const [page, position] = this._getItemPosition(icon);
+ if (addedApps.includes(icon))
+ this._addItem(icon, page, position);
+ else if (page !== -1 && position !== -1)
+ this._moveItem(icon, page, position);
+ });
+
+ this._viewIsReady = true;
+ this.emit('view-loaded');
+ }
+
+ getAllItems() {
+ return this._orderedItems;
+ }
+
+ _compareItems(a, b) {
+ return a.name.localeCompare(b.name);
+ }
+
+ _selectAppInternal(id) {
+ if (this._items.has(id))
+ this._items.get(id).navigate_focus(null, St.DirectionType.TAB_FORWARD, false);
+ else
+ log('No such application %s'.format(id));
+ }
+
+ selectApp(id) {
+ if (this._items.has(id)) {
+ let item = this._items.get(id);
+
+ if (item.mapped) {
+ this._selectAppInternal(id);
+ } else {
+ // Need to wait until the view is mapped
+ let signalId = item.connect('notify::mapped', actor => {
+ if (actor.mapped) {
+ actor.disconnect(signalId);
+ this._selectAppInternal(id);
+ }
+ });
+ }
+ } else {
+ // Need to wait until the view is built
+ let signalId = this.connect('view-loaded', () => {
+ this.disconnect(signalId);
+ this.selectApp(id);
+ });
+ }
+ }
+
+ _doSpringAnimation(animationDirection) {
+ this._grid.opacity = 255;
+ this._grid.animateSpring(
+ animationDirection,
+ Main.overview.dash.showAppsButton);
+ }
+
+ _clearAnimateLater() {
+ if (this._animateLaterId) {
+ Meta.later_remove(this._animateLaterId);
+ this._animateLaterId = 0;
+ }
+ if (this._viewLoadedHandlerId) {
+ this.disconnect(this._viewLoadedHandlerId);
+ this._viewLoadedHandlerId = 0;
+ }
+ }
+
+ animate(animationDirection, onComplete) {
+ if (onComplete) {
+ let animationDoneId = this._grid.connect('animation-done', () => {
+ this._grid.disconnect(animationDoneId);
+ onComplete();
+ });
+ }
+
+ this._clearAnimateLater();
+ this._grid.opacity = 255;
+
+ if (animationDirection == IconGrid.AnimationDirection.IN) {
+ const doSpringAnimationLater = laterType => {
+ this._animateLaterId = Meta.later_add(laterType,
+ () => {
+ this._animateLaterId = 0;
+ this._doSpringAnimation(animationDirection);
+ return GLib.SOURCE_REMOVE;
+ });
+ };
+
+ if (this._viewIsReady) {
+ this._grid.opacity = 0;
+ doSpringAnimationLater(Meta.LaterType.IDLE);
+ } else {
+ this._viewLoadedHandlerId = this.connect('view-loaded',
+ () => {
+ this._clearAnimateLater();
+ this._grid.opacity = 255;
+ doSpringAnimationLater(Meta.LaterType.BEFORE_REDRAW);
+ });
+ }
+ } else {
+ this._doSpringAnimation(animationDirection);
+ }
+ }
+
+ _getDropTarget(x, y, source) {
+ const { currentPage } = this._grid;
+
+ let [item, dragLocation] = this._grid.getDropTarget(x, y);
+
+ const [sourcePage, sourcePosition] = this._grid.getItemPosition(source);
+ const targetPage = currentPage;
+ let targetPosition = item
+ ? this._grid.getItemPosition(item)[1] : -1;
+
+ // In case we're hovering over the edge of an item but the
+ // reflow will happen in the opposite direction (the drag
+ // can't "naturally push the item away"), we instead set the
+ // drop target to the adjacent item that can be pushed away
+ // in the reflow-direction.
+ //
+ // We must avoid doing that if we're hovering over the first
+ // or last column though, in that case there is no adjacent
+ // icon we could push away.
+ if (dragLocation === IconGrid.DragLocation.START_EDGE &&
+ targetPosition > sourcePosition &&
+ targetPage === sourcePage) {
+ const nColumns = this._grid.layout_manager.columns_per_page;
+ const targetColumn = targetPosition % nColumns;
+
+ if (targetColumn > 0) {
+ targetPosition -= 1;
+ dragLocation = IconGrid.DragLocation.END_EDGE;
+ }
+ } else if (dragLocation === IconGrid.DragLocation.END_EDGE &&
+ (targetPosition < sourcePosition ||
+ targetPage !== sourcePage)) {
+ const nColumns = this._grid.layout_manager.columns_per_page;
+ const targetColumn = targetPosition % nColumns;
+
+ if (targetColumn < nColumns - 1) {
+ targetPosition += 1;
+ dragLocation = IconGrid.DragLocation.START_EDGE;
+ }
+ }
+
+ // Append to the page if dragging over empty area
+ if (dragLocation === IconGrid.DragLocation.EMPTY_SPACE) {
+ const pageItems =
+ this._grid.getItemsAtPage(currentPage).filter(c => c.visible);
+
+ targetPosition = pageItems.length;
+ }
+
+ return [targetPage, targetPosition, dragLocation];
+ }
+
+ _moveItem(item, newPage, newPosition) {
+ const [page, position] = this._grid.getItemPosition(item);
+ if (page === newPage && position === newPosition)
+ return;
+
+ // Update the _orderedItems array
+ let index = this._orderedItems.indexOf(item);
+ this._orderedItems.splice(index, 1);
+
+ index = this._getLinearPosition(newPage, newPosition);
+ this._orderedItems.splice(index, 0, item);
+
+ this._grid.moveItem(item, newPage, newPosition);
+ }
+
+ vfunc_allocate(box) {
+ const width = box.get_width();
+ const height = box.get_height();
+
+ this.adaptToSize(width, height);
+
+ super.vfunc_allocate(box);
+ }
+
+ vfunc_map() {
+ this._swipeTracker.enabled = true;
+ this._connectDnD();
+ super.vfunc_map();
+ }
+
+ vfunc_unmap() {
+ this._swipeTracker.enabled = false;
+ this._clearAnimateLater();
+ this._disconnectDnD();
+ super.vfunc_unmap();
+ }
+
+ animateSwitch(animationDirection) {
+ this.remove_all_transitions();
+ this._grid.remove_all_transitions();
+
+ let params = {
+ duration: VIEWS_SWITCH_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ };
+ if (animationDirection == IconGrid.AnimationDirection.IN) {
+ this.show();
+ params.opacity = 255;
+ params.delay = VIEWS_SWITCH_ANIMATION_DELAY;
+ } else {
+ params.opacity = 0;
+ params.delay = 0;
+ params.onComplete = () => this.hide();
+ }
+
+ this._grid.ease(params);
+ }
+
+ goToPage(pageNumber, animate = true) {
+ pageNumber = Math.clamp(pageNumber, 0, this._grid.nPages - 1);
+
+ if (this._grid.currentPage === pageNumber)
+ return;
+
+ this._grid.goToPage(pageNumber, animate);
+ }
+
+ adaptToSize(width, height) {
+ let box = new Clutter.ActorBox({
+ x2: width,
+ y2: height,
+ });
+ box = this._scrollView.get_theme_node().get_content_box(box);
+ box = this._grid.get_theme_node().get_content_box(box);
+
+ const availWidth = box.get_width();
+ const availHeight = box.get_height();
+
+ this._grid.adaptToSize(availWidth, availHeight);
+
+ this._availWidth = availWidth;
+ this._availHeight = availHeight;
+ }
+});
+
+var PageManager = GObject.registerClass({
+ Signals: { 'layout-changed': {} },
+}, class PageManager extends GObject.Object {
+ _init() {
+ super._init();
+
+ this._updatingPages = false;
+ this._loadPages();
+
+ global.settings.connect('changed::app-picker-layout',
+ this._loadPages.bind(this));
+
+ }
+
+ _loadPages() {
+ const layout = global.settings.get_value('app-picker-layout');
+ this._pages = layout.recursiveUnpack();
+ if (!this._updatingPages)
+ this.emit('layout-changed');
+ }
+
+ getAppPosition(appId) {
+ let position = -1;
+ let page = -1;
+
+ for (let pageIndex = 0; pageIndex < this._pages.length; pageIndex++) {
+ const pageData = this._pages[pageIndex];
+
+ if (appId in pageData) {
+ page = pageIndex;
+ position = pageData[appId].position;
+ break;
+ }
+ }
+
+ return [page, position];
+ }
+
+ set pages(p) {
+ const packedPages = [];
+
+ // Pack the icon properties as a GVariant
+ for (const page of p) {
+ const pageData = {};
+ for (const [appId, properties] of Object.entries(page))
+ pageData[appId] = new GLib.Variant('a{sv}', properties);
+ packedPages.push(pageData);
+ }
+
+ this._updatingPages = true;
+
+ const variant = new GLib.Variant('aa{sv}', packedPages);
+ global.settings.set_value('app-picker-layout', variant);
+
+ this._updatingPages = false;
+ }
+
+ get pages() {
+ return this._pages;
+ }
+});
+
+var AppDisplay = GObject.registerClass(
+class AppDisplay extends BaseAppView {
+ _init() {
+ super._init({
+ layout_manager: new Clutter.BinLayout(),
+ x_expand: true,
+ y_expand: true,
+ });
+
+ this._pageManager = new PageManager();
+ this._pageManager.connect('layout-changed', () => this._redisplay());
+
+ this._scrollView.add_style_class_name('all-apps');
+
+ this._stack = new St.Widget({
+ layout_manager: new Clutter.BinLayout(),
+ x_expand: true,
+ y_expand: true,
+ });
+ this.add_actor(this._stack);
+ this._stack.add_actor(this._scrollView);
+
+ this.add_actor(this._pageIndicators);
+
+ this._folderIcons = [];
+
+ this._currentDialog = null;
+ this._displayingDialog = false;
+ this._currentDialogDestroyId = 0;
+
+ this._placeholder = null;
+
+ Main.overview.connect('hidden', () => this.goToPage(0));
+
+ this._redisplayWorkId = Main.initializeDeferredWork(this, this._redisplay.bind(this));
+
+ Shell.AppSystem.get_default().connect('installed-changed', () => {
+ this._viewIsReady = false;
+ Main.queueDeferredWork(this._redisplayWorkId);
+ });
+ this._folderSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.app-folders' });
+ this._folderSettings.connect('changed::folder-children', () => {
+ this._viewIsReady = false;
+ Main.queueDeferredWork(this._redisplayWorkId);
+ });
+
+ this._switcherooNotifyId = global.connect('notify::switcheroo-control',
+ () => this._updateDiscreteGpuAvailable());
+ this._updateDiscreteGpuAvailable();
+ }
+
+ _updateDiscreteGpuAvailable() {
+ this._switcherooProxy = global.get_switcheroo_control();
+ if (this._switcherooProxy) {
+ let prop = this._switcherooProxy.get_cached_property('HasDualGpu');
+ discreteGpuAvailable = prop ? prop.unpack() : false;
+ } else {
+ discreteGpuAvailable = false;
+ }
+ }
+
+ _onDestroy() {
+ super._onDestroy();
+
+ if (this._scrollTimeoutId !== 0) {
+ GLib.source_remove(this._scrollTimeoutId);
+ this._scrollTimeoutId = 0;
+ }
+ }
+
+ vfunc_map() {
+ this._keyPressEventId =
+ global.stage.connect('key-press-event',
+ this._onKeyPressEvent.bind(this));
+ super.vfunc_map();
+ }
+
+ vfunc_unmap() {
+ if (this._keyPressEventId) {
+ global.stage.disconnect(this._keyPressEventId);
+ this._keyPressEventId = 0;
+ }
+ super.vfunc_unmap();
+ }
+
+ _redisplay() {
+ this._folderIcons.forEach(icon => {
+ icon.view._redisplay();
+ });
+
+ super._redisplay();
+ }
+
+ _savePages() {
+ const pages = [];
+
+ for (let i = 0; i < this._grid.nPages; i++) {
+ const pageItems =
+ this._grid.getItemsAtPage(i).filter(c => c.visible);
+ const pageData = {};
+
+ pageItems.forEach((item, index) => {
+ pageData[item.id] = {
+ position: GLib.Variant.new_int32(index),
+ };
+ });
+ pages.push(pageData);
+ }
+
+ this._pageManager.pages = pages;
+ }
+
+ _ensurePlaceholder(source) {
+ if (this._placeholder)
+ return;
+
+ const appSys = Shell.AppSystem.get_default();
+ const app = appSys.lookup_app(source.id);
+
+ const isDraggable =
+ global.settings.is_writable('favorite-apps') ||
+ global.settings.is_writable('app-picker-layout');
+
+ this._placeholder = new AppIcon(app, { isDraggable });
+ this._placeholder.scaleAndFade();
+ this._redisplay();
+ }
+
+ _removePlaceholder() {
+ if (this._placeholder) {
+ this._placeholder.undoScaleAndFade();
+ this._placeholder = null;
+ this._redisplay();
+ }
+ }
+
+ getAppInfos() {
+ return this._appInfoList;
+ }
+
+ _getItemPosition(item) {
+ if (item === this._placeholder) {
+ let [page, position] = this._grid.getItemPosition(item);
+
+ if (page === -1)
+ page = this._findBestPageToAppend(this._grid.currentPage);
+
+ return [page, position];
+ }
+
+ return this._pageManager.getAppPosition(item.id);
+ }
+
+ _compareItems(a, b) {
+ const [aPage, aPosition] = this._getItemPosition(a);
+ const [bPage, bPosition] = this._getItemPosition(b);
+
+ if (aPage === -1 && bPage === -1)
+ return a.name.localeCompare(b.name);
+ else if (aPage === -1)
+ return 1;
+ else if (bPage === -1)
+ return -1;
+
+ if (aPage !== bPage)
+ return aPage - bPage;
+
+ return aPosition - bPosition;
+ }
+
+ _loadApps() {
+ let appIcons = [];
+ this._appInfoList = Shell.AppSystem.get_default().get_installed().filter(appInfo => {
+ try {
+ appInfo.get_id(); // catch invalid file encodings
+ } catch (e) {
+ return false;
+ }
+ return this._parentalControlsManager.shouldShowApp(appInfo);
+ });
+
+ let apps = this._appInfoList.map(app => app.get_id());
+
+ let appSys = Shell.AppSystem.get_default();
+
+ const appsInsideFolders = new Set();
+ this._folderIcons = [];
+
+ let folders = this._folderSettings.get_strv('folder-children');
+ folders.forEach(id => {
+ let path = '%sfolders/%s/'.format(this._folderSettings.path, id);
+ let icon = this._items.get(id);
+ if (!icon) {
+ icon = new FolderIcon(id, path, this);
+ icon.connect('apps-changed', () => {
+ this._redisplay();
+ this._savePages();
+ });
+ }
+
+ // Don't try to display empty folders
+ if (!icon.visible) {
+ icon.destroy();
+ return;
+ }
+
+ appIcons.push(icon);
+ this._folderIcons.push(icon);
+
+ icon.getAppIds().forEach(appId => appsInsideFolders.add(appId));
+ });
+
+ // Allow dragging of the icon only if the Dash would accept a drop to
+ // change favorite-apps. There are no other possible drop targets from
+ // the app picker, so there's no other need for a drag to start,
+ // at least on single-monitor setups.
+ // This also disables drag-to-launch on multi-monitor setups,
+ // but we hope that is not used much.
+ const isDraggable =
+ global.settings.is_writable('favorite-apps') ||
+ global.settings.is_writable('app-picker-layout');
+
+ apps.forEach(appId => {
+ if (appsInsideFolders.has(appId))
+ return;
+
+ let icon = this._items.get(appId);
+ if (!icon) {
+ let app = appSys.lookup_app(appId);
+
+ icon = new AppIcon(app, { isDraggable });
+ }
+
+ appIcons.push(icon);
+ });
+
+ // At last, if there's a placeholder available, add it
+ if (this._placeholder)
+ appIcons.push(this._placeholder);
+
+ return appIcons;
+ }
+
+ // Overridden from BaseAppView
+ animate(animationDirection, onComplete) {
+ this._scrollView.reactive = false;
+ this._swipeTracker.enabled = false;
+ let completionFunc = () => {
+ this._scrollView.reactive = true;
+ this._swipeTracker.enabled = this.mapped;
+ if (onComplete)
+ onComplete();
+ };
+
+ if (animationDirection == IconGrid.AnimationDirection.OUT &&
+ this._displayingDialog && this._currentDialog) {
+ this._currentDialog.popdown();
+ } else {
+ super.animate(animationDirection, completionFunc);
+ if (animationDirection == IconGrid.AnimationDirection.OUT)
+ this._pageIndicators.animateIndicators(animationDirection);
+ }
+ }
+
+ animateSwitch(animationDirection) {
+ super.animateSwitch(animationDirection);
+
+ if (this._currentDialog && this._displayingDialog &&
+ animationDirection == IconGrid.AnimationDirection.OUT) {
+ this._currentDialog.ease({
+ opacity: 0,
+ duration: VIEWS_SWITCH_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => (this.opacity = 255),
+ });
+ }
+
+ if (animationDirection == IconGrid.AnimationDirection.OUT)
+ this._pageIndicators.animateIndicators(animationDirection);
+ }
+
+ goToPage(pageNumber, animate = true) {
+ pageNumber = Math.clamp(pageNumber, 0, this._grid.nPages - 1);
+
+ if (this._grid.currentPage === pageNumber &&
+ this._displayingDialog &&
+ this._currentDialog)
+ return;
+ if (this._displayingDialog && this._currentDialog)
+ this._currentDialog.popdown();
+
+ super.goToPage(pageNumber, animate);
+ }
+
+ _onScroll(actor, event) {
+ if (this._displayingDialog || !this._scrollView.reactive)
+ return Clutter.EVENT_STOP;
+
+ return super._onScroll(actor, event);
+ }
+
+ _onKeyPressEvent(actor, event) {
+ if (this._displayingDialog)
+ return Clutter.EVENT_STOP;
+
+ if (event.get_key_symbol() === Clutter.KEY_Page_Up) {
+ this.goToPage(this._grid.currentPage - 1);
+ return Clutter.EVENT_STOP;
+ } else if (event.get_key_symbol() === Clutter.KEY_Page_Down) {
+ this.goToPage(this._grid.currentPage + 1);
+ return Clutter.EVENT_STOP;
+ }
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ addFolderDialog(dialog) {
+ Main.layoutManager.overviewGroup.add_child(dialog);
+ dialog.connect('open-state-changed', (o, isOpen) => {
+ if (this._currentDialog) {
+ this._currentDialog.disconnect(this._currentDialogDestroyId);
+ this._currentDialogDestroyId = 0;
+ }
+
+ this._currentDialog = null;
+
+ if (isOpen) {
+ this._currentDialog = dialog;
+ this._currentDialogDestroyId = dialog.connect('destroy', () => {
+ this._currentDialog = null;
+ this._currentDialogDestroyId = 0;
+ });
+ }
+ this._displayingDialog = isOpen;
+ });
+ }
+
+ _maybeMoveItem(dragEvent) {
+ const clonedEvent = {
+ ...dragEvent,
+ source: this._placeholder ? this._placeholder : dragEvent.source,
+ };
+
+ super._maybeMoveItem(clonedEvent);
+ }
+
+ _onDragBegin(overview, source) {
+ super._onDragBegin(overview, source);
+
+ // When dragging from a folder dialog, the dragged app icon doesn't
+ // exist in AppDisplay. We work around that by adding a placeholder
+ // icon that is either destroyed on cancel, or becomes the effective
+ // new icon when dropped.
+ if (_getViewFromIcon(source) instanceof FolderView)
+ this._ensurePlaceholder(source);
+ }
+
+ _onDragMotion(dragEvent) {
+ if (this._currentDialog)
+ return DND.DragMotionResult.CONTINUE;
+
+ return super._onDragMotion(dragEvent);
+ }
+
+ _onDragEnd() {
+ super._onDragEnd();
+ this._removePlaceholder();
+ }
+
+ _onDragCancelled(overview, source) {
+ const view = _getViewFromIcon(source);
+
+ if (view instanceof FolderView)
+ return;
+
+ super._onDragCancelled(overview, source);
+ }
+
+ acceptDrop(source) {
+ if (!super.acceptDrop(source))
+ return false;
+
+ this._savePages();
+
+ let view = _getViewFromIcon(source);
+ if (view instanceof FolderView)
+ view.removeApp(source.app);
+
+ if (this._currentDialog)
+ this._currentDialog.popdown();
+
+ return true;
+ }
+
+ createFolder(apps) {
+ let newFolderId = GLib.uuid_string_random();
+
+ let folders = this._folderSettings.get_strv('folder-children');
+ folders.push(newFolderId);
+ this._folderSettings.set_strv('folder-children', folders);
+
+ // Create the new folder
+ let newFolderPath = this._folderSettings.path.concat('folders/', newFolderId, '/');
+ let newFolderSettings = new Gio.Settings({
+ schema_id: 'org.gnome.desktop.app-folders.folder',
+ path: newFolderPath,
+ });
+ if (!newFolderSettings) {
+ log('Error creating new folder');
+ return false;
+ }
+
+ // The hovered AppIcon always passes its own id as the first
+ // one, and this is where we want the folder to be created
+ let [folderPage, folderPosition] =
+ this._grid.getItemPosition(this._items.get(apps[0]));
+
+ // Adjust the final position
+ folderPosition -= apps.reduce((counter, appId) => {
+ const [page, position] =
+ this._grid.getItemPosition(this._items.get(appId));
+ if (page === folderPage && position < folderPosition)
+ counter++;
+ return counter;
+ }, 0);
+
+ let appItems = apps.map(id => this._items.get(id).app);
+ let folderName = _findBestFolderName(appItems);
+ if (!folderName)
+ folderName = _("Unnamed Folder");
+
+ newFolderSettings.delay();
+ newFolderSettings.set_string('name', folderName);
+ newFolderSettings.set_strv('apps', apps);
+ newFolderSettings.apply();
+
+ this._redisplay();
+
+ // Move the folder to where the icon target icon was
+ const folderItem = this._items.get(newFolderId);
+ this._moveItem(folderItem, folderPage, folderPosition);
+ this._savePages();
+
+ return true;
+ }
+});
+
+var AppSearchProvider = class AppSearchProvider {
+ constructor() {
+ this._appSys = Shell.AppSystem.get_default();
+ this.id = 'applications';
+ this.isRemoteProvider = false;
+ this.canLaunchSearch = false;
+
+ this._systemActions = new SystemActions.getDefault();
+
+ this._parentalControlsManager = ParentalControlsManager.getDefault();
+ }
+
+ getResultMetas(apps, callback) {
+ let metas = [];
+ for (let id of apps) {
+ if (id.endsWith('.desktop')) {
+ let app = this._appSys.lookup_app(id);
+
+ metas.push({
+ id: app.get_id(),
+ name: app.get_name(),
+ createIcon: size => app.create_icon_texture(size),
+ });
+ } else {
+ let name = this._systemActions.getName(id);
+ let iconName = this._systemActions.getIconName(id);
+
+ let createIcon = size => new St.Icon({ icon_name: iconName,
+ width: size,
+ height: size,
+ style_class: 'system-action-icon' });
+
+ metas.push({ id, name, createIcon });
+ }
+ }
+
+ callback(metas);
+ }
+
+ filterResults(results, maxNumber) {
+ return results.slice(0, maxNumber);
+ }
+
+ getInitialResultSet(terms, callback, _cancellable) {
+ // Defer until the parental controls manager is initialised, so the
+ // results can be filtered correctly.
+ if (!this._parentalControlsManager.initialized) {
+ let initializedId = this._parentalControlsManager.connect('app-filter-changed', () => {
+ if (this._parentalControlsManager.initialized) {
+ this._parentalControlsManager.disconnect(initializedId);
+ this.getInitialResultSet(terms, callback, _cancellable);
+ }
+ });
+ return;
+ }
+
+ let query = terms.join(' ');
+ let groups = Shell.AppSystem.search(query);
+ let usage = Shell.AppUsage.get_default();
+ let results = [];
+
+ groups.forEach(group => {
+ group = group.filter(appID => {
+ const app = this._appSys.lookup_app(appID);
+ return app && this._parentalControlsManager.shouldShowApp(app.app_info);
+ });
+ results = results.concat(group.sort(
+ (a, b) => usage.compare(a, b)));
+ });
+
+ results = results.concat(this._systemActions.getMatchingActions(terms));
+
+ callback(results);
+ }
+
+ getSubsearchResultSet(previousResults, terms, callback, cancellable) {
+ this.getInitialResultSet(terms, callback, cancellable);
+ }
+
+ createResultObject(resultMeta) {
+ if (resultMeta.id.endsWith('.desktop'))
+ return new AppIcon(this._appSys.lookup_app(resultMeta['id']));
+ else
+ return new SystemActionIcon(this, resultMeta);
+ }
+};
+
+var AppViewItem = GObject.registerClass(
+class AppViewItem extends St.Button {
+ _init(params = {}, isDraggable = true) {
+ super._init({
+ pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }),
+ reactive: true,
+ button_mask: St.ButtonMask.ONE | St.ButtonMask.TWO,
+ can_focus: true,
+ ...params,
+ });
+
+ this._delegate = this;
+
+ if (isDraggable) {
+ this._draggable = DND.makeDraggable(this);
+ this._draggable.connect('drag-begin', this._onDragBegin.bind(this));
+ this._draggable.connect('drag-cancelled', this._onDragCancelled.bind(this));
+ this._draggable.connect('drag-end', this._onDragEnd.bind(this));
+ }
+
+ this._otherIconIsHovering = false;
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ _onDestroy() {
+ if (this._dragMonitor) {
+ DND.removeDragMonitor(this._dragMonitor);
+ this._dragMonitor = null;
+ }
+
+ if (this._draggable) {
+ if (this._dragging)
+ Main.overview.endItemDrag(this);
+ this._draggable = null;
+ }
+ }
+
+ _onDragBegin() {
+ this._dragging = true;
+ this.scaleAndFade();
+ Main.overview.beginItemDrag(this);
+ }
+
+ _onDragCancelled() {
+ this._dragging = false;
+ Main.overview.cancelledItemDrag(this);
+ }
+
+ _onDragEnd() {
+ this._dragging = false;
+ this.undoScaleAndFade();
+ Main.overview.endItemDrag(this);
+ }
+
+ scaleIn() {
+ this.scale_x = 0;
+ this.scale_y = 0;
+
+ this.ease({
+ scale_x: 1,
+ scale_y: 1,
+ duration: APP_ICON_SCALE_IN_TIME,
+ delay: APP_ICON_SCALE_IN_DELAY,
+ mode: Clutter.AnimationMode.EASE_OUT_QUINT,
+ });
+ }
+
+ scaleAndFade() {
+ this.reactive = false;
+ this.ease({
+ scale_x: 0.5,
+ scale_y: 0.5,
+ opacity: 0,
+ });
+ }
+
+ undoScaleAndFade() {
+ this.reactive = true;
+ this.ease({
+ scale_x: 1.0,
+ scale_y: 1.0,
+ opacity: 255,
+ });
+ }
+
+ _canAccept(source) {
+ return source !== this;
+ }
+
+ _setHoveringByDnd(hovering) {
+ if (this._otherIconIsHovering === hovering)
+ return;
+
+ this._otherIconIsHovering = hovering;
+
+ if (hovering) {
+ this._dragMonitor = {
+ dragMotion: this._onDragMotion.bind(this),
+ };
+ DND.addDragMonitor(this._dragMonitor);
+ } else {
+ DND.removeDragMonitor(this._dragMonitor);
+ }
+ }
+
+ _onDragMotion(dragEvent) {
+ if (!this.contains(dragEvent.targetActor))
+ this._setHoveringByDnd(false);
+
+ return DND.DragMotionResult.CONTINUE;
+ }
+
+ _withinLeeways(x) {
+ return x < IconGrid.LEFT_DIVIDER_LEEWAY ||
+ x > this.width - IconGrid.RIGHT_DIVIDER_LEEWAY;
+ }
+
+ handleDragOver(source, _actor, x) {
+ if (source === this)
+ return DND.DragMotionResult.NO_DROP;
+
+ if (!this._canAccept(source))
+ return DND.DragMotionResult.CONTINUE;
+
+ if (this._withinLeeways(x)) {
+ this._setHoveringByDnd(false);
+ return DND.DragMotionResult.CONTINUE;
+ }
+
+ this._setHoveringByDnd(true);
+
+ return DND.DragMotionResult.MOVE_DROP;
+ }
+
+ acceptDrop(source, _actor, x) {
+ this._setHoveringByDnd(false);
+
+ if (!this._canAccept(source))
+ return false;
+
+ if (this._withinLeeways(x))
+ return false;
+
+ return true;
+ }
+
+ get id() {
+ return this._id;
+ }
+
+ get name() {
+ return this._name;
+ }
+});
+
+var FolderGrid = GObject.registerClass(
+class FolderGrid extends IconGrid.IconGrid {
+ _init() {
+ super._init({
+ allow_incomplete_pages: false,
+ orientation: Clutter.Orientation.HORIZONTAL,
+ columns_per_page: 3,
+ rows_per_page: 3,
+ page_halign: Clutter.ActorAlign.CENTER,
+ page_valign: Clutter.ActorAlign.CENTER,
+ });
+ }
+
+ adaptToSize(width, height) {
+ this.layout_manager.adaptToSize(width, height);
+ }
+});
+
+var FolderView = GObject.registerClass(
+class FolderView extends BaseAppView {
+ _init(folder, id, parentView) {
+ super._init({
+ layout_manager: new Clutter.BinLayout(),
+ x_expand: true,
+ y_expand: true,
+ }, Clutter.Orientation.HORIZONTAL);
+
+ // If it not expand, the parent doesn't take into account its preferred_width when allocating
+ // the second time it allocates, so we apply the "Standard hack for ClutterBinLayout"
+ this._grid.x_expand = true;
+ this._id = id;
+ this._folder = folder;
+ this._parentView = parentView;
+ this._grid._delegate = this;
+
+ const box = new St.BoxLayout({
+ vertical: true,
+ reactive: true,
+ x_expand: true,
+ y_expand: true,
+ });
+ box.add_child(this._scrollView);
+ box.add_child(this._pageIndicators);
+ this.add_child(box);
+
+ let action = new Clutter.PanAction({ interpolate: true });
+ action.connect('pan', this._onPan.bind(this));
+ this._scrollView.add_action(action);
+
+ this._deletingFolder = false;
+ this._appIds = [];
+ this._redisplay();
+ }
+
+ _createGrid() {
+ return new FolderGrid();
+ }
+
+ _getFolderApps() {
+ const appIds = [];
+ const excludedApps = this._folder.get_strv('excluded-apps');
+ const appSys = Shell.AppSystem.get_default();
+ const addAppId = appId => {
+ if (excludedApps.includes(appId))
+ return;
+
+ const app = appSys.lookup_app(appId);
+ if (!app)
+ return;
+
+ if (!this._parentalControlsManager.shouldShowApp(app.get_app_info()))
+ return;
+
+ if (appIds.indexOf(appId) !== -1)
+ return;
+
+ appIds.push(appId);
+ };
+
+ const folderApps = this._folder.get_strv('apps');
+ folderApps.forEach(addAppId);
+
+ const folderCategories = this._folder.get_strv('categories');
+ const appInfos = this._parentView.getAppInfos();
+ appInfos.forEach(appInfo => {
+ let appCategories = _getCategories(appInfo);
+ if (!_listsIntersect(folderCategories, appCategories))
+ return;
+
+ addAppId(appInfo.get_id());
+ });
+
+ return appIds;
+ }
+
+ _getItemPosition(item) {
+ const appIndex = this._appIds.indexOf(item.id);
+
+ if (appIndex === -1)
+ return [-1, -1];
+
+ const { itemsPerPage } = this._grid;
+ return [Math.floor(appIndex / itemsPerPage), appIndex % itemsPerPage];
+ }
+
+ _compareItems(a, b) {
+ const aPosition = this._appIds.indexOf(a.id);
+ const bPosition = this._appIds.indexOf(b.id);
+
+ if (aPosition === -1 && bPosition === -1)
+ return a.name.localeCompare(b.name);
+ else if (aPosition === -1)
+ return 1;
+ else if (bPosition === -1)
+ return -1;
+
+ return aPosition - bPosition;
+ }
+
+ // Overridden from BaseAppView
+ animate(animationDirection) {
+ this._grid.animatePulse(animationDirection);
+ }
+
+ createFolderIcon(size) {
+ let layout = new Clutter.GridLayout();
+ let icon = new St.Widget({
+ layout_manager: layout,
+ style_class: 'app-folder-icon',
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+ layout.hookup_style(icon);
+ let subSize = Math.floor(FOLDER_SUBICON_FRACTION * size);
+
+ let numItems = this._orderedItems.length;
+ let rtl = icon.get_text_direction() == Clutter.TextDirection.RTL;
+ for (let i = 0; i < 4; i++) {
+ const style = 'width: %dpx; height: %dpx;'.format(subSize, subSize);
+ let bin = new St.Bin({ style });
+ if (i < numItems)
+ bin.child = this._orderedItems[i].app.create_icon_texture(subSize);
+ layout.attach(bin, rtl ? (i + 1) % 2 : i % 2, Math.floor(i / 2), 1, 1);
+ }
+
+ return icon;
+ }
+
+ _onPan(action) {
+ let [dist_, dx_, dy] = action.get_motion_delta(0);
+ let adjustment = this._scrollView.vscroll.adjustment;
+ adjustment.value -= (dy / this._scrollView.height) * adjustment.page_size;
+ return false;
+ }
+
+ adaptToSize(width, height) {
+ const [, indicatorHeight] = this._pageIndicators.get_preferred_height(-1);
+ height -= indicatorHeight;
+
+ super.adaptToSize(width, height);
+ }
+
+ _loadApps() {
+ let apps = [];
+ let appSys = Shell.AppSystem.get_default();
+
+ this._appIds.forEach(appId => {
+ const app = appSys.lookup_app(appId);
+
+ let icon = this._items.get(appId);
+ if (!icon)
+ icon = new AppIcon(app);
+
+ apps.push(icon);
+ });
+
+ return apps;
+ }
+
+ _redisplay() {
+ // Keep the app ids list cached
+ this._appIds = this._getFolderApps();
+
+ super._redisplay();
+ }
+
+ acceptDrop(source) {
+ if (!super.acceptDrop(source))
+ return false;
+
+ const folderApps = this._orderedItems.map(item => item.id);
+ this._folder.set_strv('apps', folderApps);
+
+ return true;
+ }
+
+ addApp(app) {
+ let folderApps = this._folder.get_strv('apps');
+ folderApps.push(app.id);
+
+ this._folder.set_strv('apps', folderApps);
+
+ // Also remove from 'excluded-apps' if the app id is listed
+ // there. This is only possible on categories-based folders.
+ let excludedApps = this._folder.get_strv('excluded-apps');
+ let index = excludedApps.indexOf(app.id);
+ if (index >= 0) {
+ excludedApps.splice(index, 1);
+ this._folder.set_strv('excluded-apps', excludedApps);
+ }
+ }
+
+ removeApp(app) {
+ let folderApps = this._folder.get_strv('apps');
+ let index = folderApps.indexOf(app.id);
+ if (index >= 0)
+ folderApps.splice(index, 1);
+
+ // Remove the folder if this is the last app icon; otherwise,
+ // just remove the icon
+ if (folderApps.length == 0) {
+ this._deletingFolder = true;
+
+ // Resetting all keys deletes the relocatable schema
+ let keys = this._folder.settings_schema.list_keys();
+ for (const key of keys)
+ this._folder.reset(key);
+
+ let settings = new Gio.Settings({ schema_id: 'org.gnome.desktop.app-folders' });
+ let folders = settings.get_strv('folder-children');
+ folders.splice(folders.indexOf(this._id), 1);
+ settings.set_strv('folder-children', folders);
+
+ this._deletingFolder = false;
+ } else {
+ // If this is a categories-based folder, also add it to
+ // the list of excluded apps
+ const categories = this._folder.get_strv('categories');
+ if (categories.length > 0) {
+ const excludedApps = this._folder.get_strv('excluded-apps');
+ excludedApps.push(app.id);
+ this._folder.set_strv('excluded-apps', excludedApps);
+ }
+
+ this._folder.set_strv('apps', folderApps);
+ }
+ }
+
+ get deletingFolder() {
+ return this._deletingFolder;
+ }
+});
+
+var FolderIcon = GObject.registerClass({
+ Signals: {
+ 'apps-changed': {},
+ },
+}, class FolderIcon extends AppViewItem {
+ _init(id, path, parentView) {
+ super._init({
+ style_class: 'app-well-app app-folder',
+ button_mask: St.ButtonMask.ONE,
+ toggle_mode: true,
+ can_focus: true,
+ }, global.settings.is_writable('app-picker-layout'));
+ this._id = id;
+ this._name = '';
+ this._parentView = parentView;
+
+ this._folder = new Gio.Settings({ schema_id: 'org.gnome.desktop.app-folders.folder',
+ path });
+
+ this.icon = new IconGrid.BaseIcon('', {
+ createIcon: this._createIcon.bind(this),
+ setSizeManually: true,
+ });
+ this.set_child(this.icon);
+ this.label_actor = this.icon.label;
+
+ this.view = new FolderView(this._folder, id, parentView);
+
+ this._folderChangedId = this._folder.connect(
+ 'changed', this._sync.bind(this));
+ this._sync();
+ }
+
+ _onDestroy() {
+ super._onDestroy();
+
+ if (this._dialog)
+ this._dialog.destroy();
+ else
+ this.view.destroy();
+
+ if (this._folderChangedId) {
+ this._folder.disconnect(this._folderChangedId);
+ delete this._folderChangedId;
+ }
+ }
+
+ vfunc_clicked() {
+ this.open();
+ }
+
+ vfunc_unmap() {
+ if (this._dialog)
+ this._dialog.popdown();
+
+ super.vfunc_unmap();
+ }
+
+ open() {
+ this._ensureFolderDialog();
+ this.view._scrollView.vscroll.adjustment.value = 0;
+ this._dialog.popup();
+ }
+
+ getAppIds() {
+ return this.view.getAllItems().map(item => item.id);
+ }
+
+ _setHoveringByDnd(hovering) {
+ if (this._otherIconIsHovering == hovering)
+ return;
+
+ super._setHoveringByDnd(hovering);
+
+ if (hovering)
+ this.add_style_pseudo_class('drop');
+ else
+ this.remove_style_pseudo_class('drop');
+ }
+
+ _onDragMotion(dragEvent) {
+ if (!this._canAccept(dragEvent.source))
+ this._setHoveringByDnd(false);
+
+ return super._onDragMotion(dragEvent);
+ }
+
+ getDragActor() {
+ const iconParams = {
+ createIcon: this._createIcon.bind(this),
+ showLabel: this.icon.label !== null,
+ setSizeManually: false,
+ };
+
+ const icon = new IconGrid.BaseIcon(this.name, iconParams);
+ icon.style_class = this.style_class;
+
+ return icon;
+ }
+
+ getDragActorSource() {
+ return this;
+ }
+
+ _canAccept(source) {
+ if (!(source instanceof AppIcon))
+ return false;
+
+ let view = _getViewFromIcon(source);
+ if (!view || !(view instanceof AppDisplay))
+ return false;
+
+ if (this._folder.get_strv('apps').includes(source.id))
+ return false;
+
+ return true;
+ }
+
+ acceptDrop(source) {
+ const accepted = super.acceptDrop(source);
+
+ if (!accepted)
+ return false;
+
+ this.view.addApp(source.app);
+
+ return true;
+ }
+
+ _updateName() {
+ let name = _getFolderName(this._folder);
+ if (this.name == name)
+ return;
+
+ this._name = name;
+ this.icon.label.text = this.name;
+ }
+
+ _sync() {
+ if (this.view.deletingFolder)
+ return;
+
+ this.emit('apps-changed');
+ this._updateName();
+ this.visible = this.view.getAllItems().length > 0;
+ this.icon.update();
+ }
+
+ _createIcon(iconSize) {
+ return this.view.createFolderIcon(iconSize, this);
+ }
+
+ _ensureFolderDialog() {
+ if (this._dialog)
+ return;
+ if (!this._dialog) {
+ this._dialog = new AppFolderDialog(this, this._folder,
+ this._parentView);
+ this._parentView.addFolderDialog(this._dialog);
+ this._dialog.connect('open-state-changed', (popup, isOpen) => {
+ const duration = FOLDER_DIALOG_ANIMATION_TIME / 2;
+ const mode = isOpen
+ ? Clutter.AnimationMode.EASE_OUT_QUAD
+ : Clutter.AnimationMode.EASE_IN_QUAD;
+
+ this.ease({
+ opacity: isOpen ? 0 : 255,
+ duration,
+ mode,
+ delay: isOpen ? 0 : FOLDER_DIALOG_ANIMATION_TIME - duration,
+ });
+
+ if (!isOpen)
+ this.checked = false;
+ });
+ }
+ }
+});
+
+var AppFolderDialog = GObject.registerClass({
+ Signals: {
+ 'open-state-changed': { param_types: [GObject.TYPE_BOOLEAN] },
+ },
+}, class AppFolderDialog extends St.Bin {
+ _init(source, folder, appDisplay) {
+ super._init({
+ visible: false,
+ x_expand: true,
+ y_expand: true,
+ reactive: true,
+ });
+
+ this.add_constraint(new Layout.MonitorConstraint({
+ primary: true,
+ work_area: true,
+ }));
+
+ const clickAction = new Clutter.ClickAction();
+ clickAction.connect('clicked', () => {
+ const [x, y] = clickAction.get_coords();
+ const actor =
+ global.stage.get_actor_at_pos(Clutter.PickMode.ALL, x, y);
+
+ if (actor === this)
+ this.popdown();
+ });
+ this.add_action(clickAction);
+
+ this._source = source;
+ this._folder = folder;
+ this._view = source.view;
+ this._appDisplay = appDisplay;
+ this._delegate = this;
+
+ this._isOpen = false;
+
+ this._viewBox = new St.BoxLayout({
+ style_class: 'app-folder-dialog',
+ x_expand: true,
+ y_expand: true,
+ x_align: Clutter.ActorAlign.FILL,
+ y_align: Clutter.ActorAlign.FILL,
+ vertical: true,
+ });
+
+ this.child = new St.Bin({
+ style_class: 'app-folder-dialog-container',
+ child: this._viewBox,
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+
+ this._addFolderNameEntry();
+ this._viewBox.add_child(this._view);
+
+ global.focus_manager.add_group(this);
+
+ this._grabHelper = new GrabHelper.GrabHelper(this, {
+ actionMode: Shell.ActionMode.POPUP,
+ });
+ this._grabHelper.addActor(Main.layoutManager.overviewGroup);
+ this.connect('destroy', this._onDestroy.bind(this));
+
+ this._dragMonitor = null;
+ this._sourceMappedId = 0;
+ this._popdownTimeoutId = 0;
+ this._needsZoomAndFade = false;
+
+ this._popdownCallbacks = [];
+ }
+
+ _addFolderNameEntry() {
+ this._entryBox = new St.BoxLayout({
+ style_class: 'folder-name-container',
+ });
+ this._viewBox.add_child(this._entryBox);
+
+ // Empty actor to center the title
+ let ghostButton = new Clutter.Actor();
+ this._entryBox.add_child(ghostButton);
+
+ let stack = new Shell.Stack({
+ x_expand: true,
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+ this._entryBox.add_child(stack);
+
+ // Folder name label
+ this._folderNameLabel = new St.Label({
+ style_class: 'folder-name-label',
+ x_expand: true,
+ y_expand: true,
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+
+ stack.add_child(this._folderNameLabel);
+
+ // Folder name entry
+ this._entry = new St.Entry({
+ style_class: 'folder-name-entry',
+ opacity: 0,
+ reactive: false,
+ });
+ this._entry.clutter_text.set({
+ x_expand: true,
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+
+ this._entry.clutter_text.connect('activate', () => {
+ this._showFolderLabel();
+ });
+
+ stack.add_child(this._entry);
+
+ // Edit button
+ this._editButton = new St.Button({
+ style_class: 'edit-folder-button',
+ button_mask: St.ButtonMask.ONE,
+ toggle_mode: true,
+ reactive: true,
+ can_focus: true,
+ x_align: Clutter.ActorAlign.END,
+ y_align: Clutter.ActorAlign.CENTER,
+ child: new St.Icon({
+ icon_name: 'document-edit-symbolic',
+ icon_size: 16,
+ }),
+ });
+
+ this._editButton.connect('notify::checked', () => {
+ if (this._editButton.checked)
+ this._showFolderEntry();
+ else
+ this._showFolderLabel();
+ });
+
+ this._entryBox.add_child(this._editButton);
+
+ ghostButton.add_constraint(new Clutter.BindConstraint({
+ source: this._editButton,
+ coordinate: Clutter.BindCoordinate.SIZE,
+ }));
+
+ this._folder.connect('changed::name', () => this._syncFolderName());
+ this._syncFolderName();
+ }
+
+ _syncFolderName() {
+ let newName = _getFolderName(this._folder);
+
+ this._folderNameLabel.text = newName;
+ this._entry.text = newName;
+ }
+
+ _switchActor(from, to) {
+ to.reactive = true;
+ to.ease({
+ opacity: 255,
+ duration: 300,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+
+ from.ease({
+ opacity: 0,
+ duration: 300,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ from.reactive = false;
+ },
+ });
+ }
+
+ _showFolderLabel() {
+ if (this._editButton.checked)
+ this._editButton.checked = false;
+
+ this._maybeUpdateFolderName();
+ this._switchActor(this._entry, this._folderNameLabel);
+ }
+
+ _showFolderEntry() {
+ this._switchActor(this._folderNameLabel, this._entry);
+
+ this._entry.clutter_text.set_selection(0, -1);
+ this._entry.clutter_text.grab_key_focus();
+ }
+
+ _maybeUpdateFolderName() {
+ let folderName = _getFolderName(this._folder);
+ let newFolderName = this._entry.text.trim();
+
+ if (newFolderName.length === 0 || newFolderName === folderName)
+ return;
+
+ this._folder.set_string('name', newFolderName);
+ this._folder.set_boolean('translate', false);
+ }
+
+ _zoomAndFadeIn() {
+ let [sourceX, sourceY] =
+ this._source.get_transformed_position();
+ let [dialogX, dialogY] =
+ this.child.get_transformed_position();
+
+ this.child.set({
+ translation_x: sourceX - dialogX,
+ translation_y: sourceY - dialogY,
+ scale_x: this._source.width / this.child.width,
+ scale_y: this._source.height / this.child.height,
+ opacity: 0,
+ });
+
+ this.ease({
+ background_color: DIALOG_SHADE_NORMAL,
+ duration: FOLDER_DIALOG_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ this.child.ease({
+ translation_x: 0,
+ translation_y: 0,
+ scale_x: 1,
+ scale_y: 1,
+ opacity: 255,
+ duration: FOLDER_DIALOG_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+
+ this._needsZoomAndFade = false;
+
+ if (this._sourceMappedId === 0) {
+ this._sourceMappedId = this._source.connect(
+ 'notify::mapped', this._zoomAndFadeOut.bind(this));
+ }
+ }
+
+ _zoomAndFadeOut() {
+ if (!this._isOpen)
+ return;
+
+ if (!this._source.mapped) {
+ this.hide();
+ return;
+ }
+
+ let [sourceX, sourceY] =
+ this._source.get_transformed_position();
+ let [dialogX, dialogY] =
+ this.child.get_transformed_position();
+
+ this.ease({
+ background_color: Clutter.Color.from_pixel(0x00000000),
+ duration: FOLDER_DIALOG_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+
+ this.child.ease({
+ translation_x: sourceX - dialogX,
+ translation_y: sourceY - dialogY,
+ scale_x: this._source.width / this.child.width,
+ scale_y: this._source.height / this.child.height,
+ opacity: 0,
+ duration: FOLDER_DIALOG_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ this.child.set({
+ translation_x: 0,
+ translation_y: 0,
+ scale_x: 1,
+ scale_y: 1,
+ opacity: 255,
+ });
+ this.hide();
+
+ this._popdownCallbacks.forEach(func => func());
+ this._popdownCallbacks = [];
+ },
+ });
+
+ this._needsZoomAndFade = false;
+ }
+
+ _removeDragMonitor() {
+ if (!this._dragMonitor)
+ return;
+
+ DND.removeDragMonitor(this._dragMonitor);
+ this._dragMonitor = null;
+ }
+
+ _removePopdownTimeout() {
+ if (this._popdownTimeoutId === 0)
+ return;
+
+ GLib.source_remove(this._popdownTimeoutId);
+ this._popdownTimeoutId = 0;
+ }
+
+ _onDestroy() {
+ if (this._isOpen) {
+ this._isOpen = false;
+ this._grabHelper.ungrab({ actor: this });
+ this._grabHelper = null;
+ }
+
+ if (this._sourceMappedId) {
+ this._source.disconnect(this._sourceMappedId);
+ this._sourceMappedId = 0;
+ }
+
+ this._removePopdownTimeout();
+ this._removeDragMonitor();
+ }
+
+ vfunc_allocate(box) {
+ super.vfunc_allocate(box);
+
+ // We can only start zooming after receiving an allocation
+ if (this._needsZoomAndFade)
+ this._zoomAndFadeIn();
+ }
+
+ vfunc_key_press_event(keyEvent) {
+ if (global.stage.get_key_focus() != this)
+ return Clutter.EVENT_PROPAGATE;
+
+ // Since we need to only grab focus on one item child when the user
+ // actually press a key we don't use navigate_focus when opening
+ // the popup.
+ // Instead of that, grab the focus on the AppFolderPopup actor
+ // and actually moves the focus to a child only when the user
+ // actually press a key.
+ // It should work with just grab_key_focus on the AppFolderPopup
+ // actor, but since the arrow keys are not wrapping_around the focus
+ // is not grabbed by a child when the widget that has the current focus
+ // is the same that is requesting focus, so to make it works with arrow
+ // keys we need to connect to the key-press-event and navigate_focus
+ // when that happens using TAB_FORWARD or TAB_BACKWARD instead of arrow
+ // keys
+
+ // Use TAB_FORWARD for down key and right key
+ // and TAB_BACKWARD for up key and left key on ltr
+ // languages
+ let direction;
+ let isLtr = Clutter.get_default_text_direction() == Clutter.TextDirection.LTR;
+ switch (keyEvent.keyval) {
+ case Clutter.KEY_Down:
+ direction = St.DirectionType.TAB_FORWARD;
+ break;
+ case Clutter.KEY_Right:
+ direction = isLtr
+ ? St.DirectionType.TAB_FORWARD
+ : St.DirectionType.TAB_BACKWARD;
+ break;
+ case Clutter.KEY_Up:
+ direction = St.DirectionType.TAB_BACKWARD;
+ break;
+ case Clutter.KEY_Left:
+ direction = isLtr
+ ? St.DirectionType.TAB_BACKWARD
+ : St.DirectionType.TAB_FORWARD;
+ break;
+ default:
+ return Clutter.EVENT_PROPAGATE;
+ }
+ return this.navigate_focus(null, direction, false);
+ }
+
+ _setLighterBackground(lighter) {
+ const backgroundColor = lighter
+ ? DIALOG_SHADE_HIGHLIGHT
+ : DIALOG_SHADE_NORMAL;
+
+ this.ease({
+ backgroundColor,
+ duration: FOLDER_DIALOG_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ _withinDialog(x, y) {
+ const childExtents = this.child.get_transformed_extents();
+ return childExtents.contains_point(new Graphene.Point({ x, y }));
+ }
+
+ _setupDragMonitor() {
+ if (this._dragMonitor)
+ return;
+
+ this._dragMonitor = {
+ dragMotion: dragEvent => {
+ const withinDialog =
+ this._withinDialog(dragEvent.x, dragEvent.y);
+
+ this._setLighterBackground(!withinDialog);
+
+ if (withinDialog) {
+ this._removePopdownTimeout();
+ this._removeDragMonitor();
+ }
+ return DND.DragMotionResult.CONTINUE;
+ },
+ };
+ DND.addDragMonitor(this._dragMonitor);
+ }
+
+ _setupPopdownTimeout() {
+ if (this._popdownTimeoutId > 0)
+ return;
+
+ this._popdownTimeoutId =
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT, POPDOWN_DIALOG_TIMEOUT, () => {
+ this._popdownTimeoutId = 0;
+ this.popdown();
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+
+ handleDragOver(source, actor, x, y) {
+ if (this._withinDialog(x, y)) {
+ this._setLighterBackground(false);
+ this._removePopdownTimeout();
+ this._removeDragMonitor();
+ } else {
+ this._setupPopdownTimeout();
+ this._setupDragMonitor();
+ }
+
+ return DND.DragMotionResult.MOVE_DROP;
+ }
+
+ acceptDrop(source) {
+ const appId = source.id;
+
+ this.popdown(() => {
+ this._view.removeApp(source);
+ this._appDisplay.selectApp(appId);
+ });
+
+ return true;
+ }
+
+ toggle() {
+ if (this._isOpen)
+ this.popdown();
+ else
+ this.popup();
+ }
+
+ popup() {
+ if (this._isOpen)
+ return;
+
+ this._isOpen = this._grabHelper.grab({
+ actor: this,
+ onUngrab: () => this.popdown(),
+ });
+
+ if (!this._isOpen)
+ return;
+
+ this.get_parent().set_child_above_sibling(this, null);
+
+ this._needsZoomAndFade = true;
+ this.show();
+
+ this.emit('open-state-changed', true);
+ }
+
+ popdown(callback) {
+ // Either call the callback right away, or wait for the zoom out
+ // animation to finish
+ if (callback) {
+ if (this.visible)
+ this._popdownCallbacks.push(callback);
+ else
+ callback();
+ }
+
+ if (!this._isOpen)
+ return;
+
+ this._zoomAndFadeOut();
+ this._showFolderLabel();
+
+ this._isOpen = false;
+ this._grabHelper.ungrab({ actor: this });
+ this.emit('open-state-changed', false);
+ }
+});
+
+var AppIcon = GObject.registerClass({
+ Signals: {
+ 'menu-state-changed': { param_types: [GObject.TYPE_BOOLEAN] },
+ 'sync-tooltip': {},
+ },
+}, class AppIcon extends AppViewItem {
+ _init(app, iconParams = {}) {
+ // Get the isDraggable property without passing it on to the BaseIcon:
+ const appIconParams = Params.parse(iconParams, { isDraggable: true }, true);
+ const isDraggable = appIconParams['isDraggable'];
+ delete iconParams['isDraggable'];
+
+ super._init({ style_class: 'app-well-app' }, isDraggable);
+
+ this.app = app;
+ this._id = app.get_id();
+ this._name = app.get_name();
+
+ this._iconContainer = new St.Widget({ layout_manager: new Clutter.BinLayout(),
+ x_expand: true, y_expand: true });
+
+ this.set_child(this._iconContainer);
+
+ this._folderPreviewId = 0;
+
+ iconParams['createIcon'] = this._createIcon.bind(this);
+ iconParams['setSizeManually'] = true;
+ this.icon = new IconGrid.BaseIcon(app.get_name(), iconParams);
+ this._iconContainer.add_child(this.icon);
+
+ this._dot = new St.Widget({
+ style_class: 'app-well-app-running-dot',
+ layout_manager: new Clutter.BinLayout(),
+ x_expand: true,
+ y_expand: true,
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.END,
+ });
+ this._iconContainer.add_child(this._dot);
+
+ this.label_actor = this.icon.label;
+
+ this.connect('popup-menu', this._onKeyboardPopupMenu.bind(this));
+
+ this._menu = null;
+ this._menuManager = new PopupMenu.PopupMenuManager(this);
+
+ this._menuTimeoutId = 0;
+ this._stateChangedId = this.app.connect('notify::state', () => {
+ this._updateRunningStyle();
+ });
+ this._updateRunningStyle();
+ }
+
+ _onDestroy() {
+ super._onDestroy();
+
+ if (this._folderPreviewId > 0) {
+ GLib.source_remove(this._folderPreviewId);
+ this._folderPreviewId = 0;
+ }
+ if (this._stateChangedId > 0)
+ this.app.disconnect(this._stateChangedId);
+
+ this._stateChangedId = 0;
+ this._removeMenuTimeout();
+ }
+
+ _onDragBegin() {
+ this._removeMenuTimeout();
+ super._onDragBegin();
+ }
+
+ _createIcon(iconSize) {
+ return this.app.create_icon_texture(iconSize);
+ }
+
+ _removeMenuTimeout() {
+ if (this._menuTimeoutId > 0) {
+ GLib.source_remove(this._menuTimeoutId);
+ this._menuTimeoutId = 0;
+ }
+ }
+
+ _updateRunningStyle() {
+ if (this.app.state != Shell.AppState.STOPPED)
+ this._dot.show();
+ else
+ this._dot.hide();
+ }
+
+ _setPopupTimeout() {
+ this._removeMenuTimeout();
+ this._menuTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, MENU_POPUP_TIMEOUT, () => {
+ this._menuTimeoutId = 0;
+ this.popupMenu();
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._menuTimeoutId, '[gnome-shell] this.popupMenu');
+ }
+
+ vfunc_leave_event(crossingEvent) {
+ const ret = super.vfunc_leave_event(crossingEvent);
+
+ this.fake_release();
+ this._removeMenuTimeout();
+ return ret;
+ }
+
+ vfunc_button_press_event(buttonEvent) {
+ const ret = super.vfunc_button_press_event(buttonEvent);
+ if (buttonEvent.button == 1) {
+ this._setPopupTimeout();
+ } else if (buttonEvent.button == 3) {
+ this.popupMenu();
+ return Clutter.EVENT_STOP;
+ }
+ return ret;
+ }
+
+ vfunc_touch_event(touchEvent) {
+ const ret = super.vfunc_touch_event(touchEvent);
+ if (touchEvent.type == Clutter.EventType.TOUCH_BEGIN)
+ this._setPopupTimeout();
+
+ return ret;
+ }
+
+ vfunc_clicked(button) {
+ this._removeMenuTimeout();
+ this.activate(button);
+ }
+
+ _onKeyboardPopupMenu() {
+ this.popupMenu();
+ this._menu.actor.navigate_focus(null, St.DirectionType.TAB_FORWARD, false);
+ }
+
+ getId() {
+ return this.app.get_id();
+ }
+
+ popupMenu() {
+ this._removeMenuTimeout();
+ this.fake_release();
+
+ if (this._draggable)
+ this._draggable.fakeRelease();
+
+ if (!this._menu) {
+ this._menu = new AppIconMenu(this);
+ this._menu.connect('activate-window', (menu, window) => {
+ this.activateWindow(window);
+ });
+ this._menu.connect('open-state-changed', (menu, isPoppedUp) => {
+ if (!isPoppedUp)
+ this._onMenuPoppedDown();
+ });
+ let id = Main.overview.connect('hiding', () => {
+ this._menu.close();
+ });
+ this.connect('destroy', () => {
+ Main.overview.disconnect(id);
+ });
+
+ this._menuManager.addMenu(this._menu);
+ }
+
+ this.emit('menu-state-changed', true);
+
+ this.set_hover(true);
+ this._menu.popup();
+ this._menuManager.ignoreRelease();
+ this.emit('sync-tooltip');
+
+ return false;
+ }
+
+ activateWindow(metaWindow) {
+ if (metaWindow)
+ Main.activateWindow(metaWindow);
+ else
+ Main.overview.hide();
+ }
+
+ _onMenuPoppedDown() {
+ this.sync_hover();
+ this.emit('menu-state-changed', false);
+ }
+
+ activate(button) {
+ let event = Clutter.get_current_event();
+ let modifiers = event ? event.get_state() : 0;
+ let isMiddleButton = button && button == Clutter.BUTTON_MIDDLE;
+ let isCtrlPressed = (modifiers & Clutter.ModifierType.CONTROL_MASK) != 0;
+ let openNewWindow = this.app.can_open_new_window() &&
+ this.app.state == Shell.AppState.RUNNING &&
+ (isCtrlPressed || isMiddleButton);
+
+ if (this.app.state == Shell.AppState.STOPPED || openNewWindow)
+ this.animateLaunch();
+
+ if (openNewWindow)
+ this.app.open_new_window(-1);
+ else
+ this.app.activate();
+
+ Main.overview.hide();
+ }
+
+ animateLaunch() {
+ this.icon.animateZoomOut();
+ }
+
+ animateLaunchAtPos(x, y) {
+ this.icon.animateZoomOutAtPos(x, y);
+ }
+
+ shellWorkspaceLaunch(params) {
+ let { stack } = new Error();
+ log('shellWorkspaceLaunch is deprecated, use app.open_new_window() instead\n%s'.format(stack));
+
+ params = Params.parse(params, { workspace: -1,
+ timestamp: 0 });
+
+ this.app.open_new_window(params.workspace);
+ }
+
+ getDragActor() {
+ return this.app.create_icon_texture(Main.overview.dash.iconSize);
+ }
+
+ // Returns the original actor that should align with the actor
+ // we show as the item is being dragged.
+ getDragActorSource() {
+ return this.icon.icon;
+ }
+
+ shouldShowTooltip() {
+ return this.hover && (!this._menu || !this._menu.isOpen);
+ }
+
+ _showFolderPreview() {
+ this.icon.label.opacity = 0;
+ this.icon.icon.ease({
+ scale_x: FOLDER_SUBICON_FRACTION,
+ scale_y: FOLDER_SUBICON_FRACTION,
+ });
+ }
+
+ _hideFolderPreview() {
+ this.icon.label.opacity = 255;
+ this.icon.icon.ease({
+ scale_x: 1.0,
+ scale_y: 1.0,
+ });
+ }
+
+ _canAccept(source) {
+ let view = _getViewFromIcon(source);
+
+ return source != this &&
+ (source instanceof this.constructor) &&
+ (view instanceof AppDisplay);
+ }
+
+ _setHoveringByDnd(hovering) {
+ if (this._otherIconIsHovering == hovering)
+ return;
+
+ super._setHoveringByDnd(hovering);
+
+ if (hovering) {
+ if (this._folderPreviewId > 0)
+ return;
+
+ this._folderPreviewId =
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => {
+ this.add_style_pseudo_class('drop');
+ this._showFolderPreview();
+ this._folderPreviewId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+ } else {
+ if (this._folderPreviewId > 0) {
+ GLib.source_remove(this._folderPreviewId);
+ this._folderPreviewId = 0;
+ }
+ this._hideFolderPreview();
+ this.remove_style_pseudo_class('drop');
+ }
+ }
+
+ acceptDrop(source, actor, x) {
+ const accepted = super.acceptDrop(source, actor, x);
+ if (!accepted)
+ return false;
+
+ let view = _getViewFromIcon(this);
+ let apps = [this.id, source.id];
+
+ return view.createFolder(apps);
+ }
+});
+
+var AppIconMenu = class AppIconMenu extends PopupMenu.PopupMenu {
+ constructor(source) {
+ let side = St.Side.LEFT;
+ if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL)
+ side = St.Side.RIGHT;
+
+ super(source, 0.5, side);
+
+ // We want to keep the item hovered while the menu is up
+ this.blockSourceEvents = true;
+
+ this._source = source;
+
+ this._parentalControlsManager = ParentalControlsManager.getDefault();
+
+ this.actor.add_style_class_name('app-well-menu');
+
+ // Chain our visibility and lifecycle to that of the source
+ this._sourceMappedId = source.connect('notify::mapped', () => {
+ if (!source.mapped)
+ this.close();
+ });
+ source.connect('destroy', () => {
+ source.disconnect(this._sourceMappedId);
+ this.destroy();
+ });
+
+ Main.uiGroup.add_actor(this.actor);
+ }
+
+ _rebuildMenu() {
+ this.removeAll();
+
+ let windows = this._source.app.get_windows().filter(
+ w => !w.skip_taskbar);
+
+ if (windows.length > 0) {
+ this.addMenuItem(
+ /* Translators: This is the heading of a list of open windows */
+ new PopupMenu.PopupSeparatorMenuItem(_('Open Windows')));
+ }
+
+ windows.forEach(window => {
+ let title = window.title
+ ? window.title : this._source.app.get_name();
+ let item = this._appendMenuItem(title);
+ item.connect('activate', () => {
+ this.emit('activate-window', window);
+ });
+ });
+
+ if (!this._source.app.is_window_backed()) {
+ this._appendSeparator();
+
+ let appInfo = this._source.app.get_app_info();
+ let actions = appInfo.list_actions();
+ if (this._source.app.can_open_new_window() &&
+ !actions.includes('new-window')) {
+ this._newWindowMenuItem = this._appendMenuItem(_("New Window"));
+ this._newWindowMenuItem.connect('activate', () => {
+ this._source.animateLaunch();
+ this._source.app.open_new_window(-1);
+ this.emit('activate-window', null);
+ });
+ this._appendSeparator();
+ }
+
+ if (discreteGpuAvailable &&
+ this._source.app.state == Shell.AppState.STOPPED) {
+ const appPrefersNonDefaultGPU = appInfo.get_boolean('PrefersNonDefaultGPU');
+ const gpuPref = appPrefersNonDefaultGPU
+ ? Shell.AppLaunchGpu.DEFAULT
+ : Shell.AppLaunchGpu.DISCRETE;
+ this._onGpuMenuItem = this._appendMenuItem(appPrefersNonDefaultGPU
+ ? _('Launch using Integrated Graphics Card')
+ : _('Launch using Discrete Graphics Card'));
+ this._onGpuMenuItem.connect('activate', () => {
+ this._source.animateLaunch();
+ this._source.app.launch(0, -1, gpuPref);
+ this.emit('activate-window', null);
+ });
+ }
+
+ for (let i = 0; i < actions.length; i++) {
+ let action = actions[i];
+ let item = this._appendMenuItem(appInfo.get_action_name(action));
+ item.connect('activate', (emitter, event) => {
+ if (action == 'new-window')
+ this._source.animateLaunch();
+
+ this._source.app.launch_action(action, event.get_time(), -1);
+ this.emit('activate-window', null);
+ });
+ }
+
+ let canFavorite = global.settings.is_writable('favorite-apps') &&
+ this._parentalControlsManager.shouldShowApp(this._source.app.app_info);
+
+ if (canFavorite) {
+ this._appendSeparator();
+
+ let isFavorite = AppFavorites.getAppFavorites().isFavorite(this._source.app.get_id());
+
+ if (isFavorite) {
+ let item = this._appendMenuItem(_("Remove from Favorites"));
+ item.connect('activate', () => {
+ let favs = AppFavorites.getAppFavorites();
+ favs.removeFavorite(this._source.app.get_id());
+ });
+ } else {
+ let item = this._appendMenuItem(_("Add to Favorites"));
+ item.connect('activate', () => {
+ let favs = AppFavorites.getAppFavorites();
+ favs.addFavorite(this._source.app.get_id());
+ });
+ }
+ }
+
+ if (Shell.AppSystem.get_default().lookup_app('org.gnome.Software.desktop')) {
+ this._appendSeparator();
+ let item = this._appendMenuItem(_("Show Details"));
+ item.connect('activate', async () => {
+ let id = this._source.app.get_id();
+ let args = GLib.Variant.new('(ss)', [id, '']);
+ const bus = await Gio.DBus.get(Gio.BusType.SESSION, null);
+ bus.call(
+ 'org.gnome.Software',
+ '/org/gnome/Software',
+ 'org.gtk.Actions', 'Activate',
+ new GLib.Variant.new(
+ '(sava{sv})', ['details', [args], null]),
+ null, 0, -1, null);
+ Main.overview.hide();
+ });
+ }
+ }
+ }
+
+ _appendSeparator() {
+ let separator = new PopupMenu.PopupSeparatorMenuItem();
+ this.addMenuItem(separator);
+ }
+
+ _appendMenuItem(labelText) {
+ // FIXME: app-well-menu-item style
+ let item = new PopupMenu.PopupMenuItem(labelText);
+ this.addMenuItem(item);
+ return item;
+ }
+
+ popup(_activatingButton) {
+ this._rebuildMenu();
+ this.open();
+ }
+};
+Signals.addSignalMethods(AppIconMenu.prototype);
+
+var SystemActionIcon = GObject.registerClass(
+class SystemActionIcon extends Search.GridSearchResult {
+ activate() {
+ SystemActions.getDefault().activateAction(this.metaInfo['id']);
+ Main.overview.hide();
+ }
+});
diff --git a/js/ui/appFavorites.js b/js/ui/appFavorites.js
new file mode 100644
index 0000000..a876727
--- /dev/null
+++ b/js/ui/appFavorites.js
@@ -0,0 +1,211 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported getAppFavorites */
+
+const Shell = imports.gi.Shell;
+const ParentalControlsManager = imports.misc.parentalControlsManager;
+const Signals = imports.signals;
+
+const Main = imports.ui.main;
+
+// In alphabetical order
+const RENAMED_DESKTOP_IDS = {
+ 'baobab.desktop': 'org.gnome.baobab.desktop',
+ 'cheese.desktop': 'org.gnome.Cheese.desktop',
+ 'dconf-editor.desktop': 'ca.desrt.dconf-editor.desktop',
+ 'empathy.desktop': 'org.gnome.Empathy.desktop',
+ 'eog.desktop': 'org.gnome.eog.desktop',
+ 'epiphany.desktop': 'org.gnome.Epiphany.desktop',
+ 'evolution.desktop': 'org.gnome.Evolution.desktop',
+ 'file-roller.desktop': 'org.gnome.FileRoller.desktop',
+ 'five-or-more.desktop': 'org.gnome.five-or-more.desktop',
+ 'four-in-a-row.desktop': 'org.gnome.Four-in-a-row.desktop',
+ 'gcalctool.desktop': 'org.gnome.Calculator.desktop',
+ 'geary.desktop': 'org.gnome.Geary.desktop',
+ 'gedit.desktop': 'org.gnome.gedit.desktop',
+ 'glchess.desktop': 'org.gnome.Chess.desktop',
+ 'glines.desktop': 'org.gnome.five-or-more.desktop',
+ 'gnect.desktop': 'org.gnome.Four-in-a-row.desktop',
+ 'gnibbles.desktop': 'org.gnome.Nibbles.desktop',
+ 'gnobots2.desktop': 'org.gnome.Robots.desktop',
+ 'gnome-boxes.desktop': 'org.gnome.Boxes.desktop',
+ 'gnome-calculator.desktop': 'org.gnome.Calculator.desktop',
+ 'gnome-chess.desktop': 'org.gnome.Chess.desktop',
+ 'gnome-clocks.desktop': 'org.gnome.clocks.desktop',
+ 'gnome-contacts.desktop': 'org.gnome.Contacts.desktop',
+ 'gnome-documents.desktop': 'org.gnome.Documents.desktop',
+ 'gnome-font-viewer.desktop': 'org.gnome.font-viewer.desktop',
+ 'gnome-klotski.desktop': 'org.gnome.Klotski.desktop',
+ 'gnome-nibbles.desktop': 'org.gnome.Nibbles.desktop',
+ 'gnome-mahjongg.desktop': 'org.gnome.Mahjongg.desktop',
+ 'gnome-mines.desktop': 'org.gnome.Mines.desktop',
+ 'gnome-music.desktop': 'org.gnome.Music.desktop',
+ 'gnome-photos.desktop': 'org.gnome.Photos.desktop',
+ 'gnome-robots.desktop': 'org.gnome.Robots.desktop',
+ 'gnome-screenshot.desktop': 'org.gnome.Screenshot.desktop',
+ 'gnome-software.desktop': 'org.gnome.Software.desktop',
+ 'gnome-terminal.desktop': 'org.gnome.Terminal.desktop',
+ 'gnome-tetravex.desktop': 'org.gnome.Tetravex.desktop',
+ 'gnome-tweaks.desktop': 'org.gnome.tweaks.desktop',
+ 'gnome-weather.desktop': 'org.gnome.Weather.desktop',
+ 'gnomine.desktop': 'org.gnome.Mines.desktop',
+ 'gnotravex.desktop': 'org.gnome.Tetravex.desktop',
+ 'gnotski.desktop': 'org.gnome.Klotski.desktop',
+ 'gtali.desktop': 'org.gnome.Tali.desktop',
+ 'iagno.desktop': 'org.gnome.Reversi.desktop',
+ 'nautilus.desktop': 'org.gnome.Nautilus.desktop',
+ 'org.gnome.gnome-2048.desktop': 'org.gnome.TwentyFortyEight.desktop',
+ 'org.gnome.taquin.desktop': 'org.gnome.Taquin.desktop',
+ 'org.gnome.Weather.Application.desktop': 'org.gnome.Weather.desktop',
+ 'polari.desktop': 'org.gnome.Polari.desktop',
+ 'seahorse.desktop': 'org.gnome.seahorse.Application.desktop',
+ 'shotwell.desktop': 'org.gnome.Shotwell.desktop',
+ 'tali.desktop': 'org.gnome.Tali.desktop',
+ 'totem.desktop': 'org.gnome.Totem.desktop',
+ 'evince.desktop': 'org.gnome.Evince.desktop',
+};
+
+class AppFavorites {
+ constructor() {
+ // Filter the apps through the user’s parental controls.
+ this._parentalControlsManager = ParentalControlsManager.getDefault();
+ this._parentalControlsManager.connect('app-filter-changed', () => {
+ this.reload();
+ this.emit('changed');
+ });
+
+ this.FAVORITE_APPS_KEY = 'favorite-apps';
+ this._favorites = {};
+ global.settings.connect('changed::%s'.format(this.FAVORITE_APPS_KEY), this._onFavsChanged.bind(this));
+ this.reload();
+ }
+
+ _onFavsChanged() {
+ this.reload();
+ this.emit('changed');
+ }
+
+ reload() {
+ let ids = global.settings.get_strv(this.FAVORITE_APPS_KEY);
+ let appSys = Shell.AppSystem.get_default();
+
+ // Map old desktop file names to the current ones
+ let updated = false;
+ ids = ids.map(id => {
+ let newId = RENAMED_DESKTOP_IDS[id];
+ if (newId !== undefined &&
+ appSys.lookup_app(newId) != null) {
+ updated = true;
+ return newId;
+ }
+ return id;
+ });
+ // ... and write back the updated desktop file names
+ if (updated)
+ global.settings.set_strv(this.FAVORITE_APPS_KEY, ids);
+
+ let apps = ids.map(id => appSys.lookup_app(id))
+ .filter(app => app !== null && this._parentalControlsManager.shouldShowApp(app.app_info));
+ this._favorites = {};
+ for (let i = 0; i < apps.length; i++) {
+ let app = apps[i];
+ this._favorites[app.get_id()] = app;
+ }
+ }
+
+ _getIds() {
+ let ret = [];
+ for (let id in this._favorites)
+ ret.push(id);
+ return ret;
+ }
+
+ getFavoriteMap() {
+ return this._favorites;
+ }
+
+ getFavorites() {
+ let ret = [];
+ for (let id in this._favorites)
+ ret.push(this._favorites[id]);
+ return ret;
+ }
+
+ isFavorite(appId) {
+ return appId in this._favorites;
+ }
+
+ _addFavorite(appId, pos) {
+ if (appId in this._favorites)
+ return false;
+
+ let app = Shell.AppSystem.get_default().lookup_app(appId);
+
+ if (!app)
+ return false;
+
+ if (!this._parentalControlsManager.shouldShowApp(app.app_info))
+ return false;
+
+ let ids = this._getIds();
+ if (pos == -1)
+ ids.push(appId);
+ else
+ ids.splice(pos, 0, appId);
+ global.settings.set_strv(this.FAVORITE_APPS_KEY, ids);
+ return true;
+ }
+
+ addFavoriteAtPos(appId, pos) {
+ if (!this._addFavorite(appId, pos))
+ return;
+
+ let app = Shell.AppSystem.get_default().lookup_app(appId);
+
+ let msg = _("%s has been added to your favorites.").format(app.get_name());
+ Main.overview.setMessage(msg, {
+ forFeedback: true,
+ undoCallback: () => this._removeFavorite(appId),
+ });
+ }
+
+ addFavorite(appId) {
+ this.addFavoriteAtPos(appId, -1);
+ }
+
+ moveFavoriteToPos(appId, pos) {
+ this._removeFavorite(appId);
+ this._addFavorite(appId, pos);
+ }
+
+ _removeFavorite(appId) {
+ if (!(appId in this._favorites))
+ return false;
+
+ let ids = this._getIds().filter(id => id != appId);
+ global.settings.set_strv(this.FAVORITE_APPS_KEY, ids);
+ return true;
+ }
+
+ removeFavorite(appId) {
+ let ids = this._getIds();
+ let pos = ids.indexOf(appId);
+
+ let app = this._favorites[appId];
+ if (!this._removeFavorite(appId))
+ return;
+
+ let msg = _("%s has been removed from your favorites.").format(app.get_name());
+ Main.overview.setMessage(msg, {
+ forFeedback: true,
+ undoCallback: () => this._addFavorite(appId, pos),
+ });
+ }
+}
+Signals.addSignalMethods(AppFavorites.prototype);
+
+var appFavoritesInstance = null;
+function getAppFavorites() {
+ if (appFavoritesInstance == null)
+ appFavoritesInstance = new AppFavorites();
+ return appFavoritesInstance;
+}
diff --git a/js/ui/audioDeviceSelection.js b/js/ui/audioDeviceSelection.js
new file mode 100644
index 0000000..8660663
--- /dev/null
+++ b/js/ui/audioDeviceSelection.js
@@ -0,0 +1,199 @@
+/* exported AudioDeviceSelectionDBus */
+const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi;
+
+const Dialog = imports.ui.dialog;
+const Main = imports.ui.main;
+const ModalDialog = imports.ui.modalDialog;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+var AudioDevice = {
+ HEADPHONES: 1 << 0,
+ HEADSET: 1 << 1,
+ MICROPHONE: 1 << 2,
+};
+
+const AudioDeviceSelectionIface = loadInterfaceXML('org.gnome.Shell.AudioDeviceSelection');
+
+var AudioDeviceSelectionDialog = GObject.registerClass({
+ Signals: { 'device-selected': { param_types: [GObject.TYPE_UINT] } },
+}, class AudioDeviceSelectionDialog extends ModalDialog.ModalDialog {
+ _init(devices) {
+ super._init({ styleClass: 'audio-device-selection-dialog' });
+
+ this._deviceItems = {};
+
+ this._buildLayout();
+
+ if (devices & AudioDevice.HEADPHONES)
+ this._addDevice(AudioDevice.HEADPHONES);
+ if (devices & AudioDevice.HEADSET)
+ this._addDevice(AudioDevice.HEADSET);
+ if (devices & AudioDevice.MICROPHONE)
+ this._addDevice(AudioDevice.MICROPHONE);
+
+ if (this._selectionBox.get_n_children() < 2)
+ throw new Error('Too few devices for a selection');
+ }
+
+ _buildLayout() {
+ let content = new Dialog.MessageDialogContent({
+ title: _('Select Audio Device'),
+ });
+
+ this._selectionBox = new St.BoxLayout({
+ style_class: 'audio-selection-box',
+ x_align: Clutter.ActorAlign.CENTER,
+ x_expand: true,
+ });
+ content.add_child(this._selectionBox);
+
+ this.contentLayout.add_child(content);
+
+ if (Main.sessionMode.allowSettings) {
+ this.addButton({
+ action: this._openSettings.bind(this),
+ label: _('Sound Settings'),
+ });
+ }
+ this.addButton({
+ action: () => this.close(),
+ label: _('Cancel'),
+ key: Clutter.KEY_Escape,
+ });
+ }
+
+ _getDeviceLabel(device) {
+ switch (device) {
+ case AudioDevice.HEADPHONES:
+ return _("Headphones");
+ case AudioDevice.HEADSET:
+ return _("Headset");
+ case AudioDevice.MICROPHONE:
+ return _("Microphone");
+ default:
+ return null;
+ }
+ }
+
+ _getDeviceIcon(device) {
+ switch (device) {
+ case AudioDevice.HEADPHONES:
+ return 'audio-headphones-symbolic';
+ case AudioDevice.HEADSET:
+ return 'audio-headset-symbolic';
+ case AudioDevice.MICROPHONE:
+ return 'audio-input-microphone-symbolic';
+ default:
+ return null;
+ }
+ }
+
+ _addDevice(device) {
+ let box = new St.BoxLayout({ style_class: 'audio-selection-device-box',
+ vertical: true });
+ box.connect('notify::height', () => {
+ Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
+ box.width = box.height;
+ return GLib.SOURCE_REMOVE;
+ });
+ });
+
+ let icon = new St.Icon({ style_class: 'audio-selection-device-icon',
+ icon_name: this._getDeviceIcon(device) });
+ box.add(icon);
+
+ let label = new St.Label({ style_class: 'audio-selection-device-label',
+ text: this._getDeviceLabel(device),
+ x_align: Clutter.ActorAlign.CENTER });
+ box.add(label);
+
+ let button = new St.Button({ style_class: 'audio-selection-device',
+ can_focus: true,
+ child: box });
+ this._selectionBox.add(button);
+
+ button.connect('clicked', () => {
+ this.emit('device-selected', device);
+ this.close();
+ Main.overview.hide();
+ });
+ }
+
+ _openSettings() {
+ let desktopFile = 'gnome-sound-panel.desktop';
+ let app = Shell.AppSystem.get_default().lookup_app(desktopFile);
+
+ if (!app) {
+ log('Settings panel for desktop file %s could not be loaded!'.format(desktopFile));
+ return;
+ }
+
+ this.close();
+ Main.overview.hide();
+ app.activate();
+ }
+});
+
+var AudioDeviceSelectionDBus = class AudioDeviceSelectionDBus {
+ constructor() {
+ this._audioSelectionDialog = null;
+
+ this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(AudioDeviceSelectionIface, this);
+ this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell/AudioDeviceSelection');
+
+ Gio.DBus.session.own_name('org.gnome.Shell.AudioDeviceSelection', Gio.BusNameOwnerFlags.REPLACE, null, null);
+ }
+
+ _onDialogClosed() {
+ this._audioSelectionDialog = null;
+ }
+
+ _onDeviceSelected(dialog, device) {
+ let connection = this._dbusImpl.get_connection();
+ let info = this._dbusImpl.get_info();
+ const deviceName = Object.keys(AudioDevice)
+ .filter(dev => AudioDevice[dev] === device)[0].toLowerCase();
+ connection.emit_signal(this._audioSelectionDialog._sender,
+ this._dbusImpl.get_object_path(),
+ info ? info.name : null,
+ 'DeviceSelected',
+ GLib.Variant.new('(s)', [deviceName]));
+ }
+
+ OpenAsync(params, invocation) {
+ if (this._audioSelectionDialog) {
+ invocation.return_value(null);
+ return;
+ }
+
+ let [deviceNames] = params;
+ let devices = 0;
+ deviceNames.forEach(n => (devices |= AudioDevice[n.toUpperCase()]));
+
+ let dialog;
+ try {
+ dialog = new AudioDeviceSelectionDialog(devices);
+ } catch (e) {
+ invocation.return_value(null);
+ return;
+ }
+ dialog._sender = invocation.get_sender();
+
+ dialog.connect('closed', this._onDialogClosed.bind(this));
+ dialog.connect('device-selected',
+ this._onDeviceSelected.bind(this));
+ dialog.open();
+
+ this._audioSelectionDialog = dialog;
+ invocation.return_value(null);
+ }
+
+ CloseAsync(params, invocation) {
+ if (this._audioSelectionDialog &&
+ this._audioSelectionDialog._sender == invocation.get_sender())
+ this._audioSelectionDialog.close();
+
+ invocation.return_value(null);
+ }
+};
diff --git a/js/ui/background.js b/js/ui/background.js
new file mode 100644
index 0000000..ca34451
--- /dev/null
+++ b/js/ui/background.js
@@ -0,0 +1,798 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported SystemBackground */
+
+// READ THIS FIRST
+// Background handling is a maze of objects, both objects in this file, and
+// also objects inside Mutter. They all have a role.
+//
+// BackgroundManager
+// The only object that other parts of GNOME Shell deal with; a
+// BackgroundManager creates background actors and adds them to
+// the specified container. When the background is changed by the
+// user it will fade out the old actor and fade in the new actor.
+// (This is separate from the fading for an animated background,
+// since using two actors is quite inefficient.)
+//
+// MetaBackgroundImage
+// An object represented an image file that will be used for drawing
+// the background. MetaBackgroundImage objects asynchronously load,
+// so they are first created in an unloaded state, then later emit
+// a ::loaded signal when the Cogl object becomes available.
+//
+// MetaBackgroundImageCache
+// A cache from filename to MetaBackgroundImage.
+//
+// BackgroundSource
+// An object that is created for each GSettings schema (separate
+// settings schemas are used for the lock screen and main background),
+// and holds a reference to shared Background objects.
+//
+// MetaBackground
+// Holds the specification of a background - a background color
+// or gradient and one or two images blended together.
+//
+// Background
+// JS delegate object that Connects a MetaBackground to the GSettings
+// schema for the background.
+//
+// Animation
+// A helper object that handles loading a XML-based animation; it is a
+// wrapper for GnomeDesktop.BGSlideShow
+//
+// MetaBackgroundActor
+// An actor that draws the background for a single monitor
+//
+// BackgroundCache
+// A cache of Settings schema => BackgroundSource and of a single Animation.
+// Also used to share file monitors.
+//
+// A static image, background color or gradient is relatively straightforward. The
+// calling code creates a separate BackgroundManager for each monitor. Since they
+// are created for the same GSettings schema, they will use the same BackgroundSource
+// object, which provides a single Background and correspondingly a single
+// MetaBackground object.
+//
+// BackgroundManager BackgroundManager
+// | \ / |
+// | BackgroundSource | looked up in BackgroundCache
+// | | |
+// | Background |
+// | | |
+// MetaBackgroundActor | MetaBackgroundActor
+// \ | /
+// `------- MetaBackground ------'
+// |
+// MetaBackgroundImage looked up in MetaBackgroundImageCache
+//
+// The animated case is tricker because the animation XML file can specify different
+// files for different monitor resolutions and aspect ratios. For this reason,
+// the BackgroundSource provides different Background share a single Animation object,
+// which tracks the animation, but use different MetaBackground objects. In the
+// common case, the different MetaBackground objects will be created for the
+// same filename and look up the *same* MetaBackgroundImage object, so there is
+// little wasted memory:
+//
+// BackgroundManager BackgroundManager
+// | \ / |
+// | BackgroundSource | looked up in BackgroundCache
+// | / \ |
+// | Background Background |
+// | | \ / | |
+// | | Animation | | looked up in BackgroundCache
+// MetaBackgroundA|tor Me|aBackgroundActor
+// \ | | /
+// MetaBackground MetaBackground
+// \ /
+// MetaBackgroundImage looked up in MetaBackgroundImageCache
+// MetaBackgroundImage
+//
+// But the case of different filenames and different background images
+// is possible as well:
+// ....
+// MetaBackground MetaBackground
+// | |
+// MetaBackgroundImage MetaBackgroundImage
+// MetaBackgroundImage MetaBackgroundImage
+
+const { Clutter, GDesktopEnums, Gio, GLib, GObject, GnomeDesktop, Meta } = imports.gi;
+const Signals = imports.signals;
+
+const LoginManager = imports.misc.loginManager;
+const Main = imports.ui.main;
+const Params = imports.misc.params;
+
+var DEFAULT_BACKGROUND_COLOR = Clutter.Color.from_pixel(0x2e3436ff);
+
+const BACKGROUND_SCHEMA = 'org.gnome.desktop.background';
+const PRIMARY_COLOR_KEY = 'primary-color';
+const SECONDARY_COLOR_KEY = 'secondary-color';
+const COLOR_SHADING_TYPE_KEY = 'color-shading-type';
+const BACKGROUND_STYLE_KEY = 'picture-options';
+const PICTURE_URI_KEY = 'picture-uri';
+
+var FADE_ANIMATION_TIME = 1000;
+
+// These parameters affect how often we redraw.
+// The first is how different (percent crossfaded) the slide show
+// has to look before redrawing and the second is the minimum
+// frequency (in seconds) we're willing to wake up
+var ANIMATION_OPACITY_STEP_INCREMENT = 4.0;
+var ANIMATION_MIN_WAKEUP_INTERVAL = 1.0;
+
+let _backgroundCache = null;
+
+function _fileEqual0(file1, file2) {
+ if (file1 == file2)
+ return true;
+
+ if (!file1 || !file2)
+ return false;
+
+ return file1.equal(file2);
+}
+
+var BackgroundCache = class BackgroundCache {
+ constructor() {
+ this._fileMonitors = {};
+ this._backgroundSources = {};
+ this._animations = {};
+ }
+
+ monitorFile(file) {
+ let key = file.hash();
+ if (this._fileMonitors[key])
+ return;
+
+ let monitor = file.monitor(Gio.FileMonitorFlags.NONE, null);
+ monitor.connect('changed',
+ (obj, theFile, otherFile, eventType) => {
+ // Ignore CHANGED and CREATED events, since in both cases
+ // we'll get a CHANGES_DONE_HINT event when done.
+ if (eventType != Gio.FileMonitorEvent.CHANGED &&
+ eventType != Gio.FileMonitorEvent.CREATED)
+ this.emit('file-changed', file);
+ });
+
+ this._fileMonitors[key] = monitor;
+ }
+
+ getAnimation(params) {
+ params = Params.parse(params, { file: null,
+ settingsSchema: null,
+ onLoaded: null });
+
+ let animation = this._animations[params.settingsSchema];
+ if (animation && _fileEqual0(animation.file, params.file)) {
+ if (params.onLoaded) {
+ let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
+ params.onLoaded(this._animations[params.settingsSchema]);
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(id, '[gnome-shell] params.onLoaded');
+ }
+ return;
+ }
+
+ animation = new Animation({ file: params.file });
+
+ animation.load(() => {
+ this._animations[params.settingsSchema] = animation;
+
+ if (params.onLoaded) {
+ let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
+ params.onLoaded(this._animations[params.settingsSchema]);
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(id, '[gnome-shell] params.onLoaded');
+ }
+ });
+ }
+
+ getBackgroundSource(layoutManager, settingsSchema) {
+ // The layoutManager is always the same one; we pass in it since
+ // Main.layoutManager may not be set yet
+
+ if (!(settingsSchema in this._backgroundSources)) {
+ this._backgroundSources[settingsSchema] = new BackgroundSource(layoutManager, settingsSchema);
+ this._backgroundSources[settingsSchema]._useCount = 1;
+ } else {
+ this._backgroundSources[settingsSchema]._useCount++;
+ }
+
+ return this._backgroundSources[settingsSchema];
+ }
+
+ releaseBackgroundSource(settingsSchema) {
+ if (settingsSchema in this._backgroundSources) {
+ let source = this._backgroundSources[settingsSchema];
+ source._useCount--;
+ if (source._useCount == 0) {
+ delete this._backgroundSources[settingsSchema];
+ source.destroy();
+ }
+ }
+ }
+};
+Signals.addSignalMethods(BackgroundCache.prototype);
+
+function getBackgroundCache() {
+ if (!_backgroundCache)
+ _backgroundCache = new BackgroundCache();
+ return _backgroundCache;
+}
+
+var Background = GObject.registerClass({
+ Signals: { 'loaded': {}, 'bg-changed': {} },
+}, class Background extends Meta.Background {
+ _init(params) {
+ params = Params.parse(params, { monitorIndex: 0,
+ layoutManager: Main.layoutManager,
+ settings: null,
+ file: null,
+ style: null });
+
+ super._init({ meta_display: global.display });
+
+ this._settings = params.settings;
+ this._file = params.file;
+ this._style = params.style;
+ this._monitorIndex = params.monitorIndex;
+ this._layoutManager = params.layoutManager;
+ this._fileWatches = {};
+ this._cancellable = new Gio.Cancellable();
+ this.isLoaded = false;
+
+ this._clock = new GnomeDesktop.WallClock();
+ this._timezoneChangedId = this._clock.connect('notify::timezone',
+ () => {
+ if (this._animation)
+ this._loadAnimation(this._animation.file);
+ });
+
+ let loginManager = LoginManager.getLoginManager();
+ this._prepareForSleepId = loginManager.connect('prepare-for-sleep',
+ (lm, aboutToSuspend) => {
+ if (aboutToSuspend)
+ return;
+ this._refreshAnimation();
+ });
+
+ this._settingsChangedSignalId =
+ this._settings.connect('changed', this._emitChangedSignal.bind(this));
+
+ this._load();
+ }
+
+ destroy() {
+ this._cancellable.cancel();
+ this._removeAnimationTimeout();
+
+ let i;
+ let keys = Object.keys(this._fileWatches);
+ for (i = 0; i < keys.length; i++)
+ this._cache.disconnect(this._fileWatches[keys[i]]);
+
+ this._fileWatches = null;
+
+ if (this._timezoneChangedId != 0)
+ this._clock.disconnect(this._timezoneChangedId);
+ this._timezoneChangedId = 0;
+
+ this._clock = null;
+
+ if (this._prepareForSleepId != 0)
+ LoginManager.getLoginManager().disconnect(this._prepareForSleepId);
+ this._prepareForSleepId = 0;
+
+ if (this._settingsChangedSignalId != 0)
+ this._settings.disconnect(this._settingsChangedSignalId);
+ this._settingsChangedSignalId = 0;
+
+ if (this._changedIdleId) {
+ GLib.source_remove(this._changedIdleId);
+ this._changedIdleId = 0;
+ }
+ }
+
+ _emitChangedSignal() {
+ if (this._changedIdleId)
+ return;
+
+ this._changedIdleId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
+ this._changedIdleId = 0;
+ this.emit('bg-changed');
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._changedIdleId,
+ '[gnome-shell] Background._emitChangedSignal');
+ }
+
+ updateResolution() {
+ if (this._animation)
+ this._refreshAnimation();
+ }
+
+ _refreshAnimation() {
+ if (!this._animation)
+ return;
+
+ this._removeAnimationTimeout();
+ this._updateAnimation();
+ }
+
+ _setLoaded() {
+ if (this.isLoaded)
+ return;
+
+ this.isLoaded = true;
+
+ let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
+ this.emit('loaded');
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(id, '[gnome-shell] Background._setLoaded Idle');
+ }
+
+ _loadPattern() {
+ let colorString, res_, color, secondColor;
+
+ colorString = this._settings.get_string(PRIMARY_COLOR_KEY);
+ [res_, color] = Clutter.Color.from_string(colorString);
+ colorString = this._settings.get_string(SECONDARY_COLOR_KEY);
+ [res_, secondColor] = Clutter.Color.from_string(colorString);
+
+ let shadingType = this._settings.get_enum(COLOR_SHADING_TYPE_KEY);
+
+ if (shadingType == GDesktopEnums.BackgroundShading.SOLID)
+ this.set_color(color);
+ else
+ this.set_gradient(shadingType, color, secondColor);
+
+ this._setLoaded();
+ }
+
+ _watchFile(file) {
+ let key = file.hash();
+ if (this._fileWatches[key])
+ return;
+
+ this._cache.monitorFile(file);
+ let signalId = this._cache.connect('file-changed',
+ (cache, changedFile) => {
+ if (changedFile.equal(file)) {
+ let imageCache = Meta.BackgroundImageCache.get_default();
+ imageCache.purge(changedFile);
+ this._emitChangedSignal();
+ }
+ });
+ this._fileWatches[key] = signalId;
+ }
+
+ _removeAnimationTimeout() {
+ if (this._updateAnimationTimeoutId) {
+ GLib.source_remove(this._updateAnimationTimeoutId);
+ this._updateAnimationTimeoutId = 0;
+ }
+ }
+
+ _updateAnimation() {
+ this._updateAnimationTimeoutId = 0;
+
+ this._animation.update(this._layoutManager.monitors[this._monitorIndex]);
+ let files = this._animation.keyFrameFiles;
+
+ let finish = () => {
+ this._setLoaded();
+ if (files.length > 1) {
+ this.set_blend(files[0], files[1],
+ this._animation.transitionProgress,
+ this._style);
+ } else if (files.length > 0) {
+ this.set_file(files[0], this._style);
+ } else {
+ this.set_file(null, this._style);
+ }
+ this._queueUpdateAnimation();
+ };
+
+ let cache = Meta.BackgroundImageCache.get_default();
+ let numPendingImages = files.length;
+ for (let i = 0; i < files.length; i++) {
+ this._watchFile(files[i]);
+ let image = cache.load(files[i]);
+ if (image.is_loaded()) {
+ numPendingImages--;
+ if (numPendingImages == 0)
+ finish();
+ } else {
+ // eslint-disable-next-line no-loop-func
+ let id = image.connect('loaded', () => {
+ image.disconnect(id);
+ numPendingImages--;
+ if (numPendingImages == 0)
+ finish();
+ });
+ }
+ }
+ }
+
+ _queueUpdateAnimation() {
+ if (this._updateAnimationTimeoutId != 0)
+ return;
+
+ if (!this._cancellable || this._cancellable.is_cancelled())
+ return;
+
+ if (!this._animation.transitionDuration)
+ return;
+
+ let nSteps = 255 / ANIMATION_OPACITY_STEP_INCREMENT;
+ let timePerStep = (this._animation.transitionDuration * 1000) / nSteps;
+
+ let interval = Math.max(ANIMATION_MIN_WAKEUP_INTERVAL * 1000,
+ timePerStep);
+
+ if (interval > GLib.MAXUINT32)
+ return;
+
+ this._updateAnimationTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT,
+ interval,
+ () => {
+ this._updateAnimationTimeoutId = 0;
+ this._updateAnimation();
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._updateAnimationTimeoutId, '[gnome-shell] this._updateAnimation');
+ }
+
+ _loadAnimation(file) {
+ this._cache.getAnimation({
+ file,
+ settingsSchema: this._settings.schema_id,
+ onLoaded: animation => {
+ this._animation = animation;
+
+ if (!this._animation || this._cancellable.is_cancelled()) {
+ this._setLoaded();
+ return;
+ }
+
+ this._updateAnimation();
+ this._watchFile(file);
+ },
+ });
+ }
+
+ _loadImage(file) {
+ this.set_file(file, this._style);
+ this._watchFile(file);
+
+ let cache = Meta.BackgroundImageCache.get_default();
+ let image = cache.load(file);
+ if (image.is_loaded()) {
+ this._setLoaded();
+ } else {
+ let id = image.connect('loaded', () => {
+ this._setLoaded();
+ image.disconnect(id);
+ });
+ }
+ }
+
+ _loadFile(file) {
+ if (file.get_basename().endsWith('.xml'))
+ this._loadAnimation(file);
+ else
+ this._loadImage(file);
+ }
+
+ _load() {
+ this._cache = getBackgroundCache();
+
+ this._loadPattern();
+
+ if (!this._file) {
+ this._setLoaded();
+ return;
+ }
+
+ this._loadFile(this._file);
+ }
+});
+
+let _systemBackground;
+
+var SystemBackground = GObject.registerClass({
+ Signals: { 'loaded': {} },
+}, class SystemBackground extends Meta.BackgroundActor {
+ _init() {
+ if (_systemBackground == null) {
+ _systemBackground = new Meta.Background({ meta_display: global.display });
+ _systemBackground.set_color(DEFAULT_BACKGROUND_COLOR);
+ }
+
+ super._init({
+ meta_display: global.display,
+ monitor: 0,
+ });
+ this.content.background = _systemBackground;
+
+ let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
+ this.emit('loaded');
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(id, '[gnome-shell] SystemBackground.loaded');
+ }
+});
+
+var BackgroundSource = class BackgroundSource {
+ constructor(layoutManager, settingsSchema) {
+ // Allow override the background image setting for performance testing
+ this._layoutManager = layoutManager;
+ this._overrideImage = GLib.getenv('SHELL_BACKGROUND_IMAGE');
+ this._settings = new Gio.Settings({ schema_id: settingsSchema });
+ this._backgrounds = [];
+
+ let monitorManager = Meta.MonitorManager.get();
+ this._monitorsChangedId =
+ monitorManager.connect('monitors-changed',
+ this._onMonitorsChanged.bind(this));
+ }
+
+ _onMonitorsChanged() {
+ for (let monitorIndex in this._backgrounds) {
+ let background = this._backgrounds[monitorIndex];
+
+ if (monitorIndex < this._layoutManager.monitors.length) {
+ background.updateResolution();
+ } else {
+ background.disconnect(background._changedId);
+ background.destroy();
+ delete this._backgrounds[monitorIndex];
+ }
+ }
+ }
+
+ getBackground(monitorIndex) {
+ let file = null;
+ let style;
+
+ // We don't watch changes to settings here,
+ // instead we rely on Background to watch those
+ // and emit 'bg-changed' at the right time
+
+ if (this._overrideImage != null) {
+ file = Gio.File.new_for_path(this._overrideImage);
+ style = GDesktopEnums.BackgroundStyle.ZOOM; // Hardcode
+ } else {
+ style = this._settings.get_enum(BACKGROUND_STYLE_KEY);
+ if (style != GDesktopEnums.BackgroundStyle.NONE) {
+ let uri = this._settings.get_string(PICTURE_URI_KEY);
+ file = Gio.File.new_for_commandline_arg(uri);
+ }
+ }
+
+ // Animated backgrounds are (potentially) per-monitor, since
+ // they can have variants that depend on the aspect ratio and
+ // size of the monitor; for other backgrounds we can use the
+ // same background object for all monitors.
+ if (file == null || !file.get_basename().endsWith('.xml'))
+ monitorIndex = 0;
+
+ if (!(monitorIndex in this._backgrounds)) {
+ let background = new Background({
+ monitorIndex,
+ layoutManager: this._layoutManager,
+ settings: this._settings,
+ file,
+ style,
+ });
+
+ background._changedId = background.connect('bg-changed', () => {
+ background.disconnect(background._changedId);
+ background.destroy();
+ delete this._backgrounds[monitorIndex];
+ });
+
+ this._backgrounds[monitorIndex] = background;
+ }
+
+ return this._backgrounds[monitorIndex];
+ }
+
+ destroy() {
+ let monitorManager = Meta.MonitorManager.get();
+ monitorManager.disconnect(this._monitorsChangedId);
+
+ for (let monitorIndex in this._backgrounds) {
+ let background = this._backgrounds[monitorIndex];
+ background.disconnect(background._changedId);
+ background.destroy();
+ }
+
+ this._backgrounds = null;
+ }
+};
+
+var Animation = GObject.registerClass(
+class Animation extends GnomeDesktop.BGSlideShow {
+ _init(params) {
+ super._init(params);
+
+ this.keyFrameFiles = [];
+ this.transitionProgress = 0.0;
+ this.transitionDuration = 0.0;
+ this.loaded = false;
+ }
+
+ load(callback) {
+ this.load_async(null, () => {
+ this.loaded = true;
+ if (callback)
+ callback();
+ });
+ }
+
+ update(monitor) {
+ this.keyFrameFiles = [];
+
+ if (this.get_num_slides() < 1)
+ return;
+
+ let [progress, duration, isFixed_, filename1, filename2] =
+ this.get_current_slide(monitor.width, monitor.height);
+
+ this.transitionDuration = duration;
+ this.transitionProgress = progress;
+
+ if (filename1)
+ this.keyFrameFiles.push(Gio.File.new_for_path(filename1));
+
+ if (filename2)
+ this.keyFrameFiles.push(Gio.File.new_for_path(filename2));
+ }
+});
+
+var BackgroundManager = class BackgroundManager {
+ constructor(params) {
+ params = Params.parse(params, { container: null,
+ layoutManager: Main.layoutManager,
+ monitorIndex: null,
+ vignette: false,
+ controlPosition: true,
+ settingsSchema: BACKGROUND_SCHEMA });
+
+ let cache = getBackgroundCache();
+ this._settingsSchema = params.settingsSchema;
+ this._backgroundSource = cache.getBackgroundSource(params.layoutManager, params.settingsSchema);
+
+ this._container = params.container;
+ this._layoutManager = params.layoutManager;
+ this._vignette = params.vignette;
+ this._monitorIndex = params.monitorIndex;
+ this._controlPosition = params.controlPosition;
+
+ this.backgroundActor = this._createBackgroundActor();
+ this._newBackgroundActor = null;
+ }
+
+ destroy() {
+ let cache = getBackgroundCache();
+ cache.releaseBackgroundSource(this._settingsSchema);
+ this._backgroundSource = null;
+
+ if (this._newBackgroundActor) {
+ this._newBackgroundActor.destroy();
+ this._newBackgroundActor = null;
+ }
+
+ if (this.backgroundActor) {
+ this.backgroundActor.destroy();
+ this.backgroundActor = null;
+ }
+ }
+
+ _swapBackgroundActor() {
+ let oldBackgroundActor = this.backgroundActor;
+ this.backgroundActor = this._newBackgroundActor;
+ this._newBackgroundActor = null;
+ this.emit('changed');
+
+ oldBackgroundActor.ease({
+ opacity: 0,
+ duration: FADE_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => oldBackgroundActor.destroy(),
+ });
+ }
+
+ _updateBackgroundActor() {
+ if (this._newBackgroundActor) {
+ /* Skip displaying existing background queued for load */
+ this._newBackgroundActor.destroy();
+ this._newBackgroundActor = null;
+ }
+
+ let newBackgroundActor = this._createBackgroundActor();
+
+ const oldContent = this.backgroundActor.content;
+ const newContent = newBackgroundActor.content;
+
+ newContent.vignette_sharpness = oldContent.vignette_sharpness;
+ newContent.brightness = oldContent.brightness;
+
+ newBackgroundActor.visible = this.backgroundActor.visible;
+
+ this._newBackgroundActor = newBackgroundActor;
+
+ const { background } = newBackgroundActor.content;
+
+ if (background.isLoaded) {
+ this._swapBackgroundActor();
+ } else {
+ newBackgroundActor.loadedSignalId = background.connect('loaded',
+ () => {
+ background.disconnect(newBackgroundActor.loadedSignalId);
+ newBackgroundActor.loadedSignalId = 0;
+
+ this._swapBackgroundActor();
+ });
+ }
+ }
+
+ _createBackgroundActor() {
+ let background = this._backgroundSource.getBackground(this._monitorIndex);
+ let backgroundActor = new Meta.BackgroundActor({
+ meta_display: global.display,
+ monitor: this._monitorIndex,
+ });
+ backgroundActor.content.set({
+ background,
+ vignette: this._vignette,
+ vignette_sharpness: 0.5,
+ brightness: 0.5,
+ });
+
+ this._container.add_child(backgroundActor);
+
+ if (this._controlPosition) {
+ let monitor = this._layoutManager.monitors[this._monitorIndex];
+ backgroundActor.set_position(monitor.x, monitor.y);
+ this._container.set_child_below_sibling(backgroundActor, null);
+ }
+
+ let changeSignalId = background.connect('bg-changed', () => {
+ background.disconnect(changeSignalId);
+ changeSignalId = null;
+ this._updateBackgroundActor();
+ });
+
+ let loadedSignalId;
+ if (background.isLoaded) {
+ GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
+ this.emit('loaded');
+ return GLib.SOURCE_REMOVE;
+ });
+ } else {
+ loadedSignalId = background.connect('loaded', () => {
+ background.disconnect(loadedSignalId);
+ loadedSignalId = null;
+ this.emit('loaded');
+ });
+ }
+
+ backgroundActor.connect('destroy', () => {
+ if (changeSignalId)
+ background.disconnect(changeSignalId);
+
+ if (loadedSignalId)
+ background.disconnect(loadedSignalId);
+
+ if (backgroundActor.loadedSignalId)
+ background.disconnect(backgroundActor.loadedSignalId);
+ });
+
+ return backgroundActor;
+ }
+};
+Signals.addSignalMethods(BackgroundManager.prototype);
diff --git a/js/ui/backgroundMenu.js b/js/ui/backgroundMenu.js
new file mode 100644
index 0000000..e31e4c1
--- /dev/null
+++ b/js/ui/backgroundMenu.js
@@ -0,0 +1,69 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported addBackgroundMenu */
+
+const { Clutter, St } = imports.gi;
+
+const BoxPointer = imports.ui.boxpointer;
+const Main = imports.ui.main;
+const PopupMenu = imports.ui.popupMenu;
+
+var BackgroundMenu = class BackgroundMenu extends PopupMenu.PopupMenu {
+ constructor(layoutManager) {
+ super(layoutManager.dummyCursor, 0, St.Side.TOP);
+
+ this.addSettingsAction(_("Change Background…"), 'gnome-background-panel.desktop');
+ this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+ this.addSettingsAction(_("Display Settings"), 'gnome-display-panel.desktop');
+ this.addSettingsAction(_("Settings"), 'gnome-control-center.desktop');
+
+ this.actor.add_style_class_name('background-menu');
+
+ layoutManager.uiGroup.add_actor(this.actor);
+ this.actor.hide();
+ }
+};
+
+function addBackgroundMenu(actor, layoutManager) {
+ actor.reactive = true;
+ actor._backgroundMenu = new BackgroundMenu(layoutManager);
+ actor._backgroundManager = new PopupMenu.PopupMenuManager(actor);
+ actor._backgroundManager.addMenu(actor._backgroundMenu);
+
+ function openMenu(x, y) {
+ Main.layoutManager.setDummyCursorGeometry(x, y, 0, 0);
+ actor._backgroundMenu.open(BoxPointer.PopupAnimation.FULL);
+ }
+
+ let clickAction = new Clutter.ClickAction();
+ clickAction.connect('long-press', (action, theActor, state) => {
+ if (state == Clutter.LongPressState.QUERY) {
+ return (action.get_button() == 0 ||
+ action.get_button() == 1) &&
+ !actor._backgroundMenu.isOpen;
+ }
+ if (state == Clutter.LongPressState.ACTIVATE) {
+ let [x, y] = action.get_coords();
+ openMenu(x, y);
+ actor._backgroundManager.ignoreRelease();
+ }
+ return true;
+ });
+ clickAction.connect('clicked', action => {
+ if (action.get_button() == 3) {
+ let [x, y] = action.get_coords();
+ openMenu(x, y);
+ }
+ });
+ actor.add_action(clickAction);
+
+ let grabOpBeginId = global.display.connect('grab-op-begin', () => {
+ clickAction.release();
+ });
+
+ actor.connect('destroy', () => {
+ actor._backgroundMenu.destroy();
+ actor._backgroundMenu = null;
+ actor._backgroundManager = null;
+ global.display.disconnect(grabOpBeginId);
+ });
+}
diff --git a/js/ui/barLevel.js b/js/ui/barLevel.js
new file mode 100644
index 0000000..f4fdb6e
--- /dev/null
+++ b/js/ui/barLevel.js
@@ -0,0 +1,234 @@
+/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */
+/* exported BarLevel */
+
+const { Atk, Clutter, GObject, St } = imports.gi;
+
+var BarLevel = GObject.registerClass({
+ Properties: {
+ 'value': GObject.ParamSpec.double(
+ 'value', 'value', 'value',
+ GObject.ParamFlags.READWRITE,
+ 0, 2, 0),
+ 'maximum-value': GObject.ParamSpec.double(
+ 'maximum-value', 'maximum-value', 'maximum-value',
+ GObject.ParamFlags.READWRITE,
+ 1, 2, 1),
+ 'overdrive-start': GObject.ParamSpec.double(
+ 'overdrive-start', 'overdrive-start', 'overdrive-start',
+ GObject.ParamFlags.READWRITE,
+ 1, 2, 1),
+ },
+}, class BarLevel extends St.DrawingArea {
+ _init(params) {
+ this._maxValue = 1;
+ this._value = 0;
+ this._overdriveStart = 1;
+ this._barLevelWidth = 0;
+
+ let defaultParams = {
+ style_class: 'barlevel',
+ accessible_role: Atk.Role.LEVEL_BAR,
+ };
+ super._init(Object.assign(defaultParams, params));
+ this.connect('notify::allocation', () => {
+ this._barLevelWidth = this.allocation.get_width();
+ });
+
+ this._customAccessible = St.GenericAccessible.new_for_actor(this);
+ this.set_accessible(this._customAccessible);
+
+ this._customAccessible.connect('get-current-value', this._getCurrentValue.bind(this));
+ this._customAccessible.connect('get-minimum-value', this._getMinimumValue.bind(this));
+ this._customAccessible.connect('get-maximum-value', this._getMaximumValue.bind(this));
+ this._customAccessible.connect('set-current-value', this._setCurrentValue.bind(this));
+
+ this.connect('notify::value', this._valueChanged.bind(this));
+ }
+
+ get value() {
+ return this._value;
+ }
+
+ set value(value) {
+ value = Math.max(Math.min(value, this._maxValue), 0);
+
+ if (this._value == value)
+ return;
+
+ this._value = value;
+ this.notify('value');
+ this.queue_repaint();
+ }
+
+ // eslint-disable-next-line camelcase
+ get maximum_value() {
+ return this._maxValue;
+ }
+
+ // eslint-disable-next-line camelcase
+ set maximum_value(value) {
+ value = Math.max(value, 1);
+
+ if (this._maxValue == value)
+ return;
+
+ this._maxValue = value;
+ this._overdriveStart = Math.min(this._overdriveStart, this._maxValue);
+ this.notify('maximum-value');
+ this.queue_repaint();
+ }
+
+ // eslint-disable-next-line camelcase
+ get overdrive_start() {
+ return this._overdriveStart;
+ }
+
+ // eslint-disable-next-line camelcase
+ set overdrive_start(value) {
+ if (this._overdriveStart == value)
+ return;
+
+ if (value > this._maxValue) {
+ throw new Error(`Tried to set overdrive value to ${value}, ` +
+ `which is a number greater than the maximum allowed value ${this._maxValue}`);
+ }
+
+ this._overdriveStart = value;
+ this.notify('overdrive-start');
+ this.queue_repaint();
+ }
+
+ vfunc_repaint() {
+ let cr = this.get_context();
+ let themeNode = this.get_theme_node();
+ let [width, height] = this.get_surface_size();
+
+ let barLevelHeight = themeNode.get_length('-barlevel-height');
+ let barLevelBorderRadius = Math.min(width, barLevelHeight) / 2;
+ let fgColor = themeNode.get_foreground_color();
+
+ let barLevelColor = themeNode.get_color('-barlevel-background-color');
+ let barLevelActiveColor = themeNode.get_color('-barlevel-active-background-color');
+ let barLevelOverdriveColor = themeNode.get_color('-barlevel-overdrive-color');
+
+ let barLevelBorderWidth = Math.min(themeNode.get_length('-barlevel-border-width'), 1);
+ let [hasBorderColor, barLevelBorderColor] =
+ themeNode.lookup_color('-barlevel-border-color', false);
+ if (!hasBorderColor)
+ barLevelBorderColor = barLevelColor;
+ let [hasActiveBorderColor, barLevelActiveBorderColor] =
+ themeNode.lookup_color('-barlevel-active-border-color', false);
+ if (!hasActiveBorderColor)
+ barLevelActiveBorderColor = barLevelActiveColor;
+ let [hasOverdriveBorderColor, barLevelOverdriveBorderColor] =
+ themeNode.lookup_color('-barlevel-overdrive-border-color', false);
+ if (!hasOverdriveBorderColor)
+ barLevelOverdriveBorderColor = barLevelOverdriveColor;
+
+ const TAU = Math.PI * 2;
+
+ let endX = 0;
+ if (this._maxValue > 0)
+ endX = barLevelBorderRadius + (width - 2 * barLevelBorderRadius) * this._value / this._maxValue;
+
+ let overdriveSeparatorX = barLevelBorderRadius + (width - 2 * barLevelBorderRadius) * this._overdriveStart / this._maxValue;
+ let overdriveActive = this._overdriveStart !== this._maxValue;
+ let overdriveSeparatorWidth = 0;
+ if (overdriveActive)
+ overdriveSeparatorWidth = themeNode.get_length('-barlevel-overdrive-separator-width');
+
+ /* background bar */
+ cr.arc(width - barLevelBorderRadius - barLevelBorderWidth, height / 2, barLevelBorderRadius, TAU * (3 / 4), TAU * (1 / 4));
+ cr.lineTo(endX, (height + barLevelHeight) / 2);
+ cr.lineTo(endX, (height - barLevelHeight) / 2);
+ cr.lineTo(width - barLevelBorderRadius - barLevelBorderWidth, (height - barLevelHeight) / 2);
+ Clutter.cairo_set_source_color(cr, barLevelColor);
+ cr.fillPreserve();
+ Clutter.cairo_set_source_color(cr, barLevelBorderColor);
+ cr.setLineWidth(barLevelBorderWidth);
+ cr.stroke();
+
+ /* normal progress bar */
+ let x = Math.min(endX, overdriveSeparatorX - overdriveSeparatorWidth / 2);
+ cr.arc(barLevelBorderRadius + barLevelBorderWidth, height / 2, barLevelBorderRadius, TAU * (1 / 4), TAU * (3 / 4));
+ cr.lineTo(x, (height - barLevelHeight) / 2);
+ cr.lineTo(x, (height + barLevelHeight) / 2);
+ cr.lineTo(barLevelBorderRadius + barLevelBorderWidth, (height + barLevelHeight) / 2);
+ if (this._value > 0)
+ Clutter.cairo_set_source_color(cr, barLevelActiveColor);
+ cr.fillPreserve();
+ Clutter.cairo_set_source_color(cr, barLevelActiveBorderColor);
+ cr.setLineWidth(barLevelBorderWidth);
+ cr.stroke();
+
+ /* overdrive progress barLevel */
+ x = Math.min(endX, overdriveSeparatorX) + overdriveSeparatorWidth / 2;
+ if (this._value > this._overdriveStart) {
+ cr.moveTo(x, (height - barLevelHeight) / 2);
+ cr.lineTo(endX, (height - barLevelHeight) / 2);
+ cr.lineTo(endX, (height + barLevelHeight) / 2);
+ cr.lineTo(x, (height + barLevelHeight) / 2);
+ cr.lineTo(x, (height - barLevelHeight) / 2);
+ Clutter.cairo_set_source_color(cr, barLevelOverdriveColor);
+ cr.fillPreserve();
+ Clutter.cairo_set_source_color(cr, barLevelOverdriveBorderColor);
+ cr.setLineWidth(barLevelBorderWidth);
+ cr.stroke();
+ }
+
+ /* end progress bar arc */
+ if (this._value > 0) {
+ if (this._value <= this._overdriveStart)
+ Clutter.cairo_set_source_color(cr, barLevelActiveColor);
+ else
+ Clutter.cairo_set_source_color(cr, barLevelOverdriveColor);
+ cr.arc(endX, height / 2, barLevelBorderRadius, TAU * (3 / 4), TAU * (1 / 4));
+ cr.lineTo(Math.floor(endX), (height + barLevelHeight) / 2);
+ cr.lineTo(Math.floor(endX), (height - barLevelHeight) / 2);
+ cr.lineTo(endX, (height - barLevelHeight) / 2);
+ cr.fillPreserve();
+ cr.setLineWidth(barLevelBorderWidth);
+ cr.stroke();
+ }
+
+ /* draw overdrive separator */
+ if (overdriveActive) {
+ cr.moveTo(overdriveSeparatorX - overdriveSeparatorWidth / 2, (height - barLevelHeight) / 2);
+ cr.lineTo(overdriveSeparatorX + overdriveSeparatorWidth / 2, (height - barLevelHeight) / 2);
+ cr.lineTo(overdriveSeparatorX + overdriveSeparatorWidth / 2, (height + barLevelHeight) / 2);
+ cr.lineTo(overdriveSeparatorX - overdriveSeparatorWidth / 2, (height + barLevelHeight) / 2);
+ cr.lineTo(overdriveSeparatorX - overdriveSeparatorWidth / 2, (height - barLevelHeight) / 2);
+ if (this._value <= this._overdriveStart)
+ Clutter.cairo_set_source_color(cr, fgColor);
+ else
+ Clutter.cairo_set_source_color(cr, barLevelColor);
+ cr.fill();
+ }
+
+ cr.$dispose();
+ }
+
+ _getCurrentValue() {
+ return this._value;
+ }
+
+ _getOverdriveStart() {
+ return this._overdriveStart;
+ }
+
+ _getMinimumValue() {
+ return 0;
+ }
+
+ _getMaximumValue() {
+ return this._maxValue;
+ }
+
+ _setCurrentValue(_actor, value) {
+ this._value = value;
+ }
+
+ _valueChanged() {
+ this._customAccessible.notify("accessible-value");
+ }
+});
diff --git a/js/ui/boxpointer.js b/js/ui/boxpointer.js
new file mode 100644
index 0000000..c664ae7
--- /dev/null
+++ b/js/ui/boxpointer.js
@@ -0,0 +1,652 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported BoxPointer */
+
+const { Clutter, GObject, Meta, St } = imports.gi;
+
+const Main = imports.ui.main;
+
+var PopupAnimation = {
+ NONE: 0,
+ SLIDE: 1 << 0,
+ FADE: 1 << 1,
+ FULL: ~0,
+};
+
+var POPUP_ANIMATION_TIME = 150;
+
+/**
+ * BoxPointer:
+ * @side: side to draw the arrow on
+ * @binProperties: Properties to set on contained bin
+ *
+ * An actor which displays a triangle "arrow" pointing to a given
+ * side. The .bin property is a container in which content can be
+ * placed. The arrow position may be controlled via
+ * setArrowOrigin(). The arrow side might be temporarily flipped
+ * depending on the box size and source position to keep the box
+ * totally inside the monitor workarea if possible.
+ *
+ */
+var BoxPointer = GObject.registerClass({
+ Signals: { 'arrow-side-changed': {} },
+}, class BoxPointer extends St.Widget {
+ _init(arrowSide, binProperties) {
+ super._init();
+
+ this.set_offscreen_redirect(Clutter.OffscreenRedirect.ALWAYS);
+
+ this._arrowSide = arrowSide;
+ this._userArrowSide = arrowSide;
+ this._arrowOrigin = 0;
+ this._arrowActor = null;
+ this.bin = new St.Bin(binProperties);
+ this.add_actor(this.bin);
+ this._border = new St.DrawingArea();
+ this._border.connect('repaint', this._drawBorder.bind(this));
+ this.add_actor(this._border);
+ this.set_child_above_sibling(this.bin, this._border);
+ this._sourceAlignment = 0.5;
+ this._muteInput = true;
+
+ this.connect('notify::visible', () => {
+ if (this.visible)
+ Meta.disable_unredirect_for_display(global.display);
+ else
+ Meta.enable_unredirect_for_display(global.display);
+ });
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ vfunc_captured_event() {
+ if (this._muteInput)
+ return Clutter.EVENT_STOP;
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _onDestroy() {
+ if (this._sourceActorDestroyId) {
+ this._sourceActor.disconnect(this._sourceActorDestroyId);
+ delete this._sourceActorDestroyId;
+ }
+ }
+
+ get arrowSide() {
+ return this._arrowSide;
+ }
+
+ open(animate, onComplete) {
+ let themeNode = this.get_theme_node();
+ let rise = themeNode.get_length('-arrow-rise');
+ let animationTime = animate & PopupAnimation.FULL ? POPUP_ANIMATION_TIME : 0;
+
+ if (animate & PopupAnimation.FADE)
+ this.opacity = 0;
+ else
+ this.opacity = 255;
+
+ this.show();
+
+ if (animate & PopupAnimation.SLIDE) {
+ switch (this._arrowSide) {
+ case St.Side.TOP:
+ this.translation_y = -rise;
+ break;
+ case St.Side.BOTTOM:
+ this.translation_y = rise;
+ break;
+ case St.Side.LEFT:
+ this.translation_x = -rise;
+ break;
+ case St.Side.RIGHT:
+ this.translation_x = rise;
+ break;
+ }
+ }
+
+ this.ease({
+ opacity: 255,
+ translation_x: 0,
+ translation_y: 0,
+ duration: animationTime,
+ mode: Clutter.AnimationMode.LINEAR,
+ onComplete: () => {
+ this._muteInput = false;
+ if (onComplete)
+ onComplete();
+ },
+ });
+ }
+
+ close(animate, onComplete) {
+ if (!this.visible)
+ return;
+
+ let translationX = 0;
+ let translationY = 0;
+ let themeNode = this.get_theme_node();
+ let rise = themeNode.get_length('-arrow-rise');
+ let fade = animate & PopupAnimation.FADE;
+ let animationTime = animate & PopupAnimation.FULL ? POPUP_ANIMATION_TIME : 0;
+
+ if (animate & PopupAnimation.SLIDE) {
+ switch (this._arrowSide) {
+ case St.Side.TOP:
+ translationY = rise;
+ break;
+ case St.Side.BOTTOM:
+ translationY = -rise;
+ break;
+ case St.Side.LEFT:
+ translationX = rise;
+ break;
+ case St.Side.RIGHT:
+ translationX = -rise;
+ break;
+ }
+ }
+
+ this._muteInput = true;
+
+ this.remove_all_transitions();
+ this.ease({
+ opacity: fade ? 0 : 255,
+ translation_x: translationX,
+ translation_y: translationY,
+ duration: animationTime,
+ mode: Clutter.AnimationMode.LINEAR,
+ onComplete: () => {
+ this.hide();
+ this.opacity = 0;
+ this.translation_x = 0;
+ this.translation_y = 0;
+ if (onComplete)
+ onComplete();
+ },
+ });
+ }
+
+ _adjustAllocationForArrow(isWidth, minSize, natSize) {
+ let themeNode = this.get_theme_node();
+ let borderWidth = themeNode.get_length('-arrow-border-width');
+ minSize += borderWidth * 2;
+ natSize += borderWidth * 2;
+ if ((!isWidth && (this._arrowSide == St.Side.TOP || this._arrowSide == St.Side.BOTTOM)) ||
+ (isWidth && (this._arrowSide == St.Side.LEFT || this._arrowSide == St.Side.RIGHT))) {
+ let rise = themeNode.get_length('-arrow-rise');
+ minSize += rise;
+ natSize += rise;
+ }
+
+ return [minSize, natSize];
+ }
+
+ vfunc_get_preferred_width(forHeight) {
+ let themeNode = this.get_theme_node();
+ forHeight = themeNode.adjust_for_height(forHeight);
+
+ let width = this.bin.get_preferred_width(forHeight);
+ width = this._adjustAllocationForArrow(true, ...width);
+
+ return themeNode.adjust_preferred_width(...width);
+ }
+
+ vfunc_get_preferred_height(forWidth) {
+ let themeNode = this.get_theme_node();
+ let borderWidth = themeNode.get_length('-arrow-border-width');
+ forWidth = themeNode.adjust_for_width(forWidth);
+
+ let height = this.bin.get_preferred_height(forWidth - 2 * borderWidth);
+ height = this._adjustAllocationForArrow(false, ...height);
+
+ return themeNode.adjust_preferred_height(...height);
+ }
+
+ vfunc_allocate(box) {
+ if (this._sourceActor && this._sourceActor.mapped) {
+ this._reposition(box);
+ this._updateFlip(box);
+ }
+
+ this.set_allocation(box);
+
+ let themeNode = this.get_theme_node();
+ let borderWidth = themeNode.get_length('-arrow-border-width');
+ let rise = themeNode.get_length('-arrow-rise');
+ let childBox = new Clutter.ActorBox();
+ let [availWidth, availHeight] = themeNode.get_content_box(box).get_size();
+
+ childBox.x1 = 0;
+ childBox.y1 = 0;
+ childBox.x2 = availWidth;
+ childBox.y2 = availHeight;
+ this._border.allocate(childBox);
+
+ childBox.x1 = borderWidth;
+ childBox.y1 = borderWidth;
+ childBox.x2 = availWidth - borderWidth;
+ childBox.y2 = availHeight - borderWidth;
+ switch (this._arrowSide) {
+ case St.Side.TOP:
+ childBox.y1 += rise;
+ break;
+ case St.Side.BOTTOM:
+ childBox.y2 -= rise;
+ break;
+ case St.Side.LEFT:
+ childBox.x1 += rise;
+ break;
+ case St.Side.RIGHT:
+ childBox.x2 -= rise;
+ break;
+ }
+ this.bin.allocate(childBox);
+ }
+
+ _drawBorder(area) {
+ let themeNode = this.get_theme_node();
+
+ if (this._arrowActor) {
+ let [sourceX, sourceY] = this._arrowActor.get_transformed_position();
+ let [sourceWidth, sourceHeight] = this._arrowActor.get_transformed_size();
+ let [absX, absY] = this.get_transformed_position();
+
+ if (this._arrowSide == St.Side.TOP ||
+ this._arrowSide == St.Side.BOTTOM)
+ this._arrowOrigin = sourceX - absX + sourceWidth / 2;
+ else
+ this._arrowOrigin = sourceY - absY + sourceHeight / 2;
+ }
+
+ let borderWidth = themeNode.get_length('-arrow-border-width');
+ let base = themeNode.get_length('-arrow-base');
+ let rise = themeNode.get_length('-arrow-rise');
+ let borderRadius = themeNode.get_length('-arrow-border-radius');
+
+ let halfBorder = borderWidth / 2;
+ let halfBase = Math.floor(base / 2);
+
+ let backgroundColor = themeNode.get_color('-arrow-background-color');
+
+ let [width, height] = area.get_surface_size();
+ let [boxWidth, boxHeight] = [width, height];
+ if (this._arrowSide == St.Side.TOP || this._arrowSide == St.Side.BOTTOM)
+ boxHeight -= rise;
+ else
+ boxWidth -= rise;
+
+ let cr = area.get_context();
+
+ // Translate so that box goes from 0,0 to boxWidth,boxHeight,
+ // with the arrow poking out of that
+ if (this._arrowSide == St.Side.TOP)
+ cr.translate(0, rise);
+ else if (this._arrowSide == St.Side.LEFT)
+ cr.translate(rise, 0);
+
+ let [x1, y1] = [halfBorder, halfBorder];
+ let [x2, y2] = [boxWidth - halfBorder, boxHeight - halfBorder];
+
+ let skipTopLeft = false;
+ let skipTopRight = false;
+ let skipBottomLeft = false;
+ let skipBottomRight = false;
+
+ if (rise) {
+ switch (this._arrowSide) {
+ case St.Side.TOP:
+ if (this._arrowOrigin == x1)
+ skipTopLeft = true;
+ else if (this._arrowOrigin == x2)
+ skipTopRight = true;
+ break;
+
+ case St.Side.RIGHT:
+ if (this._arrowOrigin == y1)
+ skipTopRight = true;
+ else if (this._arrowOrigin == y2)
+ skipBottomRight = true;
+ break;
+
+ case St.Side.BOTTOM:
+ if (this._arrowOrigin == x1)
+ skipBottomLeft = true;
+ else if (this._arrowOrigin == x2)
+ skipBottomRight = true;
+ break;
+
+ case St.Side.LEFT:
+ if (this._arrowOrigin == y1)
+ skipTopLeft = true;
+ else if (this._arrowOrigin == y2)
+ skipBottomLeft = true;
+ break;
+ }
+ }
+
+ cr.moveTo(x1 + borderRadius, y1);
+ if (this._arrowSide == St.Side.TOP && rise) {
+ if (skipTopLeft) {
+ cr.moveTo(x1, y2 - borderRadius);
+ cr.lineTo(x1, y1 - rise);
+ cr.lineTo(x1 + halfBase, y1);
+ } else if (skipTopRight) {
+ cr.lineTo(x2 - halfBase, y1);
+ cr.lineTo(x2, y1 - rise);
+ cr.lineTo(x2, y1 + borderRadius);
+ } else {
+ cr.lineTo(this._arrowOrigin - halfBase, y1);
+ cr.lineTo(this._arrowOrigin, y1 - rise);
+ cr.lineTo(this._arrowOrigin + halfBase, y1);
+ }
+ }
+
+ if (!skipTopRight) {
+ cr.lineTo(x2 - borderRadius, y1);
+ cr.arc(x2 - borderRadius, y1 + borderRadius, borderRadius,
+ 3 * Math.PI / 2, Math.PI * 2);
+ }
+
+ if (this._arrowSide == St.Side.RIGHT && rise) {
+ if (skipTopRight) {
+ cr.lineTo(x2 + rise, y1);
+ cr.lineTo(x2 + rise, y1 + halfBase);
+ } else if (skipBottomRight) {
+ cr.lineTo(x2, y2 - halfBase);
+ cr.lineTo(x2 + rise, y2);
+ cr.lineTo(x2 - borderRadius, y2);
+ } else {
+ cr.lineTo(x2, this._arrowOrigin - halfBase);
+ cr.lineTo(x2 + rise, this._arrowOrigin);
+ cr.lineTo(x2, this._arrowOrigin + halfBase);
+ }
+ }
+
+ if (!skipBottomRight) {
+ cr.lineTo(x2, y2 - borderRadius);
+ cr.arc(x2 - borderRadius, y2 - borderRadius, borderRadius,
+ 0, Math.PI / 2);
+ }
+
+ if (this._arrowSide == St.Side.BOTTOM && rise) {
+ if (skipBottomLeft) {
+ cr.lineTo(x1 + halfBase, y2);
+ cr.lineTo(x1, y2 + rise);
+ cr.lineTo(x1, y2 - borderRadius);
+ } else if (skipBottomRight) {
+ cr.lineTo(x2, y2 + rise);
+ cr.lineTo(x2 - halfBase, y2);
+ } else {
+ cr.lineTo(this._arrowOrigin + halfBase, y2);
+ cr.lineTo(this._arrowOrigin, y2 + rise);
+ cr.lineTo(this._arrowOrigin - halfBase, y2);
+ }
+ }
+
+ if (!skipBottomLeft) {
+ cr.lineTo(x1 + borderRadius, y2);
+ cr.arc(x1 + borderRadius, y2 - borderRadius, borderRadius,
+ Math.PI / 2, Math.PI);
+ }
+
+ if (this._arrowSide == St.Side.LEFT && rise) {
+ if (skipTopLeft) {
+ cr.lineTo(x1, y1 + halfBase);
+ cr.lineTo(x1 - rise, y1);
+ cr.lineTo(x1 + borderRadius, y1);
+ } else if (skipBottomLeft) {
+ cr.lineTo(x1 - rise, y2);
+ cr.lineTo(x1 - rise, y2 - halfBase);
+ } else {
+ cr.lineTo(x1, this._arrowOrigin + halfBase);
+ cr.lineTo(x1 - rise, this._arrowOrigin);
+ cr.lineTo(x1, this._arrowOrigin - halfBase);
+ }
+ }
+
+ if (!skipTopLeft) {
+ cr.lineTo(x1, y1 + borderRadius);
+ cr.arc(x1 + borderRadius, y1 + borderRadius, borderRadius,
+ Math.PI, 3 * Math.PI / 2);
+ }
+
+ Clutter.cairo_set_source_color(cr, backgroundColor);
+ cr.fillPreserve();
+
+ if (borderWidth > 0) {
+ let borderColor = themeNode.get_color('-arrow-border-color');
+ Clutter.cairo_set_source_color(cr, borderColor);
+ cr.setLineWidth(borderWidth);
+ cr.stroke();
+ }
+
+ cr.$dispose();
+ }
+
+ setPosition(sourceActor, alignment) {
+ if (!this._sourceActor || sourceActor != this._sourceActor) {
+ if (this._sourceActorDestroyId) {
+ this._sourceActor.disconnect(this._sourceActorDestroyId);
+ delete this._sourceActorDestroyId;
+ }
+
+ this._sourceActor = sourceActor;
+
+ if (this._sourceActor) {
+ this._sourceActorDestroyId = this._sourceActor.connect('destroy', () => {
+ this._sourceActor = null;
+ delete this._sourceActorDestroyId;
+ });
+ }
+ }
+
+ this._arrowAlignment = alignment;
+
+ this.queue_relayout();
+ }
+
+ setSourceAlignment(alignment) {
+ this._sourceAlignment = alignment;
+
+ if (!this._sourceActor)
+ return;
+
+ this.setPosition(this._sourceActor, this._arrowAlignment);
+ }
+
+ _reposition(allocationBox) {
+ let sourceActor = this._sourceActor;
+ let alignment = this._arrowAlignment;
+ let monitorIndex = Main.layoutManager.findIndexForActor(sourceActor);
+
+ this._sourceExtents = sourceActor.get_transformed_extents();
+ this._workArea = Main.layoutManager.getWorkAreaForMonitor(monitorIndex);
+
+ // Position correctly relative to the sourceActor
+ let sourceNode = sourceActor.get_theme_node();
+ let sourceContentBox = sourceNode.get_content_box(sourceActor.get_allocation_box());
+ let sourceTopLeft = this._sourceExtents.get_top_left();
+ let sourceBottomRight = this._sourceExtents.get_bottom_right();
+ let sourceCenterX = sourceTopLeft.x + sourceContentBox.x1 + (sourceContentBox.x2 - sourceContentBox.x1) * this._sourceAlignment;
+ let sourceCenterY = sourceTopLeft.y + sourceContentBox.y1 + (sourceContentBox.y2 - sourceContentBox.y1) * this._sourceAlignment;
+ let [, , natWidth, natHeight] = this.get_preferred_size();
+
+ // We also want to keep it onscreen, and separated from the
+ // edge by the same distance as the main part of the box is
+ // separated from its sourceActor
+ let workarea = this._workArea;
+ let themeNode = this.get_theme_node();
+ let borderWidth = themeNode.get_length('-arrow-border-width');
+ let arrowBase = themeNode.get_length('-arrow-base');
+ let borderRadius = themeNode.get_length('-arrow-border-radius');
+ let margin = 4 * borderRadius + borderWidth + arrowBase;
+
+ let gap = themeNode.get_length('-boxpointer-gap');
+ let padding = themeNode.get_length('-arrow-rise');
+
+ let resX, resY;
+
+ switch (this._arrowSide) {
+ case St.Side.TOP:
+ resY = sourceBottomRight.y + gap;
+ break;
+ case St.Side.BOTTOM:
+ resY = sourceTopLeft.y - natHeight - gap;
+ break;
+ case St.Side.LEFT:
+ resX = sourceBottomRight.x + gap;
+ break;
+ case St.Side.RIGHT:
+ resX = sourceTopLeft.x - natWidth - gap;
+ break;
+ }
+
+ // Now align and position the pointing axis, making sure it fits on
+ // screen. If the arrowOrigin is so close to the edge that the arrow
+ // will not be isosceles, we try to compensate as follows:
+ // - We skip the rounded corner and settle for a right angled arrow
+ // as shown below. See _drawBorder for further details.
+ // |\_____
+ // |
+ // |
+ // - If the arrow was going to be acute angled, we move the position
+ // of the box to maintain the arrow's accuracy.
+
+ let arrowOrigin;
+ let halfBase = Math.floor(arrowBase / 2);
+ let halfBorder = borderWidth / 2;
+ let halfMargin = margin / 2;
+ let [x1, y1] = [halfBorder, halfBorder];
+ let [x2, y2] = [natWidth - halfBorder, natHeight - halfBorder];
+
+ switch (this._arrowSide) {
+ case St.Side.TOP:
+ case St.Side.BOTTOM:
+ resX = sourceCenterX - (halfMargin + (natWidth - margin) * alignment);
+
+ resX = Math.max(resX, workarea.x + padding);
+ resX = Math.min(resX, workarea.x + workarea.width - (padding + natWidth));
+
+ arrowOrigin = sourceCenterX - resX;
+ if (arrowOrigin <= (x1 + (borderRadius + halfBase))) {
+ if (arrowOrigin > x1)
+ resX += arrowOrigin - x1;
+ arrowOrigin = x1;
+ } else if (arrowOrigin >= (x2 - (borderRadius + halfBase))) {
+ if (arrowOrigin < x2)
+ resX -= x2 - arrowOrigin;
+ arrowOrigin = x2;
+ }
+ break;
+
+ case St.Side.LEFT:
+ case St.Side.RIGHT:
+ resY = sourceCenterY - (halfMargin + (natHeight - margin) * alignment);
+
+ resY = Math.max(resY, workarea.y + padding);
+ resY = Math.min(resY, workarea.y + workarea.height - (padding + natHeight));
+
+ arrowOrigin = sourceCenterY - resY;
+ if (arrowOrigin <= (y1 + (borderRadius + halfBase))) {
+ if (arrowOrigin > y1)
+ resY += arrowOrigin - y1;
+ arrowOrigin = y1;
+ } else if (arrowOrigin >= (y2 - (borderRadius + halfBase))) {
+ if (arrowOrigin < y2)
+ resX -= y2 - arrowOrigin;
+ arrowOrigin = y2;
+ }
+ break;
+ }
+
+ this.setArrowOrigin(arrowOrigin);
+
+ let parent = this.get_parent();
+ let success, x, y;
+ while (!success) {
+ [success, x, y] = parent.transform_stage_point(resX, resY);
+ parent = parent.get_parent();
+ }
+
+ // Actually set the position
+ allocationBox.set_origin(Math.floor(x), Math.floor(y));
+ }
+
+ // @origin: Coordinate specifying middle of the arrow, along
+ // the Y axis for St.Side.LEFT, St.Side.RIGHT from the top and X axis from
+ // the left for St.Side.TOP and St.Side.BOTTOM.
+ setArrowOrigin(origin) {
+ if (this._arrowOrigin != origin) {
+ this._arrowOrigin = origin;
+ this._border.queue_repaint();
+ }
+ }
+
+ // @actor: an actor relative to which the arrow is positioned.
+ // Differently from setPosition, this will not move the boxpointer itself,
+ // on the arrow
+ setArrowActor(actor) {
+ if (this._arrowActor != actor) {
+ this._arrowActor = actor;
+ this._border.queue_repaint();
+ }
+ }
+
+ _calculateArrowSide(arrowSide) {
+ let sourceTopLeft = this._sourceExtents.get_top_left();
+ let sourceBottomRight = this._sourceExtents.get_bottom_right();
+ let [, , boxWidth, boxHeight] = this.get_preferred_size();
+ let workarea = this._workArea;
+
+ switch (arrowSide) {
+ case St.Side.TOP:
+ if (sourceBottomRight.y + boxHeight > workarea.y + workarea.height &&
+ boxHeight < sourceTopLeft.y - workarea.y)
+ return St.Side.BOTTOM;
+ break;
+ case St.Side.BOTTOM:
+ if (sourceTopLeft.y - boxHeight < workarea.y &&
+ boxHeight < workarea.y + workarea.height - sourceBottomRight.y)
+ return St.Side.TOP;
+ break;
+ case St.Side.LEFT:
+ if (sourceBottomRight.x + boxWidth > workarea.x + workarea.width &&
+ boxWidth < sourceTopLeft.x - workarea.x)
+ return St.Side.RIGHT;
+ break;
+ case St.Side.RIGHT:
+ if (sourceTopLeft.x - boxWidth < workarea.x &&
+ boxWidth < workarea.x + workarea.width - sourceBottomRight.x)
+ return St.Side.LEFT;
+ break;
+ }
+
+ return arrowSide;
+ }
+
+ _updateFlip(allocationBox) {
+ let arrowSide = this._calculateArrowSide(this._userArrowSide);
+ if (this._arrowSide != arrowSide) {
+ this._arrowSide = arrowSide;
+ this._reposition(allocationBox);
+
+ this.emit('arrow-side-changed');
+ }
+ }
+
+ updateArrowSide(side) {
+ this._arrowSide = side;
+ this._border.queue_repaint();
+
+ this.emit('arrow-side-changed');
+ }
+
+ getPadding(side) {
+ return this.bin.get_theme_node().get_padding(side);
+ }
+
+ getArrowHeight() {
+ return this.get_theme_node().get_length('-arrow-rise');
+ }
+});
diff --git a/js/ui/calendar.js b/js/ui/calendar.js
new file mode 100644
index 0000000..710efba
--- /dev/null
+++ b/js/ui/calendar.js
@@ -0,0 +1,1033 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Calendar, CalendarMessageList, DBusEventSource */
+
+const { Clutter, Gio, GLib, GObject, Shell, St } = imports.gi;
+
+const Main = imports.ui.main;
+const MessageList = imports.ui.messageList;
+const MessageTray = imports.ui.messageTray;
+const Mpris = imports.ui.mpris;
+const PopupMenu = imports.ui.popupMenu;
+const Util = imports.misc.util;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+var MSECS_IN_DAY = 24 * 60 * 60 * 1000;
+var SHOW_WEEKDATE_KEY = 'show-weekdate';
+
+var MESSAGE_ICON_SIZE = -1; // pick up from CSS
+
+var NC_ = (context, str) => '%s\u0004%s'.format(context, str);
+
+function sameYear(dateA, dateB) {
+ return dateA.getYear() == dateB.getYear();
+}
+
+function sameMonth(dateA, dateB) {
+ return sameYear(dateA, dateB) && (dateA.getMonth() == dateB.getMonth());
+}
+
+function sameDay(dateA, dateB) {
+ return sameMonth(dateA, dateB) && (dateA.getDate() == dateB.getDate());
+}
+
+function _isWorkDay(date) {
+ /* Translators: Enter 0-6 (Sunday-Saturday) for non-work days. Examples: "0" (Sunday) "6" (Saturday) "06" (Sunday and Saturday). */
+ let days = C_('calendar-no-work', "06");
+ return !days.includes(date.getDay().toString());
+}
+
+function _getBeginningOfDay(date) {
+ let ret = new Date(date.getTime());
+ ret.setHours(0);
+ ret.setMinutes(0);
+ ret.setSeconds(0);
+ ret.setMilliseconds(0);
+ return ret;
+}
+
+function _getEndOfDay(date) {
+ let ret = new Date(date.getTime());
+ ret.setHours(23);
+ ret.setMinutes(59);
+ ret.setSeconds(59);
+ ret.setMilliseconds(999);
+ return ret;
+}
+
+function _getCalendarDayAbbreviation(dayNumber) {
+ let abbreviations = [
+ /* Translators: Calendar grid abbreviation for Sunday.
+ *
+ * NOTE: These grid abbreviations are always shown together
+ * and in order, e.g. "S M T W T F S".
+ */
+ NC_("grid sunday", "S"),
+ /* Translators: Calendar grid abbreviation for Monday */
+ NC_("grid monday", "M"),
+ /* Translators: Calendar grid abbreviation for Tuesday */
+ NC_("grid tuesday", "T"),
+ /* Translators: Calendar grid abbreviation for Wednesday */
+ NC_("grid wednesday", "W"),
+ /* Translators: Calendar grid abbreviation for Thursday */
+ NC_("grid thursday", "T"),
+ /* Translators: Calendar grid abbreviation for Friday */
+ NC_("grid friday", "F"),
+ /* Translators: Calendar grid abbreviation for Saturday */
+ NC_("grid saturday", "S"),
+ ];
+ return Shell.util_translate_time_string(abbreviations[dayNumber]);
+}
+
+// Abstraction for an appointment/event in a calendar
+
+var CalendarEvent = class CalendarEvent {
+ constructor(id, date, end, summary, allDay) {
+ this.id = id;
+ this.date = date;
+ this.end = end;
+ this.summary = summary;
+ this.allDay = allDay;
+ }
+};
+
+// Interface for appointments/events - e.g. the contents of a calendar
+//
+
+var EventSourceBase = GObject.registerClass({
+ GTypeFlags: GObject.TypeFlags.ABSTRACT,
+ Properties: {
+ 'has-calendars': GObject.ParamSpec.boolean(
+ 'has-calendars', 'has-calendars', 'has-calendars',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'is-loading': GObject.ParamSpec.boolean(
+ 'is-loading', 'is-loading', 'is-loading',
+ GObject.ParamFlags.READABLE,
+ false),
+ },
+ Signals: { 'changed': {} },
+}, class EventSourceBase extends GObject.Object {
+ get isLoading() {
+ throw new GObject.NotImplementedError('isLoading in %s'.format(this.constructor.name));
+ }
+
+ get hasCalendars() {
+ throw new GObject.NotImplementedError('hasCalendars in %s'.format(this.constructor.name));
+ }
+
+ destroy() {
+ }
+
+ requestRange(_begin, _end) {
+ throw new GObject.NotImplementedError('requestRange in %s'.format(this.constructor.name));
+ }
+
+ getEvents(_begin, _end) {
+ throw new GObject.NotImplementedError('getEvents in %s'.format(this.constructor.name));
+ }
+
+ hasEvents(_day) {
+ throw new GObject.NotImplementedError('hasEvents in %s'.format(this.constructor.name));
+ }
+});
+
+var EmptyEventSource = GObject.registerClass(
+class EmptyEventSource extends EventSourceBase {
+ get isLoading() {
+ return false;
+ }
+
+ get hasCalendars() {
+ return false;
+ }
+
+ requestRange(_begin, _end) {
+ }
+
+ getEvents(_begin, _end) {
+ let result = [];
+ return result;
+ }
+
+ hasEvents(_day) {
+ return false;
+ }
+});
+
+const CalendarServerIface = loadInterfaceXML('org.gnome.Shell.CalendarServer');
+
+const CalendarServerInfo = Gio.DBusInterfaceInfo.new_for_xml(CalendarServerIface);
+
+function CalendarServer() {
+ return new Gio.DBusProxy({ g_connection: Gio.DBus.session,
+ g_interface_name: CalendarServerInfo.name,
+ g_interface_info: CalendarServerInfo,
+ g_name: 'org.gnome.Shell.CalendarServer',
+ g_object_path: '/org/gnome/Shell/CalendarServer' });
+}
+
+function _datesEqual(a, b) {
+ if (a < b)
+ return false;
+ else if (a > b)
+ return false;
+ return true;
+}
+
+function _dateIntervalsOverlap(a0, a1, b0, b1) {
+ if (a1 <= b0)
+ return false;
+ else if (b1 <= a0)
+ return false;
+ else
+ return true;
+}
+
+// an implementation that reads data from a session bus service
+var DBusEventSource = GObject.registerClass(
+class DBusEventSource extends EventSourceBase {
+ _init() {
+ super._init();
+ this._resetCache();
+ this._isLoading = false;
+
+ this._initialized = false;
+ this._dbusProxy = new CalendarServer();
+ this._initProxy();
+ }
+
+ async _initProxy() {
+ let loaded = false;
+
+ try {
+ await this._dbusProxy.init_async(GLib.PRIORITY_DEFAULT, null);
+ loaded = true;
+ } catch (e) {
+ // Ignore timeouts and install signals as normal, because with high
+ // probability the service will appear later on, and we will get a
+ // NameOwnerChanged which will finish loading
+ //
+ // (But still _initialized to false, because the proxy does not know
+ // about the HasCalendars property and would cause an exception trying
+ // to read it)
+ if (!e.matches(Gio.DBusError, Gio.DBusError.TIMED_OUT)) {
+ log('Error loading calendars: %s'.format(e.message));
+ return;
+ }
+ }
+
+ this._dbusProxy.connectSignal('EventsAddedOrUpdated',
+ this._onEventsAddedOrUpdated.bind(this));
+ this._dbusProxy.connectSignal('EventsRemoved',
+ this._onEventsRemoved.bind(this));
+ this._dbusProxy.connectSignal('ClientDisappeared',
+ this._onClientDisappeared.bind(this));
+
+ this._dbusProxy.connect('notify::g-name-owner', () => {
+ if (this._dbusProxy.g_name_owner)
+ this._onNameAppeared();
+ else
+ this._onNameVanished();
+ });
+
+ this._dbusProxy.connect('g-properties-changed', () => {
+ this.notify('has-calendars');
+ });
+
+ this._initialized = loaded;
+ if (loaded) {
+ this.notify('has-calendars');
+ this._onNameAppeared();
+ }
+ }
+
+ destroy() {
+ this._dbusProxy.run_dispose();
+ }
+
+ get hasCalendars() {
+ if (this._initialized)
+ return this._dbusProxy.HasCalendars;
+ else
+ return false;
+ }
+
+ get isLoading() {
+ return this._isLoading;
+ }
+
+ _resetCache() {
+ this._events = new Map();
+ this._lastRequestBegin = null;
+ this._lastRequestEnd = null;
+ }
+
+ _onNameAppeared() {
+ this._initialized = true;
+ this._resetCache();
+ this._loadEvents(true);
+ }
+
+ _onNameVanished() {
+ this._resetCache();
+ this.emit('changed');
+ }
+
+ _onEventsAddedOrUpdated(dbusProxy, nameOwner, argArray) {
+ const [appointments = []] = argArray;
+ let changed = false;
+
+ for (let n = 0; n < appointments.length; n++) {
+ const [id, summary, allDay, startTime, endTime] = appointments[n];
+ const date = new Date(startTime * 1000);
+ const end = new Date(endTime * 1000);
+ let event = new CalendarEvent(id, date, end, summary, allDay);
+ this._events.set(event.id, event);
+
+ changed = true;
+ }
+
+ if (changed)
+ this.emit('changed');
+ }
+
+ _onEventsRemoved(dbusProxy, nameOwner, argArray) {
+ const [ids = []] = argArray;
+
+ let changed = false;
+ for (const id of ids)
+ changed |= this._events.delete(id);
+
+ if (changed)
+ this.emit('changed');
+ }
+
+ _onClientDisappeared(dbusProxy, nameOwner, argArray) {
+ let [sourceUid = ''] = argArray;
+ sourceUid += '\n';
+
+ let changed = false;
+ for (const id of this._events.keys()) {
+ if (id.startsWith(sourceUid))
+ changed |= this._events.delete(id);
+ }
+
+ if (changed)
+ this.emit('changed');
+ }
+
+ _loadEvents(forceReload) {
+ // Ignore while loading
+ if (!this._initialized)
+ return;
+
+ if (this._curRequestBegin && this._curRequestEnd) {
+ if (forceReload) {
+ this._events.clear();
+ this.emit('changed');
+ }
+ this._dbusProxy.SetTimeRangeRemote(
+ this._curRequestBegin.getTime() / 1000,
+ this._curRequestEnd.getTime() / 1000,
+ forceReload,
+ Gio.DBusCallFlags.NONE);
+ }
+ }
+
+ requestRange(begin, end) {
+ if (!(_datesEqual(begin, this._lastRequestBegin) && _datesEqual(end, this._lastRequestEnd))) {
+ this._lastRequestBegin = begin;
+ this._lastRequestEnd = end;
+ this._curRequestBegin = begin;
+ this._curRequestEnd = end;
+ this._loadEvents(true);
+ }
+ }
+
+ *_getFilteredEvents(begin, end) {
+ for (const event of this._events.values()) {
+ if (_dateIntervalsOverlap(event.date, event.end, begin, end))
+ yield event;
+ }
+ }
+
+ getEvents(begin, end) {
+ let result = [...this._getFilteredEvents(begin, end)];
+
+ result.sort((event1, event2) => {
+ // sort events by end time on ending day
+ let d1 = event1.date < begin && event1.end <= end ? event1.end : event1.date;
+ let d2 = event2.date < begin && event2.end <= end ? event2.end : event2.date;
+ return d1.getTime() - d2.getTime();
+ });
+ return result;
+ }
+
+ hasEvents(day) {
+ let dayBegin = _getBeginningOfDay(day);
+ let dayEnd = _getEndOfDay(day);
+
+ const { done } = this._getFilteredEvents(dayBegin, dayEnd).next();
+ return !done;
+ }
+});
+
+var Calendar = GObject.registerClass({
+ Signals: { 'selected-date-changed': { param_types: [GLib.DateTime.$gtype] } },
+}, class Calendar extends St.Widget {
+ _init() {
+ this._weekStart = Shell.util_get_week_start();
+ this._settings = new Gio.Settings({ schema_id: 'org.gnome.desktop.calendar' });
+
+ this._settings.connect('changed::%s'.format(SHOW_WEEKDATE_KEY), this._onSettingsChange.bind(this));
+ this._useWeekdate = this._settings.get_boolean(SHOW_WEEKDATE_KEY);
+
+ /**
+ * Translators: The header displaying just the month name
+ * standalone, when this is a month of the current year.
+ * "%OB" is the new format specifier introduced in glibc 2.27,
+ * in most cases you should not change it.
+ */
+ this._headerFormatWithoutYear = _('%OB');
+ /**
+ * Translators: The header displaying the month name and the year
+ * number, when this is a month of a different year. You can
+ * reorder the format specifiers or add other modifications
+ * according to the requirements of your language.
+ * "%OB" is the new format specifier introduced in glibc 2.27,
+ * in most cases you should not use the old "%B" here unless you
+ * absolutely know what you are doing.
+ */
+ this._headerFormat = _('%OB %Y');
+
+ // Start off with the current date
+ this._selectedDate = new Date();
+
+ this._shouldDateGrabFocus = false;
+
+ super._init({
+ style_class: 'calendar',
+ layout_manager: new Clutter.GridLayout(),
+ reactive: true,
+ });
+
+ this._buildHeader();
+ }
+
+ setEventSource(eventSource) {
+ if (!(eventSource instanceof EventSourceBase))
+ throw new Error('Event source is not valid type');
+
+ this._eventSource = eventSource;
+ this._eventSource.connect('changed', () => {
+ this._rebuildCalendar();
+ this._update();
+ });
+ this._rebuildCalendar();
+ this._update();
+ }
+
+ // Sets the calendar to show a specific date
+ setDate(date) {
+ if (sameDay(date, this._selectedDate))
+ return;
+
+ this._selectedDate = date;
+ this._update();
+
+ let datetime = GLib.DateTime.new_from_unix_local(
+ this._selectedDate.getTime() / 1000);
+ this.emit('selected-date-changed', datetime);
+ }
+
+ updateTimeZone() {
+ // The calendar need to be rebuilt after a time zone update because
+ // the date might have changed.
+ this._rebuildCalendar();
+ this._update();
+ }
+
+ _buildHeader() {
+ let layout = this.layout_manager;
+ let offsetCols = this._useWeekdate ? 1 : 0;
+ this.destroy_all_children();
+
+ // Top line of the calendar '<| September 2009 |>'
+ this._topBox = new St.BoxLayout();
+ layout.attach(this._topBox, 0, 0, offsetCols + 7, 1);
+
+ this._backButton = new St.Button({ style_class: 'calendar-change-month-back pager-button',
+ accessible_name: _("Previous month"),
+ can_focus: true });
+ this._backButton.add_actor(new St.Icon({ icon_name: 'pan-start-symbolic' }));
+ this._topBox.add(this._backButton);
+ this._backButton.connect('clicked', this._onPrevMonthButtonClicked.bind(this));
+
+ this._monthLabel = new St.Label({
+ style_class: 'calendar-month-label',
+ can_focus: true,
+ x_align: Clutter.ActorAlign.CENTER,
+ x_expand: true,
+ });
+ this._topBox.add_child(this._monthLabel);
+
+ this._forwardButton = new St.Button({ style_class: 'calendar-change-month-forward pager-button',
+ accessible_name: _("Next month"),
+ can_focus: true });
+ this._forwardButton.add_actor(new St.Icon({ icon_name: 'pan-end-symbolic' }));
+ this._topBox.add(this._forwardButton);
+ this._forwardButton.connect('clicked', this._onNextMonthButtonClicked.bind(this));
+
+ // Add weekday labels...
+ //
+ // We need to figure out the abbreviated localized names for the days of the week;
+ // we do this by just getting the next 7 days starting from right now and then putting
+ // them in the right cell in the table. It doesn't matter if we add them in order
+ let iter = new Date(this._selectedDate);
+ iter.setSeconds(0); // Leap second protection. Hah!
+ iter.setHours(12);
+ for (let i = 0; i < 7; i++) {
+ // Could use iter.toLocaleFormat('%a') but that normally gives three characters
+ // and we want, ideally, a single character for e.g. S M T W T F S
+ let customDayAbbrev = _getCalendarDayAbbreviation(iter.getDay());
+ let label = new St.Label({ style_class: 'calendar-day-base calendar-day-heading',
+ text: customDayAbbrev,
+ can_focus: true });
+ label.accessible_name = iter.toLocaleFormat('%A');
+ let col;
+ if (this.get_text_direction() == Clutter.TextDirection.RTL)
+ col = 6 - (7 + iter.getDay() - this._weekStart) % 7;
+ else
+ col = offsetCols + (7 + iter.getDay() - this._weekStart) % 7;
+ layout.attach(label, col, 1, 1, 1);
+ iter.setTime(iter.getTime() + MSECS_IN_DAY);
+ }
+
+ // All the children after this are days, and get removed when we update the calendar
+ this._firstDayIndex = this.get_n_children();
+ }
+
+ vfunc_scroll_event(scrollEvent) {
+ switch (scrollEvent.direction) {
+ case Clutter.ScrollDirection.UP:
+ case Clutter.ScrollDirection.LEFT:
+ this._onPrevMonthButtonClicked();
+ break;
+ case Clutter.ScrollDirection.DOWN:
+ case Clutter.ScrollDirection.RIGHT:
+ this._onNextMonthButtonClicked();
+ break;
+ }
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _onPrevMonthButtonClicked() {
+ let newDate = new Date(this._selectedDate);
+ let oldMonth = newDate.getMonth();
+ if (oldMonth == 0) {
+ newDate.setMonth(11);
+ newDate.setFullYear(newDate.getFullYear() - 1);
+ if (newDate.getMonth() != 11) {
+ let day = 32 - new Date(newDate.getFullYear() - 1, 11, 32).getDate();
+ newDate = new Date(newDate.getFullYear() - 1, 11, day);
+ }
+ } else {
+ newDate.setMonth(oldMonth - 1);
+ if (newDate.getMonth() != oldMonth - 1) {
+ let day = 32 - new Date(newDate.getFullYear(), oldMonth - 1, 32).getDate();
+ newDate = new Date(newDate.getFullYear(), oldMonth - 1, day);
+ }
+ }
+
+ this._backButton.grab_key_focus();
+
+ this.setDate(newDate);
+ }
+
+ _onNextMonthButtonClicked() {
+ let newDate = new Date(this._selectedDate);
+ let oldMonth = newDate.getMonth();
+ if (oldMonth == 11) {
+ newDate.setMonth(0);
+ newDate.setFullYear(newDate.getFullYear() + 1);
+ if (newDate.getMonth() != 0) {
+ let day = 32 - new Date(newDate.getFullYear() + 1, 0, 32).getDate();
+ newDate = new Date(newDate.getFullYear() + 1, 0, day);
+ }
+ } else {
+ newDate.setMonth(oldMonth + 1);
+ if (newDate.getMonth() != oldMonth + 1) {
+ let day = 32 - new Date(newDate.getFullYear(), oldMonth + 1, 32).getDate();
+ newDate = new Date(newDate.getFullYear(), oldMonth + 1, day);
+ }
+ }
+
+ this._forwardButton.grab_key_focus();
+
+ this.setDate(newDate);
+ }
+
+ _onSettingsChange() {
+ this._useWeekdate = this._settings.get_boolean(SHOW_WEEKDATE_KEY);
+ this._buildHeader();
+ this._rebuildCalendar();
+ this._update();
+ }
+
+ _rebuildCalendar() {
+ let now = new Date();
+
+ // Remove everything but the topBox and the weekday labels
+ let children = this.get_children();
+ for (let i = this._firstDayIndex; i < children.length; i++)
+ children[i].destroy();
+
+ this._buttons = [];
+
+ // Start at the beginning of the week before the start of the month
+ //
+ // We want to show always 6 weeks (to keep the calendar menu at the same
+ // height if there are no events), so we pad it according to the following
+ // policy:
+ //
+ // 1 - If a month has 6 weeks, we place no padding (example: Dec 2012)
+ // 2 - If a month has 5 weeks and it starts on week start, we pad one week
+ // before it (example: Apr 2012)
+ // 3 - If a month has 5 weeks and it starts on any other day, we pad one week
+ // after it (example: Nov 2012)
+ // 4 - If a month has 4 weeks, we pad one week before and one after it
+ // (example: Feb 2010)
+ //
+ // Actually computing the number of weeks is complex, but we know that the
+ // problematic categories (2 and 4) always start on week start, and that
+ // all months at the end have 6 weeks.
+ let beginDate = new Date(this._selectedDate);
+ beginDate.setDate(1);
+ beginDate.setSeconds(0);
+ beginDate.setHours(12);
+
+ this._calendarBegin = new Date(beginDate);
+ this._markedAsToday = now;
+
+ let daysToWeekStart = (7 + beginDate.getDay() - this._weekStart) % 7;
+ let startsOnWeekStart = daysToWeekStart == 0;
+ let weekPadding = startsOnWeekStart ? 7 : 0;
+
+ beginDate.setTime(beginDate.getTime() - (weekPadding + daysToWeekStart) * MSECS_IN_DAY);
+
+ let layout = this.layout_manager;
+ let iter = new Date(beginDate);
+ let row = 2;
+ // nRows here means 6 weeks + one header + one navbar
+ let nRows = 8;
+ while (row < nRows) {
+ // xgettext:no-javascript-format
+ let button = new St.Button({ label: iter.toLocaleFormat(C_("date day number format", "%d")),
+ can_focus: true });
+ let rtl = button.get_text_direction() == Clutter.TextDirection.RTL;
+
+ if (this._eventSource instanceof EmptyEventSource)
+ button.reactive = false;
+
+ button._date = new Date(iter);
+ button.connect('clicked', () => {
+ this._shouldDateGrabFocus = true;
+ this.setDate(button._date);
+ this._shouldDateGrabFocus = false;
+ });
+
+ let hasEvents = this._eventSource.hasEvents(iter);
+ let styleClass = 'calendar-day-base calendar-day';
+
+ if (_isWorkDay(iter))
+ styleClass += ' calendar-work-day';
+ else
+ styleClass += ' calendar-nonwork-day';
+
+ // Hack used in lieu of border-collapse - see gnome-shell.css
+ if (row == 2)
+ styleClass = 'calendar-day-top %s'.format(styleClass);
+
+ let leftMost = rtl
+ ? iter.getDay() == (this._weekStart + 6) % 7
+ : iter.getDay() == this._weekStart;
+ if (leftMost)
+ styleClass = 'calendar-day-left %s'.format(styleClass);
+
+ if (sameDay(now, iter))
+ styleClass += ' calendar-today';
+ else if (iter.getMonth() != this._selectedDate.getMonth())
+ styleClass += ' calendar-other-month-day';
+
+ if (hasEvents)
+ styleClass += ' calendar-day-with-events';
+
+ button.style_class = styleClass;
+
+ let offsetCols = this._useWeekdate ? 1 : 0;
+ let col;
+ if (rtl)
+ col = 6 - (7 + iter.getDay() - this._weekStart) % 7;
+ else
+ col = offsetCols + (7 + iter.getDay() - this._weekStart) % 7;
+ layout.attach(button, col, row, 1, 1);
+
+ this._buttons.push(button);
+
+ if (this._useWeekdate && iter.getDay() == 4) {
+ let label = new St.Label({ text: iter.toLocaleFormat('%V'),
+ style_class: 'calendar-day-base calendar-week-number',
+ can_focus: true });
+ let weekFormat = Shell.util_translate_time_string(N_("Week %V"));
+ label.clutter_text.y_align = Clutter.ActorAlign.CENTER;
+ label.accessible_name = iter.toLocaleFormat(weekFormat);
+ layout.attach(label, rtl ? 7 : 0, row, 1, 1);
+ }
+
+ iter.setTime(iter.getTime() + MSECS_IN_DAY);
+
+ if (iter.getDay() == this._weekStart)
+ row++;
+ }
+
+ // Signal to the event source that we are interested in events
+ // only from this date range
+ this._eventSource.requestRange(beginDate, iter);
+ }
+
+ _update() {
+ let now = new Date();
+
+ if (sameYear(this._selectedDate, now))
+ this._monthLabel.text = this._selectedDate.toLocaleFormat(this._headerFormatWithoutYear);
+ else
+ this._monthLabel.text = this._selectedDate.toLocaleFormat(this._headerFormat);
+
+ if (!this._calendarBegin || !sameMonth(this._selectedDate, this._calendarBegin) || !sameDay(now, this._markedAsToday))
+ this._rebuildCalendar();
+
+ this._buttons.forEach(button => {
+ if (sameDay(button._date, this._selectedDate)) {
+ button.add_style_pseudo_class('selected');
+ if (this._shouldDateGrabFocus)
+ button.grab_key_focus();
+ } else {
+ button.remove_style_pseudo_class('selected');
+ }
+ });
+ }
+});
+
+var NotificationMessage = GObject.registerClass(
+class NotificationMessage extends MessageList.Message {
+ _init(notification) {
+ super._init(notification.title, notification.bannerBodyText);
+ this.setUseBodyMarkup(notification.bannerBodyMarkup);
+
+ this.notification = notification;
+
+ this.setIcon(this._getIcon());
+
+ this.connect('close', () => {
+ this._closed = true;
+ if (this.notification)
+ this.notification.destroy(MessageTray.NotificationDestroyedReason.DISMISSED);
+ });
+ this._destroyId = notification.connect('destroy', () => {
+ this._disconnectNotificationSignals();
+ this.notification = null;
+ if (!this._closed)
+ this.close();
+ });
+ this._updatedId = notification.connect('updated',
+ this._onUpdated.bind(this));
+ }
+
+ _getIcon() {
+ if (this.notification.gicon) {
+ return new St.Icon({ gicon: this.notification.gicon,
+ icon_size: MESSAGE_ICON_SIZE });
+ } else {
+ return this.notification.source.createIcon(MESSAGE_ICON_SIZE);
+ }
+ }
+
+ _onUpdated(n, _clear) {
+ this.setIcon(this._getIcon());
+ this.setTitle(n.title);
+ this.setBody(n.bannerBodyText);
+ this.setUseBodyMarkup(n.bannerBodyMarkup);
+ }
+
+ vfunc_clicked() {
+ this.notification.activate();
+ }
+
+ _onDestroy() {
+ super._onDestroy();
+ this._disconnectNotificationSignals();
+ }
+
+ _disconnectNotificationSignals() {
+ if (this._updatedId)
+ this.notification.disconnect(this._updatedId);
+ this._updatedId = 0;
+
+ if (this._destroyId)
+ this.notification.disconnect(this._destroyId);
+ this._destroyId = 0;
+ }
+
+ canClose() {
+ return true;
+ }
+});
+
+var TimeLabel = GObject.registerClass(
+class NotificationTimeLabel extends St.Label {
+ _init(datetime) {
+ super._init({
+ style_class: 'event-time',
+ x_align: Clutter.ActorAlign.START,
+ y_align: Clutter.ActorAlign.END,
+ });
+ this._datetime = datetime;
+ }
+
+ vfunc_map() {
+ this.text = Util.formatTimeSpan(this._datetime);
+ super.vfunc_map();
+ }
+});
+
+var NotificationSection = GObject.registerClass(
+class NotificationSection extends MessageList.MessageListSection {
+ _init() {
+ super._init();
+
+ this._sources = new Map();
+ this._nUrgent = 0;
+
+ Main.messageTray.connect('source-added', this._sourceAdded.bind(this));
+ Main.messageTray.getSources().forEach(source => {
+ this._sourceAdded(Main.messageTray, source);
+ });
+ }
+
+ get allowed() {
+ return Main.sessionMode.hasNotifications &&
+ !Main.sessionMode.isGreeter;
+ }
+
+ _sourceAdded(tray, source) {
+ let obj = {
+ destroyId: 0,
+ notificationAddedId: 0,
+ };
+
+ obj.destroyId = source.connect('destroy', () => {
+ this._onSourceDestroy(source, obj);
+ });
+ obj.notificationAddedId = source.connect('notification-added',
+ this._onNotificationAdded.bind(this));
+
+ this._sources.set(source, obj);
+ }
+
+ _onNotificationAdded(source, notification) {
+ let message = new NotificationMessage(notification);
+ message.setSecondaryActor(new TimeLabel(notification.datetime));
+
+ let isUrgent = notification.urgency == MessageTray.Urgency.CRITICAL;
+
+ let updatedId = notification.connect('updated', () => {
+ message.setSecondaryActor(new TimeLabel(notification.datetime));
+ this.moveMessage(message, isUrgent ? 0 : this._nUrgent, this.mapped);
+ });
+ let destroyId = notification.connect('destroy', () => {
+ notification.disconnect(destroyId);
+ notification.disconnect(updatedId);
+ if (isUrgent)
+ this._nUrgent--;
+ });
+
+ if (isUrgent) {
+ // Keep track of urgent notifications to keep them on top
+ this._nUrgent++;
+ } else if (this.mapped) {
+ // Only acknowledge non-urgent notifications in case it
+ // has important actions that are inaccessible when not
+ // shown as banner
+ notification.acknowledged = true;
+ }
+
+ let index = isUrgent ? 0 : this._nUrgent;
+ this.addMessageAtIndex(message, index, this.mapped);
+ }
+
+ _onSourceDestroy(source, obj) {
+ source.disconnect(obj.destroyId);
+ source.disconnect(obj.notificationAddedId);
+
+ this._sources.delete(source);
+ }
+
+ vfunc_map() {
+ this._messages.forEach(message => {
+ if (message.notification.urgency != MessageTray.Urgency.CRITICAL)
+ message.notification.acknowledged = true;
+ });
+ super.vfunc_map();
+ }
+});
+
+var Placeholder = GObject.registerClass(
+class Placeholder extends St.BoxLayout {
+ _init() {
+ super._init({ style_class: 'message-list-placeholder', vertical: true });
+ this._date = new Date();
+
+ const file = Gio.File.new_for_uri(
+ 'resource:///org/gnome/shell/theme/no-notifications.svg');
+ this._icon = new St.Icon({ gicon: new Gio.FileIcon({ file }) });
+ this.add_actor(this._icon);
+
+ this._label = new St.Label({ text: _('No Notifications') });
+ this.add_actor(this._label);
+ }
+});
+
+const DoNotDisturbSwitch = GObject.registerClass(
+class DoNotDisturbSwitch extends PopupMenu.Switch {
+ _init() {
+ this._settings = new Gio.Settings({
+ schema_id: 'org.gnome.desktop.notifications',
+ });
+
+ super._init(this._settings.get_boolean('show-banners'));
+
+ this._settings.bind('show-banners',
+ this, 'state',
+ Gio.SettingsBindFlags.INVERT_BOOLEAN);
+
+ this.connect('destroy', () => {
+ this._settings.run_dispose();
+ this._settings = null;
+ });
+ }
+});
+
+var CalendarMessageList = GObject.registerClass(
+class CalendarMessageList extends St.Widget {
+ _init() {
+ super._init({
+ style_class: 'message-list',
+ layout_manager: new Clutter.BinLayout(),
+ x_expand: true,
+ y_expand: true,
+ });
+
+ this._placeholder = new Placeholder();
+ this.add_actor(this._placeholder);
+
+ let box = new St.BoxLayout({ vertical: true,
+ x_expand: true, y_expand: true });
+ this.add_actor(box);
+
+ this._scrollView = new St.ScrollView({
+ style_class: 'vfade',
+ overlay_scrollbars: true,
+ x_expand: true, y_expand: true,
+ });
+ this._scrollView.set_policy(St.PolicyType.NEVER, St.PolicyType.AUTOMATIC);
+ box.add_actor(this._scrollView);
+
+ let hbox = new St.BoxLayout({ style_class: 'message-list-controls' });
+ box.add_child(hbox);
+
+ const dndLabel = new St.Label({
+ text: _('Do Not Disturb'),
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ hbox.add_child(dndLabel);
+
+ this._dndSwitch = new DoNotDisturbSwitch();
+ this._dndButton = new St.Button({
+ can_focus: true,
+ toggle_mode: true,
+ child: this._dndSwitch,
+ label_actor: dndLabel,
+ });
+ this._dndSwitch.bind_property('state',
+ this._dndButton, 'checked',
+ GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE);
+ hbox.add_child(this._dndButton);
+
+ this._clearButton = new St.Button({
+ style_class: 'message-list-clear-button button',
+ label: _('Clear'),
+ can_focus: true,
+ x_expand: true,
+ x_align: Clutter.ActorAlign.END,
+ });
+ this._clearButton.connect('clicked', () => {
+ this._sectionList.get_children().forEach(s => s.clear());
+ });
+ hbox.add_actor(this._clearButton);
+
+ this._placeholder.bind_property('visible',
+ this._clearButton, 'visible',
+ GObject.BindingFlags.INVERT_BOOLEAN);
+
+ this._sectionList = new St.BoxLayout({ style_class: 'message-list-sections',
+ vertical: true,
+ x_expand: true,
+ y_expand: true,
+ y_align: Clutter.ActorAlign.START });
+ this._sectionList.connect('actor-added', this._sync.bind(this));
+ this._sectionList.connect('actor-removed', this._sync.bind(this));
+ this._scrollView.add_actor(this._sectionList);
+
+ this._mediaSection = new Mpris.MediaSection();
+ this._addSection(this._mediaSection);
+
+ this._notificationSection = new NotificationSection();
+ this._addSection(this._notificationSection);
+
+ Main.sessionMode.connect('updated', this._sync.bind(this));
+ }
+
+ _addSection(section) {
+ let connectionsIds = [];
+
+ for (let prop of ['visible', 'empty', 'can-clear']) {
+ connectionsIds.push(
+ section.connect('notify::%s'.format(prop), this._sync.bind(this)));
+ }
+ connectionsIds.push(section.connect('message-focused', (_s, messageActor) => {
+ Util.ensureActorVisibleInScrollView(this._scrollView, messageActor);
+ }));
+
+ connectionsIds.push(section.connect('destroy', () => {
+ connectionsIds.forEach(id => section.disconnect(id));
+ this._sectionList.remove_actor(section);
+ }));
+
+ this._sectionList.add_actor(section);
+ }
+
+ _sync() {
+ let sections = this._sectionList.get_children();
+ let visible = sections.some(s => s.allowed);
+ this.visible = visible;
+ if (!visible)
+ return;
+
+ let empty = sections.every(s => s.empty || !s.visible);
+ this._placeholder.visible = empty;
+
+ let canClear = sections.some(s => s.canClear && s.visible);
+ this._clearButton.reactive = canClear;
+ }
+});
diff --git a/js/ui/checkBox.js b/js/ui/checkBox.js
new file mode 100644
index 0000000..d64bd0d
--- /dev/null
+++ b/js/ui/checkBox.js
@@ -0,0 +1,40 @@
+/* exported CheckBox */
+const { Atk, Clutter, GObject, Pango, St } = imports.gi;
+
+var CheckBox = GObject.registerClass(
+class CheckBox extends St.Button {
+ _init(label) {
+ let container = new St.BoxLayout({
+ x_expand: true,
+ y_expand: true,
+ });
+ super._init({
+ style_class: 'check-box',
+ child: container,
+ button_mask: St.ButtonMask.ONE,
+ toggle_mode: true,
+ can_focus: true,
+ });
+ this.set_accessible_role(Atk.Role.CHECK_BOX);
+
+ this._box = new St.Bin({ y_align: Clutter.ActorAlign.START });
+ container.add_actor(this._box);
+
+ this._label = new St.Label({ y_align: Clutter.ActorAlign.CENTER });
+ this._label.clutter_text.set_line_wrap(true);
+ this._label.clutter_text.set_ellipsize(Pango.EllipsizeMode.NONE);
+ this.set_label_actor(this._label);
+ container.add_actor(this._label);
+
+ if (label)
+ this.setLabel(label);
+ }
+
+ setLabel(label) {
+ this._label.set_text(label);
+ }
+
+ getLabelActor() {
+ return this._label;
+ }
+});
diff --git a/js/ui/closeDialog.js b/js/ui/closeDialog.js
new file mode 100644
index 0000000..63a0bcf
--- /dev/null
+++ b/js/ui/closeDialog.js
@@ -0,0 +1,210 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported CloseDialog */
+
+const { Clutter, GLib, GObject, Meta, Shell, St } = imports.gi;
+
+const Dialog = imports.ui.dialog;
+const Main = imports.ui.main;
+
+var FROZEN_WINDOW_BRIGHTNESS = -0.3;
+var DIALOG_TRANSITION_TIME = 150;
+var ALIVE_TIMEOUT = 5000;
+
+var CloseDialog = GObject.registerClass({
+ Implements: [Meta.CloseDialog],
+ Properties: {
+ 'window': GObject.ParamSpec.override('window', Meta.CloseDialog),
+ },
+}, class CloseDialog extends GObject.Object {
+ _init(window) {
+ super._init();
+ this._window = window;
+ this._dialog = null;
+ this._tracked = undefined;
+ this._timeoutId = 0;
+ this._windowFocusChangedId = 0;
+ this._keyFocusChangedId = 0;
+ }
+
+ get window() {
+ return this._window;
+ }
+
+ set window(window) {
+ this._window = window;
+ }
+
+ _createDialogContent() {
+ let tracker = Shell.WindowTracker.get_default();
+ let windowApp = tracker.get_window_app(this._window);
+
+ /* Translators: %s is an application name */
+ let title = _("“%s” is not responding.").format(windowApp.get_name());
+ let description = _('You may choose to wait a short while for it to ' +
+ 'continue or force the application to quit entirely.');
+ return new Dialog.MessageDialogContent({ title, description });
+ }
+
+ _updateScale() {
+ // Since this is a child of MetaWindowActor (which, for Wayland clients,
+ // applies the geometry scale factor to its children itself, see
+ // meta_window_actor_set_geometry_scale()), make sure we don't apply
+ // the factor twice in the end.
+ if (this._window.get_client_type() !== Meta.WindowClientType.WAYLAND)
+ return;
+
+ let { scaleFactor } = St.ThemeContext.get_for_stage(global.stage);
+ this._dialog.set_scale(1 / scaleFactor, 1 / scaleFactor);
+ }
+
+ _initDialog() {
+ if (this._dialog)
+ return;
+
+ let windowActor = this._window.get_compositor_private();
+ this._dialog = new Dialog.Dialog(windowActor, 'close-dialog');
+ this._dialog.width = windowActor.width;
+ this._dialog.height = windowActor.height;
+
+ this._dialog.contentLayout.add_child(this._createDialogContent());
+ this._dialog.addButton({ label: _('Force Quit'),
+ action: this._onClose.bind(this),
+ default: true });
+ this._dialog.addButton({ label: _('Wait'),
+ action: this._onWait.bind(this),
+ key: Clutter.KEY_Escape });
+
+ global.focus_manager.add_group(this._dialog);
+
+ let themeContext = St.ThemeContext.get_for_stage(global.stage);
+ themeContext.connect('notify::scale-factor', this._updateScale.bind(this));
+
+ this._updateScale();
+ }
+
+ _addWindowEffect() {
+ // We set the effect on the surface actor, so the dialog itself
+ // (which is a child of the MetaWindowActor) does not get the
+ // effect applied itself.
+ let windowActor = this._window.get_compositor_private();
+ let surfaceActor = windowActor.get_first_child();
+ let effect = new Clutter.BrightnessContrastEffect();
+ effect.set_brightness(FROZEN_WINDOW_BRIGHTNESS);
+ surfaceActor.add_effect_with_name("gnome-shell-frozen-window", effect);
+ }
+
+ _removeWindowEffect() {
+ let windowActor = this._window.get_compositor_private();
+ let surfaceActor = windowActor.get_first_child();
+ surfaceActor.remove_effect_by_name("gnome-shell-frozen-window");
+ }
+
+ _onWait() {
+ this.response(Meta.CloseDialogResponse.WAIT);
+ }
+
+ _onClose() {
+ this.response(Meta.CloseDialogResponse.FORCE_CLOSE);
+ }
+
+ _onFocusChanged() {
+ if (Meta.is_wayland_compositor())
+ return;
+
+ let focusWindow = global.display.focus_window;
+ let keyFocus = global.stage.key_focus;
+
+ let shouldTrack;
+ if (focusWindow != null)
+ shouldTrack = focusWindow == this._window;
+ else
+ shouldTrack = keyFocus && this._dialog.contains(keyFocus);
+
+ if (this._tracked === shouldTrack)
+ return;
+
+ if (shouldTrack) {
+ Main.layoutManager.trackChrome(this._dialog,
+ { affectsInputRegion: true });
+ } else {
+ Main.layoutManager.untrackChrome(this._dialog);
+ }
+
+ // The buttons are broken when they aren't added to the input region,
+ // so disable them properly in that case
+ this._dialog.buttonLayout.get_children().forEach(b => {
+ b.reactive = shouldTrack;
+ });
+
+ this._tracked = shouldTrack;
+ }
+
+ vfunc_show() {
+ if (this._dialog != null)
+ return;
+
+ Meta.disable_unredirect_for_display(global.display);
+
+ this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, ALIVE_TIMEOUT,
+ () => {
+ this._window.check_alive(global.display.get_current_time_roundtrip());
+ return GLib.SOURCE_CONTINUE;
+ });
+
+ this._windowFocusChangedId =
+ global.display.connect('notify::focus-window',
+ this._onFocusChanged.bind(this));
+
+ this._keyFocusChangedId =
+ global.stage.connect('notify::key-focus',
+ this._onFocusChanged.bind(this));
+
+ this._addWindowEffect();
+ this._initDialog();
+
+ this._dialog._dialog.scale_y = 0;
+ this._dialog._dialog.set_pivot_point(0.5, 0.5);
+
+ this._dialog._dialog.ease({
+ scale_y: 1,
+ mode: Clutter.AnimationMode.LINEAR,
+ duration: DIALOG_TRANSITION_TIME,
+ onComplete: this._onFocusChanged.bind(this),
+ });
+ }
+
+ vfunc_hide() {
+ if (this._dialog == null)
+ return;
+
+ Meta.enable_unredirect_for_display(global.display);
+
+ GLib.source_remove(this._timeoutId);
+ this._timeoutId = 0;
+
+ global.display.disconnect(this._windowFocusChangedId);
+ this._windowFocusChangedId = 0;
+
+ global.stage.disconnect(this._keyFocusChangedId);
+ this._keyFocusChangedId = 0;
+
+ this._dialog._dialog.remove_all_transitions();
+
+ let dialog = this._dialog;
+ this._dialog = null;
+ this._removeWindowEffect();
+
+ dialog.makeInactive();
+ dialog._dialog.ease({
+ scale_y: 0,
+ mode: Clutter.AnimationMode.LINEAR,
+ duration: DIALOG_TRANSITION_TIME,
+ onComplete: () => dialog.destroy(),
+ });
+ }
+
+ vfunc_focus() {
+ if (this._dialog)
+ this._dialog.grab_key_focus();
+ }
+});
diff --git a/js/ui/components/__init__.js b/js/ui/components/__init__.js
new file mode 100644
index 0000000..7430013
--- /dev/null
+++ b/js/ui/components/__init__.js
@@ -0,0 +1,58 @@
+/* exported ComponentManager */
+const Main = imports.ui.main;
+
+var ComponentManager = class {
+ constructor() {
+ this._allComponents = {};
+ this._enabledComponents = [];
+
+ Main.sessionMode.connect('updated', this._sessionUpdated.bind(this));
+ this._sessionUpdated();
+ }
+
+ _sessionUpdated() {
+ let newEnabledComponents = Main.sessionMode.components;
+
+ newEnabledComponents
+ .filter(name => !this._enabledComponents.includes(name))
+ .forEach(name => this._enableComponent(name));
+
+ this._enabledComponents
+ .filter(name => !newEnabledComponents.includes(name))
+ .forEach(name => this._disableComponent(name));
+
+ this._enabledComponents = newEnabledComponents;
+ }
+
+ _importComponent(name) {
+ let module = imports.ui.components[name];
+ return module.Component;
+ }
+
+ _ensureComponent(name) {
+ let component = this._allComponents[name];
+ if (component)
+ return component;
+
+ if (Main.sessionMode.isLocked)
+ return null;
+
+ let constructor = this._importComponent(name);
+ component = new constructor();
+ this._allComponents[name] = component;
+ return component;
+ }
+
+ _enableComponent(name) {
+ let component = this._ensureComponent(name);
+ if (component)
+ component.enable();
+ }
+
+ _disableComponent(name) {
+ let component = this._allComponents[name];
+ if (component == null)
+ return;
+ component.disable();
+ }
+};
diff --git a/js/ui/components/automountManager.js b/js/ui/components/automountManager.js
new file mode 100644
index 0000000..1cb84a7
--- /dev/null
+++ b/js/ui/components/automountManager.js
@@ -0,0 +1,259 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Component */
+
+const { Gio, GLib } = imports.gi;
+const Params = imports.misc.params;
+
+const GnomeSession = imports.misc.gnomeSession;
+const Main = imports.ui.main;
+const ShellMountOperation = imports.ui.shellMountOperation;
+
+var GNOME_SESSION_AUTOMOUNT_INHIBIT = 16;
+
+// GSettings keys
+const SETTINGS_SCHEMA = 'org.gnome.desktop.media-handling';
+const SETTING_ENABLE_AUTOMOUNT = 'automount';
+
+var AUTORUN_EXPIRE_TIMEOUT_SECS = 10;
+
+var AutomountManager = class {
+ constructor() {
+ this._settings = new Gio.Settings({ schema_id: SETTINGS_SCHEMA });
+ this._volumeQueue = [];
+ this._activeOperations = new Map();
+ this._session = new GnomeSession.SessionManager();
+ this._session.connectSignal('InhibitorAdded',
+ this._InhibitorsChanged.bind(this));
+ this._session.connectSignal('InhibitorRemoved',
+ this._InhibitorsChanged.bind(this));
+ this._inhibited = false;
+
+ this._volumeMonitor = Gio.VolumeMonitor.get();
+ }
+
+ enable() {
+ this._volumeAddedId = this._volumeMonitor.connect('volume-added', this._onVolumeAdded.bind(this));
+ this._volumeRemovedId = this._volumeMonitor.connect('volume-removed', this._onVolumeRemoved.bind(this));
+ this._driveConnectedId = this._volumeMonitor.connect('drive-connected', this._onDriveConnected.bind(this));
+ this._driveDisconnectedId = this._volumeMonitor.connect('drive-disconnected', this._onDriveDisconnected.bind(this));
+ this._driveEjectButtonId = this._volumeMonitor.connect('drive-eject-button', this._onDriveEjectButton.bind(this));
+
+ this._mountAllId = GLib.idle_add(GLib.PRIORITY_DEFAULT, this._startupMountAll.bind(this));
+ GLib.Source.set_name_by_id(this._mountAllId, '[gnome-shell] this._startupMountAll');
+ }
+
+ disable() {
+ this._volumeMonitor.disconnect(this._volumeAddedId);
+ this._volumeMonitor.disconnect(this._volumeRemovedId);
+ this._volumeMonitor.disconnect(this._driveConnectedId);
+ this._volumeMonitor.disconnect(this._driveDisconnectedId);
+ this._volumeMonitor.disconnect(this._driveEjectButtonId);
+
+ if (this._mountAllId > 0) {
+ GLib.source_remove(this._mountAllId);
+ this._mountAllId = 0;
+ }
+ }
+
+ _InhibitorsChanged(_object, _senderName, [_inhibitor]) {
+ this._session.IsInhibitedRemote(GNOME_SESSION_AUTOMOUNT_INHIBIT,
+ (result, error) => {
+ if (!error)
+ this._inhibited = result[0];
+ });
+ }
+
+ _startupMountAll() {
+ let volumes = this._volumeMonitor.get_volumes();
+ volumes.forEach(volume => {
+ this._checkAndMountVolume(volume, { checkSession: false,
+ useMountOp: false,
+ allowAutorun: false });
+ });
+
+ this._mountAllId = 0;
+ return GLib.SOURCE_REMOVE;
+ }
+
+ _onDriveConnected() {
+ // if we're not in the current ConsoleKit session,
+ // or screensaver is active, don't play sounds
+ if (!this._session.SessionIsActive)
+ return;
+
+ let player = global.display.get_sound_player();
+ player.play_from_theme('device-added-media',
+ _("External drive connected"),
+ null);
+ }
+
+ _onDriveDisconnected() {
+ // if we're not in the current ConsoleKit session,
+ // or screensaver is active, don't play sounds
+ if (!this._session.SessionIsActive)
+ return;
+
+ let player = global.display.get_sound_player();
+ player.play_from_theme('device-removed-media',
+ _("External drive disconnected"),
+ null);
+ }
+
+ _onDriveEjectButton(monitor, drive) {
+ // TODO: this code path is not tested, as the GVfs volume monitor
+ // doesn't emit this signal just yet.
+ if (!this._session.SessionIsActive)
+ return;
+
+ // we force stop/eject in this case, so we don't have to pass a
+ // mount operation object
+ if (drive.can_stop()) {
+ drive.stop(Gio.MountUnmountFlags.FORCE, null, null,
+ (o, res) => {
+ try {
+ drive.stop_finish(res);
+ } catch (e) {
+ log('Unable to stop the drive after drive-eject-button %s'.format(e.toString()));
+ }
+ });
+ } else if (drive.can_eject()) {
+ drive.eject_with_operation(Gio.MountUnmountFlags.FORCE, null, null,
+ (o, res) => {
+ try {
+ drive.eject_with_operation_finish(res);
+ } catch (e) {
+ log('Unable to eject the drive after drive-eject-button %s'.format(e.toString()));
+ }
+ });
+ }
+ }
+
+ _onVolumeAdded(monitor, volume) {
+ this._checkAndMountVolume(volume);
+ }
+
+ _checkAndMountVolume(volume, params) {
+ params = Params.parse(params, { checkSession: true,
+ useMountOp: true,
+ allowAutorun: true });
+
+ if (params.checkSession) {
+ // if we're not in the current ConsoleKit session,
+ // don't attempt automount
+ if (!this._session.SessionIsActive)
+ return;
+ }
+
+ if (this._inhibited)
+ return;
+
+ // Volume is already mounted, don't bother.
+ if (volume.get_mount())
+ return;
+
+ if (!this._settings.get_boolean(SETTING_ENABLE_AUTOMOUNT) ||
+ !volume.should_automount() ||
+ !volume.can_mount()) {
+ // allow the autorun to run anyway; this can happen if the
+ // mount gets added programmatically later, even if
+ // should_automount() or can_mount() are false, like for
+ // blank optical media.
+ this._allowAutorun(volume);
+ this._allowAutorunExpire(volume);
+
+ return;
+ }
+
+ if (params.useMountOp) {
+ let operation = new ShellMountOperation.ShellMountOperation(volume);
+ this._mountVolume(volume, operation, params.allowAutorun);
+ } else {
+ this._mountVolume(volume, null, params.allowAutorun);
+ }
+ }
+
+ _mountVolume(volume, operation, allowAutorun) {
+ if (allowAutorun)
+ this._allowAutorun(volume);
+
+ let mountOp = operation ? operation.mountOp : null;
+ this._activeOperations.set(volume, operation);
+
+ volume.mount(0, mountOp, null,
+ this._onVolumeMounted.bind(this));
+ }
+
+ _onVolumeMounted(volume, res) {
+ this._allowAutorunExpire(volume);
+
+ try {
+ volume.mount_finish(res);
+ this._closeOperation(volume);
+ } catch (e) {
+ // FIXME: we will always get G_IO_ERROR_FAILED from the gvfs udisks
+ // backend, see https://bugs.freedesktop.org/show_bug.cgi?id=51271
+ // To reask the password if the user input was empty or wrong, we
+ // will check for corresponding error messages. However, these
+ // error strings are not unique for the cases in the comments below.
+ if (e.message.includes('No key available with this passphrase') || // cryptsetup
+ e.message.includes('No key available to unlock device') || // udisks (no password)
+ // libblockdev wrong password opening LUKS device
+ e.message.includes('Failed to activate device: Incorrect passphrase') ||
+ // cryptsetup returns EINVAL in many cases, including wrong TCRYPT password/parameters
+ e.message.includes('Failed to load device\'s parameters: Invalid argument')) {
+
+ this._reaskPassword(volume);
+ } else {
+ if (e.message.includes('Compiled against a version of libcryptsetup that does not support the VeraCrypt PIM setting')) {
+ Main.notifyError(_("Unable to unlock volume"),
+ _("The installed udisks version does not support the PIM setting"));
+ }
+
+ if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED_HANDLED))
+ log('Unable to mount volume %s: %s'.format(volume.get_name(), e.toString()));
+ this._closeOperation(volume);
+ }
+ }
+ }
+
+ _onVolumeRemoved(monitor, volume) {
+ if (volume._allowAutorunExpireId && volume._allowAutorunExpireId > 0) {
+ GLib.source_remove(volume._allowAutorunExpireId);
+ delete volume._allowAutorunExpireId;
+ }
+ this._volumeQueue =
+ this._volumeQueue.filter(element => element != volume);
+ }
+
+ _reaskPassword(volume) {
+ let prevOperation = this._activeOperations.get(volume);
+ let existingDialog = prevOperation ? prevOperation.borrowDialog() : null;
+ let operation =
+ new ShellMountOperation.ShellMountOperation(volume,
+ { existingDialog });
+ this._mountVolume(volume, operation);
+ }
+
+ _closeOperation(volume) {
+ let operation = this._activeOperations.get(volume);
+ if (!operation)
+ return;
+ operation.close();
+ this._activeOperations.delete(volume);
+ }
+
+ _allowAutorun(volume) {
+ volume.allowAutorun = true;
+ }
+
+ _allowAutorunExpire(volume) {
+ let id = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, AUTORUN_EXPIRE_TIMEOUT_SECS, () => {
+ volume.allowAutorun = false;
+ delete volume._allowAutorunExpireId;
+ return GLib.SOURCE_REMOVE;
+ });
+ volume._allowAutorunExpireId = id;
+ GLib.Source.set_name_by_id(id, '[gnome-shell] volume.allowAutorun');
+ }
+};
+var Component = AutomountManager;
diff --git a/js/ui/components/autorunManager.js b/js/ui/components/autorunManager.js
new file mode 100644
index 0000000..cc7b412
--- /dev/null
+++ b/js/ui/components/autorunManager.js
@@ -0,0 +1,359 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Component */
+
+const { Clutter, Gio, GObject, St } = imports.gi;
+
+const GnomeSession = imports.misc.gnomeSession;
+const Main = imports.ui.main;
+const MessageTray = imports.ui.messageTray;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+// GSettings keys
+const SETTINGS_SCHEMA = 'org.gnome.desktop.media-handling';
+const SETTING_DISABLE_AUTORUN = 'autorun-never';
+const SETTING_START_APP = 'autorun-x-content-start-app';
+const SETTING_IGNORE = 'autorun-x-content-ignore';
+const SETTING_OPEN_FOLDER = 'autorun-x-content-open-folder';
+
+var AutorunSetting = {
+ RUN: 0,
+ IGNORE: 1,
+ FILES: 2,
+ ASK: 3,
+};
+
+// misc utils
+function shouldAutorunMount(mount) {
+ let root = mount.get_root();
+ let volume = mount.get_volume();
+
+ if (!volume || !volume.allowAutorun)
+ return false;
+
+ if (root.is_native() && isMountRootHidden(root))
+ return false;
+
+ return true;
+}
+
+function isMountRootHidden(root) {
+ let path = root.get_path();
+
+ // skip any mounts in hidden directory hierarchies
+ return path.includes('/.');
+}
+
+function isMountNonLocal(mount) {
+ // If the mount doesn't have an associated volume, that means it's
+ // an uninteresting filesystem. Most devices that we care about will
+ // have a mount, like media players and USB sticks.
+ let volume = mount.get_volume();
+ if (volume == null)
+ return true;
+
+ return volume.get_identifier("class") == "network";
+}
+
+function startAppForMount(app, mount) {
+ let files = [];
+ let root = mount.get_root();
+ let retval = false;
+
+ files.push(root);
+
+ try {
+ retval = app.launch(files,
+ global.create_app_launch_context(0, -1));
+ } catch (e) {
+ log('Unable to launch the application %s: %s'.format(app.get_name(), e.toString()));
+ }
+
+ return retval;
+}
+
+const HotplugSnifferIface = loadInterfaceXML('org.gnome.Shell.HotplugSniffer');
+const HotplugSnifferProxy = Gio.DBusProxy.makeProxyWrapper(HotplugSnifferIface);
+function HotplugSniffer() {
+ return new HotplugSnifferProxy(Gio.DBus.session,
+ 'org.gnome.Shell.HotplugSniffer',
+ '/org/gnome/Shell/HotplugSniffer');
+}
+
+var ContentTypeDiscoverer = class {
+ constructor(callback) {
+ this._callback = callback;
+ this._settings = new Gio.Settings({ schema_id: SETTINGS_SCHEMA });
+ }
+
+ guessContentTypes(mount) {
+ let autorunEnabled = !this._settings.get_boolean(SETTING_DISABLE_AUTORUN);
+ let shouldScan = autorunEnabled && !isMountNonLocal(mount);
+
+ if (shouldScan) {
+ // guess mount's content types using GIO
+ mount.guess_content_type(false, null,
+ this._onContentTypeGuessed.bind(this));
+ } else {
+ this._emitCallback(mount, []);
+ }
+ }
+
+ _onContentTypeGuessed(mount, res) {
+ let contentTypes = [];
+
+ try {
+ contentTypes = mount.guess_content_type_finish(res);
+ } catch (e) {
+ log('Unable to guess content types on added mount %s: %s'.format(mount.get_name(), e.toString()));
+ }
+
+ if (contentTypes.length) {
+ this._emitCallback(mount, contentTypes);
+ } else {
+ let root = mount.get_root();
+
+ let hotplugSniffer = new HotplugSniffer();
+ hotplugSniffer.SniffURIRemote(root.get_uri(),
+ result => {
+ [contentTypes] = result;
+ this._emitCallback(mount, contentTypes);
+ });
+ }
+ }
+
+ _emitCallback(mount, contentTypes = []) {
+ // we're not interested in win32 software content types here
+ contentTypes = contentTypes.filter(
+ type => type !== 'x-content/win32-software');
+
+ let apps = [];
+ contentTypes.forEach(type => {
+ let app = Gio.app_info_get_default_for_type(type, false);
+
+ if (app)
+ apps.push(app);
+ });
+
+ if (apps.length == 0)
+ apps.push(Gio.app_info_get_default_for_type('inode/directory', false));
+
+ this._callback(mount, apps, contentTypes);
+ }
+};
+
+var AutorunManager = class {
+ constructor() {
+ this._session = new GnomeSession.SessionManager();
+ this._volumeMonitor = Gio.VolumeMonitor.get();
+
+ this._dispatcher = new AutorunDispatcher(this);
+ }
+
+ enable() {
+ this._mountAddedId = this._volumeMonitor.connect('mount-added', this._onMountAdded.bind(this));
+ this._mountRemovedId = this._volumeMonitor.connect('mount-removed', this._onMountRemoved.bind(this));
+ }
+
+ disable() {
+ this._volumeMonitor.disconnect(this._mountAddedId);
+ this._volumeMonitor.disconnect(this._mountRemovedId);
+ }
+
+ _onMountAdded(monitor, mount) {
+ // don't do anything if our session is not the currently
+ // active one
+ if (!this._session.SessionIsActive)
+ return;
+
+ let discoverer = new ContentTypeDiscoverer((m, apps, contentTypes) => {
+ this._dispatcher.addMount(mount, apps, contentTypes);
+ });
+ discoverer.guessContentTypes(mount);
+ }
+
+ _onMountRemoved(monitor, mount) {
+ this._dispatcher.removeMount(mount);
+ }
+};
+
+var AutorunDispatcher = class {
+ constructor(manager) {
+ this._manager = manager;
+ this._sources = [];
+ this._settings = new Gio.Settings({ schema_id: SETTINGS_SCHEMA });
+ }
+
+ _getAutorunSettingForType(contentType) {
+ let runApp = this._settings.get_strv(SETTING_START_APP);
+ if (runApp.includes(contentType))
+ return AutorunSetting.RUN;
+
+ let ignore = this._settings.get_strv(SETTING_IGNORE);
+ if (ignore.includes(contentType))
+ return AutorunSetting.IGNORE;
+
+ let openFiles = this._settings.get_strv(SETTING_OPEN_FOLDER);
+ if (openFiles.includes(contentType))
+ return AutorunSetting.FILES;
+
+ return AutorunSetting.ASK;
+ }
+
+ _getSourceForMount(mount) {
+ let filtered = this._sources.filter(source => source.mount == mount);
+
+ // we always make sure not to add two sources for the same
+ // mount in addMount(), so it's safe to assume filtered.length
+ // is always either 1 or 0.
+ if (filtered.length == 1)
+ return filtered[0];
+
+ return null;
+ }
+
+ _addSource(mount, apps) {
+ // if we already have a source showing for this
+ // mount, return
+ if (this._getSourceForMount(mount))
+ return;
+
+ // add a new source
+ this._sources.push(new AutorunSource(this._manager, mount, apps));
+ }
+
+ addMount(mount, apps, contentTypes) {
+ // if autorun is disabled globally, return
+ if (this._settings.get_boolean(SETTING_DISABLE_AUTORUN))
+ return;
+
+ // if the mount doesn't want to be autorun, return
+ if (!shouldAutorunMount(mount))
+ return;
+
+ let setting;
+ if (contentTypes.length > 0)
+ setting = this._getAutorunSettingForType(contentTypes[0]);
+ else
+ setting = AutorunSetting.ASK;
+
+ // check at the settings for the first content type
+ // to see whether we should ask
+ if (setting == AutorunSetting.IGNORE)
+ return; // return right away
+
+ let success = false;
+ let app = null;
+
+ if (setting == AutorunSetting.RUN)
+ app = Gio.app_info_get_default_for_type(contentTypes[0], false);
+ else if (setting == AutorunSetting.FILES)
+ app = Gio.app_info_get_default_for_type('inode/directory', false);
+
+ if (app)
+ success = startAppForMount(app, mount);
+
+ // we fallback here also in case the settings did not specify 'ask',
+ // but we failed launching the default app or the default file manager
+ if (!success)
+ this._addSource(mount, apps);
+ }
+
+ removeMount(mount) {
+ let source = this._getSourceForMount(mount);
+
+ // if we aren't tracking this mount, don't do anything
+ if (!source)
+ return;
+
+ // destroy the notification source
+ source.destroy();
+ }
+};
+
+var AutorunSource = GObject.registerClass(
+class AutorunSource extends MessageTray.Source {
+ _init(manager, mount, apps) {
+ super._init(mount.get_name());
+
+ this._manager = manager;
+ this.mount = mount;
+ this.apps = apps;
+
+ this._notification = new AutorunNotification(this._manager, this);
+
+ // add ourselves as a source, and popup the notification
+ Main.messageTray.add(this);
+ this.showNotification(this._notification);
+ }
+
+ getIcon() {
+ return this.mount.get_icon();
+ }
+
+ _createPolicy() {
+ return new MessageTray.NotificationApplicationPolicy('org.gnome.Nautilus');
+ }
+});
+
+var AutorunNotification = GObject.registerClass(
+class AutorunNotification extends MessageTray.Notification {
+ _init(manager, source) {
+ super._init(source, source.title);
+
+ this._manager = manager;
+ this._mount = source.mount;
+ }
+
+ createBanner() {
+ let banner = new MessageTray.NotificationBanner(this);
+
+ this.source.apps.forEach(app => {
+ let actor = this._buttonForApp(app);
+
+ if (actor)
+ banner.addButton(actor);
+ });
+
+ return banner;
+ }
+
+ _buttonForApp(app) {
+ let box = new St.BoxLayout({
+ x_expand: true,
+ x_align: Clutter.ActorAlign.START,
+ });
+ let icon = new St.Icon({ gicon: app.get_icon(),
+ style_class: 'hotplug-notification-item-icon' });
+ box.add(icon);
+
+ let label = new St.Bin({
+ child: new St.Label({
+ text: _("Open with %s").format(app.get_name()),
+ y_align: Clutter.ActorAlign.CENTER,
+ }),
+ });
+ box.add(label);
+
+ let button = new St.Button({ child: box,
+ x_expand: true,
+ button_mask: St.ButtonMask.ONE,
+ style_class: 'hotplug-notification-item button' });
+
+ button.connect('clicked', () => {
+ startAppForMount(app, this._mount);
+ this.destroy();
+ });
+
+ return button;
+ }
+
+ activate() {
+ super.activate();
+
+ let app = Gio.app_info_get_default_for_type('inode/directory', false);
+ startAppForMount(app, this._mount);
+ }
+});
+
+var Component = AutorunManager;
diff --git a/js/ui/components/keyring.js b/js/ui/components/keyring.js
new file mode 100644
index 0000000..8623f2f
--- /dev/null
+++ b/js/ui/components/keyring.js
@@ -0,0 +1,229 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Component */
+
+const { Clutter, Gcr, Gio, GObject, Pango, Shell, St } = imports.gi;
+
+const Dialog = imports.ui.dialog;
+const ModalDialog = imports.ui.modalDialog;
+const ShellEntry = imports.ui.shellEntry;
+const CheckBox = imports.ui.checkBox;
+const Util = imports.misc.util;
+
+var KeyringDialog = GObject.registerClass(
+class KeyringDialog extends ModalDialog.ModalDialog {
+ _init() {
+ super._init({ styleClass: 'prompt-dialog' });
+
+ this.prompt = new Shell.KeyringPrompt();
+ this.prompt.connect('show-password', this._onShowPassword.bind(this));
+ this.prompt.connect('show-confirm', this._onShowConfirm.bind(this));
+ this.prompt.connect('prompt-close', this._onHidePrompt.bind(this));
+
+ let content = new Dialog.MessageDialogContent();
+
+ this.prompt.bind_property('message',
+ content, 'title', GObject.BindingFlags.SYNC_CREATE);
+ this.prompt.bind_property('description',
+ content, 'description', GObject.BindingFlags.SYNC_CREATE);
+
+ let passwordBox = new St.BoxLayout({
+ style_class: 'prompt-dialog-password-layout',
+ vertical: true,
+ });
+
+ this._passwordEntry = new St.PasswordEntry({
+ style_class: 'prompt-dialog-password-entry',
+ can_focus: true,
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+ ShellEntry.addContextMenu(this._passwordEntry);
+ this._passwordEntry.clutter_text.connect('activate', this._onPasswordActivate.bind(this));
+ this.prompt.bind_property('password-visible',
+ this._passwordEntry, 'visible', GObject.BindingFlags.SYNC_CREATE);
+ passwordBox.add_child(this._passwordEntry);
+
+ this._confirmEntry = new St.PasswordEntry({
+ style_class: 'prompt-dialog-password-entry',
+ can_focus: true,
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+ ShellEntry.addContextMenu(this._confirmEntry);
+ this._confirmEntry.clutter_text.connect('activate', this._onConfirmActivate.bind(this));
+ this.prompt.bind_property('confirm-visible',
+ this._confirmEntry, 'visible', GObject.BindingFlags.SYNC_CREATE);
+ passwordBox.add_child(this._confirmEntry);
+
+ this.prompt.set_password_actor(this._passwordEntry.clutter_text);
+ this.prompt.set_confirm_actor(this._confirmEntry.clutter_text);
+
+ let warningBox = new St.BoxLayout({ vertical: true });
+
+ let capsLockWarning = new ShellEntry.CapsLockWarning();
+ let syncCapsLockWarningVisibility = () => {
+ capsLockWarning.visible =
+ this.prompt.password_visible || this.prompt.confirm_visible;
+ };
+ this.prompt.connect('notify::password-visible', syncCapsLockWarningVisibility);
+ this.prompt.connect('notify::confirm-visible', syncCapsLockWarningVisibility);
+ warningBox.add_child(capsLockWarning);
+
+ let warning = new St.Label({ style_class: 'prompt-dialog-error-label' });
+ warning.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+ warning.clutter_text.line_wrap = true;
+ this.prompt.bind_property('warning',
+ warning, 'text', GObject.BindingFlags.SYNC_CREATE);
+ this.prompt.connect('notify::warning-visible', () => {
+ warning.opacity = this.prompt.warning_visible ? 255 : 0;
+ });
+ this.prompt.connect('notify::warning', () => {
+ if (this._passwordEntry && warning !== '')
+ Util.wiggle(this._passwordEntry);
+ });
+ warningBox.add_child(warning);
+
+ passwordBox.add_child(warningBox);
+ content.add_child(passwordBox);
+
+ this._choice = new CheckBox.CheckBox();
+ this.prompt.bind_property('choice-label', this._choice.getLabelActor(),
+ 'text', GObject.BindingFlags.SYNC_CREATE);
+ this.prompt.bind_property('choice-chosen', this._choice,
+ 'checked', GObject.BindingFlags.SYNC_CREATE | GObject.BindingFlags.BIDIRECTIONAL);
+ this.prompt.bind_property('choice-visible', this._choice,
+ 'visible', GObject.BindingFlags.SYNC_CREATE);
+ content.add_child(this._choice);
+
+ this.contentLayout.add_child(content);
+
+ this._cancelButton = this.addButton({
+ label: '',
+ action: this._onCancelButton.bind(this),
+ key: Clutter.KEY_Escape,
+ });
+ this._continueButton = this.addButton({
+ label: '',
+ action: this._onContinueButton.bind(this),
+ default: true,
+ });
+
+ this.prompt.bind_property('cancel-label', this._cancelButton, 'label', GObject.BindingFlags.SYNC_CREATE);
+ this.prompt.bind_property('continue-label', this._continueButton, 'label', GObject.BindingFlags.SYNC_CREATE);
+ }
+
+ _updateSensitivity(sensitive) {
+ if (this._passwordEntry)
+ this._passwordEntry.reactive = sensitive;
+
+ if (this._confirmEntry)
+ this._confirmEntry.reactive = sensitive;
+
+ this._continueButton.can_focus = sensitive;
+ this._continueButton.reactive = sensitive;
+ }
+
+ _ensureOpen() {
+ // NOTE: ModalDialog.open() is safe to call if the dialog is
+ // already open - it just returns true without side-effects
+ if (this.open())
+ return true;
+
+ // The above fail if e.g. unable to get input grab
+ //
+ // In an ideal world this wouldn't happen (because the
+ // Shell is in complete control of the session) but that's
+ // just not how things work right now.
+
+ log('keyringPrompt: Failed to show modal dialog.' +
+ ' Dismissing prompt request');
+ this.prompt.cancel();
+ return false;
+ }
+
+ _onShowPassword() {
+ this._ensureOpen();
+ this._updateSensitivity(true);
+ this._passwordEntry.text = '';
+ this._passwordEntry.grab_key_focus();
+ }
+
+ _onShowConfirm() {
+ this._ensureOpen();
+ this._updateSensitivity(true);
+ this._confirmEntry.text = '';
+ this._continueButton.grab_key_focus();
+ }
+
+ _onHidePrompt() {
+ this.close();
+ }
+
+ _onPasswordActivate() {
+ if (this.prompt.confirm_visible)
+ this._confirmEntry.grab_key_focus();
+ else
+ this._onContinueButton();
+ }
+
+ _onConfirmActivate() {
+ this._onContinueButton();
+ }
+
+ _onContinueButton() {
+ this._updateSensitivity(false);
+ this.prompt.complete();
+ }
+
+ _onCancelButton() {
+ this.prompt.cancel();
+ }
+});
+
+var KeyringDummyDialog = class {
+ constructor() {
+ this.prompt = new Shell.KeyringPrompt();
+ this.prompt.connect('show-password', this._cancelPrompt.bind(this));
+ this.prompt.connect('show-confirm', this._cancelPrompt.bind(this));
+ }
+
+ _cancelPrompt() {
+ this.prompt.cancel();
+ }
+};
+
+var KeyringPrompter = GObject.registerClass(
+class KeyringPrompter extends Gcr.SystemPrompter {
+ _init() {
+ super._init();
+ this.connect('new-prompt', () => {
+ let dialog = this._enabled
+ ? new KeyringDialog()
+ : new KeyringDummyDialog();
+ this._currentPrompt = dialog.prompt;
+ return this._currentPrompt;
+ });
+ this._dbusId = null;
+ this._registered = false;
+ this._enabled = false;
+ this._currentPrompt = null;
+ }
+
+ enable() {
+ if (!this._registered) {
+ this.register(Gio.DBus.session);
+ this._dbusId = Gio.DBus.session.own_name('org.gnome.keyring.SystemPrompter',
+ Gio.BusNameOwnerFlags.ALLOW_REPLACEMENT, null, null);
+ this._registered = true;
+ }
+ this._enabled = true;
+ }
+
+ disable() {
+ this._enabled = false;
+
+ if (this.prompting)
+ this._currentPrompt.cancel();
+ this._currentPrompt = null;
+ }
+});
+
+var Component = KeyringPrompter;
diff --git a/js/ui/components/networkAgent.js b/js/ui/components/networkAgent.js
new file mode 100644
index 0000000..38b55da
--- /dev/null
+++ b/js/ui/components/networkAgent.js
@@ -0,0 +1,809 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Component */
+
+const { Clutter, Gio, GLib, GObject, NM, Pango, Shell, St } = imports.gi;
+const ByteArray = imports.byteArray;
+const Signals = imports.signals;
+
+const Dialog = imports.ui.dialog;
+const Main = imports.ui.main;
+const MessageTray = imports.ui.messageTray;
+const ModalDialog = imports.ui.modalDialog;
+const ShellEntry = imports.ui.shellEntry;
+
+Gio._promisify(Shell.NetworkAgent.prototype, 'init_async', 'init_finish');
+Gio._promisify(Shell.NetworkAgent.prototype,
+ 'search_vpn_plugin', 'search_vpn_plugin_finish');
+
+const VPN_UI_GROUP = 'VPN Plugin UI';
+
+var NetworkSecretDialog = GObject.registerClass(
+class NetworkSecretDialog extends ModalDialog.ModalDialog {
+ _init(agent, requestId, connection, settingName, hints, flags, contentOverride) {
+ super._init({ styleClass: 'prompt-dialog' });
+
+ this._agent = agent;
+ this._requestId = requestId;
+ this._connection = connection;
+ this._settingName = settingName;
+ this._hints = hints;
+
+ if (contentOverride)
+ this._content = contentOverride;
+ else
+ this._content = this._getContent();
+
+ let contentBox = new Dialog.MessageDialogContent({
+ title: this._content.title,
+ description: this._content.message,
+ });
+
+ let initialFocusSet = false;
+ for (let i = 0; i < this._content.secrets.length; i++) {
+ let secret = this._content.secrets[i];
+ let reactive = secret.key != null;
+
+ let entryParams = {
+ style_class: 'prompt-dialog-password-entry',
+ hint_text: secret.label,
+ text: secret.value,
+ can_focus: reactive,
+ reactive,
+ x_align: Clutter.ActorAlign.CENTER,
+ };
+ if (secret.password)
+ secret.entry = new St.PasswordEntry(entryParams);
+ else
+ secret.entry = new St.Entry(entryParams);
+ ShellEntry.addContextMenu(secret.entry);
+ contentBox.add_child(secret.entry);
+
+ if (secret.validate)
+ secret.valid = secret.validate(secret);
+ else // no special validation, just ensure it's not empty
+ secret.valid = secret.value.length > 0;
+
+ if (reactive) {
+ if (!initialFocusSet) {
+ this.setInitialKeyFocus(secret.entry);
+ initialFocusSet = true;
+ }
+
+ secret.entry.clutter_text.connect('activate', this._onOk.bind(this));
+ secret.entry.clutter_text.connect('text-changed', () => {
+ secret.value = secret.entry.get_text();
+ if (secret.validate)
+ secret.valid = secret.validate(secret);
+ else
+ secret.valid = secret.value.length > 0;
+ this._updateOkButton();
+ });
+ } else {
+ secret.valid = true;
+ }
+ }
+
+ if (this._content.secrets.some(s => s.password)) {
+ let capsLockWarning = new ShellEntry.CapsLockWarning();
+ contentBox.add_child(capsLockWarning);
+ }
+
+ if (flags & NM.SecretAgentGetSecretsFlags.WPS_PBC_ACTIVE) {
+ let descriptionLabel = new St.Label({
+ text: _('Alternatively you can connect by pushing the “WPS” button on your router.'),
+ style_class: 'message-dialog-description',
+ });
+ descriptionLabel.clutter_text.line_wrap = true;
+ descriptionLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+
+ contentBox.add_child(descriptionLabel);
+ }
+
+ this.contentLayout.add_child(contentBox);
+
+ this._okButton = {
+ label: _("Connect"),
+ action: this._onOk.bind(this),
+ default: true,
+ };
+
+ this.setButtons([{
+ label: _("Cancel"),
+ action: this.cancel.bind(this),
+ key: Clutter.KEY_Escape,
+ }, this._okButton]);
+
+ this._updateOkButton();
+ }
+
+ _updateOkButton() {
+ let valid = true;
+ for (let i = 0; i < this._content.secrets.length; i++) {
+ let secret = this._content.secrets[i];
+ valid = valid && secret.valid;
+ }
+
+ this._okButton.button.reactive = valid;
+ this._okButton.button.can_focus = valid;
+ }
+
+ _onOk() {
+ let valid = true;
+ for (let i = 0; i < this._content.secrets.length; i++) {
+ let secret = this._content.secrets[i];
+ valid = valid && secret.valid;
+ if (secret.key !== null) {
+ if (this._settingName === 'vpn')
+ this._agent.add_vpn_secret(this._requestId, secret.key, secret.value);
+ else
+ this._agent.set_password(this._requestId, secret.key, secret.value);
+ }
+ }
+
+ if (valid) {
+ this._agent.respond(this._requestId, Shell.NetworkAgentResponse.CONFIRMED);
+ this.close(global.get_current_time());
+ }
+ // do nothing if not valid
+ }
+
+ cancel() {
+ this._agent.respond(this._requestId, Shell.NetworkAgentResponse.USER_CANCELED);
+ this.close(global.get_current_time());
+ }
+
+ _validateWpaPsk(secret) {
+ let value = secret.value;
+ if (value.length == 64) {
+ // must be composed of hexadecimal digits only
+ for (let i = 0; i < 64; i++) {
+ if (!((value[i] >= 'a' && value[i] <= 'f') ||
+ (value[i] >= 'A' && value[i] <= 'F') ||
+ (value[i] >= '0' && value[i] <= '9')))
+ return false;
+ }
+ return true;
+ }
+
+ return value.length >= 8 && value.length <= 63;
+ }
+
+ _validateStaticWep(secret) {
+ let value = secret.value;
+ if (secret.wep_key_type == NM.WepKeyType.KEY) {
+ if (value.length == 10 || value.length == 26) {
+ for (let i = 0; i < value.length; i++) {
+ if (!((value[i] >= 'a' && value[i] <= 'f') ||
+ (value[i] >= 'A' && value[i] <= 'F') ||
+ (value[i] >= '0' && value[i] <= '9')))
+ return false;
+ }
+ } else if (value.length == 5 || value.length == 13) {
+ for (let i = 0; i < value.length; i++) {
+ if (!((value[i] >= 'a' && value[i] <= 'z') ||
+ (value[i] >= 'A' && value[i] <= 'Z')))
+ return false;
+ }
+ } else {
+ return false;
+ }
+ } else if (secret.wep_key_type == NM.WepKeyType.PASSPHRASE) {
+ if (value.length < 0 || value.length > 64)
+ return false;
+ }
+ return true;
+ }
+
+ _getWirelessSecrets(secrets, _wirelessSetting) {
+ let wirelessSecuritySetting = this._connection.get_setting_wireless_security();
+
+ if (this._settingName == '802-1x') {
+ this._get8021xSecrets(secrets);
+ return;
+ }
+
+ switch (wirelessSecuritySetting.key_mgmt) {
+ // First the easy ones
+ case 'wpa-none':
+ case 'wpa-psk':
+ case 'sae':
+ secrets.push({ label: _('Password'), key: 'psk',
+ value: wirelessSecuritySetting.psk || '',
+ validate: this._validateWpaPsk, password: true });
+ break;
+ case 'none': // static WEP
+ secrets.push({
+ label: _('Key'),
+ key: 'wep-key%s'.format(wirelessSecuritySetting.wep_tx_keyidx),
+ value: wirelessSecuritySetting.get_wep_key(wirelessSecuritySetting.wep_tx_keyidx) || '',
+ wep_key_type: wirelessSecuritySetting.wep_key_type,
+ validate: this._validateStaticWep,
+ password: true,
+ });
+ break;
+ case 'ieee8021x':
+ if (wirelessSecuritySetting.auth_alg == 'leap') { // Cisco LEAP
+ secrets.push({ label: _('Password'), key: 'leap-password',
+ value: wirelessSecuritySetting.leap_password || '', password: true });
+ } else { // Dynamic (IEEE 802.1x) WEP
+ this._get8021xSecrets(secrets);
+ }
+ break;
+ case 'wpa-eap':
+ this._get8021xSecrets(secrets);
+ break;
+ default:
+ log('Invalid wireless key management: %s'.format(wirelessSecuritySetting.key_mgmt));
+ }
+ }
+
+ _get8021xSecrets(secrets) {
+ let ieee8021xSetting = this._connection.get_setting_802_1x();
+
+ /* If hints were given we know exactly what we need to ask */
+ if (this._settingName == "802-1x" && this._hints.length) {
+ if (this._hints.includes('identity')) {
+ secrets.push({ label: _('Username'), key: 'identity',
+ value: ieee8021xSetting.identity || '', password: false });
+ }
+ if (this._hints.includes('password')) {
+ secrets.push({ label: _('Password'), key: 'password',
+ value: ieee8021xSetting.password || '', password: true });
+ }
+ if (this._hints.includes('private-key-password')) {
+ secrets.push({ label: _('Private key password'), key: 'private-key-password',
+ value: ieee8021xSetting.private_key_password || '', password: true });
+ }
+ return;
+ }
+
+ switch (ieee8021xSetting.get_eap_method(0)) {
+ case 'md5':
+ case 'leap':
+ case 'ttls':
+ case 'peap':
+ case 'fast':
+ // TTLS and PEAP are actually much more complicated, but this complication
+ // is not visible here since we only care about phase2 authentication
+ // (and don't even care of which one)
+ secrets.push({ label: _('Username'), key: null,
+ value: ieee8021xSetting.identity || '', password: false });
+ secrets.push({ label: _('Password'), key: 'password',
+ value: ieee8021xSetting.password || '', password: true });
+ break;
+ case 'tls':
+ secrets.push({ label: _('Identity'), key: null,
+ value: ieee8021xSetting.identity || '', password: false });
+ secrets.push({ label: _('Private key password'), key: 'private-key-password',
+ value: ieee8021xSetting.private_key_password || '', password: true });
+ break;
+ default:
+ log('Invalid EAP/IEEE802.1x method: %s'.format(ieee8021xSetting.get_eap_method(0)));
+ }
+ }
+
+ _getPPPoESecrets(secrets) {
+ let pppoeSetting = this._connection.get_setting_pppoe();
+ secrets.push({ label: _('Username'), key: 'username',
+ value: pppoeSetting.username || '', password: false });
+ secrets.push({ label: _('Service'), key: 'service',
+ value: pppoeSetting.service || '', password: false });
+ secrets.push({ label: _('Password'), key: 'password',
+ value: pppoeSetting.password || '', password: true });
+ }
+
+ _getMobileSecrets(secrets, connectionType) {
+ let setting;
+ if (connectionType == 'bluetooth')
+ setting = this._connection.get_setting_cdma() || this._connection.get_setting_gsm();
+ else
+ setting = this._connection.get_setting_by_name(connectionType);
+ secrets.push({ label: _('Password'), key: 'password',
+ value: setting.value || '', password: true });
+ }
+
+ _getContent() {
+ let connectionSetting = this._connection.get_setting_connection();
+ let connectionType = connectionSetting.get_connection_type();
+ let wirelessSetting;
+ let ssid;
+
+ let content = { };
+ content.secrets = [];
+
+ switch (connectionType) {
+ case '802-11-wireless':
+ wirelessSetting = this._connection.get_setting_wireless();
+ ssid = NM.utils_ssid_to_utf8(wirelessSetting.get_ssid().get_data());
+ content.title = _('Authentication required');
+ content.message = _("Passwords or encryption keys are required to access the wireless network “%s”.").format(ssid);
+ this._getWirelessSecrets(content.secrets, wirelessSetting);
+ break;
+ case '802-3-ethernet':
+ content.title = _("Wired 802.1X authentication");
+ content.message = null;
+ content.secrets.push({ label: _('Network name'), key: null,
+ value: connectionSetting.get_id(), password: false });
+ this._get8021xSecrets(content.secrets);
+ break;
+ case 'pppoe':
+ content.title = _("DSL authentication");
+ content.message = null;
+ this._getPPPoESecrets(content.secrets);
+ break;
+ case 'gsm':
+ if (this._hints.includes('pin')) {
+ let gsmSetting = this._connection.get_setting_gsm();
+ content.title = _("PIN code required");
+ content.message = _("PIN code is needed for the mobile broadband device");
+ content.secrets.push({ label: _('PIN'), key: 'pin',
+ value: gsmSetting.pin || '', password: true });
+ break;
+ }
+ // fall through
+ case 'cdma':
+ case 'bluetooth':
+ content.title = _('Authentication required');
+ content.message = _("A password is required to connect to “%s”.").format(connectionSetting.get_id());
+ this._getMobileSecrets(content.secrets, connectionType);
+ break;
+ default:
+ log('Invalid connection type: %s'.format(connectionType));
+ }
+
+ return content;
+ }
+});
+
+var VPNRequestHandler = class {
+ constructor(agent, requestId, authHelper, serviceType, connection, hints, flags) {
+ this._agent = agent;
+ this._requestId = requestId;
+ this._connection = connection;
+ this._flags = flags;
+ this._pluginOutBuffer = [];
+ this._title = null;
+ this._description = null;
+ this._content = [];
+ this._shellDialog = null;
+
+ let connectionSetting = connection.get_setting_connection();
+
+ let argv = [authHelper.fileName,
+ '-u', connectionSetting.uuid,
+ '-n', connectionSetting.id,
+ '-s', serviceType];
+ if (authHelper.externalUIMode)
+ argv.push('--external-ui-mode');
+ if (flags & NM.SecretAgentGetSecretsFlags.ALLOW_INTERACTION)
+ argv.push('-i');
+ if (flags & NM.SecretAgentGetSecretsFlags.REQUEST_NEW)
+ argv.push('-r');
+ if (authHelper.supportsHints) {
+ for (let i = 0; i < hints.length; i++) {
+ argv.push('-t');
+ argv.push(hints[i]);
+ }
+ }
+
+ this._newStylePlugin = authHelper.externalUIMode;
+
+ try {
+ let [success_, pid, stdin, stdout, stderr] =
+ GLib.spawn_async_with_pipes(null, /* pwd */
+ argv,
+ null, /* envp */
+ GLib.SpawnFlags.DO_NOT_REAP_CHILD,
+ null /* child_setup */);
+
+ this._childPid = pid;
+ this._stdin = new Gio.UnixOutputStream({ fd: stdin, close_fd: true });
+ this._stdout = new Gio.UnixInputStream({ fd: stdout, close_fd: true });
+ GLib.close(stderr);
+ this._dataStdout = new Gio.DataInputStream({ base_stream: this._stdout });
+
+ if (this._newStylePlugin)
+ this._readStdoutNewStyle();
+ else
+ this._readStdoutOldStyle();
+
+ this._childWatch = GLib.child_watch_add(GLib.PRIORITY_DEFAULT, pid,
+ this._vpnChildFinished.bind(this));
+
+ this._writeConnection();
+ } catch (e) {
+ logError(e, 'error while spawning VPN auth helper');
+
+ this._agent.respond(requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR);
+ }
+ }
+
+ cancel(respond) {
+ if (respond)
+ this._agent.respond(this._requestId, Shell.NetworkAgentResponse.USER_CANCELED);
+
+ if (this._newStylePlugin && this._shellDialog) {
+ this._shellDialog.close(global.get_current_time());
+ this._shellDialog.destroy();
+ } else {
+ try {
+ this._stdin.write('QUIT\n\n', null);
+ } catch (e) { /* ignore broken pipe errors */ }
+ }
+
+ this.destroy();
+ }
+
+ destroy() {
+ if (this._destroyed)
+ return;
+
+ this.emit('destroy');
+ if (this._childWatch)
+ GLib.source_remove(this._childWatch);
+
+ this._stdin.close(null);
+ // Stdout is closed when we finish reading from it
+
+ this._destroyed = true;
+ }
+
+ _vpnChildFinished(pid, status, _requestObj) {
+ this._childWatch = 0;
+ if (this._newStylePlugin) {
+ // For new style plugin, all work is done in the async reading functions
+ // Just reap the process here
+ return;
+ }
+
+ let [exited, exitStatus] = Shell.util_wifexited(status);
+
+ if (exited) {
+ if (exitStatus != 0)
+ this._agent.respond(this._requestId, Shell.NetworkAgentResponse.USER_CANCELED);
+ else
+ this._agent.respond(this._requestId, Shell.NetworkAgentResponse.CONFIRMED);
+ } else {
+ this._agent.respond(this._requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR);
+ }
+
+ this.destroy();
+ }
+
+ _vpnChildProcessLineOldStyle(line) {
+ if (this._previousLine != undefined) {
+ // Two consecutive newlines mean that the child should be closed
+ // (the actual newlines are eaten by Gio.DataInputStream)
+ // Send a termination message
+ if (line == '' && this._previousLine == '') {
+ try {
+ this._stdin.write('QUIT\n\n', null);
+ } catch (e) { /* ignore broken pipe errors */ }
+ } else {
+ this._agent.add_vpn_secret(this._requestId, this._previousLine, line);
+ this._previousLine = undefined;
+ }
+ } else {
+ this._previousLine = line;
+ }
+ }
+
+ async _readStdoutOldStyle() {
+ const [line, len_] =
+ await this._dataStdout.read_line_async(GLib.PRIORITY_DEFAULT, null);
+
+ if (line === null) {
+ // end of file
+ this._stdout.close(null);
+ return;
+ }
+
+ this._vpnChildProcessLineOldStyle(ByteArray.toString(line));
+
+ // try to read more!
+ this._readStdoutOldStyle();
+ }
+
+ async _readStdoutNewStyle() {
+ const cnt =
+ await this._dataStdout.fill_async(-1, GLib.PRIORITY_DEFAULT, null);
+
+ if (cnt === 0) {
+ // end of file
+ this._showNewStyleDialog();
+
+ this._stdout.close(null);
+ return;
+ }
+
+ // Try to read more
+ this._dataStdout.set_buffer_size(2 * this._dataStdout.get_buffer_size());
+ this._readStdoutNewStyle();
+ }
+
+ _showNewStyleDialog() {
+ let keyfile = new GLib.KeyFile();
+ let data;
+ let contentOverride;
+
+ try {
+ data = ByteArray.toGBytes(this._dataStdout.peek_buffer());
+ keyfile.load_from_bytes(data, GLib.KeyFileFlags.NONE);
+
+ if (keyfile.get_integer(VPN_UI_GROUP, 'Version') != 2)
+ throw new Error('Invalid plugin keyfile version, is %d');
+
+ contentOverride = { title: keyfile.get_string(VPN_UI_GROUP, 'Title'),
+ message: keyfile.get_string(VPN_UI_GROUP, 'Description'),
+ secrets: [] };
+
+ let [groups, len_] = keyfile.get_groups();
+ for (let i = 0; i < groups.length; i++) {
+ if (groups[i] == VPN_UI_GROUP)
+ continue;
+
+ let value = keyfile.get_string(groups[i], 'Value');
+ let shouldAsk = keyfile.get_boolean(groups[i], 'ShouldAsk');
+
+ if (shouldAsk) {
+ contentOverride.secrets.push({
+ label: keyfile.get_string(groups[i], 'Label'),
+ key: groups[i],
+ value,
+ password: keyfile.get_boolean(groups[i], 'IsSecret'),
+ });
+ } else {
+ if (!value.length) // Ignore empty secrets
+ continue;
+
+ this._agent.add_vpn_secret(this._requestId, groups[i], value);
+ }
+ }
+ } catch (e) {
+ // No output is a valid case it means "both secrets are stored"
+ if (data.length > 0) {
+ logError(e, 'error while reading VPN plugin output keyfile');
+
+ this._agent.respond(this._requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR);
+ this.destroy();
+ return;
+ }
+ }
+
+ if (contentOverride && contentOverride.secrets.length) {
+ // Only show the dialog if we actually have something to ask
+ this._shellDialog = new NetworkSecretDialog(this._agent, this._requestId, this._connection, 'vpn', [], this._flags, contentOverride);
+ this._shellDialog.open(global.get_current_time());
+ } else {
+ this._agent.respond(this._requestId, Shell.NetworkAgentResponse.CONFIRMED);
+ this.destroy();
+ }
+ }
+
+ _writeConnection() {
+ let vpnSetting = this._connection.get_setting_vpn();
+
+ try {
+ vpnSetting.foreach_data_item((key, value) => {
+ this._stdin.write('DATA_KEY=%s\n'.format(key), null);
+ this._stdin.write('DATA_VAL=%s\n\n'.format(value || ''), null);
+ });
+ vpnSetting.foreach_secret((key, value) => {
+ this._stdin.write('SECRET_KEY=%s\n'.format(key), null);
+ this._stdin.write('SECRET_VAL=%s\n\n'.format(value || ''), null);
+ });
+ this._stdin.write('DONE\n\n', null);
+ } catch (e) {
+ logError(e, 'internal error while writing connection to helper');
+
+ this._agent.respond(this._requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR);
+ this.destroy();
+ }
+ }
+};
+Signals.addSignalMethods(VPNRequestHandler.prototype);
+
+var NetworkAgent = class {
+ constructor() {
+ this._native = new Shell.NetworkAgent({
+ identifier: 'org.gnome.Shell.NetworkAgent',
+ capabilities: NM.SecretAgentCapabilities.VPN_HINTS,
+ auto_register: false,
+ });
+
+ this._dialogs = { };
+ this._vpnRequests = { };
+ this._notifications = { };
+
+ this._native.connect('new-request', this._newRequest.bind(this));
+ this._native.connect('cancel-request', this._cancelRequest.bind(this));
+
+ this._initialized = false;
+ this._initNative();
+ }
+
+ async _initNative() {
+ try {
+ await this._native.init_async(GLib.PRIORITY_DEFAULT, null);
+ this._initialized = true;
+ } catch (e) {
+ this._native = null;
+ logError(e, 'error initializing the NetworkManager Agent');
+ }
+ }
+
+ enable() {
+ if (!this._native)
+ return;
+
+ this._native.auto_register = true;
+ if (this._initialized && !this._native.registered)
+ this._native.register_async(null, null);
+ }
+
+ disable() {
+ let requestId;
+
+ for (requestId in this._dialogs)
+ this._dialogs[requestId].cancel();
+ this._dialogs = { };
+
+ for (requestId in this._vpnRequests)
+ this._vpnRequests[requestId].cancel(true);
+ this._vpnRequests = { };
+
+ for (requestId in this._notifications)
+ this._notifications[requestId].destroy();
+ this._notifications = { };
+
+ if (!this._native)
+ return;
+
+ this._native.auto_register = false;
+ if (this._initialized && this._native.registered)
+ this._native.unregister_async(null, null);
+ }
+
+ _showNotification(requestId, connection, settingName, hints, flags) {
+ let source = new MessageTray.Source(_("Network Manager"), 'network-transmit-receive');
+ source.policy = new MessageTray.NotificationApplicationPolicy('gnome-network-panel');
+
+ let title, body;
+
+ let connectionSetting = connection.get_setting_connection();
+ let connectionType = connectionSetting.get_connection_type();
+ switch (connectionType) {
+ case '802-11-wireless': {
+ let wirelessSetting = connection.get_setting_wireless();
+ let ssid = NM.utils_ssid_to_utf8(wirelessSetting.get_ssid().get_data());
+ title = _('Authentication required');
+ body = _("Passwords or encryption keys are required to access the wireless network “%s”.").format(ssid);
+ break;
+ }
+ case '802-3-ethernet':
+ title = _("Wired 802.1X authentication");
+ body = _("A password is required to connect to “%s”.".format(connection.get_id()));
+ break;
+ case 'pppoe':
+ title = _("DSL authentication");
+ body = _("A password is required to connect to “%s”.".format(connection.get_id()));
+ break;
+ case 'gsm':
+ if (hints.includes('pin')) {
+ title = _("PIN code required");
+ body = _("PIN code is needed for the mobile broadband device");
+ break;
+ }
+ // fall through
+ case 'cdma':
+ case 'bluetooth':
+ title = _('Authentication required');
+ body = _("A password is required to connect to “%s”.").format(connectionSetting.get_id());
+ break;
+ case 'vpn':
+ title = _("VPN password");
+ body = _("A password is required to connect to “%s”.").format(connectionSetting.get_id());
+ break;
+ default:
+ log('Invalid connection type: %s'.format(connectionType));
+ this._native.respond(requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR);
+ return;
+ }
+
+ let notification = new MessageTray.Notification(source, title, body);
+
+ notification.connect('activated', () => {
+ notification.answered = true;
+ this._handleRequest(requestId, connection, settingName, hints, flags);
+ });
+
+ this._notifications[requestId] = notification;
+ notification.connect('destroy', () => {
+ if (!notification.answered)
+ this._native.respond(requestId, Shell.NetworkAgentResponse.USER_CANCELED);
+ delete this._notifications[requestId];
+ });
+
+ Main.messageTray.add(source);
+ source.showNotification(notification);
+ }
+
+ _newRequest(agent, requestId, connection, settingName, hints, flags) {
+ if (!(flags & NM.SecretAgentGetSecretsFlags.USER_REQUESTED))
+ this._showNotification(requestId, connection, settingName, hints, flags);
+ else
+ this._handleRequest(requestId, connection, settingName, hints, flags);
+ }
+
+ _handleRequest(requestId, connection, settingName, hints, flags) {
+ if (settingName == 'vpn') {
+ this._vpnRequest(requestId, connection, hints, flags);
+ return;
+ }
+
+ let dialog = new NetworkSecretDialog(this._native, requestId, connection, settingName, hints, flags);
+ dialog.connect('destroy', () => {
+ delete this._dialogs[requestId];
+ });
+ this._dialogs[requestId] = dialog;
+ dialog.open(global.get_current_time());
+ }
+
+ _cancelRequest(agent, requestId) {
+ if (this._dialogs[requestId]) {
+ this._dialogs[requestId].close(global.get_current_time());
+ this._dialogs[requestId].destroy();
+ delete this._dialogs[requestId];
+ } else if (this._vpnRequests[requestId]) {
+ this._vpnRequests[requestId].cancel(false);
+ delete this._vpnRequests[requestId];
+ }
+ }
+
+ async _vpnRequest(requestId, connection, hints, flags) {
+ let vpnSetting = connection.get_setting_vpn();
+ let serviceType = vpnSetting.service_type;
+
+ let binary = await this._findAuthBinary(serviceType);
+ if (!binary) {
+ log('Invalid VPN service type (cannot find authentication binary)');
+
+ /* cancel the auth process */
+ this._native.respond(requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR);
+ return;
+ }
+
+ let vpnRequest = new VPNRequestHandler(this._native, requestId, binary, serviceType, connection, hints, flags);
+ vpnRequest.connect('destroy', () => {
+ delete this._vpnRequests[requestId];
+ });
+ this._vpnRequests[requestId] = vpnRequest;
+ }
+
+ async _findAuthBinary(serviceType) {
+ let plugin;
+
+ try {
+ plugin = await this._native.search_vpn_plugin(serviceType);
+ } catch (e) {
+ logError(e);
+ return null;
+ }
+
+ const fileName = plugin.get_auth_dialog();
+ if (!GLib.file_test(fileName, GLib.FileTest.IS_EXECUTABLE)) {
+ log('VPN plugin at %s is not executable'.format(fileName));
+ return null;
+ }
+
+ const prop = plugin.lookup_property('GNOME', 'supports-external-ui-mode');
+ const trimmedProp = prop ? prop.trim().toLowerCase() : '';
+
+ return {
+ fileName,
+ supportsHints: plugin.supports_hints(),
+ externalUIMode: ['true', 'yes', 'on', '1'].includes(trimmedProp),
+ };
+ }
+};
+var Component = NetworkAgent;
diff --git a/js/ui/components/polkitAgent.js b/js/ui/components/polkitAgent.js
new file mode 100644
index 0000000..27b705e
--- /dev/null
+++ b/js/ui/components/polkitAgent.js
@@ -0,0 +1,477 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Component */
+
+const { AccountsService, Clutter, GLib,
+ GObject, Pango, PolkitAgent, Polkit, Shell, St } = imports.gi;
+
+const Dialog = imports.ui.dialog;
+const Main = imports.ui.main;
+const ModalDialog = imports.ui.modalDialog;
+const ShellEntry = imports.ui.shellEntry;
+const UserWidget = imports.ui.userWidget;
+const Util = imports.misc.util;
+
+const DialogMode = {
+ AUTH: 0,
+ CONFIRM: 1,
+};
+
+const DIALOG_ICON_SIZE = 64;
+
+const DELAYED_RESET_TIMEOUT = 200;
+
+var AuthenticationDialog = GObject.registerClass({
+ Signals: { 'done': { param_types: [GObject.TYPE_BOOLEAN] } },
+}, class AuthenticationDialog extends ModalDialog.ModalDialog {
+ _init(actionId, description, cookie, userNames) {
+ super._init({ styleClass: 'prompt-dialog' });
+
+ this.actionId = actionId;
+ this.message = description;
+ this.userNames = userNames;
+
+ this._sessionUpdatedId = Main.sessionMode.connect('updated', () => {
+ this.visible = !Main.sessionMode.isLocked;
+ });
+
+ this.connect('closed', this._onDialogClosed.bind(this));
+
+ let title = _("Authentication Required");
+
+ let headerContent = new Dialog.MessageDialogContent({ title, description });
+ this.contentLayout.add_child(headerContent);
+
+ let bodyContent = new Dialog.MessageDialogContent();
+
+ if (userNames.length > 1) {
+ log('polkitAuthenticationAgent: Received %d '.format(userNames.length) +
+ 'identities that can be used for authentication. Only ' +
+ 'considering one.');
+ }
+
+ let userName = GLib.get_user_name();
+ if (!userNames.includes(userName))
+ userName = 'root';
+ if (!userNames.includes(userName))
+ userName = userNames[0];
+
+ this._user = AccountsService.UserManager.get_default().get_user(userName);
+
+ let userBox = new St.BoxLayout({
+ style_class: 'polkit-dialog-user-layout',
+ vertical: true,
+ });
+ bodyContent.add_child(userBox);
+
+ this._userAvatar = new UserWidget.Avatar(this._user, {
+ iconSize: DIALOG_ICON_SIZE,
+ });
+ this._userAvatar.x_align = Clutter.ActorAlign.CENTER;
+ userBox.add_child(this._userAvatar);
+
+ this._userLabel = new St.Label({
+ style_class: userName === 'root'
+ ? 'polkit-dialog-user-root-label'
+ : 'polkit-dialog-user-label',
+ });
+
+ if (userName === 'root')
+ this._userLabel.text = _('Administrator');
+
+ userBox.add_child(this._userLabel);
+
+ let passwordBox = new St.BoxLayout({
+ style_class: 'prompt-dialog-password-layout',
+ vertical: true,
+ });
+
+ this._passwordEntry = new St.PasswordEntry({
+ style_class: 'prompt-dialog-password-entry',
+ text: "",
+ can_focus: true,
+ visible: false,
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+ ShellEntry.addContextMenu(this._passwordEntry);
+ this._passwordEntry.clutter_text.connect('activate', this._onEntryActivate.bind(this));
+ this._passwordEntry.bind_property('reactive',
+ this._passwordEntry.clutter_text, 'editable',
+ GObject.BindingFlags.SYNC_CREATE);
+ passwordBox.add_child(this._passwordEntry);
+
+ let warningBox = new St.BoxLayout({ vertical: true });
+
+ let capsLockWarning = new ShellEntry.CapsLockWarning();
+ this._passwordEntry.bind_property('visible',
+ capsLockWarning, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+ warningBox.add_child(capsLockWarning);
+
+ this._errorMessageLabel = new St.Label({
+ style_class: 'prompt-dialog-error-label',
+ visible: false,
+ });
+ this._errorMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+ this._errorMessageLabel.clutter_text.line_wrap = true;
+ warningBox.add_child(this._errorMessageLabel);
+
+ this._infoMessageLabel = new St.Label({
+ style_class: 'prompt-dialog-info-label',
+ visible: false,
+ });
+ this._infoMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+ this._infoMessageLabel.clutter_text.line_wrap = true;
+ warningBox.add_child(this._infoMessageLabel);
+
+ /* text is intentionally non-blank otherwise the height is not the same as for
+ * infoMessage and errorMessageLabel - but it is still invisible because
+ * gnome-shell.css sets the color to be transparent
+ */
+ this._nullMessageLabel = new St.Label({ style_class: 'prompt-dialog-null-label' });
+ this._nullMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+ this._nullMessageLabel.clutter_text.line_wrap = true;
+ warningBox.add_child(this._nullMessageLabel);
+
+ passwordBox.add_child(warningBox);
+ bodyContent.add_child(passwordBox);
+
+ this._cancelButton = this.addButton({ label: _("Cancel"),
+ action: this.cancel.bind(this),
+ key: Clutter.KEY_Escape });
+ this._okButton = this.addButton({ label: _("Authenticate"),
+ action: this._onAuthenticateButtonPressed.bind(this),
+ reactive: false });
+ this._okButton.bind_property('reactive',
+ this._okButton, 'can-focus',
+ GObject.BindingFlags.SYNC_CREATE);
+
+ this._passwordEntry.clutter_text.connect('text-changed', text => {
+ this._okButton.reactive = text.get_text().length > 0;
+ });
+
+ this.contentLayout.add_child(bodyContent);
+
+ this._doneEmitted = false;
+
+ this._mode = -1;
+
+ this._identityToAuth = Polkit.UnixUser.new_for_name(userName);
+ this._cookie = cookie;
+
+ this._userLoadedId = this._user.connect('notify::is-loaded',
+ this._onUserChanged.bind(this));
+ this._userChangedId = this._user.connect('changed',
+ this._onUserChanged.bind(this));
+ this._onUserChanged();
+ }
+
+ _initiateSession() {
+ this._destroySession(DELAYED_RESET_TIMEOUT);
+
+ this._session = new PolkitAgent.Session({ identity: this._identityToAuth,
+ cookie: this._cookie });
+ this._sessionCompletedId = this._session.connect('completed', this._onSessionCompleted.bind(this));
+ this._sessionRequestId = this._session.connect('request', this._onSessionRequest.bind(this));
+ this._sessionShowErrorId = this._session.connect('show-error', this._onSessionShowError.bind(this));
+ this._sessionShowInfoId = this._session.connect('show-info', this._onSessionShowInfo.bind(this));
+ this._session.initiate();
+ }
+
+ _ensureOpen() {
+ // NOTE: ModalDialog.open() is safe to call if the dialog is
+ // already open - it just returns true without side-effects
+ if (!this.open(global.get_current_time())) {
+ // This can fail if e.g. unable to get input grab
+ //
+ // In an ideal world this wouldn't happen (because the
+ // Shell is in complete control of the session) but that's
+ // just not how things work right now.
+ //
+ // One way to make this happen is by running 'sleep 3;
+ // pkexec bash' and then opening a popup menu.
+ //
+ // We could add retrying if this turns out to be a problem
+
+ log('polkitAuthenticationAgent: Failed to show modal dialog. ' +
+ 'Dismissing authentication request for action-id %s '.format(this.actionId) +
+ 'cookie %s'.format(this._cookie));
+ this._emitDone(true);
+ }
+ }
+
+ _emitDone(dismissed) {
+ if (!this._doneEmitted) {
+ this._doneEmitted = true;
+ this.emit('done', dismissed);
+ }
+ }
+
+ _onEntryActivate() {
+ let response = this._passwordEntry.get_text();
+ if (response.length === 0)
+ return;
+
+ this._passwordEntry.reactive = false;
+ this._okButton.reactive = false;
+
+ this._session.response(response);
+ // When the user responds, dismiss already shown info and
+ // error texts (if any)
+ this._errorMessageLabel.hide();
+ this._infoMessageLabel.hide();
+ this._nullMessageLabel.show();
+ }
+
+ _onAuthenticateButtonPressed() {
+ if (this._mode === DialogMode.CONFIRM)
+ this._initiateSession();
+ else
+ this._onEntryActivate();
+ }
+
+ _onSessionCompleted(session, gainedAuthorization) {
+ if (this._completed || this._doneEmitted)
+ return;
+
+ this._completed = true;
+
+ /* Yay, all done */
+ if (gainedAuthorization) {
+ this._emitDone(false);
+
+ } else {
+ /* Unless we are showing an existing error message from the PAM
+ * module (the PAM module could be reporting the authentication
+ * error providing authentication-method specific information),
+ * show "Sorry, that didn't work. Please try again."
+ */
+ if (!this._errorMessageLabel.visible) {
+ /* Translators: "that didn't work" refers to the fact that the
+ * requested authentication was not gained; this can happen
+ * because of an authentication error (like invalid password),
+ * for instance. */
+ this._errorMessageLabel.set_text(_("Sorry, that didn’t work. Please try again."));
+ this._errorMessageLabel.show();
+ this._infoMessageLabel.hide();
+ this._nullMessageLabel.hide();
+
+ Util.wiggle(this._passwordEntry);
+ }
+
+ /* Try and authenticate again */
+ this._initiateSession();
+ }
+ }
+
+ _onSessionRequest(session, request, echoOn) {
+ if (this._sessionRequestTimeoutId) {
+ GLib.source_remove(this._sessionRequestTimeoutId);
+ this._sessionRequestTimeoutId = 0;
+ }
+
+ // Hack: The request string comes directly from PAM, if it's "Password:"
+ // we replace it with our own to allow localization, if it's something
+ // else we remove the last colon and any trailing or leading spaces.
+ if (request === 'Password:' || request === 'Password: ')
+ this._passwordEntry.hint_text = _('Password');
+ else
+ this._passwordEntry.hint_text = request.replace(/: *$/, '').trim();
+
+ this._passwordEntry.password_visible = echoOn;
+
+ this._passwordEntry.show();
+ this._passwordEntry.set_text('');
+ this._passwordEntry.reactive = true;
+ this._okButton.reactive = false;
+
+ this._ensureOpen();
+ this._passwordEntry.grab_key_focus();
+ }
+
+ _onSessionShowError(session, text) {
+ this._passwordEntry.set_text('');
+ this._errorMessageLabel.set_text(text);
+ this._errorMessageLabel.show();
+ this._infoMessageLabel.hide();
+ this._nullMessageLabel.hide();
+ this._ensureOpen();
+ }
+
+ _onSessionShowInfo(session, text) {
+ this._passwordEntry.set_text('');
+ this._infoMessageLabel.set_text(text);
+ this._infoMessageLabel.show();
+ this._errorMessageLabel.hide();
+ this._nullMessageLabel.hide();
+ this._ensureOpen();
+ }
+
+ _destroySession(delay = 0) {
+ if (this._session) {
+ this._session.disconnect(this._sessionCompletedId);
+ this._session.disconnect(this._sessionRequestId);
+ this._session.disconnect(this._sessionShowErrorId);
+ this._session.disconnect(this._sessionShowInfoId);
+
+ if (!this._completed)
+ this._session.cancel();
+
+ this._completed = false;
+ this._session = null;
+ }
+
+ if (this._sessionRequestTimeoutId) {
+ GLib.source_remove(this._sessionRequestTimeoutId);
+ this._sessionRequestTimeoutId = 0;
+ }
+
+ let resetDialog = () => {
+ this._sessionRequestTimeoutId = 0;
+
+ if (this.state != ModalDialog.State.OPENED)
+ return GLib.SOURCE_REMOVE;
+
+ this._passwordEntry.hide();
+ this._cancelButton.grab_key_focus();
+ this._okButton.reactive = false;
+
+ return GLib.SOURCE_REMOVE;
+ };
+
+ if (delay) {
+ this._sessionRequestTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, resetDialog);
+ GLib.Source.set_name_by_id(this._sessionRequestTimeoutId, '[gnome-shell] this._sessionRequestTimeoutId');
+ } else {
+ resetDialog();
+ }
+ }
+
+ _onUserChanged() {
+ if (!this._user.is_loaded)
+ return;
+
+ let userName = this._user.get_user_name();
+ let realName = this._user.get_real_name();
+
+ if (userName !== 'root')
+ this._userLabel.set_text(realName);
+
+ this._userAvatar.update();
+
+ if (this._user.get_password_mode() === AccountsService.UserPasswordMode.NONE) {
+ if (this._mode === DialogMode.CONFIRM)
+ return;
+
+ this._mode = DialogMode.CONFIRM;
+ this._destroySession();
+
+ this._okButton.reactive = true;
+
+ /* We normally open the dialog when we get a "request" signal, but
+ * since in this case initiating a session would perform the
+ * authentication, only open the dialog and initiate the session
+ * when the user confirmed. */
+ this._ensureOpen();
+ } else {
+ if (this._mode === DialogMode.AUTH)
+ return;
+
+ this._mode = DialogMode.AUTH;
+ this._initiateSession();
+ }
+ }
+
+ close(timestamp) {
+ // Ensure cleanup if the dialog was never shown
+ if (this.state === ModalDialog.State.CLOSED)
+ this._onDialogClosed();
+ super.close(timestamp);
+ }
+
+ cancel() {
+ this.close(global.get_current_time());
+ this._emitDone(true);
+ }
+
+ _onDialogClosed() {
+ if (this._sessionUpdatedId)
+ Main.sessionMode.disconnect(this._sessionUpdatedId);
+
+ if (this._sessionRequestTimeoutId)
+ GLib.source_remove(this._sessionRequestTimeoutId);
+ this._sessionRequestTimeoutId = 0;
+
+ if (this._user) {
+ this._user.disconnect(this._userLoadedId);
+ this._user.disconnect(this._userChangedId);
+ this._user = null;
+ }
+
+ this._destroySession();
+ }
+});
+
+var AuthenticationAgent = GObject.registerClass(
+class AuthenticationAgent extends Shell.PolkitAuthenticationAgent {
+ _init() {
+ super._init();
+
+ this._currentDialog = null;
+ this.connect('initiate', this._onInitiate.bind(this));
+ this.connect('cancel', this._onCancel.bind(this));
+ this._sessionUpdatedId = 0;
+ }
+
+ enable() {
+ try {
+ this.register();
+ } catch (e) {
+ log('Failed to register AuthenticationAgent');
+ }
+ }
+
+ disable() {
+ try {
+ this.unregister();
+ } catch (e) {
+ log('Failed to unregister AuthenticationAgent');
+ }
+ }
+
+ _onInitiate(nativeAgent, actionId, message, iconName, cookie, userNames) {
+ // Don't pop up a dialog while locked
+ if (Main.sessionMode.isLocked) {
+ this._sessionUpdatedId = Main.sessionMode.connect('updated', () => {
+ Main.sessionMode.disconnect(this._sessionUpdatedId);
+ this._sessionUpdatedId = 0;
+
+ this._onInitiate(nativeAgent, actionId, message, iconName, cookie, userNames);
+ });
+ return;
+ }
+
+ this._currentDialog = new AuthenticationDialog(actionId, message, cookie, userNames);
+ this._currentDialog.connect('done', this._onDialogDone.bind(this));
+ }
+
+ _onCancel(_nativeAgent) {
+ this._completeRequest(false);
+ }
+
+ _onDialogDone(_dialog, dismissed) {
+ this._completeRequest(dismissed);
+ }
+
+ _completeRequest(dismissed) {
+ this._currentDialog.close();
+ this._currentDialog = null;
+
+ if (this._sessionUpdatedId)
+ Main.sessionMode.disconnect(this._sessionUpdatedId);
+ this._sessionUpdatedId = 0;
+
+ this.complete(dismissed);
+ }
+});
+
+var Component = AuthenticationAgent;
diff --git a/js/ui/components/telepathyClient.js b/js/ui/components/telepathyClient.js
new file mode 100644
index 0000000..0c9514e
--- /dev/null
+++ b/js/ui/components/telepathyClient.js
@@ -0,0 +1,1018 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Component */
+
+const { Clutter, Gio, GLib, GObject, St } = imports.gi;
+
+var Tpl = null;
+var Tp = null;
+try {
+ ({ TelepathyGLib: Tp, TelepathyLogger: Tpl } = imports.gi);
+
+ Gio._promisify(Tp.Channel.prototype, 'close_async', 'close_finish');
+ Gio._promisify(Tp.TextChannel.prototype,
+ 'send_message_async', 'send_message_finish');
+ Gio._promisify(Tp.ChannelDispatchOperation.prototype,
+ 'claim_with_async', 'claim_with_finish');
+ Gio._promisify(Tpl.LogManager.prototype,
+ 'get_filtered_events_async', 'get_filtered_events_finish');
+} catch (e) {
+ log('Telepathy is not available, chat integration will be disabled.');
+}
+
+const History = imports.misc.history;
+const Main = imports.ui.main;
+const MessageList = imports.ui.messageList;
+const MessageTray = imports.ui.messageTray;
+const Params = imports.misc.params;
+const Util = imports.misc.util;
+
+const HAVE_TP = Tp != null && Tpl != null;
+
+// See Notification.appendMessage
+var SCROLLBACK_IMMEDIATE_TIME = 3 * 60; // 3 minutes
+var SCROLLBACK_RECENT_TIME = 15 * 60; // 15 minutes
+var SCROLLBACK_RECENT_LENGTH = 20;
+var SCROLLBACK_IDLE_LENGTH = 5;
+
+// See Source._displayPendingMessages
+var SCROLLBACK_HISTORY_LINES = 10;
+
+// See Notification._onEntryChanged
+var COMPOSING_STOP_TIMEOUT = 5;
+
+var CHAT_EXPAND_LINES = 12;
+
+var NotificationDirection = {
+ SENT: 'chat-sent',
+ RECEIVED: 'chat-received',
+};
+
+const ChatMessage = HAVE_TP ? GObject.registerClass({
+ Properties: {
+ 'message-type': GObject.ParamSpec.int(
+ 'message-type', 'message-type', 'message-type',
+ GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
+ Math.min(...Object.values(Tp.ChannelTextMessageType)),
+ Math.max(...Object.values(Tp.ChannelTextMessageType)),
+ Tp.ChannelTextMessageType.NORMAL),
+ 'text': GObject.ParamSpec.string(
+ 'text', 'text', 'text',
+ GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
+ null),
+ 'sender': GObject.ParamSpec.string(
+ 'sender', 'sender', 'sender',
+ GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
+ null),
+ 'timestamp': GObject.ParamSpec.int64(
+ 'timestamp', 'timestamp', 'timestamp',
+ GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
+ 0, Number.MAX_SAFE_INTEGER, 0),
+ 'direction': GObject.ParamSpec.string(
+ 'direction', 'direction', 'direction',
+ GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
+ null),
+ },
+}, class ChatMessageClass extends GObject.Object {
+ static newFromTpMessage(tpMessage, direction) {
+ return new ChatMessage({
+ 'message-type': tpMessage.get_message_type(),
+ 'text': tpMessage.to_text()[0],
+ 'sender': tpMessage.sender.alias,
+ 'timestamp': direction === NotificationDirection.RECEIVED
+ ? tpMessage.get_received_timestamp() : tpMessage.get_sent_timestamp(),
+ direction,
+ });
+ }
+
+ static newFromTplTextEvent(tplTextEvent) {
+ let direction =
+ tplTextEvent.get_sender().get_entity_type() === Tpl.EntityType.SELF
+ ? NotificationDirection.SENT : NotificationDirection.RECEIVED;
+
+ return new ChatMessage({
+ 'message-type': tplTextEvent.get_message_type(),
+ 'text': tplTextEvent.get_message(),
+ 'sender': tplTextEvent.get_sender().get_alias(),
+ 'timestamp': tplTextEvent.get_timestamp(),
+ direction,
+ });
+ }
+}) : null;
+
+
+var TelepathyComponent = class {
+ constructor() {
+ this._client = null;
+
+ if (!HAVE_TP)
+ return; // Telepathy isn't available
+
+ this._client = new TelepathyClient();
+ }
+
+ enable() {
+ if (!this._client)
+ return;
+
+ try {
+ this._client.register();
+ } catch (e) {
+ throw new Error('Could not register Telepathy client. Error: %s'.format(e.toString()));
+ }
+
+ if (!this._client.account_manager.is_prepared(Tp.AccountManager.get_feature_quark_core()))
+ this._client.account_manager.prepare_async(null, null);
+ }
+
+ disable() {
+ if (!this._client)
+ return;
+
+ this._client.unregister();
+ }
+};
+
+var TelepathyClient = HAVE_TP ? GObject.registerClass(
+class TelepathyClient extends Tp.BaseClient {
+ _init() {
+ // channel path -> ChatSource
+ this._chatSources = {};
+ this._chatState = Tp.ChannelChatState.ACTIVE;
+
+ // account path -> AccountNotification
+ this._accountNotifications = {};
+
+ // Define features we want
+ this._accountManager = Tp.AccountManager.dup();
+ let factory = this._accountManager.get_factory();
+ factory.add_account_features([Tp.Account.get_feature_quark_connection()]);
+ factory.add_connection_features([Tp.Connection.get_feature_quark_contact_list()]);
+ factory.add_channel_features([Tp.Channel.get_feature_quark_contacts()]);
+ factory.add_contact_features([Tp.ContactFeature.ALIAS,
+ Tp.ContactFeature.AVATAR_DATA,
+ Tp.ContactFeature.PRESENCE,
+ Tp.ContactFeature.SUBSCRIPTION_STATES]);
+
+ // Set up a SimpleObserver, which will call _observeChannels whenever a
+ // channel matching its filters is detected.
+ // The second argument, recover, means _observeChannels will be run
+ // for any existing channel as well.
+ super._init({ name: 'GnomeShell',
+ account_manager: this._accountManager,
+ uniquify_name: true });
+
+ // We only care about single-user text-based chats
+ let filter = {};
+ filter[Tp.PROP_CHANNEL_CHANNEL_TYPE] = Tp.IFACE_CHANNEL_TYPE_TEXT;
+ filter[Tp.PROP_CHANNEL_TARGET_HANDLE_TYPE] = Tp.HandleType.CONTACT;
+
+ this.set_observer_recover(true);
+ this.add_observer_filter(filter);
+ this.add_approver_filter(filter);
+ this.add_handler_filter(filter);
+
+ // Allow other clients (such as Empathy) to preempt our channels if
+ // needed
+ this.set_delegated_channels_callback(
+ this._delegatedChannelsCb.bind(this));
+ }
+
+ vfunc_observe_channels(...args) {
+ let [account, conn, channels, dispatchOp_, requests_, context] = args;
+ let len = channels.length;
+ for (let i = 0; i < len; i++) {
+ let channel = channels[i];
+ let [targetHandle_, targetHandleType] = channel.get_handle();
+
+ if (channel.get_invalidated())
+ continue;
+
+ /* Only observe contact text channels */
+ if (!(channel instanceof Tp.TextChannel) ||
+ targetHandleType != Tp.HandleType.CONTACT)
+ continue;
+
+ this._createChatSource(account, conn, channel, channel.get_target_contact());
+ }
+
+ context.accept();
+ }
+
+ _createChatSource(account, conn, channel, contact) {
+ if (this._chatSources[channel.get_object_path()])
+ return;
+
+ let source = new ChatSource(account, conn, channel, contact, this);
+
+ this._chatSources[channel.get_object_path()] = source;
+ source.connect('destroy', () => {
+ delete this._chatSources[channel.get_object_path()];
+ });
+ }
+
+ vfunc_handle_channels(...args) {
+ let [account, conn, channels, requests_, userActionTime_, context] = args;
+ this._handlingChannels(account, conn, channels, true);
+ context.accept();
+ }
+
+ _handlingChannels(account, conn, channels, notify) {
+ let len = channels.length;
+ for (let i = 0; i < len; i++) {
+ let channel = channels[i];
+
+ // We can only handle text channel, so close any other channel
+ if (!(channel instanceof Tp.TextChannel)) {
+ channel.close_async();
+ continue;
+ }
+
+ if (channel.get_invalidated())
+ continue;
+
+ // 'notify' will be true when coming from an actual HandleChannels
+ // call, and not when from a successful Claim call. The point is
+ // we don't want to notify for a channel we just claimed which
+ // has no new messages (for example, a new channel which only has
+ // a delivery notification). We rely on _displayPendingMessages()
+ // and _messageReceived() to notify for new messages.
+
+ // But we should still notify from HandleChannels because the
+ // Telepathy spec states that handlers must foreground channels
+ // in HandleChannels calls which are already being handled.
+
+ if (notify && this.is_handling_channel(channel)) {
+ // We are already handling the channel, display the source
+ let source = this._chatSources[channel.get_object_path()];
+ if (source)
+ source.showNotification();
+ }
+ }
+ }
+
+ vfunc_add_dispatch_operation(...args) {
+ let [account, conn, channels, dispatchOp, context] = args;
+ let channel = channels[0];
+ let chanType = channel.get_channel_type();
+
+ if (channel.get_invalidated()) {
+ context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT,
+ message: 'Channel is invalidated' }));
+ return;
+ }
+
+ if (chanType == Tp.IFACE_CHANNEL_TYPE_TEXT) {
+ this._approveTextChannel(account, conn, channel, dispatchOp, context);
+ } else {
+ context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT,
+ message: 'Unsupported channel type' }));
+ }
+ }
+
+ async _approveTextChannel(account, conn, channel, dispatchOp, context) {
+ let [targetHandle_, targetHandleType] = channel.get_handle();
+
+ if (targetHandleType != Tp.HandleType.CONTACT) {
+ context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT,
+ message: 'Unsupported handle type' }));
+ return;
+ }
+
+ context.accept();
+
+ // Approve private text channels right away as we are going to handle it
+ try {
+ await dispatchOp.claim_with_async(this);
+ this._handlingChannels(account, conn, [channel], false);
+ } catch (err) {
+ log('Failed to Claim channel: %s'.format(err.toString()));
+ }
+ }
+
+ _delegatedChannelsCb(_client, _channels) {
+ // Nothing to do as we don't make a distinction between observed and
+ // handled channels.
+ }
+}) : null;
+
+var ChatSource = HAVE_TP ? GObject.registerClass(
+class ChatSource extends MessageTray.Source {
+ _init(account, conn, channel, contact, client) {
+ this._account = account;
+ this._contact = contact;
+ this._client = client;
+
+ super._init(contact.get_alias());
+
+ this.isChat = true;
+ this._pendingMessages = [];
+
+ this._conn = conn;
+ this._channel = channel;
+ this._closedId = this._channel.connect('invalidated', this._channelClosed.bind(this));
+
+ this._notifyTimeoutId = 0;
+
+ this._presence = contact.get_presence_type();
+
+ this._sentId = this._channel.connect('message-sent', this._messageSent.bind(this));
+ this._receivedId = this._channel.connect('message-received', this._messageReceived.bind(this));
+ this._pendingId = this._channel.connect('pending-message-removed', this._pendingRemoved.bind(this));
+
+ this._notifyAliasId = this._contact.connect('notify::alias', this._updateAlias.bind(this));
+ this._notifyAvatarId = this._contact.connect('notify::avatar-file', this._updateAvatarIcon.bind(this));
+ this._presenceChangedId = this._contact.connect('presence-changed', this._presenceChanged.bind(this));
+
+ // Add ourselves as a source.
+ Main.messageTray.add(this);
+
+ this._getLogMessages();
+ }
+
+ _ensureNotification() {
+ if (this._notification)
+ return;
+
+ this._notification = new ChatNotification(this);
+ this._notification.connect('activated', this.open.bind(this));
+ this._notification.connect('updated', () => {
+ if (this._banner && this._banner.expanded)
+ this._ackMessages();
+ });
+ this._notification.connect('destroy', () => {
+ this._notification = null;
+ });
+ this.pushNotification(this._notification);
+ }
+
+ _createPolicy() {
+ if (this._account.protocol_name == 'irc')
+ return new MessageTray.NotificationApplicationPolicy('org.gnome.Polari');
+ return new MessageTray.NotificationApplicationPolicy('empathy');
+ }
+
+ createBanner() {
+ this._banner = new ChatNotificationBanner(this._notification);
+
+ // We ack messages when the user expands the new notification
+ let id = this._banner.connect('expanded', this._ackMessages.bind(this));
+ this._banner.connect('destroy', () => {
+ this._banner.disconnect(id);
+ this._banner = null;
+ });
+
+ return this._banner;
+ }
+
+ _updateAlias() {
+ let oldAlias = this.title;
+ let newAlias = this._contact.get_alias();
+
+ if (oldAlias == newAlias)
+ return;
+
+ this.setTitle(newAlias);
+ if (this._notification)
+ this._notification.appendAliasChange(oldAlias, newAlias);
+ }
+
+ getIcon() {
+ let file = this._contact.get_avatar_file();
+ if (file)
+ return new Gio.FileIcon({ file });
+ else
+ return new Gio.ThemedIcon({ name: 'avatar-default' });
+ }
+
+ getSecondaryIcon() {
+ let iconName;
+ let presenceType = this._contact.get_presence_type();
+
+ switch (presenceType) {
+ case Tp.ConnectionPresenceType.AVAILABLE:
+ iconName = 'user-available';
+ break;
+ case Tp.ConnectionPresenceType.BUSY:
+ iconName = 'user-busy';
+ break;
+ case Tp.ConnectionPresenceType.OFFLINE:
+ iconName = 'user-offline';
+ break;
+ case Tp.ConnectionPresenceType.HIDDEN:
+ iconName = 'user-invisible';
+ break;
+ case Tp.ConnectionPresenceType.AWAY:
+ iconName = 'user-away';
+ break;
+ case Tp.ConnectionPresenceType.EXTENDED_AWAY:
+ iconName = 'user-idle';
+ break;
+ default:
+ iconName = 'user-offline';
+ }
+ return new Gio.ThemedIcon({ name: iconName });
+ }
+
+ _updateAvatarIcon() {
+ this.iconUpdated();
+ if (this._notifiction) {
+ this._notification.update(this._notification.title,
+ this._notification.bannerBodyText,
+ { gicon: this.getIcon() });
+ }
+ }
+
+ open() {
+ Main.overview.hide();
+ Main.panel.closeCalendar();
+
+ if (this._client.is_handling_channel(this._channel)) {
+ // We are handling the channel, try to pass it to Empathy or Polari
+ // (depending on the channel type)
+ // We don't check if either app is available - mission control will
+ // fallback to something else if activation fails
+
+ let target;
+ if (this._channel.connection.protocol_name == 'irc')
+ target = 'org.freedesktop.Telepathy.Client.Polari';
+ else
+ target = 'org.freedesktop.Telepathy.Client.Empathy.Chat';
+ this._client.delegate_channels_async([this._channel], global.get_current_time(), target, null);
+ } else {
+ // We are not the handler, just ask to present the channel
+ let dbus = Tp.DBusDaemon.dup();
+ let cd = Tp.ChannelDispatcher.new(dbus);
+
+ cd.present_channel_async(this._channel, global.get_current_time(), null);
+ }
+ }
+
+ async _getLogMessages() {
+ let logManager = Tpl.LogManager.dup_singleton();
+ let entity = Tpl.Entity.new_from_tp_contact(this._contact, Tpl.EntityType.CONTACT);
+
+ const [events] = await logManager.get_filtered_events_async(
+ this._account, entity,
+ Tpl.EventTypeMask.TEXT, SCROLLBACK_HISTORY_LINES,
+ null);
+
+ let logMessages = events.map(e => ChatMessage.newFromTplTextEvent(e));
+ this._ensureNotification();
+
+ let pendingTpMessages = this._channel.get_pending_messages();
+ let pendingMessages = [];
+
+ for (let i = 0; i < pendingTpMessages.length; i++) {
+ let message = pendingTpMessages[i];
+
+ if (message.get_message_type() == Tp.ChannelTextMessageType.DELIVERY_REPORT)
+ continue;
+
+ pendingMessages.push(ChatMessage.newFromTpMessage(message,
+ NotificationDirection.RECEIVED));
+
+ this._pendingMessages.push(message);
+ }
+
+ this.countUpdated();
+
+ let showTimestamp = false;
+
+ for (let i = 0; i < logMessages.length; i++) {
+ let logMessage = logMessages[i];
+ let isPending = false;
+
+ // Skip any log messages that are also in pendingMessages
+ for (let j = 0; j < pendingMessages.length; j++) {
+ let pending = pendingMessages[j];
+ if (logMessage.timestamp == pending.timestamp && logMessage.text == pending.text) {
+ isPending = true;
+ break;
+ }
+ }
+
+ if (!isPending) {
+ showTimestamp = true;
+ this._notification.appendMessage(logMessage, true, ['chat-log-message']);
+ }
+ }
+
+ if (showTimestamp)
+ this._notification.appendTimestamp();
+
+ for (let i = 0; i < pendingMessages.length; i++)
+ this._notification.appendMessage(pendingMessages[i], true);
+
+ if (pendingMessages.length > 0)
+ this.showNotification();
+ }
+
+ destroy(reason) {
+ if (this._client.is_handling_channel(this._channel)) {
+ this._ackMessages();
+ // The chat box has been destroyed so it can't
+ // handle the channel any more.
+ this._channel.close_async();
+ } else {
+ // Don't indicate any unread messages when the notification
+ // that represents them has been destroyed.
+ this._pendingMessages = [];
+ this.countUpdated();
+ }
+
+ // Keep source alive while the channel is open
+ if (reason != MessageTray.NotificationDestroyedReason.SOURCE_CLOSED)
+ return;
+
+ if (this._destroyed)
+ return;
+
+ this._destroyed = true;
+ this._channel.disconnect(this._closedId);
+ this._channel.disconnect(this._receivedId);
+ this._channel.disconnect(this._pendingId);
+ this._channel.disconnect(this._sentId);
+
+ this._contact.disconnect(this._notifyAliasId);
+ this._contact.disconnect(this._notifyAvatarId);
+ this._contact.disconnect(this._presenceChangedId);
+
+ super.destroy(reason);
+ }
+
+ _channelClosed() {
+ this.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED);
+ }
+
+ /* All messages are new messages for Telepathy sources */
+ get count() {
+ return this._pendingMessages.length;
+ }
+
+ get unseenCount() {
+ return this.count;
+ }
+
+ get countVisible() {
+ return this.count > 0;
+ }
+
+ _messageReceived(channel, message) {
+ if (message.get_message_type() == Tp.ChannelTextMessageType.DELIVERY_REPORT)
+ return;
+
+ this._ensureNotification();
+ this._pendingMessages.push(message);
+ this.countUpdated();
+
+ message = ChatMessage.newFromTpMessage(message,
+ NotificationDirection.RECEIVED);
+ this._notification.appendMessage(message);
+
+ // Wait a bit before notifying for the received message, a handler
+ // could ack it in the meantime.
+ if (this._notifyTimeoutId != 0)
+ GLib.source_remove(this._notifyTimeoutId);
+ this._notifyTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500,
+ this._notifyTimeout.bind(this));
+ GLib.Source.set_name_by_id(this._notifyTimeoutId, '[gnome-shell] this._notifyTimeout');
+ }
+
+ _notifyTimeout() {
+ if (this._pendingMessages.length != 0)
+ this.showNotification();
+
+ this._notifyTimeoutId = 0;
+
+ return GLib.SOURCE_REMOVE;
+ }
+
+ // This is called for both messages we send from
+ // our client and other clients as well.
+ _messageSent(channel, message, _flags, _token) {
+ this._ensureNotification();
+ message = ChatMessage.newFromTpMessage(message,
+ NotificationDirection.SENT);
+ this._notification.appendMessage(message);
+ }
+
+ showNotification() {
+ super.showNotification(this._notification);
+ }
+
+ respond(text) {
+ let type;
+ if (text.slice(0, 4) == '/me ') {
+ type = Tp.ChannelTextMessageType.ACTION;
+ text = text.slice(4);
+ } else {
+ type = Tp.ChannelTextMessageType.NORMAL;
+ }
+
+ let msg = Tp.ClientMessage.new_text(type, text);
+ this._channel.send_message_async(msg, 0);
+ }
+
+ setChatState(state) {
+ // We don't want to send COMPOSING every time a letter is typed into
+ // the entry. We send the state only when it changes. Telepathy/Empathy
+ // might change it behind our back if the user is using both
+ // gnome-shell's entry and the Empathy conversation window. We could
+ // keep track of it with the ChatStateChanged signal but it is good
+ // enough right now.
+ if (state != this._chatState) {
+ this._chatState = state;
+ this._channel.set_chat_state_async(state, null);
+ }
+ }
+
+ _presenceChanged(_contact, _presence, _status, _message) {
+ if (this._notification) {
+ this._notification.update(this._notification.title,
+ this._notification.bannerBodyText,
+ { secondaryGIcon: this.getSecondaryIcon() });
+ }
+ }
+
+ _pendingRemoved(channel, message) {
+ let idx = this._pendingMessages.indexOf(message);
+
+ if (idx >= 0) {
+ this._pendingMessages.splice(idx, 1);
+ this.countUpdated();
+ }
+
+ if (this._pendingMessages.length == 0 &&
+ this._banner && !this._banner.expanded)
+ this._banner.hide();
+ }
+
+ _ackMessages() {
+ // Don't clear our messages here, tp-glib will send a
+ // 'pending-message-removed' for each one.
+ this._channel.ack_all_pending_messages_async(null);
+ }
+}) : null;
+
+const ChatNotificationMessage = HAVE_TP ? GObject.registerClass(
+class ChatNotificationMessage extends GObject.Object {
+ _init(props = {}) {
+ super._init();
+ this.set(props);
+ }
+}) : null;
+
+var ChatNotification = HAVE_TP ? GObject.registerClass({
+ Signals: {
+ 'message-removed': { param_types: [ChatNotificationMessage.$gtype] },
+ 'message-added': { param_types: [ChatNotificationMessage.$gtype] },
+ 'timestamp-changed': { param_types: [ChatNotificationMessage.$gtype] },
+ },
+}, class ChatNotification extends MessageTray.Notification {
+ _init(source) {
+ super._init(source, source.title, null,
+ { secondaryGIcon: source.getSecondaryIcon() });
+ this.setUrgency(MessageTray.Urgency.HIGH);
+ this.setResident(true);
+
+ this.messages = [];
+ this._timestampTimeoutId = 0;
+ }
+
+ destroy(reason) {
+ if (this._timestampTimeoutId)
+ GLib.source_remove(this._timestampTimeoutId);
+ this._timestampTimeoutId = 0;
+ super.destroy(reason);
+ }
+
+ /**
+ * appendMessage:
+ * @param {Object} message: An object with the properties
+ * {string} message.text: the body of the message,
+ * {Tp.ChannelTextMessageType} message.messageType: the type
+ * {string} message.sender: the name of the sender,
+ * {number} message.timestamp: the time the message was sent
+ * {NotificationDirection} message.direction: a #NotificationDirection
+ *
+ * @param {bool} noTimestamp: Whether to add a timestamp. If %true,
+ * no timestamp will be added, regardless of the difference since
+ * the last timestamp
+ */
+ appendMessage(message, noTimestamp) {
+ let messageBody = GLib.markup_escape_text(message.text, -1);
+ let styles = [message.direction];
+
+ if (message.messageType == Tp.ChannelTextMessageType.ACTION) {
+ let senderAlias = GLib.markup_escape_text(message.sender, -1);
+ messageBody = '<i>%s</i> %s'.format(senderAlias, messageBody);
+ styles.push('chat-action');
+ }
+
+ if (message.direction == NotificationDirection.RECEIVED) {
+ this.update(this.source.title, messageBody,
+ { datetime: GLib.DateTime.new_from_unix_local(message.timestamp),
+ bannerMarkup: true });
+ }
+
+ let group = message.direction == NotificationDirection.RECEIVED
+ ? 'received' : 'sent';
+
+ this._append({ body: messageBody,
+ group,
+ styles,
+ timestamp: message.timestamp,
+ noTimestamp });
+ }
+
+ _filterMessages() {
+ if (this.messages.length < 1)
+ return;
+
+ let lastMessageTime = this.messages[0].timestamp;
+ let currentTime = Date.now() / 1000;
+
+ // Keep the scrollback from growing too long. If the most
+ // recent message (before the one we just added) is within
+ // SCROLLBACK_RECENT_TIME, we will keep
+ // SCROLLBACK_RECENT_LENGTH previous messages. Otherwise
+ // we'll keep SCROLLBACK_IDLE_LENGTH messages.
+
+ let maxLength = lastMessageTime < currentTime - SCROLLBACK_RECENT_TIME
+ ? SCROLLBACK_IDLE_LENGTH : SCROLLBACK_RECENT_LENGTH;
+
+ let filteredHistory = this.messages.filter(item => item.realMessage);
+ if (filteredHistory.length > maxLength) {
+ let lastMessageToKeep = filteredHistory[maxLength];
+ let expired = this.messages.splice(this.messages.indexOf(lastMessageToKeep));
+ for (let i = 0; i < expired.length; i++)
+ this.emit('message-removed', expired[i]);
+ }
+ }
+
+ /**
+ * _append:
+ * @param {Object} props: An object with the properties:
+ * {string} props.body: The text of the message.
+ * {string} props.group: The group of the message, one of:
+ * 'received', 'sent', 'meta'.
+ * {string[]} props.styles: Style class names for the message to have.
+ * {number} props.timestamp: The timestamp of the message.
+ * {bool} props.noTimestamp: suppress timestamp signal?
+ */
+ _append(props) {
+ let currentTime = Date.now() / 1000;
+ props = Params.parse(props, { body: null,
+ group: null,
+ styles: [],
+ timestamp: currentTime,
+ noTimestamp: false });
+ const { noTimestamp } = props;
+ delete props.noTimestamp;
+
+ // Reset the old message timeout
+ if (this._timestampTimeoutId)
+ GLib.source_remove(this._timestampTimeoutId);
+ this._timestampTimeoutId = 0;
+
+ let message = new ChatNotificationMessage({
+ realMessage: props.group !== 'meta',
+ showTimestamp: false,
+ ...props,
+ });
+
+ this.messages.unshift(message);
+ this.emit('message-added', message);
+
+ if (!noTimestamp) {
+ let timestamp = props.timestamp;
+ if (timestamp < currentTime - SCROLLBACK_IMMEDIATE_TIME) {
+ this.appendTimestamp();
+ } else {
+ // Schedule a new timestamp in SCROLLBACK_IMMEDIATE_TIME
+ // from the timestamp of the message.
+ this._timestampTimeoutId = GLib.timeout_add_seconds(
+ GLib.PRIORITY_DEFAULT,
+ SCROLLBACK_IMMEDIATE_TIME - (currentTime - timestamp),
+ this.appendTimestamp.bind(this));
+ GLib.Source.set_name_by_id(this._timestampTimeoutId, '[gnome-shell] this.appendTimestamp');
+ }
+ }
+
+ this._filterMessages();
+ }
+
+ appendTimestamp() {
+ this._timestampTimeoutId = 0;
+
+ this.messages[0].showTimestamp = true;
+ this.emit('timestamp-changed', this.messages[0]);
+
+ this._filterMessages();
+
+ return GLib.SOURCE_REMOVE;
+ }
+
+ appendAliasChange(oldAlias, newAlias) {
+ oldAlias = GLib.markup_escape_text(oldAlias, -1);
+ newAlias = GLib.markup_escape_text(newAlias, -1);
+
+ /* Translators: this is the other person changing their old IM name to their new
+ IM name. */
+ let message = '<i>' + _("%s is now known as %s").format(oldAlias, newAlias) + '</i>';
+
+ this._append({ body: message,
+ group: 'meta',
+ styles: ['chat-meta-message'] });
+
+ this._filterMessages();
+ }
+}) : null;
+
+var ChatLineBox = GObject.registerClass(
+class ChatLineBox extends St.BoxLayout {
+ vfunc_get_preferred_height(forWidth) {
+ let [, natHeight] = super.vfunc_get_preferred_height(forWidth);
+ return [natHeight, natHeight];
+ }
+});
+
+var ChatNotificationBanner = GObject.registerClass(
+class ChatNotificationBanner extends MessageTray.NotificationBanner {
+ _init(notification) {
+ super._init(notification);
+
+ this._responseEntry = new St.Entry({ style_class: 'chat-response',
+ x_expand: true,
+ can_focus: true });
+ this._responseEntry.clutter_text.connect('activate', this._onEntryActivated.bind(this));
+ this._responseEntry.clutter_text.connect('text-changed', this._onEntryChanged.bind(this));
+ this.setActionArea(this._responseEntry);
+
+ this._responseEntry.clutter_text.connect('key-focus-in', () => {
+ this.focused = true;
+ });
+ this._responseEntry.clutter_text.connect('key-focus-out', () => {
+ this.focused = false;
+ this.emit('unfocused');
+ });
+
+ this._scrollArea = new St.ScrollView({ style_class: 'chat-scrollview vfade',
+ vscrollbar_policy: St.PolicyType.AUTOMATIC,
+ hscrollbar_policy: St.PolicyType.NEVER,
+ visible: this.expanded });
+ this._contentArea = new St.BoxLayout({ style_class: 'chat-body',
+ vertical: true });
+ this._scrollArea.add_actor(this._contentArea);
+
+ this.setExpandedBody(this._scrollArea);
+ this.setExpandedLines(CHAT_EXPAND_LINES);
+
+ this._lastGroup = null;
+
+ // Keep track of the bottom position for the current adjustment and
+ // force a scroll to the bottom if things change while we were at the
+ // bottom
+ this._oldMaxScrollValue = this._scrollArea.vscroll.adjustment.value;
+ this._scrollArea.vscroll.adjustment.connect('changed', adjustment => {
+ if (adjustment.value == this._oldMaxScrollValue)
+ this.scrollTo(St.Side.BOTTOM);
+ this._oldMaxScrollValue = Math.max(adjustment.lower, adjustment.upper - adjustment.page_size);
+ });
+
+ this._inputHistory = new History.HistoryManager({ entry: this._responseEntry.clutter_text });
+
+ this._composingTimeoutId = 0;
+
+ this._messageActors = new Map();
+
+ this._messageAddedId = this.notification.connect('message-added',
+ (n, message) => {
+ this._addMessage(message);
+ });
+ this._messageRemovedId = this.notification.connect('message-removed',
+ (n, message) => {
+ let actor = this._messageActors.get(message);
+ if (this._messageActors.delete(message))
+ actor.destroy();
+ });
+ this._timestampChangedId = this.notification.connect('timestamp-changed',
+ (n, message) => {
+ this._updateTimestamp(message);
+ });
+
+ for (let i = this.notification.messages.length - 1; i >= 0; i--)
+ this._addMessage(this.notification.messages[i]);
+ }
+
+ _onDestroy() {
+ super._onDestroy();
+ this.notification.disconnect(this._messageAddedId);
+ this.notification.disconnect(this._messageRemovedId);
+ this.notification.disconnect(this._timestampChangedId);
+ }
+
+ scrollTo(side) {
+ let adjustment = this._scrollArea.vscroll.adjustment;
+ if (side == St.Side.TOP)
+ adjustment.value = adjustment.lower;
+ else if (side == St.Side.BOTTOM)
+ adjustment.value = adjustment.upper;
+ }
+
+ hide() {
+ this.emit('done-displaying');
+ }
+
+ _addMessage(message) {
+ let body = new MessageList.URLHighlighter(message.body, true, true);
+
+ let styles = message.styles;
+ for (let i = 0; i < styles.length; i++)
+ body.add_style_class_name(styles[i]);
+
+ let group = message.group;
+ if (group != this._lastGroup) {
+ this._lastGroup = group;
+ body.add_style_class_name('chat-new-group');
+ }
+
+ let lineBox = new ChatLineBox();
+ lineBox.add(body);
+ this._contentArea.add_actor(lineBox);
+ this._messageActors.set(message, lineBox);
+
+ this._updateTimestamp(message);
+ }
+
+ _updateTimestamp(message) {
+ let actor = this._messageActors.get(message);
+ if (!actor)
+ return;
+
+ while (actor.get_n_children() > 1)
+ actor.get_child_at_index(1).destroy();
+
+ if (message.showTimestamp) {
+ let lastMessageTime = message.timestamp;
+ let lastMessageDate = new Date(lastMessageTime * 1000);
+
+ let timeLabel = Util.createTimeLabel(lastMessageDate);
+ timeLabel.style_class = 'chat-meta-message';
+ timeLabel.x_expand = timeLabel.y_expand = true;
+ timeLabel.x_align = timeLabel.y_align = Clutter.ActorAlign.END;
+
+ actor.add_actor(timeLabel);
+ }
+ }
+
+ _onEntryActivated() {
+ let text = this._responseEntry.get_text();
+ if (text == '')
+ return;
+
+ this._inputHistory.addItem(text);
+
+ // Telepathy sends out the Sent signal for us.
+ // see Source._messageSent
+ this._responseEntry.set_text('');
+ this.notification.source.respond(text);
+ }
+
+ _composingStopTimeout() {
+ this._composingTimeoutId = 0;
+
+ this.notification.source.setChatState(Tp.ChannelChatState.PAUSED);
+
+ return GLib.SOURCE_REMOVE;
+ }
+
+ _onEntryChanged() {
+ let text = this._responseEntry.get_text();
+
+ // If we're typing, we want to send COMPOSING.
+ // If we empty the entry, we want to send ACTIVE.
+ // If we've stopped typing for COMPOSING_STOP_TIMEOUT
+ // seconds, we want to send PAUSED.
+
+ // Remove composing timeout.
+ if (this._composingTimeoutId > 0) {
+ GLib.source_remove(this._composingTimeoutId);
+ this._composingTimeoutId = 0;
+ }
+
+ if (text != '') {
+ this.notification.source.setChatState(Tp.ChannelChatState.COMPOSING);
+
+ this._composingTimeoutId = GLib.timeout_add_seconds(
+ GLib.PRIORITY_DEFAULT,
+ COMPOSING_STOP_TIMEOUT,
+ this._composingStopTimeout.bind(this));
+ GLib.Source.set_name_by_id(this._composingTimeoutId, '[gnome-shell] this._composingStopTimeout');
+ } else {
+ this.notification.source.setChatState(Tp.ChannelChatState.ACTIVE);
+ }
+ }
+});
+
+var Component = TelepathyComponent;
diff --git a/js/ui/ctrlAltTab.js b/js/ui/ctrlAltTab.js
new file mode 100644
index 0000000..fbc7a0f
--- /dev/null
+++ b/js/ui/ctrlAltTab.js
@@ -0,0 +1,192 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported CtrlAltTabManager */
+
+const { Clutter, GObject, Meta, Shell, St } = imports.gi;
+
+const Main = imports.ui.main;
+const SwitcherPopup = imports.ui.switcherPopup;
+const Params = imports.misc.params;
+
+var POPUP_APPICON_SIZE = 96;
+
+var SortGroup = {
+ TOP: 0,
+ MIDDLE: 1,
+ BOTTOM: 2,
+};
+
+var CtrlAltTabManager = class CtrlAltTabManager {
+ constructor() {
+ this._items = [];
+ this.addGroup(global.window_group, _("Windows"),
+ 'focus-windows-symbolic', { sortGroup: SortGroup.TOP,
+ focusCallback: this._focusWindows.bind(this) });
+ }
+
+ addGroup(root, name, icon, params) {
+ let item = Params.parse(params, { sortGroup: SortGroup.MIDDLE,
+ proxy: root,
+ focusCallback: null });
+
+ item.root = root;
+ item.name = name;
+ item.iconName = icon;
+
+ this._items.push(item);
+ root.connect('destroy', () => this.removeGroup(root));
+ if (root instanceof St.Widget)
+ global.focus_manager.add_group(root);
+ }
+
+ removeGroup(root) {
+ if (root instanceof St.Widget)
+ global.focus_manager.remove_group(root);
+ for (let i = 0; i < this._items.length; i++) {
+ if (this._items[i].root == root) {
+ this._items.splice(i, 1);
+ return;
+ }
+ }
+ }
+
+ focusGroup(item, timestamp) {
+ if (item.focusCallback)
+ item.focusCallback(timestamp);
+ else
+ item.root.navigate_focus(null, St.DirectionType.TAB_FORWARD, false);
+ }
+
+ // Sort the items into a consistent order; panel first, tray last,
+ // and everything else in between, sorted by X coordinate, so that
+ // they will have the same left-to-right ordering in the
+ // Ctrl-Alt-Tab dialog as they do onscreen.
+ _sortItems(a, b) {
+ if (a.sortGroup != b.sortGroup)
+ return a.sortGroup - b.sortGroup;
+
+ let [ax] = a.proxy.get_transformed_position();
+ let [bx] = b.proxy.get_transformed_position();
+
+ return ax - bx;
+ }
+
+ popup(backward, binding, mask) {
+ // Start with the set of focus groups that are currently mapped
+ let items = this._items.filter(item => item.proxy.mapped);
+
+ // And add the windows metacity would show in its Ctrl-Alt-Tab list
+ if (Main.sessionMode.hasWindows && !Main.overview.visible) {
+ let display = global.display;
+ let workspaceManager = global.workspace_manager;
+ let activeWorkspace = workspaceManager.get_active_workspace();
+ let windows = display.get_tab_list(Meta.TabList.DOCKS,
+ activeWorkspace);
+ let windowTracker = Shell.WindowTracker.get_default();
+ let textureCache = St.TextureCache.get_default();
+ for (let i = 0; i < windows.length; i++) {
+ let icon = null;
+ let iconName = null;
+ if (windows[i].get_window_type() == Meta.WindowType.DESKTOP) {
+ iconName = 'video-display-symbolic';
+ } else {
+ let app = windowTracker.get_window_app(windows[i]);
+ if (app) {
+ icon = app.create_icon_texture(POPUP_APPICON_SIZE);
+ } else {
+ icon = new St.Icon({
+ gicon: textureCache.bind_cairo_surface_property(windows[i], 'icon'),
+ icon_size: POPUP_APPICON_SIZE,
+ });
+ }
+ }
+
+ items.push({ name: windows[i].title,
+ proxy: windows[i].get_compositor_private(),
+ focusCallback: timestamp => {
+ Main.activateWindow(windows[i], timestamp);
+ },
+ iconActor: icon,
+ iconName,
+ sortGroup: SortGroup.MIDDLE });
+ }
+ }
+
+ if (!items.length)
+ return;
+
+ items.sort(this._sortItems.bind(this));
+
+ if (!this._popup) {
+ this._popup = new CtrlAltTabPopup(items);
+ this._popup.show(backward, binding, mask);
+
+ this._popup.connect('destroy',
+ () => {
+ this._popup = null;
+ });
+ }
+ }
+
+ _focusWindows(timestamp) {
+ global.display.focus_default_window(timestamp);
+ }
+};
+
+var CtrlAltTabPopup = GObject.registerClass(
+class CtrlAltTabPopup extends SwitcherPopup.SwitcherPopup {
+ _init(items) {
+ super._init(items);
+
+ this._switcherList = new CtrlAltTabSwitcher(this._items);
+ }
+
+ _keyPressHandler(keysym, action) {
+ if (action == Meta.KeyBindingAction.SWITCH_PANELS)
+ this._select(this._next());
+ else if (action == Meta.KeyBindingAction.SWITCH_PANELS_BACKWARD)
+ this._select(this._previous());
+ else if (keysym == Clutter.KEY_Left)
+ this._select(this._previous());
+ else if (keysym == Clutter.KEY_Right)
+ this._select(this._next());
+ else
+ return Clutter.EVENT_PROPAGATE;
+
+ return Clutter.EVENT_STOP;
+ }
+
+ _finish(time) {
+ super._finish(time);
+ Main.ctrlAltTabManager.focusGroup(this._items[this._selectedIndex], time);
+ }
+});
+
+var CtrlAltTabSwitcher = GObject.registerClass(
+class CtrlAltTabSwitcher extends SwitcherPopup.SwitcherList {
+ _init(items) {
+ super._init(true);
+
+ for (let i = 0; i < items.length; i++)
+ this._addIcon(items[i]);
+ }
+
+ _addIcon(item) {
+ let box = new St.BoxLayout({ style_class: 'alt-tab-app',
+ vertical: true });
+
+ let icon = item.iconActor;
+ if (!icon) {
+ icon = new St.Icon({ icon_name: item.iconName,
+ icon_size: POPUP_APPICON_SIZE });
+ }
+ box.add_child(icon);
+
+ let text = new St.Label({
+ text: item.name,
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+ box.add_child(text);
+
+ this.addItem(box, text);
+ }
+});
diff --git a/js/ui/dash.js b/js/ui/dash.js
new file mode 100644
index 0000000..7e4457e
--- /dev/null
+++ b/js/ui/dash.js
@@ -0,0 +1,914 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Dash */
+
+const { Clutter, GLib, GObject,
+ Graphene, Meta, Shell, St } = imports.gi;
+
+const AppDisplay = imports.ui.appDisplay;
+const AppFavorites = imports.ui.appFavorites;
+const DND = imports.ui.dnd;
+const IconGrid = imports.ui.iconGrid;
+const Main = imports.ui.main;
+
+var DASH_ANIMATION_TIME = 200;
+var DASH_ITEM_LABEL_SHOW_TIME = 150;
+var DASH_ITEM_LABEL_HIDE_TIME = 100;
+var DASH_ITEM_HOVER_TIMEOUT = 300;
+
+function getAppFromSource(source) {
+ if (source instanceof AppDisplay.AppIcon)
+ return source.app;
+ else
+ return null;
+}
+
+var DashIcon = GObject.registerClass(
+class DashIcon extends AppDisplay.AppIcon {
+ _init(app) {
+ super._init(app, {
+ setSizeManually: true,
+ showLabel: false,
+ });
+ }
+
+ // Disable scale-n-fade methods used during DND by parent
+ scaleAndFade() {
+ }
+
+ undoScaleAndFade() {
+ }
+
+ handleDragOver() {
+ return DND.DragMotionResult.CONTINUE;
+ }
+
+ acceptDrop() {
+ return false;
+ }
+});
+
+// A container like StBin, but taking the child's scale into account
+// when requesting a size
+var DashItemContainer = GObject.registerClass(
+class DashItemContainer extends St.Widget {
+ _init() {
+ super._init({ style_class: 'dash-item-container',
+ pivot_point: new Graphene.Point({ x: .5, y: .5 }),
+ scale_x: 0,
+ scale_y: 0,
+ opacity: 0,
+ x_expand: true,
+ x_align: Clutter.ActorAlign.CENTER });
+
+ this._labelText = "";
+ this.label = new St.Label({ style_class: 'dash-label' });
+ this.label.hide();
+ Main.layoutManager.addChrome(this.label);
+ this.label_actor = this.label;
+
+ this.child = null;
+ this.animatingOut = false;
+
+ this.connect('notify::scale-x', () => this.queue_relayout());
+ this.connect('notify::scale-y', () => this.queue_relayout());
+
+ this.connect('destroy', () => {
+ if (this.child != null)
+ this.child.destroy();
+ this.label.destroy();
+ });
+ }
+
+ vfunc_get_preferred_height(forWidth) {
+ let themeNode = this.get_theme_node();
+ forWidth = themeNode.adjust_for_width(forWidth);
+ let [minHeight, natHeight] = super.vfunc_get_preferred_height(forWidth);
+ return themeNode.adjust_preferred_height(minHeight * this.scale_y,
+ natHeight * this.scale_y);
+ }
+
+ vfunc_get_preferred_width(forHeight) {
+ let themeNode = this.get_theme_node();
+ forHeight = themeNode.adjust_for_height(forHeight);
+ let [minWidth, natWidth] = super.vfunc_get_preferred_width(forHeight);
+ return themeNode.adjust_preferred_width(minWidth * this.scale_x,
+ natWidth * this.scale_x);
+ }
+
+ showLabel() {
+ if (!this._labelText)
+ return;
+
+ this.label.set_text(this._labelText);
+ this.label.opacity = 0;
+ this.label.show();
+
+ let [stageX, stageY] = this.get_transformed_position();
+
+ let itemHeight = this.allocation.y2 - this.allocation.y1;
+
+ let labelHeight = this.label.get_height();
+ let yOffset = Math.floor((itemHeight - labelHeight) / 2);
+
+ let y = stageY + yOffset;
+
+ let node = this.label.get_theme_node();
+ let xOffset = node.get_length('-x-offset');
+
+ let x;
+ if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL)
+ x = stageX - this.label.get_width() - xOffset;
+ else
+ x = stageX + this.get_width() + xOffset;
+
+ this.label.set_position(x, y);
+ this.label.ease({
+ opacity: 255,
+ duration: DASH_ITEM_LABEL_SHOW_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ setLabelText(text) {
+ this._labelText = text;
+ this.child.accessible_name = text;
+ }
+
+ hideLabel() {
+ this.label.ease({
+ opacity: 0,
+ duration: DASH_ITEM_LABEL_HIDE_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this.label.hide(),
+ });
+ }
+
+ setChild(actor) {
+ if (this.child == actor)
+ return;
+
+ this.destroy_all_children();
+
+ this.child = actor;
+ this.add_actor(this.child);
+ }
+
+ show(animate) {
+ if (this.child == null)
+ return;
+
+ let time = animate ? DASH_ANIMATION_TIME : 0;
+ this.ease({
+ scale_x: 1,
+ scale_y: 1,
+ opacity: 255,
+ duration: time,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ animateOutAndDestroy() {
+ this.label.hide();
+
+ if (this.child == null) {
+ this.destroy();
+ return;
+ }
+
+ this.animatingOut = true;
+ this.ease({
+ scale_x: 0,
+ scale_y: 0,
+ opacity: 0,
+ duration: DASH_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this.destroy(),
+ });
+ }
+});
+
+var ShowAppsIcon = GObject.registerClass(
+class ShowAppsIcon extends DashItemContainer {
+ _init() {
+ super._init();
+
+ this.toggleButton = new St.Button({ style_class: 'show-apps',
+ track_hover: true,
+ can_focus: true,
+ toggle_mode: true });
+ this._iconActor = null;
+ this.icon = new IconGrid.BaseIcon(_("Show Applications"),
+ { setSizeManually: true,
+ showLabel: false,
+ createIcon: this._createIcon.bind(this) });
+ this.toggleButton.add_actor(this.icon);
+ this.toggleButton._delegate = this;
+
+ this.setChild(this.toggleButton);
+ this.setDragApp(null);
+ }
+
+ _createIcon(size) {
+ this._iconActor = new St.Icon({ icon_name: 'view-app-grid-symbolic',
+ icon_size: size,
+ style_class: 'show-apps-icon',
+ track_hover: true });
+ return this._iconActor;
+ }
+
+ _canRemoveApp(app) {
+ if (app == null)
+ return false;
+
+ if (!global.settings.is_writable('favorite-apps'))
+ return false;
+
+ let id = app.get_id();
+ let isFavorite = AppFavorites.getAppFavorites().isFavorite(id);
+ return isFavorite;
+ }
+
+ setDragApp(app) {
+ let canRemove = this._canRemoveApp(app);
+
+ this.toggleButton.set_hover(canRemove);
+ if (this._iconActor)
+ this._iconActor.set_hover(canRemove);
+
+ if (canRemove)
+ this.setLabelText(_("Remove from Favorites"));
+ else
+ this.setLabelText(_("Show Applications"));
+ }
+
+ handleDragOver(source, _actor, _x, _y, _time) {
+ if (!this._canRemoveApp(getAppFromSource(source)))
+ return DND.DragMotionResult.NO_DROP;
+
+ return DND.DragMotionResult.MOVE_DROP;
+ }
+
+ acceptDrop(source, _actor, _x, _y, _time) {
+ let app = getAppFromSource(source);
+ if (!this._canRemoveApp(app))
+ return false;
+
+ let id = app.get_id();
+
+ Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
+ AppFavorites.getAppFavorites().removeFavorite(id);
+ return false;
+ });
+
+ return true;
+ }
+});
+
+var DragPlaceholderItem = GObject.registerClass(
+class DragPlaceholderItem extends DashItemContainer {
+ _init() {
+ super._init();
+ this.setChild(new St.Bin({ style_class: 'placeholder' }));
+ }
+});
+
+var EmptyDropTargetItem = GObject.registerClass(
+class EmptyDropTargetItem extends DashItemContainer {
+ _init() {
+ super._init();
+ this.setChild(new St.Bin({ style_class: 'empty-dash-drop-target' }));
+ }
+});
+
+var DashActor = GObject.registerClass(
+class DashActor extends St.Widget {
+ _init() {
+ let layout = new Clutter.BoxLayout({ orientation: Clutter.Orientation.VERTICAL });
+ super._init({
+ name: 'dash',
+ layout_manager: layout,
+ clip_to_allocation: true,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ }
+
+ vfunc_allocate(box) {
+ let contentBox = this.get_theme_node().get_content_box(box);
+ let availWidth = contentBox.x2 - contentBox.x1;
+
+ this.set_allocation(box);
+
+ let [appIcons, showAppsButton] = this.get_children();
+ let [, showAppsNatHeight] = showAppsButton.get_preferred_height(availWidth);
+
+ let childBox = new Clutter.ActorBox();
+ childBox.x1 = contentBox.x1;
+ childBox.y1 = contentBox.y1;
+ childBox.x2 = contentBox.x2;
+ childBox.y2 = contentBox.y2 - showAppsNatHeight;
+ appIcons.allocate(childBox);
+
+ childBox.y1 = contentBox.y2 - showAppsNatHeight;
+ childBox.y2 = contentBox.y2;
+ showAppsButton.allocate(childBox);
+ }
+
+ vfunc_get_preferred_height(forWidth) {
+ // We want to request the natural height of all our children
+ // as our natural height, so we chain up to StWidget (which
+ // then calls BoxLayout), but we only request the showApps
+ // button as the minimum size
+
+ let [, natHeight] = super.vfunc_get_preferred_height(forWidth);
+
+ let themeNode = this.get_theme_node();
+ let adjustedForWidth = themeNode.adjust_for_width(forWidth);
+ let [, showAppsButton] = this.get_children();
+ let [minHeight] = showAppsButton.get_preferred_height(adjustedForWidth);
+ [minHeight] = themeNode.adjust_preferred_height(minHeight, natHeight);
+
+ return [minHeight, natHeight];
+ }
+});
+
+const baseIconSizes = [16, 22, 24, 32, 48, 64];
+
+var Dash = GObject.registerClass({
+ Signals: { 'icon-size-changed': {} },
+}, class Dash extends St.Bin {
+ _init() {
+ this._maxHeight = -1;
+ this.iconSize = 64;
+ this._shownInitially = false;
+
+ this._dragPlaceholder = null;
+ this._dragPlaceholderPos = -1;
+ this._animatingPlaceholdersCount = 0;
+ this._showLabelTimeoutId = 0;
+ this._resetHoverTimeoutId = 0;
+ this._labelShowing = false;
+
+ this._container = new DashActor();
+ this._box = new St.BoxLayout({ vertical: true,
+ clip_to_allocation: true });
+ this._box._delegate = this;
+ this._container.add_actor(this._box);
+ this._container.set_offscreen_redirect(Clutter.OffscreenRedirect.ALWAYS);
+
+ this._showAppsIcon = new ShowAppsIcon();
+ this._showAppsIcon.show(false);
+ this._showAppsIcon.icon.setIconSize(this.iconSize);
+ this._hookUpLabel(this._showAppsIcon);
+
+ this.showAppsButton = this._showAppsIcon.toggleButton;
+
+ this._container.add_actor(this._showAppsIcon);
+
+ super._init({ child: this._container });
+ this.connect('notify::height', () => {
+ if (this._maxHeight != this.height)
+ this._queueRedisplay();
+ this._maxHeight = this.height;
+ });
+
+ this._workId = Main.initializeDeferredWork(this._box, this._redisplay.bind(this));
+
+ this._appSystem = Shell.AppSystem.get_default();
+
+ this._appSystem.connect('installed-changed', () => {
+ AppFavorites.getAppFavorites().reload();
+ this._queueRedisplay();
+ });
+ AppFavorites.getAppFavorites().connect('changed', this._queueRedisplay.bind(this));
+ this._appSystem.connect('app-state-changed', this._queueRedisplay.bind(this));
+
+ Main.overview.connect('item-drag-begin',
+ this._onDragBegin.bind(this));
+ Main.overview.connect('item-drag-end',
+ this._onDragEnd.bind(this));
+ Main.overview.connect('item-drag-cancelled',
+ this._onDragCancelled.bind(this));
+
+ // Translators: this is the name of the dock/favorites area on
+ // the left of the overview
+ Main.ctrlAltTabManager.addGroup(this, _("Dash"), 'user-bookmarks-symbolic');
+ }
+
+ _onDragBegin() {
+ this._dragCancelled = false;
+ this._dragMonitor = {
+ dragMotion: this._onDragMotion.bind(this),
+ };
+ DND.addDragMonitor(this._dragMonitor);
+
+ if (this._box.get_n_children() == 0) {
+ this._emptyDropTarget = new EmptyDropTargetItem();
+ this._box.insert_child_at_index(this._emptyDropTarget, 0);
+ this._emptyDropTarget.show(true);
+ }
+ }
+
+ _onDragCancelled() {
+ this._dragCancelled = true;
+ this._endDrag();
+ }
+
+ _onDragEnd() {
+ if (this._dragCancelled)
+ return;
+
+ this._endDrag();
+ }
+
+ _endDrag() {
+ this._clearDragPlaceholder();
+ this._clearEmptyDropTarget();
+ this._showAppsIcon.setDragApp(null);
+ DND.removeDragMonitor(this._dragMonitor);
+ }
+
+ _onDragMotion(dragEvent) {
+ let app = getAppFromSource(dragEvent.source);
+ if (app == null)
+ return DND.DragMotionResult.CONTINUE;
+
+ let showAppsHovered =
+ this._showAppsIcon.contains(dragEvent.targetActor);
+
+ if (!this._box.contains(dragEvent.targetActor) || showAppsHovered)
+ this._clearDragPlaceholder();
+
+ if (showAppsHovered)
+ this._showAppsIcon.setDragApp(app);
+ else
+ this._showAppsIcon.setDragApp(null);
+
+ return DND.DragMotionResult.CONTINUE;
+ }
+
+ _appIdListToHash(apps) {
+ let ids = {};
+ for (let i = 0; i < apps.length; i++)
+ ids[apps[i].get_id()] = apps[i];
+ return ids;
+ }
+
+ _queueRedisplay() {
+ Main.queueDeferredWork(this._workId);
+ }
+
+ _hookUpLabel(item, appIcon) {
+ item.child.connect('notify::hover', () => {
+ this._syncLabel(item, appIcon);
+ });
+
+ item.child.connect('clicked', () => {
+ this._labelShowing = false;
+ item.hideLabel();
+ });
+
+ let id = Main.overview.connect('hiding', () => {
+ this._labelShowing = false;
+ item.hideLabel();
+ });
+ item.child.connect('destroy', () => {
+ Main.overview.disconnect(id);
+ });
+
+ if (appIcon) {
+ appIcon.connect('sync-tooltip', () => {
+ this._syncLabel(item, appIcon);
+ });
+ }
+ }
+
+ _createAppItem(app) {
+ let appIcon = new DashIcon(app);
+
+ appIcon.connect('menu-state-changed',
+ (o, opened) => {
+ this._itemMenuStateChanged(item, opened);
+ });
+
+ let item = new DashItemContainer();
+ item.setChild(appIcon);
+
+ // Override default AppIcon label_actor, now the
+ // accessible_name is set at DashItemContainer.setLabelText
+ appIcon.label_actor = null;
+ item.setLabelText(app.get_name());
+
+ appIcon.icon.setIconSize(this.iconSize);
+ this._hookUpLabel(item, appIcon);
+
+ return item;
+ }
+
+ _itemMenuStateChanged(item, opened) {
+ // When the menu closes, it calls sync_hover, which means
+ // that the notify::hover handler does everything we need to.
+ if (opened) {
+ if (this._showLabelTimeoutId > 0) {
+ GLib.source_remove(this._showLabelTimeoutId);
+ this._showLabelTimeoutId = 0;
+ }
+
+ item.hideLabel();
+ }
+ }
+
+ _syncLabel(item, appIcon) {
+ let shouldShow = appIcon ? appIcon.shouldShowTooltip() : item.child.get_hover();
+
+ if (shouldShow) {
+ if (this._showLabelTimeoutId == 0) {
+ let timeout = this._labelShowing ? 0 : DASH_ITEM_HOVER_TIMEOUT;
+ this._showLabelTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, timeout,
+ () => {
+ this._labelShowing = true;
+ item.showLabel();
+ this._showLabelTimeoutId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._showLabelTimeoutId, '[gnome-shell] item.showLabel');
+ if (this._resetHoverTimeoutId > 0) {
+ GLib.source_remove(this._resetHoverTimeoutId);
+ this._resetHoverTimeoutId = 0;
+ }
+ }
+ } else {
+ if (this._showLabelTimeoutId > 0)
+ GLib.source_remove(this._showLabelTimeoutId);
+ this._showLabelTimeoutId = 0;
+ item.hideLabel();
+ if (this._labelShowing) {
+ this._resetHoverTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, DASH_ITEM_HOVER_TIMEOUT,
+ () => {
+ this._labelShowing = false;
+ this._resetHoverTimeoutId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._resetHoverTimeoutId, '[gnome-shell] this._labelShowing');
+ }
+ }
+ }
+
+ _adjustIconSize() {
+ // For the icon size, we only consider children which are "proper"
+ // icons (i.e. ignoring drag placeholders) and which are not
+ // animating out (which means they will be destroyed at the end of
+ // the animation)
+ let iconChildren = this._box.get_children().filter(actor => {
+ return actor.child &&
+ actor.child._delegate &&
+ actor.child._delegate.icon &&
+ !actor.animatingOut;
+ });
+
+ iconChildren.push(this._showAppsIcon);
+
+ if (this._maxHeight == -1)
+ return;
+
+ let themeNode = this._container.get_theme_node();
+ let maxAllocation = new Clutter.ActorBox({ x1: 0, y1: 0,
+ x2: 42 /* whatever */,
+ y2: this._maxHeight });
+ let maxContent = themeNode.get_content_box(maxAllocation);
+ let availHeight = maxContent.y2 - maxContent.y1;
+ let spacing = themeNode.get_length('spacing');
+
+ let firstButton = iconChildren[0].child;
+ let firstIcon = firstButton._delegate.icon;
+
+ // Enforce valid spacings during the size request
+ firstIcon.icon.ensure_style();
+ let [, iconHeight] = firstIcon.icon.get_preferred_height(-1);
+ let [, buttonHeight] = firstButton.get_preferred_height(-1);
+
+ // Subtract icon padding and box spacing from the available height
+ availHeight -= iconChildren.length * (buttonHeight - iconHeight) +
+ (iconChildren.length - 1) * spacing;
+
+ let availSize = availHeight / iconChildren.length;
+
+ let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ let iconSizes = baseIconSizes.map(s => s * scaleFactor);
+
+ let newIconSize = baseIconSizes[0];
+ for (let i = 0; i < iconSizes.length; i++) {
+ if (iconSizes[i] < availSize)
+ newIconSize = baseIconSizes[i];
+ }
+
+ if (newIconSize == this.iconSize)
+ return;
+
+ let oldIconSize = this.iconSize;
+ this.iconSize = newIconSize;
+ this.emit('icon-size-changed');
+
+ let scale = oldIconSize / newIconSize;
+ for (let i = 0; i < iconChildren.length; i++) {
+ let icon = iconChildren[i].child._delegate.icon;
+
+ // Set the new size immediately, to keep the icons' sizes
+ // in sync with this.iconSize
+ icon.setIconSize(this.iconSize);
+
+ // Don't animate the icon size change when the overview
+ // is transitioning, not visible or when initially filling
+ // the dash
+ if (!Main.overview.visible || Main.overview.animationInProgress ||
+ !this._shownInitially)
+ continue;
+
+ let [targetWidth, targetHeight] = icon.icon.get_size();
+
+ // Scale the icon's texture to the previous size and
+ // tween to the new size
+ icon.icon.set_size(icon.icon.width * scale,
+ icon.icon.height * scale);
+
+ icon.icon.ease({
+ width: targetWidth,
+ height: targetHeight,
+ duration: DASH_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+ }
+
+ _redisplay() {
+ let favorites = AppFavorites.getAppFavorites().getFavoriteMap();
+
+ let running = this._appSystem.get_running();
+
+ let children = this._box.get_children().filter(actor => {
+ return actor.child &&
+ actor.child._delegate &&
+ actor.child._delegate.app;
+ });
+ // Apps currently in the dash
+ let oldApps = children.map(actor => actor.child._delegate.app);
+ // Apps supposed to be in the dash
+ let newApps = [];
+
+ for (let id in favorites)
+ newApps.push(favorites[id]);
+
+ for (let i = 0; i < running.length; i++) {
+ let app = running[i];
+ if (app.get_id() in favorites)
+ continue;
+ newApps.push(app);
+ }
+
+ // Figure out the actual changes to the list of items; we iterate
+ // over both the list of items currently in the dash and the list
+ // of items expected there, and collect additions and removals.
+ // Moves are both an addition and a removal, where the order of
+ // the operations depends on whether we encounter the position
+ // where the item has been added first or the one from where it
+ // was removed.
+ // There is an assumption that only one item is moved at a given
+ // time; when moving several items at once, everything will still
+ // end up at the right position, but there might be additional
+ // additions/removals (e.g. it might remove all the launchers
+ // and add them back in the new order even if a smaller set of
+ // additions and removals is possible).
+ // If above assumptions turns out to be a problem, we might need
+ // to use a more sophisticated algorithm, e.g. Longest Common
+ // Subsequence as used by diff.
+ let addedItems = [];
+ let removedActors = [];
+
+ let newIndex = 0;
+ let oldIndex = 0;
+ while (newIndex < newApps.length || oldIndex < oldApps.length) {
+ let oldApp = oldApps.length > oldIndex ? oldApps[oldIndex] : null;
+ let newApp = newApps.length > newIndex ? newApps[newIndex] : null;
+
+ // No change at oldIndex/newIndex
+ if (oldApp == newApp) {
+ oldIndex++;
+ newIndex++;
+ continue;
+ }
+
+ // App removed at oldIndex
+ if (oldApp && !newApps.includes(oldApp)) {
+ removedActors.push(children[oldIndex]);
+ oldIndex++;
+ continue;
+ }
+
+ // App added at newIndex
+ if (newApp && !oldApps.includes(newApp)) {
+ addedItems.push({ app: newApp,
+ item: this._createAppItem(newApp),
+ pos: newIndex });
+ newIndex++;
+ continue;
+ }
+
+ // App moved
+ let nextApp = newApps.length > newIndex + 1
+ ? newApps[newIndex + 1] : null;
+ let insertHere = nextApp && nextApp == oldApp;
+ let alreadyRemoved = removedActors.reduce((result, actor) => {
+ let removedApp = actor.child._delegate.app;
+ return result || removedApp == newApp;
+ }, false);
+
+ if (insertHere || alreadyRemoved) {
+ let newItem = this._createAppItem(newApp);
+ addedItems.push({ app: newApp,
+ item: newItem,
+ pos: newIndex + removedActors.length });
+ newIndex++;
+ } else {
+ removedActors.push(children[oldIndex]);
+ oldIndex++;
+ }
+ }
+
+ for (let i = 0; i < addedItems.length; i++) {
+ this._box.insert_child_at_index(addedItems[i].item,
+ addedItems[i].pos);
+ }
+
+ for (let i = 0; i < removedActors.length; i++) {
+ let item = removedActors[i];
+
+ // Don't animate item removal when the overview is transitioning
+ // or hidden
+ if (Main.overview.visible && !Main.overview.animationInProgress)
+ item.animateOutAndDestroy();
+ else
+ item.destroy();
+ }
+
+ this._adjustIconSize();
+
+ // Skip animations on first run when adding the initial set
+ // of items, to avoid all items zooming in at once
+
+ let animate = this._shownInitially && Main.overview.visible &&
+ !Main.overview.animationInProgress;
+
+ if (!this._shownInitially)
+ this._shownInitially = true;
+
+ for (let i = 0; i < addedItems.length; i++)
+ addedItems[i].item.show(animate);
+
+ // Workaround for https://bugzilla.gnome.org/show_bug.cgi?id=692744
+ // Without it, StBoxLayout may use a stale size cache
+ this._box.queue_relayout();
+ }
+
+ _clearDragPlaceholder() {
+ if (this._dragPlaceholder) {
+ this._animatingPlaceholdersCount++;
+ this._dragPlaceholder.animateOutAndDestroy();
+ this._dragPlaceholder.connect('destroy', () => {
+ this._animatingPlaceholdersCount--;
+ });
+ this._dragPlaceholder = null;
+ }
+ this._dragPlaceholderPos = -1;
+ }
+
+ _clearEmptyDropTarget() {
+ if (this._emptyDropTarget) {
+ this._emptyDropTarget.animateOutAndDestroy();
+ this._emptyDropTarget = null;
+ }
+ }
+
+ handleDragOver(source, actor, x, y, _time) {
+ let app = getAppFromSource(source);
+
+ // Don't allow favoriting of transient apps
+ if (app == null || app.is_window_backed())
+ return DND.DragMotionResult.NO_DROP;
+
+ if (!global.settings.is_writable('favorite-apps'))
+ return DND.DragMotionResult.NO_DROP;
+
+ let favorites = AppFavorites.getAppFavorites().getFavorites();
+ let numFavorites = favorites.length;
+
+ let favPos = favorites.indexOf(app);
+
+ let children = this._box.get_children();
+ let numChildren = children.length;
+ let boxHeight = this._box.height;
+
+ // Keep the placeholder out of the index calculation; assuming that
+ // the remove target has the same size as "normal" items, we don't
+ // need to do the same adjustment there.
+ if (this._dragPlaceholder) {
+ boxHeight -= this._dragPlaceholder.height;
+ numChildren--;
+ }
+
+ let pos;
+ if (!this._emptyDropTarget)
+ pos = Math.floor(y * numChildren / boxHeight);
+ else
+ pos = 0; // always insert at the top when dash is empty
+
+ // Put the placeholder after the last favorite if we are not
+ // in the favorites zone
+ if (pos > numFavorites)
+ pos = numFavorites;
+
+ if (pos !== this._dragPlaceholderPos && this._animatingPlaceholdersCount === 0) {
+ this._dragPlaceholderPos = pos;
+
+ // Don't allow positioning before or after self
+ if (favPos != -1 && (pos == favPos || pos == favPos + 1)) {
+ this._clearDragPlaceholder();
+ return DND.DragMotionResult.CONTINUE;
+ }
+
+ // If the placeholder already exists, we just move
+ // it, but if we are adding it, expand its size in
+ // an animation
+ let fadeIn;
+ if (this._dragPlaceholder) {
+ this._dragPlaceholder.destroy();
+ fadeIn = false;
+ } else {
+ fadeIn = true;
+ }
+
+ this._dragPlaceholder = new DragPlaceholderItem();
+ this._dragPlaceholder.child.set_width(this.iconSize);
+ this._dragPlaceholder.child.set_height(this.iconSize / 2);
+ this._box.insert_child_at_index(this._dragPlaceholder,
+ this._dragPlaceholderPos);
+ this._dragPlaceholder.show(fadeIn);
+ }
+
+ if (!this._dragPlaceholder)
+ return DND.DragMotionResult.NO_DROP;
+
+ let srcIsFavorite = favPos != -1;
+
+ if (srcIsFavorite)
+ return DND.DragMotionResult.MOVE_DROP;
+
+ return DND.DragMotionResult.COPY_DROP;
+ }
+
+ // Draggable target interface
+ acceptDrop(source, _actor, _x, _y, _time) {
+ let app = getAppFromSource(source);
+
+ // Don't allow favoriting of transient apps
+ if (app == null || app.is_window_backed())
+ return false;
+
+ if (!global.settings.is_writable('favorite-apps'))
+ return false;
+
+ let id = app.get_id();
+
+ let favorites = AppFavorites.getAppFavorites().getFavoriteMap();
+
+ let srcIsFavorite = id in favorites;
+
+ let favPos = 0;
+ let children = this._box.get_children();
+ for (let i = 0; i < this._dragPlaceholderPos; i++) {
+ if (this._dragPlaceholder &&
+ children[i] == this._dragPlaceholder)
+ continue;
+
+ let childId = children[i].child._delegate.app.get_id();
+ if (childId == id)
+ continue;
+ if (childId in favorites)
+ favPos++;
+ }
+
+ // No drag placeholder means we don't want to favorite the app
+ // and we are dragging it to its original position
+ if (!this._dragPlaceholder)
+ return true;
+
+ Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
+ let appFavorites = AppFavorites.getAppFavorites();
+ if (srcIsFavorite)
+ appFavorites.moveFavoriteToPos(id, favPos);
+ else
+ appFavorites.addFavoriteAtPos(id, favPos);
+ return false;
+ });
+
+ return true;
+ }
+});
diff --git a/js/ui/dateMenu.js b/js/ui/dateMenu.js
new file mode 100644
index 0000000..33c054c
--- /dev/null
+++ b/js/ui/dateMenu.js
@@ -0,0 +1,923 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported DateMenuButton */
+
+const { Clutter, Gio, GLib, GnomeDesktop,
+ GObject, GWeather, Pango, Shell, St } = imports.gi;
+
+const Util = imports.misc.util;
+const Main = imports.ui.main;
+const PanelMenu = imports.ui.panelMenu;
+const Calendar = imports.ui.calendar;
+const Weather = imports.misc.weather;
+const System = imports.system;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+const NC_ = (context, str) => '%s\u0004%s'.format(context, str);
+const T_ = Shell.util_translate_time_string;
+
+const MAX_FORECASTS = 5;
+const ELLIPSIS_CHAR = '\u2026';
+
+const ClocksIntegrationIface = loadInterfaceXML('org.gnome.Shell.ClocksIntegration');
+const ClocksProxy = Gio.DBusProxy.makeProxyWrapper(ClocksIntegrationIface);
+
+function _isToday(date) {
+ let now = new Date();
+ return now.getYear() == date.getYear() &&
+ now.getMonth() == date.getMonth() &&
+ now.getDate() == date.getDate();
+}
+
+function _gDateTimeToDate(datetime) {
+ return new Date(datetime.to_unix() * 1000 + datetime.get_microsecond() / 1000);
+}
+
+var TodayButton = GObject.registerClass(
+class TodayButton extends St.Button {
+ _init(calendar) {
+ // Having the ability to go to the current date if the user is already
+ // on the current date can be confusing. So don't make the button reactive
+ // until the selected date changes.
+ super._init({
+ style_class: 'datemenu-today-button',
+ x_expand: true,
+ can_focus: true,
+ reactive: false,
+ });
+
+ let hbox = new St.BoxLayout({ vertical: true });
+ this.add_actor(hbox);
+
+ this._dayLabel = new St.Label({ style_class: 'day-label',
+ x_align: Clutter.ActorAlign.START });
+ hbox.add_actor(this._dayLabel);
+
+ this._dateLabel = new St.Label({ style_class: 'date-label' });
+ hbox.add_actor(this._dateLabel);
+
+ this._calendar = calendar;
+ this._calendar.connect('selected-date-changed', (_calendar, datetime) => {
+ // Make the button reactive only if the selected date is not the
+ // current date.
+ this.reactive = !_isToday(_gDateTimeToDate(datetime));
+ });
+ }
+
+ vfunc_clicked() {
+ this._calendar.setDate(new Date(), false);
+ }
+
+ setDate(date) {
+ this._dayLabel.set_text(date.toLocaleFormat('%A'));
+
+ /* Translators: This is the date format to use when the calendar popup is
+ * shown - it is shown just below the time in the top bar (e.g.,
+ * "Tue 9:29 AM"). The string itself should become a full date, e.g.,
+ * "February 17 2015".
+ */
+ let dateFormat = Shell.util_translate_time_string(N_("%B %-d %Y"));
+ this._dateLabel.set_text(date.toLocaleFormat(dateFormat));
+
+ /* Translators: This is the accessible name of the date button shown
+ * below the time in the shell; it should combine the weekday and the
+ * date, e.g. "Tuesday February 17 2015".
+ */
+ dateFormat = Shell.util_translate_time_string(N_("%A %B %e %Y"));
+ this.accessible_name = date.toLocaleFormat(dateFormat);
+ }
+});
+
+var EventsSection = GObject.registerClass(
+class EventsSection extends St.Button {
+ _init() {
+ super._init({
+ style_class: 'events-button',
+ can_focus: true,
+ x_expand: true,
+ child: new St.BoxLayout({
+ style_class: 'events-box',
+ vertical: true,
+ x_expand: true,
+ }),
+ });
+
+ this._startDate = null;
+ this._endDate = null;
+
+ this._eventSource = null;
+ this._calendarApp = null;
+
+ this._title = new St.Label({
+ style_class: 'events-title',
+ });
+ this.child.add_child(this._title);
+
+ this._eventsList = new St.BoxLayout({
+ style_class: 'events-list',
+ vertical: true,
+ x_expand: true,
+ });
+ this.child.add_child(this._eventsList);
+
+ this._appSys = Shell.AppSystem.get_default();
+ this._appSys.connect('installed-changed',
+ this._appInstalledChanged.bind(this));
+ this._appInstalledChanged();
+ }
+
+ setDate(date) {
+ const day = [date.getFullYear(), date.getMonth(), date.getDate()];
+ this._startDate = new Date(...day);
+ this._endDate = new Date(...day, 23, 59, 59, 999);
+
+ this._updateTitle();
+ this._reloadEvents();
+ }
+
+ setEventSource(eventSource) {
+ if (!(eventSource instanceof Calendar.EventSourceBase))
+ throw new Error('Event source is not valid type');
+
+ this._eventSource = eventSource;
+ this._eventSource.connect('changed', this._reloadEvents.bind(this));
+ this._eventSource.connect('notify::has-calendars',
+ this._sync.bind(this));
+ this._sync();
+ }
+
+ _updateTitle() {
+ /* Translators: Shown on calendar heading when selected day occurs on current year */
+ const sameYearFormat = T_(NC_('calendar heading', '%B %-d'));
+
+ /* Translators: Shown on calendar heading when selected day occurs on different year */
+ const otherYearFormat = T_(NC_('calendar heading', '%B %-d %Y'));
+
+ const timeSpanDay = GLib.TIME_SPAN_DAY / 1000;
+ const now = new Date();
+
+ if (this._startDate <= now && now <= this._endDate)
+ this._title.text = _('Today');
+ else if (this._endDate < now && now - this._endDate < timeSpanDay)
+ this._title.text = _('Yesterday');
+ else if (this._startDate > now && this._startDate - now < timeSpanDay)
+ this._title.text = _('Tomorrow');
+ else if (this._startDate.getFullYear() === now.getFullYear())
+ this._title.text = this._startDate.toLocaleFormat(sameYearFormat);
+ else
+ this._title.text = this._startDate.toLocaleFormat(otherYearFormat);
+ }
+
+ _formatEventTime(event) {
+ const allDay = event.allDay ||
+ (event.date <= this._startDate && event.end >= this._endDate);
+
+ let title;
+ if (allDay) {
+ /* Translators: Shown in calendar event list for all day events
+ * Keep it short, best if you can use less then 10 characters
+ */
+ title = C_('event list time', 'All Day');
+ } else {
+ let date = event.date >= this._startDate ? event.date : event.end;
+ title = Util.formatTime(date, { timeOnly: true });
+ }
+
+ const rtl = Clutter.get_default_text_direction() === Clutter.TextDirection.RTL;
+ if (event.date < this._startDate && !event.allDay) {
+ if (rtl)
+ title = '%s%s'.format(title, ELLIPSIS_CHAR);
+ else
+ title = '%s%s'.format(ELLIPSIS_CHAR, title);
+ }
+ if (event.end > this._endDate && !event.allDay) {
+ if (rtl)
+ title = '%s%s'.format(ELLIPSIS_CHAR, title);
+ else
+ title = '%s%s'.format(title, ELLIPSIS_CHAR);
+ }
+ return title;
+ }
+
+ _reloadEvents() {
+ if (this._eventSource.isLoading || this._reloading)
+ return;
+
+ this._reloading = true;
+
+ [...this._eventsList].forEach(c => c.destroy());
+
+ const events =
+ this._eventSource.getEvents(this._startDate, this._endDate);
+
+ for (let event of events) {
+ const box = new St.BoxLayout({
+ style_class: 'event-box',
+ vertical: true,
+ });
+ box.add(new St.Label({
+ text: event.summary,
+ style_class: 'event-summary',
+ }));
+ box.add(new St.Label({
+ text: this._formatEventTime(event),
+ style_class: 'event-time',
+ }));
+ this._eventsList.add_child(box);
+ }
+
+ if (this._eventsList.get_n_children() === 0) {
+ const placeholder = new St.Label({
+ text: _('No Events'),
+ style_class: 'event-placeholder',
+ });
+ this._eventsList.add_child(placeholder);
+ }
+
+ this._reloading = false;
+ this._sync();
+ }
+
+ vfunc_clicked() {
+ Main.overview.hide();
+ Main.panel.closeCalendar();
+
+ let appInfo = this._calendarApp;
+ if (appInfo.get_id() === 'org.gnome.Evolution.desktop') {
+ const app = this._appSys.lookup_app('evolution-calendar.desktop');
+ if (app)
+ appInfo = app.app_info;
+ }
+ appInfo.launch([], global.create_app_launch_context(0, -1));
+ }
+
+ _appInstalledChanged() {
+ const apps = Gio.AppInfo.get_recommended_for_type('text/calendar');
+ if (apps && (apps.length > 0)) {
+ const app = Gio.AppInfo.get_default_for_type('text/calendar', false);
+ const defaultInRecommended = apps.some(a => a.equal(app));
+ this._calendarApp = defaultInRecommended ? app : apps[0];
+ } else {
+ this._calendarApp = null;
+ }
+
+ return this._sync();
+ }
+
+ _sync() {
+ this.visible = this._eventSource && this._eventSource.hasCalendars;
+ this.reactive = this._calendarApp !== null;
+ }
+});
+
+var WorldClocksSection = GObject.registerClass(
+class WorldClocksSection extends St.Button {
+ _init() {
+ super._init({
+ style_class: 'world-clocks-button',
+ can_focus: true,
+ x_expand: true,
+ });
+ this._clock = new GnomeDesktop.WallClock();
+ this._clockNotifyId = 0;
+ this._tzNotifyId = 0;
+
+ this._locations = [];
+
+ let layout = new Clutter.GridLayout({ orientation: Clutter.Orientation.VERTICAL });
+ this._grid = new St.Widget({ style_class: 'world-clocks-grid',
+ x_expand: true,
+ layout_manager: layout });
+ layout.hookup_style(this._grid);
+
+ this.child = this._grid;
+
+ this._clocksApp = null;
+ this._clocksProxy = new ClocksProxy(
+ Gio.DBus.session,
+ 'org.gnome.clocks',
+ '/org/gnome/clocks',
+ this._onProxyReady.bind(this),
+ null /* cancellable */,
+ Gio.DBusProxyFlags.DO_NOT_AUTO_START | Gio.DBusProxyFlags.GET_INVALIDATED_PROPERTIES);
+
+ this._settings = new Gio.Settings({
+ schema_id: 'org.gnome.shell.world-clocks',
+ });
+ this._settings.connect('changed', this._clocksChanged.bind(this));
+ this._clocksChanged();
+
+ this._appSystem = Shell.AppSystem.get_default();
+ this._appSystem.connect('installed-changed',
+ this._sync.bind(this));
+ this._sync();
+ }
+
+ vfunc_clicked() {
+ if (this._clocksApp)
+ this._clocksApp.activate();
+
+ Main.overview.hide();
+ Main.panel.closeCalendar();
+ }
+
+ _sync() {
+ this._clocksApp = this._appSystem.lookup_app('org.gnome.clocks.desktop');
+ this.visible = this._clocksApp != null;
+ }
+
+ _clocksChanged() {
+ this._grid.destroy_all_children();
+ this._locations = [];
+
+ let world = GWeather.Location.get_world();
+ let clocks = this._settings.get_value('locations').deep_unpack();
+ for (let i = 0; i < clocks.length; i++) {
+ let l = world.deserialize(clocks[i]);
+ if (l && l.get_timezone() != null)
+ this._locations.push({ location: l });
+ }
+
+ this._locations.sort((a, b) => {
+ return a.location.get_timezone().get_offset() -
+ b.location.get_timezone().get_offset();
+ });
+
+ let layout = this._grid.layout_manager;
+ let title = this._locations.length == 0
+ ? _("Add world clocks…")
+ : _("World Clocks");
+ let header = new St.Label({ style_class: 'world-clocks-header',
+ x_align: Clutter.ActorAlign.START,
+ text: title });
+ layout.attach(header, 0, 0, 2, 1);
+ this.label_actor = header;
+
+ for (let i = 0; i < this._locations.length; i++) {
+ let l = this._locations[i].location;
+
+ let name = l.get_city_name() || l.get_name();
+ let label = new St.Label({ style_class: 'world-clocks-city',
+ text: name,
+ x_align: Clutter.ActorAlign.START,
+ y_align: Clutter.ActorAlign.CENTER,
+ x_expand: true });
+
+ let time = new St.Label({ style_class: 'world-clocks-time' });
+
+ const tz = new St.Label({
+ style_class: 'world-clocks-timezone',
+ x_align: Clutter.ActorAlign.END,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+
+ time.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+ tz.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+
+ if (this._grid.text_direction == Clutter.TextDirection.RTL) {
+ layout.attach(tz, 0, i + 1, 1, 1);
+ layout.attach(time, 1, i + 1, 1, 1);
+ layout.attach(label, 2, i + 1, 1, 1);
+ } else {
+ layout.attach(label, 0, i + 1, 1, 1);
+ layout.attach(time, 1, i + 1, 1, 1);
+ layout.attach(tz, 2, i + 1, 1, 1);
+ }
+
+ this._locations[i].timeLabel = time;
+ this._locations[i].tzLabel = tz;
+ }
+
+ if (this._grid.get_n_children() > 1) {
+ if (!this._clockNotifyId) {
+ this._clockNotifyId =
+ this._clock.connect('notify::clock', this._updateTimeLabels.bind(this));
+ }
+ if (!this._tzNotifyId) {
+ this._tzNotifyId =
+ this._clock.connect('notify::timezone', this._updateTimezoneLabels.bind(this));
+ }
+ this._updateTimeLabels();
+ this._updateTimezoneLabels();
+ } else {
+ if (this._clockNotifyId)
+ this._clock.disconnect(this._clockNotifyId);
+ this._clockNotifyId = 0;
+
+ if (this._tzNotifyId)
+ this._clock.disconnect(this._tzNotifyId);
+ this._tzNotifyId = 0;
+ }
+ }
+
+ _getTimezoneOffsetAtLocation(location) {
+ const localOffset = GLib.DateTime.new_now_local().get_utc_offset();
+ const utcOffset = this._getTimeAtLocation(location).get_utc_offset();
+ const offsetCurrentTz = utcOffset - localOffset;
+ const offsetHours = Math.abs(offsetCurrentTz) / GLib.TIME_SPAN_HOUR;
+ const offsetMinutes =
+ (Math.abs(offsetCurrentTz) % GLib.TIME_SPAN_HOUR) /
+ GLib.TIME_SPAN_MINUTE;
+
+ const prefix = offsetCurrentTz >= 0 ? '+' : '-';
+ const text = offsetMinutes === 0
+ ? '%s%d'.format(prefix, offsetHours)
+ : '%s%d\u2236%d'.format(prefix, offsetHours, offsetMinutes);
+ return text;
+ }
+
+ _getTimeAtLocation(location) {
+ let tz = GLib.TimeZone.new(location.get_timezone().get_tzid());
+ return GLib.DateTime.new_now(tz);
+ }
+
+ _updateTimeLabels() {
+ for (let i = 0; i < this._locations.length; i++) {
+ let l = this._locations[i];
+ let now = this._getTimeAtLocation(l.location);
+ l.timeLabel.text = Util.formatTime(now, { timeOnly: true });
+ }
+ }
+
+ _updateTimezoneLabels() {
+ for (let i = 0; i < this._locations.length; i++) {
+ let l = this._locations[i];
+ l.tzLabel.text = this._getTimezoneOffsetAtLocation(l.location);
+ }
+ }
+
+ _onProxyReady(proxy, error) {
+ if (error) {
+ log('Failed to create GNOME Clocks proxy: %s'.format(error));
+ return;
+ }
+
+ this._clocksProxy.connect('g-properties-changed',
+ this._onClocksPropertiesChanged.bind(this));
+ this._onClocksPropertiesChanged();
+ }
+
+ _onClocksPropertiesChanged() {
+ if (this._clocksProxy.g_name_owner == null)
+ return;
+
+ this._settings.set_value('locations',
+ new GLib.Variant('av', this._clocksProxy.Locations));
+ }
+});
+
+var WeatherSection = GObject.registerClass(
+class WeatherSection extends St.Button {
+ _init() {
+ super._init({
+ style_class: 'weather-button',
+ can_focus: true,
+ x_expand: true,
+ });
+
+ this._weatherClient = new Weather.WeatherClient();
+
+ let box = new St.BoxLayout({
+ style_class: 'weather-box',
+ vertical: true,
+ x_expand: true,
+ });
+
+ this.child = box;
+
+ let titleBox = new St.BoxLayout({ style_class: 'weather-header-box' });
+ this._titleLabel = new St.Label({
+ style_class: 'weather-header',
+ x_align: Clutter.ActorAlign.START,
+ x_expand: true,
+ y_align: Clutter.ActorAlign.END,
+ });
+ titleBox.add_child(this._titleLabel);
+ box.add_child(titleBox);
+
+ this._titleLocation = new St.Label({
+ style_class: 'weather-header location',
+ x_align: Clutter.ActorAlign.END,
+ y_align: Clutter.ActorAlign.END,
+ });
+ titleBox.add_child(this._titleLocation);
+
+ let layout = new Clutter.GridLayout({ orientation: Clutter.Orientation.VERTICAL });
+ this._forecastGrid = new St.Widget({
+ style_class: 'weather-grid',
+ layout_manager: layout,
+ });
+ layout.hookup_style(this._forecastGrid);
+ box.add_child(this._forecastGrid);
+
+ this._weatherClient.connect('changed', this._sync.bind(this));
+ this._sync();
+ }
+
+ vfunc_map() {
+ this._weatherClient.update();
+ super.vfunc_map();
+ }
+
+ vfunc_clicked() {
+ this._weatherClient.activateApp();
+
+ Main.overview.hide();
+ Main.panel.closeCalendar();
+ }
+
+ _getInfos() {
+ let forecasts = this._weatherClient.info.get_forecast_list();
+
+ let now = GLib.DateTime.new_now_local();
+ let current = GLib.DateTime.new_from_unix_local(0);
+ let infos = [];
+ for (let i = 0; i < forecasts.length; i++) {
+ const [valid, timestamp] = forecasts[i].get_value_update();
+ if (!valid || timestamp === 0)
+ continue; // 0 means 'never updated'
+
+ const datetime = GLib.DateTime.new_from_unix_local(timestamp);
+ if (now.difference(datetime) > 0)
+ continue; // Ignore earlier forecasts
+
+ if (datetime.difference(current) < GLib.TIME_SPAN_HOUR)
+ continue; // Enforce a minimum interval of 1h
+
+ if (infos.push(forecasts[i]) == MAX_FORECASTS)
+ break; // Use a maximum of five forecasts
+
+ current = datetime;
+ }
+ return infos;
+ }
+
+ _addForecasts() {
+ let layout = this._forecastGrid.layout_manager;
+
+ let infos = this._getInfos();
+ if (this._forecastGrid.text_direction == Clutter.TextDirection.RTL)
+ infos.reverse();
+
+ let col = 0;
+ infos.forEach(fc => {
+ const [valid_, timestamp] = fc.get_value_update();
+ let timeStr = Util.formatTime(new Date(timestamp * 1000), {
+ timeOnly: true,
+ ampm: false,
+ });
+ const [, tempValue] = fc.get_value_temp(GWeather.TemperatureUnit.DEFAULT);
+ const tempPrefix = Math.round(tempValue) >= 0 ? ' ' : '';
+
+ let time = new St.Label({
+ style_class: 'weather-forecast-time',
+ text: timeStr,
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+ let icon = new St.Icon({
+ style_class: 'weather-forecast-icon',
+ icon_name: fc.get_symbolic_icon_name(),
+ x_align: Clutter.ActorAlign.CENTER,
+ x_expand: true,
+ });
+ let temp = new St.Label({
+ style_class: 'weather-forecast-temp',
+ text: '%s%d°'.format(tempPrefix, Math.round(tempValue)),
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+
+ temp.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+ time.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+
+ layout.attach(time, col, 0, 1, 1);
+ layout.attach(icon, col, 1, 1, 1);
+ layout.attach(temp, col, 2, 1, 1);
+ col++;
+ });
+ }
+
+ _setStatusLabel(text) {
+ let layout = this._forecastGrid.layout_manager;
+ let label = new St.Label({ text });
+ layout.attach(label, 0, 0, 1, 1);
+ }
+
+ _findBestLocationName(loc) {
+ const locName = loc.get_name();
+
+ if (loc.get_level() === GWeather.LocationLevel.CITY ||
+ !loc.has_coords())
+ return locName;
+
+ const world = GWeather.Location.get_world();
+ const city = world.find_nearest_city(...loc.get_coords());
+ const cityName = city.get_name();
+
+ return locName.includes(cityName) ? cityName : locName;
+ }
+
+ _updateForecasts() {
+ this._forecastGrid.destroy_all_children();
+
+ if (!this._weatherClient.hasLocation)
+ return;
+
+ const { info } = this._weatherClient;
+ this._titleLocation.text = this._findBestLocationName(info.location);
+
+ if (this._weatherClient.loading) {
+ this._setStatusLabel(_("Loading…"));
+ return;
+ }
+
+ if (info.is_valid()) {
+ this._addForecasts();
+ return;
+ }
+
+ if (info.network_error())
+ this._setStatusLabel(_("Go online for weather information"));
+ else
+ this._setStatusLabel(_("Weather information is currently unavailable"));
+ }
+
+ _sync() {
+ this.visible = this._weatherClient.available;
+
+ if (!this.visible)
+ return;
+
+ if (this._weatherClient.hasLocation)
+ this._titleLabel.text = _('Weather');
+ else
+ this._titleLabel.text = _('Select weather location…');
+
+ this._forecastGrid.visible = this._weatherClient.hasLocation;
+ this._titleLocation.visible = this._weatherClient.hasLocation;
+
+ this._updateForecasts();
+ }
+});
+
+var MessagesIndicator = GObject.registerClass(
+class MessagesIndicator extends St.Icon {
+ _init() {
+ super._init({
+ icon_size: 16,
+ visible: false,
+ y_expand: true,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+
+ this._sources = [];
+ this._count = 0;
+
+ this._settings = new Gio.Settings({
+ schema_id: 'org.gnome.desktop.notifications',
+ });
+ this._settings.connect('changed::show-banners', this._sync.bind(this));
+
+ Main.messageTray.connect('source-added', this._onSourceAdded.bind(this));
+ Main.messageTray.connect('source-removed', this._onSourceRemoved.bind(this));
+ Main.messageTray.connect('queue-changed', this._updateCount.bind(this));
+
+ let sources = Main.messageTray.getSources();
+ sources.forEach(source => this._onSourceAdded(null, source));
+
+ this._sync();
+
+ this.connect('destroy', () => {
+ this._settings.run_dispose();
+ this._settings = null;
+ });
+ }
+
+ _onSourceAdded(tray, source) {
+ source.connect('notify::count', this._updateCount.bind(this));
+ this._sources.push(source);
+ this._updateCount();
+ }
+
+ _onSourceRemoved(tray, source) {
+ this._sources.splice(this._sources.indexOf(source), 1);
+ this._updateCount();
+ }
+
+ _updateCount() {
+ let count = 0;
+ this._sources.forEach(source => (count += source.unseenCount));
+ this._count = count - Main.messageTray.queueCount;
+
+ this._sync();
+ }
+
+ _sync() {
+ let doNotDisturb = !this._settings.get_boolean('show-banners');
+ this.icon_name = doNotDisturb
+ ? 'notifications-disabled-symbolic'
+ : 'message-indicator-symbolic';
+ this.visible = doNotDisturb || this._count > 0;
+ }
+});
+
+var FreezableBinLayout = GObject.registerClass(
+class FreezableBinLayout extends Clutter.BinLayout {
+ _init() {
+ super._init();
+
+ this._frozen = false;
+ this._savedWidth = [NaN, NaN];
+ this._savedHeight = [NaN, NaN];
+ }
+
+ set frozen(v) {
+ if (this._frozen == v)
+ return;
+
+ this._frozen = v;
+ if (!this._frozen)
+ this.layout_changed();
+ }
+
+ vfunc_get_preferred_width(container, forHeight) {
+ if (!this._frozen || this._savedWidth.some(isNaN))
+ return super.vfunc_get_preferred_width(container, forHeight);
+ return this._savedWidth;
+ }
+
+ vfunc_get_preferred_height(container, forWidth) {
+ if (!this._frozen || this._savedHeight.some(isNaN))
+ return super.vfunc_get_preferred_height(container, forWidth);
+ return this._savedHeight;
+ }
+
+ vfunc_allocate(container, allocation) {
+ super.vfunc_allocate(container, allocation);
+
+ let [width, height] = allocation.get_size();
+ this._savedWidth = [width, width];
+ this._savedHeight = [height, height];
+ }
+});
+
+var CalendarColumnLayout = GObject.registerClass(
+class CalendarColumnLayout extends Clutter.BoxLayout {
+ _init(actors) {
+ super._init({ orientation: Clutter.Orientation.VERTICAL });
+ this._colActors = actors;
+ }
+
+ vfunc_get_preferred_width(container, forHeight) {
+ const actors =
+ this._colActors.filter(a => a.get_parent() === container);
+ if (actors.length === 0)
+ return super.vfunc_get_preferred_width(container, forHeight);
+ return actors.reduce(([minAcc, natAcc], child) => {
+ const [min, nat] = child.get_preferred_width(forHeight);
+ return [Math.max(minAcc, min), Math.max(natAcc, nat)];
+ }, [0, 0]);
+ }
+});
+
+var DateMenuButton = GObject.registerClass(
+class DateMenuButton extends PanelMenu.Button {
+ _init() {
+ let hbox;
+ let vbox;
+
+ super._init(0.5);
+
+ this._clockDisplay = new St.Label({ style_class: 'clock' });
+ this._clockDisplay.clutter_text.y_align = Clutter.ActorAlign.CENTER;
+ this._clockDisplay.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+
+ this._indicator = new MessagesIndicator();
+
+ const indicatorPad = new St.Widget();
+ this._indicator.bind_property('visible',
+ indicatorPad, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+ indicatorPad.add_constraint(new Clutter.BindConstraint({
+ source: this._indicator,
+ coordinate: Clutter.BindCoordinate.SIZE,
+ }));
+
+ let box = new St.BoxLayout({ style_class: 'clock-display-box' });
+ box.add_actor(indicatorPad);
+ box.add_actor(this._clockDisplay);
+ box.add_actor(this._indicator);
+
+ this.label_actor = this._clockDisplay;
+ this.add_actor(box);
+ this.add_style_class_name('clock-display');
+
+ let layout = new FreezableBinLayout();
+ let bin = new St.Widget({ layout_manager: layout });
+ // For some minimal compatibility with PopupMenuItem
+ bin._delegate = this;
+ this.menu.box.add_child(bin);
+
+ hbox = new St.BoxLayout({ name: 'calendarArea' });
+ bin.add_actor(hbox);
+
+ this._calendar = new Calendar.Calendar();
+ this._calendar.connect('selected-date-changed', (_calendar, datetime) => {
+ let date = _gDateTimeToDate(datetime);
+ layout.frozen = !_isToday(date);
+ this._eventsItem.setDate(date);
+ });
+ this._date = new TodayButton(this._calendar);
+
+ this.menu.connect('open-state-changed', (menu, isOpen) => {
+ // Whenever the menu is opened, select today
+ if (isOpen) {
+ let now = new Date();
+ this._calendar.setDate(now);
+ this._date.setDate(now);
+ this._eventsItem.setDate(now);
+ }
+ });
+
+ // Fill up the first column
+ this._messageList = new Calendar.CalendarMessageList();
+ hbox.add_child(this._messageList);
+
+ // Fill up the second column
+ const boxLayout = new CalendarColumnLayout([this._calendar, this._date]);
+ vbox = new St.Widget({ style_class: 'datemenu-calendar-column',
+ layout_manager: boxLayout });
+ boxLayout.hookup_style(vbox);
+ hbox.add(vbox);
+
+ vbox.add_actor(this._date);
+ vbox.add_actor(this._calendar);
+
+ this._displaysSection = new St.ScrollView({ style_class: 'datemenu-displays-section vfade',
+ x_expand: true,
+ overlay_scrollbars: true });
+ this._displaysSection.set_policy(St.PolicyType.NEVER, St.PolicyType.EXTERNAL);
+ vbox.add_actor(this._displaysSection);
+
+ let displaysBox = new St.BoxLayout({ vertical: true,
+ x_expand: true,
+ style_class: 'datemenu-displays-box' });
+ this._displaysSection.add_actor(displaysBox);
+
+ this._eventsItem = new EventsSection();
+ displaysBox.add_child(this._eventsItem);
+
+ this._clocksItem = new WorldClocksSection();
+ displaysBox.add_child(this._clocksItem);
+
+ this._weatherItem = new WeatherSection();
+ displaysBox.add_child(this._weatherItem);
+
+ // Done with hbox for calendar and event list
+
+ this._clock = new GnomeDesktop.WallClock();
+ this._clock.bind_property('clock', this._clockDisplay, 'text', GObject.BindingFlags.SYNC_CREATE);
+ this._clock.connect('notify::timezone', this._updateTimeZone.bind(this));
+
+ Main.sessionMode.connect('updated', this._sessionUpdated.bind(this));
+ this._sessionUpdated();
+ }
+
+ _getEventSource() {
+ return new Calendar.DBusEventSource();
+ }
+
+ _setEventSource(eventSource) {
+ if (this._eventSource)
+ this._eventSource.destroy();
+
+ this._calendar.setEventSource(eventSource);
+ this._eventsItem.setEventSource(eventSource);
+
+ this._eventSource = eventSource;
+ }
+
+ _updateTimeZone() {
+ // SpiderMonkey caches the time zone so we must explicitly clear it
+ // before we can update the calendar, see
+ // https://bugzilla.gnome.org/show_bug.cgi?id=678507
+ System.clearDateCaches();
+
+ this._calendar.updateTimeZone();
+ }
+
+ _sessionUpdated() {
+ let eventSource;
+ let showEvents = Main.sessionMode.showCalendarEvents;
+ if (showEvents)
+ eventSource = this._getEventSource();
+ else
+ eventSource = new Calendar.EmptyEventSource();
+
+ this._setEventSource(eventSource);
+
+ // Displays are not actually expected to launch Settings when activated
+ // but the corresponding app (clocks, weather); however we can consider
+ // that display-specific settings, so re-use "allowSettings" here ...
+ this._displaysSection.visible = Main.sessionMode.allowSettings;
+ }
+});
diff --git a/js/ui/dialog.js b/js/ui/dialog.js
new file mode 100644
index 0000000..f2b6fee
--- /dev/null
+++ b/js/ui/dialog.js
@@ -0,0 +1,368 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Dialog, MessageDialogContent, ListSection, ListSectionItem */
+
+const { Clutter, GLib, GObject, Meta, Pango, St } = imports.gi;
+
+function _setLabel(label, value) {
+ label.set({
+ text: value || '',
+ visible: value !== null,
+ });
+}
+
+var Dialog = GObject.registerClass(
+class Dialog extends St.Widget {
+ _init(parentActor, styleClass) {
+ super._init({ layout_manager: new Clutter.BinLayout() });
+ this.connect('destroy', this._onDestroy.bind(this));
+
+ this._initialKeyFocus = null;
+ this._initialKeyFocusDestroyId = 0;
+ this._pressedKey = null;
+ this._buttonKeys = {};
+ this._createDialog();
+ this.add_child(this._dialog);
+
+ if (styleClass != null)
+ this._dialog.add_style_class_name(styleClass);
+
+ this._parentActor = parentActor;
+ this._eventId = this._parentActor.connect('event', this._modalEventHandler.bind(this));
+ this._parentActor.add_child(this);
+ }
+
+ _createDialog() {
+ this._dialog = new St.BoxLayout({
+ style_class: 'modal-dialog',
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER,
+ vertical: true,
+ });
+
+ // modal dialogs are fixed width and grow vertically; set the request
+ // mode accordingly so wrapped labels are handled correctly during
+ // size requests.
+ this._dialog.request_mode = Clutter.RequestMode.HEIGHT_FOR_WIDTH;
+ this._dialog.set_offscreen_redirect(Clutter.OffscreenRedirect.ALWAYS);
+
+ this.contentLayout = new St.BoxLayout({
+ vertical: true,
+ style_class: 'modal-dialog-content-box',
+ y_expand: true,
+ });
+ this._dialog.add_child(this.contentLayout);
+
+ this.buttonLayout = new St.Widget({
+ layout_manager: new Clutter.BoxLayout({ homogeneous: true }),
+ });
+ this._dialog.add_child(this.buttonLayout);
+ }
+
+ makeInactive() {
+ if (this._eventId != 0)
+ this._parentActor.disconnect(this._eventId);
+ this._eventId = 0;
+
+ this.buttonLayout.get_children().forEach(c => c.set_reactive(false));
+ }
+
+ _onDestroy() {
+ this.makeInactive();
+ }
+
+ _modalEventHandler(actor, event) {
+ if (event.type() == Clutter.EventType.KEY_PRESS) {
+ this._pressedKey = event.get_key_symbol();
+ } else if (event.type() == Clutter.EventType.KEY_RELEASE) {
+ let pressedKey = this._pressedKey;
+ this._pressedKey = null;
+
+ let symbol = event.get_key_symbol();
+ if (symbol != pressedKey)
+ return Clutter.EVENT_PROPAGATE;
+
+ let buttonInfo = this._buttonKeys[symbol];
+ if (!buttonInfo)
+ return Clutter.EVENT_PROPAGATE;
+
+ let { button, action } = buttonInfo;
+
+ if (action && button.reactive) {
+ action();
+ return Clutter.EVENT_STOP;
+ }
+ }
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _setInitialKeyFocus(actor) {
+ if (this._initialKeyFocus)
+ this._initialKeyFocus.disconnect(this._initialKeyFocusDestroyId);
+
+ this._initialKeyFocus = actor;
+
+ this._initialKeyFocusDestroyId = actor.connect('destroy', () => {
+ this._initialKeyFocus = null;
+ this._initialKeyFocusDestroyId = 0;
+ });
+ }
+
+ get initialKeyFocus() {
+ return this._initialKeyFocus || this;
+ }
+
+ addButton(buttonInfo) {
+ let { label, action, key } = buttonInfo;
+ let isDefault = buttonInfo['default'];
+ let keys;
+
+ if (key)
+ keys = [key];
+ else if (isDefault)
+ keys = [Clutter.KEY_Return, Clutter.KEY_KP_Enter, Clutter.KEY_ISO_Enter];
+ else
+ keys = [];
+
+ let button = new St.Button({
+ style_class: 'modal-dialog-linked-button',
+ button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE,
+ reactive: true,
+ can_focus: true,
+ x_expand: true,
+ y_expand: true,
+ label,
+ });
+ button.connect('clicked', () => action());
+
+ buttonInfo['button'] = button;
+
+ if (isDefault)
+ button.add_style_pseudo_class('default');
+
+ if (this._initialKeyFocus == null || isDefault)
+ this._setInitialKeyFocus(button);
+
+ for (let i in keys)
+ this._buttonKeys[keys[i]] = buttonInfo;
+
+ this.buttonLayout.add_actor(button);
+
+ return button;
+ }
+
+ clearButtons() {
+ this.buttonLayout.destroy_all_children();
+ this._buttonKeys = {};
+ }
+});
+
+var MessageDialogContent = GObject.registerClass({
+ Properties: {
+ 'title': GObject.ParamSpec.string(
+ 'title', 'title', 'title',
+ GObject.ParamFlags.READWRITE |
+ GObject.ParamFlags.CONSTRUCT,
+ null),
+ 'description': GObject.ParamSpec.string(
+ 'description', 'description', 'description',
+ GObject.ParamFlags.READWRITE |
+ GObject.ParamFlags.CONSTRUCT,
+ null),
+ },
+}, class MessageDialogContent extends St.BoxLayout {
+ _init(params) {
+ this._title = new St.Label({ style_class: 'message-dialog-title' });
+ this._description = new St.Label({ style_class: 'message-dialog-description' });
+
+ this._description.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+ this._description.clutter_text.line_wrap = true;
+
+ let defaultParams = {
+ style_class: 'message-dialog-content',
+ x_expand: true,
+ vertical: true,
+ };
+ super._init(Object.assign(defaultParams, params));
+
+ this.connect('notify::size', this._updateTitleStyle.bind(this));
+ this.connect('destroy', this._onDestroy.bind(this));
+
+ this.add_child(this._title);
+ this.add_child(this._description);
+ }
+
+ _onDestroy() {
+ if (this._updateTitleStyleLater) {
+ Meta.later_remove(this._updateTitleStyleLater);
+ delete this._updateTitleStyleLater;
+ }
+ }
+
+ get title() {
+ return this._title.text;
+ }
+
+ get description() {
+ return this._description.text;
+ }
+
+ _updateTitleStyle() {
+ if (!this._title.mapped)
+ return;
+
+ this._title.ensure_style();
+ const [, titleNatWidth] = this._title.get_preferred_width(-1);
+
+ if (titleNatWidth > this.width) {
+ if (this._updateTitleStyleLater)
+ return;
+
+ this._updateTitleStyleLater = Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
+ this._updateTitleStyleLater = 0;
+ this._title.add_style_class_name('lightweight');
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+
+ }
+
+ set title(title) {
+ if (this._title.text === title)
+ return;
+
+ _setLabel(this._title, title);
+
+ this._title.remove_style_class_name('lightweight');
+ this._updateTitleStyle();
+
+ this.notify('title');
+ }
+
+ set description(description) {
+ if (this._description.text === description)
+ return;
+
+ _setLabel(this._description, description);
+ this.notify('description');
+ }
+});
+
+var ListSection = GObject.registerClass({
+ Properties: {
+ 'title': GObject.ParamSpec.string(
+ 'title', 'title', 'title',
+ GObject.ParamFlags.READWRITE |
+ GObject.ParamFlags.CONSTRUCT,
+ null),
+ },
+}, class ListSection extends St.BoxLayout {
+ _init(params) {
+ this._title = new St.Label({ style_class: 'dialog-list-title' });
+
+ this._listScrollView = new St.ScrollView({
+ style_class: 'dialog-list-scrollview',
+ hscrollbar_policy: St.PolicyType.NEVER,
+ });
+
+ this.list = new St.BoxLayout({
+ style_class: 'dialog-list-box',
+ vertical: true,
+ });
+ this._listScrollView.add_actor(this.list);
+
+ let defaultParams = {
+ style_class: 'dialog-list',
+ x_expand: true,
+ vertical: true,
+ };
+ super._init(Object.assign(defaultParams, params));
+
+ this.label_actor = this._title;
+ this.add_child(this._title);
+ this.add_child(this._listScrollView);
+ }
+
+ get title() {
+ return this._title.text;
+ }
+
+ set title(title) {
+ _setLabel(this._title, title);
+ this.notify('title');
+ }
+});
+
+var ListSectionItem = GObject.registerClass({
+ Properties: {
+ 'icon-actor': GObject.ParamSpec.object(
+ 'icon-actor', 'icon-actor', 'Icon actor',
+ GObject.ParamFlags.READWRITE,
+ Clutter.Actor.$gtype),
+ 'title': GObject.ParamSpec.string(
+ 'title', 'title', 'title',
+ GObject.ParamFlags.READWRITE |
+ GObject.ParamFlags.CONSTRUCT,
+ null),
+ 'description': GObject.ParamSpec.string(
+ 'description', 'description', 'description',
+ GObject.ParamFlags.READWRITE |
+ GObject.ParamFlags.CONSTRUCT,
+ null),
+ },
+}, class ListSectionItem extends St.BoxLayout {
+ _init(params) {
+ this._iconActorBin = new St.Bin();
+
+ let textLayout = new St.BoxLayout({
+ vertical: true,
+ y_expand: true,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+
+ this._title = new St.Label({ style_class: 'dialog-list-item-title' });
+
+ this._description = new St.Label({
+ style_class: 'dialog-list-item-title-description',
+ });
+
+ textLayout.add_child(this._title);
+ textLayout.add_child(this._description);
+
+ let defaultParams = { style_class: 'dialog-list-item' };
+ super._init(Object.assign(defaultParams, params));
+
+ this.label_actor = this._title;
+ this.add_child(this._iconActorBin);
+ this.add_child(textLayout);
+ }
+
+ // eslint-disable-next-line camelcase
+ get icon_actor() {
+ return this._iconActorBin.get_child();
+ }
+
+ // eslint-disable-next-line camelcase
+ set icon_actor(actor) {
+ this._iconActorBin.set_child(actor);
+ this.notify('icon-actor');
+ }
+
+ get title() {
+ return this._title.text;
+ }
+
+ set title(title) {
+ _setLabel(this._title, title);
+ this.notify('title');
+ }
+
+ get description() {
+ return this._description.text;
+ }
+
+ set description(description) {
+ _setLabel(this._description, description);
+ this.notify('description');
+ }
+});
diff --git a/js/ui/dnd.js b/js/ui/dnd.js
new file mode 100644
index 0000000..b1e1680
--- /dev/null
+++ b/js/ui/dnd.js
@@ -0,0 +1,800 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported addDragMonitor, removeDragMonitor, makeDraggable */
+
+const { Clutter, GLib, Meta, Shell, St } = imports.gi;
+const Signals = imports.signals;
+
+const Main = imports.ui.main;
+const Params = imports.misc.params;
+
+// Time to scale down to maxDragActorSize
+var SCALE_ANIMATION_TIME = 250;
+// Time to animate to original position on cancel
+var SNAP_BACK_ANIMATION_TIME = 250;
+// Time to animate to original position on success
+var REVERT_ANIMATION_TIME = 750;
+
+var DragMotionResult = {
+ NO_DROP: 0,
+ COPY_DROP: 1,
+ MOVE_DROP: 2,
+ CONTINUE: 3,
+};
+
+var DragState = {
+ INIT: 0,
+ DRAGGING: 1,
+ CANCELLED: 2,
+};
+
+var DRAG_CURSOR_MAP = {
+ 0: Meta.Cursor.DND_UNSUPPORTED_TARGET,
+ 1: Meta.Cursor.DND_COPY,
+ 2: Meta.Cursor.DND_MOVE,
+};
+
+var DragDropResult = {
+ FAILURE: 0,
+ SUCCESS: 1,
+ CONTINUE: 2,
+};
+var dragMonitors = [];
+
+let eventHandlerActor = null;
+let currentDraggable = null;
+
+function _getEventHandlerActor() {
+ if (!eventHandlerActor) {
+ eventHandlerActor = new Clutter.Actor({ width: 0, height: 0 });
+ Main.uiGroup.add_actor(eventHandlerActor);
+ // We connect to 'event' rather than 'captured-event' because the capturing phase doesn't happen
+ // when you've grabbed the pointer.
+ eventHandlerActor.connect('event', (actor, event) => {
+ return currentDraggable._onEvent(actor, event);
+ });
+ }
+ return eventHandlerActor;
+}
+
+function addDragMonitor(monitor) {
+ dragMonitors.push(monitor);
+}
+
+function removeDragMonitor(monitor) {
+ for (let i = 0; i < dragMonitors.length; i++) {
+ if (dragMonitors[i] == monitor) {
+ dragMonitors.splice(i, 1);
+ return;
+ }
+ }
+}
+
+var _Draggable = class _Draggable {
+ constructor(actor, params) {
+ params = Params.parse(params, { manualMode: false,
+ restoreOnSuccess: false,
+ dragActorMaxSize: undefined,
+ dragActorOpacity: undefined });
+
+ this.actor = actor;
+ this._dragState = DragState.INIT;
+
+ if (!params.manualMode) {
+ this.actor.connect('button-press-event',
+ this._onButtonPress.bind(this));
+ this.actor.connect('touch-event',
+ this._onTouchEvent.bind(this));
+ }
+
+ this.actor.connect('destroy', () => {
+ this._actorDestroyed = true;
+
+ if (this._dragState == DragState.DRAGGING && this._dragCancellable)
+ this._cancelDrag(global.get_current_time());
+ this.disconnectAll();
+ });
+ this._onEventId = null;
+ this._touchSequence = null;
+
+ this._restoreOnSuccess = params.restoreOnSuccess;
+ this._dragActorMaxSize = params.dragActorMaxSize;
+ this._dragActorOpacity = params.dragActorOpacity;
+
+ this._buttonDown = false; // The mouse button has been pressed and has not yet been released.
+ this._animationInProgress = false; // The drag is over and the item is in the process of animating to its original position (snapping back or reverting).
+ this._dragCancellable = true;
+
+ this._eventsGrabbed = false;
+ this._capturedEventId = 0;
+ }
+
+ _onButtonPress(actor, event) {
+ if (event.get_button() != 1)
+ return Clutter.EVENT_PROPAGATE;
+
+ this._buttonDown = true;
+ this._grabActor(event.get_device());
+
+ let [stageX, stageY] = event.get_coords();
+ this._dragStartX = stageX;
+ this._dragStartY = stageY;
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _onTouchEvent(actor, event) {
+ // We only handle touch events here on wayland. On X11
+ // we do get emulated pointer events, which already works
+ // for single-touch cases. Besides, the X11 passive touch grab
+ // set up by Mutter will make us see first the touch events
+ // and later the pointer events, so it will look like two
+ // unrelated series of events, we want to avoid double handling
+ // in these cases.
+ if (!Meta.is_wayland_compositor())
+ return Clutter.EVENT_PROPAGATE;
+
+ if (event.type() != Clutter.EventType.TOUCH_BEGIN ||
+ !global.display.is_pointer_emulating_sequence(event.get_event_sequence()))
+ return Clutter.EVENT_PROPAGATE;
+
+ this._buttonDown = true;
+ this._grabActor(event.get_device(), event.get_event_sequence());
+
+ let [stageX, stageY] = event.get_coords();
+ this._dragStartX = stageX;
+ this._dragStartY = stageY;
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _grabDevice(actor, pointer, touchSequence) {
+ if (touchSequence)
+ pointer.sequence_grab(touchSequence, actor);
+ else if (pointer)
+ pointer.grab(actor);
+
+ this._grabbedDevice = pointer;
+ this._touchSequence = touchSequence;
+
+ this._capturedEventId = global.stage.connect('captured-event', (o, event) => {
+ let device = event.get_device();
+ if (device != this._grabbedDevice &&
+ device.get_device_type() != Clutter.InputDeviceType.KEYBOARD_DEVICE)
+ return Clutter.EVENT_STOP;
+ return Clutter.EVENT_PROPAGATE;
+ });
+ }
+
+ _ungrabDevice() {
+ if (this._capturedEventId != 0) {
+ global.stage.disconnect(this._capturedEventId);
+ this._capturedEventId = 0;
+ }
+
+ if (this._touchSequence)
+ this._grabbedDevice.sequence_ungrab(this._touchSequence);
+ else
+ this._grabbedDevice.ungrab();
+
+ this._touchSequence = null;
+ this._grabbedDevice = null;
+ }
+
+ _grabActor(device, touchSequence) {
+ this._grabDevice(this.actor, device, touchSequence);
+ this._onEventId = this.actor.connect('event',
+ this._onEvent.bind(this));
+ }
+
+ _ungrabActor() {
+ if (!this._onEventId)
+ return;
+
+ this._ungrabDevice();
+ this.actor.disconnect(this._onEventId);
+ this._onEventId = null;
+ }
+
+ _grabEvents(device, touchSequence) {
+ if (!this._eventsGrabbed) {
+ this._eventsGrabbed = Main.pushModal(_getEventHandlerActor());
+ if (this._eventsGrabbed)
+ this._grabDevice(_getEventHandlerActor(), device, touchSequence);
+ }
+ }
+
+ _ungrabEvents() {
+ if (this._eventsGrabbed) {
+ this._ungrabDevice();
+ Main.popModal(_getEventHandlerActor());
+ this._eventsGrabbed = false;
+ }
+ }
+
+ _eventIsRelease(event) {
+ if (event.type() == Clutter.EventType.BUTTON_RELEASE) {
+ let buttonMask = Clutter.ModifierType.BUTTON1_MASK |
+ Clutter.ModifierType.BUTTON2_MASK |
+ Clutter.ModifierType.BUTTON3_MASK;
+ /* We only obey the last button release from the device,
+ * other buttons may get pressed/released during the DnD op.
+ */
+ return (event.get_state() & buttonMask) == 0;
+ } else if (event.type() == Clutter.EventType.TOUCH_END) {
+ /* For touch, we only obey the pointer emulating sequence */
+ return global.display.is_pointer_emulating_sequence(event.get_event_sequence());
+ }
+
+ return false;
+ }
+
+ _onEvent(actor, event) {
+ let device = event.get_device();
+
+ if (this._grabbedDevice &&
+ device != this._grabbedDevice &&
+ device.get_device_type() != Clutter.InputDeviceType.KEYBOARD_DEVICE)
+ return Clutter.EVENT_PROPAGATE;
+
+ // We intercept BUTTON_RELEASE event to know that the button was released in case we
+ // didn't start the drag, to drop the draggable in case the drag was in progress, and
+ // to complete the drag and ensure that whatever happens to be under the pointer does
+ // not get triggered if the drag was cancelled with Esc.
+ if (this._eventIsRelease(event)) {
+ this._buttonDown = false;
+ if (this._dragState == DragState.DRAGGING) {
+ return this._dragActorDropped(event);
+ } else if ((this._dragActor != null || this._dragState == DragState.CANCELLED) &&
+ !this._animationInProgress) {
+ // Drag must have been cancelled with Esc.
+ this._dragComplete();
+ return Clutter.EVENT_STOP;
+ } else {
+ // Drag has never started.
+ this._ungrabActor();
+ return Clutter.EVENT_PROPAGATE;
+ }
+ // We intercept MOTION event to figure out if the drag has started and to draw
+ // this._dragActor under the pointer when dragging is in progress
+ } else if (event.type() == Clutter.EventType.MOTION ||
+ (event.type() == Clutter.EventType.TOUCH_UPDATE &&
+ global.display.is_pointer_emulating_sequence(event.get_event_sequence()))) {
+ if (this._dragActor && this._dragState == DragState.DRAGGING)
+ return this._updateDragPosition(event);
+ else if (this._dragActor == null && this._dragState != DragState.CANCELLED)
+ return this._maybeStartDrag(event);
+
+ // We intercept KEY_PRESS event so that we can process Esc key press to cancel
+ // dragging and ignore all other key presses.
+ } else if (event.type() == Clutter.EventType.KEY_PRESS && this._dragState == DragState.DRAGGING) {
+ let symbol = event.get_key_symbol();
+ if (symbol == Clutter.KEY_Escape) {
+ this._cancelDrag(event.get_time());
+ return Clutter.EVENT_STOP;
+ }
+ }
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ /**
+ * fakeRelease:
+ *
+ * Fake a release event.
+ * Must be called if you want to intercept release events on draggable
+ * actors for other purposes (for example if you're using
+ * PopupMenu.ignoreRelease())
+ */
+ fakeRelease() {
+ this._buttonDown = false;
+ this._ungrabActor();
+ }
+
+ /**
+ * startDrag:
+ * @param {number} stageX: X coordinate of event
+ * @param {number} stageY: Y coordinate of event
+ * @param {number} time: Event timestamp
+ * @param {Clutter.EventSequence=} sequence: Event sequence
+ * @param {Clutter.InputDevice=} device: device that originated the event
+ *
+ * Directly initiate a drag and drop operation from the given actor.
+ * This function is useful to call if you've specified manualMode
+ * for the draggable.
+ */
+ startDrag(stageX, stageY, time, sequence, device) {
+ if (currentDraggable)
+ return;
+
+ if (device == undefined) {
+ let event = Clutter.get_current_event();
+
+ if (event)
+ device = event.get_device();
+
+ if (device == undefined) {
+ let seat = Clutter.get_default_backend().get_default_seat();
+ device = seat.get_pointer();
+ }
+ }
+
+ currentDraggable = this;
+ this._dragState = DragState.DRAGGING;
+
+ // Special-case St.Button: the pointer grab messes with the internal
+ // state, so force a reset to a reasonable state here
+ if (this.actor instanceof St.Button) {
+ this.actor.fake_release();
+ this.actor.hover = false;
+ }
+
+ this.emit('drag-begin', time);
+ if (this._onEventId)
+ this._ungrabActor();
+
+ this._grabEvents(device, sequence);
+ global.display.set_cursor(Meta.Cursor.DND_IN_DRAG);
+
+ this._dragX = this._dragStartX = stageX;
+ this._dragY = this._dragStartY = stageY;
+
+ if (this.actor._delegate && this.actor._delegate.getDragActor) {
+ this._dragActor = this.actor._delegate.getDragActor();
+ Main.uiGroup.add_child(this._dragActor);
+ Main.uiGroup.set_child_above_sibling(this._dragActor, null);
+ Shell.util_set_hidden_from_pick(this._dragActor, true);
+
+ // Drag actor does not always have to be the same as actor. For example drag actor
+ // can be an image that's part of the actor. So to perform "snap back" correctly we need
+ // to know what was the drag actor source.
+ if (this.actor._delegate.getDragActorSource) {
+ this._dragActorSource = this.actor._delegate.getDragActorSource();
+ // If the user dragged from the source, then position
+ // the dragActor over it. Otherwise, center it
+ // around the pointer
+ let [sourceX, sourceY] = this._dragActorSource.get_transformed_position();
+ let x, y;
+ if (stageX > sourceX && stageX <= sourceX + this._dragActor.width &&
+ stageY > sourceY && stageY <= sourceY + this._dragActor.height) {
+ x = sourceX;
+ y = sourceY;
+ } else {
+ x = stageX - this._dragActor.width / 2;
+ y = stageY - this._dragActor.height / 2;
+ }
+ this._dragActor.set_position(x, y);
+ } else {
+ this._dragActorSource = this.actor;
+ }
+ this._dragOrigParent = undefined;
+
+ this._dragOffsetX = this._dragActor.x - this._dragStartX;
+ this._dragOffsetY = this._dragActor.y - this._dragStartY;
+ } else {
+ this._dragActor = this.actor;
+
+ this._dragActorSource = undefined;
+ this._dragOrigParent = this.actor.get_parent();
+ this._dragActorHadFixedPos = this._dragActor.fixed_position_set;
+ this._dragOrigX = this._dragActor.allocation.x1;
+ this._dragOrigY = this._dragActor.allocation.y1;
+ this._dragOrigWidth = this._dragActor.allocation.get_width();
+ this._dragOrigHeight = this._dragActor.allocation.get_height();
+ this._dragOrigScale = this._dragActor.scale_x;
+
+ // When the actor gets reparented to the uiGroup, it will be
+ // allocated its preferred size, so use that size instead of the
+ // current allocation size.
+ const [, newAllocatedWidth] = this._dragActor.get_preferred_width(-1);
+ const [, newAllocatedHeight] = this._dragActor.get_preferred_height(-1);
+
+ const transformedExtents = this._dragActor.get_transformed_extents();
+
+ // Set the actor's scale such that it will keep the same
+ // transformed size when it's reparented to the uiGroup
+ this._dragActor.set_scale(
+ transformedExtents.get_width() / newAllocatedWidth,
+ transformedExtents.get_height() / newAllocatedHeight);
+
+ this._dragOffsetX = transformedExtents.origin.x - this._dragStartX;
+ this._dragOffsetY = transformedExtents.origin.y - this._dragStartY;
+
+ this._dragOrigParent.remove_actor(this._dragActor);
+ Main.uiGroup.add_child(this._dragActor);
+ Main.uiGroup.set_child_above_sibling(this._dragActor, null);
+ Shell.util_set_hidden_from_pick(this._dragActor, true);
+ }
+
+ this._dragActorDestroyId = this._dragActor.connect('destroy', () => {
+ // Cancel ongoing animation (if any)
+ this._finishAnimation();
+
+ this._dragActor = null;
+ if (this._dragState == DragState.DRAGGING)
+ this._dragState = DragState.CANCELLED;
+ });
+ this._dragOrigOpacity = this._dragActor.opacity;
+ if (this._dragActorOpacity != undefined)
+ this._dragActor.opacity = this._dragActorOpacity;
+
+ this._snapBackX = this._dragStartX + this._dragOffsetX;
+ this._snapBackY = this._dragStartY + this._dragOffsetY;
+ this._snapBackScale = this._dragActor.scale_x;
+
+ let origDragOffsetX = this._dragOffsetX;
+ let origDragOffsetY = this._dragOffsetY;
+ let [transX, transY] = this._dragActor.get_translation();
+ this._dragOffsetX -= transX;
+ this._dragOffsetY -= transY;
+
+ this._dragActor.set_position(
+ this._dragX + this._dragOffsetX,
+ this._dragY + this._dragOffsetY);
+
+ if (this._dragActorMaxSize != undefined) {
+ let [scaledWidth, scaledHeight] = this._dragActor.get_transformed_size();
+ let currentSize = Math.max(scaledWidth, scaledHeight);
+ if (currentSize > this._dragActorMaxSize) {
+ let scale = this._dragActorMaxSize / currentSize;
+ let origScale = this._dragActor.scale_x;
+
+ // The position of the actor changes as we scale
+ // around the drag position, but we can't just tween
+ // to the final position because that tween would
+ // fight with updates as the user continues dragging
+ // the mouse; instead we do the position computations in
+ // a ::new-frame handler.
+ this._dragActor.ease({
+ scale_x: scale * origScale,
+ scale_y: scale * origScale,
+ duration: SCALE_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+
+ this._dragActor.get_transition('scale-x').connect('new-frame', () => {
+ let currentScale = this._dragActor.scale_x / origScale;
+ this._dragOffsetX = currentScale * origDragOffsetX - transX;
+ this._dragOffsetY = currentScale * origDragOffsetY - transY;
+ this._dragActor.set_position(
+ this._dragX + this._dragOffsetX,
+ this._dragY + this._dragOffsetY);
+ });
+ }
+ }
+ }
+
+ _maybeStartDrag(event) {
+ let [stageX, stageY] = event.get_coords();
+
+ // See if the user has moved the mouse enough to trigger a drag
+ let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ let threshold = St.Settings.get().drag_threshold * scaleFactor;
+ if (!currentDraggable &&
+ (Math.abs(stageX - this._dragStartX) > threshold ||
+ Math.abs(stageY - this._dragStartY) > threshold)) {
+ this.startDrag(stageX, stageY, event.get_time(), this._touchSequence, event.get_device());
+ this._updateDragPosition(event);
+ }
+
+ return true;
+ }
+
+ _pickTargetActor() {
+ return this._dragActor.get_stage().get_actor_at_pos(Clutter.PickMode.ALL,
+ this._dragX, this._dragY);
+ }
+
+ _updateDragHover() {
+ this._updateHoverId = 0;
+ let target = this._pickTargetActor();
+
+ let dragEvent = {
+ x: this._dragX,
+ y: this._dragY,
+ dragActor: this._dragActor,
+ source: this.actor._delegate,
+ targetActor: target,
+ };
+
+ let targetActorDestroyHandlerId;
+ let handleTargetActorDestroyClosure;
+ handleTargetActorDestroyClosure = () => {
+ target = this._pickTargetActor();
+ dragEvent.targetActor = target;
+ targetActorDestroyHandlerId =
+ target.connect('destroy', handleTargetActorDestroyClosure);
+ };
+ targetActorDestroyHandlerId =
+ target.connect('destroy', handleTargetActorDestroyClosure);
+
+ for (let i = 0; i < dragMonitors.length; i++) {
+ let motionFunc = dragMonitors[i].dragMotion;
+ if (motionFunc) {
+ let result = motionFunc(dragEvent);
+ if (result != DragMotionResult.CONTINUE) {
+ global.display.set_cursor(DRAG_CURSOR_MAP[result]);
+ return GLib.SOURCE_REMOVE;
+ }
+ }
+ }
+ dragEvent.targetActor.disconnect(targetActorDestroyHandlerId);
+
+ while (target) {
+ if (target._delegate && target._delegate.handleDragOver) {
+ let [r_, targX, targY] = target.transform_stage_point(this._dragX, this._dragY);
+ // We currently loop through all parents on drag-over even if one of the children has handled it.
+ // We can check the return value of the function and break the loop if it's true if we don't want
+ // to continue checking the parents.
+ let result = target._delegate.handleDragOver(this.actor._delegate,
+ this._dragActor,
+ targX,
+ targY,
+ 0);
+ if (result != DragMotionResult.CONTINUE) {
+ global.display.set_cursor(DRAG_CURSOR_MAP[result]);
+ return GLib.SOURCE_REMOVE;
+ }
+ }
+ target = target.get_parent();
+ }
+ global.display.set_cursor(Meta.Cursor.DND_IN_DRAG);
+ return GLib.SOURCE_REMOVE;
+ }
+
+ _queueUpdateDragHover() {
+ if (this._updateHoverId)
+ return;
+
+ this._updateHoverId = GLib.idle_add(GLib.PRIORITY_DEFAULT,
+ this._updateDragHover.bind(this));
+ GLib.Source.set_name_by_id(this._updateHoverId, '[gnome-shell] this._updateDragHover');
+ }
+
+ _updateDragPosition(event) {
+ let [stageX, stageY] = event.get_coords();
+ this._dragX = stageX;
+ this._dragY = stageY;
+ this._dragActor.set_position(stageX + this._dragOffsetX,
+ stageY + this._dragOffsetY);
+
+ this._queueUpdateDragHover();
+ return true;
+ }
+
+ _dragActorDropped(event) {
+ let [dropX, dropY] = event.get_coords();
+ let target = this._dragActor.get_stage().get_actor_at_pos(Clutter.PickMode.ALL,
+ dropX, dropY);
+
+ // We call observers only once per motion with the innermost
+ // target actor. If necessary, the observer can walk the
+ // parent itself.
+ let dropEvent = {
+ dropActor: this._dragActor,
+ targetActor: target,
+ clutterEvent: event,
+ };
+ for (let i = 0; i < dragMonitors.length; i++) {
+ let dropFunc = dragMonitors[i].dragDrop;
+ if (dropFunc) {
+ switch (dropFunc(dropEvent)) {
+ case DragDropResult.FAILURE:
+ case DragDropResult.SUCCESS:
+ return true;
+ case DragDropResult.CONTINUE:
+ continue;
+ }
+ }
+ }
+
+ // At this point it is too late to cancel a drag by destroying
+ // the actor, the fate of which is decided by acceptDrop and its
+ // side-effects
+ this._dragCancellable = false;
+
+ while (target) {
+ if (target._delegate && target._delegate.acceptDrop) {
+ let [r_, targX, targY] = target.transform_stage_point(dropX, dropY);
+ let accepted = false;
+ try {
+ accepted = target._delegate.acceptDrop(this.actor._delegate,
+ this._dragActor, targX, targY, event.get_time());
+ } catch (e) {
+ // On error, skip this target
+ logError(e, "Skipping drag target");
+ }
+ if (accepted) {
+ // If it accepted the drop without taking the actor,
+ // handle it ourselves.
+ if (this._dragActor && this._dragActor.get_parent() == Main.uiGroup) {
+ if (this._restoreOnSuccess) {
+ this._restoreDragActor(event.get_time());
+ return true;
+ } else {
+ this._dragActor.destroy();
+ }
+ }
+
+ this._dragState = DragState.INIT;
+ global.display.set_cursor(Meta.Cursor.DEFAULT);
+ this.emit('drag-end', event.get_time(), true);
+ this._dragComplete();
+ return true;
+ }
+ }
+ target = target.get_parent();
+ }
+
+ this._cancelDrag(event.get_time());
+
+ return true;
+ }
+
+ _getRestoreLocation() {
+ let x, y, scale;
+
+ if (this._dragActorSource && this._dragActorSource.visible) {
+ // Snap the clone back to its source
+ [x, y] = this._dragActorSource.get_transformed_position();
+ let [sourceScaledWidth] = this._dragActorSource.get_transformed_size();
+ scale = sourceScaledWidth ? sourceScaledWidth / this._dragActor.width : 0;
+ } else if (this._dragOrigParent) {
+ // Snap the actor back to its original position within
+ // its parent, adjusting for the fact that the parent
+ // may have been moved or scaled
+ let [parentX, parentY] = this._dragOrigParent.get_transformed_position();
+ let [parentWidth] = this._dragOrigParent.get_size();
+ let [parentScaledWidth] = this._dragOrigParent.get_transformed_size();
+ let parentScale = 1.0;
+ if (parentWidth != 0)
+ parentScale = parentScaledWidth / parentWidth;
+
+ // Also adjust for the difference in the original actor width
+ // and the width it is now (children of uiGroup always get
+ // allocated their preferred size)
+ const childScaleX =
+ this._dragOrigWidth / this._dragActor.allocation.get_width();
+
+ x = parentX + parentScale * this._dragOrigX;
+ y = parentY + parentScale * this._dragOrigY;
+ scale = this._dragOrigScale * parentScale * childScaleX;
+ } else {
+ // Snap back actor to its original stage position
+ x = this._snapBackX;
+ y = this._snapBackY;
+ scale = this._snapBackScale;
+ }
+
+ return [x, y, scale];
+ }
+
+ _cancelDrag(eventTime) {
+ this.emit('drag-cancelled', eventTime);
+ let wasCancelled = this._dragState == DragState.CANCELLED;
+ this._dragState = DragState.CANCELLED;
+
+ if (this._actorDestroyed || wasCancelled) {
+ global.display.set_cursor(Meta.Cursor.DEFAULT);
+ if (!this._buttonDown)
+ this._dragComplete();
+ this.emit('drag-end', eventTime, false);
+ if (!this._dragOrigParent && this._dragActor)
+ this._dragActor.destroy();
+
+ return;
+ }
+
+ let [snapBackX, snapBackY, snapBackScale] = this._getRestoreLocation();
+
+ this._animateDragEnd(eventTime, {
+ x: snapBackX,
+ y: snapBackY,
+ scale_x: snapBackScale,
+ scale_y: snapBackScale,
+ duration: SNAP_BACK_ANIMATION_TIME,
+ });
+ }
+
+ _restoreDragActor(eventTime) {
+ this._dragState = DragState.INIT;
+ let [restoreX, restoreY, restoreScale] = this._getRestoreLocation();
+
+ // fade the actor back in at its original location
+ this._dragActor.set_position(restoreX, restoreY);
+ this._dragActor.set_scale(restoreScale, restoreScale);
+ this._dragActor.opacity = 0;
+
+ this._animateDragEnd(eventTime, {
+ duration: REVERT_ANIMATION_TIME,
+ });
+ }
+
+ _animateDragEnd(eventTime, params) {
+ this._animationInProgress = true;
+
+ // start the animation
+ this._dragActor.ease(Object.assign(params, {
+ opacity: this._dragOrigOpacity,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ this._onAnimationComplete(this._dragActor, eventTime);
+ },
+ }));
+ }
+
+ _finishAnimation() {
+ if (!this._animationInProgress)
+ return;
+
+ this._animationInProgress = false;
+ if (!this._buttonDown)
+ this._dragComplete();
+
+ global.display.set_cursor(Meta.Cursor.DEFAULT);
+ }
+
+ _onAnimationComplete(dragActor, eventTime) {
+ if (this._dragOrigParent) {
+ Main.uiGroup.remove_child(this._dragActor);
+ this._dragOrigParent.add_actor(this._dragActor);
+ dragActor.set_scale(this._dragOrigScale, this._dragOrigScale);
+ if (this._dragActorHadFixedPos)
+ dragActor.set_position(this._dragOrigX, this._dragOrigY);
+ else
+ dragActor.fixed_position_set = false;
+ } else {
+ dragActor.destroy();
+ }
+
+ this.emit('drag-end', eventTime, false);
+ this._finishAnimation();
+ }
+
+ _dragComplete() {
+ if (!this._actorDestroyed && this._dragActor)
+ Shell.util_set_hidden_from_pick(this._dragActor, false);
+
+ this._ungrabEvents();
+ global.sync_pointer();
+
+ if (this._updateHoverId) {
+ GLib.source_remove(this._updateHoverId);
+ this._updateHoverId = 0;
+ }
+
+ if (this._dragActor) {
+ this._dragActor.disconnect(this._dragActorDestroyId);
+ this._dragActor = null;
+ }
+
+ this._dragState = DragState.INIT;
+ currentDraggable = null;
+ }
+};
+Signals.addSignalMethods(_Draggable.prototype);
+
+/**
+ * makeDraggable:
+ * @param {Clutter.Actor} actor: Source actor
+ * @param {Object=} params: Additional parameters
+ * @returns {Object} a new Draggable
+ *
+ * Create an object which controls drag and drop for the given actor.
+ *
+ * If %manualMode is %true in @params, do not automatically start
+ * drag and drop on click
+ *
+ * If %dragActorMaxSize is present in @params, the drag actor will
+ * be scaled down to be no larger than that size in pixels.
+ *
+ * If %dragActorOpacity is present in @params, the drag actor will
+ * will be set to have that opacity during the drag.
+ *
+ * Note that when the drag actor is the source actor and the drop
+ * succeeds, the actor scale and opacity aren't reset; if the drop
+ * target wants to reuse the actor, it's up to the drop target to
+ * reset these values.
+ */
+function makeDraggable(actor, params) {
+ return new _Draggable(actor, params);
+}
diff --git a/js/ui/edgeDragAction.js b/js/ui/edgeDragAction.js
new file mode 100644
index 0000000..8502170
--- /dev/null
+++ b/js/ui/edgeDragAction.js
@@ -0,0 +1,77 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported EdgeDragAction */
+
+const { Clutter, GObject, Meta, St } = imports.gi;
+
+const Main = imports.ui.main;
+
+var EDGE_THRESHOLD = 20;
+var DRAG_DISTANCE = 80;
+
+var EdgeDragAction = GObject.registerClass({
+ Signals: { 'activated': {} },
+}, class EdgeDragAction extends Clutter.GestureAction {
+ _init(side, allowedModes) {
+ super._init();
+ this._side = side;
+ this._allowedModes = allowedModes;
+ this.set_n_touch_points(1);
+
+ global.display.connect('grab-op-begin', () => this.cancel());
+ }
+
+ _getMonitorRect(x, y) {
+ let rect = new Meta.Rectangle({ x: x - 1, y: y - 1, width: 1, height: 1 });
+ let monitorIndex = global.display.get_monitor_index_for_rect(rect);
+
+ return global.display.get_monitor_geometry(monitorIndex);
+ }
+
+ vfunc_gesture_prepare(_actor) {
+ if (this.get_n_current_points() == 0)
+ return false;
+
+ if (!(this._allowedModes & Main.actionMode))
+ return false;
+
+ let [x, y] = this.get_press_coords(0);
+ let monitorRect = this._getMonitorRect(x, y);
+
+ return (this._side == St.Side.LEFT && x < monitorRect.x + EDGE_THRESHOLD) ||
+ (this._side == St.Side.RIGHT && x > monitorRect.x + monitorRect.width - EDGE_THRESHOLD) ||
+ (this._side == St.Side.TOP && y < monitorRect.y + EDGE_THRESHOLD) ||
+ (this._side == St.Side.BOTTOM && y > monitorRect.y + monitorRect.height - EDGE_THRESHOLD);
+ }
+
+ vfunc_gesture_progress(_actor) {
+ let [startX, startY] = this.get_press_coords(0);
+ let [x, y] = this.get_motion_coords(0);
+ let offsetX = Math.abs(x - startX);
+ let offsetY = Math.abs(y - startY);
+
+ if (offsetX < EDGE_THRESHOLD && offsetY < EDGE_THRESHOLD)
+ return true;
+
+ if ((offsetX > offsetY &&
+ (this._side == St.Side.TOP || this._side == St.Side.BOTTOM)) ||
+ (offsetY > offsetX &&
+ (this._side == St.Side.LEFT || this._side == St.Side.RIGHT))) {
+ this.cancel();
+ return false;
+ }
+
+ return true;
+ }
+
+ vfunc_gesture_end(_actor) {
+ let [startX, startY] = this.get_press_coords(0);
+ let [x, y] = this.get_motion_coords(0);
+ let monitorRect = this._getMonitorRect(startX, startY);
+
+ if ((this._side == St.Side.TOP && y > monitorRect.y + DRAG_DISTANCE) ||
+ (this._side == St.Side.BOTTOM && y < monitorRect.y + monitorRect.height - DRAG_DISTANCE) ||
+ (this._side == St.Side.LEFT && x > monitorRect.x + DRAG_DISTANCE) ||
+ (this._side == St.Side.RIGHT && x < monitorRect.x + monitorRect.width - DRAG_DISTANCE))
+ this.emit('activated');
+ }
+});
diff --git a/js/ui/endSessionDialog.js b/js/ui/endSessionDialog.js
new file mode 100644
index 0000000..ebf4f4d
--- /dev/null
+++ b/js/ui/endSessionDialog.js
@@ -0,0 +1,810 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported init, EndSessionDialog */
+/*
+ * Copyright 2010-2016 Red Hat, Inc
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2, or (at your option)
+ * any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see <http://www.gnu.org/licenses/>.
+ */
+
+const { AccountsService, Clutter, Gio,
+ GLib, GObject, Pango, Polkit, Shell, St, UPowerGlib: UPower } = imports.gi;
+
+const CheckBox = imports.ui.checkBox;
+const Dialog = imports.ui.dialog;
+const GnomeSession = imports.misc.gnomeSession;
+const LoginManager = imports.misc.loginManager;
+const ModalDialog = imports.ui.modalDialog;
+const UserWidget = imports.ui.userWidget;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+const _ITEM_ICON_SIZE = 64;
+
+const LOW_BATTERY_THRESHOLD = 30;
+
+const EndSessionDialogIface = loadInterfaceXML('org.gnome.SessionManager.EndSessionDialog');
+
+const logoutDialogContent = {
+ subjectWithUser: C_("title", "Log Out %s"),
+ subject: C_("title", "Log Out"),
+ descriptionWithUser(user, seconds) {
+ return ngettext(
+ '%s will be logged out automatically in %d second.',
+ '%s will be logged out automatically in %d seconds.',
+ seconds).format(user, seconds);
+ },
+ description(seconds) {
+ return ngettext(
+ 'You will be logged out automatically in %d second.',
+ 'You will be logged out automatically in %d seconds.',
+ seconds).format(seconds);
+ },
+ showBatteryWarning: false,
+ confirmButtons: [{
+ signal: 'ConfirmedLogout',
+ label: C_('button', 'Log Out'),
+ }],
+ showOtherSessions: false,
+};
+
+const shutdownDialogContent = {
+ subject: C_("title", "Power Off"),
+ subjectWithUpdates: C_("title", "Install Updates & Power Off"),
+ description(seconds) {
+ return ngettext(
+ 'The system will power off automatically in %d second.',
+ 'The system will power off automatically in %d seconds.',
+ seconds).format(seconds);
+ },
+ checkBoxText: C_("checkbox", "Install pending software updates"),
+ showBatteryWarning: true,
+ confirmButtons: [{
+ signal: 'ConfirmedShutdown',
+ label: C_('button', 'Power Off'),
+ }],
+ iconName: 'system-shutdown-symbolic',
+ showOtherSessions: true,
+};
+
+const restartDialogContent = {
+ subject: C_("title", "Restart"),
+ subjectWithUpdates: C_('title', 'Install Updates & Restart'),
+ description(seconds) {
+ return ngettext(
+ 'The system will restart automatically in %d second.',
+ 'The system will restart automatically in %d seconds.',
+ seconds).format(seconds);
+ },
+ checkBoxText: C_('checkbox', 'Install pending software updates'),
+ showBatteryWarning: true,
+ confirmButtons: [{
+ signal: 'ConfirmedReboot',
+ label: C_('button', 'Restart'),
+ }],
+ iconName: 'view-refresh-symbolic',
+ showOtherSessions: true,
+};
+
+const restartUpdateDialogContent = {
+
+ subject: C_("title", "Restart & Install Updates"),
+ description(seconds) {
+ return ngettext(
+ 'The system will automatically restart and install updates in %d second.',
+ 'The system will automatically restart and install updates in %d seconds.',
+ seconds).format(seconds);
+ },
+ showBatteryWarning: true,
+ confirmButtons: [{
+ signal: 'ConfirmedReboot',
+ label: C_('button', 'Restart &amp; Install'),
+ }],
+ unusedFutureButtonForTranslation: C_("button", "Install &amp; Power Off"),
+ unusedFutureCheckBoxForTranslation: C_("checkbox", "Power off after updates are installed"),
+ iconName: 'view-refresh-symbolic',
+ showOtherSessions: true,
+};
+
+const restartUpgradeDialogContent = {
+
+ subject: C_("title", "Restart & Install Upgrade"),
+ upgradeDescription(distroName, distroVersion) {
+ /* Translators: This is the text displayed for system upgrades in the
+ shut down dialog. First %s gets replaced with the distro name and
+ second %s with the distro version to upgrade to */
+ return _("%s %s will be installed after restart. Upgrade installation can take a long time: ensure that you have backed up and that the computer is plugged in.").format(distroName, distroVersion);
+ },
+ disableTimer: true,
+ showBatteryWarning: false,
+ confirmButtons: [{
+ signal: 'ConfirmedReboot',
+ label: C_('button', 'Restart &amp; Install'),
+ }],
+ iconName: 'view-refresh-symbolic',
+ showOtherSessions: true,
+};
+
+const DialogType = {
+ LOGOUT: 0 /* GSM_SHELL_END_SESSION_DIALOG_TYPE_LOGOUT */,
+ SHUTDOWN: 1 /* GSM_SHELL_END_SESSION_DIALOG_TYPE_SHUTDOWN */,
+ RESTART: 2 /* GSM_SHELL_END_SESSION_DIALOG_TYPE_RESTART */,
+ UPDATE_RESTART: 3,
+ UPGRADE_RESTART: 4,
+};
+
+const DialogContent = {
+ 0 /* DialogType.LOGOUT */: logoutDialogContent,
+ 1 /* DialogType.SHUTDOWN */: shutdownDialogContent,
+ 2 /* DialogType.RESTART */: restartDialogContent,
+ 3 /* DialogType.UPDATE_RESTART */: restartUpdateDialogContent,
+ 4 /* DialogType.UPGRADE_RESTART */: restartUpgradeDialogContent,
+};
+
+var MAX_USERS_IN_SESSION_DIALOG = 5;
+
+const LogindSessionIface = loadInterfaceXML('org.freedesktop.login1.Session');
+const LogindSession = Gio.DBusProxy.makeProxyWrapper(LogindSessionIface);
+
+const PkOfflineIface = loadInterfaceXML('org.freedesktop.PackageKit.Offline');
+const PkOfflineProxy = Gio.DBusProxy.makeProxyWrapper(PkOfflineIface);
+
+const UPowerIface = loadInterfaceXML('org.freedesktop.UPower.Device');
+const UPowerProxy = Gio.DBusProxy.makeProxyWrapper(UPowerIface);
+
+function findAppFromInhibitor(inhibitor) {
+ let desktopFile;
+ try {
+ [desktopFile] = inhibitor.GetAppIdSync();
+ } catch (e) {
+ // XXX -- sometimes JIT inhibitors generated by gnome-session
+ // get removed too soon. Don't fail in this case.
+ log('gnome-session gave us a dead inhibitor: %s'.format(inhibitor.get_object_path()));
+ return null;
+ }
+
+ if (!GLib.str_has_suffix(desktopFile, '.desktop'))
+ desktopFile += '.desktop';
+
+ return Shell.AppSystem.get_default().lookup_heuristic_basename(desktopFile);
+}
+
+// The logout timer only shows updates every 10 seconds
+// until the last 10 seconds, then it shows updates every
+// second. This function takes a given time and returns
+// what we should show to the user for that time.
+function _roundSecondsToInterval(totalSeconds, secondsLeft, interval) {
+ let time;
+
+ time = Math.ceil(secondsLeft);
+
+ // Final count down is in decrements of 1
+ if (time <= interval)
+ return time;
+
+ // Round up higher than last displayable time interval
+ time += interval - 1;
+
+ // Then round down to that time interval
+ if (time > totalSeconds)
+ time = Math.ceil(totalSeconds);
+ else
+ time -= time % interval;
+
+ return time;
+}
+
+function _setCheckBoxLabel(checkBox, text) {
+ let label = checkBox.getLabelActor();
+
+ if (text) {
+ label.set_text(text);
+ checkBox.show();
+ } else {
+ label.set_text('');
+ checkBox.hide();
+ }
+}
+
+function init() {
+ // This always returns the same singleton object
+ // By instantiating it initially, we register the
+ // bus object, etc.
+ new EndSessionDialog();
+}
+
+var EndSessionDialog = GObject.registerClass(
+class EndSessionDialog extends ModalDialog.ModalDialog {
+ _init() {
+ super._init({ styleClass: 'end-session-dialog',
+ destroyOnClose: false });
+
+ this._loginManager = LoginManager.getLoginManager();
+ this._loginManager.canRebootToBootLoaderMenu(
+ (canRebootToBootLoaderMenu, unusedNeedsAuth) => {
+ this._canRebootToBootLoaderMenu = canRebootToBootLoaderMenu;
+ });
+
+ this._userManager = AccountsService.UserManager.get_default();
+ this._user = this._userManager.get_user(GLib.get_user_name());
+ this._updatesPermission = null;
+
+ this._pkOfflineProxy = new PkOfflineProxy(Gio.DBus.system,
+ 'org.freedesktop.PackageKit',
+ '/org/freedesktop/PackageKit',
+ this._onPkOfflineProxyCreated.bind(this));
+
+ this._powerProxy = new UPowerProxy(Gio.DBus.system,
+ 'org.freedesktop.UPower',
+ '/org/freedesktop/UPower/devices/DisplayDevice',
+ (proxy, error) => {
+ if (error) {
+ log(error.message);
+ return;
+ }
+ this._powerProxy.connect('g-properties-changed',
+ this._sync.bind(this));
+ this._sync();
+ });
+
+ this._secondsLeft = 0;
+ this._totalSecondsToStayOpen = 0;
+ this._applications = [];
+ this._sessions = [];
+ this._capturedEventId = 0;
+ this._rebootButton = null;
+ this._rebootButtonAlt = null;
+
+ this.connect('destroy',
+ this._onDestroy.bind(this));
+ this.connect('opened',
+ this._onOpened.bind(this));
+
+ this._userLoadedId = this._user.connect('notify::is-loaded', this._sync.bind(this));
+ this._userChangedId = this._user.connect('changed', this._sync.bind(this));
+
+ this._messageDialogContent = new Dialog.MessageDialogContent();
+
+ this._checkBox = new CheckBox.CheckBox();
+ this._checkBox.connect('clicked', this._sync.bind(this));
+ this._messageDialogContent.add_child(this._checkBox);
+
+ this._batteryWarning = new St.Label({
+ style_class: 'end-session-dialog-battery-warning',
+ text: _('Low battery power: please plug in before installing updates.'),
+ });
+ this._batteryWarning.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+ this._batteryWarning.clutter_text.line_wrap = true;
+ this._messageDialogContent.add_child(this._batteryWarning);
+
+ this.contentLayout.add_child(this._messageDialogContent);
+
+ this._applicationSection = new Dialog.ListSection({
+ title: _('Some applications are busy or have unsaved work'),
+ });
+ this.contentLayout.add_child(this._applicationSection);
+
+ this._sessionSection = new Dialog.ListSection({
+ title: _('Other users are logged in'),
+ });
+ this.contentLayout.add_child(this._sessionSection);
+
+ this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(EndSessionDialogIface, this);
+ this._dbusImpl.export(Gio.DBus.session, '/org/gnome/SessionManager/EndSessionDialog');
+ }
+
+ async _onPkOfflineProxyCreated(proxy, error) {
+ if (error) {
+ log(error.message);
+ return;
+ }
+
+ // Creating a D-Bus proxy won't propagate SERVICE_UNKNOWN or NAME_HAS_NO_OWNER
+ // errors if PackageKit is not available, but the GIO implementation will make
+ // sure in that case that the proxy's g-name-owner is set to null, so check that.
+ if (this._pkOfflineProxy.g_name_owner === null) {
+ this._pkOfflineProxy = null;
+ return;
+ }
+
+ // It only makes sense to check for this permission if PackageKit is available.
+ try {
+ this._updatesPermission = await Polkit.Permission.new(
+ 'org.freedesktop.packagekit.trigger-offline-update', null, null);
+ } catch (e) {
+ log('No permission to trigger offline updates: %s'.format(e.toString()));
+ }
+ }
+
+ _onDestroy() {
+ this._user.disconnect(this._userLoadedId);
+ this._user.disconnect(this._userChangedId);
+ }
+
+ _isDischargingBattery() {
+ return this._powerProxy.IsPresent &&
+ this._powerProxy.State !== UPower.DeviceState.CHARGING &&
+ this._powerProxy.State !== UPower.DeviceState.FULLY_CHARGED;
+ }
+
+ _isBatteryLow() {
+ return this._isDischargingBattery() && this._powerProxy.Percentage < LOW_BATTERY_THRESHOLD;
+ }
+
+ _shouldShowLowBatteryWarning(dialogContent) {
+ if (!dialogContent.showBatteryWarning)
+ return false;
+
+ if (!this._isBatteryLow())
+ return false;
+
+ if (this._checkBox.checked)
+ return true;
+
+ // Show the warning if updates have already been triggered, but
+ // the user doesn't have enough permissions to cancel them.
+ let updatesAllowed = this._updatesPermission && this._updatesPermission.allowed;
+ return this._updateInfo.UpdatePrepared && this._updateInfo.UpdateTriggered && !updatesAllowed;
+ }
+
+ _sync() {
+ let open = this.state == ModalDialog.State.OPENING || this.state == ModalDialog.State.OPENED;
+ if (!open)
+ return;
+
+ let dialogContent = DialogContent[this._type];
+
+ let subject = dialogContent.subject;
+
+ // Use different title when we are installing updates
+ if (dialogContent.subjectWithUpdates && this._checkBox.checked)
+ subject = dialogContent.subjectWithUpdates;
+
+ this._batteryWarning.visible = this._shouldShowLowBatteryWarning(dialogContent);
+
+ let description;
+ let displayTime = _roundSecondsToInterval(this._totalSecondsToStayOpen,
+ this._secondsLeft,
+ 10);
+
+ if (this._user.is_loaded) {
+ let realName = this._user.get_real_name();
+
+ if (realName != null) {
+ if (dialogContent.subjectWithUser)
+ subject = dialogContent.subjectWithUser.format(realName);
+
+ if (dialogContent.descriptionWithUser)
+ description = dialogContent.descriptionWithUser(realName, displayTime);
+ }
+ }
+
+ // Use a different description when we are installing a system upgrade
+ // if the PackageKit proxy is available (i.e. PackageKit is available).
+ if (dialogContent.upgradeDescription) {
+ const { name, version } = this._updateInfo.PreparedUpgrade;
+ if (name != null && version != null)
+ description = dialogContent.upgradeDescription(name, version);
+ }
+
+ // Fall back to regular description
+ if (!description)
+ description = dialogContent.description(displayTime);
+
+ this._messageDialogContent.title = subject;
+ this._messageDialogContent.description = description;
+
+ let hasApplications = this._applications.length > 0;
+ let hasSessions = this._sessions.length > 0;
+
+ this._applicationSection.visible = hasApplications;
+ this._sessionSection.visible = hasSessions;
+ }
+
+ _onCapturedEvent(actor, event) {
+ let altEnabled = false;
+
+ let type = event.type();
+ if (type !== Clutter.EventType.KEY_PRESS && type !== Clutter.EventType.KEY_RELEASE)
+ return Clutter.EVENT_PROPAGATE;
+
+ let key = event.get_key_symbol();
+ if (key !== Clutter.KEY_Alt_L && key !== Clutter.KEY_Alt_R)
+ return Clutter.EVENT_PROPAGATE;
+
+ if (type === Clutter.EventType.KEY_PRESS)
+ altEnabled = true;
+
+ this._rebootButton.visible = !altEnabled;
+ this._rebootButtonAlt.visible = altEnabled;
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _updateButtons() {
+ this.clearButtons();
+
+ this.addButton({ action: this.cancel.bind(this),
+ label: _("Cancel"),
+ key: Clutter.KEY_Escape });
+
+ let dialogContent = DialogContent[this._type];
+ for (let i = 0; i < dialogContent.confirmButtons.length; i++) {
+ let signal = dialogContent.confirmButtons[i].signal;
+ let label = dialogContent.confirmButtons[i].label;
+ let button = this.addButton({
+ action: () => {
+ this.close(true);
+ let signalId = this.connect('closed', () => {
+ this.disconnect(signalId);
+ this._confirm(signal);
+ });
+ },
+ label,
+ });
+
+ // Add Alt "Boot Options" option to the Reboot button
+ if (this._canRebootToBootLoaderMenu && signal === 'ConfirmedReboot') {
+ this._rebootButton = button;
+ this._rebootButtonAlt = this.addButton({
+ action: () => {
+ this.close(true);
+ let signalId = this.connect('closed', () => {
+ this.disconnect(signalId);
+ this._confirmRebootToBootLoaderMenu();
+ });
+ },
+ label: C_('button', 'Boot Options'),
+ });
+ this._rebootButtonAlt.visible = false;
+ this._capturedEventId = global.stage.connect('captured-event',
+ this._onCapturedEvent.bind(this));
+ }
+ }
+ }
+
+ _stopAltCapture() {
+ if (this._capturedEventId > 0) {
+ global.stage.disconnect(this._capturedEventId);
+ this._capturedEventId = 0;
+ }
+ this._rebootButton = null;
+ this._rebootButtonAlt = null;
+ }
+
+ close(skipSignal) {
+ super.close();
+
+ if (!skipSignal)
+ this._dbusImpl.emit_signal('Closed', null);
+ }
+
+ cancel() {
+ this._stopTimer();
+ this._stopAltCapture();
+ this._dbusImpl.emit_signal('Canceled', null);
+ this.close();
+ }
+
+ _confirmRebootToBootLoaderMenu() {
+ this._loginManager.setRebootToBootLoaderMenu();
+ this._confirm('ConfirmedReboot');
+ }
+
+ _confirm(signal) {
+ let callback = () => {
+ this._fadeOutDialog();
+ this._stopTimer();
+ this._stopAltCapture();
+ this._dbusImpl.emit_signal(signal, null);
+ };
+
+ // Offline update not available; just emit the signal
+ if (!this._checkBox.visible) {
+ callback();
+ return;
+ }
+
+ // Trigger the offline update as requested
+ if (this._checkBox.checked) {
+ switch (signal) {
+ case "ConfirmedReboot":
+ this._triggerOfflineUpdateReboot(callback);
+ break;
+ case "ConfirmedShutdown":
+ // To actually trigger the offline update, we need to
+ // reboot to do the upgrade. When the upgrade is complete,
+ // the computer will shut down automatically.
+ signal = "ConfirmedReboot";
+ this._triggerOfflineUpdateShutdown(callback);
+ break;
+ default:
+ callback();
+ break;
+ }
+ } else {
+ this._triggerOfflineUpdateCancel(callback);
+ }
+ }
+
+ _onOpened() {
+ this._sync();
+ }
+
+ _triggerOfflineUpdateReboot(callback) {
+ // Handle this gracefully if PackageKit is not available.
+ if (!this._pkOfflineProxy) {
+ callback();
+ return;
+ }
+
+ this._pkOfflineProxy.TriggerRemote('reboot', (result, error) => {
+ if (error)
+ log(error.message);
+
+ callback();
+ });
+ }
+
+ _triggerOfflineUpdateShutdown(callback) {
+ // Handle this gracefully if PackageKit is not available.
+ if (!this._pkOfflineProxy) {
+ callback();
+ return;
+ }
+
+ this._pkOfflineProxy.TriggerRemote('power-off', (result, error) => {
+ if (error)
+ log(error.message);
+
+ callback();
+ });
+ }
+
+ _triggerOfflineUpdateCancel(callback) {
+ // Handle this gracefully if PackageKit is not available.
+ if (!this._pkOfflineProxy) {
+ callback();
+ return;
+ }
+
+ this._pkOfflineProxy.CancelRemote((result, error) => {
+ if (error)
+ log(error.message);
+
+ callback();
+ });
+ }
+
+ _startTimer() {
+ let startTime = GLib.get_monotonic_time();
+ this._secondsLeft = this._totalSecondsToStayOpen;
+
+ this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 1, () => {
+ let currentTime = GLib.get_monotonic_time();
+ let secondsElapsed = (currentTime - startTime) / 1000000;
+
+ this._secondsLeft = this._totalSecondsToStayOpen - secondsElapsed;
+ if (this._secondsLeft > 0) {
+ this._sync();
+ return GLib.SOURCE_CONTINUE;
+ }
+
+ let dialogContent = DialogContent[this._type];
+ let button = dialogContent.confirmButtons[dialogContent.confirmButtons.length - 1];
+ this._confirm(button.signal);
+ this._timerId = 0;
+
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._timerId, '[gnome-shell] this._confirm');
+ }
+
+ _stopTimer() {
+ if (this._timerId > 0) {
+ GLib.source_remove(this._timerId);
+ this._timerId = 0;
+ }
+
+ this._secondsLeft = 0;
+ }
+
+ _onInhibitorLoaded(inhibitor) {
+ if (!this._applications.includes(inhibitor)) {
+ // Stale inhibitor
+ return;
+ }
+
+ let app = findAppFromInhibitor(inhibitor);
+
+ if (app) {
+ let [description] = inhibitor.GetReasonSync();
+ let listItem = new Dialog.ListSectionItem({
+ icon_actor: app.create_icon_texture(_ITEM_ICON_SIZE),
+ title: app.get_name(),
+ description,
+ });
+ this._applicationSection.list.add_child(listItem);
+ } else {
+ // inhibiting app is a service, not an application
+ this._applications.splice(this._applications.indexOf(inhibitor), 1);
+ }
+
+ this._sync();
+ }
+
+ _loadSessions() {
+ this._loginManager.listSessions(result => {
+ let n = 0;
+ for (let i = 0; i < result.length; i++) {
+ let [id_, uid_, userName, seat_, sessionPath] = result[i];
+ let proxy = new LogindSession(Gio.DBus.system, 'org.freedesktop.login1', sessionPath);
+
+ if (proxy.Class != 'user')
+ continue;
+
+ if (proxy.State == 'closing')
+ continue;
+
+ let sessionId = GLib.getenv('XDG_SESSION_ID');
+ if (!sessionId) {
+ this._loginManager.getCurrentSessionProxy(currentSessionProxy => {
+ sessionId = currentSessionProxy.Id;
+ log('endSessionDialog: No XDG_SESSION_ID, fetched from logind: %d'.format(sessionId));
+ });
+ }
+
+ if (proxy.Id == sessionId)
+ continue;
+
+ let session = { user: this._userManager.get_user(userName),
+ username: userName,
+ type: proxy.Type,
+ remote: proxy.Remote };
+ this._sessions.push(session);
+
+ let userAvatar = new UserWidget.Avatar(session.user, { iconSize: _ITEM_ICON_SIZE });
+ userAvatar.update();
+
+ userName = session.user.get_real_name()
+ ? session.user.get_real_name() : session.username;
+
+ let userLabelText;
+ if (session.remote)
+ /* Translators: Remote here refers to a remote session, like a ssh login */
+ userLabelText = _('%s (remote)').format(userName);
+ else if (session.type === 'tty')
+ /* Translators: Console here refers to a tty like a VT console */
+ userLabelText = _('%s (console)').format(userName);
+ else
+ userLabelText = userName;
+
+ let listItem = new Dialog.ListSectionItem({
+ icon_actor: userAvatar,
+ title: userLabelText,
+ });
+ this._sessionSection.list.add_child(listItem);
+
+ // limit the number of entries
+ n++;
+ if (n == MAX_USERS_IN_SESSION_DIALOG)
+ break;
+ }
+
+ this._sync();
+ });
+ }
+
+ async _getUpdateInfo() {
+ const connection = this._pkOfflineProxy.get_connection();
+ const reply = await connection.call(
+ this._pkOfflineProxy.g_name,
+ this._pkOfflineProxy.g_object_path,
+ 'org.freedesktop.DBus.Properties',
+ 'GetAll',
+ new GLib.Variant('(s)', [this._pkOfflineProxy.g_interface_name]),
+ null,
+ Gio.DBusCallFlags.NONE,
+ -1,
+ null);
+ const [info] = reply.recursiveUnpack();
+ return info;
+ }
+
+ async OpenAsync(parameters, invocation) {
+ let [type, timestamp, totalSecondsToStayOpen, inhibitorObjectPaths] = parameters;
+ this._totalSecondsToStayOpen = totalSecondsToStayOpen;
+ this._type = type;
+
+ try {
+ this._updateInfo = await this._getUpdateInfo();
+ } catch (e) {
+ if (this._pkOfflineProxy !== null)
+ log('Failed to get update info from PackageKit: %s'.format(e.message));
+
+ this._updateInfo = {
+ UpdateTriggered: false,
+ UpdatePrepared: false,
+ UpgradeTriggered: false,
+ PreparedUpgrade: {},
+ };
+ }
+
+ // Only consider updates and upgrades if PackageKit is available.
+ if (this._pkOfflineProxy && this._type == DialogType.RESTART) {
+ if (this._updateInfo.UpdateTriggered)
+ this._type = DialogType.UPDATE_RESTART;
+ else if (this._updateInfo.UpgradeTriggered)
+ this._type = DialogType.UPGRADE_RESTART;
+ }
+
+ this._applications = [];
+ this._applicationSection.list.destroy_all_children();
+
+ this._sessions = [];
+ this._sessionSection.list.destroy_all_children();
+
+ if (!(this._type in DialogContent)) {
+ invocation.return_dbus_error('org.gnome.Shell.ModalDialog.TypeError',
+ "Unknown dialog type requested");
+ return;
+ }
+
+ let dialogContent = DialogContent[this._type];
+
+ for (let i = 0; i < inhibitorObjectPaths.length; i++) {
+ let inhibitor = new GnomeSession.Inhibitor(inhibitorObjectPaths[i], proxy => {
+ this._onInhibitorLoaded(proxy);
+ });
+
+ this._applications.push(inhibitor);
+ }
+
+ if (dialogContent.showOtherSessions)
+ this._loadSessions();
+
+ let updatesAllowed = this._updatesPermission && this._updatesPermission.allowed;
+
+ _setCheckBoxLabel(this._checkBox, dialogContent.checkBoxText || '');
+ this._checkBox.visible = dialogContent.checkBoxText && this._updateInfo.UpdatePrepared && updatesAllowed;
+
+ if (this._type === DialogType.UPGRADE_RESTART)
+ this._checkBox.checked = this._checkBox.visible && this._updateInfo.UpdateTriggered && !this._isDischargingBattery();
+ else
+ this._checkBox.checked = this._checkBox.visible && !this._isBatteryLow();
+
+ this._batteryWarning.visible = this._shouldShowLowBatteryWarning(dialogContent);
+
+ this._updateButtons();
+
+ if (!this.open(timestamp)) {
+ invocation.return_dbus_error('org.gnome.Shell.ModalDialog.GrabError',
+ "Cannot grab pointer and keyboard");
+ return;
+ }
+
+ if (!dialogContent.disableTimer)
+ this._startTimer();
+
+ this._sync();
+
+ let signalId = this.connect('opened', () => {
+ invocation.return_value(null);
+ this.disconnect(signalId);
+ });
+ }
+
+ Close(_parameters, _invocation) {
+ this.close();
+ }
+});
diff --git a/js/ui/environment.js b/js/ui/environment.js
new file mode 100644
index 0000000..5790aa0
--- /dev/null
+++ b/js/ui/environment.js
@@ -0,0 +1,400 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported init */
+
+const Config = imports.misc.config;
+
+imports.gi.versions.Clutter = Config.LIBMUTTER_API_VERSION;
+imports.gi.versions.Gio = '2.0';
+imports.gi.versions.GdkPixbuf = '2.0';
+imports.gi.versions.Gtk = '3.0';
+imports.gi.versions.Soup = '2.4';
+imports.gi.versions.TelepathyGLib = '0.12';
+imports.gi.versions.TelepathyLogger = '0.2';
+
+const { Clutter, Gio, GLib, GObject, Meta, Polkit, Shell, St } = imports.gi;
+const Gettext = imports.gettext;
+const System = imports.system;
+
+Gio._promisify(Gio.DataInputStream.prototype, 'fill_async', 'fill_finish');
+Gio._promisify(Gio.DataInputStream.prototype,
+ 'read_line_async', 'read_line_finish');
+Gio._promisify(Gio.DBus, 'get', 'get_finish');
+Gio._promisify(Gio.DBusConnection.prototype, 'call', 'call_finish');
+Gio._promisify(Gio.DBusProxy, 'new', 'new_finish');
+Gio._promisify(Gio.DBusProxy.prototype, 'init_async', 'init_finish');
+Gio._promisify(Gio.DBusProxy.prototype,
+ 'call_with_unix_fd_list', 'call_with_unix_fd_list_finish');
+Gio._promisify(Polkit.Permission, 'new', 'new_finish');
+
+let _localTimeZone = null;
+
+// We can't import shell JS modules yet, because they may have
+// variable initializations, etc, that depend on init() already having
+// been run.
+
+
+// "monkey patch" in some varargs ClutterContainer methods; we need
+// to do this per-container class since there is no representation
+// of interfaces in Javascript
+function _patchContainerClass(containerClass) {
+ // This one is a straightforward mapping of the C method
+ containerClass.prototype.child_set = function (actor, props) {
+ let meta = this.get_child_meta(actor);
+ for (let prop in props)
+ meta[prop] = props[prop];
+ };
+
+ // clutter_container_add() actually is a an add-many-actors
+ // method. We conveniently, but somewhat dubiously, take the
+ // this opportunity to make it do something more useful.
+ containerClass.prototype.add = function (actor, props) {
+ this.add_actor(actor);
+ if (props)
+ this.child_set(actor, props);
+ };
+}
+
+function _patchLayoutClass(layoutClass, styleProps) {
+ if (styleProps) {
+ layoutClass.prototype.hookup_style = function (container) {
+ container.connect('style-changed', () => {
+ let node = container.get_theme_node();
+ for (let prop in styleProps) {
+ let [found, length] = node.lookup_length(styleProps[prop], false);
+ if (found)
+ this[prop] = length;
+ }
+ });
+ };
+ }
+}
+
+function _makeEaseCallback(params, cleanup) {
+ let onComplete = params.onComplete;
+ delete params.onComplete;
+
+ let onStopped = params.onStopped;
+ delete params.onStopped;
+
+ return isFinished => {
+ cleanup();
+
+ if (onStopped)
+ onStopped(isFinished);
+ if (onComplete && isFinished)
+ onComplete();
+ };
+}
+
+function _getPropertyTarget(actor, propName) {
+ if (!propName.startsWith('@'))
+ return [actor, propName];
+
+ let [type, name, prop] = propName.split('.');
+ switch (type) {
+ case '@layout':
+ return [actor.layout_manager, name];
+ case '@actions':
+ return [actor.get_action(name), prop];
+ case '@constraints':
+ return [actor.get_constraint(name), prop];
+ case '@content':
+ return [actor.content, name];
+ case '@effects':
+ return [actor.get_effect(name), prop];
+ }
+
+ throw new Error(`Invalid property name ${propName}`);
+}
+
+function _easeActor(actor, params) {
+ actor.save_easing_state();
+
+ if (params.duration != undefined)
+ actor.set_easing_duration(params.duration);
+ delete params.duration;
+
+ if (params.delay != undefined)
+ actor.set_easing_delay(params.delay);
+ delete params.delay;
+
+ let repeatCount = 0;
+ if (params.repeatCount != undefined)
+ repeatCount = params.repeatCount;
+ delete params.repeatCount;
+
+ let autoReverse = false;
+ if (params.autoReverse != undefined)
+ autoReverse = params.autoReverse;
+ delete params.autoReverse;
+
+ // repeatCount doesn't include the initial iteration
+ const numIterations = repeatCount + 1;
+ // whether the transition should finish where it started
+ const isReversed = autoReverse && numIterations % 2 === 0;
+
+ if (params.mode != undefined)
+ actor.set_easing_mode(params.mode);
+ delete params.mode;
+
+ const prepare = () => {
+ Meta.disable_unredirect_for_display(global.display);
+ global.begin_work();
+ };
+ const cleanup = () => {
+ Meta.enable_unredirect_for_display(global.display);
+ global.end_work();
+ };
+ let callback = _makeEaseCallback(params, cleanup);
+
+ // cancel overwritten transitions
+ let animatedProps = Object.keys(params).map(p => p.replace('_', '-', 'g'));
+ animatedProps.forEach(p => actor.remove_transition(p));
+
+ if (actor.get_easing_duration() > 0 || !isReversed)
+ actor.set(params);
+ actor.restore_easing_state();
+
+ let transition = animatedProps.map(p => actor.get_transition(p))
+ .find(t => t !== null);
+
+ if (transition && transition.delay)
+ transition.connect('started', () => prepare());
+ else
+ prepare();
+
+ if (transition) {
+ transition.set({ repeatCount, autoReverse });
+ transition.connect('stopped', (t, finished) => callback(finished));
+ } else {
+ callback(true);
+ }
+}
+
+function _easeActorProperty(actor, propName, target, params) {
+ // Avoid pointless difference with ease()
+ if (params.mode)
+ params.progress_mode = params.mode;
+ delete params.mode;
+
+ if (params.duration)
+ params.duration = adjustAnimationTime(params.duration);
+ let duration = Math.floor(params.duration || 0);
+
+ let repeatCount = 0;
+ if (params.repeatCount != undefined)
+ repeatCount = params.repeatCount;
+ delete params.repeatCount;
+
+ let autoReverse = false;
+ if (params.autoReverse != undefined)
+ autoReverse = params.autoReverse;
+ delete params.autoReverse;
+
+ // repeatCount doesn't include the initial iteration
+ const numIterations = repeatCount + 1;
+ // whether the transition should finish where it started
+ const isReversed = autoReverse && numIterations % 2 === 0;
+
+ // Copy Clutter's behavior for implicit animations, see
+ // should_skip_implicit_transition()
+ if (actor instanceof Clutter.Actor && !actor.mapped)
+ duration = 0;
+
+ const prepare = () => {
+ Meta.disable_unredirect_for_display(global.display);
+ global.begin_work();
+ };
+ const cleanup = () => {
+ Meta.enable_unredirect_for_display(global.display);
+ global.end_work();
+ };
+ let callback = _makeEaseCallback(params, cleanup);
+
+ // cancel overwritten transition
+ actor.remove_transition(propName);
+
+ if (duration == 0) {
+ let [obj, prop] = _getPropertyTarget(actor, propName);
+
+ if (!isReversed)
+ obj[prop] = target;
+
+ prepare();
+ callback(true);
+
+ return;
+ }
+
+ let pspec = actor.find_property(propName);
+ let transition = new Clutter.PropertyTransition(Object.assign({
+ property_name: propName,
+ interval: new Clutter.Interval({ value_type: pspec.value_type }),
+ remove_on_complete: true,
+ repeat_count: repeatCount,
+ auto_reverse: autoReverse,
+ }, params));
+ actor.add_transition(propName, transition);
+
+ transition.set_to(target);
+
+ if (transition.delay)
+ transition.connect('started', () => prepare());
+ else
+ prepare();
+
+ transition.connect('stopped', (t, finished) => callback(finished));
+}
+
+function _loggingFunc(...args) {
+ let fields = { 'MESSAGE': args.join(', ') };
+ let domain = "GNOME Shell";
+
+ // If the caller is an extension, add it as metadata
+ let extension = imports.misc.extensionUtils.getCurrentExtension();
+ if (extension != null) {
+ domain = extension.metadata.name;
+ fields['GNOME_SHELL_EXTENSION_UUID'] = extension.uuid;
+ fields['GNOME_SHELL_EXTENSION_NAME'] = extension.metadata.name;
+ }
+
+ GLib.log_structured(domain, GLib.LogLevelFlags.LEVEL_MESSAGE, fields);
+}
+
+function init() {
+ // Add some bindings to the global JS namespace
+ globalThis.global = Shell.Global.get();
+
+ globalThis.log = _loggingFunc;
+
+ globalThis._ = Gettext.gettext;
+ globalThis.C_ = Gettext.pgettext;
+ globalThis.ngettext = Gettext.ngettext;
+ globalThis.N_ = s => s;
+
+ GObject.gtypeNameBasedOnJSPath = true;
+
+ // Miscellaneous monkeypatching
+ _patchContainerClass(St.BoxLayout);
+
+ _patchLayoutClass(Clutter.GridLayout, { row_spacing: 'spacing-rows',
+ column_spacing: 'spacing-columns' });
+ _patchLayoutClass(Clutter.BoxLayout, { spacing: 'spacing' });
+
+ let origSetEasingDuration = Clutter.Actor.prototype.set_easing_duration;
+ Clutter.Actor.prototype.set_easing_duration = function (msecs) {
+ origSetEasingDuration.call(this, adjustAnimationTime(msecs));
+ };
+ let origSetEasingDelay = Clutter.Actor.prototype.set_easing_delay;
+ Clutter.Actor.prototype.set_easing_delay = function (msecs) {
+ origSetEasingDelay.call(this, adjustAnimationTime(msecs));
+ };
+
+ Clutter.Actor.prototype.ease = function (props) {
+ _easeActor(this, props);
+ };
+ Clutter.Actor.prototype.ease_property = function (propName, target, params) {
+ _easeActorProperty(this, propName, target, params);
+ };
+ St.Adjustment.prototype.ease = function (target, params) {
+ // we're not an actor of course, but we implement the same
+ // transition API as Clutter.Actor, so this works anyway
+ _easeActorProperty(this, 'value', target, params);
+ };
+
+ Clutter.Actor.prototype[Symbol.iterator] = function* () {
+ for (let c = this.get_first_child(); c; c = c.get_next_sibling())
+ yield c;
+ };
+
+ Clutter.Actor.prototype.toString = function () {
+ return St.describe_actor(this);
+ };
+ // Deprecation warning for former JS classes turned into an actor subclass
+ Object.defineProperty(Clutter.Actor.prototype, 'actor', {
+ get() {
+ let klass = this.constructor.name;
+ let { stack } = new Error();
+ log(`Usage of object.actor is deprecated for ${klass}\n${stack}`);
+ return this;
+ },
+ });
+
+ Gio._LocalFilePrototype.touch_async = function (callback) {
+ Shell.util_touch_file_async(this, callback);
+ };
+ Gio._LocalFilePrototype.touch_finish = function (result) {
+ return Shell.util_touch_file_finish(this, result);
+ };
+
+ St.set_slow_down_factor = function (factor) {
+ let { stack } = new Error();
+ log(`St.set_slow_down_factor() is deprecated, use St.Settings.slow_down_factor\n${stack}`);
+ St.Settings.get().slow_down_factor = factor;
+ };
+
+ let origToString = Object.prototype.toString;
+ Object.prototype.toString = function () {
+ let base = origToString.call(this);
+ try {
+ if ('actor' in this && this.actor instanceof Clutter.Actor)
+ return base.replace(/\]$/, ` delegate for ${this.actor.toString().substring(1)}`);
+ else
+ return base;
+ } catch (e) {
+ return base;
+ }
+ };
+
+ // Override to clear our own timezone cache as well
+ const origClearDateCaches = System.clearDateCaches;
+ System.clearDateCaches = function () {
+ _localTimeZone = null;
+ origClearDateCaches();
+ };
+
+ // Work around https://bugzilla.mozilla.org/show_bug.cgi?id=508783
+ Date.prototype.toLocaleFormat = function (format) {
+ if (_localTimeZone === null)
+ _localTimeZone = GLib.TimeZone.new_local();
+
+ let dt = GLib.DateTime.new(_localTimeZone,
+ this.getFullYear(),
+ this.getMonth() + 1,
+ this.getDate(),
+ this.getHours(),
+ this.getMinutes(),
+ this.getSeconds());
+ return dt ? dt.format(format) : '';
+ };
+
+ let slowdownEnv = GLib.getenv('GNOME_SHELL_SLOWDOWN_FACTOR');
+ if (slowdownEnv) {
+ let factor = parseFloat(slowdownEnv);
+ if (!isNaN(factor) && factor > 0.0)
+ St.Settings.get().slow_down_factor = factor;
+ }
+
+ // OK, now things are initialized enough that we can import shell JS
+ const Format = imports.format;
+
+ String.prototype.format = Format.format;
+
+ Math.clamp = function (x, lower, upper) {
+ return Math.min(Math.max(x, lower), upper);
+ };
+}
+
+// adjustAnimationTime:
+// @msecs: time in milliseconds
+//
+// Adjust @msecs to account for St's enable-animations
+// and slow-down-factor settings
+function adjustAnimationTime(msecs) {
+ let settings = St.Settings.get();
+
+ if (!settings.enable_animations)
+ return 1;
+ return settings.slow_down_factor * msecs;
+}
+
diff --git a/js/ui/extensionDownloader.js b/js/ui/extensionDownloader.js
new file mode 100644
index 0000000..a7b40f9
--- /dev/null
+++ b/js/ui/extensionDownloader.js
@@ -0,0 +1,248 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported init, installExtension, uninstallExtension, checkForUpdates */
+
+const { Clutter, Gio, GLib, GObject, Soup } = imports.gi;
+
+const Config = imports.misc.config;
+const Dialog = imports.ui.dialog;
+const ExtensionUtils = imports.misc.extensionUtils;
+const FileUtils = imports.misc.fileUtils;
+const Main = imports.ui.main;
+const ModalDialog = imports.ui.modalDialog;
+
+var REPOSITORY_URL_DOWNLOAD = 'https://extensions.gnome.org/download-extension/%s.shell-extension.zip';
+var REPOSITORY_URL_INFO = 'https://extensions.gnome.org/extension-info/';
+var REPOSITORY_URL_UPDATE = 'https://extensions.gnome.org/update-info/';
+
+let _httpSession;
+
+function installExtension(uuid, invocation) {
+ let params = { uuid,
+ shell_version: Config.PACKAGE_VERSION };
+
+ let message = Soup.form_request_new_from_hash('GET', REPOSITORY_URL_INFO, params);
+
+ _httpSession.queue_message(message, () => {
+ if (message.status_code != Soup.KnownStatusCode.OK) {
+ Main.extensionManager.logExtensionError(uuid, 'downloading info: %d'.format(message.status_code));
+ invocation.return_dbus_error('org.gnome.Shell.DownloadInfoError', message.status_code.toString());
+ return;
+ }
+
+ let info;
+ try {
+ info = JSON.parse(message.response_body.data);
+ } catch (e) {
+ Main.extensionManager.logExtensionError(uuid, 'parsing info: %s'.format(e.toString()));
+ invocation.return_dbus_error('org.gnome.Shell.ParseInfoError', e.toString());
+ return;
+ }
+
+ let dialog = new InstallExtensionDialog(uuid, info, invocation);
+ dialog.open(global.get_current_time());
+ });
+}
+
+function uninstallExtension(uuid) {
+ let extension = Main.extensionManager.lookup(uuid);
+ if (!extension)
+ return false;
+
+ // Don't try to uninstall system extensions
+ if (extension.type != ExtensionUtils.ExtensionType.PER_USER)
+ return false;
+
+ if (!Main.extensionManager.unloadExtension(extension))
+ return false;
+
+ FileUtils.recursivelyDeleteDir(extension.dir, true);
+
+ try {
+ const updatesDir = Gio.File.new_for_path(GLib.build_filenamev(
+ [global.userdatadir, 'extension-updates', extension.uuid]));
+ FileUtils.recursivelyDeleteDir(updatesDir, true);
+ } catch (e) {
+ // not an error
+ }
+
+ return true;
+}
+
+function gotExtensionZipFile(session, message, uuid, dir, callback, errback) {
+ if (message.status_code != Soup.KnownStatusCode.OK) {
+ errback('DownloadExtensionError', message.status_code);
+ return;
+ }
+
+ try {
+ if (!dir.query_exists(null))
+ dir.make_directory_with_parents(null);
+ } catch (e) {
+ errback('CreateExtensionDirectoryError', e);
+ return;
+ }
+
+ let [file, stream] = Gio.File.new_tmp('XXXXXX.shell-extension.zip');
+ let contents = message.response_body.flatten().get_as_bytes();
+ stream.output_stream.write_bytes(contents, null);
+ stream.close(null);
+ let [success, pid] = GLib.spawn_async(null,
+ ['unzip', '-uod', dir.get_path(), '--', file.get_path()],
+ null,
+ GLib.SpawnFlags.SEARCH_PATH | GLib.SpawnFlags.DO_NOT_REAP_CHILD,
+ null);
+
+ if (!success) {
+ errback('ExtractExtensionError');
+ return;
+ }
+
+ GLib.child_watch_add(GLib.PRIORITY_DEFAULT, pid, (o, status) => {
+ GLib.spawn_close_pid(pid);
+
+ if (status != 0)
+ errback('ExtractExtensionError');
+ else
+ callback();
+ });
+}
+
+function downloadExtensionUpdate(uuid) {
+ if (!Main.extensionManager.updatesSupported)
+ return;
+
+ let dir = Gio.File.new_for_path(
+ GLib.build_filenamev([global.userdatadir, 'extension-updates', uuid]));
+
+ let params = { shell_version: Config.PACKAGE_VERSION };
+
+ let url = REPOSITORY_URL_DOWNLOAD.format(uuid);
+ let message = Soup.form_request_new_from_hash('GET', url, params);
+
+ _httpSession.queue_message(message, session => {
+ gotExtensionZipFile(session, message, uuid, dir, () => {
+ Main.extensionManager.notifyExtensionUpdate(uuid);
+ }, (code, msg) => {
+ log('Error while downloading update for extension %s: %s (%s)'.format(uuid, code, msg));
+ });
+ });
+}
+
+function checkForUpdates() {
+ if (!Main.extensionManager.updatesSupported)
+ return;
+
+ let metadatas = {};
+ Main.extensionManager.getUuids().forEach(uuid => {
+ let extension = Main.extensionManager.lookup(uuid);
+ if (extension.type !== ExtensionUtils.ExtensionType.PER_USER)
+ return;
+ if (extension.hasUpdate)
+ return;
+ metadatas[uuid] = {
+ version: extension.metadata.version,
+ };
+ });
+
+ if (Object.keys(metadatas).length === 0)
+ return; // nothing to update
+
+ let versionCheck = global.settings.get_boolean(
+ 'disable-extension-version-validation');
+ let params = {
+ shell_version: Config.PACKAGE_VERSION,
+ installed: JSON.stringify(metadatas),
+ disable_version_validation: versionCheck.toString(),
+ };
+
+ let url = REPOSITORY_URL_UPDATE;
+ let message = Soup.form_request_new_from_hash('GET', url, params);
+ _httpSession.queue_message(message, () => {
+ if (message.status_code != Soup.KnownStatusCode.OK)
+ return;
+
+ let operations = JSON.parse(message.response_body.data);
+ for (let uuid in operations) {
+ let operation = operations[uuid];
+ if (operation === 'upgrade' || operation === 'downgrade')
+ downloadExtensionUpdate(uuid);
+ }
+ });
+}
+
+var InstallExtensionDialog = GObject.registerClass(
+class InstallExtensionDialog extends ModalDialog.ModalDialog {
+ _init(uuid, info, invocation) {
+ super._init({ styleClass: 'extension-dialog' });
+
+ this._uuid = uuid;
+ this._info = info;
+ this._invocation = invocation;
+
+ this.setButtons([{
+ label: _("Cancel"),
+ action: this._onCancelButtonPressed.bind(this),
+ key: Clutter.KEY_Escape,
+ }, {
+ label: _("Install"),
+ action: this._onInstallButtonPressed.bind(this),
+ default: true,
+ }]);
+
+ let content = new Dialog.MessageDialogContent({
+ title: _('Install Extension'),
+ description: _('Download and install “%s” from extensions.gnome.org?').format(info.name),
+ });
+
+ this.contentLayout.add(content);
+ }
+
+ _onCancelButtonPressed() {
+ this.close();
+ this._invocation.return_value(GLib.Variant.new('(s)', ['cancelled']));
+ }
+
+ _onInstallButtonPressed() {
+ let params = { shell_version: Config.PACKAGE_VERSION };
+
+ let url = REPOSITORY_URL_DOWNLOAD.format(this._uuid);
+ let message = Soup.form_request_new_from_hash('GET', url, params);
+
+ let uuid = this._uuid;
+ let dir = Gio.File.new_for_path(GLib.build_filenamev([global.userdatadir, 'extensions', uuid]));
+ let invocation = this._invocation;
+ function errback(code, msg) {
+ log('Error while installing %s: %s (%s)'.format(uuid, code, msg));
+ invocation.return_dbus_error('org.gnome.Shell.%s'.format(code), msg || '');
+ }
+
+ function callback() {
+ try {
+ let extension = Main.extensionManager.createExtensionObject(uuid, dir, ExtensionUtils.ExtensionType.PER_USER);
+ Main.extensionManager.loadExtension(extension);
+ if (!Main.extensionManager.enableExtension(uuid))
+ throw new Error('Cannot add %s to enabled extensions gsettings key'.format(uuid));
+ } catch (e) {
+ uninstallExtension(uuid);
+ errback('LoadExtensionError', e);
+ return;
+ }
+
+ invocation.return_value(GLib.Variant.new('(s)', ['successful']));
+ }
+
+ _httpSession.queue_message(message, session => {
+ gotExtensionZipFile(session, message, uuid, dir, callback, errback);
+ });
+
+ this.close();
+ }
+});
+
+function init() {
+ _httpSession = new Soup.Session({ ssl_use_system_ca_file: true });
+
+ // See: https://bugzilla.gnome.org/show_bug.cgi?id=655189 for context.
+ // _httpSession.add_feature(new Soup.ProxyResolverDefault());
+ Soup.Session.prototype.add_feature.call(_httpSession, new Soup.ProxyResolverDefault());
+}
diff --git a/js/ui/extensionSystem.js b/js/ui/extensionSystem.js
new file mode 100644
index 0000000..6b624fc
--- /dev/null
+++ b/js/ui/extensionSystem.js
@@ -0,0 +1,658 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported init connect disconnect */
+
+const { GLib, Gio, GObject, Shell, St } = imports.gi;
+const ByteArray = imports.byteArray;
+const Signals = imports.signals;
+
+const ExtensionDownloader = imports.ui.extensionDownloader;
+const ExtensionUtils = imports.misc.extensionUtils;
+const FileUtils = imports.misc.fileUtils;
+const Main = imports.ui.main;
+const MessageTray = imports.ui.messageTray;
+
+const { ExtensionState, ExtensionType } = ExtensionUtils;
+
+const ENABLED_EXTENSIONS_KEY = 'enabled-extensions';
+const DISABLED_EXTENSIONS_KEY = 'disabled-extensions';
+const DISABLE_USER_EXTENSIONS_KEY = 'disable-user-extensions';
+const EXTENSION_DISABLE_VERSION_CHECK_KEY = 'disable-extension-version-validation';
+
+const UPDATE_CHECK_TIMEOUT = 24 * 60 * 60; // 1 day in seconds
+
+var ExtensionManager = class {
+ constructor() {
+ this._initialized = false;
+ this._enabled = false;
+ this._updateNotified = false;
+
+ this._extensions = new Map();
+ this._unloadedExtensions = new Map();
+ this._enabledExtensions = [];
+ this._extensionOrder = [];
+
+ Main.sessionMode.connect('updated', this._sessionUpdated.bind(this));
+ }
+
+ init() {
+ // The following file should exist for a period of time when extensions
+ // are enabled after start. If it exists, then the systemd unit will
+ // disable extensions should gnome-shell crash.
+ // Should the file already exist from a previous login, then this is OK.
+ let disableFilename = GLib.build_filenamev([GLib.get_user_runtime_dir(), 'gnome-shell-disable-extensions']);
+ let disableFile = Gio.File.new_for_path(disableFilename);
+ try {
+ disableFile.create(Gio.FileCreateFlags.REPLACE_DESTINATION, null);
+ } catch (e) {
+ log('Failed to create file %s: %s'.format(disableFilename, e.message));
+ }
+
+ GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 60, () => {
+ disableFile.delete(null);
+ return GLib.SOURCE_REMOVE;
+ });
+
+ this._installExtensionUpdates();
+ this._sessionUpdated();
+
+ GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, UPDATE_CHECK_TIMEOUT, () => {
+ ExtensionDownloader.checkForUpdates();
+ return GLib.SOURCE_CONTINUE;
+ });
+ ExtensionDownloader.checkForUpdates();
+ }
+
+ get updatesSupported() {
+ const appSys = Shell.AppSystem.get_default();
+ return appSys.lookup_app('org.gnome.Extensions.desktop') !== null;
+ }
+
+ lookup(uuid) {
+ return this._extensions.get(uuid);
+ }
+
+ getUuids() {
+ return [...this._extensions.keys()];
+ }
+
+ _callExtensionDisable(uuid) {
+ let extension = this.lookup(uuid);
+ if (!extension)
+ return;
+
+ if (extension.state != ExtensionState.ENABLED)
+ return;
+
+ // "Rebase" the extension order by disabling and then enabling extensions
+ // in order to help prevent conflicts.
+
+ // Example:
+ // order = [A, B, C, D, E]
+ // user disables C
+ // this should: disable E, disable D, disable C, enable D, enable E
+
+ let orderIdx = this._extensionOrder.indexOf(uuid);
+ let order = this._extensionOrder.slice(orderIdx + 1);
+ let orderReversed = order.slice().reverse();
+
+ for (let i = 0; i < orderReversed.length; i++) {
+ let otherUuid = orderReversed[i];
+ try {
+ this.lookup(otherUuid).stateObj.disable();
+ } catch (e) {
+ this.logExtensionError(otherUuid, e);
+ }
+ }
+
+ try {
+ extension.stateObj.disable();
+ } catch (e) {
+ this.logExtensionError(uuid, e);
+ }
+
+ if (extension.stylesheet) {
+ let theme = St.ThemeContext.get_for_stage(global.stage).get_theme();
+ theme.unload_stylesheet(extension.stylesheet);
+ delete extension.stylesheet;
+ }
+
+ for (let i = 0; i < order.length; i++) {
+ let otherUuid = order[i];
+ try {
+ this.lookup(otherUuid).stateObj.enable();
+ } catch (e) {
+ this.logExtensionError(otherUuid, e);
+ }
+ }
+
+ this._extensionOrder.splice(orderIdx, 1);
+
+ if (extension.state != ExtensionState.ERROR) {
+ extension.state = ExtensionState.DISABLED;
+ this.emit('extension-state-changed', extension);
+ }
+ }
+
+ _callExtensionEnable(uuid) {
+ if (!Main.sessionMode.allowExtensions)
+ return;
+
+ let extension = this.lookup(uuid);
+ if (!extension)
+ return;
+
+ if (extension.state == ExtensionState.INITIALIZED)
+ this._callExtensionInit(uuid);
+
+ if (extension.state != ExtensionState.DISABLED)
+ return;
+
+ let stylesheetNames = ['%s.css'.format(global.session_mode), 'stylesheet.css'];
+ let theme = St.ThemeContext.get_for_stage(global.stage).get_theme();
+ for (let i = 0; i < stylesheetNames.length; i++) {
+ try {
+ let stylesheetFile = extension.dir.get_child(stylesheetNames[i]);
+ theme.load_stylesheet(stylesheetFile);
+ extension.stylesheet = stylesheetFile;
+ break;
+ } catch (e) {
+ if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
+ continue; // not an error
+ this.logExtensionError(uuid, e);
+ return;
+ }
+ }
+
+ try {
+ extension.stateObj.enable();
+ extension.state = ExtensionState.ENABLED;
+ this._extensionOrder.push(uuid);
+ this.emit('extension-state-changed', extension);
+ } catch (e) {
+ if (extension.stylesheet) {
+ theme.unload_stylesheet(extension.stylesheet);
+ delete extension.stylesheet;
+ }
+ this.logExtensionError(uuid, e);
+ }
+ }
+
+ enableExtension(uuid) {
+ if (!this._extensions.has(uuid))
+ return false;
+
+ let enabledExtensions = global.settings.get_strv(ENABLED_EXTENSIONS_KEY);
+ let disabledExtensions = global.settings.get_strv(DISABLED_EXTENSIONS_KEY);
+
+ if (disabledExtensions.includes(uuid)) {
+ disabledExtensions = disabledExtensions.filter(item => item !== uuid);
+ global.settings.set_strv(DISABLED_EXTENSIONS_KEY, disabledExtensions);
+ }
+
+ if (!enabledExtensions.includes(uuid)) {
+ enabledExtensions.push(uuid);
+ global.settings.set_strv(ENABLED_EXTENSIONS_KEY, enabledExtensions);
+ }
+
+ return true;
+ }
+
+ disableExtension(uuid) {
+ if (!this._extensions.has(uuid))
+ return false;
+
+ let enabledExtensions = global.settings.get_strv(ENABLED_EXTENSIONS_KEY);
+ let disabledExtensions = global.settings.get_strv(DISABLED_EXTENSIONS_KEY);
+
+ if (enabledExtensions.includes(uuid)) {
+ enabledExtensions = enabledExtensions.filter(item => item !== uuid);
+ global.settings.set_strv(ENABLED_EXTENSIONS_KEY, enabledExtensions);
+ }
+
+ if (!disabledExtensions.includes(uuid)) {
+ disabledExtensions.push(uuid);
+ global.settings.set_strv(DISABLED_EXTENSIONS_KEY, disabledExtensions);
+ }
+
+ return true;
+ }
+
+ openExtensionPrefs(uuid, parentWindow, options) {
+ const extension = this.lookup(uuid);
+ if (!extension || !extension.hasPrefs)
+ return false;
+
+ Gio.DBus.session.call(
+ 'org.gnome.Shell.Extensions',
+ '/org/gnome/Shell/Extensions',
+ 'org.gnome.Shell.Extensions',
+ 'OpenExtensionPrefs',
+ new GLib.Variant('(ssa{sv})', [uuid, parentWindow, options]),
+ null,
+ Gio.DBusCallFlags.NONE,
+ -1,
+ null);
+ return true;
+ }
+
+ notifyExtensionUpdate(uuid) {
+ let extension = this.lookup(uuid);
+ if (!extension)
+ return;
+
+ extension.hasUpdate = true;
+ this.emit('extension-state-changed', extension);
+
+ if (!this._updateNotified) {
+ this._updateNotified = true;
+
+ let source = new ExtensionUpdateSource();
+ Main.messageTray.add(source);
+
+ let notification = new MessageTray.Notification(source,
+ _('Extension Updates Available'),
+ _('Extension updates are ready to be installed.'));
+ notification.connect('activated',
+ () => source.open());
+ source.showNotification(notification);
+ }
+ }
+
+ logExtensionError(uuid, error) {
+ let extension = this.lookup(uuid);
+ if (!extension)
+ return;
+
+ const message = error instanceof Error
+ ? error.message : error.toString();
+
+ extension.error = message;
+ extension.state = ExtensionState.ERROR;
+ if (!extension.errors)
+ extension.errors = [];
+ extension.errors.push(message);
+
+ logError(error, 'Extension %s'.format(uuid));
+ this._updateCanChange(extension);
+ this.emit('extension-state-changed', extension);
+ }
+
+ createExtensionObject(uuid, dir, type) {
+ let metadataFile = dir.get_child('metadata.json');
+ if (!metadataFile.query_exists(null))
+ throw new Error('Missing metadata.json');
+
+ let metadataContents, success_;
+ try {
+ [success_, metadataContents] = metadataFile.load_contents(null);
+ metadataContents = ByteArray.toString(metadataContents);
+ } catch (e) {
+ throw new Error('Failed to load metadata.json: %s'.format(e.toString()));
+ }
+ let meta;
+ try {
+ meta = JSON.parse(metadataContents);
+ } catch (e) {
+ throw new Error('Failed to parse metadata.json: %s'.format(e.toString()));
+ }
+
+ let requiredProperties = ['uuid', 'name', 'description', 'shell-version'];
+ for (let i = 0; i < requiredProperties.length; i++) {
+ let prop = requiredProperties[i];
+ if (!meta[prop])
+ throw new Error('missing "%s" property in metadata.json'.format(prop));
+ }
+
+ if (uuid != meta.uuid)
+ throw new Error('uuid "%s" from metadata.json does not match directory name "%s"'.format(meta.uuid, uuid));
+
+ let extension = {
+ metadata: meta,
+ uuid: meta.uuid,
+ type,
+ dir,
+ path: dir.get_path(),
+ error: '',
+ hasPrefs: dir.get_child('prefs.js').query_exists(null),
+ hasUpdate: false,
+ canChange: false,
+ };
+ this._extensions.set(uuid, extension);
+
+ return extension;
+ }
+
+ _canLoad(extension) {
+ if (!this._unloadedExtensions.has(extension.uuid))
+ return true;
+
+ const version = this._unloadedExtensions.get(extension.uuid);
+ return extension.metadata.version === version;
+ }
+
+ loadExtension(extension) {
+ // Default to error, we set success as the last step
+ extension.state = ExtensionState.ERROR;
+
+ let checkVersion = !global.settings.get_boolean(EXTENSION_DISABLE_VERSION_CHECK_KEY);
+
+ if (checkVersion && ExtensionUtils.isOutOfDate(extension)) {
+ extension.state = ExtensionState.OUT_OF_DATE;
+ } else if (!this._canLoad(extension)) {
+ this.logExtensionError(extension.uuid, new Error(
+ 'A different version was loaded previously. You need to log out for changes to take effect.'));
+ } else {
+ let enabled = this._enabledExtensions.includes(extension.uuid);
+ if (enabled) {
+ if (!this._callExtensionInit(extension.uuid))
+ return;
+ if (extension.state == ExtensionState.DISABLED)
+ this._callExtensionEnable(extension.uuid);
+ } else {
+ extension.state = ExtensionState.INITIALIZED;
+ }
+
+ this._unloadedExtensions.delete(extension.uuid);
+ }
+
+ this._updateCanChange(extension);
+ this.emit('extension-state-changed', extension);
+ }
+
+ unloadExtension(extension) {
+ const { uuid, type } = extension;
+
+ // Try to disable it -- if it's ERROR'd, we can't guarantee that,
+ // but it will be removed on next reboot, and hopefully nothing
+ // broke too much.
+ this._callExtensionDisable(uuid);
+
+ extension.state = ExtensionState.UNINSTALLED;
+ this.emit('extension-state-changed', extension);
+
+ // If we did install an importer, it is now cached and it's
+ // impossible to load a different version
+ if (type === ExtensionType.PER_USER && extension.imports)
+ this._unloadedExtensions.set(uuid, extension.metadata.version);
+
+ this._extensions.delete(uuid);
+ return true;
+ }
+
+ reloadExtension(oldExtension) {
+ // Grab the things we'll need to pass to createExtensionObject
+ // to reload it.
+ let { uuid, dir, type } = oldExtension;
+
+ // Then unload the old extension.
+ this.unloadExtension(oldExtension);
+
+ // Now, recreate the extension and load it.
+ let newExtension;
+ try {
+ newExtension = this.createExtensionObject(uuid, dir, type);
+ } catch (e) {
+ this.logExtensionError(uuid, e);
+ return;
+ }
+
+ this.loadExtension(newExtension);
+ }
+
+ _callExtensionInit(uuid) {
+ if (!Main.sessionMode.allowExtensions)
+ return false;
+
+ let extension = this.lookup(uuid);
+ if (!extension)
+ throw new Error("Extension was not properly created. Call createExtensionObject first");
+
+ let dir = extension.dir;
+ let extensionJs = dir.get_child('extension.js');
+ if (!extensionJs.query_exists(null)) {
+ this.logExtensionError(uuid, new Error('Missing extension.js'));
+ return false;
+ }
+
+ let extensionModule;
+ let extensionState = null;
+
+ ExtensionUtils.installImporter(extension);
+ try {
+ extensionModule = extension.imports.extension;
+ } catch (e) {
+ this.logExtensionError(uuid, e);
+ return false;
+ }
+
+ if (extensionModule.init) {
+ try {
+ extensionState = extensionModule.init(extension);
+ } catch (e) {
+ this.logExtensionError(uuid, e);
+ return false;
+ }
+ }
+
+ if (!extensionState)
+ extensionState = extensionModule;
+ extension.stateObj = extensionState;
+
+ extension.state = ExtensionState.DISABLED;
+ this.emit('extension-loaded', uuid);
+ return true;
+ }
+
+ _getModeExtensions() {
+ if (Array.isArray(Main.sessionMode.enabledExtensions))
+ return Main.sessionMode.enabledExtensions;
+ return [];
+ }
+
+ _updateCanChange(extension) {
+ let hasError =
+ extension.state == ExtensionState.ERROR ||
+ extension.state == ExtensionState.OUT_OF_DATE;
+
+ let isMode = this._getModeExtensions().includes(extension.uuid);
+ let modeOnly = global.settings.get_boolean(DISABLE_USER_EXTENSIONS_KEY);
+
+ let changeKey = isMode
+ ? DISABLE_USER_EXTENSIONS_KEY
+ : ENABLED_EXTENSIONS_KEY;
+
+ extension.canChange =
+ !hasError &&
+ global.settings.is_writable(changeKey) &&
+ (isMode || !modeOnly);
+ }
+
+ _getEnabledExtensions() {
+ let extensions = this._getModeExtensions();
+
+ if (!global.settings.get_boolean(DISABLE_USER_EXTENSIONS_KEY))
+ extensions = extensions.concat(global.settings.get_strv(ENABLED_EXTENSIONS_KEY));
+
+ // filter out 'disabled-extensions' which takes precedence
+ let disabledExtensions = global.settings.get_strv(DISABLED_EXTENSIONS_KEY);
+ return extensions.filter(item => !disabledExtensions.includes(item));
+ }
+
+ _onUserExtensionsEnabledChanged() {
+ this._onEnabledExtensionsChanged();
+ this._onSettingsWritableChanged();
+ }
+
+ _onEnabledExtensionsChanged() {
+ let newEnabledExtensions = this._getEnabledExtensions();
+
+ // Find and enable all the newly enabled extensions: UUIDs found in the
+ // new setting, but not in the old one.
+ newEnabledExtensions
+ .filter(uuid => !this._enabledExtensions.includes(uuid))
+ .forEach(uuid => this._callExtensionEnable(uuid));
+
+ // Find and disable all the newly disabled extensions: UUIDs found in the
+ // old setting, but not in the new one.
+ this._extensionOrder
+ .filter(uuid => !newEnabledExtensions.includes(uuid))
+ .reverse().forEach(uuid => this._callExtensionDisable(uuid));
+
+ this._enabledExtensions = newEnabledExtensions;
+ }
+
+ _onSettingsWritableChanged() {
+ for (let extension of this._extensions.values()) {
+ this._updateCanChange(extension);
+ this.emit('extension-state-changed', extension);
+ }
+ }
+
+ _onVersionValidationChanged() {
+ // Disabling extensions modifies the order array, so use a copy
+ let extensionOrder = this._extensionOrder.slice();
+
+ // Disable enabled extensions in the reverse order first to avoid
+ // the "rebasing" done in _callExtensionDisable...
+ extensionOrder.slice().reverse().forEach(uuid => {
+ this._callExtensionDisable(uuid);
+ });
+
+ // ...and then reload and enable extensions in the correct order again.
+ [...this._extensions.values()].sort((a, b) => {
+ return extensionOrder.indexOf(a.uuid) - extensionOrder.indexOf(b.uuid);
+ }).forEach(extension => this.reloadExtension(extension));
+ }
+
+ _installExtensionUpdates() {
+ if (!this.updatesSupported)
+ return;
+
+ FileUtils.collectFromDatadirs('extension-updates', true, (dir, info) => {
+ let fileType = info.get_file_type();
+ if (fileType !== Gio.FileType.DIRECTORY)
+ return;
+ let uuid = info.get_name();
+ let extensionDir = Gio.File.new_for_path(
+ GLib.build_filenamev([global.userdatadir, 'extensions', uuid]));
+
+ try {
+ FileUtils.recursivelyDeleteDir(extensionDir, false);
+ FileUtils.recursivelyMoveDir(dir, extensionDir);
+ } catch (e) {
+ log('Failed to install extension updates for %s'.format(uuid));
+ } finally {
+ FileUtils.recursivelyDeleteDir(dir, true);
+ }
+ });
+ }
+
+ _loadExtensions() {
+ global.settings.connect('changed::%s'.format(ENABLED_EXTENSIONS_KEY),
+ this._onEnabledExtensionsChanged.bind(this));
+ global.settings.connect('changed::%s'.format(DISABLED_EXTENSIONS_KEY),
+ this._onEnabledExtensionsChanged.bind(this));
+ global.settings.connect('changed::%s'.format(DISABLE_USER_EXTENSIONS_KEY),
+ this._onUserExtensionsEnabledChanged.bind(this));
+ global.settings.connect('changed::%s'.format(EXTENSION_DISABLE_VERSION_CHECK_KEY),
+ this._onVersionValidationChanged.bind(this));
+ global.settings.connect('writable-changed::%s'.format(ENABLED_EXTENSIONS_KEY),
+ this._onSettingsWritableChanged.bind(this));
+ global.settings.connect('writable-changed::%s'.format(DISABLED_EXTENSIONS_KEY),
+ this._onSettingsWritableChanged.bind(this));
+
+ this._enabledExtensions = this._getEnabledExtensions();
+
+ let perUserDir = Gio.File.new_for_path(global.userdatadir);
+ FileUtils.collectFromDatadirs('extensions', true, (dir, info) => {
+ let fileType = info.get_file_type();
+ if (fileType != Gio.FileType.DIRECTORY)
+ return;
+ let uuid = info.get_name();
+ let existing = this.lookup(uuid);
+ if (existing) {
+ log('Extension %s already installed in %s. %s will not be loaded'.format(uuid, existing.path, dir.get_path()));
+ return;
+ }
+
+ let extension;
+ let type = dir.has_prefix(perUserDir)
+ ? ExtensionType.PER_USER
+ : ExtensionType.SYSTEM;
+ try {
+ extension = this.createExtensionObject(uuid, dir, type);
+ } catch (e) {
+ logError(e, 'Could not load extension %s'.format(uuid));
+ return;
+ }
+ this.loadExtension(extension);
+ });
+ }
+
+ _enableAllExtensions() {
+ if (this._enabled)
+ return;
+
+ if (!this._initialized) {
+ this._loadExtensions();
+ this._initialized = true;
+ } else {
+ this._enabledExtensions.forEach(uuid => {
+ this._callExtensionEnable(uuid);
+ });
+ }
+ this._enabled = true;
+ }
+
+ _disableAllExtensions() {
+ if (!this._enabled)
+ return;
+
+ if (this._initialized) {
+ this._extensionOrder.slice().reverse().forEach(uuid => {
+ this._callExtensionDisable(uuid);
+ });
+ }
+
+ this._enabled = false;
+ }
+
+ _sessionUpdated() {
+ // For now sessionMode.allowExtensions controls extensions from both the
+ // 'enabled-extensions' preference and the sessionMode.enabledExtensions
+ // property; it might make sense to make enabledExtensions independent
+ // from allowExtensions in the future
+ if (Main.sessionMode.allowExtensions) {
+ // Take care of added or removed sessionMode extensions
+ this._onEnabledExtensionsChanged();
+ this._enableAllExtensions();
+ } else {
+ this._disableAllExtensions();
+ }
+ }
+};
+Signals.addSignalMethods(ExtensionManager.prototype);
+
+const ExtensionUpdateSource = GObject.registerClass(
+class ExtensionUpdateSource extends MessageTray.Source {
+ _init() {
+ let appSys = Shell.AppSystem.get_default();
+ this._app = appSys.lookup_app('org.gnome.Extensions.desktop');
+
+ super._init(this._app.get_name());
+ }
+
+ getIcon() {
+ return this._app.app_info.get_icon();
+ }
+
+ _createPolicy() {
+ return new MessageTray.NotificationApplicationPolicy(this._app.id);
+ }
+
+ open() {
+ this._app.activate();
+ Main.overview.hide();
+ Main.panel.closeCalendar();
+ }
+});
diff --git a/js/ui/focusCaretTracker.js b/js/ui/focusCaretTracker.js
new file mode 100644
index 0000000..cc70ae5
--- /dev/null
+++ b/js/ui/focusCaretTracker.js
@@ -0,0 +1,89 @@
+/** -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */
+/*
+ * Copyright 2012 Inclusive Design Research Centre, OCAD University.
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library. If not, see <http://www.gnu.org/licenses/>.
+ *
+ * Author:
+ * Joseph Scheuhammer <clown@alum.mit.edu>
+ * Contributor:
+ * Magdalen Berns <m.berns@sms.ed.ac.uk>
+ */
+
+const Atspi = imports.gi.Atspi;
+const Signals = imports.signals;
+
+const CARETMOVED = 'object:text-caret-moved';
+const STATECHANGED = 'object:state-changed';
+
+var FocusCaretTracker = class FocusCaretTracker {
+ constructor() {
+ this._atspiListener = Atspi.EventListener.new(this._onChanged.bind(this));
+
+ this._atspiInited = false;
+ this._focusListenerRegistered = false;
+ this._caretListenerRegistered = false;
+ }
+
+ _onChanged(event) {
+ if (event.type.indexOf(STATECHANGED) == 0)
+ this.emit('focus-changed', event);
+ else if (event.type == CARETMOVED)
+ this.emit('caret-moved', event);
+ }
+
+ _initAtspi() {
+ if (!this._atspiInited && Atspi.init() == 0) {
+ Atspi.set_timeout(250, 250);
+ this._atspiInited = true;
+ }
+
+ return this._atspiInited;
+ }
+
+ registerFocusListener() {
+ if (!this._initAtspi() || this._focusListenerRegistered)
+ return;
+
+ this._atspiListener.register(`${STATECHANGED}:focused`);
+ this._atspiListener.register(`${STATECHANGED}:selected`);
+ this._focusListenerRegistered = true;
+ }
+
+ registerCaretListener() {
+ if (!this._initAtspi() || this._caretListenerRegistered)
+ return;
+
+ this._atspiListener.register(CARETMOVED);
+ this._caretListenerRegistered = true;
+ }
+
+ deregisterFocusListener() {
+ if (!this._focusListenerRegistered)
+ return;
+
+ this._atspiListener.deregister(`${STATECHANGED}:focused`);
+ this._atspiListener.deregister(`${STATECHANGED}:selected`);
+ this._focusListenerRegistered = false;
+ }
+
+ deregisterCaretListener() {
+ if (!this._caretListenerRegistered)
+ return;
+
+ this._atspiListener.deregister(CARETMOVED);
+ this._caretListenerRegistered = false;
+ }
+};
+Signals.addSignalMethods(FocusCaretTracker.prototype);
diff --git a/js/ui/grabHelper.js b/js/ui/grabHelper.js
new file mode 100644
index 0000000..2ba2aad
--- /dev/null
+++ b/js/ui/grabHelper.js
@@ -0,0 +1,332 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported GrabHelper */
+
+const { Clutter, St } = imports.gi;
+
+const Main = imports.ui.main;
+const Params = imports.misc.params;
+
+let _capturedEventId = 0;
+let _grabHelperStack = [];
+function _onCapturedEvent(actor, event) {
+ let grabHelper = _grabHelperStack[_grabHelperStack.length - 1];
+ return grabHelper.onCapturedEvent(event);
+}
+
+function _pushGrabHelper(grabHelper) {
+ _grabHelperStack.push(grabHelper);
+
+ if (_capturedEventId == 0)
+ _capturedEventId = global.stage.connect('captured-event', _onCapturedEvent);
+}
+
+function _popGrabHelper(grabHelper) {
+ let poppedHelper = _grabHelperStack.pop();
+ if (poppedHelper != grabHelper)
+ throw new Error("incorrect grab helper pop");
+
+ if (_grabHelperStack.length == 0) {
+ global.stage.disconnect(_capturedEventId);
+ _capturedEventId = 0;
+ }
+}
+
+// GrabHelper:
+// @owner: the actor that owns the GrabHelper
+// @params: optional parameters to pass to Main.pushModal()
+//
+// Creates a new GrabHelper object, for dealing with keyboard and pointer grabs
+// associated with a set of actors.
+//
+// Note that the grab can be automatically dropped at any time by the user, and
+// your code just needs to deal with it; you shouldn't adjust behavior directly
+// after you call ungrab(), but instead pass an 'onUngrab' callback when you
+// call grab().
+var GrabHelper = class GrabHelper {
+ constructor(owner, params) {
+ if (!(owner instanceof Clutter.Actor))
+ throw new Error('GrabHelper owner must be a Clutter.Actor');
+
+ this._owner = owner;
+ this._modalParams = params;
+
+ this._grabStack = [];
+
+ this._actors = [];
+ this._ignoreUntilRelease = false;
+
+ this._modalCount = 0;
+ }
+
+ // addActor:
+ // @actor: an actor
+ //
+ // Adds @actor to the set of actors that are allowed to process events
+ // during a grab.
+ addActor(actor) {
+ actor.__grabHelperDestroyId = actor.connect('destroy', () => {
+ this.removeActor(actor);
+ });
+ this._actors.push(actor);
+ }
+
+ // removeActor:
+ // @actor: an actor
+ //
+ // Removes @actor from the set of actors that are allowed to
+ // process events during a grab.
+ removeActor(actor) {
+ let index = this._actors.indexOf(actor);
+ if (index != -1)
+ this._actors.splice(index, 1);
+ if (actor.__grabHelperDestroyId) {
+ actor.disconnect(actor.__grabHelperDestroyId);
+ delete actor.__grabHelperDestroyId;
+ }
+ }
+
+ _isWithinGrabbedActor(actor) {
+ let currentActor = this.currentGrab.actor;
+ while (actor) {
+ if (this._actors.includes(actor))
+ return true;
+ if (actor == currentActor)
+ return true;
+ actor = actor.get_parent();
+ }
+ return false;
+ }
+
+ get currentGrab() {
+ return this._grabStack[this._grabStack.length - 1] || {};
+ }
+
+ get grabbed() {
+ return this._grabStack.length > 0;
+ }
+
+ get grabStack() {
+ return this._grabStack;
+ }
+
+ _findStackIndex(actor) {
+ if (!actor)
+ return -1;
+
+ for (let i = 0; i < this._grabStack.length; i++) {
+ if (this._grabStack[i].actor === actor)
+ return i;
+ }
+ return -1;
+ }
+
+ _actorInGrabStack(actor) {
+ while (actor) {
+ let idx = this._findStackIndex(actor);
+ if (idx >= 0)
+ return idx;
+ actor = actor.get_parent();
+ }
+ return -1;
+ }
+
+ isActorGrabbed(actor) {
+ return this._findStackIndex(actor) >= 0;
+ }
+
+ // grab:
+ // @params: A bunch of parameters, see below
+ //
+ // The general effect of a "grab" is to ensure that the passed in actor
+ // and all actors inside the grab get exclusive control of the mouse and
+ // keyboard, with the grab automatically being dropped if the user tries
+ // to dismiss it. The actor is passed in through @params.actor.
+ //
+ // grab() can be called multiple times, with the scope of the grab being
+ // changed to a different actor every time. A nested grab does not have
+ // to have its grabbed actor inside the parent grab actors.
+ //
+ // Grabs can be automatically dropped if the user tries to dismiss it
+ // in one of two ways: the user clicking outside the currently grabbed
+ // actor, or the user typing the Escape key.
+ //
+ // If the user clicks outside the grabbed actors, and the clicked on
+ // actor is part of a previous grab in the stack, grabs will be popped
+ // until that grab is active. However, the click event will not be
+ // replayed to the actor.
+ //
+ // If the user types the Escape key, one grab from the grab stack will
+ // be popped.
+ //
+ // When a grab is popped by user interacting as described above, if you
+ // pass a callback as @params.onUngrab, it will be called with %true.
+ //
+ // If @params.focus is not null, we'll set the key focus directly
+ // to that actor instead of navigating in @params.actor. This is for
+ // use cases like menus, where we want to grab the menu actor, but keep
+ // focus on the clicked on menu item.
+ grab(params) {
+ params = Params.parse(params, { actor: null,
+ focus: null,
+ onUngrab: null });
+
+ let focus = global.stage.key_focus;
+ let hadFocus = focus && this._isWithinGrabbedActor(focus);
+ let newFocus = params.actor;
+
+ if (this.isActorGrabbed(params.actor))
+ return true;
+
+ params.savedFocus = focus;
+
+ if (!this._takeModalGrab())
+ return false;
+
+ this._grabStack.push(params);
+
+ if (params.focus) {
+ params.focus.grab_key_focus();
+ } else if (newFocus && hadFocus) {
+ if (!newFocus.navigate_focus(null, St.DirectionType.TAB_FORWARD, false))
+ newFocus.grab_key_focus();
+ }
+
+ return true;
+ }
+
+ grabAsync(params) {
+ return new Promise((resolve, reject) => {
+ params.onUngrab = resolve;
+
+ if (!this.grab(params))
+ reject(new Error('Grab failed'));
+ });
+ }
+
+ _takeModalGrab() {
+ let firstGrab = this._modalCount == 0;
+ if (firstGrab) {
+ if (!Main.pushModal(this._owner, this._modalParams))
+ return false;
+
+ _pushGrabHelper(this);
+ }
+
+ this._modalCount++;
+ return true;
+ }
+
+ _releaseModalGrab() {
+ this._modalCount--;
+ if (this._modalCount > 0)
+ return;
+
+ _popGrabHelper(this);
+
+ this._ignoreUntilRelease = false;
+
+ Main.popModal(this._owner);
+ global.sync_pointer();
+ }
+
+ // ignoreRelease:
+ //
+ // Make sure that the next button release event evaluated by the
+ // capture event handler returns false. This is designed for things
+ // like the ComboBoxMenu that go away on press, but need to eat
+ // the next release event.
+ ignoreRelease() {
+ this._ignoreUntilRelease = true;
+ }
+
+ // ungrab:
+ // @params: The parameters for the grab; see below.
+ //
+ // Pops @params.actor from the grab stack, potentially dropping
+ // the grab. If the actor is not on the grab stack, this call is
+ // ignored with no ill effects.
+ //
+ // If the actor is not at the top of the grab stack, grabs are
+ // popped until the grabbed actor is at the top of the grab stack.
+ // The onUngrab callback for every grab is called for every popped
+ // grab with the parameter %false.
+ ungrab(params) {
+ params = Params.parse(params, { actor: this.currentGrab.actor,
+ isUser: false });
+
+ let grabStackIndex = this._findStackIndex(params.actor);
+ if (grabStackIndex < 0)
+ return;
+
+ let focus = global.stage.key_focus;
+ let hadFocus = focus && this._isWithinGrabbedActor(focus);
+
+ let poppedGrabs = this._grabStack.slice(grabStackIndex);
+ // "Pop" all newly ungrabbed actors off the grab stack
+ // by truncating the array.
+ this._grabStack.length = grabStackIndex;
+
+ for (let i = poppedGrabs.length - 1; i >= 0; i--) {
+ let poppedGrab = poppedGrabs[i];
+
+ if (poppedGrab.onUngrab)
+ poppedGrab.onUngrab(params.isUser);
+
+ this._releaseModalGrab();
+ }
+
+ if (hadFocus) {
+ let poppedGrab = poppedGrabs[0];
+ if (poppedGrab.savedFocus)
+ poppedGrab.savedFocus.grab_key_focus();
+ }
+ }
+
+ onCapturedEvent(event) {
+ let type = event.type();
+
+ if (type == Clutter.EventType.KEY_PRESS &&
+ event.get_key_symbol() == Clutter.KEY_Escape) {
+ this.ungrab({ isUser: true });
+ return Clutter.EVENT_STOP;
+ }
+
+ let motion = type == Clutter.EventType.MOTION;
+ let press = type == Clutter.EventType.BUTTON_PRESS;
+ let release = type == Clutter.EventType.BUTTON_RELEASE;
+ let button = press || release;
+
+ let touchUpdate = type == Clutter.EventType.TOUCH_UPDATE;
+ let touchBegin = type == Clutter.EventType.TOUCH_BEGIN;
+ let touchEnd = type == Clutter.EventType.TOUCH_END;
+ let touch = touchUpdate || touchBegin || touchEnd;
+
+ if (touch && !global.display.is_pointer_emulating_sequence(event.get_event_sequence()))
+ return Clutter.EVENT_PROPAGATE;
+
+ if (this._ignoreUntilRelease && (motion || release || touch)) {
+ if (release || touchEnd)
+ this._ignoreUntilRelease = false;
+ return Clutter.EVENT_STOP;
+ }
+
+ if (this._isWithinGrabbedActor(event.get_source()))
+ return Clutter.EVENT_PROPAGATE;
+
+ if (Main.keyboard.shouldTakeEvent(event))
+ return Clutter.EVENT_PROPAGATE;
+
+ if (button || touchBegin) {
+ // If we have a press event, ignore the next
+ // motion/release events.
+ if (press || touchBegin)
+ this._ignoreUntilRelease = true;
+
+ let i = this._actorInGrabStack(event.get_source()) + 1;
+ this.ungrab({ actor: this._grabStack[i].actor, isUser: true });
+ return Clutter.EVENT_STOP;
+ }
+
+ return Clutter.EVENT_STOP;
+ }
+};
diff --git a/js/ui/ibusCandidatePopup.js b/js/ui/ibusCandidatePopup.js
new file mode 100644
index 0000000..6c497b2
--- /dev/null
+++ b/js/ui/ibusCandidatePopup.js
@@ -0,0 +1,325 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported CandidatePopup */
+
+const { Clutter, GObject, IBus, St } = imports.gi;
+
+const BoxPointer = imports.ui.boxpointer;
+const Main = imports.ui.main;
+
+var MAX_CANDIDATES_PER_PAGE = 16;
+
+var DEFAULT_INDEX_LABELS = ['1', '2', '3', '4', '5', '6', '7', '8',
+ '9', '0', 'a', 'b', 'c', 'd', 'e', 'f'];
+
+var CandidateArea = GObject.registerClass({
+ Signals: {
+ 'candidate-clicked': { param_types: [GObject.TYPE_UINT,
+ GObject.TYPE_UINT,
+ Clutter.ModifierType.$gtype] },
+ 'cursor-down': {},
+ 'cursor-up': {},
+ 'next-page': {},
+ 'previous-page': {},
+ },
+}, class CandidateArea extends St.BoxLayout {
+ _init() {
+ super._init({
+ vertical: true,
+ reactive: true,
+ visible: false,
+ });
+ this._candidateBoxes = [];
+ for (let i = 0; i < MAX_CANDIDATES_PER_PAGE; ++i) {
+ let box = new St.BoxLayout({ style_class: 'candidate-box',
+ reactive: true,
+ track_hover: true });
+ box._indexLabel = new St.Label({ style_class: 'candidate-index' });
+ box._candidateLabel = new St.Label({ style_class: 'candidate-label' });
+ box.add_child(box._indexLabel);
+ box.add_child(box._candidateLabel);
+ this._candidateBoxes.push(box);
+ this.add(box);
+
+ let j = i;
+ box.connect('button-release-event', (actor, event) => {
+ this.emit('candidate-clicked', j, event.get_button(), event.get_state());
+ return Clutter.EVENT_PROPAGATE;
+ });
+ }
+
+ this._buttonBox = new St.BoxLayout({ style_class: 'candidate-page-button-box' });
+
+ this._previousButton = new St.Button({
+ style_class: 'candidate-page-button candidate-page-button-previous button',
+ x_expand: true,
+ });
+ this._previousButton.child = new St.Icon({ style_class: 'candidate-page-button-icon' });
+ this._buttonBox.add_child(this._previousButton);
+
+ this._nextButton = new St.Button({
+ style_class: 'candidate-page-button candidate-page-button-next button',
+ x_expand: true,
+ });
+ this._nextButton.child = new St.Icon({ style_class: 'candidate-page-button-icon' });
+ this._buttonBox.add_child(this._nextButton);
+
+ this.add(this._buttonBox);
+
+ this._previousButton.connect('clicked', () => {
+ this.emit('previous-page');
+ });
+ this._nextButton.connect('clicked', () => {
+ this.emit('next-page');
+ });
+
+ this._orientation = -1;
+ this._cursorPosition = 0;
+ }
+
+ vfunc_scroll_event(scrollEvent) {
+ switch (scrollEvent.direction) {
+ case Clutter.ScrollDirection.UP:
+ this.emit('cursor-up');
+ break;
+ case Clutter.ScrollDirection.DOWN:
+ this.emit('cursor-down');
+ break;
+ }
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ setOrientation(orientation) {
+ if (this._orientation == orientation)
+ return;
+
+ this._orientation = orientation;
+
+ if (this._orientation == IBus.Orientation.HORIZONTAL) {
+ this.vertical = false;
+ this.remove_style_class_name('vertical');
+ this.add_style_class_name('horizontal');
+ this._previousButton.child.icon_name = 'go-previous-symbolic';
+ this._nextButton.child.icon_name = 'go-next-symbolic';
+ } else { // VERTICAL || SYSTEM
+ this.vertical = true;
+ this.add_style_class_name('vertical');
+ this.remove_style_class_name('horizontal');
+ this._previousButton.child.icon_name = 'go-up-symbolic';
+ this._nextButton.child.icon_name = 'go-down-symbolic';
+ }
+ }
+
+ setCandidates(indexes, candidates, cursorPosition, cursorVisible) {
+ for (let i = 0; i < MAX_CANDIDATES_PER_PAGE; ++i) {
+ let visible = i < candidates.length;
+ let box = this._candidateBoxes[i];
+ box.visible = visible;
+
+ if (!visible)
+ continue;
+
+ box._indexLabel.text = indexes && indexes[i] ? indexes[i] : DEFAULT_INDEX_LABELS[i];
+ box._candidateLabel.text = candidates[i];
+ }
+
+ this._candidateBoxes[this._cursorPosition].remove_style_pseudo_class('selected');
+ this._cursorPosition = cursorPosition;
+ if (cursorVisible)
+ this._candidateBoxes[cursorPosition].add_style_pseudo_class('selected');
+ }
+
+ updateButtons(wrapsAround, page, nPages) {
+ if (nPages < 2) {
+ this._buttonBox.hide();
+ return;
+ }
+ this._buttonBox.show();
+ this._previousButton.reactive = wrapsAround || page > 0;
+ this._nextButton.reactive = wrapsAround || page < nPages - 1;
+ }
+});
+
+var CandidatePopup = GObject.registerClass(
+class IbusCandidatePopup extends BoxPointer.BoxPointer {
+ _init() {
+ super._init(St.Side.TOP);
+ this.visible = false;
+ this.style_class = 'candidate-popup-boxpointer';
+
+ this._dummyCursor = new St.Widget({ opacity: 0 });
+ Main.layoutManager.uiGroup.add_actor(this._dummyCursor);
+
+ Main.layoutManager.addChrome(this);
+
+ let box = new St.BoxLayout({ style_class: 'candidate-popup-content',
+ vertical: true });
+ this.bin.set_child(box);
+
+ this._preeditText = new St.Label({ style_class: 'candidate-popup-text',
+ visible: false });
+ box.add(this._preeditText);
+
+ this._auxText = new St.Label({ style_class: 'candidate-popup-text',
+ visible: false });
+ box.add(this._auxText);
+
+ this._candidateArea = new CandidateArea();
+ box.add(this._candidateArea);
+
+ this._candidateArea.connect('previous-page', () => {
+ this._panelService.page_up();
+ });
+ this._candidateArea.connect('next-page', () => {
+ this._panelService.page_down();
+ });
+
+ this._candidateArea.connect('cursor-up', () => {
+ this._panelService.cursor_up();
+ });
+ this._candidateArea.connect('cursor-down', () => {
+ this._panelService.cursor_down();
+ });
+
+ this._candidateArea.connect('candidate-clicked', (area, index, button, state) => {
+ this._panelService.candidate_clicked(index, button, state);
+ });
+
+ this._panelService = null;
+ }
+
+ setPanelService(panelService) {
+ this._panelService = panelService;
+ if (!panelService)
+ return;
+
+ panelService.connect('set-cursor-location', (ps, x, y, w, h) => {
+ this._setDummyCursorGeometry(x, y, w, h);
+ });
+ try {
+ panelService.connect('set-cursor-location-relative', (ps, x, y, w, h) => {
+ if (!global.display.focus_window)
+ return;
+ let window = global.display.focus_window.get_compositor_private();
+ this._setDummyCursorGeometry(window.x + x, window.y + y, w, h);
+ });
+ } catch (e) {
+ // Only recent IBus versions have support for this signal
+ // which is used for wayland clients. In order to work
+ // with older IBus versions we can silently ignore the
+ // signal's absence.
+ }
+ panelService.connect('update-preedit-text', (ps, text, cursorPosition, visible) => {
+ this._preeditText.visible = visible;
+ this._updateVisibility();
+
+ this._preeditText.text = text.get_text();
+
+ let attrs = text.get_attributes();
+ if (attrs) {
+ this._setTextAttributes(this._preeditText.clutter_text,
+ attrs);
+ }
+ });
+ panelService.connect('show-preedit-text', () => {
+ this._preeditText.show();
+ this._updateVisibility();
+ });
+ panelService.connect('hide-preedit-text', () => {
+ this._preeditText.hide();
+ this._updateVisibility();
+ });
+ panelService.connect('update-auxiliary-text', (_ps, text, visible) => {
+ this._auxText.visible = visible;
+ this._updateVisibility();
+
+ this._auxText.text = text.get_text();
+ });
+ panelService.connect('show-auxiliary-text', () => {
+ this._auxText.show();
+ this._updateVisibility();
+ });
+ panelService.connect('hide-auxiliary-text', () => {
+ this._auxText.hide();
+ this._updateVisibility();
+ });
+ panelService.connect('update-lookup-table', (_ps, lookupTable, visible) => {
+ this._candidateArea.visible = visible;
+ this._updateVisibility();
+
+ let nCandidates = lookupTable.get_number_of_candidates();
+ let cursorPos = lookupTable.get_cursor_pos();
+ let pageSize = lookupTable.get_page_size();
+ let nPages = Math.ceil(nCandidates / pageSize);
+ let page = cursorPos == 0 ? 0 : Math.floor(cursorPos / pageSize);
+ let startIndex = page * pageSize;
+ let endIndex = Math.min((page + 1) * pageSize, nCandidates);
+
+ let indexes = [];
+ let indexLabel;
+ for (let i = 0; (indexLabel = lookupTable.get_label(i)); ++i)
+ indexes.push(indexLabel.get_text());
+
+ Main.keyboard.resetSuggestions();
+
+ let candidates = [];
+ for (let i = startIndex; i < endIndex; ++i) {
+ candidates.push(lookupTable.get_candidate(i).get_text());
+
+ Main.keyboard.addSuggestion(lookupTable.get_candidate(i).get_text(), () => {
+ let index = i;
+ this._panelService.candidate_clicked(index, 1, 0);
+ });
+ }
+
+ this._candidateArea.setCandidates(indexes,
+ candidates,
+ cursorPos % pageSize,
+ lookupTable.is_cursor_visible());
+ this._candidateArea.setOrientation(lookupTable.get_orientation());
+ this._candidateArea.updateButtons(lookupTable.is_round(), page, nPages);
+ });
+ panelService.connect('show-lookup-table', () => {
+ this._candidateArea.show();
+ this._updateVisibility();
+ });
+ panelService.connect('hide-lookup-table', () => {
+ this._candidateArea.hide();
+ this._updateVisibility();
+ });
+ panelService.connect('focus-out', () => {
+ this.close(BoxPointer.PopupAnimation.NONE);
+ Main.keyboard.resetSuggestions();
+ });
+ }
+
+ _setDummyCursorGeometry(x, y, w, h) {
+ this._dummyCursor.set_position(Math.round(x), Math.round(y));
+ this._dummyCursor.set_size(Math.round(w), Math.round(h));
+
+ if (this.visible)
+ this.setPosition(this._dummyCursor, 0);
+ }
+
+ _updateVisibility() {
+ let isVisible = !Main.keyboard.visible &&
+ (this._preeditText.visible ||
+ this._auxText.visible ||
+ this._candidateArea.visible);
+
+ if (isVisible) {
+ this.setPosition(this._dummyCursor, 0);
+ this.open(BoxPointer.PopupAnimation.NONE);
+ this.get_parent().set_child_above_sibling(this, null);
+ } else {
+ this.close(BoxPointer.PopupAnimation.NONE);
+ }
+ }
+
+ _setTextAttributes(clutterText, ibusAttrList) {
+ let attr;
+ for (let i = 0; (attr = ibusAttrList.get(i)); ++i) {
+ if (attr.get_attr_type() == IBus.AttrType.BACKGROUND)
+ clutterText.set_selection(attr.get_start_index(), attr.get_end_index());
+ }
+ }
+});
diff --git a/js/ui/iconGrid.js b/js/ui/iconGrid.js
new file mode 100644
index 0000000..e41d4f6
--- /dev/null
+++ b/js/ui/iconGrid.js
@@ -0,0 +1,1741 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported BaseIcon, IconGrid, IconGridLayout */
+
+const { Clutter, GLib, GObject, Meta, St } = imports.gi;
+
+const Params = imports.misc.params;
+const Main = imports.ui.main;
+
+var ICON_SIZE = 96;
+
+var ANIMATION_TIME_IN = 350;
+var ANIMATION_TIME_OUT = 1 / 2 * ANIMATION_TIME_IN;
+var ANIMATION_MAX_DELAY_FOR_ITEM = 2 / 3 * ANIMATION_TIME_IN;
+var ANIMATION_MAX_DELAY_OUT_FOR_ITEM = 2 / 3 * ANIMATION_TIME_OUT;
+var ANIMATION_FADE_IN_TIME_FOR_ITEM = 1 / 4 * ANIMATION_TIME_IN;
+
+var PAGE_SWITCH_TIME = 300;
+
+var AnimationDirection = {
+ IN: 0,
+ OUT: 1,
+};
+
+var IconSize = {
+ LARGE: 96,
+ MEDIUM: 64,
+ SMALL: 32,
+ TINY: 16,
+};
+
+var APPICON_ANIMATION_OUT_SCALE = 3;
+var APPICON_ANIMATION_OUT_TIME = 250;
+
+const ICON_POSITION_DELAY = 10;
+
+const defaultGridModes = [
+ {
+ rows: 8,
+ columns: 3,
+ },
+ {
+ rows: 6,
+ columns: 4,
+ },
+ {
+ rows: 4,
+ columns: 6,
+ },
+ {
+ rows: 3,
+ columns: 8,
+ },
+];
+
+var LEFT_DIVIDER_LEEWAY = 20;
+var RIGHT_DIVIDER_LEEWAY = 20;
+
+var DragLocation = {
+ INVALID: 0,
+ START_EDGE: 1,
+ ON_ICON: 2,
+ END_EDGE: 3,
+ EMPTY_SPACE: 4,
+};
+
+var BaseIcon = GObject.registerClass(
+class BaseIcon extends St.Bin {
+ _init(label, params) {
+ params = Params.parse(params, { createIcon: null,
+ setSizeManually: false,
+ showLabel: true });
+
+ let styleClass = 'overview-icon';
+ if (params.showLabel)
+ styleClass += ' overview-icon-with-label';
+
+ super._init({ style_class: styleClass });
+
+ this.connect('destroy', this._onDestroy.bind(this));
+
+ this._box = new St.BoxLayout({
+ vertical: true,
+ x_expand: true,
+ y_expand: true,
+ });
+ this.set_child(this._box);
+
+ this.iconSize = ICON_SIZE;
+ this._iconBin = new St.Bin({ x_align: Clutter.ActorAlign.CENTER });
+
+ this._box.add_actor(this._iconBin);
+
+ if (params.showLabel) {
+ this.label = new St.Label({ text: label });
+ this.label.clutter_text.set({
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ this._box.add_actor(this.label);
+ } else {
+ this.label = null;
+ }
+
+ if (params.createIcon)
+ this.createIcon = params.createIcon;
+ this._setSizeManually = params.setSizeManually;
+
+ this.icon = null;
+
+ let cache = St.TextureCache.get_default();
+ this._iconThemeChangedId = cache.connect('icon-theme-changed', this._onIconThemeChanged.bind(this));
+ }
+
+ vfunc_get_preferred_width(_forHeight) {
+ // Return the actual height to keep the squared aspect
+ return this.get_preferred_height(-1);
+ }
+
+ // This can be overridden by a subclass, or by the createIcon
+ // parameter to _init()
+ createIcon(_size) {
+ throw new GObject.NotImplementedError(`createIcon in ${this.constructor.name}`);
+ }
+
+ setIconSize(size) {
+ if (!this._setSizeManually)
+ throw new Error('setSizeManually has to be set to use setIconsize');
+
+ if (size == this.iconSize)
+ return;
+
+ this._createIconTexture(size);
+ }
+
+ _createIconTexture(size) {
+ if (this.icon)
+ this.icon.destroy();
+ this.iconSize = size;
+ this.icon = this.createIcon(this.iconSize);
+
+ this._iconBin.child = this.icon;
+ }
+
+ vfunc_style_changed() {
+ super.vfunc_style_changed();
+ let node = this.get_theme_node();
+
+ let size;
+ if (this._setSizeManually) {
+ size = this.iconSize;
+ } else {
+ const { scaleFactor } =
+ St.ThemeContext.get_for_stage(global.stage);
+
+ let [found, len] = node.lookup_length('icon-size', false);
+ size = found ? len / scaleFactor : ICON_SIZE;
+ }
+
+ if (this.iconSize == size && this._iconBin.child)
+ return;
+
+ this._createIconTexture(size);
+ }
+
+ _onDestroy() {
+ if (this._iconThemeChangedId > 0) {
+ let cache = St.TextureCache.get_default();
+ cache.disconnect(this._iconThemeChangedId);
+ this._iconThemeChangedId = 0;
+ }
+ }
+
+ _onIconThemeChanged() {
+ this._createIconTexture(this.iconSize);
+ }
+
+ animateZoomOut() {
+ // Animate only the child instead of the entire actor, so the
+ // styles like hover and running are not applied while
+ // animating.
+ zoomOutActor(this.child);
+ }
+
+ animateZoomOutAtPos(x, y) {
+ zoomOutActorAtPos(this.child, x, y);
+ }
+
+ update() {
+ this._createIconTexture(this.iconSize);
+ }
+});
+
+function zoomOutActor(actor) {
+ let [x, y] = actor.get_transformed_position();
+ zoomOutActorAtPos(actor, x, y);
+}
+
+function zoomOutActorAtPos(actor, x, y) {
+ let actorClone = new Clutter.Clone({ source: actor,
+ reactive: false });
+ let [width, height] = actor.get_transformed_size();
+
+ actorClone.set_size(width, height);
+ actorClone.set_position(x, y);
+ actorClone.opacity = 255;
+ actorClone.set_pivot_point(0.5, 0.5);
+
+ Main.uiGroup.add_actor(actorClone);
+
+ // Avoid monitor edges to not zoom outside the current monitor
+ let monitor = Main.layoutManager.findMonitorForActor(actor);
+ let scaledWidth = width * APPICON_ANIMATION_OUT_SCALE;
+ let scaledHeight = height * APPICON_ANIMATION_OUT_SCALE;
+ let scaledX = x - (scaledWidth - width) / 2;
+ let scaledY = y - (scaledHeight - height) / 2;
+ let containedX = Math.clamp(scaledX, monitor.x, monitor.x + monitor.width - scaledWidth);
+ let containedY = Math.clamp(scaledY, monitor.y, monitor.y + monitor.height - scaledHeight);
+
+ actorClone.ease({
+ scale_x: APPICON_ANIMATION_OUT_SCALE,
+ scale_y: APPICON_ANIMATION_OUT_SCALE,
+ translation_x: containedX - scaledX,
+ translation_y: containedY - scaledY,
+ opacity: 0,
+ duration: APPICON_ANIMATION_OUT_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => actorClone.destroy(),
+ });
+}
+
+function animateIconPosition(icon, box, nChangedIcons) {
+ if (!icon.has_allocation() || icon.allocation.equal(box) || icon.opacity === 0) {
+ icon.allocate(box);
+ return false;
+ }
+
+ icon.save_easing_state();
+ icon.set_easing_mode(Clutter.AnimationMode.EASE_OUT_QUAD);
+ icon.set_easing_delay(nChangedIcons * ICON_POSITION_DELAY);
+
+ icon.allocate(box);
+
+ icon.restore_easing_state();
+
+ return true;
+}
+
+function swap(value, length) {
+ return length - value - 1;
+}
+
+var IconGridLayout = GObject.registerClass({
+ Properties: {
+ 'allow-incomplete-pages': GObject.ParamSpec.boolean('allow-incomplete-pages',
+ 'Allow incomplete pages', 'Allow incomplete pages',
+ GObject.ParamFlags.READWRITE,
+ true),
+ 'column-spacing': GObject.ParamSpec.int('column-spacing',
+ 'Column spacing', 'Column spacing',
+ GObject.ParamFlags.READWRITE,
+ 0, GLib.MAXINT32, 0),
+ 'columns-per-page': GObject.ParamSpec.int('columns-per-page',
+ 'Columns per page', 'Columns per page',
+ GObject.ParamFlags.READWRITE,
+ 1, GLib.MAXINT32, 1),
+ 'fixed-icon-size': GObject.ParamSpec.int('fixed-icon-size',
+ 'Fixed icon size', 'Fixed icon size',
+ GObject.ParamFlags.READWRITE,
+ -1, GLib.MAXINT32, -1),
+ 'icon-size': GObject.ParamSpec.int('icon-size',
+ 'Icon size', 'Icon size',
+ GObject.ParamFlags.READABLE,
+ 0, GLib.MAXINT32, 0),
+ 'last-row-align': GObject.ParamSpec.enum('last-row-align',
+ 'Last row align', 'Last row align',
+ GObject.ParamFlags.READWRITE,
+ Clutter.ActorAlign.$gtype,
+ Clutter.ActorAlign.FILL),
+ 'max-column-spacing': GObject.ParamSpec.int('max-column-spacing',
+ 'Maximum column spacing', 'Maximum column spacing',
+ GObject.ParamFlags.READWRITE,
+ -1, GLib.MAXINT32, -1),
+ 'max-row-spacing': GObject.ParamSpec.int('max-row-spacing',
+ 'Maximum row spacing', 'Maximum row spacing',
+ GObject.ParamFlags.READWRITE,
+ -1, GLib.MAXINT32, -1),
+ 'orientation': GObject.ParamSpec.enum('orientation',
+ 'Orientation', 'Orientation',
+ GObject.ParamFlags.READWRITE,
+ Clutter.Orientation.$gtype,
+ Clutter.Orientation.VERTICAL),
+ 'page-halign': GObject.ParamSpec.enum('page-halign',
+ 'Horizontal page align',
+ 'Horizontal page align',
+ GObject.ParamFlags.READWRITE,
+ Clutter.ActorAlign.$gtype,
+ Clutter.ActorAlign.FILL),
+ 'page-valign': GObject.ParamSpec.enum('page-valign',
+ 'Vertical page align',
+ 'Vertical page align',
+ GObject.ParamFlags.READWRITE,
+ Clutter.ActorAlign.$gtype,
+ Clutter.ActorAlign.FILL),
+ 'row-spacing': GObject.ParamSpec.int('row-spacing',
+ 'Row spacing', 'Row spacing',
+ GObject.ParamFlags.READWRITE,
+ 0, GLib.MAXINT32, 0),
+ 'rows-per-page': GObject.ParamSpec.int('rows-per-page',
+ 'Rows per page', 'Rows per page',
+ GObject.ParamFlags.READWRITE,
+ 1, GLib.MAXINT32, 1),
+ },
+ Signals: {
+ 'pages-changed': {},
+ },
+}, class IconGridLayout extends Clutter.LayoutManager {
+ _init(params = {}) {
+ params = Params.parse(params, {
+ allow_incomplete_pages: true,
+ column_spacing: 0,
+ columns_per_page: 6,
+ fixed_icon_size: -1,
+ last_row_align: Clutter.ActorAlign.FILL,
+ max_column_spacing: -1,
+ max_row_spacing: -1,
+ orientation: Clutter.Orientation.VERTICAL,
+ page_halign: Clutter.ActorAlign.FILL,
+ page_valign: Clutter.ActorAlign.FILL,
+ row_spacing: 0,
+ rows_per_page: 4,
+ });
+
+ this._allowIncompletePages = params.allow_incomplete_pages;
+ this._columnSpacing = params.column_spacing;
+ this._columnsPerPage = params.columns_per_page;
+ this._fixedIconSize = params.fixed_icon_size;
+ this._lastRowAlign = params.last_row_align;
+ this._maxColumnSpacing = params.max_column_spacing;
+ this._maxRowSpacing = params.max_row_spacing;
+ this._orientation = params.orientation;
+ this._pageHAlign = params.page_halign;
+ this._pageVAlign = params.page_valign;
+ this._rowSpacing = params.row_spacing;
+ this._rowsPerPage = params.rows_per_page;
+
+ super._init(params);
+
+ this._iconSize = this._fixedIconSize !== -1
+ ? this._fixedIconSize
+ : IconSize.LARGE;
+
+ this._pageSizeChanged = false;
+ this._pageHeight = 0;
+ this._pageWidth = 0;
+ this._nPages = -1;
+
+ // [
+ // {
+ // children: [ itemData, itemData, itemData, ... ],
+ // },
+ // {
+ // children: [ itemData, itemData, itemData, ... ],
+ // },
+ // {
+ // children: [ itemData, itemData, itemData, ... ],
+ // },
+ // ]
+ this._pages = [];
+
+ // {
+ // item: {
+ // actor: Clutter.Actor,
+ // pageIndex: <index>,
+ // },
+ // item: {
+ // actor: Clutter.Actor,
+ // pageIndex: <index>,
+ // },
+ // }
+ this._items = new Map();
+
+ this._containerDestroyedId = 0;
+ this._updateIconSizesLaterId = 0;
+
+ this._resolveOnIdleId = 0;
+ this._iconSizeUpdateResolveCbs = [];
+ }
+
+ _findBestIconSize() {
+ const nColumns = this._columnsPerPage;
+ const nRows = this._rowsPerPage;
+ const columnSpacingPerPage = this._columnSpacing * (nColumns - 1);
+ const rowSpacingPerPage = this._rowSpacing * (nRows - 1);
+ const [firstItem] = this._container;
+
+ if (this._fixedIconSize !== -1)
+ return this._fixedIconSize;
+
+ const iconSizes = Object.values(IconSize).sort((a, b) => b - a);
+ for (const size of iconSizes) {
+ let usedWidth, usedHeight;
+
+ if (firstItem) {
+ firstItem.icon.setIconSize(size);
+ const [firstItemWidth, firstItemHeight] =
+ firstItem.get_preferred_size();
+
+ const itemSize = Math.max(firstItemWidth, firstItemHeight);
+
+ usedWidth = itemSize * nColumns;
+ usedHeight = itemSize * nRows;
+ } else {
+ usedWidth = size * nColumns;
+ usedHeight = size * nRows;
+ }
+
+ const emptyHSpace =
+ this._pageWidth - usedWidth - columnSpacingPerPage;
+ const emptyVSpace =
+ this._pageHeight - usedHeight - rowSpacingPerPage;
+
+ if (emptyHSpace >= 0 && emptyVSpace > 0)
+ return size;
+ }
+
+ return IconSize.TINY;
+ }
+
+ _getChildrenMaxSize() {
+ let minWidth = 0;
+ let minHeight = 0;
+
+ for (const child of this._container) {
+ if (!child.visible)
+ continue;
+
+ const [childMinHeight] = child.get_preferred_height(-1);
+ const [childMinWidth] = child.get_preferred_width(-1);
+
+ minWidth = Math.max(minWidth, childMinWidth);
+ minHeight = Math.max(minHeight, childMinHeight);
+ }
+
+ return Math.max(minWidth, minHeight);
+ }
+
+ _getVisibleChildrenForPage(pageIndex) {
+ return this._pages[pageIndex].children.filter(actor => actor.visible);
+ }
+
+ _updatePages() {
+ for (let i = 0; i < this._pages.length; i++)
+ this._relocateSurplusItems(i);
+ }
+
+ _unlinkItem(item) {
+ const itemData = this._items.get(item);
+
+ item.disconnect(itemData.destroyId);
+ item.disconnect(itemData.visibleId);
+
+ this._items.delete(item);
+ }
+
+ _removePage(pageIndex) {
+ // Make sure to not leave any icon left here
+ this._pages[pageIndex].children.forEach(item => {
+ this._unlinkItem(item);
+ });
+
+ // Adjust the page indexes of items after this page
+ for (const itemData of this._items.values()) {
+ if (itemData.pageIndex > pageIndex)
+ itemData.pageIndex--;
+ }
+
+ this._pages.splice(pageIndex, 1);
+ this.emit('pages-changed');
+ }
+
+ _fillItemVacancies(pageIndex) {
+ if (pageIndex >= this._pages.length - 1)
+ return;
+
+ const visiblePageItems = this._getVisibleChildrenForPage(pageIndex);
+ const itemsPerPage = this._columnsPerPage * this._rowsPerPage;
+
+ // No reduce needed
+ if (visiblePageItems.length === itemsPerPage)
+ return;
+
+ const visibleNextPageItems = this._getVisibleChildrenForPage(pageIndex + 1);
+ const nMissingItems = Math.min(itemsPerPage - visiblePageItems.length, visibleNextPageItems.length);
+
+ // Append to the current page the first items of the next page
+ for (let i = 0; i < nMissingItems; i++) {
+ const reducedItem = visibleNextPageItems[i];
+
+ this._removeItemData(reducedItem);
+ this._addItemToPage(reducedItem, pageIndex, -1);
+ }
+ }
+
+ _removeItemData(item) {
+ const itemData = this._items.get(item);
+ const pageIndex = itemData.pageIndex;
+ const page = this._pages[pageIndex];
+ const itemIndex = page.children.indexOf(item);
+
+ this._unlinkItem(item);
+
+ page.children.splice(itemIndex, 1);
+
+ // Delete the page if this is the last icon in it
+ const visibleItems = this._getVisibleChildrenForPage(pageIndex);
+ if (visibleItems.length === 0)
+ this._removePage(pageIndex);
+
+ if (!this._allowIncompletePages)
+ this._fillItemVacancies(pageIndex);
+ }
+
+ _relocateSurplusItems(pageIndex) {
+ const visiblePageItems = this._getVisibleChildrenForPage(pageIndex);
+ const itemsPerPage = this._columnsPerPage * this._rowsPerPage;
+
+ // No overflow needed
+ if (visiblePageItems.length <= itemsPerPage)
+ return;
+
+ const nExtraItems = visiblePageItems.length - itemsPerPage;
+ for (let i = 0; i < nExtraItems; i++) {
+ const overflowIndex = visiblePageItems.length - i - 1;
+ const overflowItem = visiblePageItems[overflowIndex];
+
+ this._removeItemData(overflowItem);
+ this._addItemToPage(overflowItem, pageIndex + 1, 0);
+ }
+ }
+
+ _appendPage() {
+ this._pages.push({ children: [] });
+ this.emit('pages-changed');
+ }
+
+ _addItemToPage(item, pageIndex, index) {
+ // Ensure we have at least one page
+ if (this._pages.length === 0)
+ this._appendPage();
+
+ // Append a new page if necessary
+ if (pageIndex === this._pages.length)
+ this._appendPage();
+
+ if (pageIndex === -1)
+ pageIndex = this._pages.length - 1;
+
+ if (index === -1)
+ index = this._pages[pageIndex].children.length;
+
+ this._items.set(item, {
+ actor: item,
+ pageIndex,
+ destroyId: item.connect('destroy', () => this._removeItemData(item)),
+ visibleId: item.connect('notify::visible', () => {
+ const itemData = this._items.get(item);
+
+ if (item.visible)
+ this._relocateSurplusItems(itemData.pageIndex);
+ else if (!this._allowIncompletePages)
+ this._fillItemVacancies(itemData.pageIndex);
+ }),
+ });
+
+ item.icon.setIconSize(this._iconSize);
+
+ this._pages[pageIndex].children.splice(index, 0, item);
+ this._relocateSurplusItems(pageIndex);
+ }
+
+ _calculateSpacing(childSize) {
+ const nColumns = this._columnsPerPage;
+ const nRows = this._rowsPerPage;
+ const usedWidth = childSize * nColumns;
+ const usedHeight = childSize * nRows;
+ const columnSpacingPerPage = this._columnSpacing * (nColumns - 1);
+ const rowSpacingPerPage = this._rowSpacing * (nRows - 1);
+
+ let emptyHSpace = this._pageWidth - usedWidth - columnSpacingPerPage;
+ let emptyVSpace = this._pageHeight - usedHeight - rowSpacingPerPage;
+ let leftEmptySpace;
+ let topEmptySpace;
+ let hSpacing;
+ let vSpacing;
+
+ switch (this._pageHAlign) {
+ case Clutter.ActorAlign.START:
+ leftEmptySpace = 0;
+ hSpacing = this._columnSpacing;
+ break;
+ case Clutter.ActorAlign.CENTER:
+ leftEmptySpace = Math.floor(emptyHSpace / 2);
+ hSpacing = this._columnSpacing;
+ break;
+ case Clutter.ActorAlign.END:
+ leftEmptySpace = emptyHSpace;
+ hSpacing = this._columnSpacing;
+ break;
+ case Clutter.ActorAlign.FILL:
+ leftEmptySpace = 0;
+ hSpacing = this._columnSpacing + emptyHSpace / (nColumns - 1);
+
+ // Maybe constraint horizontal spacing
+ if (this._maxColumnSpacing !== -1 && hSpacing > this._maxColumnSpacing) {
+ const extraHSpacing =
+ (this._maxColumnSpacing - this._columnSpacing) * (nColumns - 1);
+
+ hSpacing = this._maxColumnSpacing;
+ leftEmptySpace =
+ Math.max((emptyHSpace - extraHSpacing) / 2, 0);
+ }
+ break;
+ }
+
+ switch (this._pageVAlign) {
+ case Clutter.ActorAlign.START:
+ topEmptySpace = 0;
+ vSpacing = this._rowSpacing;
+ break;
+ case Clutter.ActorAlign.CENTER:
+ topEmptySpace = Math.floor(emptyVSpace / 2);
+ vSpacing = this._rowSpacing;
+ break;
+ case Clutter.ActorAlign.END:
+ topEmptySpace = emptyVSpace;
+ vSpacing = this._rowSpacing;
+ break;
+ case Clutter.ActorAlign.FILL:
+ topEmptySpace = 0;
+ vSpacing = this._rowSpacing + emptyVSpace / (nRows - 1);
+
+ // Maybe constraint vertical spacing
+ if (this._maxRowSpacing !== -1 && vSpacing > this._maxRowSpacing) {
+ const extraVSpacing =
+ (this._maxRowSpacing - this._rowSpacing) * (nRows - 1);
+
+ vSpacing = this._maxRowSpacing;
+ topEmptySpace =
+ Math.max((emptyVSpace - extraVSpacing) / 2, 0);
+ }
+
+ break;
+ }
+
+ return [leftEmptySpace, topEmptySpace, hSpacing, vSpacing];
+ }
+
+ _getRowPadding(items, itemIndex, childSize, spacing) {
+ const nRows = Math.ceil(items.length / this._columnsPerPage);
+
+ let rowAlign = 0;
+ const row = Math.floor(itemIndex / this._columnsPerPage);
+
+ // Only apply to the last row
+ if (row < nRows - 1)
+ return 0;
+
+ const rowStart = row * this._columnsPerPage;
+ const rowEnd = Math.min((row + 1) * this._columnsPerPage - 1, items.length - 1);
+ const itemsInThisRow = rowEnd - rowStart + 1;
+ const nEmpty = this._columnsPerPage - itemsInThisRow;
+ const availableWidth = nEmpty * (spacing + childSize);
+
+ const isRtl =
+ Clutter.get_default_text_direction() === Clutter.TextDirection.RTL;
+
+ switch (this._lastRowAlign) {
+ case Clutter.ActorAlign.START:
+ rowAlign = 0;
+ break;
+ case Clutter.ActorAlign.CENTER:
+ rowAlign = availableWidth / 2;
+ break;
+ case Clutter.ActorAlign.END:
+ rowAlign = availableWidth;
+ break;
+ case Clutter.ActorAlign.FILL:
+ rowAlign = 0;
+ break;
+ }
+
+ return isRtl ? rowAlign * -1 : rowAlign;
+ }
+
+ _runPostAllocation() {
+ if (this._iconSizeUpdateResolveCbs.length > 0 &&
+ this._resolveOnIdleId === 0) {
+ this._resolveOnIdleId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
+ this._iconSizeUpdateResolveCbs.forEach(cb => cb());
+ this._iconSizeUpdateResolveCbs = [];
+ this._resolveOnIdleId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+ }
+
+ _onDestroy() {
+ if (this._updateIconSizesLaterId >= 0) {
+ Meta.later_remove(this._updateIconSizesLaterId);
+ this._updateIconSizesLaterId = 0;
+ }
+
+ if (this._resolveOnIdleId > 0) {
+ GLib.source_remove(this._resolveOnIdleId);
+ delete this._resolveOnIdleId;
+ }
+ }
+
+ vfunc_set_container(container) {
+ if (this._container)
+ this._container.disconnect(this._containerDestroyedId);
+
+ this._container = container;
+
+ if (this._container)
+ this._containerDestroyedId = this._container.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ vfunc_get_preferred_width(_container, _forHeight) {
+ let minWidth = -1;
+ let natWidth = -1;
+
+ switch (this._orientation) {
+ case Clutter.Orientation.VERTICAL:
+ minWidth = IconSize.TINY;
+ natWidth = this._pageWidth;
+ break;
+
+ case Clutter.Orientation.HORIZONTAL:
+ minWidth = this._pageWidth * this._pages.length;
+ natWidth = minWidth;
+ break;
+ }
+
+ return [minWidth, natWidth];
+ }
+
+ vfunc_get_preferred_height(_container, _forWidth) {
+ let minHeight = -1;
+ let natHeight = -1;
+
+ switch (this._orientation) {
+ case Clutter.Orientation.VERTICAL:
+ minHeight = this._pageHeight * this._pages.length;
+ natHeight = minHeight;
+ break;
+
+ case Clutter.Orientation.HORIZONTAL:
+ minHeight = IconSize.TINY;
+ natHeight = this._pageHeight;
+ break;
+ }
+
+ return [minHeight, natHeight];
+ }
+
+ vfunc_allocate() {
+ if (this._pageWidth === 0 || this._pageHeight === 0)
+ throw new Error('IconGridLayout.adaptToSize wasn\'t called before allocation');
+
+ this._updatePages();
+
+ const isRtl =
+ Clutter.get_default_text_direction() === Clutter.TextDirection.RTL;
+ const childSize = this._getChildrenMaxSize();
+
+ const [leftEmptySpace, topEmptySpace, hSpacing, vSpacing] =
+ this._calculateSpacing(childSize);
+
+ const childBox = new Clutter.ActorBox();
+ childBox.set_size(childSize, childSize);
+
+ let nChangedIcons = 0;
+
+ this._pages.forEach((page, pageIndex) => {
+ const visibleItems =
+ page.children.filter(actor => actor.visible);
+
+ if (isRtl && this._orientation === Clutter.Orientation.HORIZONTAL)
+ pageIndex = swap(pageIndex, this._pages.length);
+
+ visibleItems.forEach((item, itemIndex) => {
+ const row = Math.floor(itemIndex / this._columnsPerPage);
+ let column = itemIndex % this._columnsPerPage;
+
+ if (isRtl)
+ column = swap(column, this._columnsPerPage);
+
+ const rowPadding = this._getRowPadding(visibleItems, itemIndex,
+ childSize, hSpacing);
+
+ // Icon position
+ let x = leftEmptySpace + rowPadding + column * (childSize + hSpacing);
+ let y = topEmptySpace + row * (childSize + vSpacing);
+
+ // Page start
+ switch (this._orientation) {
+ case Clutter.Orientation.HORIZONTAL:
+ x += pageIndex * this._pageWidth;
+ break;
+ case Clutter.Orientation.VERTICAL:
+ y += pageIndex * this._pageHeight;
+ break;
+ }
+
+ childBox.set_origin(Math.floor(x), Math.floor(y));
+
+ // Only ease icons when the page size didn't change
+ if (this._pageSizeChanged)
+ item.allocate(childBox);
+ else if (animateIconPosition(item, childBox, nChangedIcons))
+ nChangedIcons++;
+ });
+ });
+
+ this._pageSizeChanged = false;
+
+ this._runPostAllocation();
+ }
+
+ /**
+ * addItem:
+ * @param {Clutter.Actor} item: item to append to the grid
+ * @param {int} page: page number
+ * @param {int} index: position in the page
+ *
+ * Adds @item to the grid. @item must not be part of the grid.
+ *
+ * If @index exceeds the number of items per page, @item will
+ * be added to the next page.
+ *
+ * @page must be a number between 0 and the number of pages.
+ * Adding to the page after next will create a new page.
+ */
+ addItem(item, page = -1, index = -1) {
+ if (this._items.has(item))
+ throw new Error(`Item ${item} already added to IconGridLayout`);
+
+ if (page > this._pages.length)
+ throw new Error(`Cannot add ${item} to page ${page}`);
+
+ if (!this._container)
+ return;
+
+ this._container.add_child(item);
+ this._addItemToPage(item, page, index);
+ }
+
+ /**
+ * appendItem:
+ * @param {Clutter.Actor} item: item to append to the grid
+ *
+ * Appends @item to the grid. @item must not be part of the grid.
+ */
+ appendItem(item) {
+ this.addItem(item);
+ }
+
+ /**
+ * moveItem:
+ * @param {Clutter.Actor} item: item to move
+ * @param {int} newPage: new page of the item
+ * @param {int} newPosition: new page of the item
+ *
+ * Moves @item to the grid. @item must be part of the grid.
+ */
+ moveItem(item, newPage, newPosition) {
+ if (!this._items.has(item))
+ throw new Error(`Item ${item} is not part of the IconGridLayout`);
+
+ this._removeItemData(item);
+ this._addItemToPage(item, newPage, newPosition);
+ }
+
+ /**
+ * removeItem:
+ * @param {Clutter.Actor} item: item to remove from the grid
+ *
+ * Removes @item to the grid. @item must be part of the grid.
+ */
+ removeItem(item) {
+ if (!this._items.has(item))
+ throw new Error(`Item ${item} is not part of the IconGridLayout`);
+
+ if (!this._container)
+ return;
+
+ this._container.remove_child(item);
+ this._removeItemData(item);
+ }
+
+ /**
+ * getItemsAtPage:
+ * @param {int} pageIndex: page index
+ *
+ * Retrieves the children at page @pageIndex. Children may be invisible.
+ *
+ * @returns {Array} an array of {Clutter.Actor}s
+ */
+ getItemsAtPage(pageIndex) {
+ if (pageIndex >= this._pages.length)
+ throw new Error(`IconGridLayout does not have page ${pageIndex}`);
+
+ return [...this._pages[pageIndex].children];
+ }
+
+ /**
+ * getItemPosition:
+ * @param {BaseIcon} item: the item
+ *
+ * Retrieves the position of @item is its page, or -1 if @item is not
+ * part of the grid.
+ *
+ * @returns {[int, int]} the page and position of @item
+ */
+ getItemPosition(item) {
+ if (!this._items.has(item))
+ return [-1, -1];
+
+ const itemData = this._items.get(item);
+ const visibleItems = this._getVisibleChildrenForPage(itemData.pageIndex);
+
+ return [itemData.pageIndex, visibleItems.indexOf(item)];
+ }
+
+ /**
+ * getItemAt:
+ * @param {int} page: the page
+ * @param {int} position: the position in page
+ *
+ * Retrieves the item at @page and @position.
+ *
+ * @returns {BaseItem} the item at @page and @position, or null
+ */
+ getItemAt(page, position) {
+ if (page < 0 || page >= this._pages.length)
+ return null;
+
+ const visibleItems = this._getVisibleChildrenForPage(page);
+
+ if (position < 0 || position >= visibleItems.length)
+ return null;
+
+ return visibleItems[position];
+ }
+
+ /**
+ * getItemPage:
+ * @param {BaseIcon} item: the item
+ *
+ * Retrieves the page @item is in, or -1 if @item is not part of the grid.
+ *
+ * @returns {int} the page where @item is in
+ */
+ getItemPage(item) {
+ if (!this._items.has(item))
+ return -1;
+
+ const itemData = this._items.get(item);
+ return itemData.pageIndex;
+ }
+
+ ensureIconSizeUpdated() {
+ if (this._updateIconSizesLaterId === 0)
+ return Promise.resolve();
+
+ return new Promise(
+ resolve => this._iconSizeUpdateResolveCbs.push(resolve));
+ }
+
+ adaptToSize(pageWidth, pageHeight) {
+ if (this._pageWidth === pageWidth && this._pageHeight === pageHeight)
+ return;
+
+ this._pageWidth = pageWidth;
+ this._pageHeight = pageHeight;
+ this._pageSizeChanged = true;
+
+ if (this._updateIconSizesLaterId === 0) {
+ this._updateIconSizesLaterId =
+ Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
+ const iconSize = this._findBestIconSize();
+
+ if (this._iconSize !== iconSize) {
+ this._iconSize = iconSize;
+
+ for (const child of this._container)
+ child.icon.setIconSize(iconSize);
+
+ this.notify('icon-size');
+ }
+
+ this._updateIconSizesLaterId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+ }
+
+ /**
+ * getDropTarget:
+ * @param {int} x: position of the horizontal axis
+ * @param {int} y: position of the vertical axis
+ *
+ * Retrieves the item located at (@x, @y), as well as the drag location.
+ * Both @x and @y are relative to the grid.
+ *
+ * @returns {[Clutter.Actor, DragLocation]} the item and drag location
+ * under (@x, @y)
+ */
+ getDropTarget(x, y) {
+ const childSize = this._getChildrenMaxSize();
+ const [leftEmptySpace, topEmptySpace, hSpacing, vSpacing] =
+ this._calculateSpacing(childSize);
+
+ const isRtl =
+ Clutter.get_default_text_direction() === Clutter.TextDirection.RTL;
+
+ let page = this._orientation === Clutter.Orientation.VERTICAL
+ ? Math.floor(y / this._pageHeight)
+ : Math.floor(x / this._pageWidth);
+
+ // Out of bounds
+ if (page >= this._pages.length)
+ return [null, DragLocation.INVALID];
+
+ if (isRtl && this._orientation === Clutter.Orientation.HORIZONTAL)
+ page = swap(page, this._pages.length);
+
+ // Page-relative coordinates from now on
+ x %= this._pageWidth;
+ y %= this._pageHeight;
+
+ if (x < leftEmptySpace || y < topEmptySpace)
+ return [null, DragLocation.INVALID];
+
+ const gridWidth =
+ childSize * this._columnsPerPage +
+ hSpacing * (this._columnsPerPage - 1);
+ const gridHeight =
+ childSize * this._rowsPerPage +
+ vSpacing * (this._rowsPerPage - 1);
+
+ if (x > leftEmptySpace + gridWidth || y > topEmptySpace + gridHeight)
+ return [null, DragLocation.INVALID];
+
+ const halfHSpacing = hSpacing / 2;
+ const halfVSpacing = vSpacing / 2;
+ const visibleItems = this._getVisibleChildrenForPage(page);
+
+ for (const item of visibleItems) {
+ const childBox = item.allocation.copy();
+
+ // Page offset
+ switch (this._orientation) {
+ case Clutter.Orientation.HORIZONTAL:
+ childBox.set_origin(childBox.x1 % this._pageWidth, childBox.y1);
+ break;
+ case Clutter.Orientation.VERTICAL:
+ childBox.set_origin(childBox.x1, childBox.y1 % this._pageHeight);
+ break;
+ }
+
+ // Outside the icon boundaries
+ if (x < childBox.x1 - halfHSpacing ||
+ x > childBox.x2 + halfHSpacing ||
+ y < childBox.y1 - halfVSpacing ||
+ y > childBox.y2 + halfVSpacing)
+ continue;
+
+ let dragLocation;
+
+ if (x < childBox.x1 + LEFT_DIVIDER_LEEWAY)
+ dragLocation = DragLocation.START_EDGE;
+ else if (x > childBox.x2 - RIGHT_DIVIDER_LEEWAY)
+ dragLocation = DragLocation.END_EDGE;
+ else
+ dragLocation = DragLocation.ON_ICON;
+
+ if (isRtl) {
+ if (dragLocation === DragLocation.START_EDGE)
+ dragLocation = DragLocation.END_EDGE;
+ else if (dragLocation === DragLocation.END_EDGE)
+ dragLocation = DragLocation.START_EDGE;
+ }
+
+ return [item, dragLocation];
+ }
+
+ return [null, DragLocation.EMPTY_SPACE];
+ }
+
+ // eslint-disable-next-line camelcase
+ get allow_incomplete_pages() {
+ return this._allowIncompletePages;
+ }
+
+ // eslint-disable-next-line camelcase
+ set allow_incomplete_pages(v) {
+ if (this._allowIncompletePages === v)
+ return;
+
+ this._allowIncompletePages = v;
+ this.notify('allow-incomplete-pages');
+ }
+
+ // eslint-disable-next-line camelcase
+ get column_spacing() {
+ return this._columnSpacing;
+ }
+
+ // eslint-disable-next-line camelcase
+ set column_spacing(v) {
+ if (this._columnSpacing === v)
+ return;
+
+ this._columnSpacing = v;
+ this.notify('column-spacing');
+ }
+
+ // eslint-disable-next-line camelcase
+ get columns_per_page() {
+ return this._columnsPerPage;
+ }
+
+ // eslint-disable-next-line camelcase
+ set columns_per_page(v) {
+ if (this._columnsPerPage === v)
+ return;
+
+ this._columnsPerPage = v;
+ this.notify('columns-per-page');
+ }
+
+ // eslint-disable-next-line camelcase
+ get fixed_icon_size() {
+ return this._fixedIconSize;
+ }
+
+ // eslint-disable-next-line camelcase
+ set fixed_icon_size(v) {
+ if (this._fixedIconSize === v)
+ return;
+
+ this._fixedIconSize = v;
+ this.notify('fixed-icon-size');
+ }
+
+ // eslint-disable-next-line camelcase
+ get icon_size() {
+ return this._iconSize;
+ }
+
+ // eslint-disable-next-line camelcase
+ get last_row_align() {
+ return this._lastRowAlign;
+ }
+
+ // eslint-disable-next-line camelcase
+ get max_column_spacing() {
+ return this._maxColumnSpacing;
+ }
+
+ // eslint-disable-next-line camelcase
+ set max_column_spacing(v) {
+ if (this._maxColumnSpacing === v)
+ return;
+
+ this._maxColumnSpacing = v;
+ this.notify('max-column-spacing');
+ }
+
+ // eslint-disable-next-line camelcase
+ get max_row_spacing() {
+ return this._maxRowSpacing;
+ }
+
+ // eslint-disable-next-line camelcase
+ set max_row_spacing(v) {
+ if (this._maxRowSpacing === v)
+ return;
+
+ this._maxRowSpacing = v;
+ this.notify('max-row-spacing');
+ }
+
+ // eslint-disable-next-line camelcase
+ set last_row_align(v) {
+ if (this._lastRowAlign === v)
+ return;
+
+ this._lastRowAlign = v;
+ this.notify('last-row-align');
+ }
+
+ get nPages() {
+ return this._pages.length;
+ }
+
+ // eslint-disable-next-line camelcase
+ get page_halign() {
+ return this._pageHAlign;
+ }
+
+ // eslint-disable-next-line camelcase
+ set page_halign(v) {
+ if (this._pageHAlign === v)
+ return;
+
+ this._pageHAlign = v;
+ this.notify('page-halign');
+ }
+
+ // eslint-disable-next-line camelcase
+ get page_valign() {
+ return this._pageVAlign;
+ }
+
+ // eslint-disable-next-line camelcase
+ set page_valign(v) {
+ if (this._pageVAlign === v)
+ return;
+
+ this._pageVAlign = v;
+ this.notify('page-valign');
+ }
+
+ // eslint-disable-next-line camelcase
+ get row_spacing() {
+ return this._rowSpacing;
+ }
+
+ // eslint-disable-next-line camelcase
+ set row_spacing(v) {
+ if (this._rowSpacing === v)
+ return;
+
+ this._rowSpacing = v;
+ this.notify('row-spacing');
+ }
+
+ // eslint-disable-next-line camelcase
+ get rows_per_page() {
+ return this._rowsPerPage;
+ }
+
+ // eslint-disable-next-line camelcase
+ set rows_per_page(v) {
+ if (this._rowsPerPage === v)
+ return;
+
+ this._rowsPerPage = v;
+ this.notify('rows-per-page');
+ }
+
+ get orientation() {
+ return this._orientation;
+ }
+
+ set orientation(v) {
+ if (this._orientation === v)
+ return;
+
+ switch (v) {
+ case Clutter.Orientation.VERTICAL:
+ this.request_mode = Clutter.RequestMode.HEIGHT_FOR_WIDTH;
+ break;
+ case Clutter.Orientation.HORIZONTAL:
+ this.request_mode = Clutter.RequestMode.WIDTH_FOR_HEIGHT;
+ break;
+ }
+
+ this._orientation = v;
+ this.notify('orientation');
+ }
+
+ get pageHeight() {
+ return this._pageHeight;
+ }
+
+ get pageWidth() {
+ return this._pageWidth;
+ }
+});
+
+var IconGrid = GObject.registerClass({
+ Signals: {
+ 'pages-changed': {},
+ 'animation-done': {},
+ },
+}, class IconGrid extends St.Viewport {
+ _init(layoutParams = {}) {
+ layoutParams = Params.parse(layoutParams, {
+ allow_incomplete_pages: false,
+ orientation: Clutter.Orientation.VERTICAL,
+ columns_per_page: 6,
+ rows_per_page: 4,
+ page_halign: Clutter.ActorAlign.CENTER,
+ page_valign: Clutter.ActorAlign.CENTER,
+ last_row_align: Clutter.ActorAlign.START,
+ column_spacing: 0,
+ row_spacing: 0,
+ });
+ const layoutManager = new IconGridLayout(layoutParams);
+ const pagesChangedId = layoutManager.connect('pages-changed',
+ () => this.emit('pages-changed'));
+
+ super._init({
+ style_class: 'icon-grid',
+ layoutManager,
+ x_expand: true,
+ y_expand: true,
+ });
+
+ this._gridModes = defaultGridModes;
+ this._currentPage = 0;
+ this._currentMode = -1;
+ this._clonesAnimating = [];
+
+ this.connect('actor-added', this._childAdded.bind(this));
+ this.connect('actor-removed', this._childRemoved.bind(this));
+ this.connect('destroy', () => layoutManager.disconnect(pagesChangedId));
+ }
+
+ _getChildrenToAnimate() {
+ const layoutManager = this.layout_manager;
+ const children = layoutManager.getItemsAtPage(this._currentPage);
+
+ return children.filter(c => c.visible);
+ }
+
+ _resetAnimationActors() {
+ this._clonesAnimating.forEach(clone => {
+ clone.source.reactive = true;
+ clone.source.opacity = 255;
+ clone.destroy();
+ });
+ this._clonesAnimating = [];
+ }
+
+ _animationDone() {
+ this._resetAnimationActors();
+ this.emit('animation-done');
+ }
+
+ _childAdded(grid, child) {
+ child._iconGridKeyFocusInId = child.connect('key-focus-in', () => {
+ this._ensureItemIsVisible(child);
+ });
+
+ child._paintVisible = child.opacity > 0;
+ child._opacityChangedId = child.connect('notify::opacity', () => {
+ let paintVisible = child._paintVisible;
+ child._paintVisible = child.opacity > 0;
+ if (paintVisible !== child._paintVisible)
+ this.queue_relayout();
+ });
+ }
+
+ _ensureItemIsVisible(item) {
+ if (!this.contains(item))
+ throw new Error(`${item} is not a child of IconGrid`);
+
+ const itemPage = this.layout_manager.getItemPage(item);
+ this.goToPage(itemPage);
+ }
+
+ _setGridMode(modeIndex) {
+ if (this._currentMode === modeIndex)
+ return;
+
+ this._currentMode = modeIndex;
+
+ if (modeIndex !== -1) {
+ const newMode = this._gridModes[modeIndex];
+
+ this.layout_manager.rows_per_page = newMode.rows;
+ this.layout_manager.columns_per_page = newMode.columns;
+ }
+ }
+
+ _findBestModeForSize(width, height) {
+ const sizeRatio = width / height;
+ let closestRatio = Infinity;
+ let bestMode = -1;
+
+ for (let modeIndex in this._gridModes) {
+ const mode = this._gridModes[modeIndex];
+ const modeRatio = mode.columns / mode.rows;
+
+ if (Math.abs(sizeRatio - modeRatio) < Math.abs(sizeRatio - closestRatio)) {
+ closestRatio = modeRatio;
+ bestMode = modeIndex;
+ }
+ }
+
+ this._setGridMode(bestMode);
+ }
+
+ _childRemoved(grid, child) {
+ child.disconnect(child._iconGridKeyFocusInId);
+ delete child._iconGridKeyFocusInId;
+
+ child.disconnect(child._opacityChangedId);
+ delete child._opacityChangedId;
+ delete child._paintVisible;
+ }
+
+ vfunc_unmap() {
+ // Cancel animations when hiding the overview, to avoid icons
+ // swarming into the void ...
+ this._resetAnimationActors();
+ super.vfunc_unmap();
+ }
+
+ vfunc_style_changed() {
+ super.vfunc_style_changed();
+
+ const node = this.get_theme_node();
+ this.layout_manager.column_spacing = node.get_length('column-spacing');
+ this.layout_manager.row_spacing = node.get_length('row-spacing');
+
+ let [found, value] = node.lookup_length('max-column-spacing', false);
+ this.layout_manager.max_column_spacing = found ? value : -1;
+
+ [found, value] = node.lookup_length('max-row-spacing', false);
+ this.layout_manager.max_row_spacing = found ? value : -1;
+ }
+
+ /**
+ * addItem:
+ * @param {Clutter.Actor} item: item to append to the grid
+ * @param {int} page: page number
+ * @param {int} index: position in the page
+ *
+ * Adds @item to the grid. @item must not be part of the grid.
+ *
+ * If @index exceeds the number of items per page, @item will
+ * be added to the next page.
+ *
+ * @page must be a number between 0 and the number of pages.
+ * Adding to the page after next will create a new page.
+ */
+ addItem(item, page = -1, index = -1) {
+ if (!(item.icon instanceof BaseIcon))
+ throw new Error('Only items with a BaseIcon icon property can be added to IconGrid');
+
+ this.layout_manager.addItem(item, page, index);
+ }
+
+ /**
+ * appendItem:
+ * @param {Clutter.Actor} item: item to append to the grid
+ *
+ * Appends @item to the grid. @item must not be part of the grid.
+ */
+ appendItem(item) {
+ this.layout_manager.appendItem(item);
+ }
+
+ /**
+ * moveItem:
+ * @param {Clutter.Actor} item: item to move
+ * @param {int} newPage: new page of the item
+ * @param {int} newPosition: new page of the item
+ *
+ * Moves @item to the grid. @item must be part of the grid.
+ */
+ moveItem(item, newPage, newPosition) {
+ this.layout_manager.moveItem(item, newPage, newPosition);
+ this.queue_relayout();
+ }
+
+ /**
+ * removeItem:
+ * @param {Clutter.Actor} item: item to remove from the grid
+ *
+ * Removes @item to the grid. @item must be part of the grid.
+ */
+ removeItem(item) {
+ if (!this.contains(item))
+ throw new Error(`Item ${item} is not part of the IconGrid`);
+
+ this.layout_manager.removeItem(item);
+ }
+
+ /**
+ * goToPage:
+ * @param {int} pageIndex: page index
+ * @param {boolean} animate: animate the page transition
+ *
+ * Moves the current page to @pageIndex. @pageIndex must be a valid page
+ * number.
+ */
+ goToPage(pageIndex, animate = true) {
+ if (pageIndex >= this.nPages)
+ throw new Error(`IconGrid does not have page ${pageIndex}`);
+
+ let newValue;
+ let adjustment;
+ switch (this.layout_manager.orientation) {
+ case Clutter.Orientation.VERTICAL:
+ adjustment = this.vadjustment;
+ newValue = pageIndex * this.layout_manager.pageHeight;
+ break;
+ case Clutter.Orientation.HORIZONTAL:
+ adjustment = this.hadjustment;
+ newValue = pageIndex * this.layout_manager.pageWidth;
+ break;
+ }
+
+ this._currentPage = pageIndex;
+
+ if (!this.mapped)
+ animate = false;
+
+ adjustment.ease(newValue, {
+ mode: Clutter.AnimationMode.EASE_OUT_CUBIC,
+ duration: animate ? PAGE_SWITCH_TIME : 0,
+ });
+ }
+
+ /**
+ * getItemPage:
+ * @param {BaseIcon} item: the item
+ *
+ * Retrieves the page @item is in, or -1 if @item is not part of the grid.
+ *
+ * @returns {int} the page where @item is in
+ */
+ getItemPage(item) {
+ return this.layout_manager.getItemPage(item);
+ }
+
+ /**
+ * getItemPosition:
+ * @param {BaseIcon} item: the item
+ *
+ * Retrieves the position of @item is its page, or -1 if @item is not
+ * part of the grid.
+ *
+ * @returns {[int, int]} the page and position of @item
+ */
+ getItemPosition(item) {
+ if (!this.contains(item))
+ return [-1, -1];
+
+ const layoutManager = this.layout_manager;
+ return layoutManager.getItemPosition(item);
+ }
+
+ /**
+ * getItemAt:
+ * @param {int} page: the page
+ * @param {int} position: the position in page
+ *
+ * Retrieves the item at @page and @position.
+ *
+ * @returns {BaseItem} the item at @page and @position, or null
+ */
+ getItemAt(page, position) {
+ const layoutManager = this.layout_manager;
+ return layoutManager.getItemAt(page, position);
+ }
+
+ /**
+ * getItemsAtPage:
+ * @param {int} page: the page index
+ *
+ * Retrieves the children at page @page, including invisible children.
+ *
+ * @returns {Array} an array of {Clutter.Actor}s
+ */
+ getItemsAtPage(page) {
+ if (page < 0 || page > this.nPages)
+ throw new Error(`Page ${page} does not exist at IconGrid`);
+
+ const layoutManager = this.layout_manager;
+ return layoutManager.getItemsAtPage(page);
+ }
+
+ get currentPage() {
+ return this._currentPage;
+ }
+
+ set currentPage(v) {
+ this.goToPage(v);
+ }
+
+ get nPages() {
+ return this.layout_manager.nPages;
+ }
+
+ adaptToSize(width, height) {
+ this._findBestModeForSize(width, height);
+ this.layout_manager.adaptToSize(width, height);
+ }
+
+ async animateSpring(animationDirection, sourceActor) {
+ this._resetAnimationActors();
+
+ let actors = this._getChildrenToAnimate();
+ if (actors.length == 0) {
+ this._animationDone();
+ return;
+ }
+
+ await this.layout_manager.ensureIconSizeUpdated();
+
+ let [sourceX, sourceY] = sourceActor.get_transformed_position();
+ let [sourceWidth, sourceHeight] = sourceActor.get_size();
+ // Get the center
+ let [sourceCenterX, sourceCenterY] = [sourceX + sourceWidth / 2, sourceY + sourceHeight / 2];
+ // Design decision, 1/2 of the source actor size.
+ let [sourceScaledWidth, sourceScaledHeight] = [sourceWidth / 2, sourceHeight / 2];
+
+ actors.forEach(actor => {
+ let [actorX, actorY] = actor._transformedPosition = actor.get_transformed_position();
+ let [x, y] = [actorX - sourceX, actorY - sourceY];
+ actor._distance = Math.sqrt(x * x + y * y);
+ });
+ let maxDist = actors.reduce((prev, cur) => {
+ return Math.max(prev, cur._distance);
+ }, 0);
+ let minDist = actors.reduce((prev, cur) => {
+ return Math.min(prev, cur._distance);
+ }, Infinity);
+ let normalization = maxDist - minDist;
+
+ actors.forEach(actor => {
+ let clone = new Clutter.Clone({ source: actor });
+ this._clonesAnimating.push(clone);
+ Main.uiGroup.add_actor(clone);
+ });
+
+ /*
+ * ^
+ * | These need to be separate loops because Main.uiGroup.add_actor
+ * | is excessively slow if done inside the below loop and we want the
+ * | below loop to complete within one frame interval (#2065, !1002).
+ * v
+ */
+
+ this._clonesAnimating.forEach(actorClone => {
+ const actor = actorClone.source;
+ actor.opacity = 0;
+ actor.reactive = false;
+
+ let [width, height] = actor.get_size();
+ actorClone.set_size(width, height);
+ let scaleX = sourceScaledWidth / width;
+ let scaleY = sourceScaledHeight / height;
+ let [adjustedSourcePositionX, adjustedSourcePositionY] = [sourceCenterX - sourceScaledWidth / 2, sourceCenterY - sourceScaledHeight / 2];
+
+ let movementParams, fadeParams;
+ if (animationDirection == AnimationDirection.IN) {
+ let isLastItem = actor._distance == minDist;
+
+ actorClone.opacity = 0;
+ actorClone.set_scale(scaleX, scaleY);
+ actorClone.set_translation(
+ adjustedSourcePositionX, adjustedSourcePositionY, 0);
+
+ let delay = (1 - (actor._distance - minDist) / normalization) * ANIMATION_MAX_DELAY_FOR_ITEM;
+ let [finalX, finalY] = actor._transformedPosition;
+ movementParams = {
+ translation_x: finalX,
+ translation_y: finalY,
+ scale_x: 1,
+ scale_y: 1,
+ duration: ANIMATION_TIME_IN,
+ mode: Clutter.AnimationMode.EASE_IN_OUT_QUAD,
+ delay,
+ };
+
+ if (isLastItem)
+ movementParams.onComplete = this._animationDone.bind(this);
+
+ fadeParams = {
+ opacity: 255,
+ duration: ANIMATION_FADE_IN_TIME_FOR_ITEM,
+ mode: Clutter.AnimationMode.EASE_IN_OUT_QUAD,
+ delay,
+ };
+ } else {
+ let isLastItem = actor._distance == maxDist;
+
+ let [startX, startY] = actor._transformedPosition;
+ actorClone.set_translation(startX, startY, 0);
+
+ let delay = (actor._distance - minDist) / normalization * ANIMATION_MAX_DELAY_OUT_FOR_ITEM;
+ movementParams = {
+ translation_x: adjustedSourcePositionX,
+ translation_y: adjustedSourcePositionY,
+ scale_x: scaleX,
+ scale_y: scaleY,
+ duration: ANIMATION_TIME_OUT,
+ mode: Clutter.AnimationMode.EASE_IN_OUT_QUAD,
+ delay,
+ };
+
+ if (isLastItem)
+ movementParams.onComplete = this._animationDone.bind(this);
+
+ fadeParams = {
+ opacity: 0,
+ duration: ANIMATION_FADE_IN_TIME_FOR_ITEM,
+ mode: Clutter.AnimationMode.EASE_IN_OUT_QUAD,
+ delay: ANIMATION_TIME_OUT + delay - ANIMATION_FADE_IN_TIME_FOR_ITEM,
+ };
+ }
+
+ actorClone.ease(movementParams);
+ actorClone.ease(fadeParams);
+ });
+ }
+
+ setGridModes(modes) {
+ this._gridModes = modes ? modes : defaultGridModes;
+ this.queue_relayout();
+ }
+
+ getDropTarget(x, y) {
+ const layoutManager = this.layout_manager;
+ return layoutManager.getDropTarget(x, y, this._currentPage);
+ }
+
+ get itemsPerPage() {
+ const layoutManager = this.layout_manager;
+ return layoutManager.rows_per_page * layoutManager.columns_per_page;
+ }
+});
diff --git a/js/ui/inhibitShortcutsDialog.js b/js/ui/inhibitShortcutsDialog.js
new file mode 100644
index 0000000..d16c55a
--- /dev/null
+++ b/js/ui/inhibitShortcutsDialog.js
@@ -0,0 +1,163 @@
+/* exported InhibitShortcutsDialog */
+const { Clutter, Gio, GLib, GObject, Gtk, Meta, Pango, Shell, St } = imports.gi;
+
+const Dialog = imports.ui.dialog;
+const ModalDialog = imports.ui.modalDialog;
+const PermissionStore = imports.misc.permissionStore;
+
+const WAYLAND_KEYBINDINGS_SCHEMA = 'org.gnome.mutter.wayland.keybindings';
+
+const APP_ALLOWLIST = ['gnome-control-center.desktop'];
+const APP_PERMISSIONS_TABLE = 'gnome';
+const APP_PERMISSIONS_ID = 'shortcuts-inhibitor';
+const GRANTED = 'GRANTED';
+const DENIED = 'DENIED';
+
+var DialogResponse = Meta.InhibitShortcutsDialogResponse;
+
+var InhibitShortcutsDialog = GObject.registerClass({
+ Implements: [Meta.InhibitShortcutsDialog],
+ Properties: {
+ 'window': GObject.ParamSpec.override('window', Meta.InhibitShortcutsDialog),
+ },
+}, class InhibitShortcutsDialog extends GObject.Object {
+ _init(window) {
+ super._init();
+ this._window = window;
+
+ this._dialog = new ModalDialog.ModalDialog();
+ this._buildLayout();
+ }
+
+ get window() {
+ return this._window;
+ }
+
+ set window(window) {
+ this._window = window;
+ }
+
+ get _app() {
+ let windowTracker = Shell.WindowTracker.get_default();
+ return windowTracker.get_window_app(this._window);
+ }
+
+ _getRestoreAccel() {
+ let settings = new Gio.Settings({ schema_id: WAYLAND_KEYBINDINGS_SCHEMA });
+ let accel = settings.get_strv('restore-shortcuts')[0] || '';
+ return Gtk.accelerator_get_label.apply(null,
+ Gtk.accelerator_parse(accel));
+ }
+
+ _shouldUsePermStore() {
+ return this._app && !this._app.is_window_backed();
+ }
+
+ _saveToPermissionStore(grant) {
+ if (!this._shouldUsePermStore() || this._permStore == null)
+ return;
+
+ let permissions = {};
+ permissions[this._app.get_id()] = [grant];
+ let data = GLib.Variant.new('av', {});
+
+ this._permStore.SetRemote(APP_PERMISSIONS_TABLE,
+ true,
+ APP_PERMISSIONS_ID,
+ permissions,
+ data,
+ (result, error) => {
+ if (error != null)
+ log(error.message);
+ });
+ }
+
+ _buildLayout() {
+ let name = this._app ? this._app.get_name() : this._window.title;
+
+ let content = new Dialog.MessageDialogContent({
+ title: _('Allow inhibiting shortcuts'),
+ description: name
+ /* Translators: %s is an application name like "Settings" */
+ ? _('The application %s wants to inhibit shortcuts').format(name)
+ : _('An application wants to inhibit shortcuts'),
+ });
+
+ let restoreAccel = this._getRestoreAccel();
+ if (restoreAccel) {
+ let restoreLabel = new St.Label({
+ /* Translators: %s is a keyboard shortcut like "Super+x" */
+ text: _('You can restore shortcuts by pressing %s.').format(restoreAccel),
+ style_class: 'message-dialog-description',
+ });
+ restoreLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+ restoreLabel.clutter_text.line_wrap = true;
+ content.add_child(restoreLabel);
+ }
+
+ this._dialog.contentLayout.add_child(content);
+
+ this._dialog.addButton({ label: _("Deny"),
+ action: () => {
+ this._saveToPermissionStore(DENIED);
+ this._emitResponse(DialogResponse.DENY);
+ },
+ key: Clutter.KEY_Escape });
+
+ this._dialog.addButton({ label: _("Allow"),
+ action: () => {
+ this._saveToPermissionStore(GRANTED);
+ this._emitResponse(DialogResponse.ALLOW);
+ },
+ default: true });
+ }
+
+ _emitResponse(response) {
+ this.emit('response', response);
+ this._dialog.close();
+ }
+
+ vfunc_show() {
+ if (this._app && APP_ALLOWLIST.includes(this._app.get_id())) {
+ this._emitResponse(DialogResponse.ALLOW);
+ return;
+ }
+
+ if (!this._shouldUsePermStore()) {
+ this._dialog.open();
+ return;
+ }
+
+ /* Check with the permission store */
+ let appId = this._app.get_id();
+ this._permStore = new PermissionStore.PermissionStore((proxy, error) => {
+ if (error) {
+ log(error.message);
+ this._dialog.open();
+ return;
+ }
+
+ this._permStore.LookupRemote(APP_PERMISSIONS_TABLE,
+ APP_PERMISSIONS_ID,
+ (res, err) => {
+ if (err) {
+ this._dialog.open();
+ log(err.message);
+ return;
+ }
+
+ let [permissions] = res;
+ if (permissions[appId] === undefined) // Not found
+ this._dialog.open();
+ else if (permissions[appId] == GRANTED)
+ this._emitResponse(DialogResponse.ALLOW);
+ else
+ this._emitResponse(DialogResponse.DENY);
+ });
+ });
+ }
+
+ vfunc_hide() {
+ this._dialog.close();
+ }
+});
diff --git a/js/ui/kbdA11yDialog.js b/js/ui/kbdA11yDialog.js
new file mode 100644
index 0000000..3cf56b1
--- /dev/null
+++ b/js/ui/kbdA11yDialog.js
@@ -0,0 +1,73 @@
+/* exported KbdA11yDialog */
+const { Clutter, Gio, GObject } = imports.gi;
+
+const Dialog = imports.ui.dialog;
+const ModalDialog = imports.ui.modalDialog;
+
+const KEYBOARD_A11Y_SCHEMA = 'org.gnome.desktop.a11y.keyboard';
+const KEY_STICKY_KEYS_ENABLED = 'stickykeys-enable';
+const KEY_SLOW_KEYS_ENABLED = 'slowkeys-enable';
+
+var KbdA11yDialog = GObject.registerClass(
+class KbdA11yDialog extends GObject.Object {
+ _init() {
+ super._init();
+
+ this._a11ySettings = new Gio.Settings({ schema_id: KEYBOARD_A11Y_SCHEMA });
+
+ let seat = Clutter.get_default_backend().get_default_seat();
+ seat.connect('kbd-a11y-flags-changed',
+ this._showKbdA11yDialog.bind(this));
+ }
+
+ _showKbdA11yDialog(seat, newFlags, whatChanged) {
+ let dialog = new ModalDialog.ModalDialog();
+ let title, description;
+ let key, enabled;
+
+ if (whatChanged & Clutter.KeyboardA11yFlags.SLOW_KEYS_ENABLED) {
+ key = KEY_SLOW_KEYS_ENABLED;
+ enabled = (newFlags & Clutter.KeyboardA11yFlags.SLOW_KEYS_ENABLED) > 0;
+ title = enabled
+ ? _("Slow Keys Turned On")
+ : _("Slow Keys Turned Off");
+ description = _('You just held down the Shift key for 8 seconds. This is the shortcut ' +
+ 'for the Slow Keys feature, which affects the way your keyboard works.');
+
+ } else if (whatChanged & Clutter.KeyboardA11yFlags.STICKY_KEYS_ENABLED) {
+ key = KEY_STICKY_KEYS_ENABLED;
+ enabled = (newFlags & Clutter.KeyboardA11yFlags.STICKY_KEYS_ENABLED) > 0;
+ title = enabled
+ ? _("Sticky Keys Turned On")
+ : _("Sticky Keys Turned Off");
+ description = enabled
+ ? _("You just pressed the Shift key 5 times in a row. This is the shortcut " +
+ "for the Sticky Keys feature, which affects the way your keyboard works.")
+ : _("You just pressed two keys at once, or pressed the Shift key 5 times in a row. " +
+ "This turns off the Sticky Keys feature, which affects the way your keyboard works.");
+ } else {
+ return;
+ }
+
+ let content = new Dialog.MessageDialogContent({ title, description });
+ dialog.contentLayout.add_child(content);
+
+ dialog.addButton({ label: enabled ? _("Leave On") : _("Turn On"),
+ action: () => {
+ this._a11ySettings.set_boolean(key, true);
+ dialog.close();
+ },
+ default: enabled,
+ key: !enabled ? Clutter.KEY_Escape : null });
+
+ dialog.addButton({ label: enabled ? _("Turn Off") : _("Leave Off"),
+ action: () => {
+ this._a11ySettings.set_boolean(key, false);
+ dialog.close();
+ },
+ default: !enabled,
+ key: enabled ? Clutter.KEY_Escape : null });
+
+ dialog.open();
+ }
+});
diff --git a/js/ui/keyboard.js b/js/ui/keyboard.js
new file mode 100644
index 0000000..e2574f0
--- /dev/null
+++ b/js/ui/keyboard.js
@@ -0,0 +1,1967 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported KeyboardManager */
+
+const { Clutter, Gio, GLib, GObject, Meta, St } = imports.gi;
+const ByteArray = imports.byteArray;
+const Signals = imports.signals;
+
+const InputSourceManager = imports.ui.status.keyboard;
+const IBusManager = imports.misc.ibusManager;
+const BoxPointer = imports.ui.boxpointer;
+const Layout = imports.ui.layout;
+const Main = imports.ui.main;
+const PageIndicators = imports.ui.pageIndicators;
+const PopupMenu = imports.ui.popupMenu;
+
+var KEYBOARD_REST_TIME = Layout.KEYBOARD_ANIMATION_TIME * 2;
+var KEY_LONG_PRESS_TIME = 250;
+var PANEL_SWITCH_ANIMATION_TIME = 500;
+var PANEL_SWITCH_RELATIVE_DISTANCE = 1 / 3; /* A third of the actor width */
+
+const A11Y_APPLICATIONS_SCHEMA = 'org.gnome.desktop.a11y.applications';
+const SHOW_KEYBOARD = 'screen-keyboard-enabled';
+
+/* KeyContainer puts keys in a grid where a 1:1 key takes this size */
+const KEY_SIZE = 2;
+
+const defaultKeysPre = [
+ [[], [], [{ width: 1.5, level: 1, extraClassName: 'shift-key-lowercase', icon: 'keyboard-shift-filled-symbolic' }], [{ label: '?123', width: 1.5, level: 2 }]],
+ [[], [], [{ width: 1.5, level: 0, extraClassName: 'shift-key-uppercase', icon: 'keyboard-shift-filled-symbolic' }], [{ label: '?123', width: 1.5, level: 2 }]],
+ [[], [], [{ label: '=/<', width: 1.5, level: 3 }], [{ label: 'ABC', width: 1.5, level: 0 }]],
+ [[], [], [{ label: '?123', width: 1.5, level: 2 }], [{ label: 'ABC', width: 1.5, level: 0 }]],
+];
+
+const defaultKeysPost = [
+ [[{ width: 1.5, keyval: Clutter.KEY_BackSpace, icon: 'edit-clear-symbolic' }],
+ [{ width: 2, keyval: Clutter.KEY_Return, extraClassName: 'enter-key', icon: 'keyboard-enter-symbolic' }],
+ [{ width: 3, level: 1, right: true, extraClassName: 'shift-key-lowercase', icon: 'keyboard-shift-filled-symbolic' }],
+ [{ action: 'emoji', icon: 'face-smile-symbolic' }, { action: 'languageMenu', extraClassName: 'layout-key', icon: 'keyboard-layout-filled-symbolic' }, { action: 'hide', extraClassName: 'hide-key', icon: 'go-down-symbolic' }]],
+ [[{ width: 1.5, keyval: Clutter.KEY_BackSpace, icon: 'edit-clear-symbolic' }],
+ [{ width: 2, keyval: Clutter.KEY_Return, extraClassName: 'enter-key', icon: 'keyboard-enter-symbolic' }],
+ [{ width: 3, level: 0, right: true, extraClassName: 'shift-key-uppercase', icon: 'keyboard-shift-filled-symbolic' }],
+ [{ action: 'emoji', icon: 'face-smile-symbolic' }, { action: 'languageMenu', extraClassName: 'layout-key', icon: 'keyboard-layout-filled-symbolic' }, { action: 'hide', extraClassName: 'hide-key', icon: 'go-down-symbolic' }]],
+ [[{ width: 1.5, keyval: Clutter.KEY_BackSpace, icon: 'edit-clear-symbolic' }],
+ [{ width: 2, keyval: Clutter.KEY_Return, extraClassName: 'enter-key', icon: 'keyboard-enter-symbolic' }],
+ [{ label: '=/<', width: 3, level: 3, right: true }],
+ [{ action: 'emoji', icon: 'face-smile-symbolic' }, { action: 'languageMenu', extraClassName: 'layout-key', icon: 'keyboard-layout-filled-symbolic' }, { action: 'hide', extraClassName: 'hide-key', icon: 'go-down-symbolic' }]],
+ [[{ width: 1.5, keyval: Clutter.KEY_BackSpace, icon: 'edit-clear-symbolic' }],
+ [{ width: 2, keyval: Clutter.KEY_Return, extraClassName: 'enter-key', icon: 'keyboard-enter-symbolic' }],
+ [{ label: '?123', width: 3, level: 2, right: true }],
+ [{ action: 'emoji', icon: 'face-smile-symbolic' }, { action: 'languageMenu', extraClassName: 'layout-key', icon: 'keyboard-layout-filled-symbolic' }, { action: 'hide', extraClassName: 'hide-key', icon: 'go-down-symbolic' }]],
+];
+
+var AspectContainer = GObject.registerClass(
+class AspectContainer extends St.Widget {
+ _init(params) {
+ super._init(params);
+ this._ratio = 1;
+ }
+
+ setRatio(relWidth, relHeight) {
+ this._ratio = relWidth / relHeight;
+ this.queue_relayout();
+ }
+
+ vfunc_get_preferred_width(forHeight) {
+ let [min, nat] = super.vfunc_get_preferred_width(forHeight);
+
+ if (forHeight > 0)
+ nat = forHeight * this._ratio;
+
+ return [min, nat];
+ }
+
+ vfunc_get_preferred_height(forWidth) {
+ let [min, nat] = super.vfunc_get_preferred_height(forWidth);
+
+ if (forWidth > 0)
+ nat = forWidth / this._ratio;
+
+ return [min, nat];
+ }
+
+ vfunc_allocate(box) {
+ if (box.get_width() > 0 && box.get_height() > 0) {
+ let sizeRatio = box.get_width() / box.get_height();
+
+ if (sizeRatio >= this._ratio) {
+ /* Restrict horizontally */
+ let width = box.get_height() * this._ratio;
+ let diff = box.get_width() - width;
+
+ box.x1 += Math.floor(diff / 2);
+ box.x2 -= Math.ceil(diff / 2);
+ } else {
+ /* Restrict vertically, align to bottom */
+ let height = box.get_width() / this._ratio;
+ box.y1 = box.y2 - Math.floor(height);
+ }
+ }
+
+ super.vfunc_allocate(box);
+ }
+});
+
+var KeyContainer = GObject.registerClass(
+class KeyContainer extends St.Widget {
+ _init() {
+ let gridLayout = new Clutter.GridLayout({ orientation: Clutter.Orientation.HORIZONTAL,
+ column_homogeneous: true,
+ row_homogeneous: true });
+ super._init({
+ layout_manager: gridLayout,
+ x_expand: true,
+ y_expand: true,
+ });
+ this._gridLayout = gridLayout;
+ this._currentRow = 0;
+ this._currentCol = 0;
+ this._maxCols = 0;
+
+ this._currentRow = null;
+ this._rows = [];
+ }
+
+ appendRow() {
+ this._currentRow++;
+ this._currentCol = 0;
+
+ let row = {
+ keys: [],
+ width: 0,
+ };
+ this._rows.push(row);
+ }
+
+ appendKey(key, width = 1, height = 1) {
+ let keyInfo = {
+ key,
+ left: this._currentCol,
+ top: this._currentRow,
+ width,
+ height,
+ };
+
+ let row = this._rows[this._rows.length - 1];
+ row.keys.push(keyInfo);
+ row.width += width;
+
+ this._currentCol += width;
+ this._maxCols = Math.max(this._currentCol, this._maxCols);
+ }
+
+ layoutButtons(container) {
+ let nCol = 0, nRow = 0;
+
+ for (let i = 0; i < this._rows.length; i++) {
+ let row = this._rows[i];
+
+ /* When starting a new row, see if we need some padding */
+ if (nCol == 0) {
+ let diff = this._maxCols - row.width;
+ if (diff >= 1)
+ nCol = diff * KEY_SIZE / 2;
+ else
+ nCol = diff * KEY_SIZE;
+ }
+
+ for (let j = 0; j < row.keys.length; j++) {
+ let keyInfo = row.keys[j];
+ let width = keyInfo.width * KEY_SIZE;
+ let height = keyInfo.height * KEY_SIZE;
+
+ this._gridLayout.attach(keyInfo.key, nCol, nRow, width, height);
+ nCol += width;
+ }
+
+ nRow += KEY_SIZE;
+ nCol = 0;
+ }
+
+ if (container)
+ container.setRatio(this._maxCols, this._rows.length);
+ }
+});
+
+var Suggestions = GObject.registerClass(
+class Suggestions extends St.BoxLayout {
+ _init() {
+ super._init({
+ style_class: 'word-suggestions',
+ vertical: false,
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+ this.show();
+ }
+
+ add(word, callback) {
+ let button = new St.Button({ label: word });
+ button.connect('clicked', callback);
+ this.add_child(button);
+ }
+
+ clear() {
+ this.remove_all_children();
+ }
+});
+
+var LanguageSelectionPopup = class extends PopupMenu.PopupMenu {
+ constructor(actor) {
+ super(actor, 0.5, St.Side.BOTTOM);
+
+ let inputSourceManager = InputSourceManager.getInputSourceManager();
+ let inputSources = inputSourceManager.inputSources;
+
+ let item;
+ for (let i in inputSources) {
+ let is = inputSources[i];
+
+ item = this.addAction(is.displayName, () => {
+ inputSourceManager.activateInputSource(is, true);
+ });
+ item.can_focus = false;
+ }
+
+ this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+ item = this.addSettingsAction(_("Region & Language Settings"), 'gnome-region-panel.desktop');
+ item.can_focus = false;
+
+ this._capturedEventId = 0;
+
+ this._unmapId = actor.connect('notify::mapped', () => {
+ if (!actor.is_mapped())
+ this.close(true);
+ });
+ }
+
+ _onCapturedEvent(actor, event) {
+ if (event.get_source() == this.actor ||
+ this.actor.contains(event.get_source()))
+ return Clutter.EVENT_PROPAGATE;
+
+ if (event.type() == Clutter.EventType.BUTTON_RELEASE || event.type() == Clutter.EventType.TOUCH_END)
+ this.close(true);
+
+ return Clutter.EVENT_STOP;
+ }
+
+ open(animate) {
+ super.open(animate);
+ this._capturedEventId = global.stage.connect('captured-event',
+ this._onCapturedEvent.bind(this));
+ }
+
+ close(animate) {
+ super.close(animate);
+ if (this._capturedEventId != 0) {
+ global.stage.disconnect(this._capturedEventId);
+ this._capturedEventId = 0;
+ }
+ }
+
+ destroy() {
+ if (this._capturedEventId != 0)
+ global.stage.disconnect(this._capturedEventId);
+ if (this._unmapId != 0)
+ this.sourceActor.disconnect(this._unmapId);
+ super.destroy();
+ }
+};
+
+var Key = GObject.registerClass({
+ Signals: {
+ 'activated': {},
+ 'long-press': {},
+ 'pressed': { param_types: [GObject.TYPE_UINT, GObject.TYPE_STRING] },
+ 'released': { param_types: [GObject.TYPE_UINT, GObject.TYPE_STRING] },
+ },
+}, class Key extends St.BoxLayout {
+ _init(key, extendedKeys, icon = null) {
+ super._init({ style_class: 'key-container' });
+
+ this.key = key || "";
+ this.keyButton = this._makeKey(this.key, icon);
+
+ /* Add the key in a container, so keys can be padded without losing
+ * logical proportions between those.
+ */
+ this.add_child(this.keyButton);
+ this.connect('destroy', this._onDestroy.bind(this));
+
+ this._extendedKeys = extendedKeys;
+ this._extendedKeyboard = null;
+ this._pressTimeoutId = 0;
+ this._capturedPress = false;
+
+ this._capturedEventId = 0;
+ this._unmapId = 0;
+ this._longPress = false;
+ }
+
+ _onDestroy() {
+ if (this._boxPointer) {
+ this._boxPointer.destroy();
+ this._boxPointer = null;
+ }
+
+ this.cancel();
+ }
+
+ _ensureExtendedKeysPopup() {
+ if (this._extendedKeys.length === 0)
+ return;
+
+ if (this._boxPointer)
+ return;
+
+ this._boxPointer = new BoxPointer.BoxPointer(St.Side.BOTTOM);
+ this._boxPointer.hide();
+ Main.layoutManager.addTopChrome(this._boxPointer);
+ this._boxPointer.setPosition(this.keyButton, 0.5);
+
+ // Adds style to existing keyboard style to avoid repetition
+ this._boxPointer.add_style_class_name('keyboard-subkeys');
+ this._getExtendedKeys();
+ this.keyButton._extendedKeys = this._extendedKeyboard;
+ }
+
+ _getKeyval(key) {
+ let unicode = key.length ? key.charCodeAt(0) : undefined;
+ return Clutter.unicode_to_keysym(unicode);
+ }
+
+ _press(key) {
+ this.emit('activated');
+
+ if (key !== this.key || this._extendedKeys.length === 0)
+ this.emit('pressed', this._getKeyval(key), key);
+
+ if (key == this.key) {
+ this._pressTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT,
+ KEY_LONG_PRESS_TIME,
+ () => {
+ this._longPress = true;
+ this._pressTimeoutId = 0;
+
+ this.emit('long-press');
+
+ if (this._extendedKeys.length > 0) {
+ this._touchPressed = false;
+ this._ensureExtendedKeysPopup();
+ this.keyButton.set_hover(false);
+ this.keyButton.fake_release();
+ this._showSubkeys();
+ }
+
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+ }
+
+ _release(key) {
+ if (this._pressTimeoutId != 0) {
+ GLib.source_remove(this._pressTimeoutId);
+ this._pressTimeoutId = 0;
+ }
+
+ if (!this._longPress && key === this.key && this._extendedKeys.length > 0)
+ this.emit('pressed', this._getKeyval(key), key);
+
+ this.emit('released', this._getKeyval(key), key);
+ this._hideSubkeys();
+ this._longPress = false;
+ }
+
+ cancel() {
+ if (this._pressTimeoutId != 0) {
+ GLib.source_remove(this._pressTimeoutId);
+ this._pressTimeoutId = 0;
+ }
+ this._touchPressed = false;
+ this.keyButton.set_hover(false);
+ this.keyButton.fake_release();
+ }
+
+ _onCapturedEvent(actor, event) {
+ let type = event.type();
+ let press = type == Clutter.EventType.BUTTON_PRESS || type == Clutter.EventType.TOUCH_BEGIN;
+ let release = type == Clutter.EventType.BUTTON_RELEASE || type == Clutter.EventType.TOUCH_END;
+
+ if (event.get_source() == this._boxPointer.bin ||
+ this._boxPointer.bin.contains(event.get_source()))
+ return Clutter.EVENT_PROPAGATE;
+
+ if (press)
+ this._capturedPress = true;
+ else if (release && this._capturedPress)
+ this._hideSubkeys();
+
+ return Clutter.EVENT_STOP;
+ }
+
+ _showSubkeys() {
+ this._boxPointer.open(BoxPointer.PopupAnimation.FULL);
+ this._capturedEventId = global.stage.connect('captured-event',
+ this._onCapturedEvent.bind(this));
+ this._unmapId = this.keyButton.connect('notify::mapped', () => {
+ if (!this.keyButton.is_mapped())
+ this._hideSubkeys();
+ });
+ }
+
+ _hideSubkeys() {
+ if (this._boxPointer)
+ this._boxPointer.close(BoxPointer.PopupAnimation.FULL);
+ if (this._capturedEventId) {
+ global.stage.disconnect(this._capturedEventId);
+ this._capturedEventId = 0;
+ }
+ if (this._unmapId) {
+ this.keyButton.disconnect(this._unmapId);
+ this._unmapId = 0;
+ }
+ this._capturedPress = false;
+ }
+
+ _makeKey(key, icon) {
+ let button = new St.Button({
+ style_class: 'keyboard-key',
+ x_expand: true,
+ });
+
+ if (icon) {
+ let child = new St.Icon({ icon_name: icon });
+ button.set_child(child);
+ this._icon = child;
+ } else {
+ let label = GLib.markup_escape_text(key, -1);
+ button.set_label(label);
+ }
+
+ button.keyWidth = 1;
+ button.connect('button-press-event', () => {
+ this._press(key);
+ return Clutter.EVENT_PROPAGATE;
+ });
+ button.connect('button-release-event', () => {
+ this._release(key);
+ return Clutter.EVENT_PROPAGATE;
+ });
+ button.connect('touch-event', (actor, event) => {
+ // We only handle touch events here on wayland. On X11
+ // we do get emulated pointer events, which already works
+ // for single-touch cases. Besides, the X11 passive touch grab
+ // set up by Mutter will make us see first the touch events
+ // and later the pointer events, so it will look like two
+ // unrelated series of events, we want to avoid double handling
+ // in these cases.
+ if (!Meta.is_wayland_compositor())
+ return Clutter.EVENT_PROPAGATE;
+
+ if (!this._touchPressed &&
+ event.type() == Clutter.EventType.TOUCH_BEGIN) {
+ this._touchPressed = true;
+ this._press(key);
+ } else if (this._touchPressed &&
+ event.type() == Clutter.EventType.TOUCH_END) {
+ this._touchPressed = false;
+ this._release(key);
+ }
+ return Clutter.EVENT_PROPAGATE;
+ });
+
+ return button;
+ }
+
+ _getExtendedKeys() {
+ this._extendedKeyboard = new St.BoxLayout({
+ style_class: 'key-container',
+ vertical: false,
+ });
+ for (let i = 0; i < this._extendedKeys.length; ++i) {
+ let extendedKey = this._extendedKeys[i];
+ let key = this._makeKey(extendedKey);
+
+ key.extendedKey = extendedKey;
+ this._extendedKeyboard.add(key);
+
+ key.set_size(...this.keyButton.allocation.get_size());
+ this.keyButton.connect('notify::allocation',
+ () => key.set_size(...this.keyButton.allocation.get_size()));
+ }
+ this._boxPointer.bin.add_actor(this._extendedKeyboard);
+ }
+
+ get subkeys() {
+ return this._boxPointer;
+ }
+
+ setWidth(width) {
+ this.keyButton.keyWidth = width;
+ }
+
+ setLatched(latched) {
+ if (!this._icon)
+ return;
+
+ if (latched) {
+ this.keyButton.add_style_pseudo_class('latched');
+ this._icon.icon_name = 'keyboard-caps-lock-filled-symbolic';
+ } else {
+ this.keyButton.remove_style_pseudo_class('latched');
+ this._icon.icon_name = 'keyboard-shift-filled-symbolic';
+ }
+ }
+});
+
+var KeyboardModel = class {
+ constructor(groupName) {
+ let names = [groupName];
+ if (groupName.includes('+'))
+ names.push(groupName.replace(/\+.*/, ''));
+ names.push('us');
+
+ for (let i = 0; i < names.length; i++) {
+ try {
+ this._model = this._loadModel(names[i]);
+ break;
+ } catch (e) {
+ }
+ }
+ }
+
+ _loadModel(groupName) {
+ let file = Gio.File.new_for_uri('resource:///org/gnome/shell/osk-layouts/%s.json'.format(groupName));
+ let [success_, contents] = file.load_contents(null);
+ contents = ByteArray.toString(contents);
+
+ return JSON.parse(contents);
+ }
+
+ getLevels() {
+ return this._model.levels;
+ }
+
+ getKeysForLevel(levelName) {
+ return this._model.levels.find(level => level == levelName);
+ }
+};
+
+var FocusTracker = class {
+ constructor() {
+ this._currentWindow = null;
+ this._rect = null;
+
+ global.display.connect('notify::focus-window', () => {
+ this._setCurrentWindow(global.display.focus_window);
+ this.emit('window-changed', this._currentWindow);
+ });
+
+ global.display.connect('grab-op-begin', (display, window, op) => {
+ if (window == this._currentWindow &&
+ (op == Meta.GrabOp.MOVING || op == Meta.GrabOp.KEYBOARD_MOVING))
+ this.emit('reset');
+ });
+
+ /* Valid for wayland clients */
+ Main.inputMethod.connect('cursor-location-changed', (o, rect) => {
+ let newRect = { x: rect.get_x(), y: rect.get_y(), width: rect.get_width(), height: rect.get_height() };
+ this._setCurrentRect(newRect);
+ });
+
+ this._ibusManager = IBusManager.getIBusManager();
+ this._ibusManager.connect('set-cursor-location', (manager, rect) => {
+ /* Valid for X11 clients only */
+ if (Main.inputMethod.currentFocus)
+ return;
+
+ this._setCurrentRect(rect);
+ });
+ this._ibusManager.connect('focus-in', () => {
+ this.emit('focus-changed', true);
+ });
+ this._ibusManager.connect('focus-out', () => {
+ this.emit('focus-changed', false);
+ });
+ }
+
+ get currentWindow() {
+ return this._currentWindow;
+ }
+
+ _setCurrentWindow(window) {
+ this._currentWindow = window;
+ }
+
+ _setCurrentRect(rect) {
+ if (this._currentWindow) {
+ let frameRect = this._currentWindow.get_frame_rect();
+ rect.x -= frameRect.x;
+ rect.y -= frameRect.y;
+ }
+
+ if (this._rect &&
+ this._rect.x == rect.x &&
+ this._rect.y == rect.y &&
+ this._rect.width == rect.width &&
+ this._rect.height == rect.height)
+ return;
+
+ this._rect = rect;
+ this.emit('position-changed');
+ }
+
+ getCurrentRect() {
+ let rect = { x: this._rect.x, y: this._rect.y,
+ width: this._rect.width, height: this._rect.height };
+
+ if (this._currentWindow) {
+ let frameRect = this._currentWindow.get_frame_rect();
+ rect.x += frameRect.x;
+ rect.y += frameRect.y;
+ }
+
+ return rect;
+ }
+};
+Signals.addSignalMethods(FocusTracker.prototype);
+
+var EmojiPager = GObject.registerClass({
+ Properties: {
+ 'delta': GObject.ParamSpec.int(
+ 'delta', 'delta', 'delta',
+ GObject.ParamFlags.READWRITE,
+ GLib.MININT32, GLib.MAXINT32, 0),
+ },
+ Signals: {
+ 'emoji': { param_types: [GObject.TYPE_STRING] },
+ 'page-changed': {
+ param_types: [GObject.TYPE_INT, GObject.TYPE_INT, GObject.TYPE_INT],
+ },
+ },
+}, class EmojiPager extends St.Widget {
+ _init(sections, nCols, nRows) {
+ super._init({
+ layout_manager: new Clutter.BinLayout(),
+ reactive: true,
+ clip_to_allocation: true,
+ y_expand: true,
+ });
+ this._sections = sections;
+ this._nCols = nCols;
+ this._nRows = nRows;
+
+ this._pages = [];
+ this._panel = null;
+ this._curPage = null;
+ this._followingPage = null;
+ this._followingPanel = null;
+ this._currentKey = null;
+ this._delta = 0;
+ this._width = null;
+
+ this._initPagingInfo();
+
+ let panAction = new Clutter.PanAction({ interpolate: false });
+ panAction.connect('pan', this._onPan.bind(this));
+ panAction.connect('gesture-begin', this._onPanBegin.bind(this));
+ panAction.connect('gesture-cancel', this._onPanCancel.bind(this));
+ panAction.connect('gesture-end', this._onPanEnd.bind(this));
+ this._panAction = panAction;
+ this.add_action(panAction);
+ }
+
+ get delta() {
+ return this._delta;
+ }
+
+ set delta(value) {
+ if (value > this._width)
+ value = this._width;
+ else if (value < -this._width)
+ value = -this._width;
+
+ if (this._delta == value)
+ return;
+
+ this._delta = value;
+ this.notify('delta');
+
+ if (value == 0)
+ return;
+
+ let relValue = Math.abs(value / this._width);
+ let followingPage = this.getFollowingPage();
+
+ if (this._followingPage != followingPage) {
+ if (this._followingPanel) {
+ this._followingPanel.destroy();
+ this._followingPanel = null;
+ }
+
+ if (followingPage != null) {
+ this._followingPanel = this._generatePanel(followingPage);
+ this._followingPanel.set_pivot_point(0.5, 0.5);
+ this.add_child(this._followingPanel);
+ this.set_child_below_sibling(this._followingPanel, this._panel);
+ }
+
+ this._followingPage = followingPage;
+ }
+
+ this._panel.translation_x = value;
+ this._panel.opacity = 255 * (1 - Math.pow(relValue, 3));
+
+ if (this._followingPanel) {
+ this._followingPanel.scale_x = 0.8 + (0.2 * relValue);
+ this._followingPanel.scale_y = 0.8 + (0.2 * relValue);
+ this._followingPanel.opacity = 255 * relValue;
+ }
+ }
+
+ _prevPage(nPage) {
+ return (nPage + this._pages.length - 1) % this._pages.length;
+ }
+
+ _nextPage(nPage) {
+ return (nPage + 1) % this._pages.length;
+ }
+
+ getFollowingPage() {
+ if (this.delta == 0)
+ return null;
+
+ if ((this.delta < 0 && global.stage.text_direction == Clutter.TextDirection.LTR) ||
+ (this.delta > 0 && global.stage.text_direction == Clutter.TextDirection.RTL))
+ return this._nextPage(this._curPage);
+ else
+ return this._prevPage(this._curPage);
+ }
+
+ _onPan(action) {
+ let [dist_, dx, dy_] = action.get_motion_delta(0);
+ this.delta += dx;
+
+ if (this._currentKey != null) {
+ this._currentKey.cancel();
+ this._currentKey = null;
+ }
+
+ return false;
+ }
+
+ _onPanBegin() {
+ this._width = this.width;
+ return true;
+ }
+
+ _onPanEnd() {
+ if (Math.abs(this._delta) < this.width * PANEL_SWITCH_RELATIVE_DISTANCE) {
+ this._onPanCancel();
+ } else {
+ let value;
+ if (this._delta > 0)
+ value = this._width;
+ else if (this._delta < 0)
+ value = -this._width;
+
+ let relDelta = Math.abs(this._delta - value) / this._width;
+ let time = PANEL_SWITCH_ANIMATION_TIME * Math.abs(relDelta);
+
+ this.remove_all_transitions();
+ this.ease_property('delta', value, {
+ duration: time,
+ onComplete: () => {
+ this.setCurrentPage(this.getFollowingPage());
+ },
+ });
+ }
+ }
+
+ _onPanCancel() {
+ let relDelta = Math.abs(this._delta) / this.width;
+ let time = PANEL_SWITCH_ANIMATION_TIME * Math.abs(relDelta);
+
+ this.remove_all_transitions();
+ this.ease_property('delta', 0, {
+ duration: time,
+ });
+ }
+
+ _initPagingInfo() {
+ for (let i = 0; i < this._sections.length; i++) {
+ let section = this._sections[i];
+ let itemsPerPage = this._nCols * this._nRows;
+ let nPages = Math.ceil(section.keys.length / itemsPerPage);
+ let page = -1;
+ let pageKeys;
+
+ for (let j = 0; j < section.keys.length; j++) {
+ if (j % itemsPerPage == 0) {
+ page++;
+ pageKeys = [];
+ this._pages.push({ pageKeys, nPages, page, section: this._sections[i] });
+ }
+
+ pageKeys.push(section.keys[j]);
+ }
+ }
+ }
+
+ _lookupSection(section, nPage) {
+ for (let i = 0; i < this._pages.length; i++) {
+ let page = this._pages[i];
+
+ if (page.section == section && page.page == nPage)
+ return i;
+ }
+
+ return -1;
+ }
+
+ _generatePanel(nPage) {
+ let gridLayout = new Clutter.GridLayout({ orientation: Clutter.Orientation.HORIZONTAL,
+ column_homogeneous: true,
+ row_homogeneous: true });
+ let panel = new St.Widget({ layout_manager: gridLayout,
+ style_class: 'emoji-page',
+ x_expand: true,
+ y_expand: true });
+
+ /* Set an expander actor so all proportions are right despite the panel
+ * not having all rows/cols filled in.
+ */
+ let expander = new Clutter.Actor();
+ gridLayout.attach(expander, 0, 0, this._nCols, this._nRows);
+
+ let page = this._pages[nPage];
+ let col = 0;
+ let row = 0;
+
+ for (let i = 0; i < page.pageKeys.length; i++) {
+ let modelKey = page.pageKeys[i];
+ let key = new Key(modelKey.label, modelKey.variants);
+
+ key.keyButton.set_button_mask(0);
+
+ key.connect('activated', () => {
+ this._currentKey = key;
+ });
+ key.connect('long-press', () => {
+ this._panAction.cancel();
+ });
+ key.connect('released', (actor, keyval, str) => {
+ if (this._currentKey != key)
+ return;
+ this._currentKey = null;
+ this.emit('emoji', str);
+ });
+
+ gridLayout.attach(key, col, row, 1, 1);
+
+ col++;
+ if (col >= this._nCols) {
+ col = 0;
+ row++;
+ }
+ }
+
+ return panel;
+ }
+
+ setCurrentPage(nPage) {
+ if (this._curPage == nPage)
+ return;
+
+ this._curPage = nPage;
+
+ if (this._panel) {
+ this._panel.destroy();
+ this._panel = null;
+ }
+
+ /* Reuse followingPage if possible */
+ if (nPage == this._followingPage) {
+ this._panel = this._followingPanel;
+ this._followingPanel = null;
+ }
+
+ if (this._followingPanel)
+ this._followingPanel.destroy();
+
+ this._followingPanel = null;
+ this._followingPage = null;
+ this._delta = 0;
+
+ if (!this._panel) {
+ this._panel = this._generatePanel(nPage);
+ this.add_child(this._panel);
+ }
+
+ let page = this._pages[nPage];
+ this.emit('page-changed', page.section.label, page.page, page.nPages);
+ }
+
+ setCurrentSection(section, nPage) {
+ for (let i = 0; i < this._pages.length; i++) {
+ let page = this._pages[i];
+
+ if (page.section == section && page.page == nPage) {
+ this.setCurrentPage(i);
+ break;
+ }
+ }
+ }
+});
+
+var EmojiSelection = GObject.registerClass({
+ Signals: {
+ 'emoji-selected': { param_types: [GObject.TYPE_STRING] },
+ 'close-request': {},
+ 'toggle': {},
+ },
+}, class EmojiSelection extends St.BoxLayout {
+ _init() {
+ super._init({
+ style_class: 'emoji-panel',
+ x_expand: true,
+ y_expand: true,
+ vertical: true,
+ });
+
+ this._sections = [
+ { first: 'grinning face', label: '🙂️' },
+ { first: 'selfie', label: '👍️' },
+ { first: 'monkey face', label: '🌷️' },
+ { first: 'grapes', label: '🍴️' },
+ { first: 'globe showing Europe-Africa', label: '✈️' },
+ { first: 'jack-o-lantern', label: '🏃️' },
+ { first: 'muted speaker', label: '🔔️' },
+ { first: 'ATM sign', label: '❤️' },
+ { first: 'chequered flag', label: '🚩️' },
+ ];
+
+ this._populateSections();
+
+ this._emojiPager = new EmojiPager(this._sections, 11, 3);
+ this._emojiPager.connect('page-changed', (pager, sectionLabel, page, nPages) => {
+ this._onPageChanged(sectionLabel, page, nPages);
+ });
+ this._emojiPager.connect('emoji', (pager, str) => {
+ this.emit('emoji-selected', str);
+ });
+ this.add_child(this._emojiPager);
+
+ this._pageIndicator = new PageIndicators.PageIndicators(
+ Clutter.Orientation.HORIZONTAL);
+ this.add_child(this._pageIndicator);
+ this._pageIndicator.setReactive(false);
+
+ this._emojiPager.connect('notify::delta', () => {
+ this._updateIndicatorPosition();
+ });
+
+ let bottomRow = this._createBottomRow();
+ this.add_child(bottomRow);
+
+ this._curPage = 0;
+ }
+
+ vfunc_map() {
+ this._emojiPager.setCurrentPage(0);
+ super.vfunc_map();
+ }
+
+ _onPageChanged(sectionLabel, page, nPages) {
+ this._curPage = page;
+ this._pageIndicator.setNPages(nPages);
+ this._updateIndicatorPosition();
+
+ for (let i = 0; i < this._sections.length; i++) {
+ let sect = this._sections[i];
+ sect.button.setLatched(sectionLabel == sect.label);
+ }
+ }
+
+ _updateIndicatorPosition() {
+ this._pageIndicator.setCurrentPosition(this._curPage -
+ this._emojiPager.delta / this._emojiPager.width);
+ }
+
+ _findSection(emoji) {
+ for (let i = 0; i < this._sections.length; i++) {
+ if (this._sections[i].first == emoji)
+ return this._sections[i];
+ }
+
+ return null;
+ }
+
+ _populateSections() {
+ let file = Gio.File.new_for_uri('resource:///org/gnome/shell/osk-layouts/emoji.json');
+ let [success_, contents] = file.load_contents(null);
+
+ if (contents instanceof Uint8Array)
+ contents = imports.byteArray.toString(contents);
+ let emoji = JSON.parse(contents);
+
+ let variants = [];
+ let currentKey = 0;
+ let currentSection = null;
+
+ for (let i = 0; i < emoji.length; i++) {
+ /* Group variants of a same emoji so they appear on the key popover */
+ if (emoji[i].name.startsWith(emoji[currentKey].name)) {
+ variants.push(emoji[i].char);
+ if (i < emoji.length - 1)
+ continue;
+ }
+
+ let newSection = this._findSection(emoji[currentKey].name);
+ if (newSection != null) {
+ currentSection = newSection;
+ currentSection.keys = [];
+ }
+
+ /* Create the key */
+ let label = emoji[currentKey].char + String.fromCharCode(0xFE0F);
+ currentSection.keys.push({ label, variants });
+ currentKey = i;
+ variants = [];
+ }
+ }
+
+ _createBottomRow() {
+ let row = new KeyContainer();
+ let key;
+
+ row.appendRow();
+
+ key = new Key('ABC', []);
+ key.keyButton.add_style_class_name('default-key');
+ key.connect('released', () => this.emit('toggle'));
+ row.appendKey(key, 1.5);
+
+ for (let i = 0; i < this._sections.length; i++) {
+ let section = this._sections[i];
+
+ key = new Key(section.label, []);
+ key.connect('released', () => this._emojiPager.setCurrentSection(section, 0));
+ row.appendKey(key);
+
+ section.button = key;
+ }
+
+ key = new Key(null, [], 'go-down-symbolic');
+ key.keyButton.add_style_class_name('default-key');
+ key.keyButton.add_style_class_name('hide-key');
+ key.connect('released', () => {
+ this.emit('close-request');
+ });
+ row.appendKey(key);
+ row.layoutButtons();
+
+ let actor = new AspectContainer({ layout_manager: new Clutter.BinLayout(),
+ x_expand: true, y_expand: true });
+ actor.add_child(row);
+ /* Regular keyboard layouts are 11.5×4 grids, optimize for that
+ * at the moment. Ideally this should be as wide as the current
+ * keymap.
+ */
+ actor.setRatio(11.5, 1);
+
+ return actor;
+ }
+});
+
+var Keypad = GObject.registerClass({
+ Signals: {
+ 'keyval': { param_types: [GObject.TYPE_UINT] },
+ },
+}, class Keypad extends AspectContainer {
+ _init() {
+ let keys = [
+ { label: '1', keyval: Clutter.KEY_1, left: 0, top: 0 },
+ { label: '2', keyval: Clutter.KEY_2, left: 1, top: 0 },
+ { label: '3', keyval: Clutter.KEY_3, left: 2, top: 0 },
+ { label: '4', keyval: Clutter.KEY_4, left: 0, top: 1 },
+ { label: '5', keyval: Clutter.KEY_5, left: 1, top: 1 },
+ { label: '6', keyval: Clutter.KEY_6, left: 2, top: 1 },
+ { label: '7', keyval: Clutter.KEY_7, left: 0, top: 2 },
+ { label: '8', keyval: Clutter.KEY_8, left: 1, top: 2 },
+ { label: '9', keyval: Clutter.KEY_9, left: 2, top: 2 },
+ { label: '0', keyval: Clutter.KEY_0, left: 1, top: 3 },
+ { keyval: Clutter.KEY_BackSpace, icon: 'edit-clear-symbolic', left: 3, top: 0 },
+ { keyval: Clutter.KEY_Return, extraClassName: 'enter-key', icon: 'keyboard-enter-symbolic', left: 3, top: 1, height: 2 },
+ ];
+
+ super._init({
+ layout_manager: new Clutter.BinLayout(),
+ x_expand: true,
+ y_expand: true,
+ });
+
+ let gridLayout = new Clutter.GridLayout({ orientation: Clutter.Orientation.HORIZONTAL,
+ column_homogeneous: true,
+ row_homogeneous: true });
+ this._box = new St.Widget({ layout_manager: gridLayout, x_expand: true, y_expand: true });
+ this.add_child(this._box);
+
+ for (let i = 0; i < keys.length; i++) {
+ let cur = keys[i];
+ let key = new Key(cur.label || "", [], cur.icon);
+
+ if (keys[i].extraClassName)
+ key.keyButton.add_style_class_name(cur.extraClassName);
+
+ let w, h;
+ w = cur.width || 1;
+ h = cur.height || 1;
+ gridLayout.attach(key, cur.left, cur.top, w, h);
+
+ key.connect('released', () => {
+ this.emit('keyval', cur.keyval);
+ });
+ }
+ }
+});
+
+var KeyboardManager = class KeyBoardManager {
+ constructor() {
+ this._keyboard = null;
+ this._a11yApplicationsSettings = new Gio.Settings({ schema_id: A11Y_APPLICATIONS_SCHEMA });
+ this._a11yApplicationsSettings.connect('changed', this._syncEnabled.bind(this));
+
+ this._seat = Clutter.get_default_backend().get_default_seat();
+ this._seat.connect('notify::touch-mode', this._syncEnabled.bind(this));
+
+ this._lastDevice = null;
+ global.backend.connect('last-device-changed', (backend, device) => {
+ if (device.device_type === Clutter.InputDeviceType.KEYBOARD_DEVICE)
+ return;
+
+ this._lastDevice = device;
+ this._syncEnabled();
+ });
+ this._syncEnabled();
+ }
+
+ _lastDeviceIsTouchscreen() {
+ if (!this._lastDevice)
+ return false;
+
+ let deviceType = this._lastDevice.get_device_type();
+ return deviceType == Clutter.InputDeviceType.TOUCHSCREEN_DEVICE;
+ }
+
+ _syncEnabled() {
+ let enableKeyboard = this._a11yApplicationsSettings.get_boolean(SHOW_KEYBOARD);
+ let autoEnabled = this._seat.get_touch_mode() && this._lastDeviceIsTouchscreen();
+ let enabled = enableKeyboard || autoEnabled;
+
+ if (!enabled && !this._keyboard)
+ return;
+
+ if (enabled && !this._keyboard) {
+ this._keyboard = new Keyboard();
+ } else if (!enabled && this._keyboard) {
+ this._keyboard.setCursorLocation(null);
+ this._keyboard.destroy();
+ this._keyboard = null;
+ Main.layoutManager.hideKeyboard(true);
+ }
+ }
+
+ get keyboardActor() {
+ return this._keyboard;
+ }
+
+ get visible() {
+ return this._keyboard && this._keyboard.visible;
+ }
+
+ open(monitor) {
+ if (this._keyboard)
+ this._keyboard.open(monitor);
+ }
+
+ close() {
+ if (this._keyboard)
+ this._keyboard.close();
+ }
+
+ addSuggestion(text, callback) {
+ if (this._keyboard)
+ this._keyboard.addSuggestion(text, callback);
+ }
+
+ resetSuggestions() {
+ if (this._keyboard)
+ this._keyboard.resetSuggestions();
+ }
+
+ shouldTakeEvent(event) {
+ if (!this._keyboard)
+ return false;
+
+ let actor = event.get_source();
+ return Main.layoutManager.keyboardBox.contains(actor) ||
+ !!actor._extendedKeys || !!actor.extendedKey;
+ }
+};
+
+var Keyboard = GObject.registerClass(
+class Keyboard extends St.BoxLayout {
+ _init() {
+ super._init({ name: 'keyboard', vertical: true });
+ this._focusInExtendedKeys = false;
+ this._emojiActive = false;
+
+ this._languagePopup = null;
+ this._currentFocusWindow = null;
+ this._animFocusedWindow = null;
+ this._delayedAnimFocusWindow = null;
+
+ this._latched = false; // current level is latched
+
+ this._suggestions = null;
+ this._emojiKeyVisible = Meta.is_wayland_compositor();
+
+ this._focusTracker = new FocusTracker();
+ this._connectSignal(this._focusTracker, 'position-changed',
+ this._onFocusPositionChanged.bind(this));
+ this._connectSignal(this._focusTracker, 'reset', () => {
+ this._delayedAnimFocusWindow = null;
+ this._animFocusedWindow = null;
+ this._oskFocusWindow = null;
+ });
+ // Valid only for X11
+ if (!Meta.is_wayland_compositor()) {
+ this._connectSignal(this._focusTracker, 'focus-changed', (_tracker, focused) => {
+ if (focused)
+ this.open(Main.layoutManager.focusIndex);
+ else
+ this.close();
+ });
+ }
+
+ this._showIdleId = 0;
+
+ this._keyboardVisible = false;
+ this._connectSignal(Main.layoutManager, 'keyboard-visible-changed', (_lm, visible) => {
+ this._keyboardVisible = visible;
+ });
+ this._keyboardRequested = false;
+ this._keyboardRestingId = 0;
+
+ this._connectSignal(Main.layoutManager, 'monitors-changed', this._relayout.bind(this));
+
+ this._setupKeyboard();
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ _connectSignal(obj, signal, callback) {
+ if (!this._connectionsIDs)
+ this._connectionsIDs = [];
+
+ let id = obj.connect(signal, callback);
+ this._connectionsIDs.push([obj, id]);
+ return id;
+ }
+
+ get visible() {
+ return this._keyboardVisible && super.visible;
+ }
+
+ set visible(visible) {
+ super.visible = visible;
+ }
+
+ _onFocusPositionChanged(focusTracker) {
+ let rect = focusTracker.getCurrentRect();
+ this.setCursorLocation(focusTracker.currentWindow, rect.x, rect.y, rect.width, rect.height);
+ }
+
+ _onDestroy() {
+ for (let [obj, id] of this._connectionsIDs)
+ obj.disconnect(id);
+ delete this._connectionsIDs;
+
+ this._clearShowIdle();
+
+ this._keyboardController.destroy();
+
+ Main.layoutManager.untrackChrome(this);
+ Main.layoutManager.keyboardBox.remove_actor(this);
+
+ if (this._languagePopup) {
+ this._languagePopup.destroy();
+ this._languagePopup = null;
+ }
+ }
+
+ _setupKeyboard() {
+ Main.layoutManager.keyboardBox.add_actor(this);
+ Main.layoutManager.trackChrome(this);
+
+ this._keyboardController = new KeyboardController();
+
+ this._groups = {};
+ this._currentPage = null;
+
+ this._suggestions = new Suggestions();
+ this.add_child(this._suggestions);
+
+ this._aspectContainer = new AspectContainer({
+ layout_manager: new Clutter.BinLayout(),
+ y_expand: true,
+ });
+ this.add_child(this._aspectContainer);
+
+ this._emojiSelection = new EmojiSelection();
+ this._emojiSelection.connect('toggle', this._toggleEmoji.bind(this));
+ this._emojiSelection.connect('close-request', () => this.close());
+ this._emojiSelection.connect('emoji-selected', (selection, emoji) => {
+ this._keyboardController.commitString(emoji);
+ });
+
+ this._aspectContainer.add_child(this._emojiSelection);
+ this._emojiSelection.hide();
+
+ this._keypad = new Keypad();
+ this._connectSignal(this._keypad, 'keyval', (_keypad, keyval) => {
+ this._keyboardController.keyvalPress(keyval);
+ this._keyboardController.keyvalRelease(keyval);
+ });
+ this._aspectContainer.add_child(this._keypad);
+ this._keypad.hide();
+ this._keypadVisible = false;
+
+ this._ensureKeysForGroup(this._keyboardController.getCurrentGroup());
+ this._setActiveLayer(0);
+
+ // Keyboard models are defined in LTR, we must override
+ // the locale setting in order to avoid flipping the
+ // keyboard on RTL locales.
+ this.text_direction = Clutter.TextDirection.LTR;
+
+ this._connectSignal(this._keyboardController, 'active-group',
+ this._onGroupChanged.bind(this));
+ this._connectSignal(this._keyboardController, 'groups-changed',
+ this._onKeyboardGroupsChanged.bind(this));
+ this._connectSignal(this._keyboardController, 'panel-state',
+ this._onKeyboardStateChanged.bind(this));
+ this._connectSignal(this._keyboardController, 'keypad-visible',
+ this._onKeypadVisible.bind(this));
+ this._connectSignal(global.stage, 'notify::key-focus',
+ this._onKeyFocusChanged.bind(this));
+
+ if (Meta.is_wayland_compositor()) {
+ this._connectSignal(this._keyboardController, 'emoji-visible',
+ this._onEmojiKeyVisible.bind(this));
+ }
+
+ this._relayout();
+ }
+
+ _onKeyFocusChanged() {
+ let focus = global.stage.key_focus;
+
+ // Showing an extended key popup and clicking a key from the extended keys
+ // will grab focus, but ignore that
+ let extendedKeysWereFocused = this._focusInExtendedKeys;
+ this._focusInExtendedKeys = focus && (focus._extendedKeys || focus.extendedKey);
+ if (this._focusInExtendedKeys || extendedKeysWereFocused)
+ return;
+
+ if (!(focus instanceof Clutter.Text)) {
+ this.close();
+ return;
+ }
+
+ if (!this._showIdleId) {
+ this._showIdleId = GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
+ this.open(Main.layoutManager.focusIndex);
+ this._showIdleId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._showIdleId, '[gnome-shell] this.open');
+ }
+ }
+
+ _createLayersForGroup(groupName) {
+ let keyboardModel = new KeyboardModel(groupName);
+ let layers = {};
+ let levels = keyboardModel.getLevels();
+ for (let i = 0; i < levels.length; i++) {
+ let currentLevel = levels[i];
+ /* There are keyboard maps which consist of 3 levels (no uppercase,
+ * basically). We however make things consistent by skipping that
+ * second level.
+ */
+ let level = i >= 1 && levels.length == 3 ? i + 1 : i;
+
+ let layout = new KeyContainer();
+ layout.shiftKeys = [];
+
+ this._loadRows(currentLevel, level, levels.length, layout);
+ layers[level] = layout;
+ this._aspectContainer.add_child(layout);
+ layout.layoutButtons(this._aspectContainer);
+
+ layout.hide();
+ }
+
+ return layers;
+ }
+
+ _ensureKeysForGroup(group) {
+ if (!this._groups[group])
+ this._groups[group] = this._createLayersForGroup(group);
+ }
+
+ _addRowKeys(keys, layout) {
+ for (let i = 0; i < keys.length; ++i) {
+ let key = keys[i];
+ let button = new Key(key.shift(), key);
+
+ /* Space key gets special width, dependent on the number of surrounding keys */
+ if (button.key == ' ')
+ button.setWidth(keys.length <= 3 ? 5 : 3);
+
+ button.connect('pressed', (actor, keyval, str) => {
+ if (!Main.inputMethod.currentFocus ||
+ !this._keyboardController.commitString(str, true)) {
+ if (keyval != 0) {
+ this._keyboardController.keyvalPress(keyval);
+ button._keyvalPress = true;
+ }
+ }
+ });
+ button.connect('released', (actor, keyval, _str) => {
+ if (keyval != 0) {
+ if (button._keyvalPress)
+ this._keyboardController.keyvalRelease(keyval);
+ button._keyvalPress = false;
+ }
+
+ if (!this._latched)
+ this._setActiveLayer(0);
+ });
+
+ layout.appendKey(button, button.keyButton.keyWidth);
+ }
+ }
+
+ _popupLanguageMenu(keyActor) {
+ if (this._languagePopup)
+ this._languagePopup.destroy();
+
+ this._languagePopup = new LanguageSelectionPopup(keyActor);
+ Main.layoutManager.addTopChrome(this._languagePopup.actor);
+ this._languagePopup.open(true);
+ }
+
+ _loadDefaultKeys(keys, layout, numLevels, numKeys) {
+ let extraButton;
+ for (let i = 0; i < keys.length; i++) {
+ let key = keys[i];
+ let keyval = key.keyval;
+ let switchToLevel = key.level;
+ let action = key.action;
+ let icon = key.icon;
+
+ /* Skip emoji button if necessary */
+ if (!this._emojiKeyVisible && action == 'emoji')
+ continue;
+
+ extraButton = new Key(key.label || '', [], icon);
+
+ extraButton.keyButton.add_style_class_name('default-key');
+ if (key.extraClassName != null)
+ extraButton.keyButton.add_style_class_name(key.extraClassName);
+ if (key.width != null)
+ extraButton.setWidth(key.width);
+
+ let actor = extraButton.keyButton;
+
+ extraButton.connect('pressed', () => {
+ if (switchToLevel != null) {
+ this._setActiveLayer(switchToLevel);
+ // Shift only gets latched on long press
+ this._latched = switchToLevel != 1;
+ } else if (keyval != null) {
+ this._keyboardController.keyvalPress(keyval);
+ }
+ });
+ extraButton.connect('released', () => {
+ if (keyval != null)
+ this._keyboardController.keyvalRelease(keyval);
+ else if (action == 'hide')
+ this.close();
+ else if (action == 'languageMenu')
+ this._popupLanguageMenu(actor);
+ else if (action == 'emoji')
+ this._toggleEmoji();
+ });
+
+ if (switchToLevel == 0) {
+ layout.shiftKeys.push(extraButton);
+ } else if (switchToLevel == 1) {
+ extraButton.connect('long-press', () => {
+ this._latched = true;
+ this._setCurrentLevelLatched(this._currentPage, this._latched);
+ });
+ }
+
+ /* Fixup default keys based on the number of levels/keys */
+ if (switchToLevel == 1 && numLevels == 3) {
+ // Hide shift key if the keymap has no uppercase level
+ if (key.right) {
+ /* Only hide the key actor, so the container still takes space */
+ extraButton.keyButton.hide();
+ } else {
+ extraButton.hide();
+ }
+ extraButton.setWidth(1.5);
+ } else if (key.right && numKeys > 8) {
+ extraButton.setWidth(2);
+ } else if (keyval == Clutter.KEY_Return && numKeys > 9) {
+ extraButton.setWidth(1.5);
+ } else if (!this._emojiKeyVisible && (action == 'hide' || action == 'languageMenu')) {
+ extraButton.setWidth(1.5);
+ }
+
+ layout.appendKey(extraButton, extraButton.keyButton.keyWidth);
+ }
+ }
+
+ _updateCurrentPageVisible() {
+ if (this._currentPage)
+ this._currentPage.visible = !this._emojiActive && !this._keypadVisible;
+ }
+
+ _setEmojiActive(active) {
+ this._emojiActive = active;
+ this._emojiSelection.visible = this._emojiActive;
+ this._updateCurrentPageVisible();
+ }
+
+ _toggleEmoji() {
+ this._setEmojiActive(!this._emojiActive);
+ }
+
+ _setCurrentLevelLatched(layout, latched) {
+ for (let i = 0; i < layout.shiftKeys.length; i++) {
+ let key = layout.shiftKeys[i];
+ key.setLatched(latched);
+ }
+ }
+
+ _getDefaultKeysForRow(row, numRows, level) {
+ /* The first 2 rows in defaultKeysPre/Post belong together with
+ * the first 2 rows on each keymap. On keymaps that have more than
+ * 4 rows, the last 2 default key rows must be respectively
+ * assigned to the 2 last keymap ones.
+ */
+ if (row < 2) {
+ return [defaultKeysPre[level][row], defaultKeysPost[level][row]];
+ } else if (row >= numRows - 2) {
+ let defaultRow = row - (numRows - 2) + 2;
+ return [defaultKeysPre[level][defaultRow], defaultKeysPost[level][defaultRow]];
+ } else {
+ return [null, null];
+ }
+ }
+
+ _mergeRowKeys(layout, pre, row, post, numLevels) {
+ if (pre != null)
+ this._loadDefaultKeys(pre, layout, numLevels, row.length);
+
+ this._addRowKeys(row, layout);
+
+ if (post != null)
+ this._loadDefaultKeys(post, layout, numLevels, row.length);
+ }
+
+ _loadRows(model, level, numLevels, layout) {
+ let rows = model.rows;
+ for (let i = 0; i < rows.length; ++i) {
+ layout.appendRow();
+ let [pre, post] = this._getDefaultKeysForRow(i, rows.length, level);
+ this._mergeRowKeys(layout, pre, rows[i], post, numLevels);
+ }
+ }
+
+ _getGridSlots() {
+ let numOfHorizSlots = 0, numOfVertSlots;
+ let rows = this._currentPage.get_children();
+ numOfVertSlots = rows.length;
+
+ for (let i = 0; i < rows.length; ++i) {
+ let keyboardRow = rows[i];
+ let keys = keyboardRow.get_children();
+
+ numOfHorizSlots = Math.max(numOfHorizSlots, keys.length);
+ }
+
+ return [numOfHorizSlots, numOfVertSlots];
+ }
+
+ _relayout() {
+ let monitor = Main.layoutManager.keyboardMonitor;
+
+ if (!monitor)
+ return;
+
+ let maxHeight = monitor.height / 3;
+ this.width = monitor.width;
+
+ if (monitor.width > monitor.height) {
+ this.height = maxHeight;
+ } else {
+ /* In portrait mode, lack of horizontal space means we won't be
+ * able to make the OSK that big while keeping size ratio, so
+ * we allow the OSK being smaller than 1/3rd of the monitor height
+ * there.
+ */
+ const forWidth = this.get_theme_node().adjust_for_width(monitor.width);
+ const [, natHeight] = this.get_preferred_height(forWidth);
+ this.height = Math.min(maxHeight, natHeight);
+ }
+ }
+
+ _onGroupChanged() {
+ this._ensureKeysForGroup(this._keyboardController.getCurrentGroup());
+ this._setActiveLayer(0);
+ }
+
+ _onKeyboardGroupsChanged() {
+ let nonGroupActors = [this._emojiSelection, this._keypad];
+ this._aspectContainer.get_children().filter(c => !nonGroupActors.includes(c)).forEach(c => {
+ c.destroy();
+ });
+
+ this._groups = {};
+ this._onGroupChanged();
+ }
+
+ _onKeypadVisible(controller, visible) {
+ if (visible == this._keypadVisible)
+ return;
+
+ this._keypadVisible = visible;
+ this._keypad.visible = this._keypadVisible;
+ this._updateCurrentPageVisible();
+ }
+
+ _onEmojiKeyVisible(controller, visible) {
+ if (visible == this._emojiKeyVisible)
+ return;
+
+ this._emojiKeyVisible = visible;
+ /* Rebuild keyboard widgetry to include emoji button */
+ this._onKeyboardGroupsChanged();
+ }
+
+ _onKeyboardStateChanged(controller, state) {
+ let enabled;
+ if (state == Clutter.InputPanelState.OFF)
+ enabled = false;
+ else if (state == Clutter.InputPanelState.ON)
+ enabled = true;
+ else if (state == Clutter.InputPanelState.TOGGLE)
+ enabled = this._keyboardVisible == false;
+ else
+ return;
+
+ if (enabled)
+ this.open(Main.layoutManager.focusIndex);
+ else
+ this.close();
+ }
+
+ _setActiveLayer(activeLevel) {
+ let activeGroupName = this._keyboardController.getCurrentGroup();
+ let layers = this._groups[activeGroupName];
+ let currentPage = layers[activeLevel];
+
+ if (this._currentPage == currentPage) {
+ this._updateCurrentPageVisible();
+ return;
+ }
+
+ if (this._currentPage != null) {
+ this._setCurrentLevelLatched(this._currentPage, false);
+ this._currentPage.disconnect(this._currentPage._destroyID);
+ this._currentPage.hide();
+ delete this._currentPage._destroyID;
+ }
+
+ this._currentPage = currentPage;
+ this._currentPage._destroyID = this._currentPage.connect('destroy', () => {
+ this._currentPage = null;
+ });
+ this._updateCurrentPageVisible();
+ }
+
+ _clearKeyboardRestTimer() {
+ if (!this._keyboardRestingId)
+ return;
+ GLib.source_remove(this._keyboardRestingId);
+ this._keyboardRestingId = 0;
+ }
+
+ open(monitor) {
+ this._clearShowIdle();
+ this._keyboardRequested = true;
+
+ if (this._keyboardVisible) {
+ if (monitor != Main.layoutManager.keyboardIndex) {
+ Main.layoutManager.keyboardIndex = monitor;
+ this._relayout();
+ }
+ return;
+ }
+
+ this._clearKeyboardRestTimer();
+ this._keyboardRestingId = GLib.timeout_add(GLib.PRIORITY_DEFAULT,
+ KEYBOARD_REST_TIME,
+ () => {
+ this._clearKeyboardRestTimer();
+ this._open(monitor);
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._keyboardRestingId, '[gnome-shell] this._clearKeyboardRestTimer');
+ }
+
+ _open(monitor) {
+ if (!this._keyboardRequested)
+ return;
+
+ Main.layoutManager.keyboardIndex = monitor;
+ this._relayout();
+ Main.layoutManager.showKeyboard();
+
+ this._setEmojiActive(false);
+
+ if (this._delayedAnimFocusWindow) {
+ this._setAnimationWindow(this._delayedAnimFocusWindow);
+ this._delayedAnimFocusWindow = null;
+ }
+ }
+
+ close() {
+ this._clearShowIdle();
+ this._keyboardRequested = false;
+
+ if (!this._keyboardVisible)
+ return;
+
+ this._clearKeyboardRestTimer();
+ this._keyboardRestingId = GLib.timeout_add(GLib.PRIORITY_DEFAULT,
+ KEYBOARD_REST_TIME,
+ () => {
+ this._clearKeyboardRestTimer();
+ this._close();
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._keyboardRestingId, '[gnome-shell] this._clearKeyboardRestTimer');
+ }
+
+ _close() {
+ if (this._keyboardRequested)
+ return;
+
+ Main.layoutManager.hideKeyboard();
+ this.setCursorLocation(null);
+ }
+
+ resetSuggestions() {
+ if (this._suggestions)
+ this._suggestions.clear();
+ }
+
+ addSuggestion(text, callback) {
+ if (!this._suggestions)
+ return;
+ this._suggestions.add(text, callback);
+ this._suggestions.show();
+ }
+
+ _clearShowIdle() {
+ if (!this._showIdleId)
+ return;
+ GLib.source_remove(this._showIdleId);
+ this._showIdleId = 0;
+ }
+
+ _windowSlideAnimationComplete(window, delta) {
+ // Synchronize window positions again.
+ let frameRect = window.get_frame_rect();
+ frameRect.y += delta;
+ window.move_frame(true, frameRect.x, frameRect.y);
+ }
+
+ _animateWindow(window, show) {
+ let windowActor = window.get_compositor_private();
+ let deltaY = Main.layoutManager.keyboardBox.height;
+ if (!windowActor)
+ return;
+
+ if (show) {
+ windowActor.ease({
+ y: windowActor.y - deltaY,
+ duration: Layout.KEYBOARD_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ this._windowSlideAnimationComplete(window, -deltaY);
+ },
+ });
+ } else {
+ windowActor.ease({
+ y: windowActor.y + deltaY,
+ duration: Layout.KEYBOARD_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_IN_QUAD,
+ onComplete: () => {
+ this._windowSlideAnimationComplete(window, deltaY);
+ },
+ });
+ }
+ }
+
+ _setAnimationWindow(window) {
+ if (this._animFocusedWindow == window)
+ return;
+
+ if (this._animFocusedWindow)
+ this._animateWindow(this._animFocusedWindow, false);
+ if (window)
+ this._animateWindow(window, true);
+
+ this._animFocusedWindow = window;
+ }
+
+ setCursorLocation(window, x, y, w, h) {
+ let monitor = Main.layoutManager.keyboardMonitor;
+
+ if (window && monitor) {
+ let keyboardHeight = Main.layoutManager.keyboardBox.height;
+
+ if (y + h >= monitor.y + monitor.height - keyboardHeight) {
+ if (this._keyboardVisible)
+ this._setAnimationWindow(window);
+ else
+ this._delayedAnimFocusWindow = window;
+ } else if (y < keyboardHeight) {
+ this._delayedAnimFocusWindow = null;
+ this._setAnimationWindow(null);
+ }
+ } else {
+ this._setAnimationWindow(null);
+ }
+
+ this._oskFocusWindow = window;
+ }
+});
+
+var KeyboardController = class {
+ constructor() {
+ let seat = Clutter.get_default_backend().get_default_seat();
+ this._virtualDevice = seat.create_virtual_device(Clutter.InputDeviceType.KEYBOARD_DEVICE);
+
+ this._inputSourceManager = InputSourceManager.getInputSourceManager();
+ this._sourceChangedId = this._inputSourceManager.connect('current-source-changed',
+ this._onSourceChanged.bind(this));
+ this._sourcesModifiedId = this._inputSourceManager.connect('sources-changed',
+ this._onSourcesModified.bind(this));
+ this._currentSource = this._inputSourceManager.currentSource;
+
+ this._notifyContentPurposeId = Main.inputMethod.connect(
+ 'notify::content-purpose', this._onContentPurposeHintsChanged.bind(this));
+ this._notifyContentHintsId = Main.inputMethod.connect(
+ 'notify::content-hints', this._onContentPurposeHintsChanged.bind(this));
+ this._notifyInputPanelStateId = Main.inputMethod.connect(
+ 'input-panel-state', (o, state) => this.emit('panel-state', state));
+ }
+
+ destroy() {
+ this._inputSourceManager.disconnect(this._sourceChangedId);
+ this._inputSourceManager.disconnect(this._sourcesModifiedId);
+ Main.inputMethod.disconnect(this._notifyContentPurposeId);
+ Main.inputMethod.disconnect(this._notifyContentHintsId);
+ Main.inputMethod.disconnect(this._notifyInputPanelStateId);
+
+ // Make sure any buttons pressed by the virtual device are released
+ // immediately instead of waiting for the next GC cycle
+ this._virtualDevice.run_dispose();
+ }
+
+ _onSourcesModified() {
+ this.emit('groups-changed');
+ }
+
+ _onSourceChanged(inputSourceManager, _oldSource) {
+ let source = inputSourceManager.currentSource;
+ this._currentSource = source;
+ this.emit('active-group', source.id);
+ }
+
+ _onContentPurposeHintsChanged(method) {
+ let purpose = method.content_purpose;
+ let emojiVisible = false;
+ let keypadVisible = false;
+
+ if (purpose == Clutter.InputContentPurpose.NORMAL ||
+ purpose == Clutter.InputContentPurpose.ALPHA ||
+ purpose == Clutter.InputContentPurpose.PASSWORD ||
+ purpose == Clutter.InputContentPurpose.TERMINAL)
+ emojiVisible = true;
+ if (purpose == Clutter.InputContentPurpose.DIGITS ||
+ purpose == Clutter.InputContentPurpose.NUMBER ||
+ purpose == Clutter.InputContentPurpose.PHONE)
+ keypadVisible = true;
+
+ this.emit('emoji-visible', emojiVisible);
+ this.emit('keypad-visible', keypadVisible);
+ }
+
+ getGroups() {
+ let inputSources = this._inputSourceManager.inputSources;
+ let groups = [];
+
+ for (let i in inputSources) {
+ let is = inputSources[i];
+ groups[is.index] = is.xkbId;
+ }
+
+ return groups;
+ }
+
+ getCurrentGroup() {
+ return this._currentSource.xkbId;
+ }
+
+ commitString(string, fromKey) {
+ if (string == null)
+ return false;
+ /* Let ibus methods fall through keyval emission */
+ if (fromKey && this._currentSource.type == InputSourceManager.INPUT_SOURCE_TYPE_IBUS)
+ return false;
+
+ Main.inputMethod.commit(string);
+ return true;
+ }
+
+ keyvalPress(keyval) {
+ this._virtualDevice.notify_keyval(Clutter.get_current_event_time(),
+ keyval, Clutter.KeyState.PRESSED);
+ }
+
+ keyvalRelease(keyval) {
+ this._virtualDevice.notify_keyval(Clutter.get_current_event_time(),
+ keyval, Clutter.KeyState.RELEASED);
+ }
+};
+Signals.addSignalMethods(KeyboardController.prototype);
diff --git a/js/ui/layout.js b/js/ui/layout.js
new file mode 100644
index 0000000..b082ec8
--- /dev/null
+++ b/js/ui/layout.js
@@ -0,0 +1,1409 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported MonitorConstraint, LayoutManager */
+
+const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi;
+const Signals = imports.signals;
+
+const Background = imports.ui.background;
+const BackgroundMenu = imports.ui.backgroundMenu;
+
+const DND = imports.ui.dnd;
+const Main = imports.ui.main;
+const Params = imports.misc.params;
+const Ripples = imports.ui.ripples;
+
+var STARTUP_ANIMATION_TIME = 500;
+var KEYBOARD_ANIMATION_TIME = 150;
+var BACKGROUND_FADE_ANIMATION_TIME = 1000;
+
+var HOT_CORNER_PRESSURE_THRESHOLD = 100; // pixels
+var HOT_CORNER_PRESSURE_TIMEOUT = 1000; // ms
+
+function isPopupMetaWindow(actor) {
+ switch (actor.meta_window.get_window_type()) {
+ case Meta.WindowType.DROPDOWN_MENU:
+ case Meta.WindowType.POPUP_MENU:
+ case Meta.WindowType.COMBO:
+ return true;
+ default:
+ return false;
+ }
+}
+
+var MonitorConstraint = GObject.registerClass({
+ Properties: {
+ 'primary': GObject.ParamSpec.boolean('primary',
+ 'Primary', 'Track primary monitor',
+ GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE,
+ false),
+ 'index': GObject.ParamSpec.int('index',
+ 'Monitor index', 'Track specific monitor',
+ GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE,
+ -1, 64, -1),
+ 'work-area': GObject.ParamSpec.boolean('work-area',
+ 'Work-area', 'Track monitor\'s work-area',
+ GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE,
+ false),
+ },
+}, class MonitorConstraint extends Clutter.Constraint {
+ _init(props) {
+ this._primary = false;
+ this._index = -1;
+ this._workArea = false;
+
+ super._init(props);
+ }
+
+ get primary() {
+ return this._primary;
+ }
+
+ set primary(v) {
+ if (v)
+ this._index = -1;
+ this._primary = v;
+ if (this.actor)
+ this.actor.queue_relayout();
+ this.notify('primary');
+ }
+
+ get index() {
+ return this._index;
+ }
+
+ set index(v) {
+ this._primary = false;
+ this._index = v;
+ if (this.actor)
+ this.actor.queue_relayout();
+ this.notify('index');
+ }
+
+ // eslint-disable-next-line camelcase
+ get work_area() {
+ return this._workArea;
+ }
+
+ // eslint-disable-next-line camelcase
+ set work_area(v) {
+ if (v == this._workArea)
+ return;
+ this._workArea = v;
+ if (this.actor)
+ this.actor.queue_relayout();
+ this.notify('work-area');
+ }
+
+ vfunc_set_actor(actor) {
+ if (actor) {
+ if (!this._monitorsChangedId) {
+ this._monitorsChangedId =
+ Main.layoutManager.connect('monitors-changed', () => {
+ this.actor.queue_relayout();
+ });
+ }
+
+ if (!this._workareasChangedId) {
+ this._workareasChangedId =
+ global.display.connect('workareas-changed', () => {
+ if (this._workArea)
+ this.actor.queue_relayout();
+ });
+ }
+ } else {
+ if (this._monitorsChangedId)
+ Main.layoutManager.disconnect(this._monitorsChangedId);
+ this._monitorsChangedId = 0;
+
+ if (this._workareasChangedId)
+ global.display.disconnect(this._workareasChangedId);
+ this._workareasChangedId = 0;
+ }
+
+ super.vfunc_set_actor(actor);
+ }
+
+ vfunc_update_allocation(actor, actorBox) {
+ if (!this._primary && this._index < 0)
+ return;
+
+ if (!Main.layoutManager.primaryMonitor)
+ return;
+
+ let index;
+ if (this._primary)
+ index = Main.layoutManager.primaryIndex;
+ else
+ index = Math.min(this._index, Main.layoutManager.monitors.length - 1);
+
+ let rect;
+ if (this._workArea) {
+ let workspaceManager = global.workspace_manager;
+ let ws = workspaceManager.get_workspace_by_index(0);
+ rect = ws.get_work_area_for_monitor(index);
+ } else {
+ rect = Main.layoutManager.monitors[index];
+ }
+
+ actorBox.init_rect(rect.x, rect.y, rect.width, rect.height);
+ }
+});
+
+var Monitor = class Monitor {
+ constructor(index, geometry, geometryScale) {
+ this.index = index;
+ this.x = geometry.x;
+ this.y = geometry.y;
+ this.width = geometry.width;
+ this.height = geometry.height;
+ this.geometry_scale = geometryScale;
+ }
+
+ get inFullscreen() {
+ return global.display.get_monitor_in_fullscreen(this.index);
+ }
+};
+
+const UiActor = GObject.registerClass(
+class UiActor extends St.Widget {
+ vfunc_get_preferred_width(_forHeight) {
+ let width = global.stage.width;
+ return [width, width];
+ }
+
+ vfunc_get_preferred_height(_forWidth) {
+ let height = global.stage.height;
+ return [height, height];
+ }
+});
+
+const defaultParams = {
+ trackFullscreen: false,
+ affectsStruts: false,
+ affectsInputRegion: true,
+};
+
+var LayoutManager = GObject.registerClass({
+ Signals: { 'hot-corners-changed': {},
+ 'startup-complete': {},
+ 'startup-prepared': {},
+ 'monitors-changed': {},
+ 'system-modal-opened': {},
+ 'keyboard-visible-changed': { param_types: [GObject.TYPE_BOOLEAN] } },
+}, class LayoutManager extends GObject.Object {
+ _init() {
+ super._init();
+
+ this._rtl = Clutter.get_default_text_direction() == Clutter.TextDirection.RTL;
+ this.monitors = [];
+ this.primaryMonitor = null;
+ this.primaryIndex = -1;
+ this.hotCorners = [];
+
+ this._keyboardIndex = -1;
+ this._rightPanelBarrier = null;
+
+ this._inOverview = false;
+ this._updateRegionIdle = 0;
+
+ this._trackedActors = [];
+ this._topActors = [];
+ this._isPopupWindowVisible = false;
+ this._startingUp = true;
+ this._pendingLoadBackground = false;
+
+ // Set up stage hierarchy to group all UI actors under one container.
+ this.uiGroup = new UiActor({ name: 'uiGroup' });
+ this.uiGroup.set_flags(Clutter.ActorFlags.NO_LAYOUT);
+
+ global.stage.add_child(this.uiGroup);
+
+ global.stage.remove_actor(global.window_group);
+ this.uiGroup.add_actor(global.window_group);
+
+ // Using addChrome() to add actors to uiGroup will position actors
+ // underneath the top_window_group.
+ // To insert actors at the top of uiGroup, we use addTopChrome() or
+ // add the actor directly using uiGroup.add_actor().
+ global.stage.remove_actor(global.top_window_group);
+ this.uiGroup.add_actor(global.top_window_group);
+
+ this.overviewGroup = new St.Widget({ name: 'overviewGroup',
+ visible: false,
+ reactive: true });
+ this.addChrome(this.overviewGroup);
+
+ this.screenShieldGroup = new St.Widget({
+ name: 'screenShieldGroup',
+ visible: false,
+ clip_to_allocation: true,
+ layout_manager: new Clutter.BinLayout(),
+ });
+ this.addChrome(this.screenShieldGroup);
+
+ this.panelBox = new St.BoxLayout({ name: 'panelBox',
+ vertical: true });
+ this.addChrome(this.panelBox, { affectsStruts: true,
+ trackFullscreen: true });
+ this.panelBox.connect('notify::allocation',
+ this._panelBoxChanged.bind(this));
+
+ this.modalDialogGroup = new St.Widget({ name: 'modalDialogGroup',
+ layout_manager: new Clutter.BinLayout() });
+ this.uiGroup.add_actor(this.modalDialogGroup);
+
+ this.keyboardBox = new St.BoxLayout({ name: 'keyboardBox',
+ reactive: true,
+ track_hover: true });
+ this.addTopChrome(this.keyboardBox);
+ this._keyboardHeightNotifyId = 0;
+
+ // A dummy actor that tracks the mouse or text cursor, based on the
+ // position and size set in setDummyCursorGeometry.
+ this.dummyCursor = new St.Widget({ width: 0, height: 0, opacity: 0 });
+ this.uiGroup.add_actor(this.dummyCursor);
+
+ let feedbackGroup = Meta.get_feedback_group_for_display(global.display);
+ global.stage.remove_actor(feedbackGroup);
+ this.uiGroup.add_actor(feedbackGroup);
+
+ this._backgroundGroup = new Meta.BackgroundGroup();
+ global.window_group.add_child(this._backgroundGroup);
+ global.window_group.set_child_below_sibling(this._backgroundGroup, null);
+ this._bgManagers = [];
+
+ this._interfaceSettings = new Gio.Settings({
+ schema_id: 'org.gnome.desktop.interface',
+ });
+
+ this._interfaceSettings.connect('changed::enable-hot-corners',
+ this._updateHotCorners.bind(this));
+
+ // Need to update struts on new workspaces when they are added
+ let workspaceManager = global.workspace_manager;
+ workspaceManager.connect('notify::n-workspaces',
+ this._queueUpdateRegions.bind(this));
+
+ let display = global.display;
+ display.connect('restacked',
+ this._windowsRestacked.bind(this));
+ display.connect('in-fullscreen-changed',
+ this._updateFullscreen.bind(this));
+
+ let monitorManager = Meta.MonitorManager.get();
+ monitorManager.connect('monitors-changed',
+ this._monitorsChanged.bind(this));
+ this._monitorsChanged();
+ }
+
+ // This is called by Main after everything else is constructed
+ init() {
+ Main.sessionMode.connect('updated', this._sessionUpdated.bind(this));
+
+ this._loadBackground();
+ }
+
+ showOverview() {
+ this.overviewGroup.show();
+
+ this._inOverview = true;
+ this._updateVisibility();
+ }
+
+ hideOverview() {
+ this.overviewGroup.hide();
+
+ this._inOverview = false;
+ this._updateVisibility();
+ }
+
+ _sessionUpdated() {
+ this._updateVisibility();
+ this._queueUpdateRegions();
+ }
+
+ _updateMonitors() {
+ let display = global.display;
+
+ this.monitors = [];
+ let nMonitors = display.get_n_monitors();
+ for (let i = 0; i < nMonitors; i++) {
+ this.monitors.push(new Monitor(i,
+ display.get_monitor_geometry(i),
+ display.get_monitor_scale(i)));
+ }
+
+ if (nMonitors == 0) {
+ this.primaryIndex = this.bottomIndex = -1;
+ } else if (nMonitors == 1) {
+ this.primaryIndex = this.bottomIndex = 0;
+ } else {
+ // If there are monitors below the primary, then we need
+ // to split primary from bottom.
+ this.primaryIndex = this.bottomIndex = display.get_primary_monitor();
+ for (let i = 0; i < this.monitors.length; i++) {
+ let monitor = this.monitors[i];
+ if (this._isAboveOrBelowPrimary(monitor)) {
+ if (monitor.y > this.monitors[this.bottomIndex].y)
+ this.bottomIndex = i;
+ }
+ }
+ }
+ if (this.primaryIndex != -1) {
+ this.primaryMonitor = this.monitors[this.primaryIndex];
+ this.bottomMonitor = this.monitors[this.bottomIndex];
+
+ if (this._pendingLoadBackground) {
+ this._loadBackground();
+ this._pendingLoadBackground = false;
+ }
+ } else {
+ this.primaryMonitor = null;
+ this.bottomMonitor = null;
+ }
+ }
+
+ _updateHotCorners() {
+ // destroy old hot corners
+ this.hotCorners.forEach(corner => {
+ if (corner)
+ corner.destroy();
+ });
+ this.hotCorners = [];
+
+ if (!this._interfaceSettings.get_boolean('enable-hot-corners')) {
+ this.emit('hot-corners-changed');
+ return;
+ }
+
+ let size = this.panelBox.height;
+
+ // build new hot corners
+ for (let i = 0; i < this.monitors.length; i++) {
+ let monitor = this.monitors[i];
+ let cornerX = this._rtl ? monitor.x + monitor.width : monitor.x;
+ let cornerY = monitor.y;
+
+ let haveTopLeftCorner = true;
+
+ if (i != this.primaryIndex) {
+ // Check if we have a top left (right for RTL) corner.
+ // I.e. if there is no monitor directly above or to the left(right)
+ let besideX = this._rtl ? monitor.x + 1 : cornerX - 1;
+ let besideY = cornerY;
+ let aboveX = cornerX;
+ let aboveY = cornerY - 1;
+
+ for (let j = 0; j < this.monitors.length; j++) {
+ if (i == j)
+ continue;
+ let otherMonitor = this.monitors[j];
+ if (besideX >= otherMonitor.x &&
+ besideX < otherMonitor.x + otherMonitor.width &&
+ besideY >= otherMonitor.y &&
+ besideY < otherMonitor.y + otherMonitor.height) {
+ haveTopLeftCorner = false;
+ break;
+ }
+ if (aboveX >= otherMonitor.x &&
+ aboveX < otherMonitor.x + otherMonitor.width &&
+ aboveY >= otherMonitor.y &&
+ aboveY < otherMonitor.y + otherMonitor.height) {
+ haveTopLeftCorner = false;
+ break;
+ }
+ }
+ }
+
+ if (haveTopLeftCorner) {
+ let corner = new HotCorner(this, monitor, cornerX, cornerY);
+ corner.setBarrierSize(size);
+ this.hotCorners.push(corner);
+ } else {
+ this.hotCorners.push(null);
+ }
+ }
+
+ this.emit('hot-corners-changed');
+ }
+
+ _addBackgroundMenu(bgManager) {
+ BackgroundMenu.addBackgroundMenu(bgManager.backgroundActor, this);
+ }
+
+ _createBackgroundManager(monitorIndex) {
+ let bgManager = new Background.BackgroundManager({ container: this._backgroundGroup,
+ layoutManager: this,
+ monitorIndex });
+
+ bgManager.connect('changed', this._addBackgroundMenu.bind(this));
+ this._addBackgroundMenu(bgManager);
+
+ return bgManager;
+ }
+
+ _showSecondaryBackgrounds() {
+ for (let i = 0; i < this.monitors.length; i++) {
+ if (i != this.primaryIndex) {
+ let backgroundActor = this._bgManagers[i].backgroundActor;
+ backgroundActor.show();
+ backgroundActor.opacity = 0;
+ backgroundActor.ease({
+ opacity: 255,
+ duration: BACKGROUND_FADE_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+ }
+ }
+
+ _waitLoaded(bgManager) {
+ return new Promise(resolve => {
+ const id = bgManager.connect('loaded', () => {
+ bgManager.disconnect(id);
+ resolve();
+ });
+ });
+ }
+
+ _updateBackgrounds() {
+ for (let i = 0; i < this._bgManagers.length; i++)
+ this._bgManagers[i].destroy();
+
+ this._bgManagers = [];
+
+ if (Main.sessionMode.isGreeter)
+ return Promise.resolve();
+
+ for (let i = 0; i < this.monitors.length; i++) {
+ let bgManager = this._createBackgroundManager(i);
+ this._bgManagers.push(bgManager);
+
+ if (i != this.primaryIndex && this._startingUp)
+ bgManager.backgroundActor.hide();
+ }
+
+ return Promise.all(this._bgManagers.map(this._waitLoaded));
+ }
+
+ _updateKeyboardBox() {
+ this.keyboardBox.set_position(this.keyboardMonitor.x,
+ this.keyboardMonitor.y + this.keyboardMonitor.height);
+ this.keyboardBox.set_size(this.keyboardMonitor.width, -1);
+ }
+
+ _updateBoxes() {
+ this.screenShieldGroup.set_position(0, 0);
+ this.screenShieldGroup.set_size(global.screen_width, global.screen_height);
+
+ if (!this.primaryMonitor)
+ return;
+
+ this.panelBox.set_position(this.primaryMonitor.x, this.primaryMonitor.y);
+ this.panelBox.set_size(this.primaryMonitor.width, -1);
+
+ this.keyboardIndex = this.primaryIndex;
+ }
+
+ _panelBoxChanged() {
+ this._updatePanelBarrier();
+
+ let size = this.panelBox.height;
+ this.hotCorners.forEach(corner => {
+ if (corner)
+ corner.setBarrierSize(size);
+ });
+ }
+
+ _updatePanelBarrier() {
+ if (this._rightPanelBarrier) {
+ this._rightPanelBarrier.destroy();
+ this._rightPanelBarrier = null;
+ }
+
+ if (!this.primaryMonitor)
+ return;
+
+ if (this.panelBox.height) {
+ let primary = this.primaryMonitor;
+
+ this._rightPanelBarrier = new Meta.Barrier({ display: global.display,
+ x1: primary.x + primary.width, y1: primary.y,
+ x2: primary.x + primary.width, y2: primary.y + this.panelBox.height,
+ directions: Meta.BarrierDirection.NEGATIVE_X });
+ }
+ }
+
+ _monitorsChanged() {
+ this._updateMonitors();
+ this._updateBoxes();
+ this._updateHotCorners();
+ this._updateBackgrounds();
+ this._updateFullscreen();
+ this._updateVisibility();
+ this._queueUpdateRegions();
+
+ this.emit('monitors-changed');
+ }
+
+ _isAboveOrBelowPrimary(monitor) {
+ let primary = this.monitors[this.primaryIndex];
+ let monitorLeft = monitor.x, monitorRight = monitor.x + monitor.width;
+ let primaryLeft = primary.x, primaryRight = primary.x + primary.width;
+
+ if ((monitorLeft >= primaryLeft && monitorLeft < primaryRight) ||
+ (monitorRight > primaryLeft && monitorRight <= primaryRight) ||
+ (primaryLeft >= monitorLeft && primaryLeft < monitorRight) ||
+ (primaryRight > monitorLeft && primaryRight <= monitorRight))
+ return true;
+
+ return false;
+ }
+
+ get currentMonitor() {
+ let index = global.display.get_current_monitor();
+ return this.monitors[index];
+ }
+
+ get keyboardMonitor() {
+ return this.monitors[this.keyboardIndex];
+ }
+
+ get focusIndex() {
+ let i = Main.layoutManager.primaryIndex;
+
+ if (global.stage.key_focus != null)
+ i = this.findIndexForActor(global.stage.key_focus);
+ else if (global.display.focus_window != null)
+ i = global.display.focus_window.get_monitor();
+ return i;
+ }
+
+ get focusMonitor() {
+ if (this.focusIndex < 0)
+ return null;
+ return this.monitors[this.focusIndex];
+ }
+
+ set keyboardIndex(v) {
+ this._keyboardIndex = v;
+ this._updateKeyboardBox();
+ }
+
+ get keyboardIndex() {
+ return this._keyboardIndex;
+ }
+
+ _loadBackground() {
+ if (!this.primaryMonitor) {
+ this._pendingLoadBackground = true;
+ return;
+ }
+ this._systemBackground = new Background.SystemBackground();
+ this._systemBackground.hide();
+
+ global.stage.insert_child_below(this._systemBackground, null);
+
+ let constraint = new Clutter.BindConstraint({ source: global.stage,
+ coordinate: Clutter.BindCoordinate.ALL });
+ this._systemBackground.add_constraint(constraint);
+
+ let signalId = this._systemBackground.connect('loaded', () => {
+ this._systemBackground.disconnect(signalId);
+
+ // We're mostly prepared for the startup animation
+ // now, but since a lot is going on asynchronously
+ // during startup, let's defer the startup animation
+ // until the event loop is uncontended and idle.
+ // This helps to prevent us from running the animation
+ // when the system is bogged down
+ const id = GLib.idle_add(GLib.PRIORITY_LOW, () => {
+ this._systemBackground.show();
+ global.stage.show();
+ this._prepareStartupAnimation();
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(id, '[gnome-shell] Startup Animation');
+ });
+ }
+
+ // Startup Animations
+ //
+ // We have two different animations, depending on whether we're a greeter
+ // or a normal session.
+ //
+ // In the greeter, we want to animate the panel from the top, and smoothly
+ // fade the login dialog on top of whatever plymouth left on screen which
+ // we get as a still frame background before drawing anything else.
+ //
+ // Here we just have the code to animate the panel, and fade up the background.
+ // The login dialog animation is handled by modalDialog.js
+ //
+ // When starting a normal user session, we want to grow it out of the middle
+ // of the screen.
+
+ async _prepareStartupAnimation() {
+ // During the initial transition, add a simple actor to block all events,
+ // so they don't get delivered to X11 windows that have been transformed.
+ this._coverPane = new Clutter.Actor({ opacity: 0,
+ width: global.screen_width,
+ height: global.screen_height,
+ reactive: true });
+ this.addChrome(this._coverPane);
+
+ if (Meta.is_restart()) {
+ // On restart, we don't do an animation. Force an update of the
+ // regions immediately so that maximized windows restore to the
+ // right size taking struts into account.
+ this._updateRegions();
+ } else if (Main.sessionMode.isGreeter) {
+ this.panelBox.translation_y = -this.panelBox.height;
+ } else {
+ // We need to force an update of the regions now before we scale
+ // the UI group to get the correct allocation for the struts.
+ this._updateRegions();
+
+ this.keyboardBox.hide();
+
+ let monitor = this.primaryMonitor;
+ let x = monitor.x + monitor.width / 2.0;
+ let y = monitor.y + monitor.height / 2.0;
+
+ this.uiGroup.set_pivot_point(x / global.screen_width,
+ y / global.screen_height);
+ this.uiGroup.scale_x = this.uiGroup.scale_y = 0.75;
+ this.uiGroup.opacity = 0;
+ global.window_group.set_clip(monitor.x, monitor.y, monitor.width, monitor.height);
+
+ await this._updateBackgrounds();
+ }
+
+ this.emit('startup-prepared');
+
+ this._startupAnimation();
+ }
+
+ _startupAnimation() {
+ if (Meta.is_restart())
+ this._startupAnimationComplete();
+ else if (Main.sessionMode.isGreeter)
+ this._startupAnimationGreeter();
+ else
+ this._startupAnimationSession();
+ }
+
+ _startupAnimationGreeter() {
+ this.panelBox.ease({
+ translation_y: 0,
+ duration: STARTUP_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this._startupAnimationComplete(),
+ });
+ }
+
+ _startupAnimationSession() {
+ this.uiGroup.ease({
+ scale_x: 1,
+ scale_y: 1,
+ opacity: 255,
+ duration: STARTUP_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this._startupAnimationComplete(),
+ });
+ }
+
+ _startupAnimationComplete() {
+ this._coverPane.destroy();
+ this._coverPane = null;
+
+ this._systemBackground.destroy();
+ this._systemBackground = null;
+
+ this._startingUp = false;
+
+ this.keyboardBox.show();
+
+ if (!Main.sessionMode.isGreeter) {
+ this._showSecondaryBackgrounds();
+ global.window_group.remove_clip();
+ }
+
+ this._queueUpdateRegions();
+
+ this.emit('startup-complete');
+ }
+
+ showKeyboard() {
+ this.keyboardBox.show();
+ this.keyboardBox.ease({
+ translation_y: -this.keyboardBox.height,
+ opacity: 255,
+ duration: KEYBOARD_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ this._showKeyboardComplete();
+ },
+ });
+ this.emit('keyboard-visible-changed', true);
+ }
+
+ _showKeyboardComplete() {
+ // Poke Chrome to update the input shape; it doesn't notice
+ // anchor point changes
+ this._updateRegions();
+
+ this._keyboardHeightNotifyId = this.keyboardBox.connect('notify::height', () => {
+ this.keyboardBox.translation_y = -this.keyboardBox.height;
+ });
+ }
+
+ hideKeyboard(immediate) {
+ if (this._keyboardHeightNotifyId) {
+ this.keyboardBox.disconnect(this._keyboardHeightNotifyId);
+ this._keyboardHeightNotifyId = 0;
+ }
+ this.keyboardBox.ease({
+ translation_y: 0,
+ opacity: 0,
+ duration: immediate ? 0 : KEYBOARD_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_IN_QUAD,
+ onComplete: () => {
+ this._hideKeyboardComplete();
+ },
+ });
+
+ this.emit('keyboard-visible-changed', false);
+ }
+
+ _hideKeyboardComplete() {
+ this.keyboardBox.hide();
+ this._updateRegions();
+ }
+
+ // setDummyCursorGeometry:
+ //
+ // The cursor dummy is a standard widget commonly used for popup
+ // menus and box pointers to track, as the box pointer API only
+ // tracks actors. If you want to pop up a menu based on where the
+ // user clicked, or where the text cursor is, the cursor dummy
+ // is what you should use. Given that the menu should not track
+ // the actual mouse pointer as it moves, you need to call this
+ // function before you show the menu to ensure it is at the right
+ // position and has the right size.
+ setDummyCursorGeometry(x, y, w, h) {
+ this.dummyCursor.set_position(Math.round(x), Math.round(y));
+ this.dummyCursor.set_size(Math.round(w), Math.round(h));
+ }
+
+ // addChrome:
+ // @actor: an actor to add to the chrome
+ // @params: (optional) additional params
+ //
+ // Adds @actor to the chrome, and (unless %affectsInputRegion in
+ // @params is %false) extends the input region to include it.
+ // Changes in @actor's size, position, and visibility will
+ // automatically result in appropriate changes to the input
+ // region.
+ //
+ // If %affectsStruts in @params is %true (and @actor is along a
+ // screen edge), then @actor's size and position will also affect
+ // the window manager struts. Changes to @actor's visibility will
+ // NOT affect whether or not the strut is present, however.
+ //
+ // If %trackFullscreen in @params is %true, the actor's visibility
+ // will be bound to the presence of fullscreen windows on the same
+ // monitor (it will be hidden whenever a fullscreen window is visible,
+ // and shown otherwise)
+ addChrome(actor, params) {
+ this.uiGroup.add_actor(actor);
+ if (this.uiGroup.contains(global.top_window_group))
+ this.uiGroup.set_child_below_sibling(actor, global.top_window_group);
+ this._trackActor(actor, params);
+ }
+
+ // addTopChrome:
+ // @actor: an actor to add to the chrome
+ // @params: (optional) additional params
+ //
+ // Like addChrome(), but adds @actor above all windows, including popups.
+ addTopChrome(actor, params) {
+ this.uiGroup.add_actor(actor);
+ this._trackActor(actor, params);
+ }
+
+ // trackChrome:
+ // @actor: a descendant of the chrome to begin tracking
+ // @params: parameters describing how to track @actor
+ //
+ // Tells the chrome to track @actor. This can be used to extend the
+ // struts or input region to cover specific children.
+ //
+ // @params can have any of the same values as in addChrome(),
+ // though some possibilities don't make sense. By default, @actor has
+ // the same params as its chrome ancestor.
+ trackChrome(actor, params = {}) {
+ let ancestor = actor.get_parent();
+ let index = this._findActor(ancestor);
+ while (ancestor && index == -1) {
+ ancestor = ancestor.get_parent();
+ index = this._findActor(ancestor);
+ }
+
+ let ancestorData = ancestor
+ ? this._trackedActors[index]
+ : defaultParams;
+ // We can't use Params.parse here because we want to drop
+ // the extra values like ancestorData.actor
+ for (let prop in defaultParams) {
+ if (!Object.prototype.hasOwnProperty.call(params, prop))
+ params[prop] = ancestorData[prop];
+ }
+
+ this._trackActor(actor, params);
+ }
+
+ // untrackChrome:
+ // @actor: an actor previously tracked via trackChrome()
+ //
+ // Undoes the effect of trackChrome()
+ untrackChrome(actor) {
+ this._untrackActor(actor);
+ }
+
+ // removeChrome:
+ // @actor: a chrome actor
+ //
+ // Removes @actor from the chrome
+ removeChrome(actor) {
+ this.uiGroup.remove_actor(actor);
+ this._untrackActor(actor);
+ }
+
+ _findActor(actor) {
+ for (let i = 0; i < this._trackedActors.length; i++) {
+ let actorData = this._trackedActors[i];
+ if (actorData.actor == actor)
+ return i;
+ }
+ return -1;
+ }
+
+ _trackActor(actor, params) {
+ if (this._findActor(actor) != -1)
+ throw new Error('trying to re-track existing chrome actor');
+
+ let actorData = Params.parse(params, defaultParams);
+ actorData.actor = actor;
+ actorData.visibleId = actor.connect('notify::visible',
+ this._queueUpdateRegions.bind(this));
+ actorData.allocationId = actor.connect('notify::allocation',
+ this._queueUpdateRegions.bind(this));
+ actorData.destroyId = actor.connect('destroy',
+ this._untrackActor.bind(this));
+ // Note that destroying actor will unset its parent, so we don't
+ // need to connect to 'destroy' too.
+
+ this._trackedActors.push(actorData);
+ this._updateActorVisibility(actorData);
+ this._queueUpdateRegions();
+ }
+
+ _untrackActor(actor) {
+ let i = this._findActor(actor);
+
+ if (i == -1)
+ return;
+ let actorData = this._trackedActors[i];
+
+ this._trackedActors.splice(i, 1);
+ actor.disconnect(actorData.visibleId);
+ actor.disconnect(actorData.allocationId);
+ actor.disconnect(actorData.destroyId);
+
+ this._queueUpdateRegions();
+ }
+
+ _updateActorVisibility(actorData) {
+ if (!actorData.trackFullscreen)
+ return;
+
+ let monitor = this.findMonitorForActor(actorData.actor);
+ actorData.actor.visible = !(global.window_group.visible &&
+ monitor &&
+ monitor.inFullscreen);
+ }
+
+ _updateVisibility() {
+ let windowsVisible = Main.sessionMode.hasWindows && !this._inOverview;
+
+ global.window_group.visible = windowsVisible;
+ global.top_window_group.visible = windowsVisible;
+
+ this._trackedActors.forEach(this._updateActorVisibility.bind(this));
+ }
+
+ getWorkAreaForMonitor(monitorIndex) {
+ // Assume that all workspaces will have the same
+ // struts and pick the first one.
+ let workspaceManager = global.workspace_manager;
+ let ws = workspaceManager.get_workspace_by_index(0);
+ return ws.get_work_area_for_monitor(monitorIndex);
+ }
+
+ // This call guarantees that we return some monitor to simplify usage of it
+ // In practice all tracked actors should be visible on some monitor anyway
+ findIndexForActor(actor) {
+ let [x, y] = actor.get_transformed_position();
+ let [w, h] = actor.get_transformed_size();
+ let rect = new Meta.Rectangle({ x, y, width: w, height: h });
+ return global.display.get_monitor_index_for_rect(rect);
+ }
+
+ findMonitorForActor(actor) {
+ let index = this.findIndexForActor(actor);
+ if (index >= 0 && index < this.monitors.length)
+ return this.monitors[index];
+ return null;
+ }
+
+ _queueUpdateRegions() {
+ if (this._startingUp)
+ return;
+
+ if (!this._updateRegionIdle) {
+ this._updateRegionIdle = Meta.later_add(Meta.LaterType.BEFORE_REDRAW,
+ this._updateRegions.bind(this));
+ }
+ }
+
+ _getWindowActorsForWorkspace(workspace) {
+ return global.get_window_actors().filter(actor => {
+ let win = actor.meta_window;
+ return win.located_on_workspace(workspace);
+ });
+ }
+
+ _updateFullscreen() {
+ this._updateVisibility();
+ this._queueUpdateRegions();
+ }
+
+ _windowsRestacked() {
+ let changed = false;
+
+ if (this._isPopupWindowVisible != global.top_window_group.get_children().some(isPopupMetaWindow))
+ changed = true;
+
+ if (changed) {
+ this._updateVisibility();
+ this._queueUpdateRegions();
+ }
+ }
+
+ _updateRegions() {
+ if (this._updateRegionIdle) {
+ Meta.later_remove(this._updateRegionIdle);
+ delete this._updateRegionIdle;
+ }
+
+ // No need to update when we have a modal.
+ if (Main.modalCount > 0)
+ return GLib.SOURCE_REMOVE;
+
+ let rects = [], struts = [], i;
+ let isPopupMenuVisible = global.top_window_group.get_children().some(isPopupMetaWindow);
+ let wantsInputRegion = !isPopupMenuVisible;
+
+ for (i = 0; i < this._trackedActors.length; i++) {
+ let actorData = this._trackedActors[i];
+ if (!(actorData.affectsInputRegion && wantsInputRegion) && !actorData.affectsStruts)
+ continue;
+
+ let [x, y] = actorData.actor.get_transformed_position();
+ let [w, h] = actorData.actor.get_transformed_size();
+ x = Math.round(x);
+ y = Math.round(y);
+ w = Math.round(w);
+ h = Math.round(h);
+
+ if (actorData.affectsInputRegion && wantsInputRegion && actorData.actor.get_paint_visibility())
+ rects.push(new Meta.Rectangle({ x, y, width: w, height: h }));
+
+ let monitor = null;
+ if (actorData.affectsStruts)
+ monitor = this.findMonitorForActor(actorData.actor);
+
+ if (monitor) {
+ // Limit struts to the size of the screen
+ let x1 = Math.max(x, 0);
+ let x2 = Math.min(x + w, global.screen_width);
+ let y1 = Math.max(y, 0);
+ let y2 = Math.min(y + h, global.screen_height);
+
+ // Metacity wants to know what side of the monitor the
+ // strut is considered to be attached to. First, we find
+ // the monitor that contains the strut. If the actor is
+ // only touching one edge, or is touching the entire
+ // border of that monitor, then it's obvious which side
+ // to call it. If it's in a corner, we pick a side
+ // arbitrarily. If it doesn't touch any edges, or it
+ // spans the width/height across the middle of the
+ // screen, then we don't create a strut for it at all.
+
+ let side;
+ if (x1 <= monitor.x && x2 >= monitor.x + monitor.width) {
+ if (y1 <= monitor.y)
+ side = Meta.Side.TOP;
+ else if (y2 >= monitor.y + monitor.height)
+ side = Meta.Side.BOTTOM;
+ else
+ continue;
+ } else if (y1 <= monitor.y && y2 >= monitor.y + monitor.height) {
+ if (x1 <= monitor.x)
+ side = Meta.Side.LEFT;
+ else if (x2 >= monitor.x + monitor.width)
+ side = Meta.Side.RIGHT;
+ else
+ continue;
+ } else if (x1 <= monitor.x) {
+ side = Meta.Side.LEFT;
+ } else if (y1 <= monitor.y) {
+ side = Meta.Side.TOP;
+ } else if (x2 >= monitor.x + monitor.width) {
+ side = Meta.Side.RIGHT;
+ } else if (y2 >= monitor.y + monitor.height) {
+ side = Meta.Side.BOTTOM;
+ } else {
+ continue;
+ }
+
+ let strutRect = new Meta.Rectangle({ x: x1, y: y1, width: x2 - x1, height: y2 - y1 });
+ let strut = new Meta.Strut({ rect: strutRect, side });
+ struts.push(strut);
+ }
+ }
+
+ global.set_stage_input_region(rects);
+ this._isPopupWindowVisible = isPopupMenuVisible;
+
+ let workspaceManager = global.workspace_manager;
+ for (let w = 0; w < workspaceManager.n_workspaces; w++) {
+ let workspace = workspaceManager.get_workspace_by_index(w);
+ workspace.set_builtin_struts(struts);
+ }
+
+ return GLib.SOURCE_REMOVE;
+ }
+
+ modalEnded() {
+ // We don't update the stage input region while in a modal,
+ // so queue an update now.
+ this._queueUpdateRegions();
+ }
+});
+
+
+// HotCorner:
+//
+// This class manages a "hot corner" that can toggle switching to
+// overview.
+var HotCorner = GObject.registerClass(
+class HotCorner extends Clutter.Actor {
+ _init(layoutManager, monitor, x, y) {
+ super._init();
+
+ // We use this flag to mark the case where the user has entered the
+ // hot corner and has not left both the hot corner and a surrounding
+ // guard area (the "environs"). This avoids triggering the hot corner
+ // multiple times due to an accidental jitter.
+ this._entered = false;
+
+ this._monitor = monitor;
+
+ this._x = x;
+ this._y = y;
+
+ this._setupFallbackCornerIfNeeded(layoutManager);
+
+ this._pressureBarrier = new PressureBarrier(HOT_CORNER_PRESSURE_THRESHOLD,
+ HOT_CORNER_PRESSURE_TIMEOUT,
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW);
+ this._pressureBarrier.connect('trigger', this._toggleOverview.bind(this));
+
+ let px = 0.0;
+ let py = 0.0;
+ if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) {
+ px = 1.0;
+ py = 0.0;
+ }
+
+ this._ripples = new Ripples.Ripples(px, py, 'ripple-box');
+ this._ripples.addTo(layoutManager.uiGroup);
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ setBarrierSize(size) {
+ if (this._verticalBarrier) {
+ this._pressureBarrier.removeBarrier(this._verticalBarrier);
+ this._verticalBarrier.destroy();
+ this._verticalBarrier = null;
+ }
+
+ if (this._horizontalBarrier) {
+ this._pressureBarrier.removeBarrier(this._horizontalBarrier);
+ this._horizontalBarrier.destroy();
+ this._horizontalBarrier = null;
+ }
+
+ if (size > 0) {
+ if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) {
+ this._verticalBarrier = new Meta.Barrier({ display: global.display,
+ x1: this._x, x2: this._x, y1: this._y, y2: this._y + size,
+ directions: Meta.BarrierDirection.NEGATIVE_X });
+ this._horizontalBarrier = new Meta.Barrier({ display: global.display,
+ x1: this._x - size, x2: this._x, y1: this._y, y2: this._y,
+ directions: Meta.BarrierDirection.POSITIVE_Y });
+ } else {
+ this._verticalBarrier = new Meta.Barrier({ display: global.display,
+ x1: this._x, x2: this._x, y1: this._y, y2: this._y + size,
+ directions: Meta.BarrierDirection.POSITIVE_X });
+ this._horizontalBarrier = new Meta.Barrier({ display: global.display,
+ x1: this._x, x2: this._x + size, y1: this._y, y2: this._y,
+ directions: Meta.BarrierDirection.POSITIVE_Y });
+ }
+
+ this._pressureBarrier.addBarrier(this._verticalBarrier);
+ this._pressureBarrier.addBarrier(this._horizontalBarrier);
+ }
+ }
+
+ _setupFallbackCornerIfNeeded(layoutManager) {
+ if (!global.display.supports_extended_barriers()) {
+ this.set({
+ name: 'hot-corner-environs',
+ x: this._x,
+ y: this._y,
+ width: 3,
+ height: 3,
+ reactive: true,
+ });
+
+ this._corner = new Clutter.Actor({ name: 'hot-corner',
+ width: 1,
+ height: 1,
+ opacity: 0,
+ reactive: true });
+ this._corner._delegate = this;
+
+ this.add_child(this._corner);
+ layoutManager.addChrome(this);
+
+ if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) {
+ this._corner.set_position(this.width - this._corner.width, 0);
+ this.set_pivot_point(1.0, 0.0);
+ this.translation_x = -this.width;
+ } else {
+ this._corner.set_position(0, 0);
+ }
+
+ this._corner.connect('enter-event',
+ this._onCornerEntered.bind(this));
+ this._corner.connect('leave-event',
+ this._onCornerLeft.bind(this));
+ }
+ }
+
+ _onDestroy() {
+ this.setBarrierSize(0);
+ this._pressureBarrier.destroy();
+ this._pressureBarrier = null;
+
+ this._ripples.destroy();
+ }
+
+ _toggleOverview() {
+ if (this._monitor.inFullscreen && !Main.overview.visible)
+ return;
+
+ if (Main.overview.shouldToggleByCornerOrButton()) {
+ Main.overview.toggle();
+ if (Main.overview.animationInProgress)
+ this._ripples.playAnimation(this._x, this._y);
+ }
+ }
+
+ handleDragOver(source, _actor, _x, _y, _time) {
+ if (source != Main.xdndHandler)
+ return DND.DragMotionResult.CONTINUE;
+
+ this._toggleOverview();
+
+ return DND.DragMotionResult.CONTINUE;
+ }
+
+ _onCornerEntered() {
+ if (!this._entered) {
+ this._entered = true;
+ this._toggleOverview();
+ }
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _onCornerLeft(actor, event) {
+ if (event.get_related() != this)
+ this._entered = false;
+ // Consume event, otherwise this will confuse onEnvironsLeft
+ return Clutter.EVENT_STOP;
+ }
+
+ vfunc_leave_event(crossingEvent) {
+ if (crossingEvent.related != this._corner)
+ this._entered = false;
+ return Clutter.EVENT_PROPAGATE;
+ }
+});
+
+var PressureBarrier = class PressureBarrier {
+ constructor(threshold, timeout, actionMode) {
+ this._threshold = threshold;
+ this._timeout = timeout;
+ this._actionMode = actionMode;
+ this._barriers = [];
+ this._eventFilter = null;
+
+ this._isTriggered = false;
+ this._reset();
+ }
+
+ addBarrier(barrier) {
+ barrier._pressureHitId = barrier.connect('hit', this._onBarrierHit.bind(this));
+ barrier._pressureLeftId = barrier.connect('left', this._onBarrierLeft.bind(this));
+
+ this._barriers.push(barrier);
+ }
+
+ _disconnectBarrier(barrier) {
+ barrier.disconnect(barrier._pressureHitId);
+ barrier.disconnect(barrier._pressureLeftId);
+ }
+
+ removeBarrier(barrier) {
+ this._disconnectBarrier(barrier);
+ this._barriers.splice(this._barriers.indexOf(barrier), 1);
+ }
+
+ destroy() {
+ this._barriers.forEach(this._disconnectBarrier.bind(this));
+ this._barriers = [];
+ }
+
+ setEventFilter(filter) {
+ this._eventFilter = filter;
+ }
+
+ _reset() {
+ this._barrierEvents = [];
+ this._currentPressure = 0;
+ this._lastTime = 0;
+ }
+
+ _isHorizontal(barrier) {
+ return barrier.y1 == barrier.y2;
+ }
+
+ _getDistanceAcrossBarrier(barrier, event) {
+ if (this._isHorizontal(barrier))
+ return Math.abs(event.dy);
+ else
+ return Math.abs(event.dx);
+ }
+
+ _getDistanceAlongBarrier(barrier, event) {
+ if (this._isHorizontal(barrier))
+ return Math.abs(event.dx);
+ else
+ return Math.abs(event.dy);
+ }
+
+ _trimBarrierEvents() {
+ // Events are guaranteed to be sorted in time order from
+ // oldest to newest, so just look for the first old event,
+ // and then chop events after that off.
+ let i = 0;
+ let threshold = this._lastTime - this._timeout;
+
+ while (i < this._barrierEvents.length) {
+ let [time, distance_] = this._barrierEvents[i];
+ if (time >= threshold)
+ break;
+ i++;
+ }
+
+ let firstNewEvent = i;
+
+ for (i = 0; i < firstNewEvent; i++) {
+ let [time_, distance] = this._barrierEvents[i];
+ this._currentPressure -= distance;
+ }
+
+ this._barrierEvents = this._barrierEvents.slice(firstNewEvent);
+ }
+
+ _onBarrierLeft(barrier, _event) {
+ barrier._isHit = false;
+ if (this._barriers.every(b => !b._isHit)) {
+ this._reset();
+ this._isTriggered = false;
+ }
+ }
+
+ _trigger() {
+ this._isTriggered = true;
+ this.emit('trigger');
+ this._reset();
+ }
+
+ _onBarrierHit(barrier, event) {
+ barrier._isHit = true;
+
+ // If we've triggered the barrier, wait until the pointer has the
+ // left the barrier hitbox until we trigger it again.
+ if (this._isTriggered)
+ return;
+
+ if (this._eventFilter && this._eventFilter(event))
+ return;
+
+ // Throw out all events not in the proper keybinding mode
+ if (!(this._actionMode & Main.actionMode))
+ return;
+
+ let slide = this._getDistanceAlongBarrier(barrier, event);
+ let distance = this._getDistanceAcrossBarrier(barrier, event);
+
+ if (distance >= this._threshold) {
+ this._trigger();
+ return;
+ }
+
+ // Throw out events where the cursor is move more
+ // along the axis of the barrier than moving with
+ // the barrier.
+ if (slide > distance)
+ return;
+
+ this._lastTime = event.time;
+
+ this._trimBarrierEvents();
+ distance = Math.min(15, distance);
+
+ this._barrierEvents.push([event.time, distance]);
+ this._currentPressure += distance;
+
+ if (this._currentPressure >= this._threshold)
+ this._trigger();
+ }
+};
+Signals.addSignalMethods(PressureBarrier.prototype);
diff --git a/js/ui/lightbox.js b/js/ui/lightbox.js
new file mode 100644
index 0000000..9aaef1a
--- /dev/null
+++ b/js/ui/lightbox.js
@@ -0,0 +1,293 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Lightbox */
+
+const { Clutter, GObject, Shell, St } = imports.gi;
+
+const Params = imports.misc.params;
+
+var DEFAULT_FADE_FACTOR = 0.4;
+var VIGNETTE_BRIGHTNESS = 0.5;
+var VIGNETTE_SHARPNESS = 0.7;
+
+const VIGNETTE_DECLARATIONS = '\
+uniform float brightness;\n\
+uniform float vignette_sharpness;\n';
+
+const VIGNETTE_CODE = '\
+cogl_color_out.a = cogl_color_in.a;\n\
+cogl_color_out.rgb = vec3(0.0, 0.0, 0.0);\n\
+vec2 position = cogl_tex_coord_in[0].xy - 0.5;\n\
+float t = length(2.0 * position);\n\
+t = clamp(t, 0.0, 1.0);\n\
+float pixel_brightness = mix(1.0, 1.0 - vignette_sharpness, t);\n\
+cogl_color_out.a = cogl_color_out.a * (1 - pixel_brightness * brightness);';
+
+var RadialShaderEffect = GObject.registerClass({
+ Properties: {
+ 'brightness': GObject.ParamSpec.float(
+ 'brightness', 'brightness', 'brightness',
+ GObject.ParamFlags.READWRITE,
+ 0, 1, 1),
+ 'sharpness': GObject.ParamSpec.float(
+ 'sharpness', 'sharpness', 'sharpness',
+ GObject.ParamFlags.READWRITE,
+ 0, 1, 0),
+ },
+}, class RadialShaderEffect extends Shell.GLSLEffect {
+ _init(params) {
+ this._brightness = undefined;
+ this._sharpness = undefined;
+
+ super._init(params);
+
+ this._brightnessLocation = this.get_uniform_location('brightness');
+ this._sharpnessLocation = this.get_uniform_location('vignette_sharpness');
+
+ this.brightness = 1.0;
+ this.sharpness = 0.0;
+ }
+
+ vfunc_build_pipeline() {
+ this.add_glsl_snippet(Shell.SnippetHook.FRAGMENT,
+ VIGNETTE_DECLARATIONS, VIGNETTE_CODE, true);
+ }
+
+ get brightness() {
+ return this._brightness;
+ }
+
+ set brightness(v) {
+ if (this._brightness == v)
+ return;
+ this._brightness = v;
+ this.set_uniform_float(this._brightnessLocation,
+ 1, [this._brightness]);
+ this.notify('brightness');
+ }
+
+ get sharpness() {
+ return this._sharpness;
+ }
+
+ set sharpness(v) {
+ if (this._sharpness == v)
+ return;
+ this._sharpness = v;
+ this.set_uniform_float(this._sharpnessLocation,
+ 1, [this._sharpness]);
+ this.notify('sharpness');
+ }
+});
+
+/**
+ * Lightbox:
+ * @container: parent Clutter.Container
+ * @params: (optional) additional parameters:
+ * - inhibitEvents: whether to inhibit events for @container
+ * - width: shade actor width
+ * - height: shade actor height
+ * - fadeFactor: fading opacity factor
+ * - radialEffect: whether to enable the GLSL radial effect
+ *
+ * Lightbox creates a dark translucent "shade" actor to hide the
+ * contents of @container, and allows you to specify particular actors
+ * in @container to highlight by bringing them above the shade. It
+ * tracks added and removed actors in @container while the lightboxing
+ * is active, and ensures that all actors are returned to their
+ * original stacking order when the lightboxing is removed. (However,
+ * if actors are restacked by outside code while the lightboxing is
+ * active, the lightbox may later revert them back to their original
+ * order.)
+ *
+ * By default, the shade window will have the height and width of
+ * @container and will track any changes in its size. You can override
+ * this by passing an explicit width and height in @params.
+ */
+var Lightbox = GObject.registerClass({
+ Properties: {
+ 'active': GObject.ParamSpec.boolean(
+ 'active', 'active', 'active', GObject.ParamFlags.READABLE, false),
+ },
+}, class Lightbox extends St.Bin {
+ _init(container, params) {
+ params = Params.parse(params, {
+ inhibitEvents: false,
+ width: null,
+ height: null,
+ fadeFactor: DEFAULT_FADE_FACTOR,
+ radialEffect: false,
+ });
+
+ super._init({
+ reactive: params.inhibitEvents,
+ width: params.width,
+ height: params.height,
+ visible: false,
+ });
+
+ this._active = false;
+ this._container = container;
+ this._children = container.get_children();
+ this._fadeFactor = params.fadeFactor;
+ this._radialEffect = Clutter.feature_available(Clutter.FeatureFlags.SHADERS_GLSL) && params.radialEffect;
+
+ if (this._radialEffect)
+ this.add_effect(new RadialShaderEffect({ name: 'radial' }));
+ else
+ this.set({ opacity: 0, style_class: 'lightbox' });
+
+ container.add_actor(this);
+ container.set_child_above_sibling(this, null);
+
+ this.connect('destroy', this._onDestroy.bind(this));
+
+ if (!params.width || !params.height) {
+ this.add_constraint(new Clutter.BindConstraint({
+ source: container,
+ coordinate: Clutter.BindCoordinate.ALL,
+ }));
+ }
+
+ this._actorAddedSignalId = container.connect('actor-added', this._actorAdded.bind(this));
+ this._actorRemovedSignalId = container.connect('actor-removed', this._actorRemoved.bind(this));
+
+ this._highlighted = null;
+ }
+
+ get active() {
+ return this._active;
+ }
+
+ _actorAdded(container, newChild) {
+ let children = this._container.get_children();
+ let myIndex = children.indexOf(this);
+ let newChildIndex = children.indexOf(newChild);
+
+ if (newChildIndex > myIndex) {
+ // The child was added above the shade (presumably it was
+ // made the new top-most child). Move it below the shade,
+ // and add it to this._children as the new topmost actor.
+ this._container.set_child_above_sibling(this, newChild);
+ this._children.push(newChild);
+ } else if (newChildIndex == 0) {
+ // Bottom of stack
+ this._children.unshift(newChild);
+ } else {
+ // Somewhere else; insert it into the correct spot
+ let prevChild = this._children.indexOf(children[newChildIndex - 1]);
+ if (prevChild != -1) // paranoia
+ this._children.splice(prevChild + 1, 0, newChild);
+ }
+ }
+
+ lightOn(fadeInTime) {
+ this.remove_all_transitions();
+
+ let easeProps = {
+ duration: fadeInTime || 0,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ };
+
+ let onComplete = () => {
+ this._active = true;
+ this.notify('active');
+ };
+
+ this.show();
+
+ if (this._radialEffect) {
+ this.ease_property(
+ '@effects.radial.brightness', VIGNETTE_BRIGHTNESS, easeProps);
+ this.ease_property(
+ '@effects.radial.sharpness', VIGNETTE_SHARPNESS,
+ Object.assign({ onComplete }, easeProps));
+ } else {
+ this.ease(Object.assign(easeProps, {
+ opacity: 255 * this._fadeFactor,
+ onComplete,
+ }));
+ }
+ }
+
+ lightOff(fadeOutTime) {
+ this.remove_all_transitions();
+
+ this._active = false;
+ this.notify('active');
+
+ let easeProps = {
+ duration: fadeOutTime || 0,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ };
+
+ let onComplete = () => this.hide();
+
+ if (this._radialEffect) {
+ this.ease_property(
+ '@effects.radial.brightness', 1.0, easeProps);
+ this.ease_property(
+ '@effects.radial.sharpness', 0.0, Object.assign({ onComplete }, easeProps));
+ } else {
+ this.ease(Object.assign(easeProps, { opacity: 0, onComplete }));
+ }
+ }
+
+ _actorRemoved(container, child) {
+ let index = this._children.indexOf(child);
+ if (index != -1) // paranoia
+ this._children.splice(index, 1);
+
+ if (child == this._highlighted)
+ this._highlighted = null;
+ }
+
+ /**
+ * highlight:
+ * @param {Clutter.Actor=} window: actor to highlight
+ *
+ * Highlights the indicated actor and unhighlights any other
+ * currently-highlighted actor. With no arguments or a false/null
+ * argument, all actors will be unhighlighted.
+ */
+ highlight(window) {
+ if (this._highlighted == window)
+ return;
+
+ // Walk this._children raising and lowering actors as needed.
+ // Things get a little tricky if the to-be-raised and
+ // to-be-lowered actors were originally adjacent, in which
+ // case we may need to indicate some *other* actor as the new
+ // sibling of the to-be-lowered one.
+
+ let below = this;
+ for (let i = this._children.length - 1; i >= 0; i--) {
+ if (this._children[i] == window)
+ this._container.set_child_above_sibling(this._children[i], null);
+ else if (this._children[i] == this._highlighted)
+ this._container.set_child_below_sibling(this._children[i], below);
+ else
+ below = this._children[i];
+ }
+
+ this._highlighted = window;
+ }
+
+ /**
+ * _onDestroy:
+ *
+ * This is called when the lightbox' actor is destroyed, either
+ * by destroying its container or by explicitly calling this.destroy().
+ */
+ _onDestroy() {
+ if (this._actorAddedSignalId) {
+ this._container.disconnect(this._actorAddedSignalId);
+ this._actorAddedSignalId = 0;
+ }
+ if (this._actorRemovedSignalId) {
+ this._container.disconnect(this._actorRemovedSignalId);
+ this._actorRemovedSignalId = 0;
+ }
+
+ this.highlight(null);
+ }
+});
diff --git a/js/ui/locatePointer.js b/js/ui/locatePointer.js
new file mode 100644
index 0000000..6ae2941
--- /dev/null
+++ b/js/ui/locatePointer.js
@@ -0,0 +1,39 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported LocatePointer */
+
+const { Gio } = imports.gi;
+const Ripples = imports.ui.ripples;
+const Main = imports.ui.main;
+
+const LOCATE_POINTER_KEY = "locate-pointer";
+const LOCATE_POINTER_SCHEMA = "org.gnome.desktop.interface";
+
+var LocatePointer = class {
+ constructor() {
+ this._settings = new Gio.Settings({ schema_id: LOCATE_POINTER_SCHEMA });
+ this._settings.connect(`changed::${LOCATE_POINTER_KEY}`, () => this._syncEnabled());
+ this._syncEnabled();
+ }
+
+ _syncEnabled() {
+ let enabled = this._settings.get_boolean(LOCATE_POINTER_KEY);
+ if (enabled == !!this._ripples)
+ return;
+
+ if (enabled) {
+ this._ripples = new Ripples.Ripples(0.5, 0.5, 'ripple-pointer-location');
+ this._ripples.addTo(Main.uiGroup);
+ } else {
+ this._ripples.destroy();
+ this._ripples = null;
+ }
+ }
+
+ show() {
+ if (!this._ripples)
+ return;
+
+ let [x, y] = global.get_pointer();
+ this._ripples.playAnimation(x, y);
+ }
+};
diff --git a/js/ui/lookingGlass.js b/js/ui/lookingGlass.js
new file mode 100644
index 0000000..5abee0f
--- /dev/null
+++ b/js/ui/lookingGlass.js
@@ -0,0 +1,1373 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported LookingGlass */
+
+const { Clutter, Cogl, Gio, GLib, GObject,
+ Graphene, Meta, Pango, Shell, St } = imports.gi;
+const Signals = imports.signals;
+const System = imports.system;
+
+const History = imports.misc.history;
+const ExtensionUtils = imports.misc.extensionUtils;
+const ShellEntry = imports.ui.shellEntry;
+const Main = imports.ui.main;
+const JsParse = imports.misc.jsParse;
+
+const { ExtensionState } = ExtensionUtils;
+
+const CHEVRON = '>>> ';
+
+/* Imports...feel free to add here as needed */
+var commandHeader = 'const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; ' +
+ 'const Main = imports.ui.main; ' +
+ /* Utility functions...we should probably be able to use these
+ * in the shell core code too. */
+ 'const stage = global.stage; ' +
+ /* Special lookingGlass functions */
+ 'const inspect = Main.lookingGlass.inspect.bind(Main.lookingGlass); ' +
+ 'const it = Main.lookingGlass.getIt(); ' +
+ 'const r = Main.lookingGlass.getResult.bind(Main.lookingGlass); ';
+
+const HISTORY_KEY = 'looking-glass-history';
+// Time between tabs for them to count as a double-tab event
+var AUTO_COMPLETE_DOUBLE_TAB_DELAY = 500;
+var AUTO_COMPLETE_SHOW_COMPLETION_ANIMATION_DURATION = 200;
+var AUTO_COMPLETE_GLOBAL_KEYWORDS = _getAutoCompleteGlobalKeywords();
+
+const LG_ANIMATION_TIME = 500;
+
+function _getAutoCompleteGlobalKeywords() {
+ const keywords = ['true', 'false', 'null', 'new'];
+ // Don't add the private properties of globalThis (i.e., ones starting with '_')
+ const windowProperties = Object.getOwnPropertyNames(globalThis).filter(
+ a => a.charAt(0) !== '_');
+ const headerProperties = JsParse.getDeclaredConstants(commandHeader);
+
+ return keywords.concat(windowProperties).concat(headerProperties);
+}
+
+var AutoComplete = class AutoComplete {
+ constructor(entry) {
+ this._entry = entry;
+ this._entry.connect('key-press-event', this._entryKeyPressEvent.bind(this));
+ this._lastTabTime = global.get_current_time();
+ }
+
+ _processCompletionRequest(event) {
+ if (event.completions.length == 0)
+ return;
+
+ // Unique match = go ahead and complete; multiple matches + single tab = complete the common starting string;
+ // multiple matches + double tab = emit a suggest event with all possible options
+ if (event.completions.length == 1) {
+ this.additionalCompletionText(event.completions[0], event.attrHead);
+ this.emit('completion', { completion: event.completions[0], type: 'whole-word' });
+ } else if (event.completions.length > 1 && event.tabType === 'single') {
+ let commonPrefix = JsParse.getCommonPrefix(event.completions);
+
+ if (commonPrefix.length > 0) {
+ this.additionalCompletionText(commonPrefix, event.attrHead);
+ this.emit('completion', { completion: commonPrefix, type: 'prefix' });
+ this.emit('suggest', { completions: event.completions });
+ }
+ } else if (event.completions.length > 1 && event.tabType === 'double') {
+ this.emit('suggest', { completions: event.completions });
+ }
+ }
+
+ _entryKeyPressEvent(actor, event) {
+ let cursorPos = this._entry.clutter_text.get_cursor_position();
+ let text = this._entry.get_text();
+ if (cursorPos != -1)
+ text = text.slice(0, cursorPos);
+
+ if (event.get_key_symbol() == Clutter.KEY_Tab) {
+ let [completions, attrHead] = JsParse.getCompletions(text, commandHeader, AUTO_COMPLETE_GLOBAL_KEYWORDS);
+ let currTime = global.get_current_time();
+ if ((currTime - this._lastTabTime) < AUTO_COMPLETE_DOUBLE_TAB_DELAY) {
+ this._processCompletionRequest({ tabType: 'double',
+ completions,
+ attrHead });
+ } else {
+ this._processCompletionRequest({ tabType: 'single',
+ completions,
+ attrHead });
+ }
+ this._lastTabTime = currTime;
+ }
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ // Insert characters of text not already included in head at cursor position. i.e., if text="abc" and head="a",
+ // the string "bc" will be appended to this._entry
+ additionalCompletionText(text, head) {
+ let additionalCompletionText = text.slice(head.length);
+ let cursorPos = this._entry.clutter_text.get_cursor_position();
+
+ this._entry.clutter_text.insert_text(additionalCompletionText, cursorPos);
+ }
+};
+Signals.addSignalMethods(AutoComplete.prototype);
+
+
+var Notebook = GObject.registerClass({
+ Signals: { 'selection': { param_types: [Clutter.Actor.$gtype] } },
+}, class Notebook extends St.BoxLayout {
+ _init() {
+ super._init({
+ vertical: true,
+ y_expand: true,
+ });
+
+ this.tabControls = new St.BoxLayout({ style_class: 'labels' });
+
+ this._selectedIndex = -1;
+ this._tabs = [];
+ }
+
+ appendPage(name, child) {
+ let labelBox = new St.BoxLayout({ style_class: 'notebook-tab',
+ reactive: true,
+ track_hover: true });
+ let label = new St.Button({ label: name });
+ label.connect('clicked', () => {
+ this.selectChild(child);
+ return true;
+ });
+ labelBox.add_child(label);
+ this.tabControls.add(labelBox);
+
+ let scrollview = new St.ScrollView({ y_expand: true });
+ scrollview.get_hscroll_bar().hide();
+ scrollview.add_actor(child);
+
+ let tabData = { child,
+ labelBox,
+ label,
+ scrollView: scrollview,
+ _scrollToBottom: false };
+ this._tabs.push(tabData);
+ scrollview.hide();
+ this.add_child(scrollview);
+
+ let vAdjust = scrollview.vscroll.adjustment;
+ vAdjust.connect('changed', () => this._onAdjustScopeChanged(tabData));
+ vAdjust.connect('notify::value', () => this._onAdjustValueChanged(tabData));
+
+ if (this._selectedIndex == -1)
+ this.selectIndex(0);
+ }
+
+ _unselect() {
+ if (this._selectedIndex < 0)
+ return;
+ let tabData = this._tabs[this._selectedIndex];
+ tabData.labelBox.remove_style_pseudo_class('selected');
+ tabData.scrollView.hide();
+ this._selectedIndex = -1;
+ }
+
+ selectIndex(index) {
+ if (index == this._selectedIndex)
+ return;
+ if (index < 0) {
+ this._unselect();
+ this.emit('selection', null);
+ return;
+ }
+
+ // Focus the new tab before unmapping the old one
+ let tabData = this._tabs[index];
+ if (!tabData.scrollView.navigate_focus(null, St.DirectionType.TAB_FORWARD, false))
+ this.grab_key_focus();
+
+ this._unselect();
+
+ tabData.labelBox.add_style_pseudo_class('selected');
+ tabData.scrollView.show();
+ this._selectedIndex = index;
+ this.emit('selection', tabData.child);
+ }
+
+ selectChild(child) {
+ if (child == null) {
+ this.selectIndex(-1);
+ } else {
+ for (let i = 0; i < this._tabs.length; i++) {
+ let tabData = this._tabs[i];
+ if (tabData.child == child) {
+ this.selectIndex(i);
+ return;
+ }
+ }
+ }
+ }
+
+ scrollToBottom(index) {
+ let tabData = this._tabs[index];
+ tabData._scrollToBottom = true;
+
+ }
+
+ _onAdjustValueChanged(tabData) {
+ let vAdjust = tabData.scrollView.vscroll.adjustment;
+ if (vAdjust.value < (vAdjust.upper - vAdjust.lower - 0.5))
+ tabData._scrolltoBottom = false;
+ }
+
+ _onAdjustScopeChanged(tabData) {
+ if (!tabData._scrollToBottom)
+ return;
+ let vAdjust = tabData.scrollView.vscroll.adjustment;
+ vAdjust.value = vAdjust.upper - vAdjust.page_size;
+ }
+
+ nextTab() {
+ let nextIndex = this._selectedIndex;
+ if (nextIndex < this._tabs.length - 1)
+ ++nextIndex;
+
+ this.selectIndex(nextIndex);
+ }
+
+ prevTab() {
+ let prevIndex = this._selectedIndex;
+ if (prevIndex > 0)
+ --prevIndex;
+
+ this.selectIndex(prevIndex);
+ }
+});
+
+function objectToString(o) {
+ if (typeof o == typeof objectToString) {
+ // special case this since the default is way, way too verbose
+ return '<js function>';
+ } else {
+ if (o === undefined)
+ return 'undefined';
+
+ if (o === null)
+ return 'null';
+
+ return o.toString();
+ }
+}
+
+var ObjLink = GObject.registerClass(
+class ObjLink extends St.Button {
+ _init(lookingGlass, o, title) {
+ let text;
+ if (title)
+ text = title;
+ else
+ text = objectToString(o);
+ text = GLib.markup_escape_text(text, -1);
+
+ super._init({
+ reactive: true,
+ track_hover: true,
+ style_class: 'shell-link',
+ label: text,
+ x_align: Clutter.ActorAlign.START,
+ });
+ this.get_child().single_line_mode = true;
+
+ this._obj = o;
+ this._lookingGlass = lookingGlass;
+ }
+
+ vfunc_clicked() {
+ this._lookingGlass.inspectObject(this._obj, this);
+ }
+});
+
+var Result = GObject.registerClass(
+class Result extends St.BoxLayout {
+ _init(lookingGlass, command, o, index) {
+ super._init({ vertical: true });
+
+ this.index = index;
+ this.o = o;
+
+ this._lookingGlass = lookingGlass;
+
+ let cmdTxt = new St.Label({ text: command });
+ cmdTxt.clutter_text.ellipsize = Pango.EllipsizeMode.END;
+ this.add(cmdTxt);
+ let box = new St.BoxLayout({});
+ this.add(box);
+ let resultTxt = new St.Label({ text: 'r(%d) = '.format(index) });
+ resultTxt.clutter_text.ellipsize = Pango.EllipsizeMode.END;
+ box.add(resultTxt);
+ let objLink = new ObjLink(this._lookingGlass, o);
+ box.add(objLink);
+ }
+});
+
+var WindowList = GObject.registerClass({
+}, class WindowList extends St.BoxLayout {
+ _init(lookingGlass) {
+ super._init({ name: 'Windows', vertical: true, style: 'spacing: 8px' });
+ let tracker = Shell.WindowTracker.get_default();
+ this._updateId = Main.initializeDeferredWork(this, this._updateWindowList.bind(this));
+ global.display.connect('window-created', this._updateWindowList.bind(this));
+ tracker.connect('tracked-windows-changed', this._updateWindowList.bind(this));
+
+ this._lookingGlass = lookingGlass;
+ }
+
+ _updateWindowList() {
+ if (!this._lookingGlass.isOpen)
+ return;
+
+ this.destroy_all_children();
+ let windows = global.get_window_actors();
+ let tracker = Shell.WindowTracker.get_default();
+ for (let i = 0; i < windows.length; i++) {
+ let metaWindow = windows[i].metaWindow;
+ // Avoid multiple connections
+ if (!metaWindow._lookingGlassManaged) {
+ metaWindow.connect('unmanaged', this._updateWindowList.bind(this));
+ metaWindow._lookingGlassManaged = true;
+ }
+ let box = new St.BoxLayout({ vertical: true });
+ this.add(box);
+ let windowLink = new ObjLink(this._lookingGlass, metaWindow, metaWindow.title);
+ box.add_child(windowLink);
+ let propsBox = new St.BoxLayout({ vertical: true, style: 'padding-left: 6px;' });
+ box.add(propsBox);
+ propsBox.add(new St.Label({ text: 'wmclass: %s'.format(metaWindow.get_wm_class()) }));
+ let app = tracker.get_window_app(metaWindow);
+ if (app != null && !app.is_window_backed()) {
+ let icon = app.create_icon_texture(22);
+ let propBox = new St.BoxLayout({ style: 'spacing: 6px; ' });
+ propsBox.add(propBox);
+ propBox.add_child(new St.Label({ text: 'app: ' }));
+ let appLink = new ObjLink(this._lookingGlass, app, app.get_id());
+ propBox.add_child(appLink);
+ propBox.add_child(icon);
+ } else {
+ propsBox.add(new St.Label({ text: '<untracked>' }));
+ }
+ }
+ }
+
+ update() {
+ this._updateWindowList();
+ }
+});
+
+var ObjInspector = GObject.registerClass(
+class ObjInspector extends St.ScrollView {
+ _init(lookingGlass) {
+ super._init({
+ pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }),
+ });
+
+ this._obj = null;
+ this._previousObj = null;
+
+ this._parentList = [];
+
+ this.get_hscroll_bar().hide();
+ this._container = new St.BoxLayout({
+ name: 'LookingGlassPropertyInspector',
+ style_class: 'lg-dialog',
+ vertical: true,
+ x_expand: true,
+ y_expand: true,
+ });
+ this.add_actor(this._container);
+
+ this._lookingGlass = lookingGlass;
+ }
+
+ selectObject(obj, skipPrevious) {
+ if (!skipPrevious)
+ this._previousObj = this._obj;
+ else
+ this._previousObj = null;
+ this._obj = obj;
+
+ this._container.destroy_all_children();
+
+ let hbox = new St.BoxLayout({ style_class: 'lg-obj-inspector-title' });
+ this._container.add_actor(hbox);
+ let label = new St.Label({
+ text: 'Inspecting: %s: %s'.format(typeof obj, objectToString(obj)),
+ x_expand: true,
+ });
+ label.single_line_mode = true;
+ hbox.add_child(label);
+ let button = new St.Button({ label: 'Insert', style_class: 'lg-obj-inspector-button' });
+ button.connect('clicked', this._onInsert.bind(this));
+ hbox.add(button);
+
+ if (this._previousObj != null) {
+ button = new St.Button({ label: 'Back', style_class: 'lg-obj-inspector-button' });
+ button.connect('clicked', this._onBack.bind(this));
+ hbox.add(button);
+ }
+
+ button = new St.Button({ style_class: 'window-close' });
+ button.add_actor(new St.Icon({ icon_name: 'window-close-symbolic' }));
+ button.connect('clicked', this.close.bind(this));
+ hbox.add(button);
+ if (typeof obj == typeof {}) {
+ let properties = [];
+ for (let propName in obj)
+ properties.push(propName);
+ properties.sort();
+
+ for (let i = 0; i < properties.length; i++) {
+ let propName = properties[i];
+ let link;
+ try {
+ let prop = obj[propName];
+ link = new ObjLink(this._lookingGlass, prop);
+ } catch (e) {
+ link = new St.Label({ text: '<error>' });
+ }
+ let box = new St.BoxLayout();
+ box.add(new St.Label({ text: '%s: '.format(propName) }));
+ box.add(link);
+ this._container.add_actor(box);
+ }
+ }
+ }
+
+ open(sourceActor) {
+ if (this._open)
+ return;
+ this._previousObj = null;
+ this._open = true;
+ this.show();
+ if (sourceActor) {
+ this.set_scale(0, 0);
+ this.ease({
+ scale_x: 1,
+ scale_y: 1,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: 200,
+ });
+ } else {
+ this.set_scale(1, 1);
+ }
+ }
+
+ close() {
+ if (!this._open)
+ return;
+ this._open = false;
+ this.hide();
+ this._previousObj = null;
+ this._obj = null;
+ }
+
+ _onInsert() {
+ let obj = this._obj;
+ this.close();
+ this._lookingGlass.insertObject(obj);
+ }
+
+ _onBack() {
+ this.selectObject(this._previousObj, true);
+ }
+});
+
+var RedBorderEffect = GObject.registerClass(
+class RedBorderEffect extends Clutter.Effect {
+ _init() {
+ super._init();
+ this._pipeline = null;
+ }
+
+ vfunc_paint(paintContext) {
+ let framebuffer = paintContext.get_framebuffer();
+ let coglContext = framebuffer.get_context();
+ let actor = this.get_actor();
+ actor.continue_paint(paintContext);
+
+ if (!this._pipeline) {
+ let color = new Cogl.Color();
+ color.init_from_4ub(0xff, 0, 0, 0xc4);
+
+ this._pipeline = new Cogl.Pipeline(coglContext);
+ this._pipeline.set_color(color);
+ }
+
+ let alloc = actor.get_allocation_box();
+ let width = 2;
+
+ // clockwise order
+ framebuffer.draw_rectangle(this._pipeline,
+ 0, 0, alloc.get_width(), width);
+ framebuffer.draw_rectangle(this._pipeline,
+ alloc.get_width() - width, width,
+ alloc.get_width(), alloc.get_height());
+ framebuffer.draw_rectangle(this._pipeline,
+ 0, alloc.get_height(),
+ alloc.get_width() - width, alloc.get_height() - width);
+ framebuffer.draw_rectangle(this._pipeline,
+ 0, alloc.get_height() - width,
+ width, width);
+ }
+});
+
+var Inspector = GObject.registerClass({
+ Signals: { 'closed': {},
+ 'target': { param_types: [Clutter.Actor.$gtype, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] } },
+}, class Inspector extends Clutter.Actor {
+ _init(lookingGlass) {
+ super._init({ width: 0, height: 0 });
+
+ Main.uiGroup.add_actor(this);
+
+ let eventHandler = new St.BoxLayout({ name: 'LookingGlassDialog',
+ vertical: false,
+ reactive: true });
+ this._eventHandler = eventHandler;
+ this.add_actor(eventHandler);
+ this._displayText = new St.Label({ x_expand: true });
+ eventHandler.add_child(this._displayText);
+
+ eventHandler.connect('key-press-event', this._onKeyPressEvent.bind(this));
+ eventHandler.connect('button-press-event', this._onButtonPressEvent.bind(this));
+ eventHandler.connect('scroll-event', this._onScrollEvent.bind(this));
+ eventHandler.connect('motion-event', this._onMotionEvent.bind(this));
+
+ let seat = Clutter.get_default_backend().get_default_seat();
+ this._pointerDevice = seat.get_pointer();
+ this._keyboardDevice = seat.get_keyboard();
+
+ this._pointerDevice.grab(eventHandler);
+ this._keyboardDevice.grab(eventHandler);
+
+ // this._target is the actor currently shown by the inspector.
+ // this._pointerTarget is the actor directly under the pointer.
+ // Normally these are the same, but if you use the scroll wheel
+ // to drill down, they'll diverge until you either scroll back
+ // out, or move the pointer outside of _pointerTarget.
+ this._target = null;
+ this._pointerTarget = null;
+
+ this._lookingGlass = lookingGlass;
+ }
+
+ vfunc_allocate(box) {
+ this.set_allocation(box);
+
+ if (!this._eventHandler)
+ return;
+
+ let primary = Main.layoutManager.primaryMonitor;
+
+ let [, , natWidth, natHeight] =
+ this._eventHandler.get_preferred_size();
+
+ let childBox = new Clutter.ActorBox();
+ childBox.x1 = primary.x + Math.floor((primary.width - natWidth) / 2);
+ childBox.x2 = childBox.x1 + natWidth;
+ childBox.y1 = primary.y + Math.floor((primary.height - natHeight) / 2);
+ childBox.y2 = childBox.y1 + natHeight;
+ this._eventHandler.allocate(childBox);
+ }
+
+ _close() {
+ this._pointerDevice.ungrab();
+ this._keyboardDevice.ungrab();
+ this._eventHandler.destroy();
+ this._eventHandler = null;
+ this.emit('closed');
+ }
+
+ _onKeyPressEvent(actor, event) {
+ if (event.get_key_symbol() === Clutter.KEY_Escape)
+ this._close();
+ return Clutter.EVENT_STOP;
+ }
+
+ _onButtonPressEvent(actor, event) {
+ if (this._target) {
+ let [stageX, stageY] = event.get_coords();
+ this.emit('target', this._target, stageX, stageY);
+ }
+ this._close();
+ return Clutter.EVENT_STOP;
+ }
+
+ _onScrollEvent(actor, event) {
+ switch (event.get_scroll_direction()) {
+ case Clutter.ScrollDirection.UP: {
+ // select parent
+ let parent = this._target.get_parent();
+ if (parent != null) {
+ this._target = parent;
+ this._update(event);
+ }
+ break;
+ }
+
+ case Clutter.ScrollDirection.DOWN:
+ // select child
+ if (this._target != this._pointerTarget) {
+ let child = this._pointerTarget;
+ while (child) {
+ let parent = child.get_parent();
+ if (parent == this._target)
+ break;
+ child = parent;
+ }
+ if (child) {
+ this._target = child;
+ this._update(event);
+ }
+ }
+ break;
+
+ default:
+ break;
+ }
+ return Clutter.EVENT_STOP;
+ }
+
+ _onMotionEvent(actor, event) {
+ this._update(event);
+ return Clutter.EVENT_STOP;
+ }
+
+ _update(event) {
+ let [stageX, stageY] = event.get_coords();
+ let target = global.stage.get_actor_at_pos(Clutter.PickMode.ALL,
+ stageX,
+ stageY);
+
+ if (target != this._pointerTarget)
+ this._target = target;
+ this._pointerTarget = target;
+
+ let position = '[inspect x: %d y: %d]'.format(stageX, stageY);
+ this._displayText.text = '';
+ this._displayText.text = '%s %s'.format(position, this._target);
+
+ this._lookingGlass.setBorderPaintTarget(this._target);
+ }
+});
+
+var Extensions = GObject.registerClass({
+}, class Extensions extends St.BoxLayout {
+ _init(lookingGlass) {
+ super._init({ vertical: true, name: 'lookingGlassExtensions' });
+
+ this._lookingGlass = lookingGlass;
+ this._noExtensions = new St.Label({ style_class: 'lg-extensions-none',
+ text: _("No extensions installed") });
+ this._numExtensions = 0;
+ this._extensionsList = new St.BoxLayout({ vertical: true,
+ style_class: 'lg-extensions-list' });
+ this._extensionsList.add(this._noExtensions);
+ this.add(this._extensionsList);
+
+ Main.extensionManager.getUuids().forEach(uuid => {
+ this._loadExtension(null, uuid);
+ });
+
+ Main.extensionManager.connect('extension-loaded',
+ this._loadExtension.bind(this));
+ }
+
+ _loadExtension(o, uuid) {
+ let extension = Main.extensionManager.lookup(uuid);
+ // There can be cases where we create dummy extension metadata
+ // that's not really a proper extension. Don't bother with these.
+ if (!extension.metadata.name)
+ return;
+
+ let extensionDisplay = this._createExtensionDisplay(extension);
+ if (this._numExtensions == 0)
+ this._extensionsList.remove_actor(this._noExtensions);
+
+ this._numExtensions++;
+ this._extensionsList.add(extensionDisplay);
+ }
+
+ _onViewSource(actor) {
+ let extension = actor._extension;
+ let uri = extension.dir.get_uri();
+ Gio.app_info_launch_default_for_uri(uri, global.create_app_launch_context(0, -1));
+ this._lookingGlass.close();
+ }
+
+ _onWebPage(actor) {
+ let extension = actor._extension;
+ Gio.app_info_launch_default_for_uri(extension.metadata.url, global.create_app_launch_context(0, -1));
+ this._lookingGlass.close();
+ }
+
+ _onViewErrors(actor) {
+ let extension = actor._extension;
+ let shouldShow = !actor._isShowing;
+
+ if (shouldShow) {
+ let errors = extension.errors;
+ let errorDisplay = new St.BoxLayout({ vertical: true });
+ if (errors && errors.length) {
+ for (let i = 0; i < errors.length; i++)
+ errorDisplay.add(new St.Label({ text: errors[i] }));
+ } else {
+ /* Translators: argument is an extension UUID. */
+ let message = _("%s has not emitted any errors.").format(extension.uuid);
+ errorDisplay.add(new St.Label({ text: message }));
+ }
+
+ actor._errorDisplay = errorDisplay;
+ actor._parentBox.add(errorDisplay);
+ actor.label = _("Hide Errors");
+ } else {
+ actor._errorDisplay.destroy();
+ actor._errorDisplay = null;
+ actor.label = _("Show Errors");
+ }
+
+ actor._isShowing = shouldShow;
+ }
+
+ _stateToString(extensionState) {
+ switch (extensionState) {
+ case ExtensionState.ENABLED:
+ return _("Enabled");
+ case ExtensionState.DISABLED:
+ case ExtensionState.INITIALIZED:
+ return _("Disabled");
+ case ExtensionState.ERROR:
+ return _("Error");
+ case ExtensionState.OUT_OF_DATE:
+ return _("Out of date");
+ case ExtensionState.DOWNLOADING:
+ return _("Downloading");
+ }
+ return 'Unknown'; // Not translated, shouldn't appear
+ }
+
+ _createExtensionDisplay(extension) {
+ let box = new St.BoxLayout({ style_class: 'lg-extension', vertical: true });
+ let name = new St.Label({
+ style_class: 'lg-extension-name',
+ text: extension.metadata.name,
+ x_expand: true,
+ });
+ box.add_child(name);
+ let description = new St.Label({
+ style_class: 'lg-extension-description',
+ text: extension.metadata.description || 'No description',
+ x_expand: true,
+ });
+ box.add_child(description);
+
+ let metaBox = new St.BoxLayout({ style_class: 'lg-extension-meta' });
+ box.add(metaBox);
+ let state = new St.Label({ style_class: 'lg-extension-state',
+ text: this._stateToString(extension.state) });
+ metaBox.add(state);
+
+ let viewsource = new St.Button({ reactive: true,
+ track_hover: true,
+ style_class: 'shell-link',
+ label: _("View Source") });
+ viewsource._extension = extension;
+ viewsource.connect('clicked', this._onViewSource.bind(this));
+ metaBox.add(viewsource);
+
+ if (extension.metadata.url) {
+ let webpage = new St.Button({ reactive: true,
+ track_hover: true,
+ style_class: 'shell-link',
+ label: _("Web Page") });
+ webpage._extension = extension;
+ webpage.connect('clicked', this._onWebPage.bind(this));
+ metaBox.add(webpage);
+ }
+
+ let viewerrors = new St.Button({ reactive: true,
+ track_hover: true,
+ style_class: 'shell-link',
+ label: _("Show Errors") });
+ viewerrors._extension = extension;
+ viewerrors._parentBox = box;
+ viewerrors._isShowing = false;
+ viewerrors.connect('clicked', this._onViewErrors.bind(this));
+ metaBox.add(viewerrors);
+
+ return box;
+ }
+});
+
+
+var ActorLink = GObject.registerClass({
+ Signals: {
+ 'inspect-actor': {},
+ },
+}, class ActorLink extends St.Button {
+ _init(actor) {
+ this._arrow = new St.Icon({
+ icon_name: 'pan-end-symbolic',
+ icon_size: 8,
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER,
+ pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }),
+ });
+
+ const label = new St.Label({
+ text: actor.toString(),
+ x_align: Clutter.ActorAlign.START,
+ });
+
+ const inspectButton = new St.Button({
+ child: new St.Icon({
+ icon_name: 'insert-object-symbolic',
+ icon_size: 12,
+ y_align: Clutter.ActorAlign.CENTER,
+ }),
+ reactive: true,
+ x_expand: true,
+ x_align: Clutter.ActorAlign.START,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ inspectButton.connect('clicked', () => this.emit('inspect-actor'));
+
+ const box = new St.BoxLayout();
+ box.add_child(this._arrow);
+ box.add_child(label);
+ box.add_child(inspectButton);
+
+ super._init({
+ reactive: true,
+ track_hover: true,
+ toggle_mode: true,
+ style_class: 'actor-link',
+ child: box,
+ x_align: Clutter.ActorAlign.START,
+ });
+
+ this._actor = actor;
+ }
+
+ vfunc_clicked() {
+ this._arrow.ease({
+ rotation_angle_z: this.checked ? 90 : 0,
+ duration: 250,
+ });
+ }
+});
+
+var ActorTreeViewer = GObject.registerClass(
+class ActorTreeViewer extends St.BoxLayout {
+ _init(lookingGlass) {
+ super._init();
+
+ this._lookingGlass = lookingGlass;
+ this._actorData = new Map();
+ }
+
+ _showActorChildren(actor) {
+ const data = this._actorData.get(actor);
+ if (!data || data.visible)
+ return;
+
+ data.visible = true;
+ data.actorAddedId = actor.connect('actor-added', (container, child) => {
+ this._addActor(data.children, child);
+ });
+ data.actorRemovedId = actor.connect('actor-removed', (container, child) => {
+ this._removeActor(child);
+ });
+
+ for (let child of actor)
+ this._addActor(data.children, child);
+ }
+
+ _hideActorChildren(actor) {
+ const data = this._actorData.get(actor);
+ if (!data || !data.visible)
+ return;
+
+ for (let child of actor)
+ this._removeActor(child);
+
+ data.visible = false;
+ if (data.actorAddedId > 0) {
+ actor.disconnect(data.actorAddedId);
+ data.actorAddedId = 0;
+ }
+ if (data.actorRemovedId > 0) {
+ actor.disconnect(data.actorRemovedId);
+ data.actorRemovedId = 0;
+ }
+ data.children.remove_all_children();
+ }
+
+ _addActor(container, actor) {
+ if (this._actorData.has(actor))
+ return;
+
+ if (actor === this._lookingGlass)
+ return;
+
+ const button = new ActorLink(actor);
+ button.connect('notify::checked', () => {
+ this._lookingGlass.setBorderPaintTarget(actor);
+ if (button.checked)
+ this._showActorChildren(actor);
+ else
+ this._hideActorChildren(actor);
+ });
+ button.connect('inspect-actor', () => {
+ this._lookingGlass.inspectObject(actor, button);
+ });
+
+ const mainContainer = new St.BoxLayout({ vertical: true });
+ const childrenContainer = new St.BoxLayout({
+ vertical: true,
+ style: 'padding: 0 0 0 18px',
+ });
+
+ mainContainer.add_child(button);
+ mainContainer.add_child(childrenContainer);
+
+ this._actorData.set(actor, {
+ button,
+ container: mainContainer,
+ children: childrenContainer,
+ visible: false,
+ actorAddedId: 0,
+ actorRemovedId: 0,
+ actorDestroyedId: actor.connect('destroy', () => this._removeActor(actor)),
+ });
+
+ let belowChild = null;
+ const nextSibling = actor.get_next_sibling();
+ if (nextSibling && this._actorData.has(nextSibling))
+ belowChild = this._actorData.get(nextSibling).container;
+
+ container.insert_child_above(mainContainer, belowChild);
+ }
+
+ _removeActor(actor) {
+ const data = this._actorData.get(actor);
+ if (!data)
+ return;
+
+ for (let child of actor)
+ this._removeActor(child);
+
+ if (data.actorAddedId > 0) {
+ actor.disconnect(data.actorAddedId);
+ data.actorAddedId = 0;
+ }
+ if (data.actorRemovedId > 0) {
+ actor.disconnect(data.actorRemovedId);
+ data.actorRemovedId = 0;
+ }
+ if (data.actorDestroyedId > 0) {
+ actor.disconnect(data.actorDestroyedId);
+ data.actorDestroyedId = 0;
+ }
+ data.container.destroy();
+ this._actorData.delete(actor);
+ }
+
+ vfunc_map() {
+ super.vfunc_map();
+ this._addActor(this, global.stage);
+ }
+
+ vfunc_unmap() {
+ super.vfunc_unmap();
+ this._removeActor(global.stage);
+ }
+});
+
+var LookingGlass = GObject.registerClass(
+class LookingGlass extends St.BoxLayout {
+ _init() {
+ super._init({
+ name: 'LookingGlassDialog',
+ style_class: 'lg-dialog',
+ vertical: true,
+ visible: false,
+ reactive: true,
+ });
+
+ this._borderPaintTarget = null;
+ this._redBorderEffect = new RedBorderEffect();
+
+ this._open = false;
+
+ this._it = null;
+ this._offset = 0;
+
+ // Sort of magic, but...eh.
+ this._maxItems = 150;
+
+ this._interfaceSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' });
+ this._interfaceSettings.connect('changed::monospace-font-name',
+ this._updateFont.bind(this));
+ this._updateFont();
+
+ // We want it to appear to slide out from underneath the panel
+ Main.uiGroup.add_actor(this);
+ Main.uiGroup.set_child_below_sibling(this,
+ Main.layoutManager.panelBox);
+ Main.layoutManager.panelBox.connect('notify::allocation',
+ this._queueResize.bind(this));
+ Main.layoutManager.keyboardBox.connect('notify::allocation',
+ this._queueResize.bind(this));
+
+ this._objInspector = new ObjInspector(this);
+ Main.uiGroup.add_actor(this._objInspector);
+ this._objInspector.hide();
+
+ let toolbar = new St.BoxLayout({ name: 'Toolbar' });
+ this.add_actor(toolbar);
+ let inspectIcon = new St.Icon({ icon_name: 'gtk-color-picker',
+ icon_size: 24 });
+ toolbar.add_actor(inspectIcon);
+ inspectIcon.reactive = true;
+ inspectIcon.connect('button-press-event', () => {
+ let inspector = new Inspector(this);
+ inspector.connect('target', (i, target, stageX, stageY) => {
+ this._pushResult('inspect(%d, %d)'.format(Math.round(stageX), Math.round(stageY)), target);
+ });
+ inspector.connect('closed', () => {
+ this.show();
+ global.stage.set_key_focus(this._entry);
+ });
+ this.hide();
+ return Clutter.EVENT_STOP;
+ });
+
+ let gcIcon = new St.Icon({ icon_name: 'user-trash-full',
+ icon_size: 24 });
+ toolbar.add_actor(gcIcon);
+ gcIcon.reactive = true;
+ gcIcon.connect('button-press-event', () => {
+ gcIcon.icon_name = 'user-trash';
+ System.gc();
+ this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => {
+ gcIcon.icon_name = 'user-trash-full';
+ this._timeoutId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._timeoutId, '[gnome-shell] gcIcon.icon_name = \'user-trash-full\'');
+ return Clutter.EVENT_PROPAGATE;
+ });
+
+ let notebook = new Notebook();
+ this._notebook = notebook;
+ this.add_child(notebook);
+
+ let emptyBox = new St.Bin({ x_expand: true });
+ toolbar.add_child(emptyBox);
+ toolbar.add_actor(notebook.tabControls);
+
+ this._evalBox = new St.BoxLayout({ name: 'EvalBox', vertical: true });
+ notebook.appendPage('Evaluator', this._evalBox);
+
+ this._resultsArea = new St.BoxLayout({
+ name: 'ResultsArea',
+ vertical: true,
+ y_expand: true,
+ });
+ this._evalBox.add_child(this._resultsArea);
+
+ this._entryArea = new St.BoxLayout({
+ name: 'EntryArea',
+ y_align: Clutter.ActorAlign.END,
+ });
+ this._evalBox.add_actor(this._entryArea);
+
+ let label = new St.Label({ text: CHEVRON });
+ this._entryArea.add(label);
+
+ this._entry = new St.Entry({
+ can_focus: true,
+ x_expand: true,
+ });
+ ShellEntry.addContextMenu(this._entry);
+ this._entryArea.add_child(this._entry);
+
+ this._windowList = new WindowList(this);
+ notebook.appendPage('Windows', this._windowList);
+
+ this._extensions = new Extensions(this);
+ notebook.appendPage('Extensions', this._extensions);
+
+ this._actorTreeViewer = new ActorTreeViewer(this);
+ notebook.appendPage('Actors', this._actorTreeViewer);
+
+ this._entry.clutter_text.connect('activate', (o, _e) => {
+ // Hide any completions we are currently showing
+ this._hideCompletions();
+
+ let text = o.get_text();
+ // Ensure we don't get newlines in the command; the history file is
+ // newline-separated.
+ text = text.replace('\n', ' ');
+ // Strip leading and trailing whitespace
+ text = text.replace(/^\s+/g, '').replace(/\s+$/g, '');
+ if (text == '')
+ return true;
+ this._evaluate(text);
+ return true;
+ });
+
+ this._history = new History.HistoryManager({ gsettingsKey: HISTORY_KEY,
+ entry: this._entry.clutter_text });
+
+ this._autoComplete = new AutoComplete(this._entry);
+ this._autoComplete.connect('suggest', (a, e) => {
+ this._showCompletions(e.completions);
+ });
+ // If a completion is completed unambiguously, the currently-displayed completion
+ // suggestions become irrelevant.
+ this._autoComplete.connect('completion', (a, e) => {
+ if (e.type == 'whole-word')
+ this._hideCompletions();
+ });
+
+ this._resize();
+ }
+
+ _updateFont() {
+ let fontName = this._interfaceSettings.get_string('monospace-font-name');
+ let fontDesc = Pango.FontDescription.from_string(fontName);
+ // We ignore everything but size and style; you'd be crazy to set your system-wide
+ // monospace font to be bold/oblique/etc. Could easily be added here.
+ let size = fontDesc.get_size() / 1024.;
+ let unit = fontDesc.get_size_is_absolute() ? 'px' : 'pt';
+ this.style = 'font-size: %d%s; font-family: "%s";'.format(
+ size, unit, fontDesc.get_family());
+ }
+
+ setBorderPaintTarget(obj) {
+ if (this._borderPaintTarget != null)
+ this._borderPaintTarget.remove_effect(this._redBorderEffect);
+ this._borderPaintTarget = obj;
+ if (this._borderPaintTarget != null)
+ this._borderPaintTarget.add_effect(this._redBorderEffect);
+ }
+
+ _pushResult(command, obj) {
+ let index = this._resultsArea.get_n_children() + this._offset;
+ let result = new Result(this, CHEVRON + command, obj, index);
+ this._resultsArea.add(result);
+ if (obj instanceof Clutter.Actor)
+ this.setBorderPaintTarget(obj);
+
+ if (this._resultsArea.get_n_children() > this._maxItems) {
+ this._resultsArea.get_first_child().destroy();
+ this._offset++;
+ }
+ this._it = obj;
+
+ // Scroll to bottom
+ this._notebook.scrollToBottom(0);
+ }
+
+ _showCompletions(completions) {
+ if (!this._completionActor) {
+ this._completionActor = new St.Label({ name: 'LookingGlassAutoCompletionText', style_class: 'lg-completions-text' });
+ this._completionActor.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+ this._completionActor.clutter_text.line_wrap = true;
+ this._evalBox.insert_child_below(this._completionActor, this._entryArea);
+ }
+
+ this._completionActor.set_text(completions.join(', '));
+
+ // Setting the height to -1 allows us to get its actual preferred height rather than
+ // whatever was last set when animating
+ this._completionActor.set_height(-1);
+ let [, naturalHeight] = this._completionActor.get_preferred_height(this._resultsArea.get_width());
+
+ // Don't reanimate if we are already visible
+ if (this._completionActor.visible) {
+ this._completionActor.height = naturalHeight;
+ } else {
+ let settings = St.Settings.get();
+ let duration = AUTO_COMPLETE_SHOW_COMPLETION_ANIMATION_DURATION / settings.slow_down_factor;
+ this._completionActor.show();
+ this._completionActor.remove_all_transitions();
+ this._completionActor.ease({
+ height: naturalHeight,
+ opacity: 255,
+ duration,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+ }
+
+ _hideCompletions() {
+ if (this._completionActor) {
+ let settings = St.Settings.get();
+ let duration = AUTO_COMPLETE_SHOW_COMPLETION_ANIMATION_DURATION / settings.slow_down_factor;
+ this._completionActor.remove_all_transitions();
+ this._completionActor.ease({
+ height: 0,
+ opacity: 0,
+ duration,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ this._completionActor.hide();
+ },
+ });
+ }
+ }
+
+ _evaluate(command) {
+ this._history.addItem(command);
+
+ let lines = command.split(';');
+ lines.push('return %s'.format(lines.pop()));
+
+ let fullCmd = commandHeader + lines.join(';');
+
+ let resultObj;
+ try {
+ resultObj = Function(fullCmd)();
+ } catch (e) {
+ resultObj = '<exception %s>'.format(e.toString());
+ }
+
+ this._pushResult(command, resultObj);
+ this._entry.text = '';
+ }
+
+ inspect(x, y) {
+ return global.stage.get_actor_at_pos(Clutter.PickMode.REACTIVE, x, y);
+ }
+
+ getIt() {
+ return this._it;
+ }
+
+ getResult(idx) {
+ try {
+ return this._resultsArea.get_child_at_index(idx - this._offset).o;
+ } catch (e) {
+ throw new Error('Unknown result at index %d'.format(idx));
+ }
+ }
+
+ toggle() {
+ if (this._open)
+ this.close();
+ else
+ this.open();
+ }
+
+ _queueResize() {
+ Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
+ this._resize();
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+
+ _resize() {
+ let primary = Main.layoutManager.primaryMonitor;
+ let myWidth = primary.width * 0.7;
+ let availableHeight = primary.height - Main.layoutManager.keyboardBox.height;
+ let myHeight = Math.min(primary.height * 0.7, availableHeight * 0.9);
+ this.x = primary.x + (primary.width - myWidth) / 2;
+ this._hiddenY = primary.y + Main.layoutManager.panelBox.height - myHeight;
+ this._targetY = this._hiddenY + myHeight;
+ this.y = this._hiddenY;
+ this.width = myWidth;
+ this.height = myHeight;
+ this._objInspector.set_size(Math.floor(myWidth * 0.8), Math.floor(myHeight * 0.8));
+ this._objInspector.set_position(this.x + Math.floor(myWidth * 0.1),
+ this._targetY + Math.floor(myHeight * 0.1));
+ }
+
+ insertObject(obj) {
+ this._pushResult('<insert>', obj);
+ }
+
+ inspectObject(obj, sourceActor) {
+ this._objInspector.open(sourceActor);
+ this._objInspector.selectObject(obj);
+ }
+
+ // Handle key events which are relevant for all tabs of the LookingGlass
+ vfunc_key_press_event(keyPressEvent) {
+ let symbol = keyPressEvent.keyval;
+ if (symbol == Clutter.KEY_Escape) {
+ if (this._objInspector.visible)
+ this._objInspector.close();
+ else
+ this.close();
+ return Clutter.EVENT_STOP;
+ }
+ // Ctrl+PgUp and Ctrl+PgDown switches tabs in the notebook view
+ if (keyPressEvent.modifier_state & Clutter.ModifierType.CONTROL_MASK) {
+ if (symbol == Clutter.KEY_Page_Up)
+ this._notebook.prevTab();
+ else if (symbol == Clutter.KEY_Page_Down)
+ this._notebook.nextTab();
+ }
+ return super.vfunc_key_press_event(keyPressEvent);
+ }
+
+ open() {
+ if (this._open)
+ return;
+
+ if (!Main.pushModal(this._entry, { actionMode: Shell.ActionMode.LOOKING_GLASS }))
+ return;
+
+ this._notebook.selectIndex(0);
+ this.show();
+ this._open = true;
+ this._history.lastItem();
+
+ this.remove_all_transitions();
+
+ // We inverse compensate for the slow-down so you can change the factor
+ // through LookingGlass without long waits.
+ let duration = LG_ANIMATION_TIME / St.Settings.get().slow_down_factor;
+ this.ease({
+ y: this._targetY,
+ duration,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+
+ this._windowList.update();
+ }
+
+ close() {
+ if (!this._open)
+ return;
+
+ this._objInspector.hide();
+
+ this._open = false;
+ this.remove_all_transitions();
+
+ this.setBorderPaintTarget(null);
+
+ Main.popModal(this._entry);
+
+ let settings = St.Settings.get();
+ let duration = Math.min(LG_ANIMATION_TIME / settings.slow_down_factor,
+ LG_ANIMATION_TIME);
+ this.ease({
+ y: this._hiddenY,
+ duration,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this.hide(),
+ });
+ }
+
+ get isOpen() {
+ return this._open;
+ }
+});
diff --git a/js/ui/magnifier.js b/js/ui/magnifier.js
new file mode 100644
index 0000000..3a778b6
--- /dev/null
+++ b/js/ui/magnifier.js
@@ -0,0 +1,1989 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+
+const { Atspi, Clutter, GDesktopEnums,
+ Gio, GLib, GObject, Meta, Shell, St } = imports.gi;
+const Signals = imports.signals;
+
+const Background = imports.ui.background;
+const FocusCaretTracker = imports.ui.focusCaretTracker;
+const Main = imports.ui.main;
+const MagnifierDBus = imports.ui.magnifierDBus;
+const Params = imports.misc.params;
+const PointerWatcher = imports.ui.pointerWatcher;
+
+var CROSSHAIRS_CLIP_SIZE = [100, 100];
+var NO_CHANGE = 0.0;
+
+var POINTER_REST_TIME = 1000; // milliseconds
+
+// Settings
+const MAGNIFIER_SCHEMA = 'org.gnome.desktop.a11y.magnifier';
+const SCREEN_POSITION_KEY = 'screen-position';
+const MAG_FACTOR_KEY = 'mag-factor';
+const INVERT_LIGHTNESS_KEY = 'invert-lightness';
+const COLOR_SATURATION_KEY = 'color-saturation';
+const BRIGHT_RED_KEY = 'brightness-red';
+const BRIGHT_GREEN_KEY = 'brightness-green';
+const BRIGHT_BLUE_KEY = 'brightness-blue';
+const CONTRAST_RED_KEY = 'contrast-red';
+const CONTRAST_GREEN_KEY = 'contrast-green';
+const CONTRAST_BLUE_KEY = 'contrast-blue';
+const LENS_MODE_KEY = 'lens-mode';
+const CLAMP_MODE_KEY = 'scroll-at-edges';
+const MOUSE_TRACKING_KEY = 'mouse-tracking';
+const FOCUS_TRACKING_KEY = 'focus-tracking';
+const CARET_TRACKING_KEY = 'caret-tracking';
+const SHOW_CROSS_HAIRS_KEY = 'show-cross-hairs';
+const CROSS_HAIRS_THICKNESS_KEY = 'cross-hairs-thickness';
+const CROSS_HAIRS_COLOR_KEY = 'cross-hairs-color';
+const CROSS_HAIRS_OPACITY_KEY = 'cross-hairs-opacity';
+const CROSS_HAIRS_LENGTH_KEY = 'cross-hairs-length';
+const CROSS_HAIRS_CLIP_KEY = 'cross-hairs-clip';
+
+var MouseSpriteContent = GObject.registerClass({
+ Implements: [Clutter.Content],
+}, class MouseSpriteContent extends GObject.Object {
+ _init() {
+ super._init();
+ this._texture = null;
+ }
+
+ vfunc_get_preferred_size() {
+ if (!this._texture)
+ return [false, 0, 0];
+
+ return [true, this._texture.get_width(), this._texture.get_height()];
+ }
+
+ vfunc_paint_content(actor, node, _paintContext) {
+ if (!this._texture)
+ return;
+
+ let color = Clutter.Color.get_static(Clutter.StaticColor.WHITE);
+ let [minFilter, magFilter] = actor.get_content_scaling_filters();
+ let textureNode = new Clutter.TextureNode(this._texture,
+ color, minFilter, magFilter);
+ textureNode.set_name('MouseSpriteContent');
+ node.add_child(textureNode);
+
+ textureNode.add_rectangle(actor.get_content_box());
+ }
+
+ get texture() {
+ return this._texture;
+ }
+
+ set texture(coglTexture) {
+ if (this._texture == coglTexture)
+ return;
+
+ let oldTexture = this._texture;
+ this._texture = coglTexture;
+ this.invalidate();
+
+ if (!oldTexture || !coglTexture ||
+ oldTexture.get_width() != coglTexture.get_width() ||
+ oldTexture.get_height() != coglTexture.get_height())
+ this.invalidate_size();
+ }
+});
+
+var Magnifier = class Magnifier {
+ constructor() {
+ // Magnifier is a manager of ZoomRegions.
+ this._zoomRegions = [];
+
+ // Create small clutter tree for the magnified mouse.
+ let cursorTracker = Meta.CursorTracker.get_for_display(global.display);
+ this._cursorTracker = cursorTracker;
+
+ this._mouseSprite = new Clutter.Actor({ request_mode: Clutter.RequestMode.CONTENT_SIZE });
+ this._mouseSprite.content = new MouseSpriteContent();
+
+ // Create the first ZoomRegion and initialize it according to the
+ // magnification settings.
+
+ [this.xMouse, this.yMouse] = global.get_pointer();
+
+ let aZoomRegion = new ZoomRegion(this, this._mouseSprite);
+ this._zoomRegions.push(aZoomRegion);
+ this._settingsInit(aZoomRegion);
+ aZoomRegion.scrollContentsTo(this.xMouse, this.yMouse);
+
+ St.Settings.get().connect('notify::magnifier-active', () => {
+ this.setActive(St.Settings.get().magnifier_active);
+ });
+
+ // Export to dbus.
+ new MagnifierDBus.ShellMagnifier();
+ this.setActive(St.Settings.get().magnifier_active);
+ }
+
+ /**
+ * showSystemCursor:
+ * Show the system mouse pointer.
+ */
+ showSystemCursor() {
+ const seat = Clutter.get_default_backend().get_default_seat();
+
+ if (seat.is_unfocus_inhibited())
+ seat.uninhibit_unfocus();
+ this._cursorTracker.set_pointer_visible(true);
+ }
+
+ /**
+ * hideSystemCursor:
+ * Hide the system mouse pointer.
+ */
+ hideSystemCursor() {
+ const seat = Clutter.get_default_backend().get_default_seat();
+
+ if (!seat.is_unfocus_inhibited())
+ seat.inhibit_unfocus();
+ this._cursorTracker.set_pointer_visible(false);
+ }
+
+ /**
+ * setActive:
+ * Show/hide all the zoom regions.
+ * @param {bool} activate: Boolean to activate or de-activate the magnifier.
+ */
+ setActive(activate) {
+ let isActive = this.isActive();
+
+ this._zoomRegions.forEach(zoomRegion => {
+ zoomRegion.setActive(activate);
+ });
+
+ if (isActive === activate)
+ return;
+
+ if (activate) {
+ this._updateMouseSprite();
+ this._cursorSpriteChangedId =
+ this._cursorTracker.connect('cursor-changed',
+ this._updateMouseSprite.bind(this));
+ Meta.disable_unredirect_for_display(global.display);
+ this.startTrackingMouse();
+ } else {
+ this._cursorTracker.disconnect(this._cursorSpriteChangedId);
+ this._mouseSprite.content.texture = null;
+ Meta.enable_unredirect_for_display(global.display);
+ this.stopTrackingMouse();
+ }
+
+ if (this._crossHairs)
+ this._crossHairs.setEnabled(activate);
+
+ // Make sure system mouse pointer is shown when all zoom regions are
+ // invisible.
+ if (!activate)
+ this.showSystemCursor();
+
+ // Notify interested parties of this change
+ this.emit('active-changed', activate);
+ }
+
+ /**
+ * isActive:
+ * @returns {bool} Whether the magnifier is active.
+ */
+ isActive() {
+ // Sufficient to check one ZoomRegion since Magnifier's active
+ // state applies to all of them.
+ if (this._zoomRegions.length == 0)
+ return false;
+ else
+ return this._zoomRegions[0].isActive();
+ }
+
+ /**
+ * startTrackingMouse:
+ * Turn on mouse tracking, if not already doing so.
+ */
+ startTrackingMouse() {
+ if (!this._pointerWatch) {
+ let interval = 1000 / Clutter.get_default_frame_rate();
+ this._pointerWatch = PointerWatcher.getPointerWatcher().addWatch(interval, this.scrollToMousePos.bind(this));
+ }
+ }
+
+ /**
+ * stopTrackingMouse:
+ * Turn off mouse tracking, if not already doing so.
+ */
+ stopTrackingMouse() {
+ if (this._pointerWatch)
+ this._pointerWatch.remove();
+
+ this._pointerWatch = null;
+ }
+
+ /**
+ * isTrackingMouse:
+ * @returns {bool} whether the magnifier is currently tracking the mouse
+ */
+ isTrackingMouse() {
+ return !!this._mouseTrackingId;
+ }
+
+ /**
+ * scrollToMousePos:
+ * Position all zoom regions' ROI relative to the current location of the
+ * system pointer.
+ * @returns {bool} true.
+ */
+ scrollToMousePos() {
+ let [xMouse, yMouse] = global.get_pointer();
+
+ if (xMouse != this.xMouse || yMouse != this.yMouse) {
+ this.xMouse = xMouse;
+ this.yMouse = yMouse;
+
+ let sysMouseOverAny = false;
+ this._zoomRegions.forEach(zoomRegion => {
+ if (zoomRegion.scrollToMousePos())
+ sysMouseOverAny = true;
+ });
+ if (sysMouseOverAny)
+ this.hideSystemCursor();
+ else
+ this.showSystemCursor();
+ }
+ return true;
+ }
+
+ /**
+ * createZoomRegion:
+ * Create a ZoomRegion instance with the given properties.
+ * @param {number} xMagFactor:
+ * The power to set horizontal magnification of the ZoomRegion. A value
+ * of 1.0 means no magnification, a value of 2.0 doubles the size.
+ * @param {number} yMagFactor:
+ * The power to set the vertical magnification of the ZoomRegion.
+ * @param {{x: number, y: number, width: number, height: number}} roi:
+ * The reg Object that defines the region to magnify, given in
+ * unmagnified coordinates.
+ * @param {{x: number, y: number, width: number, height: number}} viewPort:
+ * Object that defines the position of the ZoomRegion on screen.
+ * @returns {ZoomRegion} the newly created ZoomRegion.
+ */
+ createZoomRegion(xMagFactor, yMagFactor, roi, viewPort) {
+ let zoomRegion = new ZoomRegion(this, this._mouseSprite);
+ zoomRegion.setViewPort(viewPort);
+
+ // We ignore the redundant width/height on the ROI
+ let fixedROI = Object.create(roi);
+ fixedROI.width = viewPort.width / xMagFactor;
+ fixedROI.height = viewPort.height / yMagFactor;
+ zoomRegion.setROI(fixedROI);
+
+ zoomRegion.addCrosshairs(this._crossHairs);
+ return zoomRegion;
+ }
+
+ /**
+ * addZoomRegion:
+ * Append the given ZoomRegion to the list of currently defined ZoomRegions
+ * for this Magnifier instance.
+ * @param {ZoomRegion} zoomRegion: The zoomRegion to add.
+ */
+ addZoomRegion(zoomRegion) {
+ if (zoomRegion) {
+ this._zoomRegions.push(zoomRegion);
+ if (!this.isTrackingMouse())
+ this.startTrackingMouse();
+ }
+ }
+
+ /**
+ * getZoomRegions:
+ * Return a list of ZoomRegion's for this Magnifier.
+ * @returns {number[]} The Magnifier's zoom region list.
+ */
+ getZoomRegions() {
+ return this._zoomRegions;
+ }
+
+ /**
+ * clearAllZoomRegions:
+ * Remove all the zoom regions from this Magnfier's ZoomRegion list.
+ */
+ clearAllZoomRegions() {
+ for (let i = 0; i < this._zoomRegions.length; i++)
+ this._zoomRegions[i].setActive(false);
+
+ this._zoomRegions.length = 0;
+ this.stopTrackingMouse();
+ this.showSystemCursor();
+ }
+
+ /**
+ * addCrosshairs:
+ * Add and show a cross hair centered on the magnified mouse.
+ */
+ addCrosshairs() {
+ if (!this._crossHairs)
+ this._crossHairs = new Crosshairs();
+
+ let thickness = this._settings.get_int(CROSS_HAIRS_THICKNESS_KEY);
+ let color = this._settings.get_string(CROSS_HAIRS_COLOR_KEY);
+ let opacity = this._settings.get_double(CROSS_HAIRS_OPACITY_KEY);
+ let length = this._settings.get_int(CROSS_HAIRS_LENGTH_KEY);
+ let clip = this._settings.get_boolean(CROSS_HAIRS_CLIP_KEY);
+
+ this.setCrosshairsThickness(thickness);
+ this.setCrosshairsColor(color);
+ this.setCrosshairsOpacity(opacity);
+ this.setCrosshairsLength(length);
+ this.setCrosshairsClip(clip);
+
+ let theCrossHairs = this._crossHairs;
+ this._zoomRegions.forEach(zoomRegion => {
+ zoomRegion.addCrosshairs(theCrossHairs);
+ });
+ }
+
+ /**
+ * setCrosshairsVisible:
+ * Show or hide the cross hair.
+ * @param {bool} visible: Flag that indicates show (true) or hide (false).
+ */
+ setCrosshairsVisible(visible) {
+ if (visible) {
+ if (!this._crossHairs)
+ this.addCrosshairs();
+ this._crossHairs.show();
+ } else {
+ // eslint-disable-next-line no-lonely-if
+ if (this._crossHairs)
+ this._crossHairs.hide();
+ }
+ }
+
+ /**
+ * setCrosshairsColor:
+ * Set the color of the crosshairs for all ZoomRegions.
+ * @param {string} color: The color as a string, e.g. '#ff0000ff' or 'red'.
+ */
+ setCrosshairsColor(color) {
+ if (this._crossHairs) {
+ let [res_, clutterColor] = Clutter.Color.from_string(color);
+ this._crossHairs.setColor(clutterColor);
+ }
+ }
+
+ /**
+ * getCrosshairsColor:
+ * Get the color of the crosshairs.
+ * @returns {string} The color as a string, e.g. '#0000ffff' or 'blue'.
+ */
+ getCrosshairsColor() {
+ if (this._crossHairs) {
+ let clutterColor = this._crossHairs.getColor();
+ return clutterColor.to_string();
+ } else {
+ return '#00000000';
+ }
+ }
+
+ /**
+ * setCrosshairsThickness:
+ * Set the crosshairs thickness for all ZoomRegions.
+ * @param {number} thickness: The width of the vertical and
+ * horizontal lines of the crosshairs.
+ */
+ setCrosshairsThickness(thickness) {
+ if (this._crossHairs)
+ this._crossHairs.setThickness(thickness);
+ }
+
+ /**
+ * getCrosshairsThickness:
+ * Get the crosshairs thickness.
+ * @returns {number} The width of the vertical and horizontal
+ * lines of the crosshairs.
+ */
+ getCrosshairsThickness() {
+ if (this._crossHairs)
+ return this._crossHairs.getThickness();
+ else
+ return 0;
+ }
+
+ /**
+ * setCrosshairsOpacity:
+ * @param {number} opacity: Value between 0.0 (transparent)
+ * and 1.0 (fully opaque).
+ */
+ setCrosshairsOpacity(opacity) {
+ if (this._crossHairs)
+ this._crossHairs.setOpacity(opacity * 255);
+ }
+
+ /**
+ * getCrosshairsOpacity:
+ * @returns {number} Value between 0.0 (transparent) and 1.0 (fully opaque).
+ */
+ getCrosshairsOpacity() {
+ if (this._crossHairs)
+ return this._crossHairs.getOpacity() / 255.0;
+ else
+ return 0.0;
+ }
+
+ /**
+ * setCrosshairsLength:
+ * Set the crosshairs length for all ZoomRegions.
+ * @param {number} length: The length of the vertical and horizontal
+ * lines making up the crosshairs.
+ */
+ setCrosshairsLength(length) {
+ if (this._crossHairs) {
+ let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ this._crossHairs.setLength(length / scaleFactor);
+ }
+ }
+
+ /**
+ * getCrosshairsLength:
+ * Get the crosshairs length.
+ * @returns {number} The length of the vertical and horizontal
+ * lines making up the crosshairs.
+ */
+ getCrosshairsLength() {
+ if (this._crossHairs)
+ return this._crossHairs.getLength();
+ else
+ return 0;
+ }
+
+ /**
+ * setCrosshairsClip:
+ * Set whether the crosshairs are clipped at their intersection.
+ * @param {bool} clip: Flag to indicate whether to clip the crosshairs.
+ */
+ setCrosshairsClip(clip) {
+ if (!this._crossHairs)
+ return;
+
+ // Setting no clipping on crosshairs means a zero sized clip rectangle.
+ this._crossHairs.setClip(clip ? CROSSHAIRS_CLIP_SIZE : [0, 0]);
+ }
+
+ /**
+ * getCrosshairsClip:
+ * Get whether the crosshairs are clipped by the mouse image.
+ * @returns {bool} Whether the crosshairs are clipped.
+ */
+ getCrosshairsClip() {
+ if (this._crossHairs) {
+ let [clipWidth, clipHeight] = this._crossHairs.getClip();
+ return clipWidth > 0 && clipHeight > 0;
+ } else {
+ return false;
+ }
+ }
+
+ // Private methods //
+
+ _updateMouseSprite() {
+ this._updateSpriteTexture();
+ let [xHot, yHot] = this._cursorTracker.get_hot();
+ this._mouseSprite.set({
+ translation_x: -xHot,
+ translation_y: -yHot,
+ });
+ }
+
+ _updateSpriteTexture() {
+ let sprite = this._cursorTracker.get_sprite();
+
+ if (sprite) {
+ this._mouseSprite.content.texture = sprite;
+ this._mouseSprite.show();
+ } else {
+ this._mouseSprite.hide();
+ }
+ }
+
+ _settingsInit(zoomRegion) {
+ this._settings = new Gio.Settings({ schema_id: MAGNIFIER_SCHEMA });
+
+ this._settings.connect(`changed::${SCREEN_POSITION_KEY}`,
+ this._updateScreenPosition.bind(this));
+ this._settings.connect(`changed::${MAG_FACTOR_KEY}`,
+ this._updateMagFactor.bind(this));
+ this._settings.connect(`changed::${LENS_MODE_KEY}`,
+ this._updateLensMode.bind(this));
+ this._settings.connect(`changed::${CLAMP_MODE_KEY}`,
+ this._updateClampMode.bind(this));
+ this._settings.connect(`changed::${MOUSE_TRACKING_KEY}`,
+ this._updateMouseTrackingMode.bind(this));
+ this._settings.connect(`changed::${FOCUS_TRACKING_KEY}`,
+ this._updateFocusTrackingMode.bind(this));
+ this._settings.connect(`changed::${CARET_TRACKING_KEY}`,
+ this._updateCaretTrackingMode.bind(this));
+
+ this._settings.connect(`changed::${INVERT_LIGHTNESS_KEY}`,
+ this._updateInvertLightness.bind(this));
+ this._settings.connect(`changed::${COLOR_SATURATION_KEY}`,
+ this._updateColorSaturation.bind(this));
+
+ this._settings.connect(`changed::${BRIGHT_RED_KEY}`,
+ this._updateBrightness.bind(this));
+ this._settings.connect(`changed::${BRIGHT_GREEN_KEY}`,
+ this._updateBrightness.bind(this));
+ this._settings.connect(`changed::${BRIGHT_BLUE_KEY}`,
+ this._updateBrightness.bind(this));
+
+ this._settings.connect(`changed::${CONTRAST_RED_KEY}`,
+ this._updateContrast.bind(this));
+ this._settings.connect(`changed::${CONTRAST_GREEN_KEY}`,
+ this._updateContrast.bind(this));
+ this._settings.connect(`changed::${CONTRAST_BLUE_KEY}`,
+ this._updateContrast.bind(this));
+
+ this._settings.connect(`changed::${SHOW_CROSS_HAIRS_KEY}`, () => {
+ this.setCrosshairsVisible(this._settings.get_boolean(SHOW_CROSS_HAIRS_KEY));
+ });
+
+ this._settings.connect(`changed::${CROSS_HAIRS_THICKNESS_KEY}`, () => {
+ this.setCrosshairsThickness(this._settings.get_int(CROSS_HAIRS_THICKNESS_KEY));
+ });
+
+ this._settings.connect(`changed::${CROSS_HAIRS_COLOR_KEY}`, () => {
+ this.setCrosshairsColor(this._settings.get_string(CROSS_HAIRS_COLOR_KEY));
+ });
+
+ this._settings.connect(`changed::${CROSS_HAIRS_OPACITY_KEY}`, () => {
+ this.setCrosshairsOpacity(this._settings.get_double(CROSS_HAIRS_OPACITY_KEY));
+ });
+
+ this._settings.connect(`changed::${CROSS_HAIRS_LENGTH_KEY}`, () => {
+ this.setCrosshairsLength(this._settings.get_int(CROSS_HAIRS_LENGTH_KEY));
+ });
+
+ this._settings.connect(`changed::${CROSS_HAIRS_CLIP_KEY}`, () => {
+ this.setCrosshairsClip(this._settings.get_boolean(CROSS_HAIRS_CLIP_KEY));
+ });
+
+ if (zoomRegion) {
+ // Mag factor is accurate to two decimal places.
+ let aPref = parseFloat(this._settings.get_double(MAG_FACTOR_KEY).toFixed(2));
+ if (aPref != 0.0)
+ zoomRegion.setMagFactor(aPref, aPref);
+
+ aPref = this._settings.get_enum(SCREEN_POSITION_KEY);
+ if (aPref)
+ zoomRegion.setScreenPosition(aPref);
+
+ zoomRegion.setLensMode(this._settings.get_boolean(LENS_MODE_KEY));
+ zoomRegion.setClampScrollingAtEdges(!this._settings.get_boolean(CLAMP_MODE_KEY));
+
+ aPref = this._settings.get_enum(MOUSE_TRACKING_KEY);
+ if (aPref)
+ zoomRegion.setMouseTrackingMode(aPref);
+
+ aPref = this._settings.get_enum(FOCUS_TRACKING_KEY);
+ if (aPref)
+ zoomRegion.setFocusTrackingMode(aPref);
+
+ aPref = this._settings.get_enum(CARET_TRACKING_KEY);
+ if (aPref)
+ zoomRegion.setCaretTrackingMode(aPref);
+
+ aPref = this._settings.get_boolean(INVERT_LIGHTNESS_KEY);
+ if (aPref)
+ zoomRegion.setInvertLightness(aPref);
+
+ aPref = this._settings.get_double(COLOR_SATURATION_KEY);
+ if (aPref)
+ zoomRegion.setColorSaturation(aPref);
+
+ let bc = {};
+ bc.r = this._settings.get_double(BRIGHT_RED_KEY);
+ bc.g = this._settings.get_double(BRIGHT_GREEN_KEY);
+ bc.b = this._settings.get_double(BRIGHT_BLUE_KEY);
+ zoomRegion.setBrightness(bc);
+
+ bc.r = this._settings.get_double(CONTRAST_RED_KEY);
+ bc.g = this._settings.get_double(CONTRAST_GREEN_KEY);
+ bc.b = this._settings.get_double(CONTRAST_BLUE_KEY);
+ zoomRegion.setContrast(bc);
+ }
+
+ let showCrosshairs = this._settings.get_boolean(SHOW_CROSS_HAIRS_KEY);
+ this.addCrosshairs();
+ this.setCrosshairsVisible(showCrosshairs);
+ }
+
+ _updateScreenPosition() {
+ // Applies only to the first zoom region.
+ if (this._zoomRegions.length) {
+ let position = this._settings.get_enum(SCREEN_POSITION_KEY);
+ this._zoomRegions[0].setScreenPosition(position);
+ if (position != GDesktopEnums.MagnifierScreenPosition.FULL_SCREEN)
+ this._updateLensMode();
+ }
+ }
+
+ _updateMagFactor() {
+ // Applies only to the first zoom region.
+ if (this._zoomRegions.length) {
+ // Mag factor is accurate to two decimal places.
+ let magFactor = parseFloat(this._settings.get_double(MAG_FACTOR_KEY).toFixed(2));
+ this._zoomRegions[0].setMagFactor(magFactor, magFactor);
+ }
+ }
+
+ _updateLensMode() {
+ // Applies only to the first zoom region.
+ if (this._zoomRegions.length)
+ this._zoomRegions[0].setLensMode(this._settings.get_boolean(LENS_MODE_KEY));
+ }
+
+ _updateClampMode() {
+ // Applies only to the first zoom region.
+ if (this._zoomRegions.length) {
+ this._zoomRegions[0].setClampScrollingAtEdges(
+ !this._settings.get_boolean(CLAMP_MODE_KEY));
+ }
+ }
+
+ _updateMouseTrackingMode() {
+ // Applies only to the first zoom region.
+ if (this._zoomRegions.length) {
+ this._zoomRegions[0].setMouseTrackingMode(
+ this._settings.get_enum(MOUSE_TRACKING_KEY));
+ }
+ }
+
+ _updateFocusTrackingMode() {
+ // Applies only to the first zoom region.
+ if (this._zoomRegions.length) {
+ this._zoomRegions[0].setFocusTrackingMode(
+ this._settings.get_enum(FOCUS_TRACKING_KEY));
+ }
+ }
+
+ _updateCaretTrackingMode() {
+ // Applies only to the first zoom region.
+ if (this._zoomRegions.length) {
+ this._zoomRegions[0].setCaretTrackingMode(
+ this._settings.get_enum(CARET_TRACKING_KEY));
+ }
+ }
+
+ _updateInvertLightness() {
+ // Applies only to the first zoom region.
+ if (this._zoomRegions.length) {
+ this._zoomRegions[0].setInvertLightness(
+ this._settings.get_boolean(INVERT_LIGHTNESS_KEY));
+ }
+ }
+
+ _updateColorSaturation() {
+ // Applies only to the first zoom region.
+ if (this._zoomRegions.length) {
+ this._zoomRegions[0].setColorSaturation(
+ this._settings.get_double(COLOR_SATURATION_KEY));
+ }
+ }
+
+ _updateBrightness() {
+ // Applies only to the first zoom region.
+ if (this._zoomRegions.length) {
+ let brightness = {};
+ brightness.r = this._settings.get_double(BRIGHT_RED_KEY);
+ brightness.g = this._settings.get_double(BRIGHT_GREEN_KEY);
+ brightness.b = this._settings.get_double(BRIGHT_BLUE_KEY);
+ this._zoomRegions[0].setBrightness(brightness);
+ }
+ }
+
+ _updateContrast() {
+ // Applies only to the first zoom region.
+ if (this._zoomRegions.length) {
+ let contrast = {};
+ contrast.r = this._settings.get_double(CONTRAST_RED_KEY);
+ contrast.g = this._settings.get_double(CONTRAST_GREEN_KEY);
+ contrast.b = this._settings.get_double(CONTRAST_BLUE_KEY);
+ this._zoomRegions[0].setContrast(contrast);
+ }
+ }
+};
+Signals.addSignalMethods(Magnifier.prototype);
+
+var ZoomRegion = class ZoomRegion {
+ constructor(magnifier, mouseSourceActor) {
+ this._magnifier = magnifier;
+ this._focusCaretTracker = new FocusCaretTracker.FocusCaretTracker();
+
+ this._mouseTrackingMode = GDesktopEnums.MagnifierMouseTrackingMode.NONE;
+ this._focusTrackingMode = GDesktopEnums.MagnifierFocusTrackingMode.NONE;
+ this._caretTrackingMode = GDesktopEnums.MagnifierCaretTrackingMode.NONE;
+ this._clampScrollingAtEdges = false;
+ this._lensMode = false;
+ this._screenPosition = GDesktopEnums.MagnifierScreenPosition.FULL_SCREEN;
+ this._invertLightness = false;
+ this._colorSaturation = 1.0;
+ this._brightness = { r: NO_CHANGE, g: NO_CHANGE, b: NO_CHANGE };
+ this._contrast = { r: NO_CHANGE, g: NO_CHANGE, b: NO_CHANGE };
+
+ this._magView = null;
+ this._background = null;
+ this._uiGroupClone = null;
+ this._mouseSourceActor = mouseSourceActor;
+ this._mouseActor = null;
+ this._crossHairs = null;
+ this._crossHairsActor = null;
+
+ this._viewPortX = 0;
+ this._viewPortY = 0;
+ this._viewPortWidth = global.screen_width;
+ this._viewPortHeight = global.screen_height;
+ this._xCenter = this._viewPortWidth / 2;
+ this._yCenter = this._viewPortHeight / 2;
+ this._xMagFactor = 1;
+ this._yMagFactor = 1;
+ this._followingCursor = false;
+ this._xFocus = 0;
+ this._yFocus = 0;
+ this._xCaret = 0;
+ this._yCaret = 0;
+
+ this._pointerIdleMonitor = Meta.IdleMonitor.get_core();
+ this._scrollContentsTimerId = 0;
+ }
+
+ _connectSignals() {
+ if (this._signalConnections)
+ return;
+
+ this._signalConnections = [];
+ let id = Main.layoutManager.connect('monitors-changed',
+ this._monitorsChanged.bind(this));
+ this._signalConnections.push([Main.layoutManager, id]);
+
+ id = this._focusCaretTracker.connect('caret-moved', this._updateCaret.bind(this));
+ this._signalConnections.push([this._focusCaretTracker, id]);
+
+ id = this._focusCaretTracker.connect('focus-changed', this._updateFocus.bind(this));
+ this._signalConnections.push([this._focusCaretTracker, id]);
+ }
+
+ _disconnectSignals() {
+ for (let [obj, id] of this._signalConnections)
+ obj.disconnect(id);
+
+ delete this._signalConnections;
+ }
+
+ _updateScreenPosition() {
+ if (this._screenPosition == GDesktopEnums.MagnifierScreenPosition.NONE) {
+ this._setViewPort({
+ x: this._viewPortX,
+ y: this._viewPortY,
+ width: this._viewPortWidth,
+ height: this._viewPortHeight,
+ });
+ } else {
+ this.setScreenPosition(this._screenPosition);
+ }
+ }
+
+ _updateFocus(caller, event) {
+ let component = event.source.get_component_iface();
+ if (!component || event.detail1 != 1)
+ return;
+ let extents;
+ try {
+ extents = component.get_extents(Atspi.CoordType.SCREEN);
+ } catch (e) {
+ log(`Failed to read extents of focused component: ${e.message}`);
+ return;
+ }
+
+ let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ let [xFocus, yFocus] = [(extents.x + (extents.width / 2)) * scaleFactor,
+ (extents.y + (extents.height / 2)) * scaleFactor];
+
+ if (this._xFocus !== xFocus || this._yFocus !== yFocus) {
+ [this._xFocus, this._yFocus] = [xFocus, yFocus];
+ this._centerFromFocusPosition();
+ }
+ }
+
+ _updateCaret(caller, event) {
+ let text = event.source.get_text_iface();
+ if (!text)
+ return;
+ let extents;
+ try {
+ extents = text.get_character_extents(text.get_caret_offset(), 0);
+ } catch (e) {
+ log(`Failed to read extents of text caret: ${e.message}`);
+ return;
+ }
+
+ let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ let [xCaret, yCaret] = [extents.x * scaleFactor, extents.y * scaleFactor];
+
+ // Ignore event(s) if the caret size is none (0x0). This happens a lot if
+ // the cursor offset can't be translated into a location. This is a work
+ // around.
+ if (extents.width === 0 && extents.height === 0)
+ return;
+
+ if (this._xCaret !== xCaret || this._yCaret !== yCaret) {
+ [this._xCaret, this._yCaret] = [xCaret, yCaret];
+ this._centerFromCaretPosition();
+ }
+ }
+
+ /**
+ * setActive:
+ * @param {bool} activate: Boolean to show/hide the ZoomRegion.
+ */
+ setActive(activate) {
+ if (activate == this.isActive())
+ return;
+
+ if (activate) {
+ this._createActors();
+ if (this._isMouseOverRegion())
+ this._magnifier.hideSystemCursor();
+ this._updateScreenPosition();
+ this._updateMagViewGeometry();
+ this._updateCloneGeometry();
+ this._updateMousePosition();
+ this._connectSignals();
+ } else {
+ this._disconnectSignals();
+ this._destroyActors();
+ }
+
+ this._syncCaretTracking();
+ this._syncFocusTracking();
+ }
+
+ /**
+ * isActive:
+ * @returns {bool} Whether this ZoomRegion is active
+ */
+ isActive() {
+ return this._magView != null;
+ }
+
+ /**
+ * setMagFactor:
+ * @param {number} xMagFactor: The power to set the horizontal
+ * magnification factor to of the magnified view. A value of 1.0
+ * means no magnification. A value of 2.0 doubles the size.
+ * @param {number} yMagFactor: The power to set the vertical
+ * magnification factor to of the magnified view.
+ */
+ setMagFactor(xMagFactor, yMagFactor) {
+ this._changeROI({ xMagFactor,
+ yMagFactor,
+ redoCursorTracking: this._followingCursor,
+ animate: true });
+ }
+
+ /**
+ * getMagFactor:
+ * @returns {number[]} an array, [xMagFactor, yMagFactor], containing
+ * the horizontal and vertical magnification powers. A value of
+ * 1.0 means no magnification. A value of 2.0 means the contents
+ * are doubled in size, and so on.
+ */
+ getMagFactor() {
+ return [this._xMagFactor, this._yMagFactor];
+ }
+
+ /**
+ * setMouseTrackingMode
+ * @param {GDesktopEnums.MagnifierMouseTrackingMode} mode: the new mode
+ */
+ setMouseTrackingMode(mode) {
+ if (mode >= GDesktopEnums.MagnifierMouseTrackingMode.NONE &&
+ mode <= GDesktopEnums.MagnifierMouseTrackingMode.PUSH)
+ this._mouseTrackingMode = mode;
+ }
+
+ /**
+ * getMouseTrackingMode
+ * @returns {GDesktopEnums.MagnifierMouseTrackingMode} the current mode
+ */
+ getMouseTrackingMode() {
+ return this._mouseTrackingMode;
+ }
+
+ /**
+ * setFocusTrackingMode
+ * @param {GDesktopEnums.MagnifierFocusTrackingMode} mode: the new mode
+ */
+ setFocusTrackingMode(mode) {
+ this._focusTrackingMode = mode;
+ this._syncFocusTracking();
+ }
+
+ /**
+ * setCaretTrackingMode
+ * @param {GDesktopEnums.MagnifierCaretTrackingMode} mode: the new mode
+ */
+ setCaretTrackingMode(mode) {
+ this._caretTrackingMode = mode;
+ this._syncCaretTracking();
+ }
+
+ _syncFocusTracking() {
+ let enabled = this._focusTrackingMode != GDesktopEnums.MagnifierFocusTrackingMode.NONE &&
+ this.isActive();
+
+ if (enabled)
+ this._focusCaretTracker.registerFocusListener();
+ else
+ this._focusCaretTracker.deregisterFocusListener();
+ }
+
+ _syncCaretTracking() {
+ let enabled = this._caretTrackingMode != GDesktopEnums.MagnifierCaretTrackingMode.NONE &&
+ this.isActive();
+
+ if (enabled)
+ this._focusCaretTracker.registerCaretListener();
+ else
+ this._focusCaretTracker.deregisterCaretListener();
+ }
+
+ /**
+ * setViewPort
+ * Sets the position and size of the ZoomRegion on screen.
+ * @param {{x: number, y: number, width: number, height: number}} viewPort:
+ * Object defining the position and size of the view port.
+ * The values are in stage coordinate space.
+ */
+ setViewPort(viewPort) {
+ this._setViewPort(viewPort);
+ this._screenPosition = GDesktopEnums.MagnifierScreenPosition.NONE;
+ }
+
+ /**
+ * setROI
+ * Sets the "region of interest" that the ZoomRegion is magnifying.
+ * @param {{x: number, y: number, width: number, height: number}} roi:
+ * Object that defines the region of the screen to magnify.
+ * The values are in screen (unmagnified) coordinate space.
+ */
+ setROI(roi) {
+ if (roi.width <= 0 || roi.height <= 0)
+ return;
+
+ this._followingCursor = false;
+ this._changeROI({ xMagFactor: this._viewPortWidth / roi.width,
+ yMagFactor: this._viewPortHeight / roi.height,
+ xCenter: roi.x + roi.width / 2,
+ yCenter: roi.y + roi.height / 2 });
+ }
+
+ /**
+ * getROI:
+ * Retrieves the "region of interest" -- the rectangular bounds of that part
+ * of the desktop that the magnified view is showing (x, y, width, height).
+ * The bounds are given in non-magnified coordinates.
+ * @returns {number[]} an array, [x, y, width, height], representing
+ * the bounding rectangle of what is shown in the magnified view.
+ */
+ getROI() {
+ let roiWidth = this._viewPortWidth / this._xMagFactor;
+ let roiHeight = this._viewPortHeight / this._yMagFactor;
+
+ return [this._xCenter - roiWidth / 2,
+ this._yCenter - roiHeight / 2,
+ roiWidth, roiHeight];
+ }
+
+ /**
+ * setLensMode:
+ * Turn lens mode on/off. In full screen mode, lens mode does nothing since
+ * a lens the size of the screen is pointless.
+ * @param {bool} lensMode: Whether lensMode should be active
+ */
+ setLensMode(lensMode) {
+ this._lensMode = lensMode;
+ if (!this._lensMode)
+ this.setScreenPosition(this._screenPosition);
+ }
+
+ /**
+ * isLensMode:
+ * Is lens mode on or off?
+ * @returns {bool} The lens mode state.
+ */
+ isLensMode() {
+ return this._lensMode;
+ }
+
+ /**
+ * setClampScrollingAtEdges:
+ * Stop vs. allow scrolling of the magnified contents when it scroll beyond
+ * the edges of the screen.
+ * @param {bool} clamp: Boolean to turn on/off clamping.
+ */
+ setClampScrollingAtEdges(clamp) {
+ this._clampScrollingAtEdges = clamp;
+ if (clamp)
+ this._changeROI();
+ }
+
+ /**
+ * setTopHalf:
+ * Magnifier view occupies the top half of the screen.
+ */
+ setTopHalf() {
+ let viewPort = {};
+ viewPort.x = 0;
+ viewPort.y = 0;
+ viewPort.width = global.screen_width;
+ viewPort.height = global.screen_height / 2;
+ this._setViewPort(viewPort);
+ this._screenPosition = GDesktopEnums.MagnifierScreenPosition.TOP_HALF;
+ }
+
+ /**
+ * setBottomHalf:
+ * Magnifier view occupies the bottom half of the screen.
+ */
+ setBottomHalf() {
+ let viewPort = {};
+ viewPort.x = 0;
+ viewPort.y = global.screen_height / 2;
+ viewPort.width = global.screen_width;
+ viewPort.height = global.screen_height / 2;
+ this._setViewPort(viewPort);
+ this._screenPosition = GDesktopEnums.MagnifierScreenPosition.BOTTOM_HALF;
+ }
+
+ /**
+ * setLeftHalf:
+ * Magnifier view occupies the left half of the screen.
+ */
+ setLeftHalf() {
+ let viewPort = {};
+ viewPort.x = 0;
+ viewPort.y = 0;
+ viewPort.width = global.screen_width / 2;
+ viewPort.height = global.screen_height;
+ this._setViewPort(viewPort);
+ this._screenPosition = GDesktopEnums.MagnifierScreenPosition.LEFT_HALF;
+ }
+
+ /**
+ * setRightHalf:
+ * Magnifier view occupies the right half of the screen.
+ */
+ setRightHalf() {
+ let viewPort = {};
+ viewPort.x = global.screen_width / 2;
+ viewPort.y = 0;
+ viewPort.width = global.screen_width / 2;
+ viewPort.height = global.screen_height;
+ this._setViewPort(viewPort);
+ this._screenPosition = GDesktopEnums.MagnifierScreenPosition.RIGHT_HALF;
+ }
+
+ /**
+ * setFullScreenMode:
+ * Set the ZoomRegion to full-screen mode.
+ * Note: disallows lens mode.
+ */
+ setFullScreenMode() {
+ let viewPort = {};
+ viewPort.x = 0;
+ viewPort.y = 0;
+ viewPort.width = global.screen_width;
+ viewPort.height = global.screen_height;
+ this.setViewPort(viewPort);
+
+ this._screenPosition = GDesktopEnums.MagnifierScreenPosition.FULL_SCREEN;
+ }
+
+ /**
+ * setScreenPosition:
+ * Positions the zoom region to one of the enumerated positions on the
+ * screen.
+ * @param {GDesktopEnums.MagnifierScreenPosition} inPosition: the position
+ */
+ setScreenPosition(inPosition) {
+ switch (inPosition) {
+ case GDesktopEnums.MagnifierScreenPosition.FULL_SCREEN:
+ this.setFullScreenMode();
+ break;
+ case GDesktopEnums.MagnifierScreenPosition.TOP_HALF:
+ this.setTopHalf();
+ break;
+ case GDesktopEnums.MagnifierScreenPosition.BOTTOM_HALF:
+ this.setBottomHalf();
+ break;
+ case GDesktopEnums.MagnifierScreenPosition.LEFT_HALF:
+ this.setLeftHalf();
+ break;
+ case GDesktopEnums.MagnifierScreenPosition.RIGHT_HALF:
+ this.setRightHalf();
+ break;
+ }
+ }
+
+ /**
+ * getScreenPosition:
+ * Tell the outside world what the current mode is -- magnifiying the
+ * top half, bottom half, etc.
+ * @returns {GDesktopEnums.MagnifierScreenPosition}: the current position.
+ */
+ getScreenPosition() {
+ return this._screenPosition;
+ }
+
+ _clearScrollContentsTimer() {
+ if (this._scrollContentsTimerId !== 0) {
+ GLib.source_remove(this._scrollContentsTimerId);
+ this._scrollContentsTimerId = 0;
+ }
+ }
+
+ /**
+ * scrollToMousePos:
+ * Set the region of interest based on the position of the system pointer.
+ * @returns {bool}: Whether the system mouse pointer is over the
+ * magnified view.
+ */
+ scrollToMousePos() {
+ this._followingCursor = true;
+ if (this._mouseTrackingMode != GDesktopEnums.MagnifierMouseTrackingMode.NONE)
+ this._changeROI({ redoCursorTracking: true });
+ else
+ this._updateMousePosition();
+
+ this._clearScrollContentsTimer();
+ this._scrollContentsTimerId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, POINTER_REST_TIME, () => {
+ this._followingCursor = false;
+ if (this._xDelayed !== null && this._yDelayed !== null) {
+ this._scrollContentsToDelayed(this._xDelayed, this._yDelayed);
+ this._xDelayed = null;
+ this._yDelayed = null;
+ }
+
+ this._scrollContentsTimerId = 0;
+
+ return GLib.SOURCE_REMOVE;
+ });
+
+ // Determine whether the system mouse pointer is over this zoom region.
+ return this._isMouseOverRegion();
+ }
+
+ _scrollContentsToDelayed(x, y) {
+ if (this._followingCursor) {
+ this._xDelayed = x;
+ this._yDelayed = y;
+ } else {
+ this.scrollContentsTo(x, y);
+ }
+ }
+
+ /**
+ * scrollContentsTo:
+ * Shift the contents of the magnified view such it is centered on the given
+ * coordinate.
+ * @param {number} x: The x-coord of the point to center on.
+ * @param {number} y: The y-coord of the point to center on.
+ */
+ scrollContentsTo(x, y) {
+ if (x < 0 || x > global.screen_width ||
+ y < 0 || y > global.screen_height)
+ return;
+
+ this._clearScrollContentsTimer();
+
+ this._followingCursor = false;
+ this._changeROI({ xCenter: x,
+ yCenter: y,
+ animate: true });
+ }
+
+ /**
+ * addCrosshairs:
+ * Add crosshairs centered on the magnified mouse.
+ * @param {Crosshairs} crossHairs: Crosshairs instance
+ */
+ addCrosshairs(crossHairs) {
+ this._crossHairs = crossHairs;
+
+ // If the crossHairs is not already within a larger container, add it
+ // to this zoom region. Otherwise, add a clone.
+ if (crossHairs && this.isActive())
+ this._crossHairsActor = crossHairs.addToZoomRegion(this, this._mouseActor);
+ }
+
+ /**
+ * setInvertLightness:
+ * Set whether to invert the lightness of the magnified view.
+ * @param {bool} flag: whether brightness should be inverted
+ */
+ setInvertLightness(flag) {
+ this._invertLightness = flag;
+ if (this._magShaderEffects)
+ this._magShaderEffects.setInvertLightness(this._invertLightness);
+ }
+
+ /**
+ * getInvertLightness:
+ * Retrieve whether the lightness is inverted.
+ * @returns {bool} whether brightness should be inverted
+ */
+ getInvertLightness() {
+ return this._invertLightness;
+ }
+
+ /**
+ * setColorSaturation:
+ * Set the color saturation of the magnified view.
+ * @param {number} saturation: A value from 0.0 to 1.0 that defines
+ * the color saturation, with 0.0 defining no color (grayscale),
+ * and 1.0 defining full color.
+ */
+ setColorSaturation(saturation) {
+ this._colorSaturation = saturation;
+ if (this._magShaderEffects)
+ this._magShaderEffects.setColorSaturation(this._colorSaturation);
+ }
+
+ /**
+ * getColorSaturation:
+ * Retrieve the color saturation of the magnified view.
+ * @returns {number} the color saturation
+ */
+ getColorSaturation() {
+ return this._colorSaturation;
+ }
+
+ /**
+ * setBrightness:
+ * Alter the brightness of the magnified view.
+ * @param {Object} brightness: Object containing the contrast for the
+ * red, green, and blue channels. Values of 0.0 represent "standard"
+ * brightness (no change), whereas values less or greater than
+ * 0.0 indicate decreased or incresaed brightness, respectively.
+ *
+ * {number} brightness.r - the red component
+ * {number} brightness.g - the green component
+ * {number} brightness.b - the blue component
+ */
+ setBrightness(brightness) {
+ this._brightness.r = brightness.r;
+ this._brightness.g = brightness.g;
+ this._brightness.b = brightness.b;
+ if (this._magShaderEffects)
+ this._magShaderEffects.setBrightness(this._brightness);
+ }
+
+ /**
+ * setContrast:
+ * Alter the contrast of the magnified view.
+ * @param {Object} contrast: Object containing the contrast for the
+ * red, green, and blue channels. Values of 0.0 represent "standard"
+ * contrast (no change), whereas values less or greater than
+ * 0.0 indicate decreased or incresaed contrast, respectively.
+ *
+ * {number} contrast.r - the red component
+ * {number} contrast.g - the green component
+ * {number} contrast.b - the blue component
+ */
+ setContrast(contrast) {
+ this._contrast.r = contrast.r;
+ this._contrast.g = contrast.g;
+ this._contrast.b = contrast.b;
+ if (this._magShaderEffects)
+ this._magShaderEffects.setContrast(this._contrast);
+ }
+
+ /**
+ * getContrast:
+ * Retrieve the contrast of the magnified view.
+ * @returns {{r: number, g: number, b: number}}: Object containing
+ * the contrast for the red, green, and blue channels.
+ */
+ getContrast() {
+ let contrast = {};
+ contrast.r = this._contrast.r;
+ contrast.g = this._contrast.g;
+ contrast.b = this._contrast.b;
+ return contrast;
+ }
+
+ // Private methods //
+
+ _createActors() {
+ // The root actor for the zoom region
+ this._magView = new St.Bin({ style_class: 'magnifier-zoom-region' });
+ global.stage.add_actor(this._magView);
+
+ // hide the magnified region from CLUTTER_PICK_ALL
+ Shell.util_set_hidden_from_pick(this._magView, true);
+
+ // Add a group to clip the contents of the magnified view.
+ let mainGroup = new Clutter.Actor({ clip_to_allocation: true });
+ this._magView.set_child(mainGroup);
+
+ // Add a background for when the magnified uiGroup is scrolled
+ // out of view (don't want to see desktop showing through).
+ this._background = new Background.SystemBackground();
+ mainGroup.add_actor(this._background);
+
+ // Clone the group that contains all of UI on the screen. This is the
+ // chrome, the windows, etc.
+ this._uiGroupClone = new Clutter.Clone({ source: Main.uiGroup,
+ clip_to_allocation: true });
+ mainGroup.add_actor(this._uiGroupClone);
+
+ // Add either the given mouseSourceActor to the ZoomRegion, or a clone of
+ // it.
+ if (this._mouseSourceActor.get_parent() != null)
+ this._mouseActor = new Clutter.Clone({ source: this._mouseSourceActor });
+ else
+ this._mouseActor = this._mouseSourceActor;
+ mainGroup.add_actor(this._mouseActor);
+
+ if (this._crossHairs)
+ this._crossHairsActor = this._crossHairs.addToZoomRegion(this, this._mouseActor);
+ else
+ this._crossHairsActor = null;
+
+ // Contrast and brightness effects.
+ this._magShaderEffects = new MagShaderEffects(mainGroup);
+ this._magShaderEffects.setColorSaturation(this._colorSaturation);
+ this._magShaderEffects.setInvertLightness(this._invertLightness);
+ this._magShaderEffects.setBrightness(this._brightness);
+ this._magShaderEffects.setContrast(this._contrast);
+ }
+
+ _destroyActors() {
+ if (this._mouseActor == this._mouseSourceActor)
+ this._mouseActor.get_parent().remove_actor(this._mouseActor);
+ if (this._crossHairs)
+ this._crossHairs.removeFromParent(this._crossHairsActor);
+
+ this._magShaderEffects.destroyEffects();
+ this._magShaderEffects = null;
+ this._magView.destroy();
+ this._magView = null;
+ this._background = null;
+ this._uiGroupClone = null;
+ this._mouseActor = null;
+ this._crossHairsActor = null;
+ }
+
+ _setViewPort(viewPort, fromROIUpdate) {
+ // Sets the position of the zoom region on the screen
+
+ let width = Math.round(Math.min(viewPort.width, global.screen_width));
+ let height = Math.round(Math.min(viewPort.height, global.screen_height));
+ let x = Math.max(viewPort.x, 0);
+ let y = Math.max(viewPort.y, 0);
+
+ x = Math.round(Math.min(x, global.screen_width - width));
+ y = Math.round(Math.min(y, global.screen_height - height));
+
+ this._viewPortX = x;
+ this._viewPortY = y;
+ this._viewPortWidth = width;
+ this._viewPortHeight = height;
+
+ this._updateMagViewGeometry();
+
+ if (!fromROIUpdate)
+ this._changeROI({ redoCursorTracking: this._followingCursor }); // will update mouse
+
+ if (this.isActive() && this._isMouseOverRegion())
+ this._magnifier.hideSystemCursor();
+ }
+
+ _changeROI(params) {
+ // Updates the area we are viewing; the magnification factors
+ // and center can be set explicitly, or we can recompute
+ // the position based on the mouse cursor position
+
+ params = Params.parse(params, { xMagFactor: this._xMagFactor,
+ yMagFactor: this._yMagFactor,
+ xCenter: this._xCenter,
+ yCenter: this._yCenter,
+ redoCursorTracking: false,
+ animate: false });
+
+ if (params.xMagFactor <= 0)
+ params.xMagFactor = this._xMagFactor;
+ if (params.yMagFactor <= 0)
+ params.yMagFactor = this._yMagFactor;
+
+ this._xMagFactor = params.xMagFactor;
+ this._yMagFactor = params.yMagFactor;
+
+ if (params.redoCursorTracking &&
+ this._mouseTrackingMode != GDesktopEnums.MagnifierMouseTrackingMode.NONE) {
+ // This depends on this.xMagFactor/yMagFactor already being updated
+ [params.xCenter, params.yCenter] = this._centerFromMousePosition();
+ }
+
+ if (this._clampScrollingAtEdges) {
+ let roiWidth = this._viewPortWidth / this._xMagFactor;
+ let roiHeight = this._viewPortHeight / this._yMagFactor;
+
+ params.xCenter = Math.min(params.xCenter, global.screen_width - roiWidth / 2);
+ params.xCenter = Math.max(params.xCenter, roiWidth / 2);
+ params.yCenter = Math.min(params.yCenter, global.screen_height - roiHeight / 2);
+ params.yCenter = Math.max(params.yCenter, roiHeight / 2);
+ }
+
+ this._xCenter = params.xCenter;
+ this._yCenter = params.yCenter;
+
+ // If in lens mode, move the magnified view such that it is centered
+ // over the actual mouse. However, in full screen mode, the "lens" is
+ // the size of the screen -- pointless to move such a large lens around.
+ if (this._lensMode && !this._isFullScreen()) {
+ this._setViewPort({ x: this._xCenter - this._viewPortWidth / 2,
+ y: this._yCenter - this._viewPortHeight / 2,
+ width: this._viewPortWidth,
+ height: this._viewPortHeight }, true);
+ }
+
+ this._updateCloneGeometry(params.animate);
+ }
+
+ _isMouseOverRegion() {
+ // Return whether the system mouse sprite is over this ZoomRegion. If the
+ // mouse's position is not given, then it is fetched.
+ let mouseIsOver = false;
+ if (this.isActive()) {
+ let xMouse = this._magnifier.xMouse;
+ let yMouse = this._magnifier.yMouse;
+
+ mouseIsOver =
+ xMouse >= this._viewPortX && xMouse < (this._viewPortX + this._viewPortWidth) &&
+ yMouse >= this._viewPortY && yMouse < (this._viewPortY + this._viewPortHeight);
+ }
+ return mouseIsOver;
+ }
+
+ _isFullScreen() {
+ // Does the magnified view occupy the whole screen? Note that this
+ // doesn't necessarily imply
+ // this._screenPosition = GDesktopEnums.MagnifierScreenPosition.FULL_SCREEN;
+
+ if (this._viewPortX != 0 || this._viewPortY != 0)
+ return false;
+ if (this._viewPortWidth != global.screen_width ||
+ this._viewPortHeight != global.screen_height)
+ return false;
+ return true;
+ }
+
+ _centerFromMousePosition() {
+ // Determines where the center should be given the current cursor
+ // position and mouse tracking mode
+
+ let xMouse = this._magnifier.xMouse;
+ let yMouse = this._magnifier.yMouse;
+
+ if (this._mouseTrackingMode == GDesktopEnums.MagnifierMouseTrackingMode.PROPORTIONAL)
+ return this._centerFromPointProportional(xMouse, yMouse);
+ else if (this._mouseTrackingMode == GDesktopEnums.MagnifierMouseTrackingMode.PUSH)
+ return this._centerFromPointPush(xMouse, yMouse);
+ else if (this._mouseTrackingMode == GDesktopEnums.MagnifierMouseTrackingMode.CENTERED)
+ return this._centerFromPointCentered(xMouse, yMouse);
+
+ return null; // Should never be hit
+ }
+
+ _centerFromCaretPosition() {
+ let xCaret = this._xCaret;
+ let yCaret = this._yCaret;
+
+ if (this._caretTrackingMode == GDesktopEnums.MagnifierCaretTrackingMode.PROPORTIONAL)
+ [xCaret, yCaret] = this._centerFromPointProportional(xCaret, yCaret);
+ else if (this._caretTrackingMode == GDesktopEnums.MagnifierCaretTrackingMode.PUSH)
+ [xCaret, yCaret] = this._centerFromPointPush(xCaret, yCaret);
+ else if (this._caretTrackingMode == GDesktopEnums.MagnifierCaretTrackingMode.CENTERED)
+ [xCaret, yCaret] = this._centerFromPointCentered(xCaret, yCaret);
+
+ this._scrollContentsToDelayed(xCaret, yCaret);
+ }
+
+ _centerFromFocusPosition() {
+ let xFocus = this._xFocus;
+ let yFocus = this._yFocus;
+
+ if (this._focusTrackingMode == GDesktopEnums.MagnifierFocusTrackingMode.PROPORTIONAL)
+ [xFocus, yFocus] = this._centerFromPointProportional(xFocus, yFocus);
+ else if (this._focusTrackingMode == GDesktopEnums.MagnifierFocusTrackingMode.PUSH)
+ [xFocus, yFocus] = this._centerFromPointPush(xFocus, yFocus);
+ else if (this._focusTrackingMode == GDesktopEnums.MagnifierFocusTrackingMode.CENTERED)
+ [xFocus, yFocus] = this._centerFromPointCentered(xFocus, yFocus);
+
+ this._scrollContentsToDelayed(xFocus, yFocus);
+ }
+
+ _centerFromPointPush(xPoint, yPoint) {
+ let [xRoi, yRoi, widthRoi, heightRoi] = this.getROI();
+ let [cursorWidth, cursorHeight] = this._mouseSourceActor.get_size();
+ let xPos = xRoi + widthRoi / 2;
+ let yPos = yRoi + heightRoi / 2;
+ let xRoiRight = xRoi + widthRoi - cursorWidth;
+ let yRoiBottom = yRoi + heightRoi - cursorHeight;
+
+ if (xPoint < xRoi)
+ xPos -= xRoi - xPoint;
+ else if (xPoint > xRoiRight)
+ xPos += xPoint - xRoiRight;
+
+ if (yPoint < yRoi)
+ yPos -= yRoi - yPoint;
+ else if (yPoint > yRoiBottom)
+ yPos += yPoint - yRoiBottom;
+
+ return [xPos, yPos];
+ }
+
+ _centerFromPointProportional(xPoint, yPoint) {
+ let [xRoi_, yRoi_, widthRoi, heightRoi] = this.getROI();
+ let halfScreenWidth = global.screen_width / 2;
+ let halfScreenHeight = global.screen_height / 2;
+ // We want to pad with a constant distance after zooming, so divide
+ // by the magnification factor.
+ let unscaledPadding = Math.min(this._viewPortWidth, this._viewPortHeight) / 5;
+ let xPadding = unscaledPadding / this._xMagFactor;
+ let yPadding = unscaledPadding / this._yMagFactor;
+ let xProportion = (xPoint - halfScreenWidth) / halfScreenWidth; // -1 ... 1
+ let yProportion = (yPoint - halfScreenHeight) / halfScreenHeight; // -1 ... 1
+ let xPos = xPoint - xProportion * (widthRoi / 2 - xPadding);
+ let yPos = yPoint - yProportion * (heightRoi / 2 - yPadding);
+
+ return [xPos, yPos];
+ }
+
+ _centerFromPointCentered(xPoint, yPoint) {
+ return [xPoint, yPoint];
+ }
+
+ _screenToViewPort(screenX, screenY) {
+ // Converts coordinates relative to the (unmagnified) screen to coordinates
+ // relative to the origin of this._magView
+ return [this._viewPortWidth / 2 + (screenX - this._xCenter) * this._xMagFactor,
+ this._viewPortHeight / 2 + (screenY - this._yCenter) * this._yMagFactor];
+ }
+
+ _updateMagViewGeometry() {
+ if (!this.isActive())
+ return;
+
+ if (this._isFullScreen())
+ this._magView.add_style_class_name('full-screen');
+ else
+ this._magView.remove_style_class_name('full-screen');
+
+ this._magView.set_size(this._viewPortWidth, this._viewPortHeight);
+ this._magView.set_position(this._viewPortX, this._viewPortY);
+ }
+
+ _updateCloneGeometry(animate = false) {
+ if (!this.isActive())
+ return;
+
+ let [x, y] = this._screenToViewPort(0, 0);
+ this._uiGroupClone.ease({
+ x: Math.round(x),
+ y: Math.round(y),
+ scale_x: this._xMagFactor,
+ scale_y: this._yMagFactor,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: animate ? 100 : 0,
+ });
+
+ let [mouseX, mouseY] = this._getMousePosition();
+ this._mouseActor.ease({
+ x: mouseX,
+ y: mouseY,
+ scale_x: this._xMagFactor,
+ scale_y: this._yMagFactor,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: animate ? 100 : 0,
+ });
+
+ if (this._crossHairsActor) {
+ let [crossX, crossY] = this._getCrossHairsPosition();
+ this._crossHairsActor.ease({
+ x: crossX,
+ y: crossY,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: animate ? 100 : 0,
+ });
+ }
+ }
+
+ _updateMousePosition() {
+ let [xMagMouse, yMagMouse] = this._getMousePosition();
+ this._mouseActor.set_position(xMagMouse, yMagMouse);
+
+ if (this._crossHairsActor) {
+ let [crossX, crossY] = this._getCrossHairsPosition();
+ this._crossHairsActor.set_position(crossX, crossY);
+ }
+ }
+
+ _getMousePosition() {
+ let [xMagMouse, yMagMouse] = this._screenToViewPort(
+ this._magnifier.xMouse, this._magnifier.yMouse);
+ return [Math.round(xMagMouse), Math.round(yMagMouse)];
+ }
+
+ _getCrossHairsPosition() {
+ let [xMagMouse, yMagMouse] = this._getMousePosition();
+ let [groupWidth, groupHeight] = this._crossHairsActor.get_size();
+
+ return [xMagMouse - groupWidth / 2, yMagMouse - groupHeight / 2];
+ }
+
+ _monitorsChanged() {
+ this._background.set_size(global.screen_width, global.screen_height);
+ this._updateScreenPosition();
+ }
+};
+
+var Crosshairs = GObject.registerClass(
+class Crosshairs extends Clutter.Actor {
+ _init() {
+
+ // Set the group containing the crosshairs to three times the desktop
+ // size in case the crosshairs need to appear to be infinite in
+ // length (i.e., extend beyond the edges of the view they appear in).
+ let groupWidth = global.screen_width * 3;
+ let groupHeight = global.screen_height * 3;
+
+ super._init({
+ clip_to_allocation: false,
+ width: groupWidth,
+ height: groupHeight,
+ });
+ this._horizLeftHair = new Clutter.Actor();
+ this._horizRightHair = new Clutter.Actor();
+ this._vertTopHair = new Clutter.Actor();
+ this._vertBottomHair = new Clutter.Actor();
+ this.add_actor(this._horizLeftHair);
+ this.add_actor(this._horizRightHair);
+ this.add_actor(this._vertTopHair);
+ this.add_actor(this._vertBottomHair);
+ this._clipSize = [0, 0];
+ this._clones = [];
+ this.reCenter();
+ this._monitorsChangedId = 0;
+ }
+
+ _monitorsChanged() {
+ this.set_size(global.screen_width * 3, global.screen_height * 3);
+ this.reCenter();
+ }
+
+ setEnabled(enabled) {
+ if (enabled && this._monitorsChangedId === 0) {
+ this._monitorsChangedId = Main.layoutManager.connect(
+ 'monitors-changed', this._monitorsChanged.bind(this));
+ } else if (!enabled && this._monitorsChangedId !== 0) {
+ Main.layoutManager.disconnect(this._monitorsChangedId);
+ this._monitorsChangedId = 0;
+ }
+ }
+
+ /**
+ * addToZoomRegion
+ * Either add the crosshairs actor to the given ZoomRegion, or, if it is
+ * already part of some other ZoomRegion, create a clone of the crosshairs
+ * actor, and add the clone instead. Returns either the original or the
+ * clone.
+ * @param {ZoomRegion} zoomRegion: The container to add the crosshairs
+ * group to.
+ * @param {Clutter.Actor} magnifiedMouse: The mouse actor for the
+ * zoom region -- used to position the crosshairs and properly
+ * layer them below the mouse.
+ * @returns {Clutter.Actor} The crosshairs actor, or its clone.
+ */
+ addToZoomRegion(zoomRegion, magnifiedMouse) {
+ let crosshairsActor = null;
+ if (zoomRegion && magnifiedMouse) {
+ let container = magnifiedMouse.get_parent();
+ if (container) {
+ crosshairsActor = this;
+ if (this.get_parent() != null) {
+ crosshairsActor = new Clutter.Clone({ source: this });
+ this._clones.push(crosshairsActor);
+
+ // Clones don't share visibility.
+ this.bind_property('visible', crosshairsActor, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+ }
+
+ container.add_actor(crosshairsActor);
+ container.set_child_above_sibling(magnifiedMouse, crosshairsActor);
+ let [xMouse, yMouse] = magnifiedMouse.get_position();
+ let [crosshairsWidth, crosshairsHeight] = crosshairsActor.get_size();
+ crosshairsActor.set_position(xMouse - crosshairsWidth / 2, yMouse - crosshairsHeight / 2);
+ }
+ }
+ return crosshairsActor;
+ }
+
+ /**
+ * removeFromParent:
+ * @param {Clutter.Actor} childActor: the actor returned from
+ * addToZoomRegion
+ * Remove the crosshairs actor from its parent container, or destroy the
+ * child actor if it was just a clone of the crosshairs actor.
+ */
+ removeFromParent(childActor) {
+ if (childActor == this)
+ childActor.get_parent().remove_actor(childActor);
+ else
+ childActor.destroy();
+ }
+
+ /**
+ * setColor:
+ * Set the color of the crosshairs.
+ * @param {Clutter.Color} clutterColor: The color
+ */
+ setColor(clutterColor) {
+ this._horizLeftHair.background_color = clutterColor;
+ this._horizRightHair.background_color = clutterColor;
+ this._vertTopHair.background_color = clutterColor;
+ this._vertBottomHair.background_color = clutterColor;
+ }
+
+ /**
+ * getColor:
+ * Get the color of the crosshairs.
+ * @returns {ClutterColor} the crosshairs color
+ */
+ getColor() {
+ return this._horizLeftHair.get_color();
+ }
+
+ /**
+ * setThickness:
+ * Set the width of the vertical and horizontal lines of the crosshairs.
+ * @param {number} thickness: the new thickness value
+ */
+ setThickness(thickness) {
+ this._horizLeftHair.set_height(thickness);
+ this._horizRightHair.set_height(thickness);
+ this._vertTopHair.set_width(thickness);
+ this._vertBottomHair.set_width(thickness);
+ this.reCenter();
+ }
+
+ /**
+ * getThickness:
+ * Get the width of the vertical and horizontal lines of the crosshairs.
+ * @returns {number} The thickness of the crosshairs.
+ */
+ getThickness() {
+ return this._horizLeftHair.get_height();
+ }
+
+ /**
+ * setOpacity:
+ * Set how opaque the crosshairs are.
+ * @param {number} opacity: Value between 0 (fully transparent)
+ * and 255 (full opaque).
+ */
+ setOpacity(opacity) {
+ // set_opacity() throws an exception for values outside the range
+ // [0, 255].
+ if (opacity < 0)
+ opacity = 0;
+ else if (opacity > 255)
+ opacity = 255;
+
+ this._horizLeftHair.set_opacity(opacity);
+ this._horizRightHair.set_opacity(opacity);
+ this._vertTopHair.set_opacity(opacity);
+ this._vertBottomHair.set_opacity(opacity);
+ }
+
+ /**
+ * setLength:
+ * Set the length of the vertical and horizontal lines in the crosshairs.
+ * @param {number} length: The length of the crosshairs.
+ */
+ setLength(length) {
+ this._horizLeftHair.set_width(length);
+ this._horizRightHair.set_width(length);
+ this._vertTopHair.set_height(length);
+ this._vertBottomHair.set_height(length);
+ this.reCenter();
+ }
+
+ /**
+ * getLength:
+ * Get the length of the vertical and horizontal lines in the crosshairs.
+ * @returns {number} The length of the crosshairs.
+ */
+ getLength() {
+ return this._horizLeftHair.get_width();
+ }
+
+ /**
+ * setClip:
+ * Set the width and height of the rectangle that clips the crosshairs at
+ * their intersection
+ * @param {number[]} size: Array of [width, height] defining the size
+ * of the clip rectangle.
+ */
+ setClip(size) {
+ if (size) {
+ // Take a chunk out of the crosshairs where it intersects the
+ // mouse.
+ this._clipSize = size;
+ this.reCenter();
+ } else {
+ // Restore the missing chunk.
+ this._clipSize = [0, 0];
+ this.reCenter();
+ }
+ }
+
+ /**
+ * reCenter:
+ * Reposition the horizontal and vertical hairs such that they cross at
+ * the center of crosshairs group. If called with the dimensions of
+ * the clip rectangle, these are used to update the size of the clip.
+ * @param {number[]=} clipSize: If present, the clip's [width, height].
+ */
+ reCenter(clipSize) {
+ let [groupWidth, groupHeight] = this.get_size();
+ let leftLength = this._horizLeftHair.get_width();
+ let topLength = this._vertTopHair.get_height();
+ let thickness = this._horizLeftHair.get_height();
+
+ // Deal with clip rectangle.
+ if (clipSize)
+ this._clipSize = clipSize;
+ let clipWidth = this._clipSize[0];
+ let clipHeight = this._clipSize[1];
+
+ let left = groupWidth / 2 - clipWidth / 2 - leftLength - thickness / 2;
+ let right = groupWidth / 2 + clipWidth / 2 + thickness / 2;
+ let top = groupHeight / 2 - clipHeight / 2 - topLength - thickness / 2;
+ let bottom = groupHeight / 2 + clipHeight / 2 + thickness / 2;
+ this._horizLeftHair.set_position(left, (groupHeight - thickness) / 2);
+ this._horizRightHair.set_position(right, (groupHeight - thickness) / 2);
+ this._vertTopHair.set_position((groupWidth - thickness) / 2, top);
+ this._vertBottomHair.set_position((groupWidth - thickness) / 2, bottom);
+ }
+});
+
+var MagShaderEffects = class MagShaderEffects {
+ constructor(uiGroupClone) {
+ this._inverse = new Shell.InvertLightnessEffect();
+ this._brightnessContrast = new Clutter.BrightnessContrastEffect();
+ this._colorDesaturation = new Clutter.DesaturateEffect();
+ this._inverse.set_enabled(false);
+ this._brightnessContrast.set_enabled(false);
+
+ this._magView = uiGroupClone;
+ this._magView.add_effect(this._inverse);
+ this._magView.add_effect(this._brightnessContrast);
+ this._magView.add_effect(this._colorDesaturation);
+ }
+
+ /**
+ * destroyEffects:
+ * Remove contrast and brightness effects from the magnified view, and
+ * lose the reference to the actor they were applied to. Don't use this
+ * object after calling this.
+ */
+ destroyEffects() {
+ this._magView.clear_effects();
+ this._colorDesaturation = null;
+ this._brightnessContrast = null;
+ this._inverse = null;
+ this._magView = null;
+ }
+
+ /**
+ * setInvertLightness:
+ * Enable/disable invert lightness effect.
+ * @param {bool} invertFlag: Enabled flag.
+ */
+ setInvertLightness(invertFlag) {
+ this._inverse.set_enabled(invertFlag);
+ }
+
+ setColorSaturation(factor) {
+ this._colorDesaturation.set_factor(1.0 - factor);
+ }
+
+ /**
+ * setBrightness:
+ * Set the brightness of the magnified view.
+ * @param {Object} brightness: Object containing the contrast for the
+ * red, green, and blue channels. Values of 0.0 represent "standard"
+ * brightness (no change), whereas values less or greater than
+ * 0.0 indicate decreased or incresaed brightness, respectively.
+ *
+ * {number} brightness.r - the red component
+ * {number} brightness.g - the green component
+ * {number} brightness.b - the blue component
+ */
+ setBrightness(brightness) {
+ let bRed = brightness.r;
+ let bGreen = brightness.g;
+ let bBlue = brightness.b;
+ this._brightnessContrast.set_brightness_full(bRed, bGreen, bBlue);
+
+ // Enable the effect if the brightness OR contrast change are such that
+ // it modifies the brightness and/or contrast.
+ let [cRed, cGreen, cBlue] = this._brightnessContrast.get_contrast();
+ this._brightnessContrast.set_enabled(
+ bRed !== NO_CHANGE || bGreen !== NO_CHANGE || bBlue !== NO_CHANGE ||
+ cRed !== NO_CHANGE || cGreen !== NO_CHANGE || cBlue !== NO_CHANGE);
+ }
+
+ /**
+ * Set the contrast of the magnified view.
+ * @param {Object} contrast: Object containing the contrast for the
+ * red, green, and blue channels. Values of 0.0 represent "standard"
+ * contrast (no change), whereas values less or greater than
+ * 0.0 indicate decreased or incresaed contrast, respectively.
+ *
+ * {number} contrast.r - the red component
+ * {number} contrast.g - the green component
+ * {number} contrast.b - the blue component
+ */
+ setContrast(contrast) {
+ let cRed = contrast.r;
+ let cGreen = contrast.g;
+ let cBlue = contrast.b;
+
+ this._brightnessContrast.set_contrast_full(cRed, cGreen, cBlue);
+
+ // Enable the effect if the contrast OR brightness change are such that
+ // it modifies the brightness and/or contrast.
+ // should be able to use Clutter.color_equal(), but that complains of
+ // a null first argument.
+ let [bRed, bGreen, bBlue] = this._brightnessContrast.get_brightness();
+ this._brightnessContrast.set_enabled(
+ cRed !== NO_CHANGE || cGreen !== NO_CHANGE || cBlue !== NO_CHANGE ||
+ bRed !== NO_CHANGE || bGreen !== NO_CHANGE || bBlue !== NO_CHANGE);
+ }
+};
diff --git a/js/ui/magnifierDBus.js b/js/ui/magnifierDBus.js
new file mode 100644
index 0000000..6f962d1
--- /dev/null
+++ b/js/ui/magnifierDBus.js
@@ -0,0 +1,351 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported ShellMagnifier */
+
+const Gio = imports.gi.Gio;
+const Main = imports.ui.main;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+const MAG_SERVICE_PATH = '/org/gnome/Magnifier';
+const ZOOM_SERVICE_PATH = '/org/gnome/Magnifier/ZoomRegion';
+
+// Subset of gnome-mag's Magnifier dbus interface -- to be expanded. See:
+// http://git.gnome.org/browse/gnome-mag/tree/xml/...Magnifier.xml
+const MagnifierIface = loadInterfaceXML('org.gnome.Magnifier');
+
+// Subset of gnome-mag's ZoomRegion dbus interface -- to be expanded. See:
+// http://git.gnome.org/browse/gnome-mag/tree/xml/...ZoomRegion.xml
+const ZoomRegionIface = loadInterfaceXML('org.gnome.Magnifier.ZoomRegion');
+
+// For making unique ZoomRegion DBus proxy object paths of the form:
+// '/org/gnome/Magnifier/ZoomRegion/zoomer0',
+// '/org/gnome/Magnifier/ZoomRegion/zoomer1', etc.
+let _zoomRegionInstanceCount = 0;
+
+var ShellMagnifier = class ShellMagnifier {
+ constructor() {
+ this._zoomers = {};
+
+ this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(MagnifierIface, this);
+ this._dbusImpl.export(Gio.DBus.session, MAG_SERVICE_PATH);
+ }
+
+ /**
+ * setActive:
+ * @param {bool} activate: activate or de-activate the magnifier.
+ */
+ setActive(activate) {
+ Main.magnifier.setActive(activate);
+ }
+
+ /**
+ * isActive:
+ * @returns {bool} Whether the magnifier is active.
+ */
+ isActive() {
+ return Main.magnifier.isActive();
+ }
+
+ /**
+ * showCursor:
+ * Show the system mouse pointer.
+ */
+ showCursor() {
+ Main.magnifier.showSystemCursor();
+ }
+
+ /**
+ * hideCursor:
+ * Hide the system mouse pointer.
+ */
+ hideCursor() {
+ Main.magnifier.hideSystemCursor();
+ }
+
+ /**
+ * createZoomRegion:
+ * Create a new ZoomRegion and return its object path.
+ * @param {number} xMagFactor:
+ * The power to set horizontal magnification of the ZoomRegion.
+ * A value of 1.0 means no magnification. A value of 2.0 doubles
+ * the size.
+ * @param {number} yMagFactor:
+ * The power to set the vertical magnification of the
+ * ZoomRegion.
+ * @param {number[]} roi
+ * Array of integers defining the region of the screen/desktop
+ * to magnify. The array has the form [left, top, right, bottom].
+ * @param {number[]} viewPort
+ * Array of integers, [left, top, right, bottom] that defines
+ * the position of the ZoomRegion on screen.
+ *
+ * FIXME: The arguments here are redundant, since the width and height of
+ * the ROI are determined by the viewport and magnification factors.
+ * We ignore the passed in width and height.
+ *
+ * @returns {ZoomRegion} The newly created ZoomRegion.
+ */
+ createZoomRegion(xMagFactor, yMagFactor, roi, viewPort) {
+ let ROI = { x: roi[0], y: roi[1], width: roi[2] - roi[0], height: roi[3] - roi[1] };
+ let viewBox = { x: viewPort[0], y: viewPort[1], width: viewPort[2] - viewPort[0], height: viewPort[3] - viewPort[1] };
+ let realZoomRegion = Main.magnifier.createZoomRegion(xMagFactor, yMagFactor, ROI, viewBox);
+ let objectPath = `${ZOOM_SERVICE_PATH}/zoomer${_zoomRegionInstanceCount}`;
+ _zoomRegionInstanceCount++;
+
+ let zoomRegionProxy = new ShellMagnifierZoomRegion(objectPath, realZoomRegion);
+ let proxyAndZoomRegion = {};
+ proxyAndZoomRegion.proxy = zoomRegionProxy;
+ proxyAndZoomRegion.zoomRegion = realZoomRegion;
+ this._zoomers[objectPath] = proxyAndZoomRegion;
+ return objectPath;
+ }
+
+ /**
+ * addZoomRegion:
+ * Append the given ZoomRegion to the magnifier's list of ZoomRegions.
+ * @param {string} zoomerObjectPath: The object path for the zoom
+ * region proxy.
+ * @returns {bool} whether the region was added successfully
+ */
+ addZoomRegion(zoomerObjectPath) {
+ let proxyAndZoomRegion = this._zoomers[zoomerObjectPath];
+ if (proxyAndZoomRegion && proxyAndZoomRegion.zoomRegion) {
+ Main.magnifier.addZoomRegion(proxyAndZoomRegion.zoomRegion);
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * getZoomRegions:
+ * Return a list of ZoomRegion object paths for this Magnifier.
+ * @returns {string[]}: The Magnifier's zoom region list as an array
+ * of DBus object paths.
+ */
+ getZoomRegions() {
+ // There may be more ZoomRegions in the magnifier itself than have
+ // been added through dbus. Make sure all of them are associated with
+ // an object path and proxy.
+ let zoomRegions = Main.magnifier.getZoomRegions();
+ let objectPaths = [];
+ let thoseZoomers = this._zoomers;
+ zoomRegions.forEach(aZoomRegion => {
+ let found = false;
+ for (let objectPath in thoseZoomers) {
+ let proxyAndZoomRegion = thoseZoomers[objectPath];
+ if (proxyAndZoomRegion.zoomRegion === aZoomRegion) {
+ objectPaths.push(objectPath);
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ // Got a ZoomRegion with no DBus proxy, make one.
+ let newPath = `${ZOOM_SERVICE_PATH}/zoomer${_zoomRegionInstanceCount}`;
+ _zoomRegionInstanceCount++;
+ let zoomRegionProxy = new ShellMagnifierZoomRegion(newPath, aZoomRegion);
+ let proxyAndZoomer = {};
+ proxyAndZoomer.proxy = zoomRegionProxy;
+ proxyAndZoomer.zoomRegion = aZoomRegion;
+ thoseZoomers[newPath] = proxyAndZoomer;
+ objectPaths.push(newPath);
+ }
+ });
+ return objectPaths;
+ }
+
+ /**
+ * clearAllZoomRegions:
+ * Remove all the zoom regions from this Magnfier's ZoomRegion list.
+ */
+ clearAllZoomRegions() {
+ Main.magnifier.clearAllZoomRegions();
+ for (let objectPath in this._zoomers) {
+ let proxyAndZoomer = this._zoomers[objectPath];
+ proxyAndZoomer.proxy.destroy();
+ proxyAndZoomer.proxy = null;
+ proxyAndZoomer.zoomRegion = null;
+ delete this._zoomers[objectPath];
+ }
+ this._zoomers = {};
+ }
+
+ /**
+ * fullScreenCapable:
+ * Consult if the Magnifier can magnify in full-screen mode.
+ * @returns {bool} Always return true.
+ */
+ fullScreenCapable() {
+ return true;
+ }
+
+ /**
+ * setCrosswireSize:
+ * Set the crosswire size of all ZoomRegions.
+ * @param {number} size: The thickness of each line in the cross wire.
+ */
+ setCrosswireSize(size) {
+ Main.magnifier.setCrosshairsThickness(size);
+ }
+
+ /**
+ * getCrosswireSize:
+ * Get the crosswire size of all ZoomRegions.
+ * @returns {number}: The thickness of each line in the cross wire.
+ */
+ getCrosswireSize() {
+ return Main.magnifier.getCrosshairsThickness();
+ }
+
+ /**
+ * setCrosswireLength:
+ * Set the crosswire length of all zoom-regions..
+ * @param {number} length: The length of each line in the cross wire.
+ */
+ setCrosswireLength(length) {
+ Main.magnifier.setCrosshairsLength(length);
+ }
+
+ /**
+ * getCrosswireSize:
+ * Get the crosswire length of all zoom-regions.
+ * @returns {number} size: The length of each line in the cross wire.
+ */
+ getCrosswireLength() {
+ return Main.magnifier.getCrosshairsLength();
+ }
+
+ /**
+ * setCrosswireClip:
+ * Set if the crosswire will be clipped by the cursor image..
+ * @param {bool} clip: Flag to indicate whether to clip the crosswire.
+ */
+ setCrosswireClip(clip) {
+ Main.magnifier.setCrosshairsClip(clip);
+ }
+
+ /**
+ * getCrosswireClip:
+ * Get the crosswire clip value.
+ * @returns {bool}: Whether the crosswire is clipped by the cursor image.
+ */
+ getCrosswireClip() {
+ return Main.magnifier.getCrosshairsClip();
+ }
+
+ /**
+ * setCrosswireColor:
+ * Set the crosswire color of all ZoomRegions.
+ * @param {number} color: Unsigned int of the form rrggbbaa.
+ */
+ setCrosswireColor(color) {
+ Main.magnifier.setCrosshairsColor('#%08x'.format(color));
+ }
+
+ /**
+ * getCrosswireClip:
+ * Get the crosswire color of all ZoomRegions.
+ * @returns {number}: The crosswire color as an unsigned int in
+ * the form rrggbbaa.
+ */
+ getCrosswireColor() {
+ let colorString = Main.magnifier.getCrosshairsColor();
+ // Drop the leading '#'.
+ return parseInt(colorString.slice(1), 16);
+ }
+};
+
+/**
+ * ShellMagnifierZoomRegion:
+ * Object that implements the DBus ZoomRegion interface.
+ * @zoomerObjectPath: String that is the path to a DBus ZoomRegion.
+ * @zoomRegion: The actual zoom region associated with the object path.
+ */
+var ShellMagnifierZoomRegion = class ShellMagnifierZoomRegion {
+ constructor(zoomerObjectPath, zoomRegion) {
+ this._zoomRegion = zoomRegion;
+
+ this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(ZoomRegionIface, this);
+ this._dbusImpl.export(Gio.DBus.session, zoomerObjectPath);
+ }
+
+ /**
+ * setMagFactor:
+ * @param {number} xMagFactor: The power to set the horizontal
+ * magnification factor to of the magnified view. A value of
+ * 1.0 means no magnification. A value of 2.0 doubles the size.
+ * @param {number} yMagFactor: The power to set the vertical
+ * magnification factor to of the magnified view.
+ */
+ setMagFactor(xMagFactor, yMagFactor) {
+ this._zoomRegion.setMagFactor(xMagFactor, yMagFactor);
+ }
+
+ /**
+ * getMagFactor:
+ * @returns {number[]}: [xMagFactor, yMagFactor], containing the horizontal
+ * and vertical magnification powers. A value of 1.0 means no
+ * magnification. A value of 2.0 means the contents are doubled
+ * in size, and so on.
+ */
+ getMagFactor() {
+ return this._zoomRegion.getMagFactor();
+ }
+
+ /**
+ * setRoi:
+ * Sets the "region of interest" that the ZoomRegion is magnifying.
+ * @param {number[]} roi: [left, top, right, bottom], defining the
+ * region of the screen to magnify.
+ * The values are in screen (unmagnified) coordinate space.
+ */
+ setRoi(roi) {
+ let roiObject = { x: roi[0], y: roi[1], width: roi[2] - roi[0], height: roi[3] - roi[1] };
+ this._zoomRegion.setROI(roiObject);
+ }
+
+ /**
+ * getRoi:
+ * Retrieves the "region of interest" -- the rectangular bounds of that part
+ * of the desktop that the magnified view is showing (x, y, width, height).
+ * The bounds are given in non-magnified coordinates.
+ * @returns {Array}: [left, top, right, bottom], representing the bounding
+ * rectangle of what is shown in the magnified view.
+ */
+ getRoi() {
+ let roi = this._zoomRegion.getROI();
+ roi[2] += roi[0];
+ roi[3] += roi[1];
+ return roi;
+ }
+
+ /**
+ * Set the "region of interest" by centering the given screen coordinate
+ * within the zoom region.
+ * @param {number} x: The x-coord of the point to place at the
+ * center of the zoom region.
+ * @param {number} y: The y-coord.
+ * @returns {bool} Whether the shift was successful (for GS-mag, this
+ * is always true).
+ */
+ shiftContentsTo(x, y) {
+ this._zoomRegion.scrollContentsTo(x, y);
+ return true;
+ }
+
+ /**
+ * moveResize
+ * Sets the position and size of the ZoomRegion on screen.
+ * @param {number[]} viewPort: [left, top, right, bottom], defining
+ * the position and size on screen to place the zoom region.
+ */
+ moveResize(viewPort) {
+ let viewRect = { x: viewPort[0], y: viewPort[1], width: viewPort[2] - viewPort[0], height: viewPort[3] - viewPort[1] };
+ this._zoomRegion.setViewPort(viewRect);
+ }
+
+ destroy() {
+ this._dbusImpl.unexport();
+ }
+};
diff --git a/js/ui/main.js b/js/ui/main.js
new file mode 100644
index 0000000..bff730c
--- /dev/null
+++ b/js/ui/main.js
@@ -0,0 +1,856 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported componentManager, notificationDaemon, windowAttentionHandler,
+ ctrlAltTabManager, padOsdService, osdWindowManager,
+ osdMonitorLabeler, shellMountOpDBusService, shellDBusService,
+ shellAccessDialogDBusService, shellAudioSelectionDBusService,
+ screenSaverDBus, uiGroup, magnifier, xdndHandler, keyboard,
+ kbdA11yDialog, introspectService, start, pushModal, popModal,
+ activateWindow, createLookingGlass, initializeDeferredWork,
+ getThemeStylesheet, setThemeStylesheet */
+
+const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi;
+
+const AccessDialog = imports.ui.accessDialog;
+const AudioDeviceSelection = imports.ui.audioDeviceSelection;
+const Components = imports.ui.components;
+const CtrlAltTab = imports.ui.ctrlAltTab;
+const EndSessionDialog = imports.ui.endSessionDialog;
+const ExtensionSystem = imports.ui.extensionSystem;
+const ExtensionDownloader = imports.ui.extensionDownloader;
+const InputMethod = imports.misc.inputMethod;
+const Introspect = imports.misc.introspect;
+const Keyboard = imports.ui.keyboard;
+const MessageTray = imports.ui.messageTray;
+const ModalDialog = imports.ui.modalDialog;
+const OsdWindow = imports.ui.osdWindow;
+const OsdMonitorLabeler = imports.ui.osdMonitorLabeler;
+const Overview = imports.ui.overview;
+const PadOsd = imports.ui.padOsd;
+const Panel = imports.ui.panel;
+const Params = imports.misc.params;
+const RunDialog = imports.ui.runDialog;
+const Layout = imports.ui.layout;
+const LoginManager = imports.misc.loginManager;
+const LookingGlass = imports.ui.lookingGlass;
+const NotificationDaemon = imports.ui.notificationDaemon;
+const WindowAttentionHandler = imports.ui.windowAttentionHandler;
+const ScreenShield = imports.ui.screenShield;
+const Scripting = imports.ui.scripting;
+const SessionMode = imports.ui.sessionMode;
+const ShellDBus = imports.ui.shellDBus;
+const ShellMountOperation = imports.ui.shellMountOperation;
+const WindowManager = imports.ui.windowManager;
+const Magnifier = imports.ui.magnifier;
+const XdndHandler = imports.ui.xdndHandler;
+const KbdA11yDialog = imports.ui.kbdA11yDialog;
+const LocatePointer = imports.ui.locatePointer;
+const PointerA11yTimeout = imports.ui.pointerA11yTimeout;
+const ParentalControlsManager = imports.misc.parentalControlsManager;
+
+const A11Y_SCHEMA = 'org.gnome.desktop.a11y.keyboard';
+const STICKY_KEYS_ENABLE = 'stickykeys-enable';
+const LOG_DOMAIN = 'GNOME Shell';
+const GNOMESHELL_STARTED_MESSAGE_ID = 'f3ea493c22934e26811cd62abe8e203a';
+
+var componentManager = null;
+var extensionManager = null;
+var panel = null;
+var overview = null;
+var runDialog = null;
+var lookingGlass = null;
+var wm = null;
+var messageTray = null;
+var screenShield = null;
+var notificationDaemon = null;
+var windowAttentionHandler = null;
+var ctrlAltTabManager = null;
+var padOsdService = null;
+var osdWindowManager = null;
+var osdMonitorLabeler = null;
+var sessionMode = null;
+var shellAccessDialogDBusService = null;
+var shellAudioSelectionDBusService = null;
+var shellDBusService = null;
+var shellMountOpDBusService = null;
+var screenSaverDBus = null;
+var modalCount = 0;
+var actionMode = Shell.ActionMode.NONE;
+var modalActorFocusStack = [];
+var uiGroup = null;
+var magnifier = null;
+var xdndHandler = null;
+var keyboard = null;
+var layoutManager = null;
+var kbdA11yDialog = null;
+var inputMethod = null;
+var introspectService = null;
+var locatePointer = null;
+let _startDate;
+let _defaultCssStylesheet = null;
+let _cssStylesheet = null;
+let _a11ySettings = null;
+let _themeResource = null;
+let _oskResource = null;
+
+Gio._promisify(Gio._LocalFilePrototype, 'delete_async', 'delete_finish');
+Gio._promisify(Gio._LocalFilePrototype, 'touch_async', 'touch_finish');
+
+let _remoteAccessInhibited = false;
+
+function _sessionUpdated() {
+ if (sessionMode.isPrimary)
+ _loadDefaultStylesheet();
+
+ wm.setCustomKeybindingHandler('panel-main-menu',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ sessionMode.hasOverview ? overview.toggle.bind(overview) : null);
+ wm.allowKeybinding('overlay-key', Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW);
+
+ wm.allowKeybinding('locate-pointer-key', Shell.ActionMode.ALL);
+
+ wm.setCustomKeybindingHandler('panel-run-dialog',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ sessionMode.hasRunDialog ? openRunDialog : null);
+
+ if (!sessionMode.hasRunDialog) {
+ if (runDialog)
+ runDialog.close();
+ if (lookingGlass)
+ lookingGlass.close();
+ }
+
+ let remoteAccessController = global.backend.get_remote_access_controller();
+ if (remoteAccessController) {
+ if (sessionMode.allowScreencast && _remoteAccessInhibited) {
+ remoteAccessController.uninhibit_remote_access();
+ _remoteAccessInhibited = false;
+ } else if (!sessionMode.allowScreencast && !_remoteAccessInhibited) {
+ remoteAccessController.inhibit_remote_access();
+ _remoteAccessInhibited = true;
+ }
+ }
+}
+
+function start() {
+ // These are here so we don't break compatibility.
+ global.logError = globalThis.log;
+ global.log = globalThis.log;
+
+ // Chain up async errors reported from C
+ global.connect('notify-error', (global, msg, detail) => {
+ notifyError(msg, detail);
+ });
+
+ let currentDesktop = GLib.getenv('XDG_CURRENT_DESKTOP');
+ if (!currentDesktop || !currentDesktop.split(':').includes('GNOME'))
+ Gio.DesktopAppInfo.set_desktop_env('GNOME');
+
+ sessionMode = new SessionMode.SessionMode();
+ sessionMode.connect('updated', _sessionUpdated);
+
+ St.Settings.get().connect('notify::gtk-theme', _loadDefaultStylesheet);
+
+ // Initialize ParentalControlsManager before the UI
+ ParentalControlsManager.getDefault();
+
+ _initializeUI();
+
+ shellAccessDialogDBusService = new AccessDialog.AccessDialogDBus();
+ shellAudioSelectionDBusService = new AudioDeviceSelection.AudioDeviceSelectionDBus();
+ shellDBusService = new ShellDBus.GnomeShell();
+ shellMountOpDBusService = new ShellMountOperation.GnomeShellMountOpHandler();
+
+ const watchId = Gio.DBus.session.watch_name('org.gnome.Shell.Notifications',
+ Gio.BusNameWatcherFlags.AUTO_START,
+ bus => bus.unwatch_name(watchId),
+ bus => bus.unwatch_name(watchId));
+
+ _sessionUpdated();
+}
+
+function _initializeUI() {
+ // Ensure ShellWindowTracker and ShellAppUsage are initialized; this will
+ // also initialize ShellAppSystem first. ShellAppSystem
+ // needs to load all the .desktop files, and ShellWindowTracker
+ // will use those to associate with windows. Right now
+ // the Monitor doesn't listen for installed app changes
+ // and recalculate application associations, so to avoid
+ // races for now we initialize it here. It's better to
+ // be predictable anyways.
+ Shell.WindowTracker.get_default();
+ Shell.AppUsage.get_default();
+
+ reloadThemeResource();
+ _loadOskLayouts();
+ _loadDefaultStylesheet();
+
+ new AnimationsSettings();
+
+ // Setup the stage hierarchy early
+ layoutManager = new Layout.LayoutManager();
+
+ // Various parts of the codebase still refer to Main.uiGroup
+ // instead of using the layoutManager. This keeps that code
+ // working until it's updated.
+ uiGroup = layoutManager.uiGroup;
+
+ padOsdService = new PadOsd.PadOsdService();
+ xdndHandler = new XdndHandler.XdndHandler();
+ ctrlAltTabManager = new CtrlAltTab.CtrlAltTabManager();
+ osdWindowManager = new OsdWindow.OsdWindowManager();
+ osdMonitorLabeler = new OsdMonitorLabeler.OsdMonitorLabeler();
+ overview = new Overview.Overview();
+ kbdA11yDialog = new KbdA11yDialog.KbdA11yDialog();
+ wm = new WindowManager.WindowManager();
+ magnifier = new Magnifier.Magnifier();
+ locatePointer = new LocatePointer.LocatePointer();
+
+ if (LoginManager.canLock())
+ screenShield = new ScreenShield.ScreenShield();
+
+ inputMethod = new InputMethod.InputMethod();
+ Clutter.get_default_backend().set_input_method(inputMethod);
+
+ messageTray = new MessageTray.MessageTray();
+ panel = new Panel.Panel();
+ keyboard = new Keyboard.KeyboardManager();
+ notificationDaemon = new NotificationDaemon.NotificationDaemon();
+ windowAttentionHandler = new WindowAttentionHandler.WindowAttentionHandler();
+ componentManager = new Components.ComponentManager();
+
+ introspectService = new Introspect.IntrospectService();
+
+ layoutManager.init();
+ overview.init();
+
+ new PointerA11yTimeout.PointerA11yTimeout();
+
+ _a11ySettings = new Gio.Settings({ schema_id: A11Y_SCHEMA });
+
+ global.display.connect('overlay-key', () => {
+ if (!_a11ySettings.get_boolean(STICKY_KEYS_ENABLE))
+ overview.toggle();
+ });
+
+ global.connect('locate-pointer', () => {
+ locatePointer.show();
+ });
+
+ global.display.connect('show-restart-message', (display, message) => {
+ showRestartMessage(message);
+ return true;
+ });
+
+ global.display.connect('restart', () => {
+ global.reexec_self();
+ return true;
+ });
+
+ global.display.connect('gl-video-memory-purged', loadTheme);
+
+ // Provide the bus object for gnome-session to
+ // initiate logouts.
+ EndSessionDialog.init();
+
+ // We're ready for the session manager to move to the next phase
+ GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
+ Shell.util_sd_notify();
+ Meta.register_with_session();
+ return GLib.SOURCE_REMOVE;
+ });
+
+ _startDate = new Date();
+
+ ExtensionDownloader.init();
+ extensionManager = new ExtensionSystem.ExtensionManager();
+ extensionManager.init();
+
+ if (sessionMode.isGreeter && screenShield) {
+ layoutManager.connect('startup-prepared', () => {
+ screenShield.showDialog();
+ });
+ }
+
+ layoutManager.connect('startup-complete', () => {
+ if (actionMode == Shell.ActionMode.NONE)
+ actionMode = Shell.ActionMode.NORMAL;
+
+ if (screenShield)
+ screenShield.lockIfWasLocked();
+
+ if (sessionMode.currentMode != 'gdm' &&
+ sessionMode.currentMode != 'initial-setup') {
+ GLib.log_structured(LOG_DOMAIN, GLib.LogLevelFlags.LEVEL_MESSAGE, {
+ 'MESSAGE': 'GNOME Shell started at %s'.format(_startDate),
+ 'MESSAGE_ID': GNOMESHELL_STARTED_MESSAGE_ID,
+ });
+ }
+
+ let credentials = new Gio.Credentials();
+ if (credentials.get_unix_user() === 0) {
+ notify(_('Logged in as a privileged user'),
+ _('Running a session as a privileged user should be avoided for security reasons. If possible, you should log in as a normal user.'));
+ }
+
+ if (sessionMode.currentMode !== 'gdm' &&
+ sessionMode.currentMode !== 'initial-setup')
+ _handleLockScreenWarning();
+
+ LoginManager.registerSessionWithGDM();
+
+ let perfModuleName = GLib.getenv("SHELL_PERF_MODULE");
+ if (perfModuleName) {
+ let perfOutput = GLib.getenv("SHELL_PERF_OUTPUT");
+ let module = eval('imports.perf.%s;'.format(perfModuleName));
+ Scripting.runPerfScript(module, perfOutput);
+ }
+ });
+}
+
+async function _handleLockScreenWarning() {
+ const path = '%s/lock-warning-shown'.format(global.userdatadir);
+ const file = Gio.File.new_for_path(path);
+
+ const hasLockScreen = screenShield !== null;
+ if (hasLockScreen) {
+ try {
+ await file.delete_async(0, null);
+ } catch (e) {
+ if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
+ logError(e);
+ }
+ } else {
+ try {
+ if (!await file.touch_async())
+ return;
+ } catch (e) {
+ logError(e);
+ }
+
+ notify(
+ _('Screen Lock disabled'),
+ _('Screen Locking requires the GNOME display manager.'));
+ }
+}
+
+function _getStylesheet(name) {
+ let stylesheet;
+
+ stylesheet = Gio.File.new_for_uri('resource:///org/gnome/shell/theme/%s'.format(name));
+ if (stylesheet.query_exists(null))
+ return stylesheet;
+
+ let dataDirs = GLib.get_system_data_dirs();
+ for (let i = 0; i < dataDirs.length; i++) {
+ let path = GLib.build_filenamev([dataDirs[i], 'gnome-shell', 'theme', name]);
+ stylesheet = Gio.file_new_for_path(path);
+ if (stylesheet.query_exists(null))
+ return stylesheet;
+ }
+
+ stylesheet = Gio.File.new_for_path('%s/theme/%s'.format(global.datadir, name));
+ if (stylesheet.query_exists(null))
+ return stylesheet;
+
+ return null;
+}
+
+function _getDefaultStylesheet() {
+ let stylesheet = null;
+ let name = sessionMode.stylesheetName;
+
+ // Look for a high-contrast variant first when using GTK+'s HighContrast
+ // theme
+ if (St.Settings.get().gtk_theme == 'HighContrast')
+ stylesheet = _getStylesheet(name.replace('.css', '-high-contrast.css'));
+
+ if (stylesheet == null)
+ stylesheet = _getStylesheet(sessionMode.stylesheetName);
+
+ return stylesheet;
+}
+
+function _loadDefaultStylesheet() {
+ let stylesheet = _getDefaultStylesheet();
+ if (_defaultCssStylesheet && _defaultCssStylesheet.equal(stylesheet))
+ return;
+
+ _defaultCssStylesheet = stylesheet;
+ loadTheme();
+}
+
+/**
+ * getThemeStylesheet:
+ *
+ * Get the theme CSS file that the shell will load
+ *
+ * @returns {?Gio.File}: A #GFile that contains the theme CSS,
+ * null if using the default
+ */
+function getThemeStylesheet() {
+ return _cssStylesheet;
+}
+
+/**
+ * setThemeStylesheet:
+ * @param {string=} cssStylesheet: A file path that contains the theme CSS,
+ * set it to null to use the default
+ *
+ * Set the theme CSS file that the shell will load
+ */
+function setThemeStylesheet(cssStylesheet) {
+ _cssStylesheet = cssStylesheet ? Gio.File.new_for_path(cssStylesheet) : null;
+}
+
+function reloadThemeResource() {
+ if (_themeResource)
+ _themeResource._unregister();
+
+ _themeResource = Gio.Resource.load('%s/%s'.format(global.datadir,
+ sessionMode.themeResourceName));
+ _themeResource._register();
+}
+
+function _loadOskLayouts() {
+ _oskResource = Gio.Resource.load('%s/gnome-shell-osk-layouts.gresource'.format(global.datadir));
+ _oskResource._register();
+}
+
+/**
+ * loadTheme:
+ *
+ * Reloads the theme CSS file
+ */
+function loadTheme() {
+ let themeContext = St.ThemeContext.get_for_stage(global.stage);
+ let previousTheme = themeContext.get_theme();
+
+ let theme = new St.Theme({
+ application_stylesheet: _cssStylesheet,
+ default_stylesheet: _defaultCssStylesheet,
+ });
+
+ if (theme.default_stylesheet == null)
+ throw new Error("No valid stylesheet found for '%s'".format(sessionMode.stylesheetName));
+
+ if (previousTheme) {
+ let customStylesheets = previousTheme.get_custom_stylesheets();
+
+ for (let i = 0; i < customStylesheets.length; i++)
+ theme.load_stylesheet(customStylesheets[i]);
+ }
+
+ themeContext.set_theme(theme);
+}
+
+/**
+ * notify:
+ * @param {string} msg: A message
+ * @param {string} details: Additional information
+ */
+function notify(msg, details) {
+ let source = new MessageTray.SystemNotificationSource();
+ messageTray.add(source);
+ let notification = new MessageTray.Notification(source, msg, details);
+ notification.setTransient(true);
+ source.showNotification(notification);
+}
+
+/**
+ * notifyError:
+ * @param {string} msg: An error message
+ * @param {string} details: Additional information
+ *
+ * See shell_global_notify_problem().
+ */
+function notifyError(msg, details) {
+ // Also print to stderr so it's logged somewhere
+ if (details)
+ log('error: %s: %s'.format(msg, details));
+ else
+ log('error: %s'.format(msg));
+
+ notify(msg, details);
+}
+
+function _findModal(actor) {
+ for (let i = 0; i < modalActorFocusStack.length; i++) {
+ if (modalActorFocusStack[i].actor == actor)
+ return i;
+ }
+ return -1;
+}
+
+/**
+ * pushModal:
+ * @param {Clutter.Actor} actor: actor which will be given keyboard focus
+ * @param {Object=} params: optional parameters
+ *
+ * Ensure we are in a mode where all keyboard and mouse input goes to
+ * the stage, and focus @actor. Multiple calls to this function act in
+ * a stacking fashion; the effect will be undone when an equal number
+ * of popModal() invocations have been made.
+ *
+ * Next, record the current Clutter keyboard focus on a stack. If the
+ * modal stack returns to this actor, reset the focus to the actor
+ * which was focused at the time pushModal() was invoked.
+ *
+ * @params may be used to provide the following parameters:
+ * - timestamp: used to associate the call with a specific user initiated
+ * event. If not provided then the value of
+ * global.get_current_time() is assumed.
+ *
+ * - options: Meta.ModalOptions flags to indicate that the pointer is
+ * already grabbed
+ *
+ * - actionMode: used to set the current Shell.ActionMode to filter
+ * global keybindings; the default of NONE will filter
+ * out all keybindings
+ *
+ * @returns {bool}: true iff we successfully acquired a grab or already had one
+ */
+function pushModal(actor, params) {
+ params = Params.parse(params, { timestamp: global.get_current_time(),
+ options: 0,
+ actionMode: Shell.ActionMode.NONE });
+
+ if (modalCount == 0) {
+ if (!global.begin_modal(params.timestamp, params.options)) {
+ log('pushModal: invocation of begin_modal failed');
+ return false;
+ }
+ Meta.disable_unredirect_for_display(global.display);
+ }
+
+ modalCount += 1;
+ let actorDestroyId = actor.connect('destroy', () => {
+ let index = _findModal(actor);
+ if (index >= 0)
+ popModal(actor);
+ });
+
+ let prevFocus = global.stage.get_key_focus();
+ let prevFocusDestroyId;
+ if (prevFocus != null) {
+ prevFocusDestroyId = prevFocus.connect('destroy', () => {
+ const index = modalActorFocusStack.findIndex(
+ record => record.prevFocus === prevFocus);
+
+ if (index >= 0)
+ modalActorFocusStack[index].prevFocus = null;
+ });
+ }
+ modalActorFocusStack.push({ actor,
+ destroyId: actorDestroyId,
+ prevFocus,
+ prevFocusDestroyId,
+ actionMode });
+
+ actionMode = params.actionMode;
+ global.stage.set_key_focus(actor);
+ return true;
+}
+
+/**
+ * popModal:
+ * @param {Clutter.Actor} actor: the actor passed to original invocation
+ * of pushModal()
+ * @param {number=} timestamp: optional timestamp
+ *
+ * Reverse the effect of pushModal(). If this invocation is undoing
+ * the topmost invocation, then the focus will be restored to the
+ * previous focus at the time when pushModal() was invoked.
+ *
+ * @timestamp is optionally used to associate the call with a specific user
+ * initiated event. If not provided then the value of
+ * global.get_current_time() is assumed.
+ */
+function popModal(actor, timestamp) {
+ if (timestamp == undefined)
+ timestamp = global.get_current_time();
+
+ let focusIndex = _findModal(actor);
+ if (focusIndex < 0) {
+ global.stage.set_key_focus(null);
+ global.end_modal(timestamp);
+ actionMode = Shell.ActionMode.NORMAL;
+
+ throw new Error('incorrect pop');
+ }
+
+ modalCount -= 1;
+
+ let record = modalActorFocusStack[focusIndex];
+ record.actor.disconnect(record.destroyId);
+
+ if (focusIndex == modalActorFocusStack.length - 1) {
+ if (record.prevFocus)
+ record.prevFocus.disconnect(record.prevFocusDestroyId);
+ actionMode = record.actionMode;
+ global.stage.set_key_focus(record.prevFocus);
+ } else {
+ // If we have:
+ // global.stage.set_focus(a);
+ // Main.pushModal(b);
+ // Main.pushModal(c);
+ // Main.pushModal(d);
+ //
+ // then we have the stack:
+ // [{ prevFocus: a, actor: b },
+ // { prevFocus: b, actor: c },
+ // { prevFocus: c, actor: d }]
+ //
+ // When actor c is destroyed/popped, if we only simply remove the
+ // record, then the focus stack will be [a, c], rather than the correct
+ // [a, b]. Shift the focus stack up before removing the record to ensure
+ // that we get the correct result.
+ let t = modalActorFocusStack[modalActorFocusStack.length - 1];
+ if (t.prevFocus)
+ t.prevFocus.disconnect(t.prevFocusDestroyId);
+ // Remove from the middle, shift the focus chain up
+ for (let i = modalActorFocusStack.length - 1; i > focusIndex; i--) {
+ modalActorFocusStack[i].prevFocus = modalActorFocusStack[i - 1].prevFocus;
+ modalActorFocusStack[i].prevFocusDestroyId = modalActorFocusStack[i - 1].prevFocusDestroyId;
+ modalActorFocusStack[i].actionMode = modalActorFocusStack[i - 1].actionMode;
+ }
+ }
+ modalActorFocusStack.splice(focusIndex, 1);
+
+ if (modalCount > 0)
+ return;
+
+ layoutManager.modalEnded();
+ global.end_modal(timestamp);
+ Meta.enable_unredirect_for_display(global.display);
+ actionMode = Shell.ActionMode.NORMAL;
+}
+
+function createLookingGlass() {
+ if (lookingGlass == null)
+ lookingGlass = new LookingGlass.LookingGlass();
+
+ return lookingGlass;
+}
+
+function openRunDialog() {
+ if (runDialog == null)
+ runDialog = new RunDialog.RunDialog();
+
+ runDialog.open();
+}
+
+/**
+ * activateWindow:
+ * @param {Meta.Window} window: the window to activate
+ * @param {number=} time: current event time
+ * @param {number=} workspaceNum: window's workspace number
+ *
+ * Activates @window, switching to its workspace first if necessary,
+ * and switching out of the overview if it's currently active
+ */
+function activateWindow(window, time, workspaceNum) {
+ let workspaceManager = global.workspace_manager;
+ let activeWorkspaceNum = workspaceManager.get_active_workspace_index();
+ let windowWorkspaceNum = workspaceNum !== undefined ? workspaceNum : window.get_workspace().index();
+
+ if (!time)
+ time = global.get_current_time();
+
+ if (windowWorkspaceNum != activeWorkspaceNum) {
+ let workspace = workspaceManager.get_workspace_by_index(windowWorkspaceNum);
+ workspace.activate_with_focus(window, time);
+ } else {
+ window.activate(time);
+ }
+
+ overview.hide();
+ panel.closeCalendar();
+}
+
+// TODO - replace this timeout with some system to guess when the user might
+// be e.g. just reading the screen and not likely to interact.
+var DEFERRED_TIMEOUT_SECONDS = 20;
+var _deferredWorkData = {};
+// Work scheduled for some point in the future
+var _deferredWorkQueue = [];
+// Work we need to process before the next redraw
+var _beforeRedrawQueue = [];
+// Counter to assign work ids
+var _deferredWorkSequence = 0;
+var _deferredTimeoutId = 0;
+
+function _runDeferredWork(workId) {
+ if (!_deferredWorkData[workId])
+ return;
+ let index = _deferredWorkQueue.indexOf(workId);
+ if (index < 0)
+ return;
+
+ _deferredWorkQueue.splice(index, 1);
+ _deferredWorkData[workId].callback();
+ if (_deferredWorkQueue.length == 0 && _deferredTimeoutId > 0) {
+ GLib.source_remove(_deferredTimeoutId);
+ _deferredTimeoutId = 0;
+ }
+}
+
+function _runAllDeferredWork() {
+ while (_deferredWorkQueue.length > 0)
+ _runDeferredWork(_deferredWorkQueue[0]);
+}
+
+function _runBeforeRedrawQueue() {
+ for (let i = 0; i < _beforeRedrawQueue.length; i++) {
+ let workId = _beforeRedrawQueue[i];
+ _runDeferredWork(workId);
+ }
+ _beforeRedrawQueue = [];
+}
+
+function _queueBeforeRedraw(workId) {
+ _beforeRedrawQueue.push(workId);
+ if (_beforeRedrawQueue.length == 1) {
+ Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
+ _runBeforeRedrawQueue();
+ return false;
+ });
+ }
+}
+
+/**
+ * initializeDeferredWork:
+ * @param {Clutter.Actor} actor: an actor
+ * @param {callback} callback: Function to invoke to perform work
+ *
+ * This function sets up a callback to be invoked when either the
+ * given actor is mapped, or after some period of time when the machine
+ * is idle. This is useful if your actor isn't always visible on the
+ * screen (for example, all actors in the overview), and you don't want
+ * to consume resources updating if the actor isn't actually going to be
+ * displaying to the user.
+ *
+ * Note that queueDeferredWork is called by default immediately on
+ * initialization as well, under the assumption that new actors
+ * will need it.
+ *
+ * @returns {string}: A string work identifier
+ */
+function initializeDeferredWork(actor, callback) {
+ // Turn into a string so we can use as an object property
+ let workId = (++_deferredWorkSequence).toString();
+ _deferredWorkData[workId] = { actor,
+ callback };
+ actor.connect('notify::mapped', () => {
+ if (!(actor.mapped && _deferredWorkQueue.includes(workId)))
+ return;
+ _queueBeforeRedraw(workId);
+ });
+ actor.connect('destroy', () => {
+ let index = _deferredWorkQueue.indexOf(workId);
+ if (index >= 0)
+ _deferredWorkQueue.splice(index, 1);
+ delete _deferredWorkData[workId];
+ });
+ queueDeferredWork(workId);
+ return workId;
+}
+
+/**
+ * queueDeferredWork:
+ * @param {string} workId: work identifier
+ *
+ * Ensure that the work identified by @workId will be
+ * run on map or timeout. You should call this function
+ * for example when data being displayed by the actor has
+ * changed.
+ */
+function queueDeferredWork(workId) {
+ let data = _deferredWorkData[workId];
+ if (!data) {
+ let message = 'Invalid work id %d'.format(workId);
+ logError(new Error(message), message);
+ return;
+ }
+ if (!_deferredWorkQueue.includes(workId))
+ _deferredWorkQueue.push(workId);
+ if (data.actor.mapped) {
+ _queueBeforeRedraw(workId);
+ } else if (_deferredTimeoutId == 0) {
+ _deferredTimeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, DEFERRED_TIMEOUT_SECONDS, () => {
+ _runAllDeferredWork();
+ _deferredTimeoutId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(_deferredTimeoutId, '[gnome-shell] _runAllDeferredWork');
+ }
+}
+
+var RestartMessage = GObject.registerClass(
+class RestartMessage extends ModalDialog.ModalDialog {
+ _init(message) {
+ super._init({ shellReactive: true,
+ styleClass: 'restart-message headline',
+ shouldFadeIn: false,
+ destroyOnClose: true });
+
+ let label = new St.Label({
+ text: message,
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+
+ this.contentLayout.add_child(label);
+ this.buttonLayout.hide();
+ }
+});
+
+function showRestartMessage(message) {
+ let restartMessage = new RestartMessage(message);
+ restartMessage.open();
+}
+
+var AnimationsSettings = class {
+ constructor() {
+ let backend = global.backend;
+ if (!backend.is_rendering_hardware_accelerated()) {
+ St.Settings.get().inhibit_animations();
+ return;
+ }
+
+ let isXvnc = Shell.util_has_x11_display_extension(
+ global.display, 'VNC-EXTENSION');
+ if (isXvnc) {
+ St.Settings.get().inhibit_animations();
+ return;
+ }
+
+ let remoteAccessController = backend.get_remote_access_controller();
+ if (!remoteAccessController)
+ return;
+
+ this._handles = new Set();
+ remoteAccessController.connect('new-handle',
+ (_, handle) => this._onNewRemoteAccessHandle(handle));
+ }
+
+ _onRemoteAccessHandleStopped(handle) {
+ let settings = St.Settings.get();
+
+ settings.uninhibit_animations();
+ this._handles.delete(handle);
+ }
+
+ _onNewRemoteAccessHandle(handle) {
+ if (!handle.get_disable_animations())
+ return;
+
+ let settings = St.Settings.get();
+
+ settings.inhibit_animations();
+ this._handles.add(handle);
+ handle.connect('stopped', this._onRemoteAccessHandleStopped.bind(this));
+ }
+};
diff --git a/js/ui/messageList.js b/js/ui/messageList.js
new file mode 100644
index 0000000..a79f87b
--- /dev/null
+++ b/js/ui/messageList.js
@@ -0,0 +1,737 @@
+/* exported MessageListSection */
+const { Atk, Clutter, Gio, GLib,
+ GObject, Graphene, Meta, Pango, St } = imports.gi;
+const Main = imports.ui.main;
+const MessageTray = imports.ui.messageTray;
+
+const Util = imports.misc.util;
+
+var MESSAGE_ANIMATION_TIME = 100;
+
+var DEFAULT_EXPAND_LINES = 6;
+
+function _fixMarkup(text, allowMarkup) {
+ if (allowMarkup) {
+ // Support &amp;, &quot;, &apos;, &lt; and &gt;, escape all other
+ // occurrences of '&'.
+ let _text = text.replace(/&(?!amp;|quot;|apos;|lt;|gt;)/g, '&amp;');
+
+ // Support <b>, <i>, and <u>, escape anything else
+ // so it displays as raw markup.
+ // Ref: https://developer.gnome.org/notification-spec/#markup
+ _text = _text.replace(/<(?!\/?[biu]>)/g, '&lt;');
+
+ try {
+ Pango.parse_markup(_text, -1, '');
+ return _text;
+ } catch (e) {}
+ }
+
+ // !allowMarkup, or invalid markup
+ return GLib.markup_escape_text(text, -1);
+}
+
+var URLHighlighter = GObject.registerClass(
+class URLHighlighter extends St.Label {
+ _init(text = '', lineWrap, allowMarkup) {
+ super._init({
+ reactive: true,
+ style_class: 'url-highlighter',
+ x_expand: true,
+ x_align: Clutter.ActorAlign.START,
+ });
+ this._linkColor = '#ccccff';
+ this.connect('style-changed', () => {
+ let [hasColor, color] = this.get_theme_node().lookup_color('link-color', false);
+ if (hasColor) {
+ let linkColor = color.to_string().substr(0, 7);
+ if (linkColor != this._linkColor) {
+ this._linkColor = linkColor;
+ this._highlightUrls();
+ }
+ }
+ });
+ this.clutter_text.line_wrap = lineWrap;
+ this.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR;
+
+ this.setMarkup(text, allowMarkup);
+ }
+
+ vfunc_button_press_event(buttonEvent) {
+ // Don't try to URL highlight when invisible.
+ // The MessageTray doesn't actually hide us, so
+ // we need to check for paint opacities as well.
+ if (!this.visible || this.get_paint_opacity() == 0)
+ return Clutter.EVENT_PROPAGATE;
+
+ // Keep Notification from seeing this and taking
+ // a pointer grab, which would block our button-release-event
+ // handler, if an URL is clicked
+ return this._findUrlAtPos(buttonEvent) != -1;
+ }
+
+ vfunc_button_release_event(buttonEvent) {
+ if (!this.visible || this.get_paint_opacity() == 0)
+ return Clutter.EVENT_PROPAGATE;
+
+ let urlId = this._findUrlAtPos(buttonEvent);
+ if (urlId != -1) {
+ let url = this._urls[urlId].url;
+ if (!url.includes(':'))
+ url = 'http://%s'.format(url);
+
+ Gio.app_info_launch_default_for_uri(
+ url, global.create_app_launch_context(0, -1));
+ return Clutter.EVENT_STOP;
+ }
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_motion_event(motionEvent) {
+ if (!this.visible || this.get_paint_opacity() == 0)
+ return Clutter.EVENT_PROPAGATE;
+
+ let urlId = this._findUrlAtPos(motionEvent);
+ if (urlId != -1 && !this._cursorChanged) {
+ global.display.set_cursor(Meta.Cursor.POINTING_HAND);
+ this._cursorChanged = true;
+ } else if (urlId == -1) {
+ global.display.set_cursor(Meta.Cursor.DEFAULT);
+ this._cursorChanged = false;
+ }
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_leave_event(crossingEvent) {
+ if (!this.visible || this.get_paint_opacity() == 0)
+ return Clutter.EVENT_PROPAGATE;
+
+ if (this._cursorChanged) {
+ this._cursorChanged = false;
+ global.display.set_cursor(Meta.Cursor.DEFAULT);
+ }
+ return super.vfunc_leave_event(crossingEvent);
+ }
+
+ setMarkup(text, allowMarkup) {
+ text = text ? _fixMarkup(text, allowMarkup) : '';
+ this._text = text;
+
+ this.clutter_text.set_markup(text);
+ /* clutter_text.text contain text without markup */
+ this._urls = Util.findUrls(this.clutter_text.text);
+ this._highlightUrls();
+ }
+
+ _highlightUrls() {
+ // text here contain markup
+ let urls = Util.findUrls(this._text);
+ let markup = '';
+ let pos = 0;
+ for (let i = 0; i < urls.length; i++) {
+ let url = urls[i];
+ let str = this._text.substr(pos, url.pos - pos);
+ markup += '%s<span foreground="%s"><u>%s</u></span>'.format(str, this._linkColor, url.url);
+ pos = url.pos + url.url.length;
+ }
+ markup += this._text.substr(pos);
+ this.clutter_text.set_markup(markup);
+ }
+
+ _findUrlAtPos(event) {
+ let { x, y } = event;
+ [, x, y] = this.transform_stage_point(x, y);
+ let findPos = -1;
+ for (let i = 0; i < this.clutter_text.text.length; i++) {
+ let [, px, py, lineHeight] = this.clutter_text.position_to_coords(i);
+ if (py > y || py + lineHeight < y || x < px)
+ continue;
+ findPos = i;
+ }
+ if (findPos != -1) {
+ for (let i = 0; i < this._urls.length; i++) {
+ if (findPos >= this._urls[i].pos &&
+ this._urls[i].pos + this._urls[i].url.length > findPos)
+ return i;
+ }
+ }
+ return -1;
+ }
+});
+
+var ScaleLayout = GObject.registerClass(
+class ScaleLayout extends Clutter.BinLayout {
+ _init(params) {
+ this._container = null;
+ super._init(params);
+ }
+
+ _connectContainer(container) {
+ if (this._container == container)
+ return;
+
+ if (this._container) {
+ for (let id of this._signals)
+ this._container.disconnect(id);
+ }
+
+ this._container = container;
+ this._signals = [];
+
+ if (this._container) {
+ for (let signal of ['notify::scale-x', 'notify::scale-y']) {
+ let id = this._container.connect(signal, () => {
+ this.layout_changed();
+ });
+ this._signals.push(id);
+ }
+ }
+ }
+
+ vfunc_get_preferred_width(container, forHeight) {
+ this._connectContainer(container);
+
+ let [min, nat] = super.vfunc_get_preferred_width(container, forHeight);
+ return [Math.floor(min * container.scale_x),
+ Math.floor(nat * container.scale_x)];
+ }
+
+ vfunc_get_preferred_height(container, forWidth) {
+ this._connectContainer(container);
+
+ let [min, nat] = super.vfunc_get_preferred_height(container, forWidth);
+ return [Math.floor(min * container.scale_y),
+ Math.floor(nat * container.scale_y)];
+ }
+});
+
+var LabelExpanderLayout = GObject.registerClass({
+ Properties: {
+ 'expansion': GObject.ParamSpec.double('expansion',
+ 'Expansion',
+ 'Expansion of the layout, between 0 (collapsed) ' +
+ 'and 1 (fully expanded',
+ GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE,
+ 0, 1, 0),
+ },
+}, class LabelExpanderLayout extends Clutter.LayoutManager {
+ _init(params) {
+ this._expansion = 0;
+ this._expandLines = DEFAULT_EXPAND_LINES;
+
+ super._init(params);
+ }
+
+ get expansion() {
+ return this._expansion;
+ }
+
+ set expansion(v) {
+ if (v == this._expansion)
+ return;
+ this._expansion = v;
+ this.notify('expansion');
+
+ let visibleIndex = this._expansion > 0 ? 1 : 0;
+ for (let i = 0; this._container && i < this._container.get_n_children(); i++)
+ this._container.get_child_at_index(i).visible = i == visibleIndex;
+
+ this.layout_changed();
+ }
+
+ set expandLines(v) {
+ if (v == this._expandLines)
+ return;
+ this._expandLines = v;
+ if (this._expansion > 0)
+ this.layout_changed();
+ }
+
+ vfunc_set_container(container) {
+ this._container = container;
+ }
+
+ vfunc_get_preferred_width(container, forHeight) {
+ let [min, nat] = [0, 0];
+
+ for (let i = 0; i < container.get_n_children(); i++) {
+ if (i > 1)
+ break; // we support one unexpanded + one expanded child
+
+ let child = container.get_child_at_index(i);
+ let [childMin, childNat] = child.get_preferred_width(forHeight);
+ [min, nat] = [Math.max(min, childMin), Math.max(nat, childNat)];
+ }
+
+ return [min, nat];
+ }
+
+ vfunc_get_preferred_height(container, forWidth) {
+ let [min, nat] = [0, 0];
+
+ let children = container.get_children();
+ if (children[0])
+ [min, nat] = children[0].get_preferred_height(forWidth);
+
+ if (children[1]) {
+ let [min2, nat2] = children[1].get_preferred_height(forWidth);
+ let [expMin, expNat] = [Math.min(min2, min * this._expandLines),
+ Math.min(nat2, nat * this._expandLines)];
+ [min, nat] = [min + this._expansion * (expMin - min),
+ nat + this._expansion * (expNat - nat)];
+ }
+
+ return [min, nat];
+ }
+
+ vfunc_allocate(container, box) {
+ for (let i = 0; i < container.get_n_children(); i++) {
+ let child = container.get_child_at_index(i);
+
+ if (child.visible)
+ child.allocate(box);
+ }
+
+ }
+});
+
+
+var Message = GObject.registerClass({
+ Signals: {
+ 'close': {},
+ 'expanded': {},
+ 'unexpanded': {},
+ },
+}, class Message extends St.Button {
+ _init(title, body) {
+ super._init({
+ style_class: 'message',
+ accessible_role: Atk.Role.NOTIFICATION,
+ can_focus: true,
+ x_expand: true,
+ y_expand: true,
+ });
+
+ this.expanded = false;
+ this._useBodyMarkup = false;
+
+ let vbox = new St.BoxLayout({
+ vertical: true,
+ x_expand: true,
+ });
+ this.set_child(vbox);
+
+ let hbox = new St.BoxLayout();
+ vbox.add_actor(hbox);
+
+ this._actionBin = new St.Widget({ layout_manager: new ScaleLayout(),
+ visible: false });
+ vbox.add_actor(this._actionBin);
+
+ this._iconBin = new St.Bin({ style_class: 'message-icon-bin',
+ y_expand: true,
+ y_align: Clutter.ActorAlign.START,
+ visible: false });
+ hbox.add_actor(this._iconBin);
+
+ let contentBox = new St.BoxLayout({ style_class: 'message-content',
+ vertical: true, x_expand: true });
+ hbox.add_actor(contentBox);
+
+ this._mediaControls = new St.BoxLayout();
+ hbox.add_actor(this._mediaControls);
+
+ let titleBox = new St.BoxLayout();
+ contentBox.add_actor(titleBox);
+
+ this.titleLabel = new St.Label({ style_class: 'message-title' });
+ this.setTitle(title);
+ titleBox.add_actor(this.titleLabel);
+
+ this._secondaryBin = new St.Bin({
+ style_class: 'message-secondary-bin',
+ x_expand: true, y_expand: true,
+ });
+ titleBox.add_actor(this._secondaryBin);
+
+ let closeIcon = new St.Icon({ icon_name: 'window-close-symbolic',
+ icon_size: 16 });
+ this._closeButton = new St.Button({
+ style_class: 'message-close-button',
+ child: closeIcon, opacity: 0,
+ });
+ titleBox.add_actor(this._closeButton);
+
+ this._bodyStack = new St.Widget({ x_expand: true });
+ this._bodyStack.layout_manager = new LabelExpanderLayout();
+ contentBox.add_actor(this._bodyStack);
+
+ this.bodyLabel = new URLHighlighter('', false, this._useBodyMarkup);
+ this.bodyLabel.add_style_class_name('message-body');
+ this._bodyStack.add_actor(this.bodyLabel);
+ this.setBody(body);
+
+ this._closeButton.connect('clicked', this.close.bind(this));
+ let actorHoverId = this.connect('notify::hover', this._sync.bind(this));
+ this._closeButton.connect('destroy', this.disconnect.bind(this, actorHoverId));
+ this.connect('destroy', this._onDestroy.bind(this));
+ this._sync();
+ }
+
+ close() {
+ this.emit('close');
+ }
+
+ setIcon(actor) {
+ this._iconBin.child = actor;
+ this._iconBin.visible = actor != null;
+ }
+
+ setSecondaryActor(actor) {
+ this._secondaryBin.child = actor;
+ }
+
+ setTitle(text) {
+ let title = text ? _fixMarkup(text.replace(/\n/g, ' '), false) : '';
+ this.titleLabel.clutter_text.set_markup(title);
+ }
+
+ setBody(text) {
+ this._bodyText = text;
+ this.bodyLabel.setMarkup(text ? text.replace(/\n/g, ' ') : '',
+ this._useBodyMarkup);
+ if (this._expandedLabel)
+ this._expandedLabel.setMarkup(text, this._useBodyMarkup);
+ }
+
+ setUseBodyMarkup(enable) {
+ if (this._useBodyMarkup === enable)
+ return;
+ this._useBodyMarkup = enable;
+ if (this.bodyLabel)
+ this.setBody(this._bodyText);
+ }
+
+ setActionArea(actor) {
+ if (actor == null) {
+ if (this._actionBin.get_n_children() > 0)
+ this._actionBin.get_child_at_index(0).destroy();
+ return;
+ }
+
+ if (this._actionBin.get_n_children() > 0)
+ throw new Error('Message already has an action area');
+
+ this._actionBin.add_actor(actor);
+ this._actionBin.visible = this.expanded;
+ }
+
+ addMediaControl(iconName, callback) {
+ let icon = new St.Icon({ icon_name: iconName, icon_size: 16 });
+ let button = new St.Button({ style_class: 'message-media-control',
+ child: icon });
+ button.connect('clicked', callback);
+ this._mediaControls.add_actor(button);
+ return button;
+ }
+
+ setExpandedBody(actor) {
+ if (actor == null) {
+ if (this._bodyStack.get_n_children() > 1)
+ this._bodyStack.get_child_at_index(1).destroy();
+ return;
+ }
+
+ if (this._bodyStack.get_n_children() > 1)
+ throw new Error('Message already has an expanded body actor');
+
+ this._bodyStack.insert_child_at_index(actor, 1);
+ }
+
+ setExpandedLines(nLines) {
+ this._bodyStack.layout_manager.expandLines = nLines;
+ }
+
+ expand(animate) {
+ this.expanded = true;
+
+ this._actionBin.visible = this._actionBin.get_n_children() > 0;
+
+ if (this._bodyStack.get_n_children() < 2) {
+ this._expandedLabel = new URLHighlighter(this._bodyText,
+ true, this._useBodyMarkup);
+ this.setExpandedBody(this._expandedLabel);
+ }
+
+ if (animate) {
+ this._bodyStack.ease_property('@layout.expansion', 1, {
+ progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: MessageTray.ANIMATION_TIME,
+ });
+
+ this._actionBin.scale_y = 0;
+ this._actionBin.ease({
+ scale_y: 1,
+ duration: MessageTray.ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ } else {
+ this._bodyStack.layout_manager.expansion = 1;
+ this._actionBin.scale_y = 1;
+ }
+
+ this.emit('expanded');
+ }
+
+ unexpand(animate) {
+ if (animate) {
+ this._bodyStack.ease_property('@layout.expansion', 0, {
+ progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: MessageTray.ANIMATION_TIME,
+ });
+
+ this._actionBin.ease({
+ scale_y: 0,
+ duration: MessageTray.ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ this._actionBin.hide();
+ this.expanded = false;
+ },
+ });
+ } else {
+ this._bodyStack.layout_manager.expansion = 0;
+ this._actionBin.scale_y = 0;
+ this.expanded = false;
+ }
+
+ this.emit('unexpanded');
+ }
+
+ canClose() {
+ return false;
+ }
+
+ _sync() {
+ let visible = this.hover && this.canClose();
+ this._closeButton.opacity = visible ? 255 : 0;
+ this._closeButton.reactive = visible;
+ }
+
+ _onDestroy() {
+ }
+
+ vfunc_key_press_event(keyEvent) {
+ let keysym = keyEvent.keyval;
+
+ if (keysym == Clutter.KEY_Delete ||
+ keysym == Clutter.KEY_KP_Delete) {
+ this.close();
+ return Clutter.EVENT_STOP;
+ }
+ return super.vfunc_key_press_event(keyEvent);
+ }
+});
+
+var MessageListSection = GObject.registerClass({
+ Properties: {
+ 'can-clear': GObject.ParamSpec.boolean(
+ 'can-clear', 'can-clear', 'can-clear',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'empty': GObject.ParamSpec.boolean(
+ 'empty', 'empty', 'empty',
+ GObject.ParamFlags.READABLE,
+ true),
+ },
+ Signals: {
+ 'can-clear-changed': {},
+ 'empty-changed': {},
+ 'message-focused': { param_types: [Message.$gtype] },
+ },
+}, class MessageListSection extends St.BoxLayout {
+ _init() {
+ super._init({
+ style_class: 'message-list-section',
+ clip_to_allocation: true,
+ vertical: true,
+ x_expand: true,
+ });
+
+ this._list = new St.BoxLayout({ style_class: 'message-list-section-list',
+ vertical: true });
+ this.add_actor(this._list);
+
+ this._list.connect('actor-added', this._sync.bind(this));
+ this._list.connect('actor-removed', this._sync.bind(this));
+
+ let id = Main.sessionMode.connect('updated',
+ this._sync.bind(this));
+ this.connect('destroy', () => {
+ Main.sessionMode.disconnect(id);
+ });
+
+ this._empty = true;
+ this._canClear = false;
+ this._sync();
+ }
+
+ get empty() {
+ return this._empty;
+ }
+
+ get canClear() {
+ return this._canClear;
+ }
+
+ get _messages() {
+ return this._list.get_children().map(i => i.child);
+ }
+
+ _onKeyFocusIn(messageActor) {
+ this.emit('message-focused', messageActor);
+ }
+
+ get allowed() {
+ return true;
+ }
+
+ addMessage(message, animate) {
+ this.addMessageAtIndex(message, -1, animate);
+ }
+
+ addMessageAtIndex(message, index, animate) {
+ if (this._messages.includes(message))
+ throw new Error('Message was already added previously');
+
+ let listItem = new St.Bin({
+ child: message,
+ layout_manager: new ScaleLayout(),
+ pivot_point: new Graphene.Point({ x: .5, y: .5 }),
+ });
+ listItem._connectionsIds = [];
+
+ listItem._connectionsIds.push(message.connect('key-focus-in',
+ this._onKeyFocusIn.bind(this)));
+ listItem._connectionsIds.push(message.connect('close', () => {
+ this.removeMessage(message, true);
+ }));
+ listItem._connectionsIds.push(message.connect('destroy', () => {
+ listItem._connectionsIds.forEach(id => message.disconnect(id));
+ listItem.destroy();
+ }));
+
+ this._list.insert_child_at_index(listItem, index);
+
+ if (animate) {
+ listItem.set({ scale_x: 0, scale_y: 0 });
+ listItem.ease({
+ scale_x: 1,
+ scale_y: 1,
+ duration: MESSAGE_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+ }
+
+ moveMessage(message, index, animate) {
+ if (!this._messages.includes(message))
+ throw new Error(`Impossible to move untracked message`);
+
+ let listItem = message.get_parent();
+
+ if (!animate) {
+ this._list.set_child_at_index(listItem, index);
+ return;
+ }
+
+ let onComplete = () => {
+ this._list.set_child_at_index(listItem, index);
+ listItem.ease({
+ scale_x: 1,
+ scale_y: 1,
+ duration: MESSAGE_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ };
+ listItem.ease({
+ scale_x: 0,
+ scale_y: 0,
+ duration: MESSAGE_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete,
+ });
+ }
+
+ removeMessage(message, animate) {
+ if (!this._messages.includes(message))
+ throw new Error(`Impossible to remove untracked message`);
+
+ let listItem = message.get_parent();
+ listItem._connectionsIds.forEach(id => message.disconnect(id));
+
+ if (animate) {
+ listItem.ease({
+ scale_x: 0,
+ scale_y: 0,
+ duration: MESSAGE_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ listItem.destroy();
+ global.sync_pointer();
+ },
+ });
+ } else {
+ listItem.destroy();
+ global.sync_pointer();
+ }
+ }
+
+ clear() {
+ let messages = this._messages.filter(msg => msg.canClose());
+
+ // If there are few messages, letting them all zoom out looks OK
+ if (messages.length < 2) {
+ messages.forEach(message => {
+ message.close();
+ });
+ } else {
+ // Otherwise we slide them out one by one, and then zoom them
+ // out "off-screen" in the end to smoothly shrink the parent
+ let delay = MESSAGE_ANIMATION_TIME / Math.max(messages.length, 5);
+ for (let i = 0; i < messages.length; i++) {
+ let message = messages[i];
+ message.get_parent().ease({
+ translation_x: this._list.width,
+ opacity: 0,
+ duration: MESSAGE_ANIMATION_TIME,
+ delay: i * delay,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => message.close(),
+ });
+ }
+ }
+ }
+
+ _shouldShow() {
+ return !this.empty;
+ }
+
+ _sync() {
+ let messages = this._messages;
+ let empty = messages.length == 0;
+
+ if (this._empty != empty) {
+ this._empty = empty;
+ this.notify('empty');
+ }
+
+ let canClear = messages.some(m => m.canClose());
+ if (this._canClear != canClear) {
+ this._canClear = canClear;
+ this.notify('can-clear');
+ }
+
+ this.visible = this.allowed && this._shouldShow();
+ }
+});
diff --git a/js/ui/messageTray.js b/js/ui/messageTray.js
new file mode 100644
index 0000000..546079f
--- /dev/null
+++ b/js/ui/messageTray.js
@@ -0,0 +1,1481 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported NotificationPolicy, NotificationGenericPolicy,
+ NotificationApplicationPolicy, Source, SourceActor,
+ SystemNotificationSource, MessageTray */
+
+const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi;
+
+const Calendar = imports.ui.calendar;
+const GnomeSession = imports.misc.gnomeSession;
+const Layout = imports.ui.layout;
+const Main = imports.ui.main;
+const Params = imports.misc.params;
+
+const SHELL_KEYBINDINGS_SCHEMA = 'org.gnome.shell.keybindings';
+
+var ANIMATION_TIME = 200;
+var NOTIFICATION_TIMEOUT = 4000;
+
+var HIDE_TIMEOUT = 200;
+var LONGER_HIDE_TIMEOUT = 600;
+
+var MAX_NOTIFICATIONS_IN_QUEUE = 3;
+var MAX_NOTIFICATIONS_PER_SOURCE = 3;
+var MAX_NOTIFICATION_BUTTONS = 3;
+
+// We delay hiding of the tray if the mouse is within MOUSE_LEFT_ACTOR_THRESHOLD
+// range from the point where it left the tray.
+var MOUSE_LEFT_ACTOR_THRESHOLD = 20;
+
+var IDLE_TIME = 1000;
+
+var State = {
+ HIDDEN: 0,
+ SHOWING: 1,
+ SHOWN: 2,
+ HIDING: 3,
+};
+
+// These reasons are useful when we destroy the notifications received through
+// the notification daemon. We use EXPIRED for notifications that we dismiss
+// and the user did not interact with, DISMISSED for all other notifications
+// that were destroyed as a result of a user action, SOURCE_CLOSED for the
+// notifications that were requested to be destroyed by the associated source,
+// and REPLACED for notifications that were destroyed as a consequence of a
+// newer version having replaced them.
+var NotificationDestroyedReason = {
+ EXPIRED: 1,
+ DISMISSED: 2,
+ SOURCE_CLOSED: 3,
+ REPLACED: 4,
+};
+
+// Message tray has its custom Urgency enumeration. LOW, NORMAL and CRITICAL
+// urgency values map to the corresponding values for the notifications received
+// through the notification daemon. HIGH urgency value is used for chats received
+// through the Telepathy client.
+var Urgency = {
+ LOW: 0,
+ NORMAL: 1,
+ HIGH: 2,
+ CRITICAL: 3,
+};
+
+// The privacy of the details of a notification. USER is for notifications which
+// contain private information to the originating user account (for example,
+// details of an e-mail they’ve received). SYSTEM is for notifications which
+// contain information private to the physical system (for example, battery
+// status) and hence the same for every user. This affects whether the content
+// of a notification is shown on the lock screen.
+var PrivacyScope = {
+ USER: 0,
+ SYSTEM: 1,
+};
+
+var FocusGrabber = class FocusGrabber {
+ constructor(actor) {
+ this._actor = actor;
+ this._prevKeyFocusActor = null;
+ this._focusActorChangedId = 0;
+ this._focused = false;
+ }
+
+ grabFocus() {
+ if (this._focused)
+ return;
+
+ this._prevKeyFocusActor = global.stage.get_key_focus();
+
+ this._focusActorChangedId = global.stage.connect('notify::key-focus', this._focusActorChanged.bind(this));
+
+ if (!this._actor.navigate_focus(null, St.DirectionType.TAB_FORWARD, false))
+ this._actor.grab_key_focus();
+
+ this._focused = true;
+ }
+
+ _focusUngrabbed() {
+ if (!this._focused)
+ return false;
+
+ if (this._focusActorChangedId > 0) {
+ global.stage.disconnect(this._focusActorChangedId);
+ this._focusActorChangedId = 0;
+ }
+
+ this._focused = false;
+ return true;
+ }
+
+ _focusActorChanged() {
+ let focusedActor = global.stage.get_key_focus();
+ if (!focusedActor || !this._actor.contains(focusedActor))
+ this._focusUngrabbed();
+ }
+
+ ungrabFocus() {
+ if (!this._focusUngrabbed())
+ return;
+
+ if (this._prevKeyFocusActor) {
+ global.stage.set_key_focus(this._prevKeyFocusActor);
+ this._prevKeyFocusActor = null;
+ } else {
+ let focusedActor = global.stage.get_key_focus();
+ if (focusedActor && this._actor.contains(focusedActor))
+ global.stage.set_key_focus(null);
+ }
+ }
+};
+
+// NotificationPolicy:
+// An object that holds all bits of configurable policy related to a notification
+// source, such as whether to play sound or honour the critical bit.
+//
+// A notification without a policy object will inherit the default one.
+var NotificationPolicy = GObject.registerClass({
+ GTypeFlags: GObject.TypeFlags.ABSTRACT,
+ Properties: {
+ 'enable': GObject.ParamSpec.boolean(
+ 'enable', 'enable', 'enable', GObject.ParamFlags.READABLE, true),
+ 'enable-sound': GObject.ParamSpec.boolean(
+ 'enable-sound', 'enable-sound', 'enable-sound',
+ GObject.ParamFlags.READABLE, true),
+ 'show-banners': GObject.ParamSpec.boolean(
+ 'show-banners', 'show-banners', 'show-banners',
+ GObject.ParamFlags.READABLE, true),
+ 'force-expanded': GObject.ParamSpec.boolean(
+ 'force-expanded', 'force-expanded', 'force-expanded',
+ GObject.ParamFlags.READABLE, false),
+ 'show-in-lock-screen': GObject.ParamSpec.boolean(
+ 'show-in-lock-screen', 'show-in-lock-screen', 'show-in-lock-screen',
+ GObject.ParamFlags.READABLE, false),
+ 'details-in-lock-screen': GObject.ParamSpec.boolean(
+ 'details-in-lock-screen', 'details-in-lock-screen', 'details-in-lock-screen',
+ GObject.ParamFlags.READABLE, false),
+ },
+}, class NotificationPolicy extends GObject.Object {
+ // Do nothing for the default policy. These methods are only useful for the
+ // GSettings policy.
+ store() { }
+
+ destroy() {
+ this.run_dispose();
+ }
+
+ get enable() {
+ return true;
+ }
+
+ get enableSound() {
+ return true;
+ }
+
+ get showBanners() {
+ return true;
+ }
+
+ get forceExpanded() {
+ return false;
+ }
+
+ get showInLockScreen() {
+ return false;
+ }
+
+ get detailsInLockScreen() {
+ return false;
+ }
+});
+
+var NotificationGenericPolicy = GObject.registerClass({
+}, class NotificationGenericPolicy extends NotificationPolicy {
+ _init() {
+ super._init();
+ this.id = 'generic';
+
+ this._masterSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.notifications' });
+ this._masterSettings.connect('changed', this._changed.bind(this));
+ }
+
+ destroy() {
+ this._masterSettings.run_dispose();
+
+ super.destroy();
+ }
+
+ _changed(settings, key) {
+ if (this.constructor.find_property(key))
+ this.notify(key);
+ }
+
+ get showBanners() {
+ return this._masterSettings.get_boolean('show-banners');
+ }
+
+ get showInLockScreen() {
+ return this._masterSettings.get_boolean('show-in-lock-screen');
+ }
+});
+
+var NotificationApplicationPolicy = GObject.registerClass({
+}, class NotificationApplicationPolicy extends NotificationPolicy {
+ _init(id) {
+ super._init();
+
+ this.id = id;
+ this._canonicalId = this._canonicalizeId(id);
+
+ this._masterSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.notifications' });
+ this._settings = new Gio.Settings({
+ schema_id: 'org.gnome.desktop.notifications.application',
+ path: '/org/gnome/desktop/notifications/application/%s/'.format(this._canonicalId),
+ });
+
+ this._masterSettings.connect('changed', this._changed.bind(this));
+ this._settings.connect('changed', this._changed.bind(this));
+ }
+
+ store() {
+ this._settings.set_string('application-id', '%s.desktop'.format(this.id));
+
+ let apps = this._masterSettings.get_strv('application-children');
+ if (!apps.includes(this._canonicalId)) {
+ apps.push(this._canonicalId);
+ this._masterSettings.set_strv('application-children', apps);
+ }
+ }
+
+ destroy() {
+ this._masterSettings.run_dispose();
+ this._settings.run_dispose();
+
+ super.destroy();
+ }
+
+ _changed(settings, key) {
+ if (this.constructor.find_property(key))
+ this.notify(key);
+ }
+
+ _canonicalizeId(id) {
+ // Keys are restricted to lowercase alphanumeric characters and dash,
+ // and two dashes cannot be in succession
+ return id.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/--+/g, '-');
+ }
+
+ get enable() {
+ return this._settings.get_boolean('enable');
+ }
+
+ get enableSound() {
+ return this._settings.get_boolean('enable-sound-alerts');
+ }
+
+ get showBanners() {
+ return this._masterSettings.get_boolean('show-banners') &&
+ this._settings.get_boolean('show-banners');
+ }
+
+ get forceExpanded() {
+ return this._settings.get_boolean('force-expanded');
+ }
+
+ get showInLockScreen() {
+ return this._masterSettings.get_boolean('show-in-lock-screen') &&
+ this._settings.get_boolean('show-in-lock-screen');
+ }
+
+ get detailsInLockScreen() {
+ return this._settings.get_boolean('details-in-lock-screen');
+ }
+});
+
+// Notification:
+// @source: the notification's Source
+// @title: the title
+// @banner: the banner text
+// @params: optional additional params
+//
+// Creates a notification. In the banner mode, the notification
+// will show an icon, @title (in bold) and @banner, all on a single
+// line (with @banner ellipsized if necessary).
+//
+// The notification will be expandable if either it has additional
+// elements that were added to it or if the @banner text did not
+// fit fully in the banner mode. When the notification is expanded,
+// the @banner text from the top line is always removed. The complete
+// @banner text is added as the first element in the content section,
+// unless 'customContent' parameter with the value 'true' is specified
+// in @params.
+//
+// Additional notification content can be added with addActor() and
+// addBody() methods. The notification content is put inside a
+// scrollview, so if it gets too tall, the notification will scroll
+// rather than continue to grow. In addition to this main content
+// area, there is also a single-row action area, which is not
+// scrolled and can contain a single actor. The action area can
+// be set by calling setActionArea() method. There is also a
+// convenience method addButton() for adding a button to the action
+// area.
+//
+// If @params contains a 'customContent' parameter with the value %true,
+// then @banner will not be shown in the body of the notification when the
+// notification is expanded and calls to update() will not clear the content
+// unless 'clear' parameter with value %true is explicitly specified.
+//
+// By default, the icon shown is the same as the source's.
+// However, if @params contains a 'gicon' parameter, the passed in gicon
+// will be used.
+//
+// You can add a secondary icon to the banner with 'secondaryGIcon'. There
+// is no fallback for this icon.
+//
+// If @params contains 'bannerMarkup', with the value %true, a subset (<b>,
+// <i> and <u>) of the markup in [1] will be interpreted within @banner. If
+// the parameter is not present, then anything that looks like markup
+// in @banner will appear literally in the output.
+//
+// If @params contains a 'clear' parameter with the value %true, then
+// the content and the action area of the notification will be cleared.
+// The content area is also always cleared if 'customContent' is false
+// because it might contain the @banner that didn't fit in the banner mode.
+//
+// If @params contains 'soundName' or 'soundFile', the corresponding
+// event sound is played when the notification is shown (if the policy for
+// @source allows playing sounds).
+//
+// [1] https://developer.gnome.org/notification-spec/#markup
+var Notification = GObject.registerClass({
+ Properties: {
+ 'acknowledged': GObject.ParamSpec.boolean(
+ 'acknowledged', 'acknowledged', 'acknowledged',
+ GObject.ParamFlags.READWRITE,
+ false),
+ },
+ Signals: {
+ 'activated': {},
+ 'destroy': { param_types: [GObject.TYPE_UINT] },
+ 'updated': { param_types: [GObject.TYPE_BOOLEAN] },
+ },
+}, class Notification extends GObject.Object {
+ _init(source, title, banner, params) {
+ super._init();
+
+ this.source = source;
+ this.title = title;
+ this.urgency = Urgency.NORMAL;
+ // 'transient' is a reserved keyword in JS, so we have to use an alternate variable name
+ this.isTransient = false;
+ this.privacyScope = PrivacyScope.USER;
+ this.forFeedback = false;
+ this._acknowledged = false;
+ this.bannerBodyText = null;
+ this.bannerBodyMarkup = false;
+ this._soundName = null;
+ this._soundFile = null;
+ this._soundPlayed = false;
+ this.actions = [];
+ this.setResident(false);
+
+ // If called with only one argument we assume the caller
+ // will call .update() later on. This is the case of
+ // NotificationDaemon, which wants to use the same code
+ // for new and updated notifications
+ if (arguments.length != 1)
+ this.update(title, banner, params);
+ }
+
+ // update:
+ // @title: the new title
+ // @banner: the new banner
+ // @params: as in the Notification constructor
+ //
+ // Updates the notification by regenerating its icon and updating
+ // the title/banner. If @params.clear is %true, it will also
+ // remove any additional actors/action buttons previously added.
+ update(title, banner, params) {
+ params = Params.parse(params, { gicon: null,
+ secondaryGIcon: null,
+ bannerMarkup: false,
+ clear: false,
+ datetime: null,
+ soundName: null,
+ soundFile: null });
+
+ this.title = title;
+ this.bannerBodyText = banner;
+ this.bannerBodyMarkup = params.bannerMarkup;
+
+ if (params.datetime)
+ this.datetime = params.datetime;
+ else
+ this.datetime = GLib.DateTime.new_now_local();
+
+ if (params.gicon || params.clear)
+ this.gicon = params.gicon;
+
+ if (params.secondaryGIcon || params.clear)
+ this.secondaryGIcon = params.secondaryGIcon;
+
+ if (params.clear)
+ this.actions = [];
+
+ if (this._soundName != params.soundName ||
+ this._soundFile != params.soundFile) {
+ this._soundName = params.soundName;
+ this._soundFile = params.soundFile;
+ this._soundPlayed = false;
+ }
+
+ this.emit('updated', params.clear);
+ }
+
+ // addAction:
+ // @label: the label for the action's button
+ // @callback: the callback for the action
+ addAction(label, callback) {
+ this.actions.push({ label, callback });
+ }
+
+ get acknowledged() {
+ return this._acknowledged;
+ }
+
+ set acknowledged(v) {
+ if (this._acknowledged == v)
+ return;
+ this._acknowledged = v;
+ this.notify('acknowledged');
+ }
+
+ setUrgency(urgency) {
+ this.urgency = urgency;
+ }
+
+ setResident(resident) {
+ this.resident = resident;
+
+ if (this.resident) {
+ if (this._activatedId) {
+ this.disconnect(this._activatedId);
+ this._activatedId = 0;
+ }
+ } else if (!this._activatedId) {
+ this._activatedId = this.connect_after('activated', () => this.destroy());
+ }
+ }
+
+ setTransient(isTransient) {
+ this.isTransient = isTransient;
+ }
+
+ setForFeedback(forFeedback) {
+ this.forFeedback = forFeedback;
+ }
+
+ setPrivacyScope(privacyScope) {
+ this.privacyScope = privacyScope;
+ }
+
+ playSound() {
+ if (this._soundPlayed)
+ return;
+
+ if (!this.source.policy.enableSound) {
+ this._soundPlayed = true;
+ return;
+ }
+
+ let player = global.display.get_sound_player();
+ if (this._soundName)
+ player.play_from_theme(this._soundName, this.title, null);
+ else if (this._soundFile)
+ player.play_from_file(this._soundFile, this.title, null);
+ }
+
+ // Allow customizing the banner UI:
+ // the default implementation defers the creation to
+ // the source (which will create a NotificationBanner),
+ // so customization can be done by subclassing either
+ // Notification or Source
+ createBanner() {
+ return this.source.createBanner(this);
+ }
+
+ activate() {
+ this.emit('activated');
+ }
+
+ destroy(reason = NotificationDestroyedReason.DISMISSED) {
+ if (this._activatedId) {
+ this.disconnect(this._activatedId);
+ delete this._activatedId;
+ }
+
+ this.emit('destroy', reason);
+ this.run_dispose();
+ }
+});
+
+var NotificationBanner = GObject.registerClass({
+ Signals: {
+ 'done-displaying': {},
+ 'unfocused': {},
+ },
+}, class NotificationBanner extends Calendar.NotificationMessage {
+ _init(notification) {
+ super._init(notification);
+
+ this.can_focus = false;
+ this.add_style_class_name('notification-banner');
+
+ this._buttonBox = null;
+
+ this._addActions();
+ this._addSecondaryIcon();
+
+ this._activatedId = this.notification.connect('activated', () => {
+ // We hide all types of notifications once the user clicks on
+ // them because the common outcome of clicking should be the
+ // relevant window being brought forward and the user's
+ // attention switching to the window.
+ this.emit('done-displaying');
+ });
+ }
+
+ _disconnectNotificationSignals() {
+ super._disconnectNotificationSignals();
+
+ if (this._activatedId)
+ this.notification.disconnect(this._activatedId);
+ this._activatedId = 0;
+ }
+
+ _onUpdated(n, clear) {
+ super._onUpdated(n, clear);
+
+ if (clear) {
+ this.setSecondaryActor(null);
+ this.setActionArea(null);
+ this._buttonBox = null;
+ }
+
+ this._addActions();
+ this._addSecondaryIcon();
+ }
+
+ _addActions() {
+ this.notification.actions.forEach(action => {
+ this.addAction(action.label, action.callback);
+ });
+ }
+
+ _addSecondaryIcon() {
+ if (this.notification.secondaryGIcon) {
+ let icon = new St.Icon({ gicon: this.notification.secondaryGIcon,
+ x_align: Clutter.ActorAlign.END });
+ this.setSecondaryActor(icon);
+ }
+ }
+
+ addButton(button, callback) {
+ 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);
+ }
+
+ if (this._buttonBox.get_n_children() >= MAX_NOTIFICATION_BUTTONS)
+ return null;
+
+ this._buttonBox.add(button);
+ button.connect('clicked', () => {
+ callback();
+
+ if (!this.notification.resident) {
+ // We don't hide a resident notification when the user invokes one of its actions,
+ // because it is common for such notifications to update themselves with new
+ // information based on the action. We'd like to display the updated information
+ // in place, rather than pop-up a new notification.
+ this.emit('done-displaying');
+ this.notification.destroy();
+ }
+ });
+
+ return button;
+ }
+
+ addAction(label, callback) {
+ let button = new St.Button({ style_class: 'notification-button',
+ label,
+ x_expand: true,
+ can_focus: true });
+
+ return this.addButton(button, callback);
+ }
+});
+
+var SourceActor = GObject.registerClass(
+class SourceActor extends St.Widget {
+ _init(source, size) {
+ super._init();
+
+ this._source = source;
+ this._size = size;
+
+ this.connect('destroy', () => {
+ this._source.disconnect(this._iconUpdatedId);
+ this._actorDestroyed = true;
+ });
+ this._actorDestroyed = false;
+
+ let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ this._iconBin = new St.Bin({ x_expand: true,
+ height: size * scaleFactor,
+ width: size * scaleFactor });
+
+ this.add_actor(this._iconBin);
+
+ this._iconUpdatedId = this._source.connect('icon-updated', this._updateIcon.bind(this));
+ this._updateIcon();
+ }
+
+ setIcon(icon) {
+ this._iconBin.child = icon;
+ this._iconSet = true;
+ }
+
+ _updateIcon() {
+ if (this._actorDestroyed)
+ return;
+
+ if (!this._iconSet)
+ this._iconBin.child = this._source.createIcon(this._size);
+ }
+});
+
+var Source = GObject.registerClass({
+ Properties: {
+ 'count': GObject.ParamSpec.int(
+ 'count', 'count', 'count',
+ GObject.ParamFlags.READABLE,
+ 0, GLib.MAXINT32, 0),
+ 'policy': GObject.ParamSpec.object(
+ 'policy', 'policy', 'policy',
+ GObject.ParamFlags.READWRITE,
+ NotificationPolicy.$gtype),
+ 'title': GObject.ParamSpec.string(
+ 'title', 'title', 'title',
+ GObject.ParamFlags.READWRITE,
+ null),
+ },
+ Signals: {
+ 'destroy': { param_types: [GObject.TYPE_UINT] },
+ 'icon-updated': {},
+ 'notification-added': { param_types: [Notification.$gtype] },
+ 'notification-show': { param_types: [Notification.$gtype] },
+ },
+}, class Source extends GObject.Object {
+ _init(title, iconName) {
+ super._init({ title });
+
+ this.SOURCE_ICON_SIZE = 48;
+
+ this.iconName = iconName;
+
+ this.isChat = false;
+
+ this.notifications = [];
+
+ this._policy = this._createPolicy();
+ }
+
+ get policy() {
+ return this._policy;
+ }
+
+ set policy(policy) {
+ if (this._policy)
+ this._policy.destroy();
+ this._policy = policy;
+ }
+
+ get count() {
+ return this.notifications.length;
+ }
+
+ get unseenCount() {
+ return this.notifications.filter(n => !n.acknowledged).length;
+ }
+
+ get countVisible() {
+ return this.count > 1;
+ }
+
+ countUpdated() {
+ super.notify('count');
+ }
+
+ _createPolicy() {
+ return new NotificationGenericPolicy();
+ }
+
+ get narrowestPrivacyScope() {
+ return this.notifications.every(n => n.privacyScope == PrivacyScope.SYSTEM)
+ ? PrivacyScope.SYSTEM
+ : PrivacyScope.USER;
+ }
+
+ setTitle(newTitle) {
+ if (this.title == newTitle)
+ return;
+
+ this.title = newTitle;
+ this.notify('title');
+ }
+
+ createBanner(notification) {
+ return new NotificationBanner(notification);
+ }
+
+ // Called to create a new icon actor.
+ // Provides a sane default implementation, override if you need
+ // something more fancy.
+ createIcon(size) {
+ return new St.Icon({ gicon: this.getIcon(),
+ icon_size: size });
+ }
+
+ getIcon() {
+ return new Gio.ThemedIcon({ name: this.iconName });
+ }
+
+ _onNotificationDestroy(notification) {
+ let index = this.notifications.indexOf(notification);
+ if (index < 0)
+ return;
+
+ this.notifications.splice(index, 1);
+ this.countUpdated();
+
+ if (this.notifications.length == 0)
+ this.destroy();
+ }
+
+ pushNotification(notification) {
+ if (this.notifications.includes(notification))
+ return;
+
+ while (this.notifications.length >= MAX_NOTIFICATIONS_PER_SOURCE)
+ this.notifications.shift().destroy(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();
+ }
+
+ showNotification(notification) {
+ notification.acknowledged = false;
+ this.pushNotification(notification);
+
+ if (this.policy.showBanners || notification.urgency == Urgency.CRITICAL)
+ this.emit('notification-show', notification);
+ }
+
+ notify(propName) {
+ if (propName instanceof Notification) {
+ try {
+ throw new Error('Source.notify() has been moved to Source.showNotification()' +
+ 'this code will break in the future');
+ } catch (e) {
+ logError(e);
+ this.showNotification(propName);
+ return;
+ }
+ }
+
+ super.notify(propName);
+ }
+
+ destroy(reason) {
+ let notifications = this.notifications;
+ this.notifications = [];
+
+ for (let i = 0; i < notifications.length; i++)
+ notifications[i].destroy(reason);
+
+ this.emit('destroy', reason);
+
+ this.policy.destroy();
+ this.run_dispose();
+ }
+
+ iconUpdated() {
+ this.emit('icon-updated');
+ }
+
+ // To be overridden by subclasses
+ open() {
+ }
+
+ destroyNonResidentNotifications() {
+ for (let i = this.notifications.length - 1; i >= 0; i--) {
+ if (!this.notifications[i].resident)
+ this.notifications[i].destroy();
+ }
+ }
+});
+
+var MessageTray = GObject.registerClass({
+ Signals: {
+ 'queue-changed': {},
+ 'source-added': { param_types: [Source.$gtype] },
+ 'source-removed': { param_types: [Source.$gtype] },
+ },
+}, class MessageTray extends St.Widget {
+ _init() {
+ super._init({
+ visible: false,
+ clip_to_allocation: true,
+ layout_manager: new Clutter.BinLayout(),
+ });
+
+ this._presence = new GnomeSession.Presence((proxy, _error) => {
+ this._onStatusChanged(proxy.status);
+ });
+ this._busy = false;
+ this._bannerBlocked = false;
+ this._presence.connectSignal('StatusChanged', (proxy, senderName, [status]) => {
+ this._onStatusChanged(status);
+ });
+
+ global.stage.connect('enter-event', (a, ev) => {
+ // HACK: St uses ClutterInputDevice for hover tracking, which
+ // misses relevant X11 events when untracked actors are
+ // involved (read: the notification banner in normal mode),
+ // so fix up Clutter's view of the pointer position in
+ // that case.
+ let related = ev.get_related();
+ if (!related || this.contains(related))
+ global.sync_pointer();
+ });
+
+ let constraint = new Layout.MonitorConstraint({ primary: true });
+ Main.layoutManager.panelBox.bind_property('visible',
+ constraint, 'work-area',
+ GObject.BindingFlags.SYNC_CREATE);
+ this.add_constraint(constraint);
+
+ this._bannerBin = new St.Widget({ name: 'notification-container',
+ reactive: true,
+ track_hover: true,
+ y_align: Clutter.ActorAlign.START,
+ x_align: Clutter.ActorAlign.CENTER,
+ y_expand: true,
+ x_expand: true,
+ layout_manager: new Clutter.BinLayout() });
+ this._bannerBin.connect('key-release-event',
+ this._onNotificationKeyRelease.bind(this));
+ this._bannerBin.connect('notify::hover',
+ this._onNotificationHoverChanged.bind(this));
+ this.add_actor(this._bannerBin);
+
+ this._notificationFocusGrabber = new FocusGrabber(this._bannerBin);
+ this._notificationQueue = [];
+ this._notification = null;
+ this._banner = null;
+ this._bannerClickedId = 0;
+
+ this._userActiveWhileNotificationShown = false;
+
+ this.idleMonitor = Meta.IdleMonitor.get_core();
+
+ this._useLongerNotificationLeftTimeout = false;
+
+ // pointerInNotification is sort of a misnomer -- it tracks whether
+ // a message tray notification should expand. The value is
+ // partially driven by the hover state of the notification, but has
+ // a lot of complex state related to timeouts and the current
+ // state of the pointer when a notification pops up.
+ this._pointerInNotification = false;
+
+ // This tracks this._bannerBin.hover and is used to fizzle
+ // out non-changing hover notifications in onNotificationHoverChanged.
+ this._notificationHovered = false;
+
+ this._notificationState = State.HIDDEN;
+ this._notificationTimeoutId = 0;
+ this._notificationRemoved = false;
+
+ Main.layoutManager.addChrome(this, { affectsInputRegion: false });
+ Main.layoutManager.trackChrome(this._bannerBin, { affectsInputRegion: true });
+
+ global.display.connect('in-fullscreen-changed', this._updateState.bind(this));
+
+ Main.sessionMode.connect('updated', this._sessionUpdated.bind(this));
+
+ Main.overview.connect('window-drag-begin',
+ this._onDragBegin.bind(this));
+ Main.overview.connect('window-drag-cancelled',
+ this._onDragEnd.bind(this));
+ Main.overview.connect('window-drag-end',
+ this._onDragEnd.bind(this));
+
+ Main.overview.connect('item-drag-begin',
+ this._onDragBegin.bind(this));
+ Main.overview.connect('item-drag-cancelled',
+ this._onDragEnd.bind(this));
+ Main.overview.connect('item-drag-end',
+ this._onDragEnd.bind(this));
+
+ Main.xdndHandler.connect('drag-begin',
+ this._onDragBegin.bind(this));
+ Main.xdndHandler.connect('drag-end',
+ this._onDragEnd.bind(this));
+
+ Main.wm.addKeybinding('focus-active-notification',
+ new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }),
+ Meta.KeyBindingFlags.NONE,
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._expandActiveNotification.bind(this));
+
+ this._sources = new Map();
+
+ this._sessionUpdated();
+ }
+
+ _sessionUpdated() {
+ this._updateState();
+ }
+
+ _onDragBegin() {
+ Shell.util_set_hidden_from_pick(this, true);
+ }
+
+ _onDragEnd() {
+ Shell.util_set_hidden_from_pick(this, false);
+ }
+
+ get bannerAlignment() {
+ return this._bannerBin.get_x_align();
+ }
+
+ set bannerAlignment(align) {
+ this._bannerBin.set_x_align(align);
+ }
+
+ _onNotificationKeyRelease(actor, event) {
+ if (event.get_key_symbol() == Clutter.KEY_Escape && event.get_state() == 0) {
+ this._expireNotification();
+ return Clutter.EVENT_STOP;
+ }
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _expireNotification() {
+ this._notificationExpired = true;
+ this._updateState();
+ }
+
+ get queueCount() {
+ return this._notificationQueue.length;
+ }
+
+ set bannerBlocked(v) {
+ if (this._bannerBlocked == v)
+ return;
+ this._bannerBlocked = v;
+ this._updateState();
+ }
+
+ contains(source) {
+ return this._sources.has(source);
+ }
+
+ add(source) {
+ if (this.contains(source)) {
+ log('Trying to re-add source %s'.format(source.title));
+ return;
+ }
+
+ // Register that we got a notification for this source
+ source.policy.store();
+
+ source.policy.connect('notify::enable', () => {
+ this._onSourceEnableChanged(source.policy, source);
+ });
+ source.policy.connect('notify', this._updateState.bind(this));
+ this._onSourceEnableChanged(source.policy, source);
+ }
+
+ _addSource(source) {
+ let obj = {
+ showId: 0,
+ destroyId: 0,
+ };
+
+ this._sources.set(source, obj);
+
+ obj.showId = source.connect('notification-show', this._onNotificationShow.bind(this));
+ obj.destroyId = source.connect('destroy', this._onSourceDestroy.bind(this));
+
+ this.emit('source-added', source);
+ }
+
+ _removeSource(source) {
+ let obj = this._sources.get(source);
+ this._sources.delete(source);
+
+ source.disconnect(obj.showId);
+ source.disconnect(obj.destroyId);
+
+ this.emit('source-removed', source);
+ }
+
+ getSources() {
+ return [...this._sources.keys()];
+ }
+
+ _onSourceEnableChanged(policy, source) {
+ let wasEnabled = this.contains(source);
+ let shouldBeEnabled = policy.enable;
+
+ if (wasEnabled != shouldBeEnabled) {
+ if (shouldBeEnabled)
+ this._addSource(source);
+ else
+ this._removeSource(source);
+ }
+ }
+
+ _onSourceDestroy(source) {
+ this._removeSource(source);
+ }
+
+ _onNotificationDestroy(notification) {
+ if (this._notification == notification && (this._notificationState == State.SHOWN || this._notificationState == State.SHOWING)) {
+ this._updateNotificationTimeout(0);
+ this._notificationRemoved = true;
+ this._updateState();
+ return;
+ }
+
+ let index = this._notificationQueue.indexOf(notification);
+ if (index != -1) {
+ this._notificationQueue.splice(index, 1);
+ this.emit('queue-changed');
+ }
+ }
+
+ _onNotificationShow(_source, notification) {
+ if (this._notification == notification) {
+ // If a notification that is being shown is updated, we update
+ // how it is shown and extend the time until it auto-hides.
+ // If a new notification is updated while it is being hidden,
+ // we stop hiding it and show it again.
+ this._updateShowingNotification();
+ } else if (!this._notificationQueue.includes(notification)) {
+ // If the queue is "full", we skip banner mode and just show a small
+ // indicator in the panel; however do make an exception for CRITICAL
+ // notifications, as only banner mode allows expansion.
+ let bannerCount = this._notification ? 1 : 0;
+ let full = this.queueCount + bannerCount >= MAX_NOTIFICATIONS_IN_QUEUE;
+ if (!full || notification.urgency == Urgency.CRITICAL) {
+ notification.connect('destroy',
+ this._onNotificationDestroy.bind(this));
+ this._notificationQueue.push(notification);
+ this._notificationQueue.sort(
+ (n1, n2) => n2.urgency - n1.urgency);
+ this.emit('queue-changed');
+ }
+ }
+ this._updateState();
+ }
+
+ _resetNotificationLeftTimeout() {
+ this._useLongerNotificationLeftTimeout = false;
+ if (this._notificationLeftTimeoutId) {
+ GLib.source_remove(this._notificationLeftTimeoutId);
+ this._notificationLeftTimeoutId = 0;
+ this._notificationLeftMouseX = -1;
+ this._notificationLeftMouseY = -1;
+ }
+ }
+
+ _onNotificationHoverChanged() {
+ if (this._bannerBin.hover == this._notificationHovered)
+ return;
+
+ this._notificationHovered = this._bannerBin.hover;
+ if (this._notificationHovered) {
+ this._resetNotificationLeftTimeout();
+
+ if (this._showNotificationMouseX >= 0) {
+ let actorAtShowNotificationPosition =
+ global.stage.get_actor_at_pos(Clutter.PickMode.ALL, this._showNotificationMouseX, this._showNotificationMouseY);
+ this._showNotificationMouseX = -1;
+ this._showNotificationMouseY = -1;
+ // Don't set this._pointerInNotification to true if the pointer was initially in the area where the notification
+ // popped up. That way we will not be expanding notifications that happen to pop up over the pointer
+ // automatically. Instead, the user is able to expand the notification by mousing away from it and then
+ // mousing back in. Because this is an expected action, we set the boolean flag that indicates that a longer
+ // timeout should be used before popping down the notification.
+ if (this._bannerBin.contains(actorAtShowNotificationPosition)) {
+ this._useLongerNotificationLeftTimeout = true;
+ return;
+ }
+ }
+
+ this._pointerInNotification = true;
+ this._updateState();
+ } else {
+ // We record the position of the mouse the moment it leaves the tray. These coordinates are used in
+ // this._onNotificationLeftTimeout() to determine if the mouse has moved far enough during the initial timeout for us
+ // to consider that the user intended to leave the tray and therefore hide the tray. If the mouse is still
+ // close to its previous position, we extend the timeout once.
+ let [x, y] = global.get_pointer();
+ this._notificationLeftMouseX = x;
+ this._notificationLeftMouseY = y;
+
+ // We wait just a little before hiding the message tray in case the user quickly moves the mouse back into it.
+ // We wait for a longer period if the notification popped up where the mouse pointer was already positioned.
+ // That gives the user more time to mouse away from the notification and mouse back in in order to expand it.
+ let timeout = this._useLongerNotificationLeftTimeout ? LONGER_HIDE_TIMEOUT : HIDE_TIMEOUT;
+ this._notificationLeftTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, timeout, this._onNotificationLeftTimeout.bind(this));
+ GLib.Source.set_name_by_id(this._notificationLeftTimeoutId, '[gnome-shell] this._onNotificationLeftTimeout');
+ }
+ }
+
+ _onStatusChanged(status) {
+ if (status == GnomeSession.PresenceStatus.BUSY) {
+ // remove notification and allow the summary to be closed now
+ this._updateNotificationTimeout(0);
+ this._busy = true;
+ } else if (status != GnomeSession.PresenceStatus.IDLE) {
+ // We preserve the previous value of this._busy if the status turns to IDLE
+ // so that we don't start showing notifications queued during the BUSY state
+ // as the screensaver gets activated.
+ this._busy = false;
+ }
+
+ this._updateState();
+ }
+
+ _onNotificationLeftTimeout() {
+ let [x, y] = global.get_pointer();
+ // We extend the timeout once if the mouse moved no further than MOUSE_LEFT_ACTOR_THRESHOLD to either side.
+ if (this._notificationLeftMouseX > -1 &&
+ y < this._notificationLeftMouseY + MOUSE_LEFT_ACTOR_THRESHOLD &&
+ y > this._notificationLeftMouseY - MOUSE_LEFT_ACTOR_THRESHOLD &&
+ x < this._notificationLeftMouseX + MOUSE_LEFT_ACTOR_THRESHOLD &&
+ x > this._notificationLeftMouseX - MOUSE_LEFT_ACTOR_THRESHOLD) {
+ this._notificationLeftMouseX = -1;
+ this._notificationLeftTimeoutId = GLib.timeout_add(
+ GLib.PRIORITY_DEFAULT,
+ LONGER_HIDE_TIMEOUT,
+ this._onNotificationLeftTimeout.bind(this));
+ GLib.Source.set_name_by_id(this._notificationLeftTimeoutId, '[gnome-shell] this._onNotificationLeftTimeout');
+ } else {
+ this._notificationLeftTimeoutId = 0;
+ this._useLongerNotificationLeftTimeout = false;
+ this._pointerInNotification = false;
+ this._updateNotificationTimeout(0);
+ this._updateState();
+ }
+ return GLib.SOURCE_REMOVE;
+ }
+
+ _escapeTray() {
+ this._pointerInNotification = false;
+ this._updateNotificationTimeout(0);
+ this._updateState();
+ }
+
+ // All of the logic for what happens when occurs here; the various
+ // event handlers merely update variables such as
+ // 'this._pointerInNotification', 'this._traySummoned', etc, and
+ // _updateState() figures out what (if anything) needs to be done
+ // at the present time.
+ _updateState() {
+ let hasMonitor = Main.layoutManager.primaryMonitor != null;
+ this.visible = !this._bannerBlocked && hasMonitor && this._banner != null;
+ if (this._bannerBlocked || !hasMonitor)
+ return;
+
+ // If our state changes caused _updateState to be called,
+ // just exit now to prevent reentrancy issues.
+ if (this._updatingState)
+ return;
+
+ this._updatingState = true;
+
+ // Filter out acknowledged notifications.
+ let changed = false;
+ this._notificationQueue = this._notificationQueue.filter(n => {
+ changed = changed || n.acknowledged;
+ return !n.acknowledged;
+ });
+
+ if (changed)
+ this.emit('queue-changed');
+
+ let hasNotifications = Main.sessionMode.hasNotifications;
+
+ if (this._notificationState == State.HIDDEN) {
+ let nextNotification = this._notificationQueue[0] || null;
+ if (hasNotifications && nextNotification) {
+ let limited = this._busy || Main.layoutManager.primaryMonitor.inFullscreen;
+ let showNextNotification = !limited || nextNotification.forFeedback || nextNotification.urgency == Urgency.CRITICAL;
+ if (showNextNotification)
+ this._showNotification();
+ }
+ } else if (this._notificationState == State.SHOWN) {
+ let expired = (this._userActiveWhileNotificationShown &&
+ this._notificationTimeoutId == 0 &&
+ this._notification.urgency != Urgency.CRITICAL &&
+ !this._banner.focused &&
+ !this._pointerInNotification) || this._notificationExpired;
+ let mustClose = this._notificationRemoved || !hasNotifications || expired;
+
+ if (mustClose) {
+ let animate = hasNotifications && !this._notificationRemoved;
+ this._hideNotification(animate);
+ } else if (this._pointerInNotification && !this._banner.expanded) {
+ this._expandBanner(false);
+ } else if (this._pointerInNotification) {
+ this._ensureBannerFocused();
+ }
+ }
+
+ this._updatingState = false;
+
+ // Clean transient variables that are used to communicate actions
+ // to updateState()
+ this._notificationExpired = false;
+ }
+
+ _onIdleMonitorBecameActive() {
+ this._userActiveWhileNotificationShown = true;
+ this._updateNotificationTimeout(2000);
+ this._updateState();
+ }
+
+ _showNotification() {
+ this._notification = this._notificationQueue.shift();
+ this.emit('queue-changed');
+
+ this._userActiveWhileNotificationShown = this.idleMonitor.get_idletime() <= IDLE_TIME;
+ if (!this._userActiveWhileNotificationShown) {
+ // If the user isn't active, set up a watch to let us know
+ // when the user becomes active.
+ this.idleMonitor.add_user_active_watch(this._onIdleMonitorBecameActive.bind(this));
+ }
+
+ this._banner = this._notification.createBanner();
+ this._bannerClickedId = this._banner.connect('done-displaying',
+ this._escapeTray.bind(this));
+ this._bannerUnfocusedId = this._banner.connect('unfocused', () => {
+ this._updateState();
+ });
+
+ this._bannerBin.add_actor(this._banner);
+
+ this._bannerBin.opacity = 0;
+ this._bannerBin.y = -this._banner.height;
+ this.show();
+
+ Meta.disable_unredirect_for_display(global.display);
+ this._updateShowingNotification();
+
+ let [x, y] = global.get_pointer();
+ // We save the position of the mouse at the time when we started showing the notification
+ // in order to determine if the notification popped up under it. We make that check if
+ // the user starts moving the mouse and _onNotificationHoverChanged() gets called. We don't
+ // expand the notification if it just happened to pop up under the mouse unless the user
+ // explicitly mouses away from it and then mouses back in.
+ this._showNotificationMouseX = x;
+ this._showNotificationMouseY = y;
+ // We save the coordinates of the mouse at the time when we started showing the notification
+ // and then we update it in _notificationTimeout(). We don't pop down the notification if
+ // the mouse is moving towards it or within it.
+ this._lastSeenMouseX = x;
+ this._lastSeenMouseY = y;
+
+ this._resetNotificationLeftTimeout();
+ }
+
+ _updateShowingNotification() {
+ this._notification.acknowledged = true;
+ this._notification.playSound();
+
+ // We auto-expand notifications with CRITICAL urgency, or for which the relevant setting
+ // is on in the control center.
+ if (this._notification.urgency == Urgency.CRITICAL ||
+ this._notification.source.policy.forceExpanded)
+ this._expandBanner(true);
+
+ // We tween all notifications to full opacity. This ensures that both new notifications and
+ // notifications that might have been in the process of hiding get full opacity.
+ //
+ // We tween any notification showing in the banner mode to the appropriate height
+ // (which is banner height or expanded height, depending on the notification state)
+ // This ensures that both new notifications and notifications in the banner mode that might
+ // have been in the process of hiding are shown with the correct height.
+ //
+ // We use this._showNotificationCompleted() onComplete callback to extend the time the updated
+ // notification is being shown.
+
+ this._notificationState = State.SHOWING;
+ this._bannerBin.remove_all_transitions();
+ this._bannerBin.ease({
+ opacity: 255,
+ duration: ANIMATION_TIME,
+ mode: Clutter.AnimationMode.LINEAR,
+ });
+ this._bannerBin.ease({
+ y: 0,
+ duration: ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_BACK,
+ onComplete: () => {
+ this._notificationState = State.SHOWN;
+ this._showNotificationCompleted();
+ this._updateState();
+ },
+ });
+ }
+
+ _showNotificationCompleted() {
+ if (this._notification.urgency != Urgency.CRITICAL)
+ this._updateNotificationTimeout(NOTIFICATION_TIMEOUT);
+ }
+
+ _updateNotificationTimeout(timeout) {
+ if (this._notificationTimeoutId) {
+ GLib.source_remove(this._notificationTimeoutId);
+ this._notificationTimeoutId = 0;
+ }
+ if (timeout > 0) {
+ this._notificationTimeoutId =
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT, timeout,
+ this._notificationTimeout.bind(this));
+ GLib.Source.set_name_by_id(this._notificationTimeoutId, '[gnome-shell] this._notificationTimeout');
+ }
+ }
+
+ _notificationTimeout() {
+ let [x, y] = global.get_pointer();
+ if (y < this._lastSeenMouseY - 10 && !this._notificationHovered) {
+ // The mouse is moving towards the notification, so don't
+ // hide it yet. (We just create a new timeout (and destroy
+ // the old one) each time because the bookkeeping is
+ // simpler.)
+ this._updateNotificationTimeout(1000);
+ } else if (this._useLongerNotificationLeftTimeout && !this._notificationLeftTimeoutId &&
+ (x != this._lastSeenMouseX || y != this._lastSeenMouseY)) {
+ // Refresh the timeout if the notification originally
+ // popped up under the pointer, and the pointer is hovering
+ // inside it.
+ this._updateNotificationTimeout(1000);
+ } else {
+ this._notificationTimeoutId = 0;
+ this._updateState();
+ }
+
+ this._lastSeenMouseX = x;
+ this._lastSeenMouseY = y;
+ return GLib.SOURCE_REMOVE;
+ }
+
+ _hideNotification(animate) {
+ this._notificationFocusGrabber.ungrabFocus();
+
+ if (this._bannerClickedId) {
+ this._banner.disconnect(this._bannerClickedId);
+ this._bannerClickedId = 0;
+ }
+ if (this._bannerUnfocusedId) {
+ this._banner.disconnect(this._bannerUnfocusedId);
+ this._bannerUnfocusedId = 0;
+ }
+
+ this._resetNotificationLeftTimeout();
+ this._bannerBin.remove_all_transitions();
+
+ if (animate) {
+ this._notificationState = State.HIDING;
+ this._bannerBin.ease({
+ opacity: 0,
+ duration: ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_BACK,
+ });
+ this._bannerBin.ease({
+ y: -this._bannerBin.height,
+ duration: ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_BACK,
+ onComplete: () => {
+ this._notificationState = State.HIDDEN;
+ this._hideNotificationCompleted();
+ this._updateState();
+ },
+ });
+ } else {
+ this._bannerBin.y = -this._bannerBin.height;
+ this._bannerBin.opacity = 0;
+ this._notificationState = State.HIDDEN;
+ this._hideNotificationCompleted();
+ }
+ }
+
+ _hideNotificationCompleted() {
+ let notification = this._notification;
+ this._notification = null;
+ if (!this._notificationRemoved && notification.isTransient)
+ notification.destroy(NotificationDestroyedReason.EXPIRED);
+
+ this._pointerInNotification = false;
+ this._notificationRemoved = false;
+ Meta.enable_unredirect_for_display(global.display);
+
+ this._banner.destroy();
+ this._banner = null;
+ this.hide();
+ }
+
+ _expandActiveNotification() {
+ if (!this._banner)
+ return;
+
+ this._expandBanner(false);
+ }
+
+ _expandBanner(autoExpanding) {
+ // Don't animate changes in notifications that are auto-expanding.
+ this._banner.expand(!autoExpanding);
+
+ // Don't focus notifications that are auto-expanding.
+ if (!autoExpanding)
+ this._ensureBannerFocused();
+ }
+
+ _ensureBannerFocused() {
+ this._notificationFocusGrabber.grabFocus();
+ }
+});
+
+var SystemNotificationSource = GObject.registerClass(
+class SystemNotificationSource extends Source {
+ _init() {
+ super._init(_("System Information"), 'dialog-information-symbolic');
+ }
+
+ open() {
+ this.destroy();
+ }
+});
diff --git a/js/ui/modalDialog.js b/js/ui/modalDialog.js
new file mode 100644
index 0000000..caa8744
--- /dev/null
+++ b/js/ui/modalDialog.js
@@ -0,0 +1,264 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported ModalDialog */
+
+const { Atk, Clutter, GObject, Shell, St } = imports.gi;
+
+const Dialog = imports.ui.dialog;
+const Layout = imports.ui.layout;
+const Lightbox = imports.ui.lightbox;
+const Main = imports.ui.main;
+const Params = imports.misc.params;
+
+var OPEN_AND_CLOSE_TIME = 100;
+var FADE_OUT_DIALOG_TIME = 1000;
+
+var State = {
+ OPENED: 0,
+ CLOSED: 1,
+ OPENING: 2,
+ CLOSING: 3,
+ FADED_OUT: 4,
+};
+
+var ModalDialog = GObject.registerClass({
+ Properties: {
+ 'state': GObject.ParamSpec.int('state', 'Dialog state', 'state',
+ GObject.ParamFlags.READABLE,
+ Math.min(...Object.values(State)),
+ Math.max(...Object.values(State)),
+ State.CLOSED),
+ },
+ Signals: { 'opened': {}, 'closed': {} },
+}, class ModalDialog extends St.Widget {
+ _init(params) {
+ super._init({ visible: false,
+ x: 0,
+ y: 0,
+ accessible_role: Atk.Role.DIALOG });
+
+ params = Params.parse(params, { shellReactive: false,
+ styleClass: null,
+ actionMode: Shell.ActionMode.SYSTEM_MODAL,
+ shouldFadeIn: true,
+ shouldFadeOut: true,
+ destroyOnClose: true });
+
+ this._state = State.CLOSED;
+ this._hasModal = false;
+ this._actionMode = params.actionMode;
+ this._shellReactive = params.shellReactive;
+ this._shouldFadeIn = params.shouldFadeIn;
+ this._shouldFadeOut = params.shouldFadeOut;
+ this._destroyOnClose = params.destroyOnClose;
+
+ Main.layoutManager.modalDialogGroup.add_actor(this);
+
+ let constraint = new Clutter.BindConstraint({ source: global.stage,
+ coordinate: Clutter.BindCoordinate.ALL });
+ this.add_constraint(constraint);
+
+ this.backgroundStack = new St.Widget({
+ layout_manager: new Clutter.BinLayout(),
+ x_expand: true,
+ y_expand: true,
+ });
+ this._backgroundBin = new St.Bin({ child: this.backgroundStack });
+ this._monitorConstraint = new Layout.MonitorConstraint();
+ this._backgroundBin.add_constraint(this._monitorConstraint);
+ this.add_actor(this._backgroundBin);
+
+ this.dialogLayout = new Dialog.Dialog(this.backgroundStack, params.styleClass);
+ this.contentLayout = this.dialogLayout.contentLayout;
+ this.buttonLayout = this.dialogLayout.buttonLayout;
+
+ if (!this._shellReactive) {
+ this._lightbox = new Lightbox.Lightbox(this,
+ { inhibitEvents: true,
+ radialEffect: true });
+ this._lightbox.highlight(this._backgroundBin);
+
+ this._eventBlocker = new Clutter.Actor({ reactive: true });
+ this.backgroundStack.add_actor(this._eventBlocker);
+ }
+
+ global.focus_manager.add_group(this.dialogLayout);
+ this._initialKeyFocus = null;
+ this._initialKeyFocusDestroyId = 0;
+ this._savedKeyFocus = null;
+ }
+
+ get state() {
+ return this._state;
+ }
+
+ _setState(state) {
+ if (this._state == state)
+ return;
+
+ this._state = state;
+ this.notify('state');
+ }
+
+ clearButtons() {
+ this.dialogLayout.clearButtons();
+ }
+
+ setButtons(buttons) {
+ this.clearButtons();
+
+ for (let buttonInfo of buttons)
+ this.addButton(buttonInfo);
+ }
+
+ addButton(buttonInfo) {
+ return this.dialogLayout.addButton(buttonInfo);
+ }
+
+ _fadeOpen(onPrimary) {
+ if (onPrimary)
+ this._monitorConstraint.primary = true;
+ else
+ this._monitorConstraint.index = global.display.get_current_monitor();
+
+ this._setState(State.OPENING);
+
+ this.dialogLayout.opacity = 255;
+ if (this._lightbox)
+ this._lightbox.lightOn();
+ this.opacity = 0;
+ this.show();
+ this.ease({
+ opacity: 255,
+ duration: this._shouldFadeIn ? OPEN_AND_CLOSE_TIME : 0,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ this._setState(State.OPENED);
+ this.emit('opened');
+ },
+ });
+ }
+
+ setInitialKeyFocus(actor) {
+ if (this._initialKeyFocusDestroyId)
+ this._initialKeyFocus.disconnect(this._initialKeyFocusDestroyId);
+
+ this._initialKeyFocus = actor;
+
+ this._initialKeyFocusDestroyId = actor.connect('destroy', () => {
+ this._initialKeyFocus = null;
+ this._initialKeyFocusDestroyId = 0;
+ });
+ }
+
+ open(timestamp, onPrimary) {
+ if (this.state == State.OPENED || this.state == State.OPENING)
+ return true;
+
+ if (!this.pushModal(timestamp))
+ return false;
+
+ this._fadeOpen(onPrimary);
+ return true;
+ }
+
+ _closeComplete() {
+ this._setState(State.CLOSED);
+ this.hide();
+ this.emit('closed');
+
+ if (this._destroyOnClose)
+ this.destroy();
+ }
+
+ close(timestamp) {
+ if (this.state == State.CLOSED || this.state == State.CLOSING)
+ return;
+
+ this._setState(State.CLOSING);
+ this.popModal(timestamp);
+ this._savedKeyFocus = null;
+
+ if (this._shouldFadeOut) {
+ this.ease({
+ opacity: 0,
+ duration: OPEN_AND_CLOSE_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this._closeComplete(),
+ });
+ } else {
+ this._closeComplete();
+ }
+ }
+
+ // Drop modal status without closing the dialog; this makes the
+ // dialog insensitive as well, so it needs to be followed shortly
+ // by either a close() or a pushModal()
+ popModal(timestamp) {
+ if (!this._hasModal)
+ return;
+
+ let focus = global.stage.key_focus;
+ if (focus && this.contains(focus))
+ this._savedKeyFocus = focus;
+ else
+ this._savedKeyFocus = null;
+ Main.popModal(this, timestamp);
+ this._hasModal = false;
+
+ if (!this._shellReactive)
+ this.backgroundStack.set_child_above_sibling(this._eventBlocker, null);
+ }
+
+ pushModal(timestamp) {
+ if (this._hasModal)
+ return true;
+
+ let params = { actionMode: this._actionMode };
+ if (timestamp)
+ params['timestamp'] = timestamp;
+ if (!Main.pushModal(this, params))
+ return false;
+
+ Main.layoutManager.emit('system-modal-opened');
+
+ this._hasModal = true;
+ if (this._savedKeyFocus) {
+ this._savedKeyFocus.grab_key_focus();
+ this._savedKeyFocus = null;
+ } else {
+ let focus = this._initialKeyFocus || this.dialogLayout.initialKeyFocus;
+ focus.grab_key_focus();
+ }
+
+ if (!this._shellReactive)
+ this.backgroundStack.set_child_below_sibling(this._eventBlocker, null);
+ return true;
+ }
+
+ // This method is like close, but fades the dialog out much slower,
+ // and leaves the lightbox in place. Once in the faded out state,
+ // the dialog can be brought back by an open call, or the lightbox
+ // can be dismissed by a close call.
+ //
+ // The main point of this method is to give some indication to the user
+ // that the dialog response has been acknowledged but will take a few
+ // moments before being processed.
+ // e.g., if a user clicked "Log Out" then the dialog should go away
+ // immediately, but the lightbox should remain until the logout is
+ // complete.
+ _fadeOutDialog(timestamp) {
+ if (this.state == State.CLOSED || this.state == State.CLOSING)
+ return;
+
+ if (this.state == State.FADED_OUT)
+ return;
+
+ this.popModal(timestamp);
+ this.dialogLayout.ease({
+ opacity: 0,
+ duration: FADE_OUT_DIALOG_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => (this.state = State.FADED_OUT),
+ });
+ }
+});
diff --git a/js/ui/mpris.js b/js/ui/mpris.js
new file mode 100644
index 0000000..3650c57
--- /dev/null
+++ b/js/ui/mpris.js
@@ -0,0 +1,300 @@
+/* exported MediaSection */
+const { Gio, GObject, Shell, St } = imports.gi;
+const Signals = imports.signals;
+
+const Main = imports.ui.main;
+const MessageList = imports.ui.messageList;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+const DBusIface = loadInterfaceXML('org.freedesktop.DBus');
+const DBusProxy = Gio.DBusProxy.makeProxyWrapper(DBusIface);
+
+const MprisIface = loadInterfaceXML('org.mpris.MediaPlayer2');
+const MprisProxy = Gio.DBusProxy.makeProxyWrapper(MprisIface);
+
+const MprisPlayerIface = loadInterfaceXML('org.mpris.MediaPlayer2.Player');
+const MprisPlayerProxy = Gio.DBusProxy.makeProxyWrapper(MprisPlayerIface);
+
+const MPRIS_PLAYER_PREFIX = 'org.mpris.MediaPlayer2.';
+
+var MediaMessage = GObject.registerClass(
+class MediaMessage extends MessageList.Message {
+ _init(player) {
+ super._init('', '');
+
+ this._player = player;
+
+ this._icon = new St.Icon({ style_class: 'media-message-cover-icon' });
+ this.setIcon(this._icon);
+
+ this._prevButton = this.addMediaControl('media-skip-backward-symbolic',
+ () => {
+ this._player.previous();
+ });
+
+ this._playPauseButton = this.addMediaControl(null,
+ () => {
+ this._player.playPause();
+ });
+
+ this._nextButton = this.addMediaControl('media-skip-forward-symbolic',
+ () => {
+ this._player.next();
+ });
+
+ this._updateHandlerId =
+ this._player.connect('changed', this._update.bind(this));
+ this._closedHandlerId =
+ this._player.connect('closed', this.close.bind(this));
+ this._update();
+ }
+
+ _onDestroy() {
+ super._onDestroy();
+ this._player.disconnect(this._updateHandlerId);
+ this._player.disconnect(this._closedHandlerId);
+ }
+
+ vfunc_clicked() {
+ this._player.raise();
+ Main.panel.closeCalendar();
+ }
+
+ _updateNavButton(button, sensitive) {
+ button.reactive = sensitive;
+ }
+
+ _update() {
+ this.setTitle(this._player.trackArtists.join(', '));
+ this.setBody(this._player.trackTitle);
+
+ if (this._player.trackCoverUrl) {
+ let file = Gio.File.new_for_uri(this._player.trackCoverUrl);
+ this._icon.gicon = new Gio.FileIcon({ file });
+ this._icon.remove_style_class_name('fallback');
+ } else {
+ this._icon.icon_name = 'audio-x-generic-symbolic';
+ this._icon.add_style_class_name('fallback');
+ }
+
+ let isPlaying = this._player.status == 'Playing';
+ let iconName = isPlaying
+ ? 'media-playback-pause-symbolic'
+ : 'media-playback-start-symbolic';
+ this._playPauseButton.child.icon_name = iconName;
+
+ this._updateNavButton(this._prevButton, this._player.canGoPrevious);
+ this._updateNavButton(this._nextButton, this._player.canGoNext);
+ }
+});
+
+var MprisPlayer = class MprisPlayer {
+ constructor(busName) {
+ this._mprisProxy = new MprisProxy(Gio.DBus.session, busName,
+ '/org/mpris/MediaPlayer2',
+ this._onMprisProxyReady.bind(this));
+ this._playerProxy = new MprisPlayerProxy(Gio.DBus.session, busName,
+ '/org/mpris/MediaPlayer2',
+ this._onPlayerProxyReady.bind(this));
+
+ this._visible = false;
+ this._trackArtists = [];
+ this._trackTitle = '';
+ this._trackCoverUrl = '';
+ this._busName = busName;
+ }
+
+ get status() {
+ return this._playerProxy.PlaybackStatus;
+ }
+
+ get trackArtists() {
+ return this._trackArtists;
+ }
+
+ get trackTitle() {
+ return this._trackTitle;
+ }
+
+ get trackCoverUrl() {
+ return this._trackCoverUrl;
+ }
+
+ playPause() {
+ this._playerProxy.PlayPauseRemote();
+ }
+
+ get canGoNext() {
+ return this._playerProxy.CanGoNext;
+ }
+
+ next() {
+ this._playerProxy.NextRemote();
+ }
+
+ get canGoPrevious() {
+ return this._playerProxy.CanGoPrevious;
+ }
+
+ previous() {
+ this._playerProxy.PreviousRemote();
+ }
+
+ raise() {
+ // The remote Raise() method may run into focus stealing prevention,
+ // so prefer activating the app via .desktop file if possible
+ let app = null;
+ if (this._mprisProxy.DesktopEntry) {
+ let desktopId = '%s.desktop'.format(this._mprisProxy.DesktopEntry);
+ app = Shell.AppSystem.get_default().lookup_app(desktopId);
+ }
+
+ if (app)
+ app.activate();
+ else if (this._mprisProxy.CanRaise)
+ this._mprisProxy.RaiseRemote();
+ }
+
+ _close() {
+ this._mprisProxy.disconnect(this._ownerNotifyId);
+ this._mprisProxy = null;
+
+ this._playerProxy.disconnect(this._propsChangedId);
+ this._playerProxy = null;
+
+ this.emit('closed');
+ }
+
+ _onMprisProxyReady() {
+ this._ownerNotifyId = this._mprisProxy.connect('notify::g-name-owner',
+ () => {
+ if (!this._mprisProxy.g_name_owner)
+ this._close();
+ });
+ // It is possible for the bus to disappear before the previous signal
+ // is connected, so we must ensure that the bus still exists at this
+ // point.
+ if (!this._mprisProxy.g_name_owner)
+ this._close();
+ }
+
+ _onPlayerProxyReady() {
+ this._propsChangedId = this._playerProxy.connect('g-properties-changed',
+ this._updateState.bind(this));
+ this._updateState();
+ }
+
+ _updateState() {
+ let metadata = {};
+ for (let prop in this._playerProxy.Metadata)
+ metadata[prop] = this._playerProxy.Metadata[prop].deep_unpack();
+
+ // Validate according to the spec; some clients send buggy metadata:
+ // https://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata
+ this._trackArtists = metadata['xesam:artist'];
+ if (!Array.isArray(this._trackArtists) ||
+ !this._trackArtists.every(artist => typeof artist === 'string')) {
+ if (typeof this._trackArtists !== 'undefined') {
+ log(('Received faulty track artist metadata from %s; ' +
+ 'expected an array of strings, got %s (%s)').format(
+ this._busName, this._trackArtists, typeof this._trackArtists));
+ }
+ this._trackArtists = [_("Unknown artist")];
+ }
+
+ this._trackTitle = metadata['xesam:title'];
+ if (typeof this._trackTitle !== 'string') {
+ if (typeof this._trackTitle !== 'undefined') {
+ log(('Received faulty track title metadata from %s; ' +
+ 'expected a string, got %s (%s)').format(
+ this._busName, this._trackTitle, typeof this._trackTitle));
+ }
+ this._trackTitle = _("Unknown title");
+ }
+
+ this._trackCoverUrl = metadata['mpris:artUrl'];
+ if (typeof this._trackCoverUrl !== 'string') {
+ if (typeof this._trackCoverUrl !== 'undefined') {
+ log(('Received faulty track cover art metadata from %s; ' +
+ 'expected a string, got %s (%s)').format(
+ this._busName, this._trackCoverUrl, typeof this._trackCoverUrl));
+ }
+ this._trackCoverUrl = '';
+ }
+
+ this.emit('changed');
+
+ let visible = this._playerProxy.CanPlay;
+
+ if (this._visible != visible) {
+ this._visible = visible;
+ if (visible)
+ this.emit('show');
+ else
+ this.emit('hide');
+ }
+ }
+};
+Signals.addSignalMethods(MprisPlayer.prototype);
+
+var MediaSection = GObject.registerClass(
+class MediaSection extends MessageList.MessageListSection {
+ _init() {
+ super._init();
+
+ this._players = new Map();
+
+ this._proxy = new DBusProxy(Gio.DBus.session,
+ 'org.freedesktop.DBus',
+ '/org/freedesktop/DBus',
+ this._onProxyReady.bind(this));
+ }
+
+ get allowed() {
+ return !Main.sessionMode.isGreeter;
+ }
+
+ _addPlayer(busName) {
+ if (this._players.get(busName))
+ return;
+
+ let player = new MprisPlayer(busName);
+ let message = null;
+ player.connect('closed',
+ () => {
+ this._players.delete(busName);
+ });
+ player.connect('show', () => {
+ message = new MediaMessage(player);
+ this.addMessage(message, true);
+ });
+ player.connect('hide', () => {
+ this.removeMessage(message, true);
+ message = null;
+ });
+
+ this._players.set(busName, player);
+ }
+
+ _onProxyReady() {
+ this._proxy.ListNamesRemote(([names]) => {
+ names.forEach(name => {
+ if (!name.startsWith(MPRIS_PLAYER_PREFIX))
+ return;
+
+ this._addPlayer(name);
+ });
+ });
+ this._proxy.connectSignal('NameOwnerChanged',
+ this._onNameOwnerChanged.bind(this));
+ }
+
+ _onNameOwnerChanged(proxy, sender, [name, oldOwner, newOwner]) {
+ if (!name.startsWith(MPRIS_PLAYER_PREFIX))
+ return;
+
+ if (newOwner && !oldOwner)
+ this._addPlayer(name);
+ }
+});
diff --git a/js/ui/notificationDaemon.js b/js/ui/notificationDaemon.js
new file mode 100644
index 0000000..a92192e
--- /dev/null
+++ b/js/ui/notificationDaemon.js
@@ -0,0 +1,785 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported NotificationDaemon */
+
+const { GdkPixbuf, Gio, GLib, GObject, Shell, St } = imports.gi;
+
+const Config = imports.misc.config;
+const Main = imports.ui.main;
+const MessageTray = imports.ui.messageTray;
+const Params = imports.misc.params;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+const FdoNotificationsIface = loadInterfaceXML('org.freedesktop.Notifications');
+
+var NotificationClosedReason = {
+ EXPIRED: 1,
+ DISMISSED: 2,
+ APP_CLOSED: 3,
+ UNDEFINED: 4,
+};
+
+var Urgency = {
+ LOW: 0,
+ NORMAL: 1,
+ CRITICAL: 2,
+};
+
+const rewriteRules = {
+ 'XChat': [
+ { pattern: /^XChat: Private message from: (\S*) \(.*\)$/,
+ replacement: '<$1>' },
+ { pattern: /^XChat: New public message from: (\S*) \((.*)\)$/,
+ replacement: '$2 <$1>' },
+ { pattern: /^XChat: Highlighted message from: (\S*) \((.*)\)$/,
+ replacement: '$2 <$1>' },
+ ],
+};
+
+var FdoNotificationDaemon = class FdoNotificationDaemon {
+ constructor() {
+ this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(FdoNotificationsIface, this);
+ this._dbusImpl.export(Gio.DBus.session, '/org/freedesktop/Notifications');
+
+ this._sources = [];
+ this._notifications = {};
+
+ this._nextNotificationId = 1;
+
+ Shell.WindowTracker.get_default().connect('notify::focus-app',
+ this._onFocusAppChanged.bind(this));
+ Main.overview.connect('hidden',
+ this._onFocusAppChanged.bind(this));
+ }
+
+ _imageForNotificationData(hints) {
+ if (hints['image-data']) {
+ let [width, height, rowStride, hasAlpha,
+ bitsPerSample, nChannels_, data] = hints['image-data'];
+ return Shell.util_create_pixbuf_from_data(data, GdkPixbuf.Colorspace.RGB, hasAlpha,
+ bitsPerSample, width, height, rowStride);
+ } else if (hints['image-path']) {
+ return this._iconForNotificationData(hints['image-path']);
+ }
+ return null;
+ }
+
+ _fallbackIconForNotificationData(hints) {
+ let stockIcon;
+ switch (hints.urgency) {
+ case Urgency.LOW:
+ case Urgency.NORMAL:
+ stockIcon = 'dialog-information';
+ break;
+ case Urgency.CRITICAL:
+ stockIcon = 'dialog-error';
+ break;
+ }
+ return new Gio.ThemedIcon({ name: stockIcon });
+ }
+
+ _iconForNotificationData(icon) {
+ if (icon) {
+ if (icon.substr(0, 7) == 'file://')
+ return new Gio.FileIcon({ file: Gio.File.new_for_uri(icon) });
+ else if (icon[0] == '/')
+ return new Gio.FileIcon({ file: Gio.File.new_for_path(icon) });
+ else
+ return new Gio.ThemedIcon({ name: icon });
+ }
+ return null;
+ }
+
+ _lookupSource(title, pid) {
+ for (let i = 0; i < this._sources.length; i++) {
+ let source = this._sources[i];
+ if (source.pid == pid && source.initialTitle == title)
+ return source;
+ }
+ return null;
+ }
+
+ // Returns the source associated with ndata.notification if it is set.
+ // If the existing or requested source is associated with a tray icon
+ // and passed in pid matches a pid of an existing source, the title
+ // match is ignored to enable representing a tray icon and notifications
+ // from the same application with a single source.
+ //
+ // If no existing source is found, a new source is created as long as
+ // pid is provided.
+ _getSource(title, pid, ndata, sender) {
+ if (!pid && !(ndata && ndata.notification))
+ throw new Error('Either a pid or ndata.notification is needed');
+
+ // We use notification's source for the notifications we still have
+ // around that are getting replaced because we don't keep sources
+ // for transient notifications in this._sources, but we still want
+ // the notification associated with them to get replaced correctly.
+ if (ndata && ndata.notification)
+ return ndata.notification.source;
+
+ let source = this._lookupSource(title, pid);
+ if (source) {
+ source.setTitle(title);
+ return source;
+ }
+
+ let appId = ndata ? ndata.hints['desktop-entry'] || null : null;
+ source = new FdoNotificationDaemonSource(title, pid, sender, appId);
+
+ this._sources.push(source);
+ source.connect('destroy', () => {
+ let index = this._sources.indexOf(source);
+ if (index >= 0)
+ this._sources.splice(index, 1);
+ });
+
+ Main.messageTray.add(source);
+ return source;
+ }
+
+ NotifyAsync(params, invocation) {
+ let [appName, replacesId, icon, summary, body, actions, hints, timeout] = params;
+ let id;
+
+ for (let hint in hints) {
+ // unpack the variants
+ hints[hint] = hints[hint].deep_unpack();
+ }
+
+ hints = Params.parse(hints, { urgency: Urgency.NORMAL }, true);
+
+ // Filter out chat, presence, calls and invitation notifications from
+ // Empathy, since we handle that information from telepathyClient.js
+ //
+ // Note that empathy uses im.received for one to one chats and
+ // x-empathy.im.mentioned for multi-user, so we're good here
+ if (appName == 'Empathy' && hints['category'] == 'im.received') {
+ // Ignore replacesId since we already sent back a
+ // NotificationClosed for that id.
+ id = this._nextNotificationId++;
+ let idleId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
+ this._emitNotificationClosed(id, NotificationClosedReason.DISMISSED);
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(idleId, '[gnome-shell] this._emitNotificationClosed');
+ return invocation.return_value(GLib.Variant.new('(u)', [id]));
+ }
+
+ let rewrites = rewriteRules[appName];
+ if (rewrites) {
+ for (let i = 0; i < rewrites.length; i++) {
+ let rule = rewrites[i];
+ if (summary.search(rule.pattern) != -1)
+ summary = summary.replace(rule.pattern, rule.replacement);
+ }
+ }
+
+ // Be compatible with the various hints for image data and image path
+ // 'image-data' and 'image-path' are the latest name of these hints, introduced in 1.2
+
+ if (!hints['image-path'] && hints['image_path'])
+ hints['image-path'] = hints['image_path']; // version 1.1 of the spec
+
+ if (!hints['image-data']) {
+ if (hints['image_data'])
+ hints['image-data'] = hints['image_data']; // version 1.1 of the spec
+ else if (hints['icon_data'] && !hints['image-path'])
+ // early versions of the spec; 'icon_data' should only be used if 'image-path' is not available
+ hints['image-data'] = hints['icon_data'];
+ }
+
+ let ndata = { appName,
+ icon,
+ summary,
+ body,
+ actions,
+ hints,
+ timeout };
+ if (replacesId != 0 && this._notifications[replacesId]) {
+ ndata.id = id = replacesId;
+ ndata.notification = this._notifications[replacesId].notification;
+ } else {
+ replacesId = 0;
+ ndata.id = id = this._nextNotificationId++;
+ }
+ this._notifications[id] = ndata;
+
+ let sender = invocation.get_sender();
+ let pid = hints['sender-pid'];
+
+ let source = this._getSource(appName, pid, ndata, sender, null);
+ this._notifyForSource(source, ndata);
+
+ return invocation.return_value(GLib.Variant.new('(u)', [id]));
+ }
+
+ _notifyForSource(source, ndata) {
+ let [id_, icon, summary, body, actions, hints, notification] =
+ [ndata.id, ndata.icon, ndata.summary, ndata.body,
+ ndata.actions, ndata.hints, ndata.notification];
+
+ if (notification == null) {
+ notification = new MessageTray.Notification(source);
+ ndata.notification = notification;
+ notification.connect('destroy', (n, reason) => {
+ delete this._notifications[ndata.id];
+ let notificationClosedReason;
+ switch (reason) {
+ case MessageTray.NotificationDestroyedReason.EXPIRED:
+ notificationClosedReason = NotificationClosedReason.EXPIRED;
+ break;
+ case MessageTray.NotificationDestroyedReason.DISMISSED:
+ notificationClosedReason = NotificationClosedReason.DISMISSED;
+ break;
+ case MessageTray.NotificationDestroyedReason.SOURCE_CLOSED:
+ notificationClosedReason = NotificationClosedReason.APP_CLOSED;
+ break;
+ }
+ this._emitNotificationClosed(ndata.id, notificationClosedReason);
+ });
+ }
+
+ // 'image-data' (or 'image-path') takes precedence over 'app-icon'.
+ let gicon = this._imageForNotificationData(hints);
+
+ if (!gicon)
+ gicon = this._iconForNotificationData(icon);
+
+ if (!gicon)
+ gicon = this._fallbackIconForNotificationData(hints);
+
+ notification.update(summary, body, { gicon,
+ bannerMarkup: true,
+ clear: true,
+ soundFile: hints['sound-file'],
+ soundName: hints['sound-name'] });
+
+ let hasDefaultAction = false;
+
+ if (actions.length) {
+ for (let i = 0; i < actions.length - 1; i += 2) {
+ let [actionId, label] = [actions[i], actions[i + 1]];
+ if (actionId == 'default') {
+ hasDefaultAction = true;
+ } else {
+ notification.addAction(label, () => {
+ this._emitActionInvoked(ndata.id, actionId);
+ });
+ }
+ }
+ }
+
+ if (hasDefaultAction) {
+ notification.connect('activated', () => {
+ this._emitActionInvoked(ndata.id, 'default');
+ });
+ } else {
+ notification.connect('activated', () => {
+ source.open();
+ });
+ }
+
+ switch (hints.urgency) {
+ case Urgency.LOW:
+ notification.setUrgency(MessageTray.Urgency.LOW);
+ break;
+ case Urgency.NORMAL:
+ notification.setUrgency(MessageTray.Urgency.NORMAL);
+ break;
+ case Urgency.CRITICAL:
+ notification.setUrgency(MessageTray.Urgency.CRITICAL);
+ break;
+ }
+ notification.setResident(!!hints.resident);
+ // 'transient' is a reserved keyword in JS, so we have to retrieve the value
+ // of the 'transient' hint with hints['transient'] rather than hints.transient
+ notification.setTransient(!!hints['transient']);
+
+ let privacyScope = hints['x-gnome-privacy-scope'] || 'user';
+ notification.setPrivacyScope(privacyScope == 'system'
+ ? MessageTray.PrivacyScope.SYSTEM
+ : MessageTray.PrivacyScope.USER);
+
+ let sourceGIcon = source.useNotificationIcon ? gicon : null;
+ source.processNotification(notification, sourceGIcon);
+ }
+
+ CloseNotification(id) {
+ let ndata = this._notifications[id];
+ if (ndata) {
+ if (ndata.notification)
+ ndata.notification.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED);
+ delete this._notifications[id];
+ }
+ }
+
+ GetCapabilities() {
+ return [
+ 'actions',
+ // 'action-icons',
+ 'body',
+ // 'body-hyperlinks',
+ // 'body-images',
+ 'body-markup',
+ // 'icon-multi',
+ 'icon-static',
+ 'persistence',
+ 'sound',
+ ];
+ }
+
+ GetServerInformation() {
+ return [
+ Config.PACKAGE_NAME,
+ 'GNOME',
+ Config.PACKAGE_VERSION,
+ '1.2',
+ ];
+ }
+
+ _onFocusAppChanged() {
+ let tracker = Shell.WindowTracker.get_default();
+ if (!tracker.focus_app)
+ return;
+
+ for (let i = 0; i < this._sources.length; i++) {
+ let source = this._sources[i];
+ if (source.app == tracker.focus_app) {
+ source.destroyNonResidentNotifications();
+ return;
+ }
+ }
+ }
+
+ _emitNotificationClosed(id, reason) {
+ this._dbusImpl.emit_signal('NotificationClosed',
+ GLib.Variant.new('(uu)', [id, reason]));
+ }
+
+ _emitActionInvoked(id, action) {
+ this._dbusImpl.emit_signal('ActionInvoked',
+ GLib.Variant.new('(us)', [id, action]));
+ }
+};
+
+var FdoNotificationDaemonSource = GObject.registerClass(
+class FdoNotificationDaemonSource extends MessageTray.Source {
+ _init(title, pid, sender, appId) {
+ this.pid = pid;
+ this.initialTitle = title;
+ this.app = this._getApp(appId);
+
+ super._init(title);
+
+ if (this.app)
+ this.title = this.app.get_name();
+ else
+ this.useNotificationIcon = true;
+
+ if (sender) {
+ this._nameWatcherId = Gio.DBus.session.watch_name(sender,
+ Gio.BusNameWatcherFlags.NONE,
+ null,
+ this._onNameVanished.bind(this));
+ } else {
+ this._nameWatcherId = 0;
+ }
+ }
+
+ _createPolicy() {
+ if (this.app && this.app.get_app_info()) {
+ let id = this.app.get_id().replace(/\.desktop$/, '');
+ return new MessageTray.NotificationApplicationPolicy(id);
+ } else {
+ return new MessageTray.NotificationGenericPolicy();
+ }
+ }
+
+ _onNameVanished() {
+ // Destroy the notification source when its sender is removed from DBus.
+ // Only do so if this.app is set to avoid removing "notify-send" sources, senders
+ // of which аre removed from DBus immediately.
+ // Sender being removed from DBus would normally result in a tray icon being removed,
+ // so allow the code path that handles the tray icon being removed to handle that case.
+ if (this.app)
+ this.destroy();
+ }
+
+ processNotification(notification, gicon) {
+ if (gicon)
+ this._gicon = gicon;
+ this.iconUpdated();
+
+ let tracker = Shell.WindowTracker.get_default();
+ if (notification.resident && this.app && tracker.focus_app == this.app)
+ this.pushNotification(notification);
+ else
+ this.showNotification(notification);
+ }
+
+ _getApp(appId) {
+ const appSys = Shell.AppSystem.get_default();
+ let app;
+
+ app = Shell.WindowTracker.get_default().get_app_from_pid(this.pid);
+ if (app != null)
+ return app;
+
+ if (appId)
+ app = appSys.lookup_app('%s.desktop'.format(appId));
+
+ if (!app)
+ app = appSys.lookup_app('%s.desktop'.format(this.initialTitle));
+
+ return app;
+ }
+
+ setTitle(title) {
+ // Do nothing if .app is set, we don't want to override the
+ // app name with whatever is provided through libnotify (usually
+ // garbage)
+ if (this.app)
+ return;
+
+ super.setTitle(title);
+ }
+
+ open() {
+ this.openApp();
+ this.destroyNonResidentNotifications();
+ }
+
+ openApp() {
+ if (this.app == null)
+ return;
+
+ this.app.activate();
+ Main.overview.hide();
+ Main.panel.closeCalendar();
+ }
+
+ destroy() {
+ if (this._nameWatcherId) {
+ Gio.DBus.session.unwatch_name(this._nameWatcherId);
+ this._nameWatcherId = 0;
+ }
+
+ super.destroy();
+ }
+
+ createIcon(size) {
+ if (this.app) {
+ return this.app.create_icon_texture(size);
+ } else if (this._gicon) {
+ return new St.Icon({ gicon: this._gicon,
+ icon_size: size });
+ } else {
+ return null;
+ }
+ }
+});
+
+const PRIORITY_URGENCY_MAP = {
+ low: MessageTray.Urgency.LOW,
+ normal: MessageTray.Urgency.NORMAL,
+ high: MessageTray.Urgency.HIGH,
+ urgent: MessageTray.Urgency.CRITICAL,
+};
+
+var GtkNotificationDaemonNotification = GObject.registerClass(
+class GtkNotificationDaemonNotification extends MessageTray.Notification {
+ _init(source, notification) {
+ super._init(source);
+ this._serialized = GLib.Variant.new('a{sv}', notification);
+
+ let { title,
+ body,
+ icon: gicon,
+ urgent,
+ priority,
+ buttons,
+ "default-action": defaultAction,
+ "default-action-target": defaultActionTarget,
+ timestamp: time } = notification;
+
+ if (priority) {
+ let urgency = PRIORITY_URGENCY_MAP[priority.unpack()];
+ this.setUrgency(urgency != undefined ? urgency : MessageTray.Urgency.NORMAL);
+ } else if (urgent) {
+ this.setUrgency(urgent.unpack()
+ ? MessageTray.Urgency.CRITICAL
+ : MessageTray.Urgency.NORMAL);
+ } else {
+ this.setUrgency(MessageTray.Urgency.NORMAL);
+ }
+
+ if (buttons) {
+ buttons.deep_unpack().forEach(button => {
+ this.addAction(button.label.unpack(), () => {
+ this._onButtonClicked(button);
+ });
+ });
+ }
+
+ this._defaultAction = defaultAction ? defaultAction.unpack() : null;
+ this._defaultActionTarget = defaultActionTarget;
+
+ this.update(title.unpack(), body ? body.unpack() : null,
+ { gicon: gicon ? Gio.icon_deserialize(gicon) : null,
+ datetime: time ? GLib.DateTime.new_from_unix_local(time.unpack()) : null });
+ }
+
+ _activateAction(namespacedActionId, target) {
+ if (namespacedActionId) {
+ if (namespacedActionId.startsWith('app.')) {
+ let actionId = namespacedActionId.slice('app.'.length);
+ this.source.activateAction(actionId, target);
+ }
+ } else {
+ this.source.open();
+ }
+ }
+
+ _onButtonClicked(button) {
+ let { action, target } = button;
+ this._activateAction(action.unpack(), target);
+ }
+
+ activate() {
+ this._activateAction(this._defaultAction, this._defaultActionTarget);
+ super.activate();
+ }
+
+ serialize() {
+ return this._serialized;
+ }
+});
+
+const FdoApplicationIface = loadInterfaceXML('org.freedesktop.Application');
+const FdoApplicationProxy = Gio.DBusProxy.makeProxyWrapper(FdoApplicationIface);
+
+function objectPathFromAppId(appId) {
+ return '/' + appId.replace(/\./g, '/').replace(/-/g, '_');
+}
+
+function getPlatformData() {
+ let startupId = GLib.Variant.new('s', '_TIME%s'.format(global.get_current_time()));
+ return { "desktop-startup-id": startupId };
+}
+
+function InvalidAppError() {}
+
+var GtkNotificationDaemonAppSource = GObject.registerClass(
+class GtkNotificationDaemonAppSource extends MessageTray.Source {
+ _init(appId) {
+ let objectPath = objectPathFromAppId(appId);
+ if (!GLib.Variant.is_object_path(objectPath))
+ throw new InvalidAppError();
+
+ let app = Shell.AppSystem.get_default().lookup_app('%s.desktop'.format(appId));
+ if (!app)
+ throw new InvalidAppError();
+
+ this._appId = appId;
+ this._app = app;
+ this._objectPath = objectPath;
+
+ super._init(app.get_name());
+
+ this._notifications = {};
+ this._notificationPending = false;
+ }
+
+ createIcon(size) {
+ return this._app.create_icon_texture(size);
+ }
+
+ _createPolicy() {
+ return new MessageTray.NotificationApplicationPolicy(this._appId);
+ }
+
+ _createApp(callback) {
+ return new FdoApplicationProxy(Gio.DBus.session, this._appId, this._objectPath, callback);
+ }
+
+ _createNotification(params) {
+ return new GtkNotificationDaemonNotification(this, params);
+ }
+
+ activateAction(actionId, target) {
+ this._createApp((app, error) => {
+ if (error == null)
+ app.ActivateActionRemote(actionId, target ? [target] : [], getPlatformData());
+ else
+ logError(error, 'Failed to activate application proxy');
+ });
+ Main.overview.hide();
+ Main.panel.closeCalendar();
+ }
+
+ open() {
+ this._createApp((app, error) => {
+ if (error == null)
+ app.ActivateRemote(getPlatformData());
+ else
+ logError(error, 'Failed to open application proxy');
+ });
+ Main.overview.hide();
+ Main.panel.closeCalendar();
+ }
+
+ addNotification(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', () => {
+ delete this._notifications[notificationId];
+ });
+ this._notifications[notificationId] = notification;
+
+ if (showBanner)
+ this.showNotification(notification);
+ else
+ this.pushNotification(notification);
+
+ this._notificationPending = false;
+ }
+
+ destroy(reason) {
+ if (this._notificationPending)
+ return;
+ super.destroy(reason);
+ }
+
+ removeNotification(notificationId) {
+ if (this._notifications[notificationId])
+ this._notifications[notificationId].destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED);
+ }
+
+ serialize() {
+ let notifications = [];
+ for (let notificationId in this._notifications) {
+ let notification = this._notifications[notificationId];
+ notifications.push([notificationId, notification.serialize()]);
+ }
+ return [this._appId, notifications];
+ }
+});
+
+const GtkNotificationsIface = loadInterfaceXML('org.gtk.Notifications');
+
+var GtkNotificationDaemon = class GtkNotificationDaemon {
+ constructor() {
+ this._sources = {};
+
+ this._loadNotifications();
+
+ this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(GtkNotificationsIface, this);
+ this._dbusImpl.export(Gio.DBus.session, '/org/gtk/Notifications');
+
+ Gio.DBus.session.own_name('org.gtk.Notifications', Gio.BusNameOwnerFlags.REPLACE, null, null);
+ }
+
+ _ensureAppSource(appId) {
+ if (this._sources[appId])
+ return this._sources[appId];
+
+ let source = new GtkNotificationDaemonAppSource(appId);
+
+ source.connect('destroy', () => {
+ delete this._sources[appId];
+ this._saveNotifications();
+ });
+ source.connect('notify::count', this._saveNotifications.bind(this));
+ Main.messageTray.add(source);
+ this._sources[appId] = source;
+ return source;
+ }
+
+ _loadNotifications() {
+ this._isLoading = true;
+
+ try {
+ let value = global.get_persistent_state('a(sa(sv))', 'notifications');
+ if (value) {
+ let sources = value.deep_unpack();
+ sources.forEach(([appId, notifications]) => {
+ if (notifications.length == 0)
+ return;
+
+ let source;
+ try {
+ source = this._ensureAppSource(appId);
+ } catch (e) {
+ if (e instanceof InvalidAppError)
+ return;
+ throw e;
+ }
+
+ notifications.forEach(([notificationId, notification]) => {
+ source.addNotification(notificationId, notification.deep_unpack(), false);
+ });
+ });
+ }
+ } catch (e) {
+ logError(e, 'Failed to load saved notifications');
+ } finally {
+ this._isLoading = false;
+ }
+ }
+
+ _saveNotifications() {
+ if (this._isLoading)
+ return;
+
+ let sources = [];
+ for (let appId in this._sources) {
+ let source = this._sources[appId];
+ sources.push(source.serialize());
+ }
+
+ global.set_persistent_state('notifications', new GLib.Variant('a(sa(sv))', sources));
+ }
+
+ AddNotificationAsync(params, invocation) {
+ let [appId, notificationId, notification] = params;
+
+ let source;
+ try {
+ source = this._ensureAppSource(appId);
+ } catch (e) {
+ if (e instanceof InvalidAppError) {
+ invocation.return_dbus_error('org.gtk.Notifications.InvalidApp', 'The app by ID "%s" could not be found'.format(appId));
+ return;
+ }
+ throw e;
+ }
+
+ let timestamp = GLib.DateTime.new_now_local().to_unix();
+ notification['timestamp'] = new GLib.Variant('x', timestamp);
+
+ source.addNotification(notificationId, notification, true);
+
+ invocation.return_value(null);
+ }
+
+ RemoveNotificationAsync(params, invocation) {
+ let [appId, notificationId] = params;
+ let source = this._sources[appId];
+ if (source)
+ source.removeNotification(notificationId);
+
+ invocation.return_value(null);
+ }
+};
+
+var NotificationDaemon = class NotificationDaemon {
+ constructor() {
+ this._fdoNotificationDaemon = new FdoNotificationDaemon();
+ this._gtkNotificationDaemon = new GtkNotificationDaemon();
+ }
+};
diff --git a/js/ui/osdMonitorLabeler.js b/js/ui/osdMonitorLabeler.js
new file mode 100644
index 0000000..b242ecc
--- /dev/null
+++ b/js/ui/osdMonitorLabeler.js
@@ -0,0 +1,114 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported OsdMonitorLabeler */
+
+const { Clutter, Gio, GObject, Meta, St } = imports.gi;
+
+const Main = imports.ui.main;
+
+var OsdMonitorLabel = GObject.registerClass(
+class OsdMonitorLabel extends St.Widget {
+ _init(monitor, label) {
+ super._init({ x_expand: true, y_expand: true });
+
+ this._monitor = monitor;
+
+ this._box = new St.BoxLayout({ style_class: 'osd-window',
+ vertical: true });
+ this.add_actor(this._box);
+
+ this._label = new St.Label({ style_class: 'osd-monitor-label',
+ text: label });
+ this._box.add(this._label);
+
+ Main.uiGroup.add_child(this);
+ Main.uiGroup.set_child_above_sibling(this, null);
+ this._position();
+
+ Meta.disable_unredirect_for_display(global.display);
+ this.connect('destroy', () => {
+ Meta.enable_unredirect_for_display(global.display);
+ });
+ }
+
+ _position() {
+ let workArea = Main.layoutManager.getWorkAreaForMonitor(this._monitor);
+
+ if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL)
+ this._box.x = workArea.x + (workArea.width - this._box.width);
+ else
+ this._box.x = workArea.x;
+
+ this._box.y = workArea.y;
+ }
+});
+
+var OsdMonitorLabeler = class {
+ constructor() {
+ this._monitorManager = Meta.MonitorManager.get();
+ this._client = null;
+ this._clientWatchId = 0;
+ this._osdLabels = [];
+ this._monitorLabels = null;
+ Main.layoutManager.connect('monitors-changed',
+ this._reset.bind(this));
+ this._reset();
+ }
+
+ _reset() {
+ for (let i in this._osdLabels)
+ this._osdLabels[i].destroy();
+ this._osdLabels = [];
+ this._monitorLabels = new Map();
+ let monitors = Main.layoutManager.monitors;
+ for (let i in monitors)
+ this._monitorLabels.set(monitors[i].index, []);
+ }
+
+ _trackClient(client) {
+ if (this._client)
+ return this._client == client;
+
+ this._client = client;
+ this._clientWatchId = Gio.bus_watch_name(Gio.BusType.SESSION, client, 0, null,
+ (c, name) => {
+ this.hide(name);
+ });
+ return true;
+ }
+
+ _untrackClient(client) {
+ if (!this._client || this._client != client)
+ return false;
+
+ Gio.bus_unwatch_name(this._clientWatchId);
+ this._clientWatchId = 0;
+ this._client = null;
+ return true;
+ }
+
+ show(client, params) {
+ if (!this._trackClient(client))
+ return;
+
+ this._reset();
+
+ for (let connector in params) {
+ let monitor = this._monitorManager.get_monitor_for_connector(connector);
+ if (monitor == -1)
+ continue;
+ this._monitorLabels.get(monitor).push(params[connector].deep_unpack());
+ }
+
+ for (let [monitor, labels] of this._monitorLabels.entries()) {
+ labels.sort();
+ this._osdLabels.push(new OsdMonitorLabel(monitor, labels.join(' ')));
+ }
+ }
+
+ hide(client) {
+ if (!this._untrackClient(client))
+ return;
+
+ this._reset();
+ }
+};
diff --git a/js/ui/osdWindow.js b/js/ui/osdWindow.js
new file mode 100644
index 0000000..bae4b86
--- /dev/null
+++ b/js/ui/osdWindow.js
@@ -0,0 +1,250 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported OsdWindowManager */
+
+const { Clutter, GLib, GObject, Meta, St } = imports.gi;
+
+const BarLevel = imports.ui.barLevel;
+const Layout = imports.ui.layout;
+const Main = imports.ui.main;
+
+var HIDE_TIMEOUT = 1500;
+var FADE_TIME = 100;
+var LEVEL_ANIMATION_TIME = 100;
+
+var OsdWindowConstraint = GObject.registerClass(
+class OsdWindowConstraint extends Clutter.Constraint {
+ _init(props) {
+ this._minSize = 0;
+ super._init(props);
+ }
+
+ set minSize(v) {
+ this._minSize = v;
+ if (this.actor)
+ this.actor.queue_relayout();
+ }
+
+ vfunc_update_allocation(actor, actorBox) {
+ // Clutter will adjust the allocation for margins,
+ // so add it to our minimum size
+ let minSize = this._minSize + actor.margin_top + actor.margin_bottom;
+ let [width, height] = actorBox.get_size();
+
+ // Enforce a ratio of 1
+ let size = Math.ceil(Math.max(minSize, height));
+ actorBox.set_size(size, size);
+
+ // Recenter
+ let [x, y] = actorBox.get_origin();
+ actorBox.set_origin(Math.ceil(x + width / 2 - size / 2),
+ Math.ceil(y + height / 2 - size / 2));
+ }
+});
+
+var OsdWindow = GObject.registerClass(
+class OsdWindow extends St.Widget {
+ _init(monitorIndex) {
+ super._init({
+ x_expand: true,
+ y_expand: true,
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+
+ this._monitorIndex = monitorIndex;
+ let constraint = new Layout.MonitorConstraint({ index: monitorIndex });
+ this.add_constraint(constraint);
+
+ this._boxConstraint = new OsdWindowConstraint();
+ this._box = new St.BoxLayout({ style_class: 'osd-window',
+ vertical: true });
+ this._box.add_constraint(this._boxConstraint);
+ this.add_actor(this._box);
+
+ this._icon = new St.Icon({ y_expand: true });
+ this._box.add_child(this._icon);
+
+ this._label = new St.Label();
+ this._box.add(this._label);
+
+ this._level = new BarLevel.BarLevel({
+ style_class: 'level',
+ value: 0,
+ });
+ this._box.add(this._level);
+
+ this._hideTimeoutId = 0;
+ this._reset();
+
+ this.connect('destroy', this._onDestroy.bind(this));
+
+ this._monitorsChangedId =
+ Main.layoutManager.connect('monitors-changed',
+ this._relayout.bind(this));
+ let themeContext = St.ThemeContext.get_for_stage(global.stage);
+ this._scaleChangedId =
+ themeContext.connect('notify::scale-factor',
+ this._relayout.bind(this));
+ this._relayout();
+ Main.uiGroup.add_child(this);
+ }
+
+ _onDestroy() {
+ if (this._monitorsChangedId)
+ Main.layoutManager.disconnect(this._monitorsChangedId);
+ this._monitorsChangedId = 0;
+
+ let themeContext = St.ThemeContext.get_for_stage(global.stage);
+ if (this._scaleChangedId)
+ themeContext.disconnect(this._scaleChangedId);
+ this._scaleChangedId = 0;
+ }
+
+ setIcon(icon) {
+ this._icon.gicon = icon;
+ }
+
+ setLabel(label) {
+ this._label.visible = label != undefined;
+ if (label)
+ this._label.text = label;
+ }
+
+ setLevel(value) {
+ this._level.visible = value != undefined;
+ if (value != undefined) {
+ if (this.visible) {
+ this._level.ease_property('value', value, {
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: LEVEL_ANIMATION_TIME,
+ });
+ } else {
+ this._level.value = value;
+ }
+ }
+ }
+
+ setMaxLevel(maxLevel = 1) {
+ this._level.maximum_value = maxLevel;
+ }
+
+ show() {
+ if (!this._icon.gicon)
+ return;
+
+ if (!this.visible) {
+ Meta.disable_unredirect_for_display(global.display);
+ super.show();
+ this.opacity = 0;
+ this.get_parent().set_child_above_sibling(this, null);
+
+ this.ease({
+ opacity: 255,
+ duration: FADE_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ if (this._hideTimeoutId)
+ GLib.source_remove(this._hideTimeoutId);
+ this._hideTimeoutId = GLib.timeout_add(
+ GLib.PRIORITY_DEFAULT, HIDE_TIMEOUT, this._hide.bind(this));
+ GLib.Source.set_name_by_id(this._hideTimeoutId, '[gnome-shell] this._hide');
+ }
+
+ cancel() {
+ if (!this._hideTimeoutId)
+ return;
+
+ GLib.source_remove(this._hideTimeoutId);
+ this._hide();
+ }
+
+ _hide() {
+ this._hideTimeoutId = 0;
+ this.ease({
+ opacity: 0,
+ duration: FADE_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ this._reset();
+ Meta.enable_unredirect_for_display(global.display);
+ },
+ });
+ return GLib.SOURCE_REMOVE;
+ }
+
+ _reset() {
+ super.hide();
+ this.setLabel(null);
+ this.setMaxLevel(null);
+ this.setLevel(null);
+ }
+
+ _relayout() {
+ /* assume 110x110 on a 640x480 display and scale from there */
+ let monitor = Main.layoutManager.monitors[this._monitorIndex];
+ if (!monitor)
+ return; // we are about to be removed
+
+ let scalew = monitor.width / 640.0;
+ let scaleh = monitor.height / 480.0;
+ let scale = Math.min(scalew, scaleh);
+ let popupSize = 110 * Math.max(1, scale);
+
+ let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ this._icon.icon_size = popupSize / (2 * scaleFactor);
+ this._box.translation_y = Math.round(monitor.height / 4);
+ this._boxConstraint.minSize = popupSize;
+ }
+});
+
+var OsdWindowManager = class {
+ constructor() {
+ this._osdWindows = [];
+ Main.layoutManager.connect('monitors-changed',
+ this._monitorsChanged.bind(this));
+ this._monitorsChanged();
+ }
+
+ _monitorsChanged() {
+ for (let i = 0; i < Main.layoutManager.monitors.length; i++) {
+ if (this._osdWindows[i] == undefined)
+ this._osdWindows[i] = new OsdWindow(i);
+ }
+
+ for (let i = Main.layoutManager.monitors.length; i < this._osdWindows.length; i++) {
+ this._osdWindows[i].destroy();
+ this._osdWindows[i] = null;
+ }
+
+ this._osdWindows.length = Main.layoutManager.monitors.length;
+ }
+
+ _showOsdWindow(monitorIndex, icon, label, level, maxLevel) {
+ this._osdWindows[monitorIndex].setIcon(icon);
+ this._osdWindows[monitorIndex].setLabel(label);
+ this._osdWindows[monitorIndex].setMaxLevel(maxLevel);
+ this._osdWindows[monitorIndex].setLevel(level);
+ this._osdWindows[monitorIndex].show();
+ }
+
+ show(monitorIndex, icon, label, level, maxLevel) {
+ if (monitorIndex != -1) {
+ for (let i = 0; i < this._osdWindows.length; i++) {
+ if (i == monitorIndex)
+ this._showOsdWindow(i, icon, label, level, maxLevel);
+ else
+ this._osdWindows[i].cancel();
+ }
+ } else {
+ for (let i = 0; i < this._osdWindows.length; i++)
+ this._showOsdWindow(i, icon, label, level, maxLevel);
+ }
+ }
+
+ hideAll() {
+ for (let i = 0; i < this._osdWindows.length; i++)
+ this._osdWindows[i].cancel();
+ }
+};
diff --git a/js/ui/overview.js b/js/ui/overview.js
new file mode 100644
index 0000000..9682f8e
--- /dev/null
+++ b/js/ui/overview.js
@@ -0,0 +1,705 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Overview */
+
+const { Clutter, GLib, GObject, Meta, Shell, St } = imports.gi;
+const Signals = imports.signals;
+
+// Time for initial animation going into Overview mode;
+// this is defined here to make it available in imports.
+var ANIMATION_TIME = 250;
+
+const Background = imports.ui.background;
+const DND = imports.ui.dnd;
+const LayoutManager = imports.ui.layout;
+const Lightbox = imports.ui.lightbox;
+const Main = imports.ui.main;
+const MessageTray = imports.ui.messageTray;
+const OverviewControls = imports.ui.overviewControls;
+const Params = imports.misc.params;
+const WorkspaceThumbnail = imports.ui.workspaceThumbnail;
+
+// Must be less than ANIMATION_TIME, since we switch to
+// or from the overview completely after ANIMATION_TIME,
+// and don't want the shading animation to get cut off
+var SHADE_ANIMATION_TIME = 200;
+
+var DND_WINDOW_SWITCH_TIMEOUT = 750;
+
+var OVERVIEW_ACTIVATION_TIMEOUT = 0.5;
+
+var ShellInfo = class {
+ constructor() {
+ this._source = null;
+ this._undoCallback = null;
+ }
+
+ _onUndoClicked() {
+ if (this._undoCallback)
+ this._undoCallback();
+ this._undoCallback = null;
+
+ if (this._source)
+ this._source.destroy();
+ }
+
+ setMessage(text, options) {
+ options = Params.parse(options, {
+ undoCallback: null,
+ forFeedback: false,
+ });
+
+ let undoCallback = options.undoCallback;
+ let forFeedback = options.forFeedback;
+
+ if (this._source == null) {
+ this._source = new MessageTray.SystemNotificationSource();
+ this._source.connect('destroy', () => {
+ this._source = null;
+ });
+ Main.messageTray.add(this._source);
+ }
+
+ let notification = null;
+ if (this._source.notifications.length == 0) {
+ notification = new MessageTray.Notification(this._source, text, null);
+ notification.setTransient(true);
+ notification.setForFeedback(forFeedback);
+ } else {
+ notification = this._source.notifications[0];
+ notification.update(text, null, { clear: true });
+ }
+
+ this._undoCallback = undoCallback;
+ if (undoCallback)
+ notification.addAction(_("Undo"), this._onUndoClicked.bind(this));
+
+ this._source.showNotification(notification);
+ }
+};
+
+var OverviewActor = GObject.registerClass(
+class OverviewActor extends St.BoxLayout {
+ _init() {
+ super._init({
+ name: 'overview',
+ /* Translators: This is the main view to select
+ activities. See also note for "Activities" string. */
+ accessible_name: _("Overview"),
+ vertical: true,
+ });
+
+ this.add_constraint(new LayoutManager.MonitorConstraint({ primary: true }));
+
+ // Add a clone of the panel to the overview so spacing and such is
+ // automatic
+ let panelGhost = new St.Bin({
+ child: new Clutter.Clone({ source: Main.panel }),
+ reactive: false,
+ opacity: 0,
+ });
+ this.add_actor(panelGhost);
+
+ this._searchEntry = new St.Entry({
+ style_class: 'search-entry',
+ /* Translators: this is the text displayed
+ in the search entry when no search is
+ active; it should not exceed ~30
+ characters. */
+ hint_text: _('Type to search'),
+ track_hover: true,
+ can_focus: true,
+ });
+ this._searchEntry.set_offscreen_redirect(Clutter.OffscreenRedirect.ALWAYS);
+ let searchEntryBin = new St.Bin({
+ child: this._searchEntry,
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+ this.add_actor(searchEntryBin);
+
+ this._controls = new OverviewControls.ControlsManager(this._searchEntry);
+
+ // Add our same-line elements after the search entry
+ this.add_child(this._controls);
+ }
+
+ get dash() {
+ return this._controls.dash;
+ }
+
+ get searchEntry() {
+ return this._searchEntry;
+ }
+
+ get viewSelector() {
+ return this._controls.viewSelector;
+ }
+});
+
+var Overview = class {
+ constructor() {
+ this._initCalled = false;
+ this._visible = false;
+
+ Main.sessionMode.connect('updated', this._sessionUpdated.bind(this));
+ this._sessionUpdated();
+ }
+
+ get dash() {
+ return this._overview.dash;
+ }
+
+ get dashIconSize() {
+ logError(new Error('Usage of Overview.\'dashIconSize\' is deprecated, ' +
+ 'use \'dash.iconSize\' property instead'));
+ return this.dash.iconSize;
+ }
+
+ get viewSelector() {
+ return this._overview.viewSelector;
+ }
+
+ get animationInProgress() {
+ return this._animationInProgress;
+ }
+
+ get visible() {
+ return this._visible;
+ }
+
+ get visibleTarget() {
+ return this._visibleTarget;
+ }
+
+ _createOverview() {
+ if (this._overview)
+ return;
+
+ if (this.isDummy)
+ return;
+
+ // The main Background actors are inside global.window_group which are
+ // hidden when displaying the overview, so we create a new
+ // one. Instances of this class share a single CoglTexture behind the
+ // scenes which allows us to show the background with different
+ // rendering options without duplicating the texture data.
+ this._backgroundGroup = new Meta.BackgroundGroup({ reactive: true });
+ Main.layoutManager.overviewGroup.add_child(this._backgroundGroup);
+ this._bgManagers = [];
+
+ this._desktopFade = new St.Widget();
+ Main.layoutManager.overviewGroup.add_child(this._desktopFade);
+
+ this._activationTime = 0;
+
+ this._visible = false; // animating to overview, in overview, animating out
+ this._shown = false; // show() and not hide()
+ this._modal = false; // have a modal grab
+ this._animationInProgress = false;
+ this._visibleTarget = false;
+
+ // During transitions, we raise this to the top to avoid having the overview
+ // area be reactive; it causes too many issues such as double clicks on
+ // Dash elements, or mouseover handlers in the workspaces.
+ this._coverPane = new Clutter.Actor({ opacity: 0,
+ reactive: true });
+ Main.layoutManager.overviewGroup.add_child(this._coverPane);
+ this._coverPane.connect('event', () => Clutter.EVENT_STOP);
+ this._coverPane.hide();
+
+ // XDND
+ this._dragMonitor = {
+ dragMotion: this._onDragMotion.bind(this),
+ };
+
+
+ Main.layoutManager.overviewGroup.connect('scroll-event',
+ this._onScrollEvent.bind(this));
+ Main.xdndHandler.connect('drag-begin', this._onDragBegin.bind(this));
+ Main.xdndHandler.connect('drag-end', this._onDragEnd.bind(this));
+
+ global.display.connect('restacked', this._onRestacked.bind(this));
+
+ this._windowSwitchTimeoutId = 0;
+ this._windowSwitchTimestamp = 0;
+ this._lastActiveWorkspaceIndex = -1;
+ this._lastHoveredWindow = null;
+
+ if (this._initCalled)
+ this.init();
+ }
+
+ _updateBackgrounds() {
+ for (let i = 0; i < this._bgManagers.length; i++)
+ this._bgManagers[i].destroy();
+
+ this._bgManagers = [];
+
+ for (let i = 0; i < Main.layoutManager.monitors.length; i++) {
+ let bgManager = new Background.BackgroundManager({ container: this._backgroundGroup,
+ monitorIndex: i,
+ vignette: true });
+ this._bgManagers.push(bgManager);
+ }
+ }
+
+ _unshadeBackgrounds() {
+ let backgrounds = this._backgroundGroup.get_children();
+ for (let i = 0; i < backgrounds.length; i++) {
+ backgrounds[i].ease_property('@content.brightness', 1.0, {
+ duration: SHADE_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ backgrounds[i].ease_property('@content.vignette-sharpness', 0.0, {
+ duration: SHADE_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+ }
+
+ _shadeBackgrounds() {
+ let backgrounds = this._backgroundGroup.get_children();
+ for (let i = 0; i < backgrounds.length; i++) {
+ backgrounds[i].ease_property('@content.brightness',
+ Lightbox.VIGNETTE_BRIGHTNESS, {
+ duration: SHADE_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ backgrounds[i].ease_property('@content.vignette-sharpness',
+ Lightbox.VIGNETTE_SHARPNESS, {
+ duration: SHADE_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+ }
+
+ _sessionUpdated() {
+ const { hasOverview } = Main.sessionMode;
+ if (!hasOverview)
+ this.hide();
+
+ this.isDummy = !hasOverview;
+ this._createOverview();
+ }
+
+ // The members we construct that are implemented in JS might
+ // want to access the overview as Main.overview to connect
+ // signal handlers and so forth. So we create them after
+ // construction in this init() method.
+ init() {
+ this._initCalled = true;
+
+ if (this.isDummy)
+ return;
+
+ this._overview = new OverviewActor();
+ this._overview._delegate = this;
+ Main.layoutManager.overviewGroup.add_child(this._overview);
+ this._overview.connect('notify::allocation', () => this.emit('relayout'));
+
+ this._shellInfo = new ShellInfo();
+
+ Main.layoutManager.connect('monitors-changed', this._relayout.bind(this));
+ this._relayout();
+ }
+
+ addSearchProvider(provider) {
+ this.viewSelector.addSearchProvider(provider);
+ }
+
+ removeSearchProvider(provider) {
+ this.viewSelector.removeSearchProvider(provider);
+ }
+
+ //
+ // options:
+ // - undoCallback (function): the callback to be called if undo support is needed
+ // - forFeedback (boolean): whether the message is for direct feedback of a user action
+ //
+ setMessage(text, options) {
+ if (this.isDummy)
+ return;
+
+ this._shellInfo.setMessage(text, options);
+ }
+
+ _onDragBegin() {
+ this._inXdndDrag = true;
+
+ DND.addDragMonitor(this._dragMonitor);
+ // Remember the workspace we started from
+ let workspaceManager = global.workspace_manager;
+ this._lastActiveWorkspaceIndex = workspaceManager.get_active_workspace_index();
+ }
+
+ _onDragEnd(time) {
+ this._inXdndDrag = false;
+
+ // In case the drag was canceled while in the overview
+ // we have to go back to where we started and hide
+ // the overview
+ if (this._shown) {
+ let workspaceManager = global.workspace_manager;
+ workspaceManager.get_workspace_by_index(this._lastActiveWorkspaceIndex).activate(time);
+ this.hide();
+ }
+ this._resetWindowSwitchTimeout();
+ this._lastHoveredWindow = null;
+ DND.removeDragMonitor(this._dragMonitor);
+ this.endItemDrag();
+ }
+
+ _resetWindowSwitchTimeout() {
+ if (this._windowSwitchTimeoutId != 0) {
+ GLib.source_remove(this._windowSwitchTimeoutId);
+ this._windowSwitchTimeoutId = 0;
+ }
+ }
+
+ _onDragMotion(dragEvent) {
+ let targetIsWindow = dragEvent.targetActor &&
+ dragEvent.targetActor._delegate &&
+ dragEvent.targetActor._delegate.metaWindow &&
+ !(dragEvent.targetActor._delegate instanceof WorkspaceThumbnail.WindowClone);
+
+ this._windowSwitchTimestamp = global.get_current_time();
+
+ if (targetIsWindow &&
+ dragEvent.targetActor._delegate.metaWindow == this._lastHoveredWindow)
+ return DND.DragMotionResult.CONTINUE;
+
+ this._lastHoveredWindow = null;
+
+ this._resetWindowSwitchTimeout();
+
+ if (targetIsWindow) {
+ this._lastHoveredWindow = dragEvent.targetActor._delegate.metaWindow;
+ this._windowSwitchTimeoutId = GLib.timeout_add(
+ GLib.PRIORITY_DEFAULT,
+ DND_WINDOW_SWITCH_TIMEOUT,
+ () => {
+ this._windowSwitchTimeoutId = 0;
+ Main.activateWindow(dragEvent.targetActor._delegate.metaWindow,
+ this._windowSwitchTimestamp);
+ this.hide();
+ this._lastHoveredWindow = null;
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._windowSwitchTimeoutId, '[gnome-shell] Main.activateWindow');
+ }
+
+ return DND.DragMotionResult.CONTINUE;
+ }
+
+ _onScrollEvent(actor, event) {
+ this.emit('scroll-event', event);
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ addAction(action) {
+ if (this.isDummy)
+ return;
+
+ this._backgroundGroup.add_action(action);
+ }
+
+ _getDesktopClone() {
+ let windows = global.get_window_actors().filter(
+ w => w.meta_window.get_window_type() === Meta.WindowType.DESKTOP);
+ if (windows.length == 0)
+ return null;
+
+ let window = windows[0];
+ let clone = new Clutter.Clone({ source: window,
+ x: window.x, y: window.y });
+ clone.source.connect('destroy', () => {
+ clone.destroy();
+ });
+ return clone;
+ }
+
+ _relayout() {
+ // To avoid updating the position and size of the workspaces
+ // we just hide the overview. The positions will be updated
+ // when it is next shown.
+ this.hide();
+
+ this._coverPane.set_position(0, 0);
+ this._coverPane.set_size(global.screen_width, global.screen_height);
+
+ this._updateBackgrounds();
+ }
+
+ _onRestacked() {
+ let stack = global.get_window_actors();
+ let stackIndices = {};
+
+ for (let i = 0; i < stack.length; i++) {
+ // Use the stable sequence for an integer to use as a hash key
+ stackIndices[stack[i].get_meta_window().get_stable_sequence()] = i;
+ }
+
+ this.emit('windows-restacked', stackIndices);
+ }
+
+ beginItemDrag(source) {
+ this.emit('item-drag-begin', source);
+ this._inItemDrag = true;
+ }
+
+ cancelledItemDrag(source) {
+ this.emit('item-drag-cancelled', source);
+ }
+
+ endItemDrag(source) {
+ if (!this._inItemDrag)
+ return;
+ this.emit('item-drag-end', source);
+ this._inItemDrag = false;
+ }
+
+ beginWindowDrag(window) {
+ this.emit('window-drag-begin', window);
+ this._inWindowDrag = true;
+ }
+
+ cancelledWindowDrag(window) {
+ this.emit('window-drag-cancelled', window);
+ }
+
+ endWindowDrag(window) {
+ if (!this._inWindowDrag)
+ return;
+ this.emit('window-drag-end', window);
+ this._inWindowDrag = false;
+ }
+
+ focusSearch() {
+ this.show();
+ this._overview.searchEntry.grab_key_focus();
+ }
+
+ fadeInDesktop() {
+ this._desktopFade.opacity = 0;
+ this._desktopFade.show();
+ this._desktopFade.ease({
+ opacity: 255,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: ANIMATION_TIME,
+ });
+ }
+
+ fadeOutDesktop() {
+ if (!this._desktopFade.get_n_children()) {
+ let clone = this._getDesktopClone();
+ if (!clone)
+ return;
+
+ this._desktopFade.add_child(clone);
+ }
+
+ this._desktopFade.opacity = 255;
+ this._desktopFade.show();
+ this._desktopFade.ease({
+ opacity: 0,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: ANIMATION_TIME,
+ });
+ }
+
+ // Checks if the Activities button is currently sensitive to
+ // clicks. The first call to this function within the
+ // OVERVIEW_ACTIVATION_TIMEOUT time of the hot corner being
+ // triggered will return false. This avoids opening and closing
+ // the overview if the user both triggered the hot corner and
+ // clicked the Activities button.
+ shouldToggleByCornerOrButton() {
+ if (this._animationInProgress)
+ return false;
+ if (this._inItemDrag || this._inWindowDrag)
+ return false;
+ if (!this._activationTime ||
+ GLib.get_monotonic_time() / GLib.USEC_PER_SEC - this._activationTime > OVERVIEW_ACTIVATION_TIMEOUT)
+ return true;
+ return false;
+ }
+
+ _syncGrab() {
+ // We delay grab changes during animation so that when removing the
+ // overview we don't have a problem with the release of a press/release
+ // going to an application.
+ if (this._animationInProgress)
+ return true;
+
+ if (this._shown) {
+ let shouldBeModal = !this._inXdndDrag;
+ if (shouldBeModal && !this._modal) {
+ let actionMode = Shell.ActionMode.OVERVIEW;
+ if (Main.pushModal(this._overview, { actionMode })) {
+ this._modal = true;
+ } else {
+ this.hide();
+ return false;
+ }
+ }
+ } else {
+ // eslint-disable-next-line no-lonely-if
+ if (this._modal) {
+ Main.popModal(this._overview);
+ this._modal = false;
+ }
+ }
+ return true;
+ }
+
+ // show:
+ //
+ // Animates the overview visible and grabs mouse and keyboard input
+ show() {
+ if (this.isDummy)
+ return;
+ if (this._shown)
+ return;
+ this._shown = true;
+
+ if (!this._syncGrab())
+ return;
+
+ Main.layoutManager.showOverview();
+ this._animateVisible();
+ }
+
+
+ _animateVisible() {
+ if (this._visible || this._animationInProgress)
+ return;
+
+ this._visible = true;
+ this._animationInProgress = true;
+ this._visibleTarget = true;
+ this._activationTime = GLib.get_monotonic_time() / GLib.USEC_PER_SEC;
+
+ Meta.disable_unredirect_for_display(global.display);
+ this.viewSelector.animateToOverview();
+
+ this._overview.opacity = 0;
+ this._overview.ease({
+ opacity: 255,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: ANIMATION_TIME,
+ onComplete: () => this._showDone(),
+ });
+ this._shadeBackgrounds();
+
+ Main.layoutManager.overviewGroup.set_child_above_sibling(
+ this._coverPane, null);
+ this._coverPane.show();
+ this.emit('showing');
+ }
+
+ _showDone() {
+ this._animationInProgress = false;
+ this._desktopFade.hide();
+ this._coverPane.hide();
+
+ this.emit('shown');
+ // Handle any calls to hide* while we were showing
+ if (!this._shown)
+ this._animateNotVisible();
+
+ this._syncGrab();
+ global.sync_pointer();
+ }
+
+ // hide:
+ //
+ // Reverses the effect of show()
+ hide() {
+ if (this.isDummy)
+ return;
+
+ if (!this._shown)
+ return;
+
+ let event = Clutter.get_current_event();
+ if (event) {
+ let type = event.type();
+ let button = type == Clutter.EventType.BUTTON_PRESS ||
+ type == Clutter.EventType.BUTTON_RELEASE;
+ let ctrl = (event.get_state() & Clutter.ModifierType.CONTROL_MASK) != 0;
+ if (button && ctrl)
+ return;
+ }
+
+ this._shown = false;
+
+ this._animateNotVisible();
+ this._syncGrab();
+ }
+
+ _animateNotVisible() {
+ if (!this._visible || this._animationInProgress)
+ return;
+
+ this._animationInProgress = true;
+ this._visibleTarget = false;
+
+ this.viewSelector.animateFromOverview();
+
+ // Make other elements fade out.
+ this._overview.ease({
+ opacity: 0,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: ANIMATION_TIME,
+ onComplete: () => this._hideDone(),
+ });
+ this._unshadeBackgrounds();
+
+ Main.layoutManager.overviewGroup.set_child_above_sibling(
+ this._coverPane, null);
+ this._coverPane.show();
+ this.emit('hiding');
+ }
+
+ _hideDone() {
+ // Re-enable unredirection
+ Meta.enable_unredirect_for_display(global.display);
+
+ this.viewSelector.hide();
+ this._desktopFade.hide();
+ this._coverPane.hide();
+
+ this._visible = false;
+ this._animationInProgress = false;
+
+ this.emit('hidden');
+ // Handle any calls to show* while we were hiding
+ if (this._shown)
+ this._animateVisible();
+ else
+ Main.layoutManager.hideOverview();
+
+ this._syncGrab();
+ }
+
+ toggle() {
+ if (this.isDummy)
+ return;
+
+ if (this._visible)
+ this.hide();
+ else
+ this.show();
+ }
+
+ getShowAppsButton() {
+ logError(new Error('Usage of Overview.\'getShowAppsButton\' is deprecated, ' +
+ 'use \'dash.showAppsButton\' property instead'));
+
+ return this.dash.showAppsButton;
+ }
+
+ get searchEntry() {
+ return this._overview.searchEntry;
+ }
+};
+Signals.addSignalMethods(Overview.prototype);
diff --git a/js/ui/overviewControls.js b/js/ui/overviewControls.js
new file mode 100644
index 0000000..b5e89bb
--- /dev/null
+++ b/js/ui/overviewControls.js
@@ -0,0 +1,519 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported ControlsManager */
+
+const { Clutter, GObject, Meta, St } = imports.gi;
+
+const Dash = imports.ui.dash;
+const Main = imports.ui.main;
+const Params = imports.misc.params;
+const ViewSelector = imports.ui.viewSelector;
+const WorkspaceThumbnail = imports.ui.workspaceThumbnail;
+const Overview = imports.ui.overview;
+
+var SIDE_CONTROLS_ANIMATION_TIME = Overview.ANIMATION_TIME;
+
+function getRtlSlideDirection(direction, actor) {
+ let rtl = actor.text_direction == Clutter.TextDirection.RTL;
+ if (rtl) {
+ direction = direction == SlideDirection.LEFT
+ ? SlideDirection.RIGHT : SlideDirection.LEFT;
+ }
+ return direction;
+}
+
+var SlideDirection = {
+ LEFT: 0,
+ RIGHT: 1,
+};
+
+var SlideLayout = GObject.registerClass({
+ Properties: {
+ 'slide-x': GObject.ParamSpec.double(
+ 'slide-x', 'slide-x', 'slide-x',
+ GObject.ParamFlags.READWRITE,
+ 0, 1, 1),
+ },
+}, class SlideLayout extends Clutter.FixedLayout {
+ _init(params) {
+ this._slideX = 1;
+ this._direction = SlideDirection.LEFT;
+
+ super._init(params);
+ }
+
+ vfunc_get_preferred_width(container, forHeight) {
+ let child = container.get_first_child();
+
+ let [minWidth, natWidth] = child.get_preferred_width(forHeight);
+
+ minWidth *= this._slideX;
+ natWidth *= this._slideX;
+
+ return [minWidth, natWidth];
+ }
+
+ vfunc_allocate(container, box) {
+ let child = container.get_first_child();
+
+ let availWidth = Math.round(box.x2 - box.x1);
+ let availHeight = Math.round(box.y2 - box.y1);
+ let [, natWidth] = child.get_preferred_width(availHeight);
+
+ // Align the actor inside the clipped box, as the actor's alignment
+ // flags only determine what to do if the allocated box is bigger
+ // than the actor's box.
+ let realDirection = getRtlSlideDirection(this._direction, child);
+ let alignX = realDirection == SlideDirection.LEFT
+ ? availWidth - natWidth
+ : availWidth - natWidth * this._slideX;
+
+ let actorBox = new Clutter.ActorBox();
+ actorBox.x1 = box.x1 + alignX;
+ actorBox.x2 = actorBox.x1 + (child.x_expand ? availWidth : natWidth);
+ actorBox.y1 = box.y1;
+ actorBox.y2 = actorBox.y1 + availHeight;
+
+ child.allocate(actorBox);
+ }
+
+ // eslint-disable-next-line camelcase
+ set slide_x(value) {
+ if (this._slideX == value)
+ return;
+ this._slideX = value;
+ this.notify('slide-x');
+ this.layout_changed();
+ }
+
+ // eslint-disable-next-line camelcase
+ get slide_x() {
+ return this._slideX;
+ }
+
+ set slideDirection(direction) {
+ this._direction = direction;
+ this.layout_changed();
+ }
+
+ get slideDirection() {
+ return this._direction;
+ }
+});
+
+var SlidingControl = GObject.registerClass(
+class SlidingControl extends St.Widget {
+ _init(params) {
+ params = Params.parse(params, { slideDirection: SlideDirection.LEFT });
+
+ this.layout = new SlideLayout();
+ this.layout.slideDirection = params.slideDirection;
+ super._init({
+ layout_manager: this.layout,
+ style_class: 'overview-controls',
+ clip_to_allocation: true,
+ });
+
+ this._visible = true;
+ this._inDrag = false;
+
+ Main.overview.connect('hiding', this._onOverviewHiding.bind(this));
+
+ Main.overview.connect('item-drag-begin', this._onDragBegin.bind(this));
+ Main.overview.connect('item-drag-end', this._onDragEnd.bind(this));
+ Main.overview.connect('item-drag-cancelled', this._onDragEnd.bind(this));
+
+ Main.overview.connect('window-drag-begin', this._onWindowDragBegin.bind(this));
+ Main.overview.connect('window-drag-cancelled', this._onWindowDragEnd.bind(this));
+ Main.overview.connect('window-drag-end', this._onWindowDragEnd.bind(this));
+ }
+
+ _getSlide() {
+ throw new GObject.NotImplementedError('_getSlide in %s'.format(this.constructor.name));
+ }
+
+ _updateSlide() {
+ this.ease_property('@layout.slide-x', this._getSlide(), {
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: SIDE_CONTROLS_ANIMATION_TIME,
+ });
+ }
+
+ getVisibleWidth() {
+ let child = this.get_first_child();
+ let [, , natWidth] = child.get_preferred_size();
+ return natWidth;
+ }
+
+ _getTranslation() {
+ let child = this.get_first_child();
+ let direction = getRtlSlideDirection(this.layout.slideDirection, child);
+ let visibleWidth = this.getVisibleWidth();
+
+ if (direction == SlideDirection.LEFT)
+ return -visibleWidth;
+ else
+ return visibleWidth;
+ }
+
+ _updateTranslation() {
+ let translationStart = 0;
+ let translationEnd = 0;
+ let translation = this._getTranslation();
+
+ let shouldShow = this._getSlide() > 0;
+ if (shouldShow)
+ translationStart = translation;
+ else
+ translationEnd = translation;
+
+ if (this.translation_x === translationEnd)
+ return;
+
+ this.translation_x = translationStart;
+ this.ease({
+ translation_x: translationEnd,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: SIDE_CONTROLS_ANIMATION_TIME,
+ });
+ }
+
+ _onOverviewHiding() {
+ // We need to explicitly slideOut since showing pages
+ // doesn't imply sliding out, instead, hiding the overview does.
+ this.slideOut();
+ }
+
+ _onWindowDragBegin() {
+ this._onDragBegin();
+ }
+
+ _onWindowDragEnd() {
+ this._onDragEnd();
+ }
+
+ _onDragBegin() {
+ this._inDrag = true;
+ this._updateTranslation();
+ this._updateSlide();
+ }
+
+ _onDragEnd() {
+ this._inDrag = false;
+ this._updateSlide();
+ }
+
+ fadeIn() {
+ this.ease({
+ opacity: 255,
+ duration: SIDE_CONTROLS_ANIMATION_TIME / 2,
+ mode: Clutter.AnimationMode.EASE_IN_QUAD,
+ });
+ }
+
+ fadeHalf() {
+ this.ease({
+ opacity: 128,
+ duration: SIDE_CONTROLS_ANIMATION_TIME / 2,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ slideIn() {
+ this._visible = true;
+ // we will update slide_x and the translation from pageEmpty
+ }
+
+ slideOut() {
+ this._visible = false;
+ this._updateTranslation();
+ // we will update slide_x from pageEmpty
+ }
+
+ pageEmpty() {
+ // When pageEmpty is received, there's no visible view in the
+ // selector; this means we can now safely set the full slide for
+ // the next page, since slideIn or slideOut might have been called,
+ // changing the visibility
+ this.remove_transition('@layout.slide-x');
+ this.layout.slide_x = this._getSlide();
+ this._updateTranslation();
+ }
+});
+
+var ThumbnailsSlider = GObject.registerClass(
+class ThumbnailsSlider extends SlidingControl {
+ _init(thumbnailsBox) {
+ super._init({ slideDirection: SlideDirection.RIGHT });
+
+ this._thumbnailsBox = thumbnailsBox;
+
+ this.request_mode = Clutter.RequestMode.WIDTH_FOR_HEIGHT;
+ this.reactive = true;
+ this.track_hover = true;
+ this.add_actor(this._thumbnailsBox);
+
+ Main.layoutManager.connect('monitors-changed', this._updateSlide.bind(this));
+ global.workspace_manager.connect('active-workspace-changed',
+ this._updateSlide.bind(this));
+ global.workspace_manager.connect('notify::n-workspaces',
+ this._updateSlide.bind(this));
+ this.connect('notify::hover', this._updateSlide.bind(this));
+ this._thumbnailsBox.bind_property('visible', this, 'visible', GObject.BindingFlags.SYNC_CREATE);
+ }
+
+ _getAlwaysZoomOut() {
+ // Always show the pager on hover, during a drag, or if workspaces are
+ // actually used, e.g. there are windows on any non-active workspace
+ let workspaceManager = global.workspace_manager;
+ let alwaysZoomOut = this.hover ||
+ this._inDrag ||
+ !Meta.prefs_get_dynamic_workspaces() ||
+ workspaceManager.n_workspaces > 2 ||
+ workspaceManager.get_active_workspace_index() != 0;
+
+ if (!alwaysZoomOut) {
+ let monitors = Main.layoutManager.monitors;
+ let primary = Main.layoutManager.primaryMonitor;
+
+ /* Look for any monitor to the right of the primary, if there is
+ * one, we always keep zoom out, otherwise its hard to reach
+ * the thumbnail area without passing into the next monitor. */
+ for (let i = 0; i < monitors.length; i++) {
+ if (monitors[i].x >= primary.x + primary.width) {
+ alwaysZoomOut = true;
+ break;
+ }
+ }
+ }
+
+ return alwaysZoomOut;
+ }
+
+ getNonExpandedWidth() {
+ let child = this.get_first_child();
+ return child.get_theme_node().get_length('visible-width');
+ }
+
+ _onDragEnd() {
+ this.sync_hover();
+ super._onDragEnd();
+ }
+
+ _getSlide() {
+ if (!this._visible)
+ return 0;
+
+ let alwaysZoomOut = this._getAlwaysZoomOut();
+ if (alwaysZoomOut)
+ return 1;
+
+ let child = this.get_first_child();
+ let preferredHeight = child.get_preferred_height(-1)[1];
+ let expandedWidth = child.get_preferred_width(preferredHeight)[1];
+
+ return this.getNonExpandedWidth() / expandedWidth;
+ }
+
+ getVisibleWidth() {
+ let alwaysZoomOut = this._getAlwaysZoomOut();
+ if (alwaysZoomOut)
+ return super.getVisibleWidth();
+ else
+ return this.getNonExpandedWidth();
+ }
+});
+
+var DashSlider = GObject.registerClass(
+class DashSlider extends SlidingControl {
+ _init(dash) {
+ super._init({ slideDirection: SlideDirection.LEFT });
+
+ this._dash = dash;
+
+ // SlideLayout reads the actor's expand flags to decide
+ // whether to allocate the natural size to its child, or the whole
+ // available allocation
+ this._dash.x_expand = true;
+
+ this.x_expand = true;
+ this.x_align = Clutter.ActorAlign.START;
+ this.y_expand = true;
+
+ this.add_actor(this._dash);
+
+ this._dash.connect('icon-size-changed', this._updateSlide.bind(this));
+ }
+
+ _getSlide() {
+ if (this._visible || this._inDrag)
+ return 1;
+ else
+ return 0;
+ }
+
+ _onWindowDragBegin() {
+ this.fadeHalf();
+ }
+
+ _onWindowDragEnd() {
+ this.fadeIn();
+ }
+});
+
+var DashSpacer = GObject.registerClass(
+class DashSpacer extends St.Widget {
+ _init(params) {
+ super._init(params);
+
+ this._bindConstraint = null;
+ }
+
+ setDashActor(dashActor) {
+ if (this._bindConstraint) {
+ this.remove_constraint(this._bindConstraint);
+ this._bindConstraint = null;
+ }
+
+ if (dashActor) {
+ this._bindConstraint = new Clutter.BindConstraint({ source: dashActor,
+ coordinate: Clutter.BindCoordinate.SIZE });
+ this.add_constraint(this._bindConstraint);
+ }
+ }
+
+ vfunc_get_preferred_width(forHeight) {
+ if (this._bindConstraint)
+ return this._bindConstraint.source.get_preferred_width(forHeight);
+ return super.vfunc_get_preferred_width(forHeight);
+ }
+
+ vfunc_get_preferred_height(forWidth) {
+ if (this._bindConstraint)
+ return this._bindConstraint.source.get_preferred_height(forWidth);
+ return super.vfunc_get_preferred_height(forWidth);
+ }
+});
+
+var ControlsLayout = GObject.registerClass({
+ Signals: { 'allocation-changed': {} },
+}, class ControlsLayout extends Clutter.BinLayout {
+ vfunc_allocate(container, box) {
+ super.vfunc_allocate(container, box);
+ this.emit('allocation-changed');
+ }
+});
+
+var ControlsManager = GObject.registerClass(
+class ControlsManager extends St.Widget {
+ _init(searchEntry) {
+ let layout = new ControlsLayout();
+ super._init({
+ layout_manager: layout,
+ x_expand: true,
+ y_expand: true,
+ clip_to_allocation: true,
+ });
+
+ this.dash = new Dash.Dash();
+ this._dashSlider = new DashSlider(this.dash);
+ this._dashSpacer = new DashSpacer();
+ this._dashSpacer.setDashActor(this._dashSlider);
+
+ let workspaceManager = global.workspace_manager;
+ let activeWorkspaceIndex = workspaceManager.get_active_workspace_index();
+
+ this._workspaceAdjustment = new St.Adjustment({
+ actor: this,
+ value: activeWorkspaceIndex,
+ lower: 0,
+ page_increment: 1,
+ page_size: 1,
+ step_increment: 0,
+ upper: workspaceManager.n_workspaces,
+ });
+
+ this._nWorkspacesNotifyId =
+ workspaceManager.connect('notify::n-workspaces',
+ this._updateAdjustment.bind(this));
+
+ this._thumbnailsBox =
+ new WorkspaceThumbnail.ThumbnailsBox(this._workspaceAdjustment);
+ this._thumbnailsSlider = new ThumbnailsSlider(this._thumbnailsBox);
+
+ this.viewSelector = new ViewSelector.ViewSelector(searchEntry,
+ this._workspaceAdjustment, this.dash.showAppsButton);
+ this.viewSelector.connect('page-changed', this._setVisibility.bind(this));
+ this.viewSelector.connect('page-empty', this._onPageEmpty.bind(this));
+
+ this._group = new St.BoxLayout({ name: 'overview-group',
+ x_expand: true, y_expand: true });
+ this.add_actor(this._group);
+
+ this.add_actor(this._dashSlider);
+
+ this._group.add_actor(this._dashSpacer);
+ this._group.add_child(this.viewSelector);
+ this._group.add_actor(this._thumbnailsSlider);
+
+ Main.overview.connect('showing', this._updateSpacerVisibility.bind(this));
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ _onDestroy() {
+ global.workspace_manager.disconnect(this._nWorkspacesNotifyId);
+ }
+
+ _updateAdjustment() {
+ let workspaceManager = global.workspace_manager;
+ let newNumWorkspaces = workspaceManager.n_workspaces;
+ let activeIndex = workspaceManager.get_active_workspace_index();
+
+ this._workspaceAdjustment.upper = newNumWorkspaces;
+
+ // A workspace might have been inserted or removed before the active
+ // one, causing the adjustment to go out of sync, so update the value
+ this._workspaceAdjustment.remove_transition('value');
+ this._workspaceAdjustment.value = activeIndex;
+ }
+
+ _setVisibility() {
+ // Ignore the case when we're leaving the overview, since
+ // actors will be made visible again when entering the overview
+ // next time, and animating them while doing so is just
+ // unnecessary noise
+ if (!Main.overview.visible ||
+ (Main.overview.animationInProgress && !Main.overview.visibleTarget))
+ return;
+
+ let activePage = this.viewSelector.getActivePage();
+ let dashVisible = activePage == ViewSelector.ViewPage.WINDOWS ||
+ activePage == ViewSelector.ViewPage.APPS;
+ let thumbnailsVisible = activePage == ViewSelector.ViewPage.WINDOWS;
+
+ if (dashVisible)
+ this._dashSlider.slideIn();
+ else
+ this._dashSlider.slideOut();
+
+ if (thumbnailsVisible)
+ this._thumbnailsSlider.slideIn();
+ else
+ this._thumbnailsSlider.slideOut();
+ }
+
+ _updateSpacerVisibility() {
+ if (Main.overview.animationInProgress && !Main.overview.visibleTarget)
+ return;
+
+ let activePage = this.viewSelector.getActivePage();
+ this._dashSpacer.visible = activePage == ViewSelector.ViewPage.WINDOWS;
+ }
+
+ _onPageEmpty() {
+ this._dashSlider.pageEmpty();
+ this._thumbnailsSlider.pageEmpty();
+
+ this._updateSpacerVisibility();
+ }
+});
diff --git a/js/ui/padOsd.js b/js/ui/padOsd.js
new file mode 100644
index 0000000..9ab1cbb
--- /dev/null
+++ b/js/ui/padOsd.js
@@ -0,0 +1,993 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported PadOsd, PadOsdService */
+
+const { Atk, Clutter, GDesktopEnums, Gio,
+ GLib, GObject, Gtk, Meta, Pango, Rsvg, St } = imports.gi;
+const ByteArray = imports.byteArray;
+const Signals = imports.signals;
+
+const Main = imports.ui.main;
+const PopupMenu = imports.ui.popupMenu;
+const Layout = imports.ui.layout;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+const ACTIVE_COLOR = "#729fcf";
+
+const LTR = 0;
+const RTL = 1;
+
+const CW = 0;
+const CCW = 1;
+
+const UP = 0;
+const DOWN = 1;
+
+var PadChooser = GObject.registerClass({
+ Signals: { 'pad-selected': { param_types: [Clutter.InputDevice.$gtype] } },
+}, class PadChooser extends St.Button {
+ _init(device, groupDevices) {
+ super._init({
+ style_class: 'pad-chooser-button',
+ toggle_mode: true,
+ });
+ this.currentDevice = device;
+ this._padChooserMenu = null;
+
+ let arrow = new St.Icon({
+ style_class: 'popup-menu-arrow',
+ icon_name: 'pan-down-symbolic',
+ accessible_role: Atk.Role.ARROW,
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ this.set_child(arrow);
+ this._ensureMenu(groupDevices);
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ vfunc_clicked() {
+ if (this.get_checked()) {
+ if (this._padChooserMenu != null)
+ this._padChooserMenu.open(true);
+ else
+ this.set_checked(false);
+ } else {
+ this._padChooserMenu.close(true);
+ }
+ }
+
+ _ensureMenu(devices) {
+ this._padChooserMenu = new PopupMenu.PopupMenu(this, 0.5, St.Side.TOP);
+ this._padChooserMenu.connect('menu-closed', () => {
+ this.set_checked(false);
+ });
+ this._padChooserMenu.actor.hide();
+ Main.uiGroup.add_actor(this._padChooserMenu.actor);
+
+ for (let i = 0; i < devices.length; i++) {
+ let device = devices[i];
+ if (device == this.currentDevice)
+ continue;
+
+ this._padChooserMenu.addAction(device.get_device_name(), () => {
+ this.emit('pad-selected', device);
+ });
+ }
+ }
+
+ _onDestroy() {
+ this._padChooserMenu.destroy();
+ }
+
+ update(devices) {
+ if (this._padChooserMenu)
+ this._padChooserMenu.actor.destroy();
+ this.set_checked(false);
+ this._ensureMenu(devices);
+ }
+});
+
+var KeybindingEntry = GObject.registerClass({
+ Signals: { 'keybinding-edited': { param_types: [GObject.TYPE_STRING] } },
+}, class KeybindingEntry extends St.Entry {
+ _init() {
+ super._init({ hint_text: _("New shortcut…"), style: 'width: 10em' });
+ }
+
+ vfunc_captured_event(event) {
+ if (event.type() != Clutter.EventType.KEY_PRESS)
+ return Clutter.EVENT_PROPAGATE;
+
+ let str = Gtk.accelerator_name_with_keycode(null,
+ event.get_key_symbol(),
+ event.get_key_code(),
+ event.get_state());
+ this.set_text(str);
+ this.emit('keybinding-edited', str);
+ return Clutter.EVENT_STOP;
+ }
+});
+
+var ActionComboBox = GObject.registerClass({
+ Signals: { 'action-selected': { param_types: [GObject.TYPE_INT] } },
+}, class ActionComboBox extends St.Button {
+ _init() {
+ super._init({ style_class: 'button' });
+ this.set_toggle_mode(true);
+
+ let boxLayout = new Clutter.BoxLayout({ orientation: Clutter.Orientation.HORIZONTAL,
+ spacing: 6 });
+ let box = new St.Widget({ layout_manager: boxLayout });
+ this.set_child(box);
+
+ this._label = new St.Label({ style_class: 'combo-box-label' });
+ box.add_child(this._label);
+
+ let arrow = new St.Icon({ style_class: 'popup-menu-arrow',
+ icon_name: 'pan-down-symbolic',
+ accessible_role: Atk.Role.ARROW,
+ y_expand: true,
+ y_align: Clutter.ActorAlign.CENTER });
+ box.add_child(arrow);
+
+ this._editMenu = new PopupMenu.PopupMenu(this, 0, St.Side.TOP);
+ this._editMenu.connect('menu-closed', () => {
+ this.set_checked(false);
+ });
+ this._editMenu.actor.hide();
+ Main.uiGroup.add_actor(this._editMenu.actor);
+
+ this._actionLabels = new Map();
+ this._actionLabels.set(GDesktopEnums.PadButtonAction.NONE, _("Application defined"));
+ this._actionLabels.set(GDesktopEnums.PadButtonAction.HELP, _("Show on-screen help"));
+ this._actionLabels.set(GDesktopEnums.PadButtonAction.SWITCH_MONITOR, _("Switch monitor"));
+ this._actionLabels.set(GDesktopEnums.PadButtonAction.KEYBINDING, _("Assign keystroke"));
+
+ this._buttonItems = [];
+
+ for (let [action, label] of this._actionLabels.entries()) {
+ let selectedAction = action;
+ let item = this._editMenu.addAction(label, () => {
+ this._onActionSelected(selectedAction);
+ });
+
+ /* These actions only apply to pad buttons */
+ if (selectedAction == GDesktopEnums.PadButtonAction.HELP ||
+ selectedAction == GDesktopEnums.PadButtonAction.SWITCH_MONITOR)
+ this._buttonItems.push(item);
+ }
+
+ this.setAction(GDesktopEnums.PadButtonAction.NONE);
+ }
+
+ _onActionSelected(action) {
+ this.setAction(action);
+ this.popdown();
+ this.emit('action-selected', action);
+ }
+
+ setAction(action) {
+ this._label.set_text(this._actionLabels.get(action));
+ }
+
+ popup() {
+ this._editMenu.open(true);
+ }
+
+ popdown() {
+ this._editMenu.close(true);
+ }
+
+ vfunc_clicked() {
+ if (this.get_checked())
+ this.popup();
+ else
+ this.popdown();
+ }
+
+ setButtonActionsActive(active) {
+ this._buttonItems.forEach(item => item.setSensitive(active));
+ }
+});
+
+var ActionEditor = GObject.registerClass({
+ Signals: { 'done': {} },
+}, class ActionEditor extends St.Widget {
+ _init() {
+ let boxLayout = new Clutter.BoxLayout({ orientation: Clutter.Orientation.HORIZONTAL,
+ spacing: 12 });
+
+ super._init({ layout_manager: boxLayout });
+
+ this._actionComboBox = new ActionComboBox();
+ this._actionComboBox.connect('action-selected', this._onActionSelected.bind(this));
+ this.add_actor(this._actionComboBox);
+
+ this._keybindingEdit = new KeybindingEntry();
+ this._keybindingEdit.connect('keybinding-edited', this._onKeybindingEdited.bind(this));
+ this.add_actor(this._keybindingEdit);
+
+ this._doneButton = new St.Button({ label: _("Done"),
+ style_class: 'button',
+ x_expand: false });
+ this._doneButton.connect('clicked', this._onEditingDone.bind(this));
+ this.add_actor(this._doneButton);
+ }
+
+ _updateKeybindingEntryState() {
+ if (this._currentAction == GDesktopEnums.PadButtonAction.KEYBINDING) {
+ this._keybindingEdit.set_text(this._currentKeybinding);
+ this._keybindingEdit.show();
+ this._keybindingEdit.grab_key_focus();
+ } else {
+ this._keybindingEdit.hide();
+ }
+ }
+
+ setSettings(settings, action) {
+ this._buttonSettings = settings;
+
+ this._currentAction = this._buttonSettings.get_enum('action');
+ this._currentKeybinding = this._buttonSettings.get_string('keybinding');
+ this._actionComboBox.setAction(this._currentAction);
+ this._updateKeybindingEntryState();
+
+ let isButton = action == Meta.PadActionType.BUTTON;
+ this._actionComboBox.setButtonActionsActive(isButton);
+ }
+
+ close() {
+ this._actionComboBox.popdown();
+ this.hide();
+ }
+
+ _onKeybindingEdited(entry, keybinding) {
+ this._currentKeybinding = keybinding;
+ }
+
+ _onActionSelected(menu, action) {
+ this._currentAction = action;
+ this._updateKeybindingEntryState();
+ }
+
+ _storeSettings() {
+ if (!this._buttonSettings)
+ return;
+
+ let keybinding = null;
+
+ if (this._currentAction == GDesktopEnums.PadButtonAction.KEYBINDING)
+ keybinding = this._currentKeybinding;
+
+ this._buttonSettings.set_enum('action', this._currentAction);
+
+ if (keybinding)
+ this._buttonSettings.set_string('keybinding', keybinding);
+ else
+ this._buttonSettings.reset('keybinding');
+ }
+
+ _onEditingDone() {
+ this._storeSettings();
+ this.close();
+ this.emit('done');
+ }
+});
+
+var PadDiagram = GObject.registerClass({
+ Properties: {
+ 'left-handed': GObject.ParamSpec.boolean('left-handed',
+ 'left-handed', 'Left handed',
+ GObject.ParamFlags.READWRITE |
+ GObject.ParamFlags.CONSTRUCT_ONLY,
+ false),
+ 'image': GObject.ParamSpec.string('image', 'image', 'Image',
+ GObject.ParamFlags.READWRITE |
+ GObject.ParamFlags.CONSTRUCT_ONLY,
+ null),
+ 'editor-actor': GObject.ParamSpec.object('editor-actor',
+ 'editor-actor',
+ 'Editor actor',
+ GObject.ParamFlags.READWRITE |
+ GObject.ParamFlags.CONSTRUCT_ONLY,
+ Clutter.Actor.$gtype),
+ },
+}, class PadDiagram extends St.DrawingArea {
+ _init(params) {
+ let file = Gio.File.new_for_uri('resource:///org/gnome/shell/theme/pad-osd.css');
+ let [success_, css] = file.load_contents(null);
+ css = ByteArray.toString(css);
+ this._curEdited = null;
+ this._prevEdited = null;
+ this._css = css;
+ this._labels = [];
+ this._activeButtons = [];
+ super._init(params);
+ }
+
+ // eslint-disable-next-line camelcase
+ get left_handed() {
+ return this._leftHanded;
+ }
+
+ // eslint-disable-next-line camelcase
+ set left_handed(leftHanded) {
+ this._leftHanded = leftHanded;
+ }
+
+ get image() {
+ return this._imagePath;
+ }
+
+ set image(imagePath) {
+ let originalHandle = Rsvg.Handle.new_from_file(imagePath);
+ let dimensions = originalHandle.get_dimensions();
+ this._imageWidth = dimensions.width;
+ this._imageHeight = dimensions.height;
+
+ this._imagePath = imagePath;
+ this._handle = this._composeStyledDiagram();
+ this._initLabels();
+ }
+
+ // eslint-disable-next-line camelcase
+ get editor_actor() {
+ return this._editorActor;
+ }
+
+ // eslint-disable-next-line camelcase
+ set editor_actor(actor) {
+ actor.hide();
+ this._editorActor = actor;
+ this.add_actor(actor);
+ }
+
+ _initLabels() {
+ let i = 0;
+ for (i = 0; ; i++) {
+ if (!this._addLabel(Meta.PadActionType.BUTTON, i))
+ break;
+ }
+
+ for (i = 0; ; i++) {
+ if (!this._addLabel(Meta.PadActionType.RING, i, CW) ||
+ !this._addLabel(Meta.PadActionType.RING, i, CCW))
+ break;
+ }
+
+ for (i = 0; ; i++) {
+ if (!this._addLabel(Meta.PadActionType.STRIP, i, UP) ||
+ !this._addLabel(Meta.PadActionType.STRIP, i, DOWN))
+ break;
+ }
+ }
+
+ _wrappingSvgHeader() {
+ return '<?xml version="1.0" encoding="UTF-8" standalone="no"?>' +
+ '<svg version="1.1" xmlns="http://www.w3.org/2000/svg" ' +
+ 'xmlns:xi="http://www.w3.org/2001/XInclude" ' +
+ 'width="%d" height="%d">'.format(this._imageWidth, this._imageHeight) +
+ '<style type="text/css">';
+ }
+
+ _wrappingSvgFooter() {
+ return '</style>' +
+ '<xi:include href="' + this._imagePath + '" />' +
+ '</svg>';
+ }
+
+ _cssString() {
+ let css = this._css;
+
+ for (let i = 0; i < this._activeButtons.length; i++) {
+ let ch = String.fromCharCode('A'.charCodeAt() + this._activeButtons[i]);
+ css += '.%s.Leader { stroke: %s !important; }'.format(ch, ACTIVE_COLOR);
+ css += '.%s.Button { stroke: %s !important; fill: %s !important; }'.format(ch, ACTIVE_COLOR, ACTIVE_COLOR);
+ }
+
+ return css;
+ }
+
+ _composeStyledDiagram() {
+ let svgData = '';
+
+ if (!GLib.file_test(this._imagePath, GLib.FileTest.EXISTS))
+ return null;
+
+ svgData += this._wrappingSvgHeader();
+ svgData += this._cssString();
+ svgData += this._wrappingSvgFooter();
+
+ let istream = new Gio.MemoryInputStream();
+ istream.add_bytes(new GLib.Bytes(svgData));
+
+ return Rsvg.Handle.new_from_stream_sync(istream,
+ Gio.File.new_for_path(this._imagePath),
+ 0, null);
+ }
+
+ _updateDiagramScale() {
+ [this._actorWidth, this._actorHeight] = this.get_size();
+ let dimensions = this._handle.get_dimensions();
+ let scaleX = this._actorWidth / dimensions.width;
+ let scaleY = this._actorHeight / dimensions.height;
+ this._scale = Math.min(scaleX, scaleY);
+ }
+
+ _allocateChild(child, x, y, direction) {
+ let [, natHeight] = child.get_preferred_height(-1);
+ let [, natWidth] = child.get_preferred_width(natHeight);
+ let childBox = new Clutter.ActorBox();
+
+ // I miss Cairo.Matrix
+ let dimensions = this._handle.get_dimensions();
+ x = x * this._scale + this._actorWidth / 2 - dimensions.width / 2 * this._scale;
+ y = y * this._scale + this._actorHeight / 2 - dimensions.height / 2 * this._scale;
+
+ if (direction == LTR) {
+ childBox.x1 = x;
+ childBox.x2 = x + natWidth;
+ } else {
+ childBox.x1 = x - natWidth;
+ childBox.x2 = x;
+ }
+
+ childBox.y1 = y - natHeight / 2;
+ childBox.y2 = y + natHeight / 2;
+ child.allocate(childBox);
+ }
+
+ vfunc_allocate(box) {
+ super.vfunc_allocate(box);
+ if (this._handle === null)
+ return;
+
+ this._updateDiagramScale();
+
+ for (let i = 0; i < this._labels.length; i++) {
+ const { label, x, y, arrangement } = this._labels[i];
+ this._allocateChild(label, x, y, arrangement);
+ }
+
+ if (this._editorActor && this._curEdited) {
+ const { x, y, arrangement } = this._curEdited;
+ this._allocateChild(this._editorActor, x, y, arrangement);
+ }
+ }
+
+ vfunc_repaint() {
+ if (this._handle == null)
+ return;
+
+ if (this._scale == null)
+ this._updateDiagramScale();
+
+ let [width, height] = this.get_surface_size();
+ let dimensions = this._handle.get_dimensions();
+ let cr = this.get_context();
+
+ cr.save();
+ cr.translate(width / 2, height / 2);
+ cr.scale(this._scale, this._scale);
+ if (this._leftHanded)
+ cr.rotate(Math.PI);
+ cr.translate(-dimensions.width / 2, -dimensions.height / 2);
+ this._handle.render_cairo(cr);
+ cr.restore();
+ cr.$dispose();
+ }
+
+ _getItemLabelCoords(labelName, leaderName) {
+ if (this._handle == null)
+ return [false];
+
+ let leaderPos, leaderSize, pos;
+ let found, direction;
+
+ [found, pos] = this._handle.get_position_sub('#%s'.format(labelName));
+ if (!found)
+ return [false];
+
+ [found, leaderPos] = this._handle.get_position_sub('#%s'.format(leaderName));
+ [found, leaderSize] = this._handle.get_dimensions_sub('#%s'.format(leaderName));
+ if (!found)
+ return [false];
+
+ if (pos.x > leaderPos.x + leaderSize.width)
+ direction = LTR;
+ else
+ direction = RTL;
+
+ if (this._leftHanded) {
+ direction = 1 - direction;
+ pos.x = this._imageWidth - pos.x;
+ pos.y = this._imageHeight - pos.y;
+ }
+
+ return [true, pos.x, pos.y, direction];
+ }
+
+ _getButtonLabels(button) {
+ let ch = String.fromCharCode('A'.charCodeAt() + button);
+ let labelName = 'Label%s'.format(ch);
+ let leaderName = 'Leader%s'.format(ch);
+ return [labelName, leaderName];
+ }
+
+ _getRingLabels(number, dir) {
+ let numStr = number > 0 ? (number + 1).toString() : '';
+ let dirStr = dir == CW ? 'CW' : 'CCW';
+ let labelName = 'LabelRing%s%s'.format(numStr, dirStr);
+ let leaderName = 'LeaderRing%s%s'.format(numStr, dirStr);
+ return [labelName, leaderName];
+ }
+
+ _getStripLabels(number, dir) {
+ let numStr = number > 0 ? (number + 1).toString() : '';
+ let dirStr = dir == UP ? 'Up' : 'Down';
+ let labelName = 'LabelStrip%s%s'.format(numStr, dirStr);
+ let leaderName = 'LeaderStrip%s%s'.format(numStr, dirStr);
+ return [labelName, leaderName];
+ }
+
+ _getLabelCoords(action, idx, dir) {
+ if (action == Meta.PadActionType.BUTTON)
+ return this._getItemLabelCoords(...this._getButtonLabels(idx));
+ else if (action == Meta.PadActionType.RING)
+ return this._getItemLabelCoords(...this._getRingLabels(idx, dir));
+ else if (action == Meta.PadActionType.STRIP)
+ return this._getItemLabelCoords(...this._getStripLabels(idx, dir));
+
+ return [false];
+ }
+
+ _invalidateSvg() {
+ if (this._handle == null)
+ return;
+ this._handle = this._composeStyledDiagram();
+ this.queue_repaint();
+ }
+
+ activateButton(button) {
+ this._activeButtons.push(button);
+ this._invalidateSvg();
+ }
+
+ deactivateButton(button) {
+ for (let i = 0; i < this._activeButtons.length; i++) {
+ if (this._activeButtons[i] == button)
+ this._activeButtons.splice(i, 1);
+ }
+ this._invalidateSvg();
+ }
+
+ _addLabel(action, idx, dir) {
+ let [found, x, y, arrangement] = this._getLabelCoords(action, idx, dir);
+ if (!found)
+ return false;
+
+ let label = new St.Label();
+ this._labels.push({ label, action, idx, dir, x, y, arrangement });
+ this.add_actor(label);
+ return true;
+ }
+
+ updateLabels(getText) {
+ for (let i = 0; i < this._labels.length; i++) {
+ const { label, action, idx, dir } = this._labels[i];
+ let str = getText(action, idx, dir);
+ label.set_text(str);
+ }
+
+ this.queue_relayout();
+ }
+
+ _applyLabel(label, action, idx, dir, str) {
+ if (str !== null)
+ label.set_text(str);
+ label.show();
+ }
+
+ stopEdition(continues, str) {
+ this._editorActor.hide();
+
+ if (this._prevEdited) {
+ const { label, action, idx, dir } = this._prevEdited;
+ this._applyLabel(label, action, idx, dir, str);
+ this._prevEdited = null;
+ }
+
+ if (this._curEdited) {
+ const { label, action, idx, dir } = this._curEdited;
+ this._applyLabel(label, action, idx, dir, str);
+ if (continues)
+ this._prevEdited = this._curEdited;
+ this._curEdited = null;
+ }
+
+ this.queue_relayout();
+ }
+
+ startEdition(action, idx, dir) {
+ let editedLabel;
+
+ if (this._curEdited)
+ return;
+
+ for (let i = 0; i < this._labels.length; i++) {
+ if (action == this._labels[i].action &&
+ idx == this._labels[i].idx && dir == this._labels[i].dir) {
+ this._curEdited = this._labels[i];
+ editedLabel = this._curEdited.label;
+ break;
+ }
+ }
+
+ if (this._curEdited == null)
+ return;
+ this._editorActor.show();
+ editedLabel.hide();
+ this.queue_relayout();
+ }
+});
+
+var PadOsd = GObject.registerClass({
+ Signals: {
+ 'pad-selected': { param_types: [Clutter.InputDevice.$gtype] },
+ 'closed': {},
+ },
+}, class PadOsd extends St.BoxLayout {
+ _init(padDevice, settings, imagePath, editionMode, monitorIndex) {
+ super._init({
+ style_class: 'pad-osd-window',
+ vertical: true,
+ x_expand: true,
+ y_expand: true,
+ reactive: true,
+ });
+
+ this.padDevice = padDevice;
+ this._groupPads = [padDevice];
+ this._settings = settings;
+ this._imagePath = imagePath;
+ this._editionMode = editionMode;
+ this._capturedEventId = global.stage.connect('captured-event', this._onCapturedEvent.bind(this));
+ this._padChooser = null;
+
+ let seat = Clutter.get_default_backend().get_default_seat();
+ this._deviceAddedId = seat.connect('device-added', (_seat, device) => {
+ if (device.get_device_type() == Clutter.InputDeviceType.PAD_DEVICE &&
+ this.padDevice.is_grouped(device)) {
+ this._groupPads.push(device);
+ this._updatePadChooser();
+ }
+ });
+ this._deviceRemovedId = seat.connect('device-removed', (_seat, device) => {
+ // If the device is being removed, destroy the padOsd.
+ if (device == this.padDevice) {
+ this.destroy();
+ } else if (this._groupPads.includes(device)) {
+ // Or update the pad chooser if the device belongs to
+ // the same group.
+ this._groupPads.splice(this._groupPads.indexOf(device), 1);
+ this._updatePadChooser();
+
+ }
+ });
+
+ seat.list_devices().forEach(device => {
+ if (device != this.padDevice &&
+ device.get_device_type() == Clutter.InputDeviceType.PAD_DEVICE &&
+ this.padDevice.is_grouped(device))
+ this._groupPads.push(device);
+ });
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ Main.uiGroup.add_actor(this);
+
+ this._monitorIndex = monitorIndex;
+ let constraint = new Layout.MonitorConstraint({ index: monitorIndex });
+ this.add_constraint(constraint);
+
+ this._titleBox = new St.BoxLayout({ style_class: 'pad-osd-title-box',
+ vertical: false,
+ x_expand: false,
+ x_align: Clutter.ActorAlign.CENTER });
+ this.add_actor(this._titleBox);
+
+ let labelBox = new St.BoxLayout({ style_class: 'pad-osd-title-menu-box',
+ vertical: true });
+ this._titleBox.add_actor(labelBox);
+
+ this._titleLabel = new St.Label({ style: 'font-side: larger; font-weight: bold;',
+ x_align: Clutter.ActorAlign.CENTER });
+ this._titleLabel.clutter_text.set_ellipsize(Pango.EllipsizeMode.NONE);
+ this._titleLabel.clutter_text.set_text(padDevice.get_device_name());
+ labelBox.add_actor(this._titleLabel);
+
+ this._tipLabel = new St.Label({ x_align: Clutter.ActorAlign.CENTER });
+ labelBox.add_actor(this._tipLabel);
+
+ this._updatePadChooser();
+
+ this._actionEditor = new ActionEditor();
+ this._actionEditor.connect('done', this._endActionEdition.bind(this));
+
+ this._padDiagram = new PadDiagram({ image: this._imagePath,
+ left_handed: settings.get_boolean('left-handed'),
+ editor_actor: this._actionEditor,
+ x_expand: true,
+ y_expand: true });
+ this.add_actor(this._padDiagram);
+ this._updateActionLabels();
+
+ let buttonBox = new St.Widget({ layout_manager: new Clutter.BinLayout(),
+ x_expand: true,
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER });
+ this.add_actor(buttonBox);
+ this._editButton = new St.Button({
+ label: _('Edit…'),
+ style_class: 'button',
+ can_focus: true,
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+ this._editButton.connect('clicked', () => {
+ this.setEditionMode(true);
+ });
+ buttonBox.add_actor(this._editButton);
+
+ this._syncEditionMode();
+ Main.pushModal(this);
+ }
+
+ _updatePadChooser() {
+ if (this._groupPads.length > 1) {
+ if (this._padChooser == null) {
+ this._padChooser = new PadChooser(this.padDevice, this._groupPads);
+ this._padChooser.connect('pad-selected', (chooser, pad) => {
+ this._requestForOtherPad(pad);
+ });
+ this._titleBox.add_child(this._padChooser);
+ } else {
+ this._padChooser.update(this._groupPads);
+ }
+ } else if (this._padChooser != null) {
+ this._padChooser.destroy();
+ this._padChooser = null;
+ }
+ }
+
+ _requestForOtherPad(pad) {
+ if (pad == this.padDevice || !this._groupPads.includes(pad))
+ return;
+
+ let editionMode = this._editionMode;
+ this.destroy();
+ global.display.request_pad_osd(pad, editionMode);
+ }
+
+ _getActionText(type, number) {
+ let str = global.display.get_pad_action_label(this.padDevice, type, number);
+ return str ? str : _("None");
+ }
+
+ _updateActionLabels() {
+ this._padDiagram.updateLabels(this._getActionText.bind(this));
+ }
+
+ _onCapturedEvent(actor, event) {
+ let isModeSwitch =
+ (event.type() == Clutter.EventType.PAD_BUTTON_PRESS ||
+ event.type() == Clutter.EventType.PAD_BUTTON_RELEASE) &&
+ this.padDevice.get_mode_switch_button_group(event.get_button()) >= 0;
+
+ if (event.type() == Clutter.EventType.PAD_BUTTON_PRESS &&
+ event.get_source_device() == this.padDevice) {
+ this._padDiagram.activateButton(event.get_button());
+
+ /* Buttons that switch between modes cannot be edited */
+ if (this._editionMode && !isModeSwitch)
+ this._startButtonActionEdition(event.get_button());
+ return Clutter.EVENT_STOP;
+ } else if (event.type() == Clutter.EventType.PAD_BUTTON_RELEASE &&
+ event.get_source_device() == this.padDevice) {
+ this._padDiagram.deactivateButton(event.get_button());
+
+ if (isModeSwitch) {
+ this._endActionEdition();
+ this._updateActionLabels();
+ }
+ return Clutter.EVENT_STOP;
+ } else if (event.type() == Clutter.EventType.KEY_PRESS &&
+ (!this._editionMode || event.get_key_symbol() === Clutter.KEY_Escape)) {
+ if (this._editedAction != null)
+ this._endActionEdition();
+ else
+ this.destroy();
+ return Clutter.EVENT_STOP;
+ } else if (event.get_source_device() == this.padDevice &&
+ event.type() == Clutter.EventType.PAD_STRIP) {
+ if (this._editionMode) {
+ let [retval_, number, mode] = event.get_pad_event_details();
+ this._startStripActionEdition(number, UP, mode);
+ }
+ } else if (event.get_source_device() == this.padDevice &&
+ event.type() == Clutter.EventType.PAD_RING) {
+ if (this._editionMode) {
+ let [retval_, number, mode] = event.get_pad_event_details();
+ this._startRingActionEdition(number, CCW, mode);
+ }
+ }
+
+ // If the event comes from another pad in the same group,
+ // show the OSD for it.
+ if (this._groupPads.includes(event.get_source_device())) {
+ this._requestForOtherPad(event.get_source_device());
+ return Clutter.EVENT_STOP;
+ }
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _syncEditionMode() {
+ this._editButton.set_reactive(!this._editionMode);
+ this._editButton.save_easing_state();
+ this._editButton.set_easing_duration(200);
+ this._editButton.set_opacity(this._editionMode ? 128 : 255);
+ this._editButton.restore_easing_state();
+
+ let title;
+
+ if (this._editionMode) {
+ title = _("Press a button to configure");
+ this._tipLabel.set_text(_("Press Esc to exit"));
+ } else {
+ title = this.padDevice.get_device_name();
+ this._tipLabel.set_text(_("Press any key to exit"));
+ }
+
+ this._titleLabel.set_text(title);
+ }
+
+ _isEditedAction(type, number, dir) {
+ if (!this._editedAction)
+ return false;
+
+ return this._editedAction.type == type &&
+ this._editedAction.number == number &&
+ this._editedAction.dir == dir;
+ }
+
+ _followUpActionEdition(str) {
+ let { type, dir, number, mode } = this._editedAction;
+ let hasNextAction = type == Meta.PadActionType.RING && dir == CCW ||
+ type == Meta.PadActionType.STRIP && dir == UP;
+ if (!hasNextAction)
+ return false;
+
+ this._padDiagram.stopEdition(true, str);
+ this._editedAction = null;
+ if (type == Meta.PadActionType.RING)
+ this._startRingActionEdition(number, CW, mode);
+ else
+ this._startStripActionEdition(number, DOWN, mode);
+
+ return true;
+ }
+
+ _endActionEdition() {
+ this._actionEditor.close();
+
+ if (this._editedAction != null) {
+ let str = global.display.get_pad_action_label(this.padDevice,
+ this._editedAction.type,
+ this._editedAction.number);
+ if (this._followUpActionEdition(str))
+ return;
+
+ this._padDiagram.stopEdition(false, str ? str : _("None"));
+ this._editedAction = null;
+ }
+
+ this._editedActionSettings = null;
+ }
+
+ _startActionEdition(key, type, number, dir, mode) {
+ if (this._isEditedAction(type, number, dir))
+ return;
+
+ this._endActionEdition();
+ this._editedAction = { type, number, dir, mode };
+
+ let settingsPath = this._settings.path + key + '/';
+ this._editedActionSettings = Gio.Settings.new_with_path('org.gnome.desktop.peripherals.tablet.pad-button',
+ settingsPath);
+ this._actionEditor.setSettings(this._editedActionSettings, type);
+ this._padDiagram.startEdition(type, number, dir);
+ }
+
+ _startButtonActionEdition(button) {
+ let ch = String.fromCharCode('A'.charCodeAt() + button);
+ let key = 'button%s'.format(ch);
+ this._startActionEdition(key, Meta.PadActionType.BUTTON, button);
+ }
+
+ _startRingActionEdition(ring, dir, mode) {
+ let ch = String.fromCharCode('A'.charCodeAt() + ring);
+ let key = 'ring%s-%s-mode-%d'.format(ch, dir == CCW ? 'ccw' : 'cw', mode);
+ this._startActionEdition(key, Meta.PadActionType.RING, ring, dir, mode);
+ }
+
+ _startStripActionEdition(strip, dir, mode) {
+ let ch = String.fromCharCode('A'.charCodeAt() + strip);
+ let key = 'strip%s-%s-mode-%d'.format(ch, dir == UP ? 'up' : 'down', mode);
+ this._startActionEdition(key, Meta.PadActionType.STRIP, strip, dir, mode);
+ }
+
+ setEditionMode(editionMode) {
+ if (this._editionMode == editionMode)
+ return;
+
+ this._editionMode = editionMode;
+ this._syncEditionMode();
+ }
+
+ _onDestroy() {
+ Main.popModal(this);
+ this._actionEditor.close();
+
+ let seat = Clutter.get_default_backend().get_default_seat();
+ if (this._deviceRemovedId != 0) {
+ seat.disconnect(this._deviceRemovedId);
+ this._deviceRemovedId = 0;
+ }
+ if (this._deviceAddedId != 0) {
+ seat.disconnect(this._deviceAddedId);
+ this._deviceAddedId = 0;
+ }
+
+ if (this._capturedEventId != 0) {
+ global.stage.disconnect(this._capturedEventId);
+ this._capturedEventId = 0;
+ }
+
+ this.emit('closed');
+ }
+});
+
+const PadOsdIface = loadInterfaceXML('org.gnome.Shell.Wacom.PadOsd');
+
+var PadOsdService = class {
+ constructor() {
+ this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(PadOsdIface, this);
+ this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell/Wacom');
+ Gio.DBus.session.own_name('org.gnome.Shell.Wacom.PadOsd', Gio.BusNameOwnerFlags.REPLACE, null, null);
+ }
+
+ ShowAsync(params, invocation) {
+ let [deviceNode, editionMode] = params;
+ let seat = Clutter.get_default_backend().get_default_seat();
+ let devices = seat.list_devices();
+ let padDevice = null;
+
+ devices.forEach(device => {
+ if (deviceNode == device.get_device_node() &&
+ device.get_device_type() == Clutter.InputDeviceType.PAD_DEVICE)
+ padDevice = device;
+ });
+
+ if (padDevice == null) {
+ invocation.return_error_literal(Gio.IOErrorEnum,
+ Gio.IOErrorEnum.CANCELLED,
+ "Invalid params");
+ return;
+ }
+
+ global.display.request_pad_osd(padDevice, editionMode);
+ invocation.return_value(null);
+ }
+};
+Signals.addSignalMethods(PadOsdService.prototype);
diff --git a/js/ui/pageIndicators.js b/js/ui/pageIndicators.js
new file mode 100644
index 0000000..8ac44d4
--- /dev/null
+++ b/js/ui/pageIndicators.js
@@ -0,0 +1,194 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported PageIndicators, AnimatedPageIndicators */
+
+const { Clutter, GLib, Graphene, GObject, Meta, St } = imports.gi;
+
+const { ANIMATION_TIME_OUT, ANIMATION_MAX_DELAY_OUT_FOR_ITEM, AnimationDirection } = imports.ui.iconGrid;
+
+const INDICATOR_INACTIVE_OPACITY = 128;
+const INDICATOR_INACTIVE_OPACITY_HOVER = 255;
+const INDICATOR_INACTIVE_SCALE = 2 / 3;
+const INDICATOR_INACTIVE_SCALE_PRESSED = 0.5;
+
+var INDICATORS_BASE_TIME = 250;
+var INDICATORS_BASE_TIME_OUT = 125;
+var INDICATORS_ANIMATION_DELAY = 125;
+var INDICATORS_ANIMATION_DELAY_OUT = 62.5;
+var INDICATORS_ANIMATION_MAX_TIME = 750;
+var SWITCH_TIME = 400;
+var INDICATORS_ANIMATION_MAX_TIME_OUT =
+ Math.min(SWITCH_TIME,
+ ANIMATION_TIME_OUT + ANIMATION_MAX_DELAY_OUT_FOR_ITEM);
+
+var ANIMATION_DELAY = 100;
+
+var PageIndicators = GObject.registerClass({
+ Signals: { 'page-activated': { param_types: [GObject.TYPE_INT] } },
+}, class PageIndicators extends St.BoxLayout {
+ _init(orientation = Clutter.Orientation.VERTICAL) {
+ let vertical = orientation == Clutter.Orientation.VERTICAL;
+ super._init({
+ style_class: 'page-indicators',
+ vertical,
+ x_expand: true, y_expand: true,
+ x_align: vertical ? Clutter.ActorAlign.END : Clutter.ActorAlign.CENTER,
+ y_align: vertical ? Clutter.ActorAlign.CENTER : Clutter.ActorAlign.END,
+ reactive: true,
+ clip_to_allocation: true,
+ });
+ this._nPages = 0;
+ this._currentPosition = 0;
+ this._reactive = true;
+ this._reactive = true;
+ }
+
+ vfunc_get_preferred_height(forWidth) {
+ // We want to request the natural height of all our children as our
+ // natural height, so we chain up to St.BoxLayout, but we only request 0
+ // as minimum height, since it's not that important if some indicators
+ // are not shown
+ let [, natHeight] = super.vfunc_get_preferred_height(forWidth);
+ return [0, natHeight];
+ }
+
+ setReactive(reactive) {
+ let children = this.get_children();
+ for (let i = 0; i < children.length; i++)
+ children[i].reactive = reactive;
+
+ this._reactive = reactive;
+ }
+
+ setNPages(nPages) {
+ if (this._nPages == nPages)
+ return;
+
+ let diff = nPages - this._nPages;
+ if (diff > 0) {
+ for (let i = 0; i < diff; i++) {
+ let pageIndex = this._nPages + i;
+ let indicator = new St.Button({ style_class: 'page-indicator',
+ button_mask: St.ButtonMask.ONE |
+ St.ButtonMask.TWO |
+ St.ButtonMask.THREE,
+ reactive: this._reactive });
+ indicator.child = new St.Widget({
+ style_class: 'page-indicator-icon',
+ pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }),
+ });
+ indicator.connect('clicked', () => {
+ this.emit('page-activated', pageIndex);
+ });
+ indicator.connect('notify::hover', () => {
+ this._updateIndicator(indicator, pageIndex);
+ });
+ indicator.connect('notify::pressed', () => {
+ this._updateIndicator(indicator, pageIndex);
+ });
+ this._updateIndicator(indicator, pageIndex);
+ this.add_actor(indicator);
+ }
+ } else {
+ let children = this.get_children().splice(diff);
+ for (let i = 0; i < children.length; i++)
+ children[i].destroy();
+ }
+ this._nPages = nPages;
+ this.visible = this._nPages > 1;
+ }
+
+ _updateIndicator(indicator, pageIndex) {
+ let progress =
+ Math.max(1 - Math.abs(this._currentPosition - pageIndex), 0);
+
+ let inactiveScale = indicator.pressed
+ ? INDICATOR_INACTIVE_SCALE_PRESSED : INDICATOR_INACTIVE_SCALE;
+ let inactiveOpacity = indicator.hover
+ ? INDICATOR_INACTIVE_OPACITY_HOVER : INDICATOR_INACTIVE_OPACITY;
+
+ let scale = inactiveScale + (1 - inactiveScale) * progress;
+ let opacity = inactiveOpacity + (255 - inactiveOpacity) * progress;
+
+ indicator.child.set_scale(scale, scale);
+ indicator.child.opacity = opacity;
+ }
+
+ setCurrentPosition(currentPosition) {
+ this._currentPosition = currentPosition;
+
+ let children = this.get_children();
+ for (let i = 0; i < children.length; i++)
+ this._updateIndicator(children[i], i);
+ }
+
+ get nPages() {
+ return this._nPages;
+ }
+});
+
+var AnimatedPageIndicators = GObject.registerClass(
+class AnimatedPageIndicators extends PageIndicators {
+ _init() {
+ super._init();
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ _onDestroy() {
+ if (this.animateLater) {
+ Meta.later_remove(this.animateLater);
+ this.animateLater = 0;
+ }
+ }
+
+ vfunc_map() {
+ super.vfunc_map();
+
+ // Implicit animations are skipped for unmapped actors, and our
+ // children aren't mapped yet, so defer to a later handler
+ this.animateLater = Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
+ this.animateLater = 0;
+ this.animateIndicators(AnimationDirection.IN);
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+
+ animateIndicators(animationDirection) {
+ if (!this.mapped)
+ return;
+
+ let children = this.get_children();
+ if (children.length == 0)
+ return;
+
+ for (let i = 0; i < this._nPages; i++)
+ children[i].remove_all_transitions();
+
+ let offset;
+ if (this.get_text_direction() == Clutter.TextDirection.RTL)
+ offset = -children[0].width;
+ else
+ offset = children[0].width;
+
+ let isAnimationIn = animationDirection == AnimationDirection.IN;
+ let delay = isAnimationIn
+ ? INDICATORS_ANIMATION_DELAY
+ : INDICATORS_ANIMATION_DELAY_OUT;
+ let baseTime = isAnimationIn ? INDICATORS_BASE_TIME : INDICATORS_BASE_TIME_OUT;
+ let totalAnimationTime = baseTime + delay * this._nPages;
+ let maxTime = isAnimationIn
+ ? INDICATORS_ANIMATION_MAX_TIME
+ : INDICATORS_ANIMATION_MAX_TIME_OUT;
+ if (totalAnimationTime > maxTime)
+ delay -= (totalAnimationTime - maxTime) / this._nPages;
+
+ for (let i = 0; i < this._nPages; i++) {
+ children[i].translation_x = isAnimationIn ? offset : 0;
+ children[i].ease({
+ translation_x: isAnimationIn ? 0 : offset,
+ duration: baseTime + delay * i,
+ mode: Clutter.AnimationMode.EASE_IN_OUT_QUAD,
+ delay: isAnimationIn ? ANIMATION_DELAY : 0,
+ });
+ }
+ }
+});
diff --git a/js/ui/panel.js b/js/ui/panel.js
new file mode 100644
index 0000000..272368a
--- /dev/null
+++ b/js/ui/panel.js
@@ -0,0 +1,1175 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Panel */
+
+const { Atk, Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi;
+const Cairo = imports.cairo;
+
+const Animation = imports.ui.animation;
+const Config = imports.misc.config;
+const CtrlAltTab = imports.ui.ctrlAltTab;
+const DND = imports.ui.dnd;
+const Overview = imports.ui.overview;
+const PopupMenu = imports.ui.popupMenu;
+const PanelMenu = imports.ui.panelMenu;
+const Main = imports.ui.main;
+
+var PANEL_ICON_SIZE = 16;
+var APP_MENU_ICON_MARGIN = 0;
+
+var BUTTON_DND_ACTIVATION_TIMEOUT = 250;
+
+// To make sure the panel corners blend nicely with the panel,
+// we draw background and borders the same way, e.g. drawing
+// them as filled shapes from the outside inwards instead of
+// using cairo stroke(). So in order to give the border the
+// appearance of being drawn on top of the background, we need
+// to blend border and background color together.
+// For that purpose we use the following helper methods, taken
+// from st-theme-node-drawing.c
+function _norm(x) {
+ return Math.round(x / 255);
+}
+
+function _over(srcColor, dstColor) {
+ let src = _premultiply(srcColor);
+ let dst = _premultiply(dstColor);
+ let result = new Clutter.Color();
+
+ result.alpha = src.alpha + _norm((255 - src.alpha) * dst.alpha);
+ result.red = src.red + _norm((255 - src.alpha) * dst.red);
+ result.green = src.green + _norm((255 - src.alpha) * dst.green);
+ result.blue = src.blue + _norm((255 - src.alpha) * dst.blue);
+
+ return _unpremultiply(result);
+}
+
+function _premultiply(color) {
+ return new Clutter.Color({ red: _norm(color.red * color.alpha),
+ green: _norm(color.green * color.alpha),
+ blue: _norm(color.blue * color.alpha),
+ alpha: color.alpha });
+}
+
+function _unpremultiply(color) {
+ if (color.alpha == 0)
+ return new Clutter.Color();
+
+ let red = Math.min((color.red * 255 + 127) / color.alpha, 255);
+ let green = Math.min((color.green * 255 + 127) / color.alpha, 255);
+ let blue = Math.min((color.blue * 255 + 127) / color.alpha, 255);
+ return new Clutter.Color({ red, green, blue, alpha: color.alpha });
+}
+
+class AppMenu extends PopupMenu.PopupMenu {
+ constructor(sourceActor) {
+ super(sourceActor, 0.5, St.Side.TOP);
+
+ this.actor.add_style_class_name('app-menu');
+
+ this._app = null;
+ this._appSystem = Shell.AppSystem.get_default();
+
+ this._windowsChangedId = 0;
+
+ /* Translators: This is the heading of a list of open windows */
+ this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem(_("Open Windows")));
+
+ this._windowSection = new PopupMenu.PopupMenuSection();
+ this.addMenuItem(this._windowSection);
+
+ this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ this._newWindowItem = this.addAction(_("New Window"), () => {
+ this._app.open_new_window(-1);
+ });
+
+ this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ this._actionSection = new PopupMenu.PopupMenuSection();
+ this.addMenuItem(this._actionSection);
+
+ this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ this._detailsItem = this.addAction(_('Show Details'), async () => {
+ let id = this._app.get_id();
+ let args = GLib.Variant.new('(ss)', [id, '']);
+ const bus = await Gio.DBus.get(Gio.BusType.SESSION, null);
+ bus.call(
+ 'org.gnome.Software',
+ '/org/gnome/Software',
+ 'org.gtk.Actions', 'Activate',
+ new GLib.Variant('(sava{sv})', ['details', [args], null]),
+ null, 0, -1, null);
+ });
+
+ this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ this.addAction(_("Quit"), () => {
+ this._app.request_quit();
+ });
+
+ this._appSystem.connect('installed-changed', () => {
+ this._updateDetailsVisibility();
+ });
+ this._updateDetailsVisibility();
+ }
+
+ _updateDetailsVisibility() {
+ let sw = this._appSystem.lookup_app('org.gnome.Software.desktop');
+ this._detailsItem.visible = sw != null;
+ }
+
+ isEmpty() {
+ if (!this._app)
+ return true;
+ return super.isEmpty();
+ }
+
+ setApp(app) {
+ if (this._app == app)
+ return;
+
+ if (this._windowsChangedId)
+ this._app.disconnect(this._windowsChangedId);
+ this._windowsChangedId = 0;
+
+ this._app = app;
+
+ if (app) {
+ this._windowsChangedId = app.connect('windows-changed', () => {
+ this._updateWindowsSection();
+ });
+ }
+
+ this._updateWindowsSection();
+
+ let appInfo = app ? app.app_info : null;
+ let actions = appInfo ? appInfo.list_actions() : [];
+
+ this._actionSection.removeAll();
+ actions.forEach(action => {
+ let label = appInfo.get_action_name(action);
+ this._actionSection.addAction(label, event => {
+ this._app.launch_action(action, event.get_time(), -1);
+ });
+ });
+
+ this._newWindowItem.visible =
+ app && app.can_open_new_window() && !actions.includes('new-window');
+ }
+
+ _updateWindowsSection() {
+ this._windowSection.removeAll();
+
+ if (!this._app)
+ return;
+
+ let windows = this._app.get_windows();
+ windows.forEach(window => {
+ let title = window.title || this._app.get_name();
+ let item = this._windowSection.addAction(title, event => {
+ Main.activateWindow(window, event.get_time());
+ });
+ let id = window.connect('notify::title', () => {
+ item.label.text = window.title || this._app.get_name();
+ });
+ item.connect('destroy', () => window.disconnect(id));
+ });
+ }
+}
+
+/**
+ * AppMenuButton:
+ *
+ * This class manages the "application menu" component. It tracks the
+ * currently focused application. However, when an app is launched,
+ * this menu also handles startup notification for it. So when we
+ * have an active startup notification, we switch modes to display that.
+ */
+var AppMenuButton = GObject.registerClass({
+ Signals: { 'changed': {} },
+}, class AppMenuButton extends PanelMenu.Button {
+ _init(panel) {
+ super._init(0.0, null, true);
+
+ this.accessible_role = Atk.Role.MENU;
+
+ this._startingApps = [];
+
+ this._menuManager = panel.menuManager;
+ this._targetApp = null;
+ this._busyNotifyId = 0;
+
+ let bin = new St.Bin({ name: 'appMenu' });
+ this.add_actor(bin);
+
+ this.bind_property("reactive", this, "can-focus", 0);
+ this.reactive = false;
+
+ this._container = new St.BoxLayout({ style_class: 'panel-status-menu-box' });
+ bin.set_child(this._container);
+
+ let textureCache = St.TextureCache.get_default();
+ textureCache.connect('icon-theme-changed',
+ this._onIconThemeChanged.bind(this));
+
+ let iconEffect = new Clutter.DesaturateEffect();
+ this._iconBox = new St.Bin({
+ style_class: 'app-menu-icon',
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ this._iconBox.add_effect(iconEffect);
+ this._container.add_actor(this._iconBox);
+
+ this._iconBox.connect('style-changed', () => {
+ let themeNode = this._iconBox.get_theme_node();
+ iconEffect.enabled = themeNode.get_icon_style() == St.IconStyle.SYMBOLIC;
+ });
+
+ this._label = new St.Label({ y_expand: true,
+ y_align: Clutter.ActorAlign.CENTER });
+ this._container.add_actor(this._label);
+ this._arrow = PopupMenu.arrowIcon(St.Side.BOTTOM);
+ this._container.add_actor(this._arrow);
+
+ this._visible = !Main.overview.visible;
+ if (!this._visible)
+ this.hide();
+ this._overviewHidingId = Main.overview.connect('hiding', this._sync.bind(this));
+ this._overviewShowingId = Main.overview.connect('showing', this._sync.bind(this));
+
+ this._spinner = new Animation.Spinner(PANEL_ICON_SIZE, {
+ animate: true,
+ hideOnStop: true,
+ });
+ this._container.add_actor(this._spinner);
+
+ let menu = new AppMenu(this);
+ this.setMenu(menu);
+ this._menuManager.addMenu(menu);
+
+ let tracker = Shell.WindowTracker.get_default();
+ let appSys = Shell.AppSystem.get_default();
+ this._focusAppNotifyId =
+ tracker.connect('notify::focus-app', this._focusAppChanged.bind(this));
+ this._appStateChangedSignalId =
+ appSys.connect('app-state-changed', this._onAppStateChanged.bind(this));
+ this._switchWorkspaceNotifyId =
+ global.window_manager.connect('switch-workspace', this._sync.bind(this));
+
+ this._sync();
+ }
+
+ fadeIn() {
+ if (this._visible)
+ return;
+
+ this._visible = true;
+ this.reactive = true;
+ this.show();
+ this.remove_all_transitions();
+ this.ease({
+ opacity: 255,
+ duration: Overview.ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ fadeOut() {
+ if (!this._visible)
+ return;
+
+ this._visible = false;
+ this.reactive = false;
+ this.remove_all_transitions();
+ this.ease({
+ opacity: 0,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: Overview.ANIMATION_TIME,
+ onComplete: () => this.hide(),
+ });
+ }
+
+ _syncIcon() {
+ if (!this._targetApp)
+ return;
+
+ let icon = this._targetApp.create_icon_texture(PANEL_ICON_SIZE - APP_MENU_ICON_MARGIN);
+ this._iconBox.set_child(icon);
+ }
+
+ _onIconThemeChanged() {
+ if (this._iconBox.child == null)
+ return;
+
+ this._syncIcon();
+ }
+
+ stopAnimation() {
+ this._spinner.stop();
+ }
+
+ startAnimation() {
+ this._spinner.play();
+ }
+
+ _onAppStateChanged(appSys, app) {
+ let state = app.state;
+ if (state != Shell.AppState.STARTING)
+ this._startingApps = this._startingApps.filter(a => a != app);
+ else if (state == Shell.AppState.STARTING)
+ this._startingApps.push(app);
+ // For now just resync on all running state changes; this is mainly to handle
+ // cases where the focused window's application changes without the focus
+ // changing. An example case is how we map OpenOffice.org based on the window
+ // title which is a dynamic property.
+ this._sync();
+ }
+
+ _focusAppChanged() {
+ let tracker = Shell.WindowTracker.get_default();
+ let focusedApp = tracker.focus_app;
+ if (!focusedApp) {
+ // If the app has just lost focus to the panel, pretend
+ // nothing happened; otherwise you can't keynav to the
+ // app menu.
+ if (global.stage.key_focus != null)
+ return;
+ }
+ this._sync();
+ }
+
+ _findTargetApp() {
+ let workspaceManager = global.workspace_manager;
+ let workspace = workspaceManager.get_active_workspace();
+ let tracker = Shell.WindowTracker.get_default();
+ let focusedApp = tracker.focus_app;
+ if (focusedApp && focusedApp.is_on_workspace(workspace))
+ return focusedApp;
+
+ for (let i = 0; i < this._startingApps.length; i++) {
+ if (this._startingApps[i].is_on_workspace(workspace))
+ return this._startingApps[i];
+ }
+
+ return null;
+ }
+
+ _sync() {
+ let targetApp = this._findTargetApp();
+
+ if (this._targetApp != targetApp) {
+ if (this._busyNotifyId) {
+ this._targetApp.disconnect(this._busyNotifyId);
+ this._busyNotifyId = 0;
+ }
+
+ this._targetApp = targetApp;
+
+ if (this._targetApp) {
+ this._busyNotifyId = this._targetApp.connect('notify::busy', this._sync.bind(this));
+ this._label.set_text(this._targetApp.get_name());
+ this.set_accessible_name(this._targetApp.get_name());
+ }
+ }
+
+ let visible = this._targetApp != null && !Main.overview.visibleTarget;
+ if (visible)
+ this.fadeIn();
+ else
+ this.fadeOut();
+
+ let isBusy = this._targetApp != null &&
+ (this._targetApp.get_state() == Shell.AppState.STARTING ||
+ this._targetApp.get_busy());
+ if (isBusy)
+ this.startAnimation();
+ else
+ this.stopAnimation();
+
+ this.reactive = visible && !isBusy;
+
+ this._syncIcon();
+ this.menu.setApp(this._targetApp);
+ this.emit('changed');
+ }
+
+ _onDestroy() {
+ if (this._appStateChangedSignalId > 0) {
+ let appSys = Shell.AppSystem.get_default();
+ appSys.disconnect(this._appStateChangedSignalId);
+ this._appStateChangedSignalId = 0;
+ }
+ if (this._focusAppNotifyId > 0) {
+ let tracker = Shell.WindowTracker.get_default();
+ tracker.disconnect(this._focusAppNotifyId);
+ this._focusAppNotifyId = 0;
+ }
+ if (this._overviewHidingId > 0) {
+ Main.overview.disconnect(this._overviewHidingId);
+ this._overviewHidingId = 0;
+ }
+ if (this._overviewShowingId > 0) {
+ Main.overview.disconnect(this._overviewShowingId);
+ this._overviewShowingId = 0;
+ }
+ if (this._switchWorkspaceNotifyId > 0) {
+ global.window_manager.disconnect(this._switchWorkspaceNotifyId);
+ this._switchWorkspaceNotifyId = 0;
+ }
+
+ super._onDestroy();
+ }
+});
+
+var ActivitiesButton = GObject.registerClass(
+class ActivitiesButton extends PanelMenu.Button {
+ _init() {
+ super._init(0.0, null, true);
+ this.accessible_role = Atk.Role.TOGGLE_BUTTON;
+
+ this.name = 'panelActivities';
+
+ /* Translators: If there is no suitable word for "Activities"
+ in your language, you can use the word for "Overview". */
+ this._label = new St.Label({ text: _("Activities"),
+ y_align: Clutter.ActorAlign.CENTER });
+ this.add_actor(this._label);
+
+ this.label_actor = this._label;
+
+ Main.overview.connect('showing', () => {
+ this.add_style_pseudo_class('overview');
+ this.add_accessible_state(Atk.StateType.CHECKED);
+ });
+ Main.overview.connect('hiding', () => {
+ this.remove_style_pseudo_class('overview');
+ this.remove_accessible_state(Atk.StateType.CHECKED);
+ });
+
+ this._xdndTimeOut = 0;
+ }
+
+ handleDragOver(source, _actor, _x, _y, _time) {
+ if (source != Main.xdndHandler)
+ return DND.DragMotionResult.CONTINUE;
+
+ if (this._xdndTimeOut != 0)
+ GLib.source_remove(this._xdndTimeOut);
+ this._xdndTimeOut = GLib.timeout_add(GLib.PRIORITY_DEFAULT, BUTTON_DND_ACTIVATION_TIMEOUT, () => {
+ this._xdndToggleOverview();
+ });
+ GLib.Source.set_name_by_id(this._xdndTimeOut, '[gnome-shell] this._xdndToggleOverview');
+
+ return DND.DragMotionResult.CONTINUE;
+ }
+
+ vfunc_captured_event(event) {
+ if (event.type() == Clutter.EventType.BUTTON_PRESS ||
+ event.type() == Clutter.EventType.TOUCH_BEGIN) {
+ if (!Main.overview.shouldToggleByCornerOrButton())
+ return Clutter.EVENT_STOP;
+ }
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_event(event) {
+ if (event.type() == Clutter.EventType.TOUCH_END ||
+ event.type() == Clutter.EventType.BUTTON_RELEASE) {
+ if (Main.overview.shouldToggleByCornerOrButton())
+ Main.overview.toggle();
+ }
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_key_release_event(keyEvent) {
+ let symbol = keyEvent.keyval;
+ if (symbol == Clutter.KEY_Return || symbol == Clutter.KEY_space) {
+ if (Main.overview.shouldToggleByCornerOrButton()) {
+ Main.overview.toggle();
+ return Clutter.EVENT_STOP;
+ }
+ }
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _xdndToggleOverview() {
+ let [x, y] = global.get_pointer();
+ let pickedActor = global.stage.get_actor_at_pos(Clutter.PickMode.REACTIVE, x, y);
+
+ if (pickedActor == this && Main.overview.shouldToggleByCornerOrButton())
+ Main.overview.toggle();
+
+ GLib.source_remove(this._xdndTimeOut);
+ this._xdndTimeOut = 0;
+ return GLib.SOURCE_REMOVE;
+ }
+});
+
+var PanelCorner = GObject.registerClass(
+class PanelCorner extends St.DrawingArea {
+ _init(side) {
+ this._side = side;
+
+ super._init({ style_class: 'panel-corner' });
+ }
+
+ _findRightmostButton(container) {
+ if (!container.get_children)
+ return null;
+
+ let children = container.get_children();
+
+ if (!children || children.length == 0)
+ return null;
+
+ // Start at the back and work backward
+ let index;
+ for (index = children.length - 1; index >= 0; index--) {
+ if (children[index].visible)
+ break;
+ }
+ if (index < 0)
+ return null;
+
+ if (!(children[index] instanceof St.Widget))
+ return null;
+
+ if (!children[index].has_style_class_name('panel-menu') &&
+ !children[index].has_style_class_name('panel-button'))
+ return this._findRightmostButton(children[index]);
+
+ return children[index];
+ }
+
+ _findLeftmostButton(container) {
+ if (!container.get_children)
+ return null;
+
+ let children = container.get_children();
+
+ if (!children || children.length == 0)
+ return null;
+
+ // Start at the front and work forward
+ let index;
+ for (index = 0; index < children.length; index++) {
+ if (children[index].visible)
+ break;
+ }
+ if (index == children.length)
+ return null;
+
+ if (!(children[index] instanceof St.Widget))
+ return null;
+
+ if (!children[index].has_style_class_name('panel-menu') &&
+ !children[index].has_style_class_name('panel-button'))
+ return this._findLeftmostButton(children[index]);
+
+ return children[index];
+ }
+
+ setStyleParent(box) {
+ let side = this._side;
+
+ let rtlAwareContainer = box instanceof St.BoxLayout;
+ if (rtlAwareContainer &&
+ box.get_text_direction() == Clutter.TextDirection.RTL) {
+ if (this._side == St.Side.LEFT)
+ side = St.Side.RIGHT;
+ else if (this._side == St.Side.RIGHT)
+ side = St.Side.LEFT;
+ }
+
+ let button;
+ if (side == St.Side.LEFT)
+ button = this._findLeftmostButton(box);
+ else if (side == St.Side.RIGHT)
+ button = this._findRightmostButton(box);
+
+ if (button) {
+ if (this._button) {
+ if (this._buttonStyleChangedSignalId) {
+ this._button.disconnect(this._buttonStyleChangedSignalId);
+ this._button.style = null;
+ }
+
+ if (this._buttonDestroySignalId)
+ this._button.disconnect(this._buttonDestroySignalId);
+ }
+
+ this._button = button;
+
+ this._buttonDestroySignalId = button.connect('destroy', () => {
+ if (this._button == button) {
+ this._button = null;
+ this._buttonStyleChangedSignalId = 0;
+ }
+ });
+
+ // Synchronize the locate button's pseudo classes with this corner
+ this._buttonStyleChangedSignalId = button.connect('style-changed',
+ () => {
+ let pseudoClass = button.get_style_pseudo_class();
+ this.set_style_pseudo_class(pseudoClass);
+ });
+
+ // The corner doesn't support theme transitions, so override
+ // the .panel-button default
+ button.style = 'transition-duration: 0ms';
+ }
+ }
+
+ vfunc_repaint() {
+ let node = this.get_theme_node();
+
+ let cornerRadius = node.get_length("-panel-corner-radius");
+ let borderWidth = node.get_length('-panel-corner-border-width');
+
+ let backgroundColor = node.get_color('-panel-corner-background-color');
+ let borderColor = node.get_color('-panel-corner-border-color');
+
+ let overlap = borderColor.alpha != 0;
+ let offsetY = overlap ? 0 : borderWidth;
+
+ let cr = this.get_context();
+ cr.setOperator(Cairo.Operator.SOURCE);
+
+ cr.moveTo(0, offsetY);
+ if (this._side == St.Side.LEFT) {
+ cr.arc(cornerRadius,
+ borderWidth + cornerRadius,
+ cornerRadius, Math.PI, 3 * Math.PI / 2);
+ } else {
+ cr.arc(0,
+ borderWidth + cornerRadius,
+ cornerRadius, 3 * Math.PI / 2, 2 * Math.PI);
+ }
+ cr.lineTo(cornerRadius, offsetY);
+ cr.closePath();
+
+ let savedPath = cr.copyPath();
+
+ let xOffsetDirection = this._side == St.Side.LEFT ? -1 : 1;
+ let over = _over(borderColor, backgroundColor);
+ Clutter.cairo_set_source_color(cr, over);
+ cr.fill();
+
+ if (overlap) {
+ let offset = borderWidth;
+ Clutter.cairo_set_source_color(cr, backgroundColor);
+
+ cr.save();
+ cr.translate(xOffsetDirection * offset, -offset);
+ cr.appendPath(savedPath);
+ cr.fill();
+ cr.restore();
+ }
+
+ cr.$dispose();
+ }
+
+ vfunc_style_changed() {
+ super.vfunc_style_changed();
+ let node = this.get_theme_node();
+
+ let cornerRadius = node.get_length("-panel-corner-radius");
+ let borderWidth = node.get_length('-panel-corner-border-width');
+
+ this.set_size(cornerRadius, borderWidth + cornerRadius);
+ this.translation_y = -borderWidth;
+ }
+});
+
+var AggregateLayout = GObject.registerClass(
+class AggregateLayout extends Clutter.BoxLayout {
+ _init(params = {}) {
+ params['orientation'] = Clutter.Orientation.VERTICAL;
+ super._init(params);
+
+ this._sizeChildren = [];
+ }
+
+ addSizeChild(actor) {
+ this._sizeChildren.push(actor);
+ this.layout_changed();
+ }
+
+ vfunc_get_preferred_width(container, forHeight) {
+ let themeNode = container.get_theme_node();
+ let minWidth = themeNode.get_min_width();
+ let natWidth = minWidth;
+
+ for (let i = 0; i < this._sizeChildren.length; i++) {
+ let child = this._sizeChildren[i];
+ let [childMin, childNat] = child.get_preferred_width(forHeight);
+ minWidth = Math.max(minWidth, childMin);
+ natWidth = Math.max(natWidth, childNat);
+ }
+ return [minWidth, natWidth];
+ }
+});
+
+var AggregateMenu = GObject.registerClass(
+class AggregateMenu extends PanelMenu.Button {
+ _init() {
+ super._init(0.0, C_("System menu in the top bar", "System"), false);
+ this.menu.actor.add_style_class_name('aggregate-menu');
+
+ let menuLayout = new AggregateLayout();
+ this.menu.box.set_layout_manager(menuLayout);
+
+ this._indicators = new St.BoxLayout({ style_class: 'panel-status-indicators-box' });
+ this.add_child(this._indicators);
+
+ if (Config.HAVE_NETWORKMANAGER)
+ this._network = new imports.ui.status.network.NMApplet();
+ else
+ this._network = null;
+
+ if (Config.HAVE_BLUETOOTH)
+ this._bluetooth = new imports.ui.status.bluetooth.Indicator();
+ else
+ this._bluetooth = null;
+
+ this._remoteAccess = new imports.ui.status.remoteAccess.RemoteAccessApplet();
+ this._power = new imports.ui.status.power.Indicator();
+ this._rfkill = new imports.ui.status.rfkill.Indicator();
+ this._volume = new imports.ui.status.volume.Indicator();
+ this._brightness = new imports.ui.status.brightness.Indicator();
+ this._system = new imports.ui.status.system.Indicator();
+ this._location = new imports.ui.status.location.Indicator();
+ this._nightLight = new imports.ui.status.nightLight.Indicator();
+ this._thunderbolt = new imports.ui.status.thunderbolt.Indicator();
+
+ this._indicators.add_child(this._remoteAccess);
+ this._indicators.add_child(this._thunderbolt);
+ this._indicators.add_child(this._location);
+ this._indicators.add_child(this._nightLight);
+ if (this._network)
+ this._indicators.add_child(this._network);
+ if (this._bluetooth)
+ this._indicators.add_child(this._bluetooth);
+ this._indicators.add_child(this._rfkill);
+ this._indicators.add_child(this._volume);
+ this._indicators.add_child(this._power);
+ this._indicators.add_child(PopupMenu.arrowIcon(St.Side.BOTTOM));
+
+ this.menu.addMenuItem(this._volume.menu);
+ this.menu.addMenuItem(this._brightness.menu);
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+ if (this._network)
+ this.menu.addMenuItem(this._network.menu);
+
+ if (this._bluetooth)
+ this.menu.addMenuItem(this._bluetooth.menu);
+
+ this.menu.addMenuItem(this._remoteAccess.menu);
+ this.menu.addMenuItem(this._location.menu);
+ this.menu.addMenuItem(this._rfkill.menu);
+ this.menu.addMenuItem(this._power.menu);
+ this.menu.addMenuItem(this._nightLight.menu);
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+ this.menu.addMenuItem(this._system.menu);
+
+ menuLayout.addSizeChild(this._location.menu.actor);
+ menuLayout.addSizeChild(this._rfkill.menu.actor);
+ menuLayout.addSizeChild(this._power.menu.actor);
+ menuLayout.addSizeChild(this._system.menu.actor);
+ }
+});
+
+const PANEL_ITEM_IMPLEMENTATIONS = {
+ 'activities': ActivitiesButton,
+ 'aggregateMenu': AggregateMenu,
+ 'appMenu': AppMenuButton,
+ 'dateMenu': imports.ui.dateMenu.DateMenuButton,
+ 'a11y': imports.ui.status.accessibility.ATIndicator,
+ 'keyboard': imports.ui.status.keyboard.InputSourceIndicator,
+ 'dwellClick': imports.ui.status.dwellClick.DwellClickIndicator,
+};
+
+var Panel = GObject.registerClass(
+class Panel extends St.Widget {
+ _init() {
+ super._init({ name: 'panel',
+ reactive: true });
+
+ this.set_offscreen_redirect(Clutter.OffscreenRedirect.ALWAYS);
+
+ this._sessionStyle = null;
+
+ this.statusArea = {};
+
+ this.menuManager = new PopupMenu.PopupMenuManager(this);
+
+ this._leftBox = new St.BoxLayout({ name: 'panelLeft' });
+ this.add_child(this._leftBox);
+ this._centerBox = new St.BoxLayout({ name: 'panelCenter' });
+ this.add_child(this._centerBox);
+ this._rightBox = new St.BoxLayout({ name: 'panelRight' });
+ this.add_child(this._rightBox);
+
+ this._leftCorner = new PanelCorner(St.Side.LEFT);
+ this.add_child(this._leftCorner);
+
+ this._rightCorner = new PanelCorner(St.Side.RIGHT);
+ this.add_child(this._rightCorner);
+
+ Main.overview.connect('showing', () => {
+ this.add_style_pseudo_class('overview');
+ });
+ Main.overview.connect('hiding', () => {
+ this.remove_style_pseudo_class('overview');
+ });
+
+ Main.layoutManager.panelBox.add(this);
+ Main.ctrlAltTabManager.addGroup(this, _("Top Bar"), 'focus-top-bar-symbolic',
+ { sortGroup: CtrlAltTab.SortGroup.TOP });
+
+ Main.sessionMode.connect('updated', this._updatePanel.bind(this));
+
+ global.display.connect('workareas-changed', () => this.queue_relayout());
+ this._updatePanel();
+ }
+
+ vfunc_get_preferred_width(_forHeight) {
+ let primaryMonitor = Main.layoutManager.primaryMonitor;
+
+ if (primaryMonitor)
+ return [0, primaryMonitor.width];
+
+ return [0, 0];
+ }
+
+ vfunc_allocate(box) {
+ this.set_allocation(box);
+
+ let allocWidth = box.x2 - box.x1;
+ let allocHeight = box.y2 - box.y1;
+
+ let [, leftNaturalWidth] = this._leftBox.get_preferred_width(-1);
+ let [, centerNaturalWidth] = this._centerBox.get_preferred_width(-1);
+ let [, rightNaturalWidth] = this._rightBox.get_preferred_width(-1);
+
+ let sideWidth, centerWidth;
+ centerWidth = centerNaturalWidth;
+
+ // get workspace area and center date entry relative to it
+ let monitor = Main.layoutManager.findMonitorForActor(this);
+ let centerOffset = 0;
+ if (monitor) {
+ let workArea = Main.layoutManager.getWorkAreaForMonitor(monitor.index);
+ centerOffset = 2 * (workArea.x - monitor.x) + workArea.width - monitor.width;
+ }
+
+ sideWidth = Math.max(0, (allocWidth - centerWidth + centerOffset) / 2);
+
+ let childBox = new Clutter.ActorBox();
+
+ childBox.y1 = 0;
+ childBox.y2 = allocHeight;
+ if (this.get_text_direction() == Clutter.TextDirection.RTL) {
+ childBox.x1 = Math.max(allocWidth - Math.min(Math.floor(sideWidth),
+ leftNaturalWidth),
+ 0);
+ childBox.x2 = allocWidth;
+ } else {
+ childBox.x1 = 0;
+ childBox.x2 = Math.min(Math.floor(sideWidth),
+ leftNaturalWidth);
+ }
+ this._leftBox.allocate(childBox);
+
+ childBox.x1 = Math.ceil(sideWidth);
+ childBox.y1 = 0;
+ childBox.x2 = childBox.x1 + centerWidth;
+ childBox.y2 = allocHeight;
+ this._centerBox.allocate(childBox);
+
+ childBox.y1 = 0;
+ childBox.y2 = allocHeight;
+ if (this.get_text_direction() == Clutter.TextDirection.RTL) {
+ childBox.x1 = 0;
+ childBox.x2 = Math.min(Math.floor(sideWidth),
+ rightNaturalWidth);
+ } else {
+ childBox.x1 = Math.max(allocWidth - Math.min(Math.floor(sideWidth),
+ rightNaturalWidth),
+ 0);
+ childBox.x2 = allocWidth;
+ }
+ this._rightBox.allocate(childBox);
+
+ let cornerWidth, cornerHeight;
+
+ [, cornerWidth] = this._leftCorner.get_preferred_width(-1);
+ [, cornerHeight] = this._leftCorner.get_preferred_height(-1);
+ childBox.x1 = 0;
+ childBox.x2 = cornerWidth;
+ childBox.y1 = allocHeight;
+ childBox.y2 = allocHeight + cornerHeight;
+ this._leftCorner.allocate(childBox);
+
+ [, cornerWidth] = this._rightCorner.get_preferred_width(-1);
+ [, cornerHeight] = this._rightCorner.get_preferred_height(-1);
+ childBox.x1 = allocWidth - cornerWidth;
+ childBox.x2 = allocWidth;
+ childBox.y1 = allocHeight;
+ childBox.y2 = allocHeight + cornerHeight;
+ this._rightCorner.allocate(childBox);
+ }
+
+ _tryDragWindow(event) {
+ if (Main.modalCount > 0)
+ return Clutter.EVENT_PROPAGATE;
+
+ if (event.source != this)
+ return Clutter.EVENT_PROPAGATE;
+
+ let { x, y } = event;
+ let dragWindow = this._getDraggableWindowForPosition(x);
+
+ if (!dragWindow)
+ return Clutter.EVENT_PROPAGATE;
+
+ return global.display.begin_grab_op(
+ dragWindow,
+ Meta.GrabOp.MOVING,
+ false, /* pointer grab */
+ true, /* frame action */
+ event.button || -1,
+ event.modifier_state,
+ event.time,
+ x, y) ? Clutter.EVENT_STOP : Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_button_press_event(buttonEvent) {
+ if (buttonEvent.button != 1)
+ return Clutter.EVENT_PROPAGATE;
+
+ return this._tryDragWindow(buttonEvent);
+ }
+
+ vfunc_touch_event(touchEvent) {
+ if (touchEvent.type != Clutter.EventType.TOUCH_BEGIN)
+ return Clutter.EVENT_PROPAGATE;
+
+ return this._tryDragWindow(touchEvent);
+ }
+
+ vfunc_key_press_event(keyEvent) {
+ let symbol = keyEvent.keyval;
+ if (symbol == Clutter.KEY_Escape) {
+ global.display.focus_default_window(keyEvent.time);
+ return Clutter.EVENT_STOP;
+ }
+
+ return super.vfunc_key_press_event(keyEvent);
+ }
+
+ _toggleMenu(indicator) {
+ if (!indicator || !indicator.mapped)
+ return; // menu not supported by current session mode
+
+ let menu = indicator.menu;
+ if (!indicator.reactive)
+ return;
+
+ menu.toggle();
+ if (menu.isOpen)
+ menu.actor.navigate_focus(null, St.DirectionType.TAB_FORWARD, false);
+ }
+
+ toggleAppMenu() {
+ this._toggleMenu(this.statusArea.appMenu);
+ }
+
+ toggleCalendar() {
+ this._toggleMenu(this.statusArea.dateMenu);
+ }
+
+ closeCalendar() {
+ let indicator = this.statusArea.dateMenu;
+ if (!indicator) // calendar not supported by current session mode
+ return;
+
+ let menu = indicator.menu;
+ if (!indicator.reactive)
+ return;
+
+ menu.close();
+ }
+
+ set boxOpacity(value) {
+ let isReactive = value > 0;
+
+ this._leftBox.opacity = value;
+ this._leftBox.reactive = isReactive;
+ this._centerBox.opacity = value;
+ this._centerBox.reactive = isReactive;
+ this._rightBox.opacity = value;
+ this._rightBox.reactive = isReactive;
+ }
+
+ get boxOpacity() {
+ return this._leftBox.opacity;
+ }
+
+ _updatePanel() {
+ let panel = Main.sessionMode.panel;
+ this._hideIndicators();
+ this._updateBox(panel.left, this._leftBox);
+ this._updateBox(panel.center, this._centerBox);
+ this._updateBox(panel.right, this._rightBox);
+
+ if (panel.left.includes('dateMenu'))
+ Main.messageTray.bannerAlignment = Clutter.ActorAlign.START;
+ else if (panel.right.includes('dateMenu'))
+ Main.messageTray.bannerAlignment = Clutter.ActorAlign.END;
+ // Default to center if there is no dateMenu
+ else
+ Main.messageTray.bannerAlignment = Clutter.ActorAlign.CENTER;
+
+ if (this._sessionStyle)
+ this._removeStyleClassName(this._sessionStyle);
+
+ this._sessionStyle = Main.sessionMode.panelStyle;
+ if (this._sessionStyle)
+ this._addStyleClassName(this._sessionStyle);
+
+ if (this.get_text_direction() == Clutter.TextDirection.RTL) {
+ this._leftCorner.setStyleParent(this._rightBox);
+ this._rightCorner.setStyleParent(this._leftBox);
+ } else {
+ this._leftCorner.setStyleParent(this._leftBox);
+ this._rightCorner.setStyleParent(this._rightBox);
+ }
+ }
+
+ _hideIndicators() {
+ for (let role in PANEL_ITEM_IMPLEMENTATIONS) {
+ let indicator = this.statusArea[role];
+ if (!indicator)
+ continue;
+ indicator.container.hide();
+ }
+ }
+
+ _ensureIndicator(role) {
+ let indicator = this.statusArea[role];
+ if (!indicator) {
+ let constructor = PANEL_ITEM_IMPLEMENTATIONS[role];
+ if (!constructor) {
+ // This icon is not implemented (this is a bug)
+ return null;
+ }
+ indicator = new constructor(this);
+ this.statusArea[role] = indicator;
+ }
+ return indicator;
+ }
+
+ _updateBox(elements, box) {
+ let nChildren = box.get_n_children();
+
+ for (let i = 0; i < elements.length; i++) {
+ let role = elements[i];
+ let indicator = this._ensureIndicator(role);
+ if (indicator == null)
+ continue;
+
+ this._addToPanelBox(role, indicator, i + nChildren, box);
+ }
+ }
+
+ _addToPanelBox(role, indicator, position, box) {
+ let container = indicator.container;
+ container.show();
+
+ let parent = container.get_parent();
+ if (parent)
+ parent.remove_actor(container);
+
+
+ box.insert_child_at_index(container, position);
+ if (indicator.menu)
+ this.menuManager.addMenu(indicator.menu);
+ this.statusArea[role] = indicator;
+ let destroyId = indicator.connect('destroy', emitter => {
+ delete this.statusArea[role];
+ emitter.disconnect(destroyId);
+ });
+ indicator.connect('menu-set', this._onMenuSet.bind(this));
+ this._onMenuSet(indicator);
+ }
+
+ addToStatusArea(role, indicator, position, box) {
+ if (this.statusArea[role])
+ throw new Error('Extension point conflict: there is already a status indicator for role %s'.format(role));
+
+ if (!(indicator instanceof PanelMenu.Button))
+ throw new TypeError('Status indicator must be an instance of PanelMenu.Button');
+
+ position = position || 0;
+ let boxes = {
+ left: this._leftBox,
+ center: this._centerBox,
+ right: this._rightBox,
+ };
+ let boxContainer = boxes[box] || this._rightBox;
+ this.statusArea[role] = indicator;
+ this._addToPanelBox(role, indicator, position, boxContainer);
+ return indicator;
+ }
+
+ _addStyleClassName(className) {
+ this.add_style_class_name(className);
+ this._rightCorner.add_style_class_name(className);
+ this._leftCorner.add_style_class_name(className);
+ }
+
+ _removeStyleClassName(className) {
+ this.remove_style_class_name(className);
+ this._rightCorner.remove_style_class_name(className);
+ this._leftCorner.remove_style_class_name(className);
+ }
+
+ _onMenuSet(indicator) {
+ if (!indicator.menu || indicator.menu._openChangedId)
+ return;
+
+ indicator.menu._openChangedId = indicator.menu.connect('open-state-changed',
+ (menu, isOpen) => {
+ let boxAlignment;
+ if (this._leftBox.contains(indicator.container))
+ boxAlignment = Clutter.ActorAlign.START;
+ else if (this._centerBox.contains(indicator.container))
+ boxAlignment = Clutter.ActorAlign.CENTER;
+ else if (this._rightBox.contains(indicator.container))
+ boxAlignment = Clutter.ActorAlign.END;
+
+ if (boxAlignment == Main.messageTray.bannerAlignment)
+ Main.messageTray.bannerBlocked = isOpen;
+ });
+ }
+
+ _getDraggableWindowForPosition(stageX) {
+ let workspaceManager = global.workspace_manager;
+ const windows = workspaceManager.get_active_workspace().list_windows();
+ const allWindowsByStacking =
+ global.display.sort_windows_by_stacking(windows).reverse();
+
+ return allWindowsByStacking.find(metaWindow => {
+ let rect = metaWindow.get_frame_rect();
+ return metaWindow.is_on_primary_monitor() &&
+ metaWindow.showing_on_its_workspace() &&
+ metaWindow.get_window_type() != Meta.WindowType.DESKTOP &&
+ metaWindow.maximized_vertically &&
+ stageX > rect.x && stageX < rect.x + rect.width;
+ });
+ }
+});
diff --git a/js/ui/panelMenu.js b/js/ui/panelMenu.js
new file mode 100644
index 0000000..81d3449
--- /dev/null
+++ b/js/ui/panelMenu.js
@@ -0,0 +1,228 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Button, SystemIndicator */
+
+const { Atk, Clutter, GObject, St } = imports.gi;
+
+const Main = imports.ui.main;
+const Params = imports.misc.params;
+const PopupMenu = imports.ui.popupMenu;
+
+var ButtonBox = GObject.registerClass(
+class ButtonBox extends St.Widget {
+ _init(params) {
+ params = Params.parse(params, {
+ style_class: 'panel-button',
+ x_expand: true,
+ y_expand: true,
+ }, true);
+
+ super._init(params);
+
+ this._delegate = this;
+
+ this.container = new St.Bin({ child: this });
+
+ this.connect('style-changed', this._onStyleChanged.bind(this));
+ this.connect('destroy', this._onDestroy.bind(this));
+
+ this._minHPadding = this._natHPadding = 0.0;
+ }
+
+ _onStyleChanged(actor) {
+ let themeNode = actor.get_theme_node();
+
+ this._minHPadding = themeNode.get_length('-minimum-hpadding');
+ this._natHPadding = themeNode.get_length('-natural-hpadding');
+ }
+
+ vfunc_get_preferred_width(_forHeight) {
+ let child = this.get_first_child();
+ let minimumSize, naturalSize;
+
+ if (child)
+ [minimumSize, naturalSize] = child.get_preferred_width(-1);
+ else
+ minimumSize = naturalSize = 0;
+
+ minimumSize += 2 * this._minHPadding;
+ naturalSize += 2 * this._natHPadding;
+
+ return [minimumSize, naturalSize];
+ }
+
+ vfunc_get_preferred_height(_forWidth) {
+ let child = this.get_first_child();
+
+ if (child)
+ return child.get_preferred_height(-1);
+
+ return [0, 0];
+ }
+
+ vfunc_allocate(box) {
+ this.set_allocation(box);
+
+ let child = this.get_first_child();
+ if (!child)
+ return;
+
+ let [, natWidth] = child.get_preferred_width(-1);
+
+ let availWidth = box.x2 - box.x1;
+ let availHeight = box.y2 - box.y1;
+
+ let childBox = new Clutter.ActorBox();
+ if (natWidth + 2 * this._natHPadding <= availWidth) {
+ childBox.x1 = this._natHPadding;
+ childBox.x2 = availWidth - this._natHPadding;
+ } else {
+ childBox.x1 = this._minHPadding;
+ childBox.x2 = availWidth - this._minHPadding;
+ }
+
+ childBox.y1 = 0;
+ childBox.y2 = availHeight;
+
+ child.allocate(childBox);
+ }
+
+ _onDestroy() {
+ this.container.child = null;
+ this.container.destroy();
+ }
+});
+
+var Button = GObject.registerClass({
+ Signals: { 'menu-set': {} },
+}, class PanelMenuButton extends ButtonBox {
+ _init(menuAlignment, nameText, dontCreateMenu) {
+ super._init({ reactive: true,
+ can_focus: true,
+ track_hover: true,
+ accessible_name: nameText ? nameText : "",
+ accessible_role: Atk.Role.MENU });
+
+ if (dontCreateMenu)
+ this.menu = new PopupMenu.PopupDummyMenu(this);
+ else
+ this.setMenu(new PopupMenu.PopupMenu(this, menuAlignment, St.Side.TOP, 0));
+ }
+
+ setSensitive(sensitive) {
+ this.reactive = sensitive;
+ this.can_focus = sensitive;
+ this.track_hover = sensitive;
+ }
+
+ setMenu(menu) {
+ if (this.menu)
+ this.menu.destroy();
+
+ this.menu = menu;
+ if (this.menu) {
+ this.menu.actor.add_style_class_name('panel-menu');
+ this.menu.connect('open-state-changed', this._onOpenStateChanged.bind(this));
+ this.menu.actor.connect('key-press-event', this._onMenuKeyPress.bind(this));
+
+ Main.uiGroup.add_actor(this.menu.actor);
+ this.menu.actor.hide();
+ }
+ this.emit('menu-set');
+ }
+
+ vfunc_event(event) {
+ if (this.menu &&
+ (event.type() == Clutter.EventType.TOUCH_BEGIN ||
+ event.type() == Clutter.EventType.BUTTON_PRESS))
+ this.menu.toggle();
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_hide() {
+ super.vfunc_hide();
+
+ if (this.menu)
+ this.menu.close();
+ }
+
+ _onMenuKeyPress(actor, event) {
+ if (global.focus_manager.navigate_from_event(event))
+ return Clutter.EVENT_STOP;
+
+ let symbol = event.get_key_symbol();
+ if (symbol == Clutter.KEY_Left || symbol == Clutter.KEY_Right) {
+ let group = global.focus_manager.get_group(this);
+ if (group) {
+ let direction = symbol == Clutter.KEY_Left ? St.DirectionType.LEFT : St.DirectionType.RIGHT;
+ group.navigate_focus(this, direction, false);
+ return Clutter.EVENT_STOP;
+ }
+ }
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _onOpenStateChanged(menu, open) {
+ if (open)
+ this.add_style_pseudo_class('active');
+ else
+ this.remove_style_pseudo_class('active');
+
+ // Setting the max-height won't do any good if the minimum height of the
+ // menu is higher then the screen; it's useful if part of the menu is
+ // scrollable so the minimum height is smaller than the natural height
+ let workArea = Main.layoutManager.getWorkAreaForMonitor(Main.layoutManager.primaryIndex);
+ let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ let verticalMargins = this.menu.actor.margin_top + this.menu.actor.margin_bottom;
+
+ // The workarea and margin dimensions are in physical pixels, but CSS
+ // measures are in logical pixels, so make sure to consider the scale
+ // factor when computing max-height
+ let maxHeight = Math.round((workArea.height - verticalMargins) / scaleFactor);
+ this.menu.actor.style = 'max-height: %spx;'.format(maxHeight);
+ }
+
+ _onDestroy() {
+ if (this.menu)
+ this.menu.destroy();
+ super._onDestroy();
+ }
+});
+
+/* SystemIndicator:
+ *
+ * This class manages one system indicator, which are the icons
+ * that you see at the top right. A system indicator is composed
+ * of an icon and a menu section, which will be composed into the
+ * aggregate menu.
+ */
+var SystemIndicator = GObject.registerClass(
+class SystemIndicator extends St.BoxLayout {
+ _init() {
+ super._init({
+ style_class: 'panel-status-indicators-box',
+ reactive: true,
+ visible: false,
+ });
+ this.menu = new PopupMenu.PopupMenuSection();
+ }
+
+ get indicators() {
+ let klass = this.constructor.name;
+ let { stack } = new Error();
+ log(`Usage of indicator.indicators is deprecated for ${klass}\n${stack}`);
+ return this;
+ }
+
+ _syncIndicatorsVisible() {
+ this.visible = this.get_children().some(a => a.visible);
+ }
+
+ _addIndicator() {
+ let icon = new St.Icon({ style_class: 'system-status-icon' });
+ this.add_actor(icon);
+ icon.connect('notify::visible', this._syncIndicatorsVisible.bind(this));
+ this._syncIndicatorsVisible();
+ return icon;
+ }
+});
diff --git a/js/ui/pointerA11yTimeout.js b/js/ui/pointerA11yTimeout.js
new file mode 100644
index 0000000..263cc3e
--- /dev/null
+++ b/js/ui/pointerA11yTimeout.js
@@ -0,0 +1,134 @@
+/* exported PointerA11yTimeout */
+const { Clutter, GObject, Meta, St } = imports.gi;
+const Main = imports.ui.main;
+const Cairo = imports.cairo;
+
+const SUCCESS_ZOOM_OUT_DURATION = 150;
+
+var PieTimer = GObject.registerClass({
+ Properties: {
+ 'angle': GObject.ParamSpec.double(
+ 'angle', 'angle', 'angle',
+ GObject.ParamFlags.READWRITE,
+ 0, 2 * Math.PI, 0),
+ },
+}, class PieTimer extends St.DrawingArea {
+ _init() {
+ this._angle = 0;
+ super._init({
+ style_class: 'pie-timer',
+ opacity: 0,
+ visible: false,
+ can_focus: false,
+ reactive: false,
+ });
+
+ this.set_pivot_point(0.5, 0.5);
+ }
+
+ get angle() {
+ return this._angle;
+ }
+
+ set angle(angle) {
+ if (this._angle == angle)
+ return;
+
+ this._angle = angle;
+ this.notify('angle');
+ this.queue_repaint();
+ }
+
+ vfunc_repaint() {
+ let node = this.get_theme_node();
+ let backgroundColor = node.get_color('-pie-background-color');
+ let borderColor = node.get_color('-pie-border-color');
+ let borderWidth = node.get_length('-pie-border-width');
+ let [width, height] = this.get_surface_size();
+ let radius = Math.min(width / 2, height / 2);
+
+ let startAngle = 3 * Math.PI / 2;
+ let endAngle = startAngle + this._angle;
+
+ let cr = this.get_context();
+ cr.setLineCap(Cairo.LineCap.ROUND);
+ cr.setLineJoin(Cairo.LineJoin.ROUND);
+ cr.translate(width / 2, height / 2);
+
+ if (this._angle < 2 * Math.PI)
+ cr.moveTo(0, 0);
+
+ cr.arc(0, 0, radius - borderWidth, startAngle, endAngle);
+
+ if (this._angle < 2 * Math.PI)
+ cr.lineTo(0, 0);
+
+ cr.closePath();
+
+ cr.setLineWidth(0);
+ Clutter.cairo_set_source_color(cr, backgroundColor);
+ cr.fillPreserve();
+
+ cr.setLineWidth(borderWidth);
+ Clutter.cairo_set_source_color(cr, borderColor);
+ cr.stroke();
+
+ cr.$dispose();
+ }
+
+ start(x, y, duration) {
+ this.x = x - this.width / 2;
+ this.y = y - this.height / 2;
+ this.show();
+
+ this.ease({
+ opacity: 255,
+ duration: duration / 4,
+ mode: Clutter.AnimationMode.EASE_IN_QUAD,
+ });
+
+ this.ease_property('angle', 2 * Math.PI, {
+ duration,
+ mode: Clutter.AnimationMode.LINEAR,
+ onComplete: this._onTransitionComplete.bind(this),
+ });
+ }
+
+ _onTransitionComplete() {
+ this.ease({
+ scale_x: 2,
+ scale_y: 2,
+ opacity: 0,
+ duration: SUCCESS_ZOOM_OUT_DURATION,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onStopped: () => this.destroy(),
+ });
+ }
+});
+
+var PointerA11yTimeout = class PointerA11yTimeout {
+ constructor() {
+ let seat = Clutter.get_default_backend().get_default_seat();
+
+ seat.connect('ptr-a11y-timeout-started', (o, device, type, timeout) => {
+ let [x, y] = global.get_pointer();
+
+ this._pieTimer = new PieTimer();
+ Main.uiGroup.add_actor(this._pieTimer);
+ Main.uiGroup.set_child_above_sibling(this._pieTimer, null);
+
+ this._pieTimer.start(x, y, timeout);
+
+ if (type == Clutter.PointerA11yTimeoutType.GESTURE)
+ global.display.set_cursor(Meta.Cursor.CROSSHAIR);
+ });
+
+ seat.connect('ptr-a11y-timeout-stopped', (o, device, type, clicked) => {
+ if (!clicked)
+ this._pieTimer.destroy();
+
+ if (type == Clutter.PointerA11yTimeoutType.GESTURE)
+ global.display.set_cursor(Meta.Cursor.DEFAULT);
+ });
+ }
+};
diff --git a/js/ui/pointerWatcher.js b/js/ui/pointerWatcher.js
new file mode 100644
index 0000000..9dbdcf6
--- /dev/null
+++ b/js/ui/pointerWatcher.js
@@ -0,0 +1,125 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported getPointerWatcher */
+
+const { GLib, Meta } = imports.gi;
+
+// We stop polling if the user is idle for more than this amount of time
+var IDLE_TIME = 1000;
+
+// This file implements a reasonably efficient system for tracking the position
+// of the mouse pointer. We simply query the pointer from the X server in a loop,
+// but we turn off the polling when the user is idle.
+
+let _pointerWatcher = null;
+function getPointerWatcher() {
+ if (_pointerWatcher == null)
+ _pointerWatcher = new PointerWatcher();
+
+ return _pointerWatcher;
+}
+
+var PointerWatch = class {
+ constructor(watcher, interval, callback) {
+ this.watcher = watcher;
+ this.interval = interval;
+ this.callback = callback;
+ }
+
+ // remove:
+ // remove this watch. This function may safely be called
+ // while the callback is executing.
+ remove() {
+ this.watcher._removeWatch(this);
+ }
+};
+
+var PointerWatcher = class {
+ constructor() {
+ this._idleMonitor = Meta.IdleMonitor.get_core();
+ this._idleMonitor.add_idle_watch(IDLE_TIME, this._onIdleMonitorBecameIdle.bind(this));
+ this._idle = this._idleMonitor.get_idletime() > IDLE_TIME;
+ this._watches = [];
+ this.pointerX = null;
+ this.pointerY = null;
+ }
+
+ // addWatch:
+ // @interval: hint as to the time resolution needed. When the user is
+ // not idle, the position of the pointer will be queried at least
+ // once every this many milliseconds.
+ // @callback to call when the pointer position changes - takes
+ // two arguments, X and Y.
+ //
+ // Set up a watch on the position of the mouse pointer. Returns a
+ // PointerWatch object which has a remove() method to remove the watch.
+ addWatch(interval, callback) {
+ // Avoid unreliably calling the watch for the current position
+ this._updatePointer();
+
+ let watch = new PointerWatch(this, interval, callback);
+ this._watches.push(watch);
+ this._updateTimeout();
+ return watch;
+ }
+
+ _removeWatch(watch) {
+ for (let i = 0; i < this._watches.length; i++) {
+ if (this._watches[i] == watch) {
+ this._watches.splice(i, 1);
+ this._updateTimeout();
+ return;
+ }
+ }
+ }
+
+ _onIdleMonitorBecameActive() {
+ this._idle = false;
+ this._updatePointer();
+ this._updateTimeout();
+ }
+
+ _onIdleMonitorBecameIdle() {
+ this._idle = true;
+ this._idleMonitor.add_user_active_watch(this._onIdleMonitorBecameActive.bind(this));
+ this._updateTimeout();
+ }
+
+ _updateTimeout() {
+ if (this._timeoutId) {
+ GLib.source_remove(this._timeoutId);
+ this._timeoutId = 0;
+ }
+
+ if (this._idle || this._watches.length == 0)
+ return;
+
+ let minInterval = this._watches[0].interval;
+ for (let i = 1; i < this._watches.length; i++)
+ minInterval = Math.min(this._watches[i].interval, minInterval);
+
+ this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, minInterval,
+ this._onTimeout.bind(this));
+ GLib.Source.set_name_by_id(this._timeoutId, '[gnome-shell] this._onTimeout');
+ }
+
+ _onTimeout() {
+ this._updatePointer();
+ return GLib.SOURCE_CONTINUE;
+ }
+
+ _updatePointer() {
+ let [x, y] = global.get_pointer();
+ if (this.pointerX == x && this.pointerY == y)
+ return;
+
+ this.pointerX = x;
+ this.pointerY = y;
+
+ for (let i = 0; i < this._watches.length;) {
+ let watch = this._watches[i];
+ watch.callback(x, y);
+ if (watch == this._watches[i]) // guard against self-removal
+ i++;
+ }
+ }
+};
diff --git a/js/ui/popupMenu.js b/js/ui/popupMenu.js
new file mode 100644
index 0000000..a618f8f
--- /dev/null
+++ b/js/ui/popupMenu.js
@@ -0,0 +1,1411 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported PopupMenuItem, PopupSeparatorMenuItem, Switch, PopupSwitchMenuItem,
+ PopupImageMenuItem, PopupMenu, PopupDummyMenu, PopupSubMenu,
+ PopupMenuSection, PopupSubMenuMenuItem, PopupMenuManager */
+
+const { Atk, Clutter, Gio, GObject, Graphene, Shell, St } = imports.gi;
+const Signals = imports.signals;
+
+const BoxPointer = imports.ui.boxpointer;
+const GrabHelper = imports.ui.grabHelper;
+const Main = imports.ui.main;
+const Params = imports.misc.params;
+
+var Ornament = {
+ NONE: 0,
+ DOT: 1,
+ CHECK: 2,
+ HIDDEN: 3,
+};
+
+function isPopupMenuItemVisible(child) {
+ if (child._delegate instanceof PopupMenuSection) {
+ if (child._delegate.isEmpty())
+ return false;
+ }
+ return child.visible;
+}
+
+/**
+ * arrowIcon
+ * @param {St.Side} side - Side to which the arrow points.
+ * @returns {St.Icon} a new arrow icon
+ */
+function arrowIcon(side) {
+ let iconName;
+ switch (side) {
+ case St.Side.TOP:
+ iconName = 'pan-up-symbolic';
+ break;
+ case St.Side.RIGHT:
+ iconName = 'pan-end-symbolic';
+ break;
+ case St.Side.BOTTOM:
+ iconName = 'pan-down-symbolic';
+ break;
+ case St.Side.LEFT:
+ iconName = 'pan-start-symbolic';
+ break;
+ }
+
+ let arrow = new St.Icon({ style_class: 'popup-menu-arrow',
+ icon_name: iconName,
+ accessible_role: Atk.Role.ARROW,
+ y_expand: true,
+ y_align: Clutter.ActorAlign.CENTER });
+
+ return arrow;
+}
+
+var PopupBaseMenuItem = GObject.registerClass({
+ Properties: {
+ 'active': GObject.ParamSpec.boolean('active', 'active', 'active',
+ GObject.ParamFlags.READWRITE,
+ false),
+ 'sensitive': GObject.ParamSpec.boolean('sensitive', 'sensitive', 'sensitive',
+ GObject.ParamFlags.READWRITE,
+ true),
+ },
+ Signals: {
+ 'activate': { param_types: [Clutter.Event.$gtype] },
+ },
+}, class PopupBaseMenuItem extends St.BoxLayout {
+ _init(params) {
+ params = Params.parse(params, {
+ reactive: true,
+ activate: true,
+ hover: true,
+ style_class: null,
+ can_focus: true,
+ });
+ super._init({ style_class: 'popup-menu-item',
+ reactive: params.reactive,
+ track_hover: params.reactive,
+ can_focus: params.can_focus,
+ accessible_role: Atk.Role.MENU_ITEM });
+ this._delegate = this;
+
+ this._ornament = Ornament.NONE;
+ this._ornamentLabel = new St.Label({ style_class: 'popup-menu-ornament' });
+ this.add(this._ornamentLabel);
+
+ this._parent = null;
+ this._active = false;
+ this._activatable = params.reactive && params.activate;
+ this._sensitive = true;
+
+ if (!this._activatable)
+ this.add_style_class_name('popup-inactive-menu-item');
+
+ if (params.style_class)
+ this.add_style_class_name(params.style_class);
+
+ if (params.reactive && params.hover)
+ this.bind_property('hover', this, 'active', GObject.BindingFlags.SYNC_CREATE);
+ }
+
+ get actor() {
+ /* This is kept for compatibility with current implementation, and we
+ don't want to warn here yet since PopupMenu depends on this */
+ return this;
+ }
+
+ _getTopMenu() {
+ if (this._parent)
+ return this._parent._getTopMenu();
+ else
+ return this;
+ }
+
+ _setParent(parent) {
+ this._parent = parent;
+ }
+
+ vfunc_button_press_event() {
+ if (!this._activatable)
+ return Clutter.EVENT_PROPAGATE;
+
+ // This is the CSS active state
+ this.add_style_pseudo_class('active');
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_button_release_event() {
+ if (!this._activatable)
+ return Clutter.EVENT_PROPAGATE;
+
+ this.remove_style_pseudo_class('active');
+ this.activate(Clutter.get_current_event());
+ return Clutter.EVENT_STOP;
+ }
+
+ vfunc_touch_event(touchEvent) {
+ if (!this._activatable)
+ return Clutter.EVENT_PROPAGATE;
+
+ if (touchEvent.type == Clutter.EventType.TOUCH_END) {
+ this.remove_style_pseudo_class('active');
+ this.activate(Clutter.get_current_event());
+ return Clutter.EVENT_STOP;
+ } else if (touchEvent.type == Clutter.EventType.TOUCH_BEGIN) {
+ // This is the CSS active state
+ this.add_style_pseudo_class('active');
+ }
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_key_press_event(keyEvent) {
+ if (!this._activatable)
+ return super.vfunc_key_press_event(keyEvent);
+
+ let state = keyEvent.modifier_state;
+
+ // if user has a modifier down (except capslock and numlock)
+ // then don't handle the key press here
+ state &= ~Clutter.ModifierType.LOCK_MASK;
+ state &= ~Clutter.ModifierType.MOD2_MASK;
+ state &= Clutter.ModifierType.MODIFIER_MASK;
+
+ if (state)
+ return Clutter.EVENT_PROPAGATE;
+
+ let symbol = keyEvent.keyval;
+ if (symbol == Clutter.KEY_space || symbol == Clutter.KEY_Return) {
+ this.activate(Clutter.get_current_event());
+ return Clutter.EVENT_STOP;
+ }
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_key_focus_in() {
+ super.vfunc_key_focus_in();
+ this.active = true;
+ }
+
+ vfunc_key_focus_out() {
+ super.vfunc_key_focus_out();
+ this.active = false;
+ }
+
+ activate(event) {
+ this.emit('activate', event);
+ }
+
+ get active() {
+ return this._active;
+ }
+
+ set active(active) {
+ let activeChanged = active != this.active;
+ if (activeChanged) {
+ this._active = active;
+ if (active) {
+ this.add_style_class_name('selected');
+ if (this.can_focus)
+ this.grab_key_focus();
+ } else {
+ this.remove_style_class_name('selected');
+ // Remove the CSS active state if the user press the button and
+ // while holding moves to another menu item, so we don't paint all items.
+ // The correct behaviour would be to set the new item with the CSS
+ // active state as well, but button-press-event is not triggered,
+ // so we should track it in our own, which would involve some work
+ // in the container
+ this.remove_style_pseudo_class('active');
+ }
+ this.notify('active');
+ }
+ }
+
+ syncSensitive() {
+ let sensitive = this.sensitive;
+ this.reactive = sensitive;
+ this.can_focus = sensitive;
+ this.notify('sensitive');
+ return sensitive;
+ }
+
+ getSensitive() {
+ let parentSensitive = this._parent ? this._parent.sensitive : true;
+ return this._activatable && this._sensitive && parentSensitive;
+ }
+
+ setSensitive(sensitive) {
+ if (this._sensitive == sensitive)
+ return;
+
+ this._sensitive = sensitive;
+ this.syncSensitive();
+ }
+
+ get sensitive() {
+ return this.getSensitive();
+ }
+
+ set sensitive(sensitive) {
+ this.setSensitive(sensitive);
+ }
+
+ setOrnament(ornament) {
+ if (ornament == this._ornament)
+ return;
+
+ this._ornament = ornament;
+
+ if (ornament == Ornament.DOT) {
+ this._ornamentLabel.text = '\u2022';
+ this.add_accessible_state(Atk.StateType.CHECKED);
+ } else if (ornament == Ornament.CHECK) {
+ this._ornamentLabel.text = '\u2713';
+ this.add_accessible_state(Atk.StateType.CHECKED);
+ } else if (ornament == Ornament.NONE || ornament == Ornament.HIDDEN) {
+ this._ornamentLabel.text = '';
+ this.remove_accessible_state(Atk.StateType.CHECKED);
+ }
+
+ this._ornamentLabel.visible = ornament != Ornament.HIDDEN;
+ }
+});
+
+var PopupMenuItem = GObject.registerClass(
+class PopupMenuItem extends PopupBaseMenuItem {
+ _init(text, params) {
+ super._init(params);
+
+ this.label = new St.Label({ text });
+ this.add_child(this.label);
+ this.label_actor = this.label;
+ }
+});
+
+
+var PopupSeparatorMenuItem = GObject.registerClass(
+class PopupSeparatorMenuItem extends PopupBaseMenuItem {
+ _init(text) {
+ super._init({
+ style_class: 'popup-separator-menu-item',
+ reactive: false,
+ can_focus: false,
+ });
+
+ this.label = new St.Label({ text: text || '' });
+ this.add(this.label);
+ this.label_actor = this.label;
+
+ this.label.connect('notify::text',
+ this._syncVisibility.bind(this));
+ this._syncVisibility();
+
+ this._separator = new St.Widget({
+ style_class: 'popup-separator-menu-item-separator',
+ x_expand: true,
+ y_expand: true,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ this.add_child(this._separator);
+ }
+
+ _syncVisibility() {
+ this.label.visible = this.label.text != '';
+ }
+});
+
+var Switch = GObject.registerClass({
+ Properties: {
+ 'state': GObject.ParamSpec.boolean(
+ 'state', 'state', 'state',
+ GObject.ParamFlags.READWRITE,
+ false),
+ },
+}, class Switch extends St.Bin {
+ _init(state) {
+ this._state = false;
+
+ super._init({
+ style_class: 'toggle-switch',
+ accessible_role: Atk.Role.CHECK_BOX,
+ can_focus: true,
+ state,
+ });
+ }
+
+ get state() {
+ return this._state;
+ }
+
+ set state(state) {
+ if (this._state === state)
+ return;
+
+ if (state)
+ this.add_style_pseudo_class('checked');
+ else
+ this.remove_style_pseudo_class('checked');
+
+ this._state = state;
+ this.notify('state');
+ }
+
+ toggle() {
+ this.state = !this.state;
+ }
+});
+
+var PopupSwitchMenuItem = GObject.registerClass({
+ Signals: { 'toggled': { param_types: [GObject.TYPE_BOOLEAN] } },
+}, class PopupSwitchMenuItem extends PopupBaseMenuItem {
+ _init(text, active, params) {
+ super._init(params);
+
+ this.label = new St.Label({ text });
+ this._switch = new Switch(active);
+
+ this.accessible_role = Atk.Role.CHECK_MENU_ITEM;
+ this.checkAccessibleState();
+ this.label_actor = this.label;
+
+ this.add_child(this.label);
+
+ this._statusBin = new St.Bin({
+ x_align: Clutter.ActorAlign.END,
+ x_expand: true,
+ });
+ this.add_child(this._statusBin);
+
+ this._statusLabel = new St.Label({
+ text: '',
+ style_class: 'popup-status-menu-item',
+ });
+ this._statusBin.child = this._switch;
+ }
+
+ setStatus(text) {
+ if (text != null) {
+ this._statusLabel.text = text;
+ this._statusBin.child = this._statusLabel;
+ this.reactive = false;
+ this.accessible_role = Atk.Role.MENU_ITEM;
+ } else {
+ this._statusBin.child = this._switch;
+ this.reactive = true;
+ this.accessible_role = Atk.Role.CHECK_MENU_ITEM;
+ }
+ this.checkAccessibleState();
+ }
+
+ activate(event) {
+ if (this._switch.mapped)
+ this.toggle();
+
+ // we allow pressing space to toggle the switch
+ // without closing the menu
+ if (event.type() == Clutter.EventType.KEY_PRESS &&
+ event.get_key_symbol() == Clutter.KEY_space)
+ return;
+
+ super.activate(event);
+ }
+
+ toggle() {
+ this._switch.toggle();
+ this.emit('toggled', this._switch.state);
+ this.checkAccessibleState();
+ }
+
+ get state() {
+ return this._switch.state;
+ }
+
+ setToggleState(state) {
+ this._switch.state = state;
+ this.checkAccessibleState();
+ }
+
+ checkAccessibleState() {
+ switch (this.accessible_role) {
+ case Atk.Role.CHECK_MENU_ITEM:
+ if (this._switch.state)
+ this.add_accessible_state(Atk.StateType.CHECKED);
+ else
+ this.remove_accessible_state(Atk.StateType.CHECKED);
+ break;
+ default:
+ this.remove_accessible_state(Atk.StateType.CHECKED);
+ }
+ }
+});
+
+var PopupImageMenuItem = GObject.registerClass(
+class PopupImageMenuItem extends PopupBaseMenuItem {
+ _init(text, icon, params) {
+ super._init(params);
+
+ this._icon = new St.Icon({ style_class: 'popup-menu-icon',
+ x_align: Clutter.ActorAlign.END });
+ this.add_child(this._icon);
+ this.label = new St.Label({ text });
+ this.add_child(this.label);
+ this.label_actor = this.label;
+
+ this.setIcon(icon);
+ }
+
+ setIcon(icon) {
+ // The 'icon' parameter can be either a Gio.Icon or a string.
+ if (icon instanceof GObject.Object && GObject.type_is_a(icon, Gio.Icon))
+ this._icon.gicon = icon;
+ else
+ this._icon.icon_name = icon;
+ }
+});
+
+var PopupMenuBase = class {
+ constructor(sourceActor, styleClass) {
+ if (this.constructor === PopupMenuBase)
+ throw new TypeError('Cannot instantiate abstract class %s'.format(this.constructor.name));
+
+ this.sourceActor = sourceActor;
+ this.focusActor = sourceActor;
+ this._parent = null;
+
+ this.box = new St.BoxLayout({
+ vertical: true,
+ x_expand: true,
+ y_expand: true,
+ });
+
+ if (styleClass !== undefined)
+ this.box.style_class = styleClass;
+ this.length = 0;
+
+ this.isOpen = false;
+
+ // If set, we don't send events (including crossing events) to the source actor
+ // for the menu which causes its prelight state to freeze
+ this.blockSourceEvents = false;
+
+ this._activeMenuItem = null;
+ this._settingsActions = { };
+
+ this._sensitive = true;
+
+ this._sessionUpdatedId = Main.sessionMode.connect('updated', this._sessionUpdated.bind(this));
+ }
+
+ _getTopMenu() {
+ if (this._parent)
+ return this._parent._getTopMenu();
+ else
+ return this;
+ }
+
+ _setParent(parent) {
+ this._parent = parent;
+ }
+
+ getSensitive() {
+ let parentSensitive = this._parent ? this._parent.sensitive : true;
+ return this._sensitive && parentSensitive;
+ }
+
+ setSensitive(sensitive) {
+ this._sensitive = sensitive;
+ this.emit('notify::sensitive');
+ }
+
+ get sensitive() {
+ return this.getSensitive();
+ }
+
+ set sensitive(sensitive) {
+ this.setSensitive(sensitive);
+ }
+
+ _sessionUpdated() {
+ this._setSettingsVisibility(Main.sessionMode.allowSettings);
+ this.close();
+ }
+
+ addAction(title, callback, icon) {
+ let menuItem;
+ if (icon != undefined)
+ menuItem = new PopupImageMenuItem(title, icon);
+ else
+ menuItem = new PopupMenuItem(title);
+
+ this.addMenuItem(menuItem);
+ menuItem.connect('activate', (o, event) => {
+ callback(event);
+ });
+
+ return menuItem;
+ }
+
+ addSettingsAction(title, desktopFile) {
+ let menuItem = this.addAction(title, () => {
+ let app = Shell.AppSystem.get_default().lookup_app(desktopFile);
+
+ if (!app) {
+ log('Settings panel for desktop file %s could not be loaded!'.format(desktopFile));
+ return;
+ }
+
+ Main.overview.hide();
+ app.activate();
+ });
+
+ menuItem.visible = Main.sessionMode.allowSettings;
+ this._settingsActions[desktopFile] = menuItem;
+
+ return menuItem;
+ }
+
+ _setSettingsVisibility(visible) {
+ for (let id in this._settingsActions) {
+ let item = this._settingsActions[id];
+ item.visible = visible;
+ }
+ }
+
+ isEmpty() {
+ let hasVisibleChildren = this.box.get_children().some(child => {
+ if (child._delegate instanceof PopupSeparatorMenuItem)
+ return false;
+ return isPopupMenuItemVisible(child);
+ });
+
+ return !hasVisibleChildren;
+ }
+
+ itemActivated(animate) {
+ if (animate == undefined)
+ animate = BoxPointer.PopupAnimation.FULL;
+
+ this._getTopMenu().close(animate);
+ }
+
+ _subMenuActiveChanged(submenu, submenuItem) {
+ if (this._activeMenuItem && this._activeMenuItem != submenuItem)
+ this._activeMenuItem.active = false;
+ this._activeMenuItem = submenuItem;
+ this.emit('active-changed', submenuItem);
+ }
+
+ _connectItemSignals(menuItem) {
+ menuItem._activeChangeId = menuItem.connect('notify::active', () => {
+ let active = menuItem.active;
+ if (active && this._activeMenuItem != menuItem) {
+ if (this._activeMenuItem)
+ this._activeMenuItem.active = false;
+ this._activeMenuItem = menuItem;
+ this.emit('active-changed', menuItem);
+ } else if (!active && this._activeMenuItem == menuItem) {
+ this._activeMenuItem = null;
+ this.emit('active-changed', null);
+ }
+ });
+ menuItem._sensitiveChangeId = menuItem.connect('notify::sensitive', () => {
+ let sensitive = menuItem.sensitive;
+ if (!sensitive && this._activeMenuItem == menuItem) {
+ if (!this.actor.navigate_focus(menuItem.actor,
+ St.DirectionType.TAB_FORWARD,
+ true))
+ this.actor.grab_key_focus();
+ } else if (sensitive && this._activeMenuItem == null) {
+ if (global.stage.get_key_focus() == this.actor)
+ menuItem.actor.grab_key_focus();
+ }
+ });
+ menuItem._activateId = menuItem.connect_after('activate', () => {
+ this.emit('activate', menuItem);
+ this.itemActivated(BoxPointer.PopupAnimation.FULL);
+ });
+
+ menuItem._parentSensitiveChangeId = this.connect('notify::sensitive', () => {
+ menuItem.syncSensitive();
+ });
+
+ // the weird name is to avoid a conflict with some random property
+ // the menuItem may have, called destroyId
+ // (FIXME: in the future it may make sense to have container objects
+ // like PopupMenuManager does)
+ menuItem._popupMenuDestroyId = menuItem.connect('destroy', () => {
+ menuItem.disconnect(menuItem._popupMenuDestroyId);
+ menuItem.disconnect(menuItem._activateId);
+ menuItem.disconnect(menuItem._activeChangeId);
+ menuItem.disconnect(menuItem._sensitiveChangeId);
+ this.disconnect(menuItem._parentSensitiveChangeId);
+ if (menuItem == this._activeMenuItem)
+ this._activeMenuItem = null;
+ });
+ }
+
+ _updateSeparatorVisibility(menuItem) {
+ if (menuItem.label.text)
+ return;
+
+ let children = this.box.get_children();
+
+ let index = children.indexOf(menuItem.actor);
+
+ if (index < 0)
+ return;
+
+ let childBeforeIndex = index - 1;
+
+ while (childBeforeIndex >= 0 && !isPopupMenuItemVisible(children[childBeforeIndex]))
+ childBeforeIndex--;
+
+ if (childBeforeIndex < 0 ||
+ children[childBeforeIndex]._delegate instanceof PopupSeparatorMenuItem) {
+ menuItem.actor.hide();
+ return;
+ }
+
+ let childAfterIndex = index + 1;
+
+ while (childAfterIndex < children.length && !isPopupMenuItemVisible(children[childAfterIndex]))
+ childAfterIndex++;
+
+ if (childAfterIndex >= children.length ||
+ children[childAfterIndex]._delegate instanceof PopupSeparatorMenuItem) {
+ menuItem.actor.hide();
+ return;
+ }
+
+ menuItem.show();
+ }
+
+ moveMenuItem(menuItem, position) {
+ let items = this._getMenuItems();
+ let i = 0;
+
+ while (i < items.length && position > 0) {
+ if (items[i] != menuItem)
+ position--;
+ i++;
+ }
+
+ if (i < items.length) {
+ if (items[i] != menuItem)
+ this.box.set_child_below_sibling(menuItem.actor, items[i].actor);
+ } else {
+ this.box.set_child_above_sibling(menuItem.actor, null);
+ }
+ }
+
+ addMenuItem(menuItem, position) {
+ let beforeItem = null;
+ if (position == undefined) {
+ this.box.add(menuItem.actor);
+ } else {
+ let items = this._getMenuItems();
+ if (position < items.length) {
+ beforeItem = items[position].actor;
+ this.box.insert_child_below(menuItem.actor, beforeItem);
+ } else {
+ this.box.add(menuItem.actor);
+ }
+ }
+
+ if (menuItem instanceof PopupMenuSection) {
+ let activeChangeId = menuItem.connect('active-changed', this._subMenuActiveChanged.bind(this));
+
+ let parentOpenStateChangedId = this.connect('open-state-changed', (self, open) => {
+ if (open)
+ menuItem.open();
+ else
+ menuItem.close();
+ });
+ let parentClosingId = this.connect('menu-closed', () => {
+ menuItem.emit('menu-closed');
+ });
+ let subMenuSensitiveChangedId = this.connect('notify::sensitive', () => {
+ menuItem.emit('notify::sensitive');
+ });
+
+ menuItem.connect('destroy', () => {
+ menuItem.disconnect(activeChangeId);
+ this.disconnect(subMenuSensitiveChangedId);
+ this.disconnect(parentOpenStateChangedId);
+ this.disconnect(parentClosingId);
+ this.length--;
+ });
+ } else if (menuItem instanceof PopupSubMenuMenuItem) {
+ if (beforeItem == null)
+ this.box.add(menuItem.menu.actor);
+ else
+ this.box.insert_child_below(menuItem.menu.actor, beforeItem);
+
+ this._connectItemSignals(menuItem);
+ let subMenuActiveChangeId = menuItem.menu.connect('active-changed', this._subMenuActiveChanged.bind(this));
+ let closingId = this.connect('menu-closed', () => {
+ menuItem.menu.close(BoxPointer.PopupAnimation.NONE);
+ });
+
+ menuItem.connect('destroy', () => {
+ menuItem.menu.disconnect(subMenuActiveChangeId);
+ this.disconnect(closingId);
+ });
+ } else if (menuItem instanceof PopupSeparatorMenuItem) {
+ this._connectItemSignals(menuItem);
+
+ // updateSeparatorVisibility needs to get called any time the
+ // separator's adjacent siblings change visibility or position.
+ // open-state-changed isn't exactly that, but doing it in more
+ // precise ways would require a lot more bookkeeping.
+ let openStateChangeId = this.connect('open-state-changed', () => {
+ this._updateSeparatorVisibility(menuItem);
+ });
+ let destroyId = menuItem.connect('destroy', () => {
+ this.disconnect(openStateChangeId);
+ menuItem.disconnect(destroyId);
+ });
+ } else if (menuItem instanceof PopupBaseMenuItem) {
+ this._connectItemSignals(menuItem);
+ } else {
+ throw TypeError("Invalid argument to PopupMenuBase.addMenuItem()");
+ }
+
+ menuItem._setParent(this);
+
+ this.length++;
+ }
+
+ _getMenuItems() {
+ return this.box.get_children().map(a => a._delegate).filter(item => {
+ return item instanceof PopupBaseMenuItem || item instanceof PopupMenuSection;
+ });
+ }
+
+ get firstMenuItem() {
+ let items = this._getMenuItems();
+ if (items.length)
+ return items[0];
+ else
+ return null;
+ }
+
+ get numMenuItems() {
+ return this._getMenuItems().length;
+ }
+
+ removeAll() {
+ let children = this._getMenuItems();
+ for (let i = 0; i < children.length; i++) {
+ let item = children[i];
+ item.destroy();
+ }
+ }
+
+ toggle() {
+ if (this.isOpen)
+ this.close(BoxPointer.PopupAnimation.FULL);
+ else
+ this.open(BoxPointer.PopupAnimation.FULL);
+ }
+
+ destroy() {
+ this.close();
+ this.removeAll();
+ this.actor.destroy();
+
+ this.emit('destroy');
+
+ Main.sessionMode.disconnect(this._sessionUpdatedId);
+ this._sessionUpdatedId = 0;
+ }
+};
+Signals.addSignalMethods(PopupMenuBase.prototype);
+
+var PopupMenu = class extends PopupMenuBase {
+ constructor(sourceActor, arrowAlignment, arrowSide) {
+ super(sourceActor, 'popup-menu-content');
+
+ this._arrowAlignment = arrowAlignment;
+ this._arrowSide = arrowSide;
+
+ this._boxPointer = new BoxPointer.BoxPointer(arrowSide);
+ this.actor = this._boxPointer;
+ this.actor._delegate = this;
+ this.actor.style_class = 'popup-menu-boxpointer';
+
+ this._boxPointer.bin.set_child(this.box);
+ this.actor.add_style_class_name('popup-menu');
+
+ global.focus_manager.add_group(this.actor);
+ this.actor.reactive = true;
+
+ if (this.sourceActor) {
+ this._keyPressId = this.sourceActor.connect('key-press-event',
+ this._onKeyPress.bind(this));
+ this._notifyMappedId = this.sourceActor.connect('notify::mapped',
+ () => {
+ if (!this.sourceActor.mapped)
+ this.close();
+ });
+ }
+
+ this._systemModalOpenedId = 0;
+ this._openedSubMenu = null;
+ }
+
+ _setOpenedSubMenu(submenu) {
+ if (this._openedSubMenu)
+ this._openedSubMenu.close(true);
+
+ this._openedSubMenu = submenu;
+ }
+
+ _onKeyPress(actor, event) {
+ // Disable toggling the menu by keyboard
+ // when it cannot be toggled by pointer
+ if (!actor.reactive)
+ return Clutter.EVENT_PROPAGATE;
+
+ let navKey;
+ switch (this._boxPointer.arrowSide) {
+ case St.Side.TOP:
+ navKey = Clutter.KEY_Down;
+ break;
+ case St.Side.BOTTOM:
+ navKey = Clutter.KEY_Up;
+ break;
+ case St.Side.LEFT:
+ navKey = Clutter.KEY_Right;
+ break;
+ case St.Side.RIGHT:
+ navKey = Clutter.KEY_Left;
+ break;
+ }
+
+ let state = event.get_state();
+
+ // if user has a modifier down (except capslock and numlock)
+ // then don't handle the key press here
+ state &= ~Clutter.ModifierType.LOCK_MASK;
+ state &= ~Clutter.ModifierType.MOD2_MASK;
+ state &= Clutter.ModifierType.MODIFIER_MASK;
+
+ if (state)
+ return Clutter.EVENT_PROPAGATE;
+
+ let symbol = event.get_key_symbol();
+ if (symbol == Clutter.KEY_space || symbol == Clutter.KEY_Return) {
+ this.toggle();
+ return Clutter.EVENT_STOP;
+ } else if (symbol == Clutter.KEY_Escape && this.isOpen) {
+ this.close();
+ return Clutter.EVENT_STOP;
+ } else if (symbol == navKey) {
+ if (!this.isOpen)
+ this.toggle();
+ this.actor.navigate_focus(null, St.DirectionType.TAB_FORWARD, false);
+ return Clutter.EVENT_STOP;
+ } else {
+ return Clutter.EVENT_PROPAGATE;
+ }
+ }
+
+ setArrowOrigin(origin) {
+ this._boxPointer.setArrowOrigin(origin);
+ }
+
+ setSourceAlignment(alignment) {
+ this._boxPointer.setSourceAlignment(alignment);
+ }
+
+ open(animate) {
+ if (this.isOpen)
+ return;
+
+ if (this.isEmpty())
+ return;
+
+ if (!this._systemModalOpenedId) {
+ this._systemModalOpenedId =
+ Main.layoutManager.connect('system-modal-opened', () => this.close());
+ }
+
+ this.isOpen = true;
+
+ this._boxPointer.setPosition(this.sourceActor, this._arrowAlignment);
+ this._boxPointer.open(animate);
+
+ this.actor.get_parent().set_child_above_sibling(this.actor, null);
+
+ this.emit('open-state-changed', true);
+ }
+
+ close(animate) {
+ if (this._activeMenuItem)
+ this._activeMenuItem.active = false;
+
+ if (this._boxPointer.visible) {
+ this._boxPointer.close(animate, () => {
+ this.emit('menu-closed');
+ });
+ }
+
+ if (!this.isOpen)
+ return;
+
+ this.isOpen = false;
+ this.emit('open-state-changed', false);
+ }
+
+ destroy() {
+ if (this._keyPressId)
+ this.sourceActor.disconnect(this._keyPressId);
+
+ if (this._notifyMappedId)
+ this.sourceActor.disconnect(this._notifyMappedId);
+
+ if (this._systemModalOpenedId)
+ Main.layoutManager.disconnect(this._systemModalOpenedId);
+ this._systemModalOpenedId = 0;
+
+ super.destroy();
+ }
+};
+
+var PopupDummyMenu = class {
+ constructor(sourceActor) {
+ this.sourceActor = sourceActor;
+ this.actor = sourceActor;
+ this.actor._delegate = this;
+ }
+
+ getSensitive() {
+ return true;
+ }
+
+ get sensitive() {
+ return this.getSensitive();
+ }
+
+ open() {
+ this.emit('open-state-changed', true);
+ }
+
+ close() {
+ this.emit('open-state-changed', false);
+ }
+
+ toggle() {}
+
+ destroy() {
+ this.emit('destroy');
+ }
+};
+Signals.addSignalMethods(PopupDummyMenu.prototype);
+
+var PopupSubMenu = class extends PopupMenuBase {
+ constructor(sourceActor, sourceArrow) {
+ super(sourceActor);
+
+ this._arrow = sourceArrow;
+
+ // Since a function of a submenu might be to provide a "More.." expander
+ // with long content, we make it scrollable - the scrollbar will only take
+ // effect if a CSS max-height is set on the top menu.
+ this.actor = new St.ScrollView({ style_class: 'popup-sub-menu',
+ hscrollbar_policy: St.PolicyType.NEVER,
+ vscrollbar_policy: St.PolicyType.NEVER });
+
+ this.actor.add_actor(this.box);
+ this.actor._delegate = this;
+ this.actor.clip_to_allocation = true;
+ this.actor.connect('key-press-event', this._onKeyPressEvent.bind(this));
+ this.actor.hide();
+ }
+
+ _needsScrollbar() {
+ let topMenu = this._getTopMenu();
+ let [, topNaturalHeight] = topMenu.actor.get_preferred_height(-1);
+ let topThemeNode = topMenu.actor.get_theme_node();
+
+ let topMaxHeight = topThemeNode.get_max_height();
+ return topMaxHeight >= 0 && topNaturalHeight >= topMaxHeight;
+ }
+
+ getSensitive() {
+ return this._sensitive && this.sourceActor.sensitive;
+ }
+
+ get sensitive() {
+ return this.getSensitive();
+ }
+
+ open(animate) {
+ if (this.isOpen)
+ return;
+
+ if (this.isEmpty())
+ return;
+
+ this.isOpen = true;
+ this.emit('open-state-changed', true);
+
+ this.actor.show();
+
+ let needsScrollbar = this._needsScrollbar();
+
+ // St.ScrollView always requests space horizontally for a possible vertical
+ // scrollbar if in AUTOMATIC mode. Doing better would require implementation
+ // of width-for-height in St.BoxLayout and St.ScrollView. This looks bad
+ // when we *don't* need it, so turn off the scrollbar when that's true.
+ // Dynamic changes in whether we need it aren't handled properly.
+ this.actor.vscrollbar_policy =
+ needsScrollbar ? St.PolicyType.AUTOMATIC : St.PolicyType.NEVER;
+
+ if (needsScrollbar)
+ this.actor.add_style_pseudo_class('scrolled');
+ else
+ this.actor.remove_style_pseudo_class('scrolled');
+
+ // It looks funny if we animate with a scrollbar (at what point is
+ // the scrollbar added?) so just skip that case
+ if (animate && needsScrollbar)
+ animate = false;
+
+ let targetAngle = this.actor.text_direction == Clutter.TextDirection.RTL ? -90 : 90;
+
+ if (animate) {
+ let [, naturalHeight] = this.actor.get_preferred_height(-1);
+ this.actor.height = 0;
+ this.actor.ease({
+ height: naturalHeight,
+ duration: 250,
+ mode: Clutter.AnimationMode.EASE_OUT_EXPO,
+ onComplete: () => this.actor.set_height(-1),
+ });
+ this._arrow.ease({
+ rotation_angle_z: targetAngle,
+ duration: 250,
+ mode: Clutter.AnimationMode.EASE_OUT_EXPO,
+ });
+ } else {
+ this._arrow.rotation_angle_z = targetAngle;
+ }
+ }
+
+ close(animate) {
+ if (!this.isOpen)
+ return;
+
+ this.isOpen = false;
+ this.emit('open-state-changed', false);
+
+ if (this._activeMenuItem)
+ this._activeMenuItem.active = false;
+
+ if (animate && this._needsScrollbar())
+ animate = false;
+
+ if (animate) {
+ this.actor.ease({
+ height: 0,
+ duration: 250,
+ mode: Clutter.AnimationMode.EASE_OUT_EXPO,
+ onComplete: () => {
+ this.actor.hide();
+ this.actor.set_height(-1);
+ },
+ });
+ this._arrow.ease({
+ rotation_angle_z: 0,
+ duration: 250,
+ mode: Clutter.AnimationMode.EASE_OUT_EXPO,
+ });
+ } else {
+ this._arrow.rotation_angle_z = 0;
+ this.actor.hide();
+ }
+ }
+
+ _onKeyPressEvent(actor, event) {
+ // Move focus back to parent menu if the user types Left.
+
+ if (this.isOpen && event.get_key_symbol() == Clutter.KEY_Left) {
+ this.close(BoxPointer.PopupAnimation.FULL);
+ this.sourceActor._delegate.active = true;
+ return Clutter.EVENT_STOP;
+ }
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+};
+
+/**
+ * PopupMenuSection:
+ *
+ * A section of a PopupMenu which is handled like a submenu
+ * (you can add and remove items, you can destroy it, you
+ * can add it to another menu), but is completely transparent
+ * to the user
+ */
+var PopupMenuSection = class extends PopupMenuBase {
+ constructor() {
+ super();
+
+ this.actor = this.box;
+ this.actor._delegate = this;
+ this.isOpen = true;
+ }
+
+ // deliberately ignore any attempt to open() or close(), but emit the
+ // corresponding signal so children can still pick it up
+ open() {
+ this.emit('open-state-changed', true);
+ }
+
+ close() {
+ this.emit('open-state-changed', false);
+ }
+};
+
+var PopupSubMenuMenuItem = GObject.registerClass(
+class PopupSubMenuMenuItem extends PopupBaseMenuItem {
+ _init(text, wantIcon) {
+ super._init();
+
+ this.add_style_class_name('popup-submenu-menu-item');
+
+ if (wantIcon) {
+ this.icon = new St.Icon({ style_class: 'popup-menu-icon' });
+ this.add_child(this.icon);
+ }
+
+ this.label = new St.Label({ text,
+ y_expand: true,
+ y_align: Clutter.ActorAlign.CENTER });
+ this.add_child(this.label);
+ this.label_actor = this.label;
+
+ let expander = new St.Bin({
+ style_class: 'popup-menu-item-expander',
+ x_expand: true,
+ });
+ this.add_child(expander);
+
+ this._triangle = arrowIcon(St.Side.RIGHT);
+ this._triangle.pivot_point = new Graphene.Point({ x: 0.5, y: 0.6 });
+
+ this._triangleBin = new St.Widget({ y_expand: true,
+ y_align: Clutter.ActorAlign.CENTER });
+ this._triangleBin.add_child(this._triangle);
+
+ this.add_child(this._triangleBin);
+ this.add_accessible_state(Atk.StateType.EXPANDABLE);
+
+ this.menu = new PopupSubMenu(this, this._triangle);
+ this.menu.connect('open-state-changed', this._subMenuOpenStateChanged.bind(this));
+ this.connect('destroy', () => this.menu.destroy());
+ }
+
+ _setParent(parent) {
+ super._setParent(parent);
+ this.menu._setParent(parent);
+ }
+
+ syncSensitive() {
+ let sensitive = super.syncSensitive();
+ this._triangle.visible = sensitive;
+ if (!sensitive)
+ this.menu.close(false);
+ }
+
+ _subMenuOpenStateChanged(menu, open) {
+ if (open) {
+ this.add_style_pseudo_class('open');
+ this._getTopMenu()._setOpenedSubMenu(this.menu);
+ this.add_accessible_state(Atk.StateType.EXPANDED);
+ this.add_style_pseudo_class('checked');
+ } else {
+ this.remove_style_pseudo_class('open');
+ this._getTopMenu()._setOpenedSubMenu(null);
+ this.remove_accessible_state(Atk.StateType.EXPANDED);
+ this.remove_style_pseudo_class('checked');
+ }
+ }
+
+ setSubmenuShown(open) {
+ if (open)
+ this.menu.open(BoxPointer.PopupAnimation.FULL);
+ else
+ this.menu.close(BoxPointer.PopupAnimation.FULL);
+ }
+
+ _setOpenState(open) {
+ this.setSubmenuShown(open);
+ }
+
+ _getOpenState() {
+ return this.menu.isOpen;
+ }
+
+ vfunc_key_press_event(keyPressEvent) {
+ let symbol = keyPressEvent.keyval;
+
+ if (symbol == Clutter.KEY_Right) {
+ this._setOpenState(true);
+ this.menu.actor.navigate_focus(null, St.DirectionType.DOWN, false);
+ return Clutter.EVENT_STOP;
+ } else if (symbol == Clutter.KEY_Left && this._getOpenState()) {
+ this._setOpenState(false);
+ return Clutter.EVENT_STOP;
+ }
+
+ return super.vfunc_key_press_event(keyPressEvent);
+ }
+
+ activate(_event) {
+ this._setOpenState(true);
+ }
+
+ vfunc_button_release_event() {
+ // Since we override the parent, we need to manage what the parent does
+ // with the active style class
+ this.remove_style_pseudo_class('active');
+ this._setOpenState(!this._getOpenState());
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_touch_event(touchEvent) {
+ if (touchEvent.type == Clutter.EventType.TOUCH_END) {
+ // Since we override the parent, we need to manage what the parent does
+ // with the active style class
+ this.remove_style_pseudo_class('active');
+ this._setOpenState(!this._getOpenState());
+ }
+ return Clutter.EVENT_PROPAGATE;
+ }
+});
+
+/* Basic implementation of a menu manager.
+ * Call addMenu to add menus
+ */
+var PopupMenuManager = class {
+ constructor(owner, grabParams) {
+ grabParams = Params.parse(grabParams,
+ { actionMode: Shell.ActionMode.POPUP });
+ this._grabHelper = new GrabHelper.GrabHelper(owner, grabParams);
+ this._menus = [];
+ }
+
+ addMenu(menu, position) {
+ if (this._findMenu(menu) > -1)
+ return;
+
+ let menudata = {
+ menu,
+ openStateChangeId: menu.connect('open-state-changed', this._onMenuOpenState.bind(this)),
+ destroyId: menu.connect('destroy', this._onMenuDestroy.bind(this)),
+ enterId: 0,
+ focusInId: 0,
+ };
+
+ let source = menu.sourceActor;
+ if (source) {
+ if (!menu.blockSourceEvents)
+ this._grabHelper.addActor(source);
+ menudata.enterId = source.connect('enter-event',
+ () => this._onMenuSourceEnter(menu));
+ menudata.focusInId = source.connect('key-focus-in', () => {
+ this._onMenuSourceEnter(menu);
+ });
+ }
+
+ if (position == undefined)
+ this._menus.push(menudata);
+ else
+ this._menus.splice(position, 0, menudata);
+ }
+
+ removeMenu(menu) {
+ if (menu == this.activeMenu)
+ this._grabHelper.ungrab({ actor: menu.actor });
+
+ let position = this._findMenu(menu);
+ if (position == -1) // not a menu we manage
+ return;
+
+ let menudata = this._menus[position];
+ menu.disconnect(menudata.openStateChangeId);
+ menu.disconnect(menudata.destroyId);
+
+ if (menudata.enterId)
+ menu.sourceActor.disconnect(menudata.enterId);
+ if (menudata.focusInId)
+ menu.sourceActor.disconnect(menudata.focusInId);
+
+ if (menu.sourceActor)
+ this._grabHelper.removeActor(menu.sourceActor);
+ this._menus.splice(position, 1);
+ }
+
+ get activeMenu() {
+ let firstGrab = this._grabHelper.grabStack[0];
+ if (firstGrab)
+ return firstGrab.actor._delegate;
+ else
+ return null;
+ }
+
+ ignoreRelease() {
+ return this._grabHelper.ignoreRelease();
+ }
+
+ _onMenuOpenState(menu, open) {
+ if (open) {
+ if (this.activeMenu)
+ this.activeMenu.close(BoxPointer.PopupAnimation.FADE);
+ this._grabHelper.grab({
+ actor: menu.actor,
+ focus: menu.focusActor,
+ onUngrab: isUser => this._closeMenu(isUser, menu),
+ });
+ } else {
+ this._grabHelper.ungrab({ actor: menu.actor });
+ }
+ }
+
+ _changeMenu(newMenu) {
+ newMenu.open(this.activeMenu
+ ? BoxPointer.PopupAnimation.FADE
+ : BoxPointer.PopupAnimation.FULL);
+ }
+
+ _onMenuSourceEnter(menu) {
+ if (!this._grabHelper.grabbed)
+ return Clutter.EVENT_PROPAGATE;
+
+ if (this._grabHelper.isActorGrabbed(menu.actor))
+ return Clutter.EVENT_PROPAGATE;
+
+ this._changeMenu(menu);
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _onMenuDestroy(menu) {
+ this.removeMenu(menu);
+ }
+
+ _findMenu(item) {
+ for (let i = 0; i < this._menus.length; i++) {
+ let menudata = this._menus[i];
+ if (item == menudata.menu)
+ return i;
+ }
+ return -1;
+ }
+
+ _closeMenu(isUser, menu) {
+ // If this isn't a user action, we called close()
+ // on the BoxPointer ourselves, so we shouldn't
+ // reanimate.
+ if (isUser)
+ menu.close(BoxPointer.PopupAnimation.FULL);
+ }
+};
diff --git a/js/ui/remoteSearch.js b/js/ui/remoteSearch.js
new file mode 100644
index 0000000..77ad317
--- /dev/null
+++ b/js/ui/remoteSearch.js
@@ -0,0 +1,335 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported loadRemoteSearchProviders */
+
+const { GdkPixbuf, Gio, GLib, Shell, St } = imports.gi;
+
+const FileUtils = imports.misc.fileUtils;
+
+const KEY_FILE_GROUP = 'Shell Search Provider';
+
+const SearchProviderIface = `
+<node>
+<interface name="org.gnome.Shell.SearchProvider">
+<method name="GetInitialResultSet">
+ <arg type="as" direction="in" />
+ <arg type="as" direction="out" />
+</method>
+<method name="GetSubsearchResultSet">
+ <arg type="as" direction="in" />
+ <arg type="as" direction="in" />
+ <arg type="as" direction="out" />
+</method>
+<method name="GetResultMetas">
+ <arg type="as" direction="in" />
+ <arg type="aa{sv}" direction="out" />
+</method>
+<method name="ActivateResult">
+ <arg type="s" direction="in" />
+</method>
+</interface>
+</node>`;
+
+const SearchProvider2Iface = `
+<node>
+<interface name="org.gnome.Shell.SearchProvider2">
+<method name="GetInitialResultSet">
+ <arg type="as" direction="in" />
+ <arg type="as" direction="out" />
+</method>
+<method name="GetSubsearchResultSet">
+ <arg type="as" direction="in" />
+ <arg type="as" direction="in" />
+ <arg type="as" direction="out" />
+</method>
+<method name="GetResultMetas">
+ <arg type="as" direction="in" />
+ <arg type="aa{sv}" direction="out" />
+</method>
+<method name="ActivateResult">
+ <arg type="s" direction="in" />
+ <arg type="as" direction="in" />
+ <arg type="u" direction="in" />
+</method>
+<method name="LaunchSearch">
+ <arg type="as" direction="in" />
+ <arg type="u" direction="in" />
+</method>
+</interface>
+</node>`;
+
+var SearchProviderProxyInfo = Gio.DBusInterfaceInfo.new_for_xml(SearchProviderIface);
+var SearchProvider2ProxyInfo = Gio.DBusInterfaceInfo.new_for_xml(SearchProvider2Iface);
+
+function loadRemoteSearchProviders(searchSettings, callback) {
+ let objectPaths = {};
+ let loadedProviders = [];
+
+ function loadRemoteSearchProvider(file) {
+ let keyfile = new GLib.KeyFile();
+ let path = file.get_path();
+
+ try {
+ keyfile.load_from_file(path, 0);
+ } catch (e) {
+ return;
+ }
+
+ if (!keyfile.has_group(KEY_FILE_GROUP))
+ return;
+
+ let remoteProvider;
+ try {
+ let group = KEY_FILE_GROUP;
+ let busName = keyfile.get_string(group, 'BusName');
+ let objectPath = keyfile.get_string(group, 'ObjectPath');
+
+ if (objectPaths[objectPath])
+ return;
+
+ let appInfo = null;
+ try {
+ let desktopId = keyfile.get_string(group, 'DesktopId');
+ appInfo = Gio.DesktopAppInfo.new(desktopId);
+ if (!appInfo.should_show())
+ return;
+ } catch (e) {
+ log(`Ignoring search provider ${path}: missing DesktopId`);
+ return;
+ }
+
+ let autoStart = true;
+ try {
+ autoStart = keyfile.get_boolean(group, 'AutoStart');
+ } catch (e) {
+ // ignore error
+ }
+
+ let version = '1';
+ try {
+ version = keyfile.get_string(group, 'Version');
+ } catch (e) {
+ // ignore error
+ }
+
+ if (version >= 2)
+ remoteProvider = new RemoteSearchProvider2(appInfo, busName, objectPath, autoStart);
+ else
+ remoteProvider = new RemoteSearchProvider(appInfo, busName, objectPath, autoStart);
+
+ remoteProvider.defaultEnabled = true;
+ try {
+ remoteProvider.defaultEnabled = !keyfile.get_boolean(group, 'DefaultDisabled');
+ } catch (e) {
+ // ignore error
+ }
+
+ objectPaths[objectPath] = remoteProvider;
+ loadedProviders.push(remoteProvider);
+ } catch (e) {
+ log('Failed to add search provider %s: %s'.format(path, e.toString()));
+ }
+ }
+
+ if (searchSettings.get_boolean('disable-external')) {
+ callback([]);
+ return;
+ }
+
+ FileUtils.collectFromDatadirs('search-providers', false, loadRemoteSearchProvider);
+
+ let sortOrder = searchSettings.get_strv('sort-order');
+
+ // Special case gnome-control-center to be always active and always first
+ sortOrder.unshift('gnome-control-center.desktop');
+
+ loadedProviders = loadedProviders.filter(provider => {
+ let appId = provider.appInfo.get_id();
+
+ if (provider.defaultEnabled) {
+ let disabled = searchSettings.get_strv('disabled');
+ return !disabled.includes(appId);
+ } else {
+ let enabled = searchSettings.get_strv('enabled');
+ return enabled.includes(appId);
+ }
+ });
+
+ loadedProviders.sort((providerA, providerB) => {
+ let idxA, idxB;
+ let appIdA, appIdB;
+
+ appIdA = providerA.appInfo.get_id();
+ appIdB = providerB.appInfo.get_id();
+
+ idxA = sortOrder.indexOf(appIdA);
+ idxB = sortOrder.indexOf(appIdB);
+
+ // if no provider is found in the order, use alphabetical order
+ if ((idxA == -1) && (idxB == -1)) {
+ let nameA = providerA.appInfo.get_name();
+ let nameB = providerB.appInfo.get_name();
+
+ return GLib.utf8_collate(nameA, nameB);
+ }
+
+ // if providerA isn't found, it's sorted after providerB
+ if (idxA == -1)
+ return 1;
+
+ // if providerB isn't found, it's sorted after providerA
+ if (idxB == -1)
+ return -1;
+
+ // finally, if both providers are found, return their order in the list
+ return idxA - idxB;
+ });
+
+ callback(loadedProviders);
+}
+
+var RemoteSearchProvider = class {
+ constructor(appInfo, dbusName, dbusPath, autoStart, proxyInfo) {
+ if (!proxyInfo)
+ proxyInfo = SearchProviderProxyInfo;
+
+ let gFlags = Gio.DBusProxyFlags.DO_NOT_LOAD_PROPERTIES;
+ if (autoStart)
+ gFlags |= Gio.DBusProxyFlags.DO_NOT_AUTO_START_AT_CONSTRUCTION;
+ else
+ gFlags |= Gio.DBusProxyFlags.DO_NOT_AUTO_START;
+
+ this.proxy = new Gio.DBusProxy({ g_bus_type: Gio.BusType.SESSION,
+ g_name: dbusName,
+ g_object_path: dbusPath,
+ g_interface_info: proxyInfo,
+ g_interface_name: proxyInfo.name,
+ gFlags });
+ this.proxy.init_async(GLib.PRIORITY_DEFAULT, null);
+
+ this.appInfo = appInfo;
+ this.id = appInfo.get_id();
+ this.isRemoteProvider = true;
+ this.canLaunchSearch = false;
+ }
+
+ createIcon(size, meta) {
+ let gicon = null;
+ let icon = null;
+
+ if (meta['icon']) {
+ gicon = Gio.icon_deserialize(meta['icon']);
+ } else if (meta['gicon']) {
+ gicon = Gio.icon_new_for_string(meta['gicon']);
+ } else if (meta['icon-data']) {
+ let [width, height, rowStride, hasAlpha,
+ bitsPerSample, nChannels_, data] = meta['icon-data'];
+ gicon = Shell.util_create_pixbuf_from_data(data, GdkPixbuf.Colorspace.RGB, hasAlpha,
+ bitsPerSample, width, height, rowStride);
+ }
+
+ if (gicon)
+ icon = new St.Icon({ gicon, icon_size: size });
+ return icon;
+ }
+
+ filterResults(results, maxNumber) {
+ if (results.length <= maxNumber)
+ return results;
+
+ let regularResults = results.filter(r => !r.startsWith('special:'));
+ let specialResults = results.filter(r => r.startsWith('special:'));
+
+ return regularResults.slice(0, maxNumber).concat(specialResults.slice(0, maxNumber));
+ }
+
+ _getResultsFinished(results, error, callback) {
+ if (error) {
+ if (error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
+ return;
+
+ log('Received error from D-Bus search provider %s: %s'.format(this.id, String(error)));
+ callback([]);
+ return;
+ }
+
+ callback(results[0]);
+ }
+
+ getInitialResultSet(terms, callback, cancellable) {
+ this.proxy.GetInitialResultSetRemote(terms,
+ (results, error) => {
+ this._getResultsFinished(results, error, callback);
+ },
+ cancellable);
+ }
+
+ getSubsearchResultSet(previousResults, newTerms, callback, cancellable) {
+ this.proxy.GetSubsearchResultSetRemote(previousResults, newTerms,
+ (results, error) => {
+ this._getResultsFinished(results, error, callback);
+ },
+ cancellable);
+ }
+
+ _getResultMetasFinished(results, error, callback) {
+ if (error) {
+ if (!error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
+ log('Received error from D-Bus search provider %s during GetResultMetas: %s'.format(this.id, String(error)));
+ callback([]);
+ return;
+ }
+ let metas = results[0];
+ let resultMetas = [];
+ for (let i = 0; i < metas.length; i++) {
+ for (let prop in metas[i]) {
+ // we can use the serialized icon variant directly
+ if (prop != 'icon')
+ metas[i][prop] = metas[i][prop].deep_unpack();
+ }
+
+ resultMetas.push({ id: metas[i]['id'],
+ name: metas[i]['name'],
+ description: metas[i]['description'],
+ createIcon: size => {
+ return this.createIcon(size, metas[i]);
+ },
+ clipboardText: metas[i]['clipboardText'] });
+ }
+ callback(resultMetas);
+ }
+
+ getResultMetas(ids, callback, cancellable) {
+ this.proxy.GetResultMetasRemote(ids,
+ (results, error) => {
+ this._getResultMetasFinished(results, error, callback);
+ },
+ cancellable);
+ }
+
+ activateResult(id) {
+ this.proxy.ActivateResultRemote(id);
+ }
+
+ launchSearch(_terms) {
+ // the provider is not compatible with the new version of the interface, launch
+ // the app itself but warn so we can catch the error in logs
+ log(`Search provider ${this.appInfo.get_id()} does not implement LaunchSearch`);
+ this.appInfo.launch([], global.create_app_launch_context(0, -1));
+ }
+};
+
+var RemoteSearchProvider2 = class extends RemoteSearchProvider {
+ constructor(appInfo, dbusName, dbusPath, autoStart) {
+ super(appInfo, dbusName, dbusPath, autoStart, SearchProvider2ProxyInfo);
+
+ this.canLaunchSearch = true;
+ }
+
+ activateResult(id, terms) {
+ this.proxy.ActivateResultRemote(id, terms, global.get_current_time());
+ }
+
+ launchSearch(terms) {
+ this.proxy.LaunchSearchRemote(terms, global.get_current_time());
+ }
+};
diff --git a/js/ui/ripples.js b/js/ui/ripples.js
new file mode 100644
index 0000000..f380622
--- /dev/null
+++ b/js/ui/ripples.js
@@ -0,0 +1,104 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Ripples */
+
+const { Clutter, St } = imports.gi;
+
+// Shamelessly copied from the layout "hotcorner" ripples implementation
+var Ripples = class Ripples {
+ constructor(px, py, styleClass) {
+ this._x = 0;
+ this._y = 0;
+
+ this._px = px;
+ this._py = py;
+
+ this._ripple1 = new St.BoxLayout({ style_class: styleClass,
+ opacity: 0,
+ can_focus: false,
+ reactive: false,
+ visible: false });
+ this._ripple1.set_pivot_point(px, py);
+
+ this._ripple2 = new St.BoxLayout({ style_class: styleClass,
+ opacity: 0,
+ can_focus: false,
+ reactive: false,
+ visible: false });
+ this._ripple2.set_pivot_point(px, py);
+
+ this._ripple3 = new St.BoxLayout({ style_class: styleClass,
+ opacity: 0,
+ can_focus: false,
+ reactive: false,
+ visible: false });
+ this._ripple3.set_pivot_point(px, py);
+ }
+
+ destroy() {
+ this._ripple1.destroy();
+ this._ripple2.destroy();
+ this._ripple3.destroy();
+ }
+
+ _animRipple(ripple, delay, duration, startScale, startOpacity, finalScale) {
+ // We draw a ripple by using a source image and animating it scaling
+ // outwards and fading away. We want the ripples to move linearly
+ // or it looks unrealistic, but if the opacity of the ripple goes
+ // linearly to zero it fades away too quickly, so we use a separate
+ // tween to give a non-linear curve to the fade-away and make
+ // it more visible in the middle section.
+
+ ripple.x = this._x;
+ ripple.y = this._y;
+ ripple.visible = true;
+ ripple.opacity = 255 * Math.sqrt(startOpacity);
+ ripple.scale_x = ripple.scale_y = startScale;
+ ripple.set_translation(-this._px * ripple.width, -this._py * ripple.height, 0.0);
+
+ ripple.ease({
+ opacity: 0,
+ delay,
+ duration,
+ mode: Clutter.AnimationMode.EASE_IN_QUAD,
+ });
+ ripple.ease({
+ scale_x: finalScale,
+ scale_y: finalScale,
+ delay,
+ duration,
+ mode: Clutter.AnimationMode.LINEAR,
+ onComplete: () => (ripple.visible = false),
+ });
+ }
+
+ addTo(stage) {
+ if (this._stage !== undefined)
+ throw new Error('Ripples already added');
+
+ this._stage = stage;
+ this._stage.add_actor(this._ripple1);
+ this._stage.add_actor(this._ripple2);
+ this._stage.add_actor(this._ripple3);
+ }
+
+ playAnimation(x, y) {
+ if (this._stage === undefined)
+ throw new Error('Ripples not added');
+
+ this._x = x;
+ this._y = y;
+
+ this._stage.set_child_above_sibling(this._ripple1, null);
+ this._stage.set_child_above_sibling(this._ripple2, this._ripple1);
+ this._stage.set_child_above_sibling(this._ripple3, this._ripple2);
+
+ // Show three concentric ripples expanding outwards; the exact
+ // parameters were found by trial and error, so don't look
+ // for them to make perfect sense mathematically
+
+ // delay time scale opacity => scale
+ this._animRipple(this._ripple1, 0, 830, 0.25, 1.0, 1.5);
+ this._animRipple(this._ripple2, 50, 1000, 0.0, 0.7, 1.25);
+ this._animRipple(this._ripple3, 350, 1000, 0.0, 0.3, 1);
+ }
+};
diff --git a/js/ui/runDialog.js b/js/ui/runDialog.js
new file mode 100644
index 0000000..356a974
--- /dev/null
+++ b/js/ui/runDialog.js
@@ -0,0 +1,256 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported RunDialog */
+
+const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi;
+
+const Dialog = imports.ui.dialog;
+const Main = imports.ui.main;
+const ModalDialog = imports.ui.modalDialog;
+const ShellEntry = imports.ui.shellEntry;
+const Util = imports.misc.util;
+const History = imports.misc.history;
+
+const HISTORY_KEY = 'command-history';
+
+const LOCKDOWN_SCHEMA = 'org.gnome.desktop.lockdown';
+const DISABLE_COMMAND_LINE_KEY = 'disable-command-line';
+
+const TERMINAL_SCHEMA = 'org.gnome.desktop.default-applications.terminal';
+const EXEC_KEY = 'exec';
+const EXEC_ARG_KEY = 'exec-arg';
+
+var RunDialog = GObject.registerClass(
+class RunDialog extends ModalDialog.ModalDialog {
+ _init() {
+ super._init({
+ styleClass: 'run-dialog',
+ destroyOnClose: false,
+ });
+
+ this._lockdownSettings = new Gio.Settings({ schema_id: LOCKDOWN_SCHEMA });
+ this._terminalSettings = new Gio.Settings({ schema_id: TERMINAL_SCHEMA });
+ global.settings.connect('changed::development-tools', () => {
+ this._enableInternalCommands = global.settings.get_boolean('development-tools');
+ });
+ this._enableInternalCommands = global.settings.get_boolean('development-tools');
+
+ this._internalCommands = {
+ 'lg': () => Main.createLookingGlass().open(),
+
+ 'r': this._restart.bind(this),
+
+ // Developer brain backwards compatibility
+ 'restart': this._restart.bind(this),
+
+ 'debugexit': () => Meta.quit(Meta.ExitCode.ERROR),
+
+ // rt is short for "reload theme"
+ 'rt': () => {
+ Main.reloadThemeResource();
+ Main.loadTheme();
+ },
+
+ 'check_cloexec_fds': () => {
+ Shell.util_check_cloexec_fds();
+ },
+ };
+
+ let title = _('Run a Command');
+
+ let content = new Dialog.MessageDialogContent({ title });
+ this.contentLayout.add_actor(content);
+
+ let entry = new St.Entry({
+ style_class: 'run-dialog-entry',
+ can_focus: true,
+ });
+ ShellEntry.addContextMenu(entry);
+
+ this._entryText = entry.clutter_text;
+ content.add_child(entry);
+ this.setInitialKeyFocus(this._entryText);
+
+ let defaultDescriptionText = _('Press ESC to close');
+
+ this._descriptionLabel = new St.Label({
+ style_class: 'run-dialog-description',
+ text: defaultDescriptionText,
+ });
+ content.add_child(this._descriptionLabel);
+
+ this._commandError = false;
+
+ this._pathCompleter = new Gio.FilenameCompleter();
+
+ this._history = new History.HistoryManager({
+ gsettingsKey: HISTORY_KEY,
+ entry: this._entryText,
+ });
+ this._entryText.connect('activate', o => {
+ this.popModal();
+ this._run(o.get_text(),
+ Clutter.get_current_event().get_state() & Clutter.ModifierType.CONTROL_MASK);
+ if (!this._commandError ||
+ !this.pushModal())
+ this.close();
+ });
+ this._entryText.connect('key-press-event', (o, e) => {
+ let symbol = e.get_key_symbol();
+ if (symbol === Clutter.KEY_Tab) {
+ let text = o.get_text();
+ let prefix;
+ if (text.lastIndexOf(' ') == -1)
+ prefix = text;
+ else
+ prefix = text.substr(text.lastIndexOf(' ') + 1);
+ let postfix = this._getCompletion(prefix);
+ if (postfix != null && postfix.length > 0) {
+ o.insert_text(postfix, -1);
+ o.set_cursor_position(text.length + postfix.length);
+ }
+ return Clutter.EVENT_STOP;
+ }
+ return Clutter.EVENT_PROPAGATE;
+ });
+ this._entryText.connect('text-changed', () => {
+ this._descriptionLabel.set_text(defaultDescriptionText);
+ });
+ }
+
+ vfunc_key_release_event(event) {
+ if (event.keyval === Clutter.KEY_Escape) {
+ this.close();
+ return Clutter.EVENT_STOP;
+ }
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _getCommandCompletion(text) {
+ function _getCommon(s1, s2) {
+ if (s1 == null)
+ return s2;
+
+ let k = 0;
+ for (; k < s1.length && k < s2.length; k++) {
+ if (s1[k] != s2[k])
+ break;
+ }
+ if (k == 0)
+ return '';
+ return s1.substr(0, k);
+ }
+
+ let paths = GLib.getenv('PATH').split(':');
+ paths.push(GLib.get_home_dir());
+ let someResults = paths.map(path => {
+ let results = [];
+ try {
+ let file = Gio.File.new_for_path(path);
+ let fileEnum = file.enumerate_children('standard::name', Gio.FileQueryInfoFlags.NONE, null);
+ let info;
+ while ((info = fileEnum.next_file(null))) {
+ let name = info.get_name();
+ if (name.slice(0, text.length) == text)
+ results.push(name);
+ }
+ } catch (e) {
+ if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND) &&
+ !e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_DIRECTORY))
+ log(e);
+ }
+ return results;
+ });
+ let results = someResults.reduce((a, b) => a.concat(b), []);
+
+ if (!results.length)
+ return null;
+
+ let common = results.reduce(_getCommon, null);
+ return common.substr(text.length);
+ }
+
+ _getCompletion(text) {
+ if (text.includes('/'))
+ return this._pathCompleter.get_completion_suffix(text);
+ else
+ return this._getCommandCompletion(text);
+ }
+
+ _run(input, inTerminal) {
+ let command = input;
+
+ this._history.addItem(input);
+ this._commandError = false;
+ let f;
+ if (this._enableInternalCommands)
+ f = this._internalCommands[input];
+ else
+ f = null;
+ if (f) {
+ f();
+ } else if (input) {
+ try {
+ if (inTerminal) {
+ let exec = this._terminalSettings.get_string(EXEC_KEY);
+ let execArg = this._terminalSettings.get_string(EXEC_ARG_KEY);
+ command = '%s %s %s'.format(exec, execArg, input);
+ }
+ Util.trySpawnCommandLine(command);
+ } catch (e) {
+ // Mmmh, that failed - see if @input matches an existing file
+ let path = null;
+ if (input.charAt(0) == '/') {
+ path = input;
+ } else {
+ if (input.charAt(0) == '~')
+ input = input.slice(1);
+ path = '%s/%s'.format(GLib.get_home_dir(), input);
+ }
+
+ if (GLib.file_test(path, GLib.FileTest.EXISTS)) {
+ let file = Gio.file_new_for_path(path);
+ try {
+ Gio.app_info_launch_default_for_uri(file.get_uri(),
+ global.create_app_launch_context(0, -1));
+ } catch (err) {
+ // The exception from gjs contains an error string like:
+ // Error invoking Gio.app_info_launch_default_for_uri: No application
+ // is registered as handling this file
+ // We are only interested in the part after the first colon.
+ let message = err.message.replace(/[^:]*: *(.+)/, '$1');
+ this._showError(message);
+ }
+ } else {
+ this._showError(e.message);
+ }
+ }
+ }
+ }
+
+ _showError(message) {
+ this._commandError = true;
+ this._descriptionLabel.set_text(message);
+ }
+
+ _restart() {
+ if (Meta.is_wayland_compositor()) {
+ this._showError(_("Restart is not available on Wayland"));
+ return;
+ }
+ this._shouldFadeOut = false;
+ this.close();
+ Meta.restart(_("Restarting…"));
+ }
+
+ open() {
+ this._history.lastItem();
+ this._entryText.set_text('');
+ this._commandError = false;
+
+ if (this._lockdownSettings.get_boolean(DISABLE_COMMAND_LINE_KEY))
+ return;
+
+ super.open();
+ }
+});
diff --git a/js/ui/screenShield.js b/js/ui/screenShield.js
new file mode 100644
index 0000000..fa3a7a8
--- /dev/null
+++ b/js/ui/screenShield.js
@@ -0,0 +1,650 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+
+const { AccountsService, Clutter, Gio,
+ GLib, Graphene, Meta, Shell, St } = imports.gi;
+const Signals = imports.signals;
+
+const GnomeSession = imports.misc.gnomeSession;
+const OVirt = imports.gdm.oVirt;
+const LoginManager = imports.misc.loginManager;
+const Lightbox = imports.ui.lightbox;
+const Main = imports.ui.main;
+const Overview = imports.ui.overview;
+const MessageTray = imports.ui.messageTray;
+const ShellDBus = imports.ui.shellDBus;
+const SmartcardManager = imports.misc.smartcardManager;
+
+const { adjustAnimationTime } = imports.ui.environment;
+
+const SCREENSAVER_SCHEMA = 'org.gnome.desktop.screensaver';
+const LOCK_ENABLED_KEY = 'lock-enabled';
+const LOCK_DELAY_KEY = 'lock-delay';
+
+const LOCKDOWN_SCHEMA = 'org.gnome.desktop.lockdown';
+const DISABLE_LOCK_KEY = 'disable-lock-screen';
+
+const LOCKED_STATE_STR = 'screenShield.locked';
+
+// ScreenShield animation time
+// - STANDARD_FADE_TIME is used when the session goes idle
+// - MANUAL_FADE_TIME is used for lowering the shield when asked by the user,
+// or when cancelling the dialog
+// - CURTAIN_SLIDE_TIME is used when raising the shield before unlocking
+var STANDARD_FADE_TIME = 10000;
+var MANUAL_FADE_TIME = 300;
+var CURTAIN_SLIDE_TIME = 300;
+
+/**
+ * If you are setting org.gnome.desktop.session.idle-delay directly in dconf,
+ * rather than through System Settings, you also need to set
+ * org.gnome.settings-daemon.plugins.power.sleep-display-ac and
+ * org.gnome.settings-daemon.plugins.power.sleep-display-battery to the same value.
+ * This will ensure that the screen blanks at the right time when it fades out.
+ * https://bugzilla.gnome.org/show_bug.cgi?id=668703 explains the dependency.
+ */
+var ScreenShield = class {
+ constructor() {
+ this.actor = Main.layoutManager.screenShieldGroup;
+
+ this._lockScreenState = MessageTray.State.HIDDEN;
+ this._lockScreenGroup = new St.Widget({
+ x_expand: true,
+ y_expand: true,
+ reactive: true,
+ can_focus: true,
+ name: 'lockScreenGroup',
+ visible: false,
+ });
+
+ this._lockDialogGroup = new St.Widget({
+ x_expand: true,
+ y_expand: true,
+ reactive: true,
+ can_focus: true,
+ pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }),
+ name: 'lockDialogGroup',
+ });
+
+ this.actor.add_actor(this._lockScreenGroup);
+ this.actor.add_actor(this._lockDialogGroup);
+
+ this._presence = new GnomeSession.Presence((proxy, error) => {
+ if (error) {
+ logError(error, 'Error while reading gnome-session presence');
+ return;
+ }
+
+ this._onStatusChanged(proxy.status);
+ });
+ this._presence.connectSignal('StatusChanged', (proxy, senderName, [status]) => {
+ this._onStatusChanged(status);
+ });
+
+ this._screenSaverDBus = new ShellDBus.ScreenSaverDBus(this);
+
+ this._smartcardManager = SmartcardManager.getSmartcardManager();
+ this._smartcardManager.connect('smartcard-inserted',
+ (manager, token) => {
+ if (this._isLocked && token.UsedToLogin)
+ this._activateDialog();
+ });
+
+ this._oVirtCredentialsManager = OVirt.getOVirtCredentialsManager();
+ this._oVirtCredentialsManager.connect('user-authenticated',
+ () => {
+ if (this._isLocked)
+ this._activateDialog();
+ });
+
+ this._loginManager = LoginManager.getLoginManager();
+ this._loginManager.connect('prepare-for-sleep',
+ this._prepareForSleep.bind(this));
+
+ this._loginSession = null;
+ this._loginManager.getCurrentSessionProxy(sessionProxy => {
+ this._loginSession = sessionProxy;
+ this._loginSession.connectSignal('Lock',
+ () => this.lock(false));
+ this._loginSession.connectSignal('Unlock',
+ () => this.deactivate(false));
+ this._loginSession.connect('g-properties-changed', this._syncInhibitor.bind(this));
+ this._syncInhibitor();
+ });
+
+ this._settings = new Gio.Settings({ schema_id: SCREENSAVER_SCHEMA });
+ this._settings.connect('changed::%s'.format(LOCK_ENABLED_KEY), this._syncInhibitor.bind(this));
+
+ this._lockSettings = new Gio.Settings({ schema_id: LOCKDOWN_SCHEMA });
+ this._lockSettings.connect('changed::%s'.format(DISABLE_LOCK_KEY), this._syncInhibitor.bind(this));
+
+ this._isModal = false;
+ this._isGreeter = false;
+ this._isActive = false;
+ this._isLocked = false;
+ this._inUnlockAnimation = false;
+ this._activationTime = 0;
+ this._becameActiveId = 0;
+ this._lockTimeoutId = 0;
+
+ // The "long" lightbox is used for the longer (20 seconds) fade from session
+ // to idle status, the "short" is used for quickly fading to black when locking
+ // manually
+ this._longLightbox = new Lightbox.Lightbox(Main.uiGroup,
+ { inhibitEvents: true,
+ fadeFactor: 1 });
+ this._longLightbox.connect('notify::active', this._onLongLightbox.bind(this));
+ this._shortLightbox = new Lightbox.Lightbox(Main.uiGroup,
+ { inhibitEvents: true,
+ fadeFactor: 1 });
+ this._shortLightbox.connect('notify::active', this._onShortLightbox.bind(this));
+
+ this.idleMonitor = Meta.IdleMonitor.get_core();
+ this._cursorTracker = Meta.CursorTracker.get_for_display(global.display);
+
+ this._syncInhibitor();
+ }
+
+ _setActive(active) {
+ let prevIsActive = this._isActive;
+ this._isActive = active;
+
+ if (prevIsActive != this._isActive)
+ this.emit('active-changed');
+
+ if (this._loginSession)
+ this._loginSession.SetLockedHintRemote(active);
+
+ this._syncInhibitor();
+ }
+
+ _activateDialog() {
+ if (this._isLocked) {
+ this._ensureUnlockDialog(true /* allowCancel */);
+ this._dialog.activate();
+ } else {
+ this.deactivate(true /* animate */);
+ }
+ }
+
+ _maybeCancelDialog() {
+ if (!this._dialog)
+ return;
+
+ this._dialog.cancel();
+ if (this._isGreeter) {
+ // LoginDialog.cancel() will grab the key focus
+ // on its own, so ensure it stays on lock screen
+ // instead
+ this._dialog.grab_key_focus();
+ }
+ }
+
+ _becomeModal() {
+ if (this._isModal)
+ return true;
+
+ this._isModal = Main.pushModal(this.actor, { actionMode: Shell.ActionMode.LOCK_SCREEN });
+ if (this._isModal)
+ return true;
+
+ // We failed to get a pointer grab, it means that
+ // something else has it. Try with a keyboard grab only
+ this._isModal = Main.pushModal(this.actor, { options: Meta.ModalOptions.POINTER_ALREADY_GRABBED,
+ actionMode: Shell.ActionMode.LOCK_SCREEN });
+ return this._isModal;
+ }
+
+ _syncInhibitor() {
+ let lockEnabled = this._settings.get_boolean(LOCK_ENABLED_KEY);
+ let lockLocked = this._lockSettings.get_boolean(DISABLE_LOCK_KEY);
+ let inhibit = this._loginSession && this._loginSession.Active &&
+ !this._isActive && lockEnabled && !lockLocked && Main.sessionMode.unlockDialog;
+ if (inhibit) {
+ this._loginManager.inhibit(_("GNOME needs to lock the screen"),
+ inhibitor => {
+ if (this._inhibitor)
+ this._inhibitor.close(null);
+ this._inhibitor = inhibitor;
+ });
+ } else {
+ if (this._inhibitor)
+ this._inhibitor.close(null);
+ this._inhibitor = null;
+ }
+ }
+
+ _prepareForSleep(loginManager, aboutToSuspend) {
+ if (aboutToSuspend) {
+ if (this._settings.get_boolean(LOCK_ENABLED_KEY))
+ this.lock(true);
+ } else {
+ this._wakeUpScreen();
+ }
+ }
+
+ _onStatusChanged(status) {
+ if (status != GnomeSession.PresenceStatus.IDLE)
+ return;
+
+ this._maybeCancelDialog();
+
+ if (this._longLightbox.visible) {
+ // We're in the process of showing.
+ return;
+ }
+
+ if (!this._becomeModal()) {
+ // We could not become modal, so we can't activate the
+ // screenshield. The user is probably very upset at this
+ // point, but any application using global grabs is broken
+ // Just tell him to stop using this app
+ //
+ // XXX: another option is to kick the user into the gdm login
+ // screen, where we're not affected by grabs
+ Main.notifyError(_("Unable to lock"),
+ _("Lock was blocked by an application"));
+ return;
+ }
+
+ if (this._activationTime == 0)
+ this._activationTime = GLib.get_monotonic_time();
+
+ let shouldLock = this._settings.get_boolean(LOCK_ENABLED_KEY) && !this._isLocked;
+
+ if (shouldLock) {
+ let lockTimeout = Math.max(
+ adjustAnimationTime(STANDARD_FADE_TIME),
+ this._settings.get_uint(LOCK_DELAY_KEY) * 1000);
+ this._lockTimeoutId = GLib.timeout_add(
+ GLib.PRIORITY_DEFAULT,
+ lockTimeout,
+ () => {
+ this._lockTimeoutId = 0;
+ this.lock(false);
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._lockTimeoutId, '[gnome-shell] this.lock');
+ }
+
+ this._activateFade(this._longLightbox, STANDARD_FADE_TIME);
+ }
+
+ _activateFade(lightbox, time) {
+ Main.uiGroup.set_child_above_sibling(lightbox, null);
+ lightbox.lightOn(time);
+
+ if (this._becameActiveId == 0)
+ this._becameActiveId = this.idleMonitor.add_user_active_watch(this._onUserBecameActive.bind(this));
+ }
+
+ _onUserBecameActive() {
+ // This function gets called here when the user becomes active
+ // after we activated a lightbox
+ // There are two possibilities here:
+ // - we're called when already locked; we just go back to the lock screen curtain
+ // - we're called because the session is IDLE but before the lightbox
+ // is fully shown; at this point isActive is false, so we just hide
+ // the lightbox, reset the activationTime and go back to the unlocked
+ // desktop
+ // using deactivate() is a little of overkill, but it ensures we
+ // don't forget of some bit like modal, DBus properties or idle watches
+ //
+ // Note: if the (long) lightbox is shown then we're necessarily
+ // active, because we call activate() without animation.
+
+ this.idleMonitor.remove_watch(this._becameActiveId);
+ this._becameActiveId = 0;
+
+ if (this._isLocked) {
+ this._longLightbox.lightOff();
+ this._shortLightbox.lightOff();
+ } else {
+ this.deactivate(false);
+ }
+ }
+
+ _onLongLightbox(lightBox) {
+ if (lightBox.active)
+ this.activate(false);
+ }
+
+ _onShortLightbox(lightBox) {
+ if (lightBox.active)
+ this._completeLockScreenShown();
+ }
+
+ showDialog() {
+ if (!this._becomeModal()) {
+ // In the login screen, this is a hard error. Fail-whale
+ log('Could not acquire modal grab for the login screen. Aborting login process.');
+ Meta.quit(Meta.ExitCode.ERROR);
+ }
+
+ this.actor.show();
+ this._isGreeter = Main.sessionMode.isGreeter;
+ this._isLocked = true;
+ this._ensureUnlockDialog(true);
+ }
+
+ _hideLockScreenComplete() {
+ this._lockScreenState = MessageTray.State.HIDDEN;
+ this._lockScreenGroup.hide();
+
+ if (this._dialog) {
+ this._dialog.grab_key_focus();
+ this._dialog.navigate_focus(null, St.DirectionType.TAB_FORWARD, false);
+ }
+ }
+
+ _showPointer() {
+ this._cursorTracker.set_pointer_visible(true);
+
+ if (this._motionId) {
+ global.stage.disconnect(this._motionId);
+ this._motionId = 0;
+ }
+ }
+
+ _hidePointerUntilMotion() {
+ this._motionId = global.stage.connect('captured-event', (stage, event) => {
+ if (event.type() === Clutter.EventType.MOTION)
+ this._showPointer();
+
+ return Clutter.EVENT_PROPAGATE;
+ });
+ this._cursorTracker.set_pointer_visible(false);
+ }
+
+ _hideLockScreen(animate) {
+ if (this._lockScreenState == MessageTray.State.HIDDEN)
+ return;
+
+ this._lockScreenState = MessageTray.State.HIDING;
+
+ this._lockDialogGroup.remove_all_transitions();
+
+ if (animate) {
+ // Animate the lock screen out of screen
+ // if velocity is not specified (i.e. we come here from pressing ESC),
+ // use the same speed regardless of original position
+ // if velocity is specified, it's in pixels per milliseconds
+ let h = global.stage.height;
+ let delta = h + this._lockDialogGroup.translation_y;
+ let velocity = global.stage.height / CURTAIN_SLIDE_TIME;
+ let duration = delta / velocity;
+
+ this._lockDialogGroup.ease({
+ translation_y: -h,
+ duration,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this._hideLockScreenComplete(),
+ });
+ } else {
+ this._hideLockScreenComplete();
+ }
+
+ this._showPointer();
+ }
+
+ _ensureUnlockDialog(allowCancel) {
+ if (!this._dialog) {
+ let constructor = Main.sessionMode.unlockDialog;
+ if (!constructor) {
+ // This session mode has no locking capabilities
+ this.deactivate(true);
+ return false;
+ }
+
+ this._dialog = new constructor(this._lockDialogGroup);
+
+ let time = global.get_current_time();
+ if (!this._dialog.open(time)) {
+ // This is kind of an impossible error: we're already modal
+ // by the time we reach this...
+ log('Could not open login dialog: failed to acquire grab');
+ this.deactivate(true);
+ return false;
+ }
+
+ this._dialog.connect('failed', this._onUnlockFailed.bind(this));
+ this._wakeUpScreenId = this._dialog.connect(
+ 'wake-up-screen', this._wakeUpScreen.bind(this));
+ }
+
+ this._dialog.allowCancel = allowCancel;
+ this._dialog.grab_key_focus();
+ return true;
+ }
+
+ _onUnlockFailed() {
+ this._resetLockScreen({ animateLockScreen: true,
+ fadeToBlack: false });
+ }
+
+ _resetLockScreen(params) {
+ // Don't reset the lock screen unless it is completely hidden
+ // This prevents the shield going down if the lock-delay timeout
+ // fires while the user is dragging (which has the potential
+ // to confuse our state)
+ if (this._lockScreenState != MessageTray.State.HIDDEN)
+ return;
+
+ this._lockScreenGroup.show();
+ this._lockScreenState = MessageTray.State.SHOWING;
+
+ let fadeToBlack = params.fadeToBlack;
+
+ if (params.animateLockScreen) {
+ this._lockDialogGroup.translation_y = -global.screen_height;
+ this._lockDialogGroup.remove_all_transitions();
+ this._lockDialogGroup.ease({
+ translation_y: 0,
+ duration: Overview.ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ this._lockScreenShown({ fadeToBlack, animateFade: true });
+ },
+ });
+ } else {
+ this._lockDialogGroup.translation_y = 0;
+ this._lockScreenShown({ fadeToBlack, animateFade: false });
+ }
+
+ this._dialog.grab_key_focus();
+ }
+
+ _lockScreenShown(params) {
+ this._hidePointerUntilMotion();
+
+ this._lockScreenState = MessageTray.State.SHOWN;
+
+ if (params.fadeToBlack && params.animateFade) {
+ // Take a beat
+
+ let id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, MANUAL_FADE_TIME, () => {
+ this._activateFade(this._shortLightbox, MANUAL_FADE_TIME);
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(id, '[gnome-shell] this._activateFade');
+ } else {
+ if (params.fadeToBlack)
+ this._activateFade(this._shortLightbox, 0);
+
+ this._completeLockScreenShown();
+ }
+ }
+
+ _completeLockScreenShown() {
+ this._setActive(true);
+ this.emit('lock-screen-shown');
+ }
+
+ _wakeUpScreen() {
+ this._onUserBecameActive();
+ this.emit('wake-up-screen');
+ }
+
+ get locked() {
+ return this._isLocked;
+ }
+
+ get active() {
+ return this._isActive;
+ }
+
+ get activationTime() {
+ return this._activationTime;
+ }
+
+ deactivate(animate) {
+ if (this._dialog)
+ this._dialog.finish(() => this._continueDeactivate(animate));
+ else
+ this._continueDeactivate(animate);
+ }
+
+ _continueDeactivate(animate) {
+ this._hideLockScreen(animate);
+
+ if (Main.sessionMode.currentMode == 'unlock-dialog')
+ Main.sessionMode.popMode('unlock-dialog');
+
+ this.emit('wake-up-screen');
+
+ if (this._isGreeter) {
+ // We don't want to "deactivate" any more than
+ // this. In particular, we don't want to drop
+ // the modal, hide ourselves or destroy the dialog
+ // But we do want to set isActive to false, so that
+ // gnome-session will reset the idle counter, and
+ // gnome-settings-daemon will stop blanking the screen
+
+ this._activationTime = 0;
+ this._setActive(false);
+ return;
+ }
+
+ if (this._dialog && !this._isGreeter)
+ this._dialog.popModal();
+
+ if (this._isModal) {
+ Main.popModal(this.actor);
+ this._isModal = false;
+ }
+
+ this._longLightbox.lightOff();
+ this._shortLightbox.lightOff();
+
+ this._lockDialogGroup.ease({
+ translation_y: -global.screen_height,
+ duration: Overview.ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this._completeDeactivate(),
+ });
+ }
+
+ _completeDeactivate() {
+ if (this._dialog) {
+ this._dialog.destroy();
+ this._dialog = null;
+ }
+
+ this.actor.hide();
+
+ if (this._becameActiveId != 0) {
+ this.idleMonitor.remove_watch(this._becameActiveId);
+ this._becameActiveId = 0;
+ }
+
+ if (this._lockTimeoutId != 0) {
+ GLib.source_remove(this._lockTimeoutId);
+ this._lockTimeoutId = 0;
+ }
+
+ this._activationTime = 0;
+ this._setActive(false);
+ this._isLocked = false;
+ this.emit('locked-changed');
+ global.set_runtime_state(LOCKED_STATE_STR, null);
+ }
+
+ activate(animate) {
+ if (this._activationTime == 0)
+ this._activationTime = GLib.get_monotonic_time();
+
+ if (!this._ensureUnlockDialog(true))
+ return;
+
+ this.actor.show();
+
+ if (Main.sessionMode.currentMode !== 'unlock-dialog') {
+ this._isGreeter = Main.sessionMode.isGreeter;
+ if (!this._isGreeter)
+ Main.sessionMode.pushMode('unlock-dialog');
+ }
+
+ this._resetLockScreen({ animateLockScreen: animate,
+ fadeToBlack: true });
+ // On wayland, a crash brings down the entire session, so we don't
+ // need to defend against being restarted unlocked
+ if (!Meta.is_wayland_compositor())
+ global.set_runtime_state(LOCKED_STATE_STR, GLib.Variant.new('b', true));
+
+ // We used to set isActive and emit active-changed here,
+ // but now we do that from lockScreenShown, which means
+ // there is a 0.3 seconds window during which the lock
+ // screen is effectively visible and the screen is locked, but
+ // the DBus interface reports the screensaver is off.
+ // This is because when we emit ActiveChanged(true),
+ // gnome-settings-daemon blanks the screen, and we don't want
+ // blank during the animation.
+ // This is not a problem for the idle fade case, because we
+ // activate without animation in that case.
+ }
+
+ lock(animate) {
+ if (this._lockSettings.get_boolean(DISABLE_LOCK_KEY)) {
+ log('Screen lock is locked down, not locking'); // lock, lock - who's there?
+ return;
+ }
+
+ // Warn the user if we can't become modal
+ if (!this._becomeModal()) {
+ Main.notifyError(_("Unable to lock"),
+ _("Lock was blocked by an application"));
+ return;
+ }
+
+ // Clear the clipboard - otherwise, its contents may be leaked
+ // to unauthorized parties by pasting into the unlock dialog's
+ // password entry and unmasking the entry
+ St.Clipboard.get_default().set_text(St.ClipboardType.CLIPBOARD, '');
+ St.Clipboard.get_default().set_text(St.ClipboardType.PRIMARY, '');
+
+ let userManager = AccountsService.UserManager.get_default();
+ let user = userManager.get_user(GLib.get_user_name());
+
+ if (this._isGreeter)
+ this._isLocked = true;
+ else
+ this._isLocked = user.password_mode != AccountsService.UserPasswordMode.NONE;
+
+ this.activate(animate);
+
+ this.emit('locked-changed');
+ }
+
+ // If the previous shell crashed, and gnome-session restarted us, then re-lock
+ lockIfWasLocked() {
+ if (!this._settings.get_boolean(LOCK_ENABLED_KEY))
+ return;
+ let wasLocked = global.get_runtime_state('b', LOCKED_STATE_STR);
+ if (wasLocked === null)
+ return;
+ Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
+ this.lock(false);
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+};
+Signals.addSignalMethods(ScreenShield.prototype);
diff --git a/js/ui/screenshot.js b/js/ui/screenshot.js
new file mode 100644
index 0000000..787e60f
--- /dev/null
+++ b/js/ui/screenshot.js
@@ -0,0 +1,631 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported ScreenshotService */
+
+const { Clutter, Gio, GObject, GLib, Meta, Shell, St } = imports.gi;
+
+const GrabHelper = imports.ui.grabHelper;
+const Lightbox = imports.ui.lightbox;
+const Main = imports.ui.main;
+
+Gio._promisify(Shell.Screenshot.prototype, 'pick_color', 'pick_color_finish');
+Gio._promisify(Shell.Screenshot.prototype, 'screenshot', 'screenshot_finish');
+Gio._promisify(Shell.Screenshot.prototype,
+ 'screenshot_window', 'screenshot_window_finish');
+Gio._promisify(Shell.Screenshot.prototype,
+ 'screenshot_area', 'screenshot_area_finish');
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+const ScreenshotIface = loadInterfaceXML('org.gnome.Shell.Screenshot');
+
+var ScreenshotService = class {
+ constructor() {
+ this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(ScreenshotIface, this);
+ this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell/Screenshot');
+
+ this._screenShooter = new Map();
+
+ this._lockdownSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.lockdown' });
+
+ Gio.DBus.session.own_name('org.gnome.Shell.Screenshot', Gio.BusNameOwnerFlags.REPLACE, null, null);
+ }
+
+ _createScreenshot(invocation, needsDisk = true) {
+ let lockedDown = false;
+ if (needsDisk)
+ lockedDown = this._lockdownSettings.get_boolean('disable-save-to-disk');
+
+ let sender = invocation.get_sender();
+ if (this._screenShooter.has(sender) || lockedDown) {
+ invocation.return_error_literal(
+ Gio.IOErrorEnum, Gio.IOErrorEnum.BUSY,
+ 'There is an ongoing operation for this sender');
+ return null;
+ }
+
+ let shooter = new Shell.Screenshot();
+ shooter._watchNameId =
+ Gio.bus_watch_name(Gio.BusType.SESSION, sender, 0, null,
+ this._onNameVanished.bind(this));
+
+ this._screenShooter.set(sender, shooter);
+
+ return shooter;
+ }
+
+ _onNameVanished(connection, name) {
+ this._removeShooterForSender(name);
+ }
+
+ _removeShooterForSender(sender) {
+ let shooter = this._screenShooter.get(sender);
+ if (!shooter)
+ return;
+
+ Gio.bus_unwatch_name(shooter._watchNameId);
+ this._screenShooter.delete(sender);
+ }
+
+ _checkArea(x, y, width, height) {
+ return x >= 0 && y >= 0 &&
+ width > 0 && height > 0 &&
+ x + width <= global.screen_width &&
+ y + height <= global.screen_height;
+ }
+
+ *_resolveRelativeFilename(filename) {
+ filename = filename.replace(/\.png$/, '');
+
+ let path = [
+ GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_PICTURES),
+ GLib.get_home_dir(),
+ ].find(p => GLib.file_test(p, GLib.FileTest.EXISTS));
+
+ if (!path)
+ return null;
+
+ yield Gio.File.new_for_path(
+ GLib.build_filenamev([path, `${filename}.png`]));
+
+ for (let idx = 1; ; idx++) {
+ yield Gio.File.new_for_path(
+ GLib.build_filenamev([path, `${filename}-${idx}.png`]));
+ }
+ }
+
+ _createStream(filename, invocation) {
+ if (filename == '')
+ return [Gio.MemoryOutputStream.new_resizable(), null];
+
+ if (GLib.path_is_absolute(filename)) {
+ try {
+ let file = Gio.File.new_for_path(filename);
+ let stream = file.replace(null, false, Gio.FileCreateFlags.NONE, null);
+ return [stream, file];
+ } catch (e) {
+ invocation.return_value(GLib.Variant.new('(bs)', [false, '']));
+ return [null, null];
+ }
+ }
+
+ for (let file of this._resolveRelativeFilename(filename)) {
+ try {
+ let stream = file.create(Gio.FileCreateFlags.NONE, null);
+ return [stream, file];
+ } catch (e) {
+ if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS))
+ break;
+ }
+ }
+
+ invocation.return_value(GLib.Variant.new('(bs)', [false, '']));
+ return [null, null];
+ }
+
+ _onScreenshotComplete(area, stream, file, flash, invocation) {
+ if (flash) {
+ let flashspot = new Flashspot(area);
+ flashspot.fire(() => {
+ this._removeShooterForSender(invocation.get_sender());
+ });
+ } else {
+ this._removeShooterForSender(invocation.get_sender());
+ }
+
+ stream.close(null);
+
+ let filenameUsed = '';
+ if (file) {
+ filenameUsed = file.get_path();
+ } else {
+ let bytes = stream.steal_as_bytes();
+ let clipboard = St.Clipboard.get_default();
+ clipboard.set_content(St.ClipboardType.CLIPBOARD, 'image/png', bytes);
+ }
+
+ let retval = GLib.Variant.new('(bs)', [true, filenameUsed]);
+ invocation.return_value(retval);
+ }
+
+ _scaleArea(x, y, width, height) {
+ let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ x *= scaleFactor;
+ y *= scaleFactor;
+ width *= scaleFactor;
+ height *= scaleFactor;
+ return [x, y, width, height];
+ }
+
+ _unscaleArea(x, y, width, height) {
+ let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ x /= scaleFactor;
+ y /= scaleFactor;
+ width /= scaleFactor;
+ height /= scaleFactor;
+ return [x, y, width, height];
+ }
+
+ async ScreenshotAreaAsync(params, invocation) {
+ let [x, y, width, height, flash, filename] = params;
+ [x, y, width, height] = this._scaleArea(x, y, width, height);
+ if (!this._checkArea(x, y, width, height)) {
+ invocation.return_error_literal(Gio.IOErrorEnum,
+ Gio.IOErrorEnum.CANCELLED,
+ "Invalid params");
+ return;
+ }
+ let screenshot = this._createScreenshot(invocation);
+ if (!screenshot)
+ return;
+
+ let [stream, file] = this._createStream(filename, invocation);
+ if (!stream)
+ return;
+
+ try {
+ let [area] =
+ await screenshot.screenshot_area(x, y, width, height, stream);
+ this._onScreenshotComplete(area, stream, file, flash, invocation);
+ } catch (e) {
+ this._removeShooterForSender(invocation.get_sender());
+ invocation.return_value(new GLib.Variant('(bs)', [false, '']));
+ }
+ }
+
+ async ScreenshotWindowAsync(params, invocation) {
+ let [includeFrame, includeCursor, flash, filename] = params;
+ let screenshot = this._createScreenshot(invocation);
+ if (!screenshot)
+ return;
+
+ let [stream, file] = this._createStream(filename, invocation);
+ if (!stream)
+ return;
+
+ try {
+ let [area] =
+ await screenshot.screenshot_window(includeFrame, includeCursor, stream);
+ this._onScreenshotComplete(area, stream, file, flash, invocation);
+ } catch (e) {
+ this._removeShooterForSender(invocation.get_sender());
+ invocation.return_value(new GLib.Variant('(bs)', [false, '']));
+ }
+ }
+
+ async ScreenshotAsync(params, invocation) {
+ let [includeCursor, flash, filename] = params;
+ let screenshot = this._createScreenshot(invocation);
+ if (!screenshot)
+ return;
+
+ let [stream, file] = this._createStream(filename, invocation);
+ if (!stream)
+ return;
+
+ try {
+ let [area] = await screenshot.screenshot(includeCursor, stream);
+ this._onScreenshotComplete(area, stream, file, flash, invocation);
+ } catch (e) {
+ this._removeShooterForSender(invocation.get_sender());
+ invocation.return_value(new GLib.Variant('(bs)', [false, '']));
+ }
+ }
+
+ async SelectAreaAsync(params, invocation) {
+ let selectArea = new SelectArea();
+ try {
+ let areaRectangle = await selectArea.selectAsync();
+ let retRectangle = this._unscaleArea(
+ areaRectangle.x, areaRectangle.y,
+ areaRectangle.width, areaRectangle.height);
+ invocation.return_value(GLib.Variant.new('(iiii)', retRectangle));
+ } catch (e) {
+ invocation.return_error_literal(
+ Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED,
+ 'Operation was cancelled');
+ }
+ }
+
+ FlashAreaAsync(params, invocation) {
+ let [x, y, width, height] = params;
+ [x, y, width, height] = this._scaleArea(x, y, width, height);
+ if (!this._checkArea(x, y, width, height)) {
+ invocation.return_error_literal(Gio.IOErrorEnum,
+ Gio.IOErrorEnum.CANCELLED,
+ "Invalid params");
+ return;
+ }
+ let flashspot = new Flashspot({ x, y, width, height });
+ flashspot.fire();
+ invocation.return_value(null);
+ }
+
+ async PickColorAsync(params, invocation) {
+ const screenshot = this._createScreenshot(invocation, false);
+ if (!screenshot)
+ return;
+
+ const pickPixel = new PickPixel(screenshot);
+ try {
+ const color = await pickPixel.pickAsync();
+ const { red, green, blue } = color;
+ const retval = GLib.Variant.new('(a{sv})', [{
+ color: GLib.Variant.new('(ddd)', [
+ red / 255.0,
+ green / 255.0,
+ blue / 255.0,
+ ]),
+ }]);
+ invocation.return_value(retval);
+ } catch (e) {
+ invocation.return_error_literal(
+ Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED,
+ 'Operation was cancelled');
+ } finally {
+ this._removeShooterForSender(invocation.get_sender());
+ }
+ }
+};
+
+var SelectArea = GObject.registerClass(
+class SelectArea extends St.Widget {
+ _init() {
+ this._startX = -1;
+ this._startY = -1;
+ this._lastX = 0;
+ this._lastY = 0;
+ this._result = null;
+
+ super._init({
+ visible: false,
+ reactive: true,
+ x: 0,
+ y: 0,
+ });
+ Main.uiGroup.add_actor(this);
+
+ this._grabHelper = new GrabHelper.GrabHelper(this);
+
+ let constraint = new Clutter.BindConstraint({ source: global.stage,
+ coordinate: Clutter.BindCoordinate.ALL });
+ this.add_constraint(constraint);
+
+ this._rubberband = new St.Widget({
+ style_class: 'select-area-rubberband',
+ visible: false,
+ });
+ this.add_actor(this._rubberband);
+ }
+
+ async selectAsync() {
+ global.display.set_cursor(Meta.Cursor.CROSSHAIR);
+ Main.uiGroup.set_child_above_sibling(this, null);
+ this.show();
+
+ try {
+ await this._grabHelper.grabAsync({ actor: this });
+ } finally {
+ global.display.set_cursor(Meta.Cursor.DEFAULT);
+
+ GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
+ this.destroy();
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+
+ return this._result;
+ }
+
+ _getGeometry() {
+ return new Meta.Rectangle({
+ x: Math.min(this._startX, this._lastX),
+ y: Math.min(this._startY, this._lastY),
+ width: Math.abs(this._startX - this._lastX) + 1,
+ height: Math.abs(this._startY - this._lastY) + 1,
+ });
+ }
+
+ vfunc_motion_event(motionEvent) {
+ if (this._startX == -1 || this._startY == -1 || this._result)
+ return Clutter.EVENT_PROPAGATE;
+
+ [this._lastX, this._lastY] = [motionEvent.x, motionEvent.y];
+ this._lastX = Math.floor(this._lastX);
+ this._lastY = Math.floor(this._lastY);
+ let geometry = this._getGeometry();
+
+ this._rubberband.set_position(geometry.x, geometry.y);
+ this._rubberband.set_size(geometry.width, geometry.height);
+ this._rubberband.show();
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_button_press_event(buttonEvent) {
+ [this._startX, this._startY] = [buttonEvent.x, buttonEvent.y];
+ this._startX = Math.floor(this._startX);
+ this._startY = Math.floor(this._startY);
+ this._rubberband.set_position(this._startX, this._startY);
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_button_release_event() {
+ this._result = this._getGeometry();
+ this.ease({
+ opacity: 0,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this._grabHelper.ungrab(),
+ });
+ return Clutter.EVENT_PROPAGATE;
+ }
+});
+
+var RecolorEffect = GObject.registerClass({
+ Properties: {
+ color: GObject.ParamSpec.boxed(
+ 'color', 'color', 'replacement color',
+ GObject.ParamFlags.WRITABLE,
+ Clutter.Color.$gtype),
+ chroma: GObject.ParamSpec.boxed(
+ 'chroma', 'chroma', 'color to replace',
+ GObject.ParamFlags.WRITABLE,
+ Clutter.Color.$gtype),
+ threshold: GObject.ParamSpec.float(
+ 'threshold', 'threshold', 'threshold',
+ GObject.ParamFlags.WRITABLE,
+ 0.0, 1.0, 0.0),
+ smoothing: GObject.ParamSpec.float(
+ 'smoothing', 'smoothing', 'smoothing',
+ GObject.ParamFlags.WRITABLE,
+ 0.0, 1.0, 0.0),
+ },
+}, class RecolorEffect extends Shell.GLSLEffect {
+ _init(params) {
+ this._color = new Clutter.Color();
+ this._chroma = new Clutter.Color();
+ this._threshold = 0;
+ this._smoothing = 0;
+
+ this._colorLocation = null;
+ this._chromaLocation = null;
+ this._thresholdLocation = null;
+ this._smoothingLocation = null;
+
+ super._init(params);
+
+ this._colorLocation = this.get_uniform_location('recolor_color');
+ this._chromaLocation = this.get_uniform_location('chroma_color');
+ this._thresholdLocation = this.get_uniform_location('threshold');
+ this._smoothingLocation = this.get_uniform_location('smoothing');
+
+ this._updateColorUniform(this._colorLocation, this._color);
+ this._updateColorUniform(this._chromaLocation, this._chroma);
+ this._updateFloatUniform(this._thresholdLocation, this._threshold);
+ this._updateFloatUniform(this._smoothingLocation, this._smoothing);
+ }
+
+ _updateColorUniform(location, color) {
+ if (!location)
+ return;
+
+ this.set_uniform_float(location,
+ 3, [color.red / 255, color.green / 255, color.blue / 255]);
+ this.queue_repaint();
+ }
+
+ _updateFloatUniform(location, value) {
+ if (!location)
+ return;
+
+ this.set_uniform_float(location, 1, [value]);
+ this.queue_repaint();
+ }
+
+ set color(c) {
+ if (this._color.equal(c))
+ return;
+
+ this._color = c;
+ this.notify('color');
+
+ this._updateColorUniform(this._colorLocation, this._color);
+ }
+
+ set chroma(c) {
+ if (this._chroma.equal(c))
+ return;
+
+ this._chroma = c;
+ this.notify('chroma');
+
+ this._updateColorUniform(this._chromaLocation, this._chroma);
+ }
+
+ set threshold(value) {
+ if (this._threshold === value)
+ return;
+
+ this._threshold = value;
+ this.notify('threshold');
+
+ this._updateFloatUniform(this._thresholdLocation, this._threshold);
+ }
+
+ set smoothing(value) {
+ if (this._smoothing === value)
+ return;
+
+ this._smoothing = value;
+ this.notify('smoothing');
+
+ this._updateFloatUniform(this._smoothingLocation, this._smoothing);
+ }
+
+ vfunc_build_pipeline() {
+ // Conversion parameters from https://en.wikipedia.org/wiki/YCbCr
+ const decl = `
+ vec3 rgb2yCrCb(vec3 c) { \n
+ float y = 0.299 * c.r + 0.587 * c.g + 0.114 * c.b; \n
+ float cr = 0.7133 * (c.r - y); \n
+ float cb = 0.5643 * (c.b - y); \n
+ return vec3(y, cr, cb); \n
+ } \n
+ \n
+ uniform vec3 chroma_color; \n
+ uniform vec3 recolor_color; \n
+ uniform float threshold; \n
+ uniform float smoothing; \n`;
+ const src = `
+ vec3 mask = rgb2yCrCb(chroma_color.rgb); \n
+ vec3 yCrCb = rgb2yCrCb(cogl_color_out.rgb); \n
+ float blend = \n
+ smoothstep(threshold, \n
+ threshold + smoothing, \n
+ distance(yCrCb.gb, mask.gb)); \n
+ cogl_color_out.rgb = \n
+ mix(recolor_color, cogl_color_out.rgb, blend); \n`;
+
+ this.add_glsl_snippet(Shell.SnippetHook.FRAGMENT, decl, src, false);
+ }
+});
+
+var PickPixel = GObject.registerClass(
+class PickPixel extends St.Widget {
+ _init(screenshot) {
+ super._init({ visible: false, reactive: true });
+
+ this._screenshot = screenshot;
+
+ this._result = null;
+ this._color = null;
+ this._inPick = false;
+
+ Main.uiGroup.add_actor(this);
+
+ this._grabHelper = new GrabHelper.GrabHelper(this);
+
+ let constraint = new Clutter.BindConstraint({ source: global.stage,
+ coordinate: Clutter.BindCoordinate.ALL });
+ this.add_constraint(constraint);
+
+ const action = new Clutter.ClickAction();
+ action.connect('clicked', async () => {
+ await this._pickColor(...action.get_coords());
+ this._result = this._color;
+ this._grabHelper.ungrab();
+ });
+ this.add_action(action);
+
+ this._recolorEffect = new RecolorEffect({
+ chroma: new Clutter.Color({
+ red: 80,
+ green: 219,
+ blue: 181,
+ }),
+ threshold: 0.04,
+ smoothing: 0.07,
+ });
+ this._previewCursor = new St.Icon({
+ icon_name: 'color-pick',
+ icon_size: Meta.prefs_get_cursor_size(),
+ effect: this._recolorEffect,
+ visible: false,
+ });
+ Main.uiGroup.add_actor(this._previewCursor);
+ }
+
+ async pickAsync() {
+ global.display.set_cursor(Meta.Cursor.BLANK);
+ Main.uiGroup.set_child_above_sibling(this, null);
+ this.show();
+
+ this._pickColor(...global.get_pointer());
+
+ try {
+ await this._grabHelper.grabAsync({ actor: this });
+ } finally {
+ global.display.set_cursor(Meta.Cursor.DEFAULT);
+ this._previewCursor.destroy();
+
+ GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
+ this.destroy();
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+
+ return this._result;
+ }
+
+ async _pickColor(x, y) {
+ if (this._inPick)
+ return;
+
+ this._inPick = true;
+ this._previewCursor.set_position(x, y);
+ [this._color] = await this._screenshot.pick_color(x, y);
+ this._inPick = false;
+
+ if (!this._color)
+ return;
+
+ this._recolorEffect.color = this._color;
+ this._previewCursor.show();
+ }
+
+ vfunc_motion_event(motionEvent) {
+ const { x, y } = motionEvent;
+ this._pickColor(x, y);
+ return Clutter.EVENT_PROPAGATE;
+ }
+});
+
+var FLASHSPOT_ANIMATION_OUT_TIME = 500; // milliseconds
+
+var Flashspot = GObject.registerClass(
+class Flashspot extends Lightbox.Lightbox {
+ _init(area) {
+ super._init(Main.uiGroup, {
+ inhibitEvents: true,
+ width: area.width,
+ height: area.height,
+ });
+ this.style_class = 'flashspot';
+ this.set_position(area.x, area.y);
+ }
+
+ fire(doneCallback) {
+ this.set({ visible: true, opacity: 255 });
+ this.ease({
+ opacity: 0,
+ duration: FLASHSPOT_ANIMATION_OUT_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ if (doneCallback)
+ doneCallback();
+ this.destroy();
+ },
+ });
+ }
+});
diff --git a/js/ui/scripting.js b/js/ui/scripting.js
new file mode 100644
index 0000000..e4b29a4
--- /dev/null
+++ b/js/ui/scripting.js
@@ -0,0 +1,351 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported sleep, waitLeisure, createTestWindow, waitTestWindows,
+ destroyTestWindows, defineScriptEvent, scriptEvent,
+ collectStatistics, runPerfScript */
+
+const { Gio, GLib, Meta, Shell } = imports.gi;
+
+const Config = imports.misc.config;
+const Main = imports.ui.main;
+const Params = imports.misc.params;
+const Util = imports.misc.util;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+// This module provides functionality for driving the shell user interface
+// in an automated fashion. The primary current use case for this is
+// automated performance testing (see runPerfScript()), but it could
+// be applied to other forms of automation, such as testing for
+// correctness as well.
+//
+// When scripting an automated test we want to make a series of calls
+// in a linear fashion, but we also want to be able to let the main
+// loop run so actions can finish. For this reason we write the script
+// as an async function that uses await when it wants to let the main
+// loop run.
+//
+// await Scripting.sleep(1000);
+// main.overview.show();
+// await Scripting.waitLeisure();
+//
+
+/**
+ * sleep:
+ * @param {number} milliseconds - number of milliseconds to wait
+ * @returns {Promise} that resolves after @milliseconds ms
+ *
+ * Used within an automation script to pause the the execution of the
+ * current script for the specified amount of time. Use as
+ * 'yield Scripting.sleep(500);'
+ */
+function sleep(milliseconds) {
+ return new Promise(resolve => {
+ let id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, milliseconds, () => {
+ resolve();
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(id, '[gnome-shell] sleep');
+ });
+}
+
+/**
+ * waitLeisure:
+ * @returns {Promise} that resolves when the shell is idle
+ *
+ * Used within an automation script to pause the the execution of the
+ * current script until the shell is completely idle. Use as
+ * 'yield Scripting.waitLeisure();'
+ */
+function waitLeisure() {
+ return new Promise(resolve => {
+ global.run_at_leisure(resolve);
+ });
+}
+
+const PerfHelperIface = loadInterfaceXML('org.gnome.Shell.PerfHelper');
+var PerfHelperProxy = Gio.DBusProxy.makeProxyWrapper(PerfHelperIface);
+function PerfHelper() {
+ return new PerfHelperProxy(Gio.DBus.session, 'org.gnome.Shell.PerfHelper', '/org/gnome/Shell/PerfHelper');
+}
+
+let _perfHelper = null;
+function _getPerfHelper() {
+ if (_perfHelper == null)
+ _perfHelper = new PerfHelper();
+
+ return _perfHelper;
+}
+
+function _spawnPerfHelper() {
+ let path = Config.LIBEXECDIR;
+ let command = `${path}/gnome-shell-perf-helper`;
+ Util.trySpawnCommandLine(command);
+}
+
+function _callRemote(obj, method, ...args) {
+ return new Promise((resolve, reject) => {
+ args.push((result, excp) => {
+ if (excp)
+ reject(excp);
+ else
+ resolve();
+ });
+
+ method.apply(obj, args);
+ });
+}
+
+/**
+ * createTestWindow:
+ * @param {Object} params: options for window creation.
+ * {number} [params.width=640] - width of window, in pixels
+ * {number} [params.height=480] - height of window, in pixels
+ * {bool} [params.alpha=false] - whether the window should have an alpha channel
+ * {bool} [params.maximized=false] - whether the window should be created maximized
+ * {bool} [params.redraws=false] - whether the window should continually redraw itself
+ * @returns {Promise}
+ *
+ * Creates a window using gnome-shell-perf-helper for testing purposes.
+ * While this function can be used with yield in an automation
+ * script to pause until the D-Bus call to the helper process returns,
+ * because of the normal X asynchronous mapping process, to actually wait
+ * until the window has been mapped and exposed, use waitTestWindows().
+ */
+function createTestWindow(params) {
+ params = Params.parse(params, { width: 640,
+ height: 480,
+ alpha: false,
+ maximized: false,
+ redraws: false });
+
+ let perfHelper = _getPerfHelper();
+ return _callRemote(perfHelper, perfHelper.CreateWindowRemote,
+ params.width, params.height,
+ params.alpha, params.maximized, params.redraws);
+}
+
+/**
+ * waitTestWindows:
+ * @returns {Promise}
+ *
+ * Used within an automation script to pause until all windows previously
+ * created with createTestWindow have been mapped and exposed.
+ */
+function waitTestWindows() {
+ let perfHelper = _getPerfHelper();
+ return _callRemote(perfHelper, perfHelper.WaitWindowsRemote);
+}
+
+/**
+ * destroyTestWindows:
+ * @returns {Promise}
+ *
+ * Destroys all windows previously created with createTestWindow().
+ * While this function can be used with yield in an automation
+ * script to pause until the D-Bus call to the helper process returns,
+ * this doesn't guarantee that Mutter has actually finished the destroy
+ * process because of normal X asynchronicity.
+ */
+function destroyTestWindows() {
+ let perfHelper = _getPerfHelper();
+ return _callRemote(perfHelper, perfHelper.DestroyWindowsRemote);
+}
+
+/**
+ * defineScriptEvent
+ * @param {string} name: The event will be called script.<name>
+ * @param {string} description: Short human-readable description of the event
+ *
+ * Convenience function to define a zero-argument performance event
+ * within the 'script' namespace that is reserved for events defined locally
+ * within a performance automation script
+ */
+function defineScriptEvent(name, description) {
+ Shell.PerfLog.get_default().define_event(`script.${name}`,
+ description,
+ "");
+}
+
+/**
+ * scriptEvent
+ * @param {string} name: Name registered with defineScriptEvent()
+ *
+ * Convenience function to record a script-local performance event
+ * previously defined with defineScriptEvent
+ */
+function scriptEvent(name) {
+ Shell.PerfLog.get_default().event(`script.${name}`);
+}
+
+/**
+ * collectStatistics
+ *
+ * Convenience function to trigger statistics collection
+ */
+function collectStatistics() {
+ Shell.PerfLog.get_default().collect_statistics();
+}
+
+function _collect(scriptModule, outputFile) {
+ let eventHandlers = {};
+
+ for (let f in scriptModule) {
+ let m = /([A-Za-z]+)_([A-Za-z]+)/.exec(f);
+ if (m)
+ eventHandlers[`${m[1]}.${m[2]}`] = scriptModule[f];
+ }
+
+ Shell.PerfLog.get_default().replay(
+ (time, eventName, signature, arg) => {
+ if (eventName in eventHandlers)
+ eventHandlers[eventName](time, arg);
+ });
+
+ if ('finish' in scriptModule)
+ scriptModule.finish();
+
+ if (outputFile) {
+ let f = Gio.file_new_for_path(outputFile);
+ let raw = f.replace(null, false,
+ Gio.FileCreateFlags.NONE,
+ null);
+ let out = Gio.BufferedOutputStream.new_sized(raw, 4096);
+ Shell.write_string_to_stream(out, "{\n");
+
+ Shell.write_string_to_stream(out, '"events":\n');
+ Shell.PerfLog.get_default().dump_events(out);
+
+ let monitors = Main.layoutManager.monitors;
+ let primary = Main.layoutManager.primaryIndex;
+ Shell.write_string_to_stream(out, ',\n"monitors":\n[');
+ for (let i = 0; i < monitors.length; i++) {
+ let monitor = monitors[i];
+ if (i != 0)
+ Shell.write_string_to_stream(out, ', ');
+ Shell.write_string_to_stream(out, '"%s%dx%d+%d+%d"'.format(i == primary ? "*" : "",
+ monitor.width, monitor.height,
+ monitor.x, monitor.y));
+ }
+ Shell.write_string_to_stream(out, ' ]');
+
+ Shell.write_string_to_stream(out, ',\n"metrics":\n[ ');
+ let first = true;
+ for (let name in scriptModule.METRICS) {
+ let metric = scriptModule.METRICS[name];
+ // Extra checks here because JSON.stringify generates
+ // invalid JSON for undefined values
+ if (metric.description == null) {
+ log(`Error: No description found for metric ${name}`);
+ continue;
+ }
+ if (metric.units == null) {
+ log(`Error: No units found for metric ${name}`);
+ continue;
+ }
+ if (metric.value == null) {
+ log(`Error: No value found for metric ${name}`);
+ continue;
+ }
+
+ if (!first)
+ Shell.write_string_to_stream(out, ',\n ');
+ first = false;
+
+ Shell.write_string_to_stream(out,
+ `{ "name": ${JSON.stringify(name)},\n` +
+ ` "description": ${JSON.stringify(metric.description)},\n` +
+ ` "units": ${JSON.stringify(metric.units)},\n` +
+ ` "value": ${JSON.stringify(metric.value)} }`);
+ }
+ Shell.write_string_to_stream(out, ' ]');
+
+ Shell.write_string_to_stream(out, ',\n"log":\n');
+ Shell.PerfLog.get_default().dump_log(out);
+
+ Shell.write_string_to_stream(out, '\n}\n');
+ out.close(null);
+ } else {
+ let metrics = [];
+ for (let metric in scriptModule.METRICS)
+ metrics.push(metric);
+
+ metrics.sort();
+
+ print('------------------------------------------------------------');
+ for (let i = 0; i < metrics.length; i++) {
+ let metric = metrics[i];
+ print(`# ${scriptModule.METRICS[metric].description}`);
+ print(`${metric}: ${scriptModule.METRICS[metric].value}${scriptModule.METRICS[metric].units}`);
+ }
+ print('------------------------------------------------------------');
+ }
+}
+
+async function _runPerfScript(scriptModule, outputFile) {
+ try {
+ await scriptModule.run();
+ } catch (err) {
+ log(`Script failed: ${err}\n${err.stack}`);
+ Meta.exit(Meta.ExitCode.ERROR);
+ }
+
+ try {
+ _collect(scriptModule, outputFile);
+ } catch (err) {
+ log(`Script failed: ${err}\n${err.stack}`);
+ Meta.exit(Meta.ExitCode.ERROR);
+ }
+ Meta.exit(Meta.ExitCode.SUCCESS);
+}
+
+/**
+ * runPerfScript
+ * @param {Object} scriptModule: module object with run and finish
+ * functions and event handlers
+ * @param {string} outputFile: path to write output to
+ *
+ * Runs a script for automated collection of performance data. The
+ * script is defined as a Javascript module with specified contents.
+ *
+ * First the run() function within the module will be called as a
+ * generator to automate a series of actions. These actions will
+ * trigger performance events and the script can also record its
+ * own performance events.
+ *
+ * Then the recorded event log is replayed using handler functions
+ * within the module. The handler for the event 'foo.bar' is called
+ * foo_bar().
+ *
+ * Finally if the module has a function called finish(), that will
+ * be called.
+ *
+ * The event handler and finish functions are expected to fill in
+ * metrics to an object within the module called METRICS. Each
+ * property of this object represents an individual metric. The
+ * name of the property is the name of the metric, the value
+ * of the property is an object with the following properties:
+ *
+ * description: human readable description of the metric
+ * units: a string representing the units of the metric. It has
+ * the form '<unit> <unit> ... / <unit> / <unit> ...'. Certain
+ * unit values are recognized: s, ms, us, B, KiB, MiB. Other
+ * values can appear but are uninterpreted. Examples 's',
+ * '/ s', 'frames', 'frames / s', 'MiB / s / frame'
+ * value: computed value of the metric
+ *
+ * The resulting metrics will be written to @outputFile as JSON, or,
+ * if @outputFile is not provided, logged.
+ *
+ * After running the script and collecting statistics from the
+ * event log, GNOME Shell will exit.
+ **/
+function runPerfScript(scriptModule, outputFile) {
+ Shell.PerfLog.get_default().set_enabled(true);
+ _spawnPerfHelper();
+
+ Gio.bus_watch_name(Gio.BusType.SESSION,
+ 'org.gnome.Shell.PerfHelper',
+ Gio.BusNameWatcherFlags.NONE,
+ () => _runPerfScript(scriptModule, outputFile),
+ null);
+}
diff --git a/js/ui/search.js b/js/ui/search.js
new file mode 100644
index 0000000..dd76735
--- /dev/null
+++ b/js/ui/search.js
@@ -0,0 +1,955 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported SearchResultsView */
+
+const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi;
+
+const AppDisplay = imports.ui.appDisplay;
+const IconGrid = imports.ui.iconGrid;
+const Main = imports.ui.main;
+const ParentalControlsManager = imports.misc.parentalControlsManager;
+const RemoteSearch = imports.ui.remoteSearch;
+const Util = imports.misc.util;
+
+const SEARCH_PROVIDERS_SCHEMA = 'org.gnome.desktop.search-providers';
+
+var MAX_LIST_SEARCH_RESULTS_ROWS = 5;
+
+var MaxWidthBox = GObject.registerClass(
+class MaxWidthBox extends St.BoxLayout {
+ vfunc_allocate(box) {
+ let themeNode = this.get_theme_node();
+ let maxWidth = themeNode.get_max_width();
+ let availWidth = box.x2 - box.x1;
+ let adjustedBox = box;
+
+ if (availWidth > maxWidth) {
+ let excessWidth = availWidth - maxWidth;
+ adjustedBox.x1 += Math.floor(excessWidth / 2);
+ adjustedBox.x2 -= Math.floor(excessWidth / 2);
+ }
+
+ super.vfunc_allocate(adjustedBox);
+ }
+});
+
+var SearchResult = GObject.registerClass(
+class SearchResult extends St.Button {
+ _init(provider, metaInfo, resultsView) {
+ this.provider = provider;
+ this.metaInfo = metaInfo;
+ this._resultsView = resultsView;
+
+ super._init({
+ reactive: true,
+ can_focus: true,
+ track_hover: true,
+ });
+ }
+
+ vfunc_clicked() {
+ this.activate();
+ }
+
+ activate() {
+ this.provider.activateResult(this.metaInfo.id, this._resultsView.terms);
+
+ if (this.metaInfo.clipboardText) {
+ St.Clipboard.get_default().set_text(
+ St.ClipboardType.CLIPBOARD, this.metaInfo.clipboardText);
+ }
+ Main.overview.toggle();
+ }
+});
+
+var ListSearchResult = GObject.registerClass(
+class ListSearchResult extends SearchResult {
+ _init(provider, metaInfo, resultsView) {
+ super._init(provider, metaInfo, resultsView);
+
+ this.style_class = 'list-search-result';
+
+ let content = new St.BoxLayout({
+ style_class: 'list-search-result-content',
+ vertical: false,
+ x_align: Clutter.ActorAlign.START,
+ x_expand: true,
+ y_expand: true,
+ });
+ this.set_child(content);
+
+ this._termsChangedId = 0;
+
+ let titleBox = new St.BoxLayout({
+ style_class: 'list-search-result-title',
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+
+ content.add_child(titleBox);
+
+ // An icon for, or thumbnail of, content
+ let icon = this.metaInfo['createIcon'](this.ICON_SIZE);
+ if (icon)
+ titleBox.add(icon);
+
+ let title = new St.Label({
+ text: this.metaInfo['name'],
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ titleBox.add_child(title);
+
+ this.label_actor = title;
+
+ if (this.metaInfo['description']) {
+ this._descriptionLabel = new St.Label({
+ style_class: 'list-search-result-description',
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ content.add_child(this._descriptionLabel);
+
+ this._termsChangedId =
+ this._resultsView.connect('terms-changed',
+ this._highlightTerms.bind(this));
+
+ this._highlightTerms();
+ }
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ get ICON_SIZE() {
+ return 24;
+ }
+
+ _highlightTerms() {
+ let markup = this._resultsView.highlightTerms(this.metaInfo['description'].split('\n')[0]);
+ this._descriptionLabel.clutter_text.set_markup(markup);
+ }
+
+ _onDestroy() {
+ if (this._termsChangedId)
+ this._resultsView.disconnect(this._termsChangedId);
+ this._termsChangedId = 0;
+ }
+});
+
+var GridSearchResult = GObject.registerClass(
+class GridSearchResult extends SearchResult {
+ _init(provider, metaInfo, resultsView) {
+ super._init(provider, metaInfo, resultsView);
+
+ this.style_class = 'grid-search-result';
+
+ this.icon = new IconGrid.BaseIcon(this.metaInfo['name'],
+ { createIcon: this.metaInfo['createIcon'] });
+ let content = new St.Bin({
+ child: this.icon,
+ x_align: Clutter.ActorAlign.START,
+ x_expand: true,
+ y_expand: true,
+ });
+ this.set_child(content);
+ this.label_actor = this.icon.label;
+ }
+});
+
+var SearchResultsBase = GObject.registerClass({
+ GTypeFlags: GObject.TypeFlags.ABSTRACT,
+ Properties: {
+ 'focus-child': GObject.ParamSpec.object(
+ 'focus-child', 'focus-child', 'focus-child',
+ GObject.ParamFlags.READABLE,
+ Clutter.Actor.$gtype),
+ },
+}, class SearchResultsBase extends St.BoxLayout {
+ _init(provider, resultsView) {
+ super._init({ style_class: 'search-section', vertical: true });
+
+ this.provider = provider;
+ this._resultsView = resultsView;
+
+ this._terms = [];
+ this._focusChild = null;
+
+ this._resultDisplayBin = new St.Bin();
+ this.add_child(this._resultDisplayBin);
+
+ let separator = new St.Widget({ style_class: 'search-section-separator' });
+ this.add(separator);
+
+ this._resultDisplays = {};
+
+ this._cancellable = new Gio.Cancellable();
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ _onDestroy() {
+ this._terms = [];
+ }
+
+ _createResultDisplay(meta) {
+ if (this.provider.createResultObject)
+ return this.provider.createResultObject(meta, this._resultsView);
+
+ return null;
+ }
+
+ clear() {
+ this._cancellable.cancel();
+ for (let resultId in this._resultDisplays)
+ this._resultDisplays[resultId].destroy();
+ this._resultDisplays = {};
+ this._clearResultDisplay();
+ this.hide();
+ }
+
+ get focusChild() {
+ return this._focusChild;
+ }
+
+ _keyFocusIn(actor) {
+ if (this._focusChild == actor)
+ return;
+ this._focusChild = actor;
+ this.notify('focus-child');
+ }
+
+ _setMoreCount(_count) {
+ }
+
+ _ensureResultActors(results, callback) {
+ let metasNeeded = results.filter(
+ resultId => this._resultDisplays[resultId] === undefined);
+
+ if (metasNeeded.length === 0) {
+ callback(true);
+ } else {
+ this._cancellable.cancel();
+ this._cancellable.reset();
+
+ this.provider.getResultMetas(metasNeeded, metas => {
+ if (this._cancellable.is_cancelled()) {
+ if (metas.length > 0)
+ log('Search provider %s returned results after the request was canceled'.format(this.provider.id));
+ callback(false);
+ return;
+ }
+ if (metas.length != metasNeeded.length) {
+ log('Wrong number of result metas returned by search provider %s: '.format(this.provider.id) +
+ 'expected %d but got %d'.format(metasNeeded.length, metas.length));
+ callback(false);
+ return;
+ }
+ if (metas.some(meta => !meta.name || !meta.id)) {
+ log('Invalid result meta returned from search provider %s'.format(this.provider.id));
+ callback(false);
+ return;
+ }
+
+ metasNeeded.forEach((resultId, i) => {
+ let meta = metas[i];
+ let display = this._createResultDisplay(meta);
+ display.connect('key-focus-in', this._keyFocusIn.bind(this));
+ this._resultDisplays[resultId] = display;
+ });
+ callback(true);
+ }, this._cancellable);
+ }
+ }
+
+ updateSearch(providerResults, terms, callback) {
+ this._terms = terms;
+ if (providerResults.length == 0) {
+ this._clearResultDisplay();
+ this.hide();
+ callback();
+ } else {
+ let maxResults = this._getMaxDisplayedResults();
+ let results = maxResults > -1
+ ? this.provider.filterResults(providerResults, maxResults)
+ : providerResults;
+ let moreCount = Math.max(providerResults.length - results.length, 0);
+
+ this._ensureResultActors(results, successful => {
+ if (!successful) {
+ this._clearResultDisplay();
+ callback();
+ return;
+ }
+
+ // To avoid CSS transitions causing flickering when
+ // the first search result stays the same, we hide the
+ // content while filling in the results.
+ this.hide();
+ this._clearResultDisplay();
+ results.forEach(resultId => {
+ this._addItem(this._resultDisplays[resultId]);
+ });
+ this._setMoreCount(this.provider.canLaunchSearch ? moreCount : 0);
+ this.show();
+ callback();
+ });
+ }
+ }
+});
+
+var ListSearchResults = GObject.registerClass(
+class ListSearchResults extends SearchResultsBase {
+ _init(provider, resultsView) {
+ super._init(provider, resultsView);
+
+ this._container = new St.BoxLayout({ style_class: 'search-section-content' });
+ this.providerInfo = new ProviderInfo(provider);
+ this.providerInfo.connect('key-focus-in', this._keyFocusIn.bind(this));
+ this.providerInfo.connect('clicked', () => {
+ this.providerInfo.animateLaunch();
+ provider.launchSearch(this._terms);
+ Main.overview.toggle();
+ });
+
+ this._container.add_child(this.providerInfo);
+
+ this._content = new St.BoxLayout({
+ style_class: 'list-search-results',
+ vertical: true,
+ x_expand: true,
+ });
+ this._container.add_child(this._content);
+
+ this._resultDisplayBin.set_child(this._container);
+ }
+
+ _setMoreCount(count) {
+ this.providerInfo.setMoreCount(count);
+ }
+
+ _getMaxDisplayedResults() {
+ return MAX_LIST_SEARCH_RESULTS_ROWS;
+ }
+
+ _clearResultDisplay() {
+ this._content.remove_all_children();
+ }
+
+ _createResultDisplay(meta) {
+ return super._createResultDisplay(meta) ||
+ new ListSearchResult(this.provider, meta, this._resultsView);
+ }
+
+ _addItem(display) {
+ this._content.add_actor(display);
+ }
+
+ getFirstResult() {
+ if (this._content.get_n_children() > 0)
+ return this._content.get_child_at_index(0);
+ else
+ return null;
+ }
+});
+
+var GridSearchResultsLayout = GObject.registerClass({
+ Properties: {
+ 'spacing': GObject.ParamSpec.int('spacing', 'Spacing', 'Spacing',
+ GObject.ParamFlags.READWRITE, 0, GLib.MAXINT32, 0),
+ },
+}, class GridSearchResultsLayout extends Clutter.LayoutManager {
+ _init() {
+ super._init();
+ this._spacing = 0;
+ }
+
+ vfunc_set_container(container) {
+ this._container = container;
+ }
+
+ vfunc_get_preferred_width(container, forHeight) {
+ let minWidth = 0;
+ let natWidth = 0;
+ let first = true;
+
+ for (let child of container) {
+ if (!child.visible)
+ continue;
+
+ const [childMinWidth, childNatWidth] = child.get_preferred_width(forHeight);
+
+ minWidth = Math.max(minWidth, childMinWidth);
+ natWidth += childNatWidth;
+
+ if (first)
+ first = false;
+ else
+ natWidth += this._spacing;
+ }
+
+ return [minWidth, natWidth];
+ }
+
+ vfunc_get_preferred_height(container, forWidth) {
+ let minHeight = 0;
+ let natHeight = 0;
+
+ for (let child of container) {
+ if (!child.visible)
+ continue;
+
+ const [childMinHeight, childNatHeight] = child.get_preferred_height(forWidth);
+
+ minHeight = Math.max(minHeight, childMinHeight);
+ natHeight = Math.max(natHeight, childNatHeight);
+ }
+
+ return [minHeight, natHeight];
+ }
+
+ vfunc_allocate(container, box) {
+ const width = box.get_width();
+
+ const childBox = new Clutter.ActorBox();
+ childBox.x1 = 0;
+ childBox.y1 = 0;
+
+ let first = true;
+ for (let child of container) {
+ if (!child.visible)
+ continue;
+
+ if (first)
+ first = false;
+ else
+ childBox.x1 += this._spacing;
+
+ const [childWidth] = child.get_preferred_width(-1);
+ const [childHeight] = child.get_preferred_height(-1);
+
+ if (childBox.x1 + childWidth <= width)
+ childBox.set_size(childWidth, childHeight);
+ else
+ childBox.set_size(0, 0);
+
+ child.allocate(childBox);
+
+ childBox.x1 += childWidth;
+ }
+ }
+
+ columnsForWidth(width) {
+ if (!this._container)
+ return -1;
+
+ const [minWidth] = this.get_preferred_width(this._container, -1);
+
+ if (minWidth === 0)
+ return -1;
+
+ let nCols = 0;
+ while (width > minWidth) {
+ width -= minWidth;
+ if (nCols > 0)
+ width -= this._spacing;
+ nCols++;
+ }
+
+ return nCols;
+ }
+
+ get spacing() {
+ return this._spacing;
+ }
+
+ set spacing(v) {
+ if (this._spacing === v)
+ return;
+ this._spacing = v;
+ this.layout_changed();
+ }
+});
+
+var GridSearchResults = GObject.registerClass(
+class GridSearchResults extends SearchResultsBase {
+ _init(provider, resultsView) {
+ super._init(provider, resultsView);
+
+ this._grid = new St.Widget({ style_class: 'grid-search-results' });
+ this._grid.layout_manager = new GridSearchResultsLayout();
+
+ this._grid.connect('style-changed', () => {
+ const node = this._grid.get_theme_node();
+ this._grid.layout_manager.spacing = node.get_length('spacing');
+ });
+
+ this._resultDisplayBin.set_child(new St.Bin({
+ child: this._grid,
+ x_align: Clutter.ActorAlign.CENTER,
+ }));
+ }
+
+ _onDestroy() {
+ if (this._updateSearchLater) {
+ Meta.later_remove(this._updateSearchLater);
+ delete this._updateSearchLater;
+ }
+
+ super._onDestroy();
+ }
+
+ updateSearch(...args) {
+ if (this._notifyAllocationId)
+ this.disconnect(this._notifyAllocationId);
+ if (this._updateSearchLater) {
+ Meta.later_remove(this._updateSearchLater);
+ delete this._updateSearchLater;
+ }
+
+ // Make sure the maximum number of results calculated by
+ // _getMaxDisplayedResults() is updated after width changes.
+ this._notifyAllocationId = this.connect('notify::allocation', () => {
+ if (this._updateSearchLater)
+ return;
+ this._updateSearchLater = Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
+ delete this._updateSearchLater;
+ super.updateSearch(...args);
+ return GLib.SOURCE_REMOVE;
+ });
+ });
+
+ super.updateSearch(...args);
+ }
+
+ _getMaxDisplayedResults() {
+ let width = this.allocation.get_width();
+ if (width == 0)
+ return -1;
+
+ return this._grid.layout_manager.columnsForWidth(width);
+ }
+
+ _clearResultDisplay() {
+ this._grid.remove_all_children();
+ }
+
+ _createResultDisplay(meta) {
+ return super._createResultDisplay(meta) ||
+ new GridSearchResult(this.provider, meta, this._resultsView);
+ }
+
+ _addItem(display) {
+ this._grid.add_child(display);
+ }
+
+ getFirstResult() {
+ for (let child of this._grid) {
+ if (child.visible)
+ return child;
+ }
+ return null;
+ }
+});
+
+var SearchResultsView = GObject.registerClass({
+ Signals: { 'terms-changed': {} },
+}, class SearchResultsView extends St.BoxLayout {
+ _init() {
+ super._init({ name: 'searchResults', vertical: true });
+
+ this._parentalControlsManager = ParentalControlsManager.getDefault();
+ this._parentalControlsManager.connect('app-filter-changed', this._reloadRemoteProviders.bind(this));
+
+ this._content = new MaxWidthBox({
+ name: 'searchResultsContent',
+ vertical: true,
+ x_expand: true,
+ });
+
+ this._scrollView = new St.ScrollView({
+ overlay_scrollbars: true,
+ style_class: 'search-display vfade',
+ x_expand: true,
+ y_expand: true,
+ });
+ this._scrollView.set_policy(St.PolicyType.NEVER, St.PolicyType.AUTOMATIC);
+ this._scrollView.add_actor(this._content);
+
+ let action = new Clutter.PanAction({ interpolate: true });
+ action.connect('pan', this._onPan.bind(this));
+ this._scrollView.add_action(action);
+
+ this.add_child(this._scrollView);
+
+ this._statusText = new St.Label({
+ style_class: 'search-statustext',
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ this._statusBin = new St.Bin({ y_expand: true });
+ this.add_child(this._statusBin);
+ this._statusBin.add_actor(this._statusText);
+
+ this._highlightDefault = false;
+ this._defaultResult = null;
+ this._startingSearch = false;
+
+ this._terms = [];
+ this._results = {};
+
+ this._providers = [];
+
+ this._highlightRegex = null;
+
+ this._searchSettings = new Gio.Settings({ schema_id: SEARCH_PROVIDERS_SCHEMA });
+ this._searchSettings.connect('changed::disabled', this._reloadRemoteProviders.bind(this));
+ this._searchSettings.connect('changed::enabled', this._reloadRemoteProviders.bind(this));
+ this._searchSettings.connect('changed::disable-external', this._reloadRemoteProviders.bind(this));
+ this._searchSettings.connect('changed::sort-order', this._reloadRemoteProviders.bind(this));
+
+ this._searchTimeoutId = 0;
+ this._cancellable = new Gio.Cancellable();
+
+ this._registerProvider(new AppDisplay.AppSearchProvider());
+
+ let appSystem = Shell.AppSystem.get_default();
+ appSystem.connect('installed-changed', this._reloadRemoteProviders.bind(this));
+ this._reloadRemoteProviders();
+ }
+
+ get terms() {
+ return this._terms;
+ }
+
+ _reloadRemoteProviders() {
+ let remoteProviders = this._providers.filter(p => p.isRemoteProvider);
+ remoteProviders.forEach(provider => {
+ this._unregisterProvider(provider);
+ });
+
+ RemoteSearch.loadRemoteSearchProviders(this._searchSettings, providers => {
+ providers.forEach(this._registerProvider.bind(this));
+ });
+ }
+
+ _registerProvider(provider) {
+ provider.searchInProgress = false;
+
+ // Filter out unwanted providers.
+ if (provider.appInfo && !this._parentalControlsManager.shouldShowApp(provider.appInfo))
+ return;
+
+ this._providers.push(provider);
+ this._ensureProviderDisplay(provider);
+ }
+
+ _unregisterProvider(provider) {
+ let index = this._providers.indexOf(provider);
+ this._providers.splice(index, 1);
+
+ if (provider.display)
+ provider.display.destroy();
+ }
+
+ _gotResults(results, provider) {
+ this._results[provider.id] = results;
+ this._updateResults(provider, results);
+ }
+
+ _clearSearchTimeout() {
+ if (this._searchTimeoutId > 0) {
+ GLib.source_remove(this._searchTimeoutId);
+ this._searchTimeoutId = 0;
+ }
+ }
+
+ _reset() {
+ this._terms = [];
+ this._results = {};
+ this._clearDisplay();
+ this._clearSearchTimeout();
+ this._defaultResult = null;
+ this._startingSearch = false;
+
+ this._updateSearchProgress();
+ }
+
+ _doSearch() {
+ this._startingSearch = false;
+
+ let previousResults = this._results;
+ this._results = {};
+
+ this._providers.forEach(provider => {
+ provider.searchInProgress = true;
+
+ let previousProviderResults = previousResults[provider.id];
+ if (this._isSubSearch && previousProviderResults) {
+ provider.getSubsearchResultSet(previousProviderResults,
+ this._terms,
+ results => {
+ this._gotResults(results, provider);
+ },
+ this._cancellable);
+ } else {
+ provider.getInitialResultSet(this._terms,
+ results => {
+ this._gotResults(results, provider);
+ },
+ this._cancellable);
+ }
+ });
+
+ this._updateSearchProgress();
+
+ this._clearSearchTimeout();
+ }
+
+ _onSearchTimeout() {
+ this._searchTimeoutId = 0;
+ this._doSearch();
+ return GLib.SOURCE_REMOVE;
+ }
+
+ setTerms(terms) {
+ // Check for the case of making a duplicate previous search before
+ // setting state of the current search or cancelling the search.
+ // This will prevent incorrect state being as a result of a duplicate
+ // search while the previous search is still active.
+ let searchString = terms.join(' ');
+ let previousSearchString = this._terms.join(' ');
+ if (searchString == previousSearchString)
+ return;
+
+ this._startingSearch = true;
+
+ this._cancellable.cancel();
+ this._cancellable.reset();
+
+ if (terms.length == 0) {
+ this._reset();
+ return;
+ }
+
+ let isSubSearch = false;
+ if (this._terms.length > 0)
+ isSubSearch = searchString.indexOf(previousSearchString) == 0;
+
+ this._terms = terms;
+ this._isSubSearch = isSubSearch;
+ this._updateSearchProgress();
+
+ if (this._searchTimeoutId == 0)
+ this._searchTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 150, this._onSearchTimeout.bind(this));
+
+ let escapedTerms = this._terms.map(term => Shell.util_regex_escape(term));
+ this._highlightRegex = new RegExp('(%s)'.format(escapedTerms.join('|')), 'gi');
+
+ this.emit('terms-changed');
+ }
+
+ _onPan(action) {
+ let [dist_, dx_, dy] = action.get_motion_delta(0);
+ let adjustment = this._scrollView.vscroll.adjustment;
+ adjustment.value -= (dy / this.height) * adjustment.page_size;
+ return false;
+ }
+
+ _focusChildChanged(provider) {
+ Util.ensureActorVisibleInScrollView(this._scrollView, provider.focusChild);
+ }
+
+ _ensureProviderDisplay(provider) {
+ if (provider.display)
+ return;
+
+ let providerDisplay;
+ if (provider.appInfo)
+ providerDisplay = new ListSearchResults(provider, this);
+ else
+ providerDisplay = new GridSearchResults(provider, this);
+
+ providerDisplay.connect('notify::focus-child', this._focusChildChanged.bind(this));
+ providerDisplay.hide();
+ this._content.add(providerDisplay);
+ provider.display = providerDisplay;
+ }
+
+ _clearDisplay() {
+ this._providers.forEach(provider => {
+ provider.display.clear();
+ });
+ }
+
+ _maybeSetInitialSelection() {
+ let newDefaultResult = null;
+
+ let providers = this._providers;
+ for (let i = 0; i < providers.length; i++) {
+ let provider = providers[i];
+ let display = provider.display;
+
+ if (!display.visible)
+ continue;
+
+ let firstResult = display.getFirstResult();
+ if (firstResult) {
+ newDefaultResult = firstResult;
+ break; // select this one!
+ }
+ }
+
+ if (newDefaultResult != this._defaultResult) {
+ this._setSelected(this._defaultResult, false);
+ this._setSelected(newDefaultResult, this._highlightDefault);
+
+ this._defaultResult = newDefaultResult;
+ }
+ }
+
+ get searchInProgress() {
+ if (this._startingSearch)
+ return true;
+
+ return this._providers.some(p => p.searchInProgress);
+ }
+
+ _updateSearchProgress() {
+ let haveResults = this._providers.some(provider => {
+ let display = provider.display;
+ return display.getFirstResult() != null;
+ });
+
+ this._scrollView.visible = haveResults;
+ this._statusBin.visible = !haveResults;
+
+ if (!haveResults) {
+ if (this.searchInProgress)
+ this._statusText.set_text(_("Searching…"));
+ else
+ this._statusText.set_text(_("No results."));
+ }
+ }
+
+ _updateResults(provider, results) {
+ let terms = this._terms;
+ let display = provider.display;
+
+ display.updateSearch(results, terms, () => {
+ provider.searchInProgress = false;
+
+ this._maybeSetInitialSelection();
+ this._updateSearchProgress();
+ });
+ }
+
+ activateDefault() {
+ // If we have a search queued up, force the search now.
+ if (this._searchTimeoutId > 0)
+ this._doSearch();
+
+ if (this._defaultResult)
+ this._defaultResult.activate();
+ }
+
+ highlightDefault(highlight) {
+ this._highlightDefault = highlight;
+ this._setSelected(this._defaultResult, highlight);
+ }
+
+ popupMenuDefault() {
+ // If we have a search queued up, force the search now.
+ if (this._searchTimeoutId > 0)
+ this._doSearch();
+
+ if (this._defaultResult)
+ this._defaultResult.popup_menu();
+ }
+
+ navigateFocus(direction) {
+ let rtl = this.get_text_direction() == Clutter.TextDirection.RTL;
+ if (direction == St.DirectionType.TAB_BACKWARD ||
+ direction == (rtl
+ ? St.DirectionType.RIGHT
+ : St.DirectionType.LEFT) ||
+ direction == St.DirectionType.UP) {
+ this.navigate_focus(null, direction, false);
+ return;
+ }
+
+ let from = this._defaultResult ? this._defaultResult : null;
+ this.navigate_focus(from, direction, false);
+ }
+
+ _setSelected(result, selected) {
+ if (!result)
+ return;
+
+ if (selected) {
+ result.add_style_pseudo_class('selected');
+ Util.ensureActorVisibleInScrollView(this._scrollView, result);
+ } else {
+ result.remove_style_pseudo_class('selected');
+ }
+ }
+
+ highlightTerms(description) {
+ if (!description)
+ return '';
+
+ if (!this._highlightRegex)
+ return description;
+
+ return description.replace(this._highlightRegex, '<b>$1</b>');
+ }
+});
+
+var ProviderInfo = GObject.registerClass(
+class ProviderInfo extends St.Button {
+ _init(provider) {
+ this.provider = provider;
+ super._init({
+ style_class: 'search-provider-icon',
+ reactive: true,
+ can_focus: true,
+ accessible_name: provider.appInfo.get_name(),
+ track_hover: true,
+ y_align: Clutter.ActorAlign.START,
+ });
+
+ this._content = new St.BoxLayout({ vertical: false,
+ style_class: 'list-search-provider-content' });
+ this.set_child(this._content);
+
+ let icon = new St.Icon({ icon_size: this.PROVIDER_ICON_SIZE,
+ gicon: provider.appInfo.get_icon() });
+
+ let detailsBox = new St.BoxLayout({ style_class: 'list-search-provider-details',
+ vertical: true,
+ x_expand: true });
+
+ let nameLabel = new St.Label({ text: provider.appInfo.get_name(),
+ x_align: Clutter.ActorAlign.START });
+
+ this._moreLabel = new St.Label({ x_align: Clutter.ActorAlign.START });
+
+ detailsBox.add_actor(nameLabel);
+ detailsBox.add_actor(this._moreLabel);
+
+
+ this._content.add_actor(icon);
+ this._content.add_actor(detailsBox);
+ }
+
+ get PROVIDER_ICON_SIZE() {
+ return 32;
+ }
+
+ animateLaunch() {
+ let appSys = Shell.AppSystem.get_default();
+ let app = appSys.lookup_app(this.provider.appInfo.get_id());
+ if (app.state == Shell.AppState.STOPPED)
+ IconGrid.zoomOutActor(this._content);
+ }
+
+ setMoreCount(count) {
+ this._moreLabel.text = ngettext("%d more", "%d more", count).format(count);
+ this._moreLabel.visible = count > 0;
+ }
+});
diff --git a/js/ui/sessionMode.js b/js/ui/sessionMode.js
new file mode 100644
index 0000000..2136e94
--- /dev/null
+++ b/js/ui/sessionMode.js
@@ -0,0 +1,198 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported SessionMode, listModes */
+
+const ByteArray = imports.byteArray;
+const GLib = imports.gi.GLib;
+const Signals = imports.signals;
+
+const FileUtils = imports.misc.fileUtils;
+const Params = imports.misc.params;
+
+const Config = imports.misc.config;
+
+const DEFAULT_MODE = 'restrictive';
+
+const _modes = {
+ 'restrictive': {
+ parentMode: null,
+ stylesheetName: 'gnome-shell.css',
+ themeResourceName: 'gnome-shell-theme.gresource',
+ hasOverview: false,
+ showCalendarEvents: false,
+ allowSettings: false,
+ allowExtensions: false,
+ allowScreencast: false,
+ enabledExtensions: [],
+ hasRunDialog: false,
+ hasWorkspaces: false,
+ hasWindows: false,
+ hasNotifications: false,
+ hasWmMenus: false,
+ isLocked: false,
+ isGreeter: false,
+ isPrimary: false,
+ unlockDialog: null,
+ components: [],
+ panel: {
+ left: [],
+ center: [],
+ right: [],
+ },
+ panelStyle: null,
+ },
+
+ 'gdm': {
+ hasNotifications: true,
+ isGreeter: true,
+ isPrimary: true,
+ unlockDialog: imports.gdm.loginDialog.LoginDialog,
+ components: ['polkitAgent'],
+ panel: {
+ left: [],
+ center: ['dateMenu'],
+ right: ['dwellClick', 'a11y', 'keyboard', 'aggregateMenu'],
+ },
+ panelStyle: 'login-screen',
+ },
+
+ 'unlock-dialog': {
+ isLocked: true,
+ unlockDialog: undefined,
+ components: ['polkitAgent', 'telepathyClient'],
+ panel: {
+ left: [],
+ center: [],
+ right: ['dwellClick', 'a11y', 'keyboard', 'aggregateMenu'],
+ },
+ panelStyle: 'unlock-screen',
+ },
+
+ 'user': {
+ hasOverview: true,
+ showCalendarEvents: true,
+ allowSettings: true,
+ allowExtensions: true,
+ allowScreencast: true,
+ hasRunDialog: true,
+ hasWorkspaces: true,
+ hasWindows: true,
+ hasWmMenus: true,
+ hasNotifications: true,
+ isLocked: false,
+ isPrimary: true,
+ unlockDialog: imports.ui.unlockDialog.UnlockDialog,
+ components: Config.HAVE_NETWORKMANAGER
+ ? ['networkAgent', 'polkitAgent', 'telepathyClient',
+ 'keyring', 'autorunManager', 'automountManager']
+ : ['polkitAgent', 'telepathyClient',
+ 'keyring', 'autorunManager', 'automountManager'],
+
+ panel: {
+ left: ['activities', 'appMenu'],
+ center: ['dateMenu'],
+ right: ['dwellClick', 'a11y', 'keyboard', 'aggregateMenu'],
+ },
+ },
+};
+
+function _loadMode(file, info) {
+ let name = info.get_name();
+ let suffix = name.indexOf('.json');
+ let modeName = suffix == -1 ? name : name.slice(name, suffix);
+
+ if (Object.prototype.hasOwnProperty.call(_modes, modeName))
+ return;
+
+ let fileContent, success_, newMode;
+ try {
+ [success_, fileContent] = file.load_contents(null);
+ fileContent = ByteArray.toString(fileContent);
+ newMode = JSON.parse(fileContent);
+ } catch (e) {
+ return;
+ }
+
+ _modes[modeName] = {};
+ const excludedProps = ['unlockDialog'];
+ for (let prop in _modes[DEFAULT_MODE]) {
+ if (newMode[prop] !== undefined &&
+ !excludedProps.includes(prop))
+ _modes[modeName][prop] = newMode[prop];
+ }
+ _modes[modeName]['isPrimary'] = true;
+}
+
+function _loadModes() {
+ FileUtils.collectFromDatadirs('modes', false, _loadMode);
+}
+
+function listModes() {
+ _loadModes();
+ let loop = new GLib.MainLoop(null, false);
+ let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
+ let names = Object.getOwnPropertyNames(_modes);
+ for (let i = 0; i < names.length; i++) {
+ if (_modes[names[i]].isPrimary)
+ print(names[i]);
+ }
+ loop.quit();
+ });
+ GLib.Source.set_name_by_id(id, '[gnome-shell] listModes');
+ loop.run();
+}
+
+var SessionMode = class {
+ constructor() {
+ _loadModes();
+ let isPrimary = _modes[global.session_mode] &&
+ _modes[global.session_mode].isPrimary;
+ let mode = isPrimary ? global.session_mode : 'user';
+ this._modeStack = [mode];
+ this._sync();
+ }
+
+ pushMode(mode) {
+ this._modeStack.push(mode);
+ this._sync();
+ }
+
+ popMode(mode) {
+ if (this.currentMode != mode || this._modeStack.length === 1)
+ throw new Error("Invalid SessionMode.popMode");
+ this._modeStack.pop();
+ this._sync();
+ }
+
+ switchMode(to) {
+ if (this.currentMode == to)
+ return;
+ this._modeStack[this._modeStack.length - 1] = to;
+ this._sync();
+ }
+
+ get currentMode() {
+ return this._modeStack[this._modeStack.length - 1];
+ }
+
+ _sync() {
+ let params = _modes[this.currentMode];
+ let defaults;
+ if (params.parentMode) {
+ defaults = Params.parse(_modes[params.parentMode],
+ _modes[DEFAULT_MODE]);
+ } else {
+ defaults = _modes[DEFAULT_MODE];
+ }
+ params = Params.parse(params, defaults);
+
+ // A simplified version of Lang.copyProperties, handles
+ // undefined as a special case for "no change / inherit from previous mode"
+ for (let prop in params) {
+ if (params[prop] !== undefined)
+ this[prop] = params[prop];
+ }
+
+ this.emit('updated');
+ }
+};
+Signals.addSignalMethods(SessionMode.prototype);
diff --git a/js/ui/shellDBus.js b/js/ui/shellDBus.js
new file mode 100644
index 0000000..16217c8
--- /dev/null
+++ b/js/ui/shellDBus.js
@@ -0,0 +1,401 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported GnomeShell, ScreenSaverDBus */
+
+const { Gio, GLib, Meta } = imports.gi;
+
+const Config = imports.misc.config;
+const ExtensionDownloader = imports.ui.extensionDownloader;
+const ExtensionUtils = imports.misc.extensionUtils;
+const Main = imports.ui.main;
+const Screenshot = imports.ui.screenshot;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+const GnomeShellIface = loadInterfaceXML('org.gnome.Shell');
+const ScreenSaverIface = loadInterfaceXML('org.gnome.ScreenSaver');
+
+var GnomeShell = class {
+ constructor() {
+ this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(GnomeShellIface, this);
+ this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell');
+
+ this._extensionsService = new GnomeShellExtensions();
+ this._screenshotService = new Screenshot.ScreenshotService();
+
+ this._grabbedAccelerators = new Map();
+ this._grabbers = new Map();
+
+ global.display.connect('accelerator-activated',
+ (display, action, device, timestamp) => {
+ this._emitAcceleratorActivated(action, device, timestamp);
+ });
+
+ this._cachedOverviewVisible = false;
+ Main.overview.connect('showing',
+ this._checkOverviewVisibleChanged.bind(this));
+ Main.overview.connect('hidden',
+ this._checkOverviewVisibleChanged.bind(this));
+ }
+
+ /**
+ * Eval:
+ * @param {string} code: A string containing JavaScript code
+ * @returns {Array}
+ *
+ * This function executes arbitrary code in the main
+ * loop, and returns a boolean success and
+ * JSON representation of the object as a string.
+ *
+ * If evaluation completes without throwing an exception,
+ * then the return value will be [true, JSON.stringify(result)].
+ * If evaluation fails, then the return value will be
+ * [false, JSON.stringify(exception)];
+ *
+ */
+ Eval(code) {
+ if (!global.settings.get_boolean('development-tools'))
+ return [false, ''];
+
+ let returnValue;
+ let success;
+ try {
+ returnValue = JSON.stringify(eval(code));
+ // A hack; DBus doesn't have null/undefined
+ if (returnValue == undefined)
+ returnValue = '';
+ success = true;
+ } catch (e) {
+ returnValue = `${e}`;
+ success = false;
+ }
+ return [success, returnValue];
+ }
+
+ FocusSearch() {
+ Main.overview.focusSearch();
+ }
+
+ ShowOSD(params) {
+ for (let param in params)
+ params[param] = params[param].deep_unpack();
+
+ let { connector,
+ label,
+ level,
+ max_level: maxLevel,
+ icon: serializedIcon } = params;
+
+ let monitorIndex = -1;
+ if (connector) {
+ let monitorManager = Meta.MonitorManager.get();
+ monitorIndex = monitorManager.get_monitor_for_connector(connector);
+ }
+
+ let icon = null;
+ if (serializedIcon)
+ icon = Gio.Icon.new_for_string(serializedIcon);
+
+ Main.osdWindowManager.show(monitorIndex, icon, label, level, maxLevel);
+ }
+
+ FocusApp(id) {
+ this.ShowApplications();
+ Main.overview.viewSelector.appDisplay.selectApp(id);
+ }
+
+ ShowApplications() {
+ Main.overview.viewSelector.showApps();
+ }
+
+ GrabAcceleratorAsync(params, invocation) {
+ let [accel, modeFlags, grabFlags] = params;
+ let sender = invocation.get_sender();
+ let bindingAction = this._grabAcceleratorForSender(accel, modeFlags, grabFlags, sender);
+ invocation.return_value(GLib.Variant.new('(u)', [bindingAction]));
+ }
+
+ GrabAcceleratorsAsync(params, invocation) {
+ let [accels] = params;
+ let sender = invocation.get_sender();
+ let bindingActions = [];
+ for (let i = 0; i < accels.length; i++) {
+ let [accel, modeFlags, grabFlags] = accels[i];
+ bindingActions.push(this._grabAcceleratorForSender(accel, modeFlags, grabFlags, sender));
+ }
+ invocation.return_value(GLib.Variant.new('(au)', [bindingActions]));
+ }
+
+ UngrabAcceleratorAsync(params, invocation) {
+ let [action] = params;
+ let sender = invocation.get_sender();
+ let ungrabSucceeded = this._ungrabAcceleratorForSender(action, sender);
+
+ invocation.return_value(GLib.Variant.new('(b)', [ungrabSucceeded]));
+ }
+
+ UngrabAcceleratorsAsync(params, invocation) {
+ let [actions] = params;
+ let sender = invocation.get_sender();
+ let ungrabSucceeded = true;
+
+ for (let i = 0; i < actions.length; i++)
+ ungrabSucceeded &= this._ungrabAcceleratorForSender(actions[i], sender);
+
+ invocation.return_value(GLib.Variant.new('(b)', [ungrabSucceeded]));
+ }
+
+ _emitAcceleratorActivated(action, device, timestamp) {
+ let destination = this._grabbedAccelerators.get(action);
+ if (!destination)
+ return;
+
+ let connection = this._dbusImpl.get_connection();
+ let info = this._dbusImpl.get_info();
+ let params = { 'device-id': GLib.Variant.new('u', device.get_device_id()),
+ 'timestamp': GLib.Variant.new('u', timestamp),
+ 'action-mode': GLib.Variant.new('u', Main.actionMode) };
+
+ let deviceNode = device.get_device_node();
+ if (deviceNode)
+ params['device-node'] = GLib.Variant.new('s', deviceNode);
+
+ connection.emit_signal(destination,
+ this._dbusImpl.get_object_path(),
+ info ? info.name : null,
+ 'AcceleratorActivated',
+ GLib.Variant.new('(ua{sv})', [action, params]));
+ }
+
+ _grabAcceleratorForSender(accelerator, modeFlags, grabFlags, sender) {
+ let bindingAction = global.display.grab_accelerator(accelerator, grabFlags);
+ if (bindingAction == Meta.KeyBindingAction.NONE)
+ return Meta.KeyBindingAction.NONE;
+
+ let bindingName = Meta.external_binding_name_for_action(bindingAction);
+ Main.wm.allowKeybinding(bindingName, modeFlags);
+
+ this._grabbedAccelerators.set(bindingAction, sender);
+
+ if (!this._grabbers.has(sender)) {
+ let id = Gio.bus_watch_name(Gio.BusType.SESSION, sender, 0, null,
+ this._onGrabberBusNameVanished.bind(this));
+ this._grabbers.set(sender, id);
+ }
+
+ return bindingAction;
+ }
+
+ _ungrabAccelerator(action) {
+ let ungrabSucceeded = global.display.ungrab_accelerator(action);
+ if (ungrabSucceeded)
+ this._grabbedAccelerators.delete(action);
+
+ return ungrabSucceeded;
+ }
+
+ _ungrabAcceleratorForSender(action, sender) {
+ let grabbedBy = this._grabbedAccelerators.get(action);
+ if (sender != grabbedBy)
+ return false;
+
+ return this._ungrabAccelerator(action);
+ }
+
+ _onGrabberBusNameVanished(connection, name) {
+ let grabs = this._grabbedAccelerators.entries();
+ for (let [action, sender] of grabs) {
+ if (sender == name)
+ this._ungrabAccelerator(action);
+ }
+ Gio.bus_unwatch_name(this._grabbers.get(name));
+ this._grabbers.delete(name);
+ }
+
+ ShowMonitorLabelsAsync(params, invocation) {
+ let sender = invocation.get_sender();
+ let [dict] = params;
+ Main.osdMonitorLabeler.show(sender, dict);
+ invocation.return_value(null);
+ }
+
+ HideMonitorLabelsAsync(params, invocation) {
+ let sender = invocation.get_sender();
+ Main.osdMonitorLabeler.hide(sender);
+ invocation.return_value(null);
+ }
+
+ _checkOverviewVisibleChanged() {
+ if (Main.overview.visible !== this._cachedOverviewVisible) {
+ this._cachedOverviewVisible = Main.overview.visible;
+ this._dbusImpl.emit_property_changed('OverviewActive', new GLib.Variant('b', this._cachedOverviewVisible));
+ }
+ }
+
+ get Mode() {
+ return global.session_mode;
+ }
+
+ get OverviewActive() {
+ return this._cachedOverviewVisible;
+ }
+
+ set OverviewActive(visible) {
+ if (visible)
+ Main.overview.show();
+ else
+ Main.overview.hide();
+ }
+
+ get ShellVersion() {
+ return Config.PACKAGE_VERSION;
+ }
+};
+
+const GnomeShellExtensionsIface = loadInterfaceXML('org.gnome.Shell.Extensions');
+
+var GnomeShellExtensions = class {
+ constructor() {
+ this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(GnomeShellExtensionsIface, this);
+ this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell');
+
+ this._userExtensionsEnabled = this.UserExtensionsEnabled;
+ global.settings.connect('changed::disable-user-extensions', () => {
+ if (this._userExtensionsEnabled === this.UserExtensionsEnabled)
+ return;
+
+ this._userExtensionsEnabled = this.UserExtensionsEnabled;
+ this._dbusImpl.emit_property_changed('UserExtensionsEnabled',
+ new GLib.Variant('b', this._userExtensionsEnabled));
+ });
+
+ Main.extensionManager.connect('extension-state-changed',
+ this._extensionStateChanged.bind(this));
+ }
+
+ ListExtensions() {
+ let out = {};
+ Main.extensionManager.getUuids().forEach(uuid => {
+ let dbusObj = this.GetExtensionInfo(uuid);
+ out[uuid] = dbusObj;
+ });
+ return out;
+ }
+
+ GetExtensionInfo(uuid) {
+ let extension = Main.extensionManager.lookup(uuid) || {};
+ return ExtensionUtils.serializeExtension(extension);
+ }
+
+ GetExtensionErrors(uuid) {
+ let extension = Main.extensionManager.lookup(uuid);
+ if (!extension)
+ return [];
+
+ if (!extension.errors)
+ return [];
+
+ return extension.errors;
+ }
+
+ InstallRemoteExtensionAsync([uuid], invocation) {
+ return ExtensionDownloader.installExtension(uuid, invocation);
+ }
+
+ UninstallExtension(uuid) {
+ return ExtensionDownloader.uninstallExtension(uuid);
+ }
+
+ EnableExtension(uuid) {
+ return Main.extensionManager.enableExtension(uuid);
+ }
+
+ DisableExtension(uuid) {
+ return Main.extensionManager.disableExtension(uuid);
+ }
+
+ LaunchExtensionPrefs(uuid) {
+ this.OpenExtensionPrefs(uuid, '', {});
+ }
+
+ OpenExtensionPrefs(uuid, parentWindow, options) {
+ Main.extensionManager.openExtensionPrefs(uuid, parentWindow, options);
+ }
+
+ ReloadExtensionAsync(params, invocation) {
+ invocation.return_error_literal(
+ Gio.DBusError,
+ Gio.DBusError.NOT_SUPPORTED,
+ 'ReloadExtension is deprecated and does not work');
+ }
+
+ CheckForUpdates() {
+ ExtensionDownloader.checkForUpdates();
+ }
+
+ get ShellVersion() {
+ return Config.PACKAGE_VERSION;
+ }
+
+ get UserExtensionsEnabled() {
+ return !global.settings.get_boolean('disable-user-extensions');
+ }
+
+ set UserExtensionsEnabled(enable) {
+ global.settings.set_boolean('disable-user-extensions', !enable);
+ }
+
+ _extensionStateChanged(_, newState) {
+ let state = ExtensionUtils.serializeExtension(newState);
+ this._dbusImpl.emit_signal('ExtensionStateChanged',
+ new GLib.Variant('(sa{sv})', [newState.uuid, state]));
+
+ this._dbusImpl.emit_signal('ExtensionStatusChanged',
+ GLib.Variant.new('(sis)', [newState.uuid, newState.state, newState.error]));
+ }
+};
+
+var ScreenSaverDBus = class {
+ constructor(screenShield) {
+ this._screenShield = screenShield;
+ screenShield.connect('active-changed', shield => {
+ this._dbusImpl.emit_signal('ActiveChanged', GLib.Variant.new('(b)', [shield.active]));
+ });
+ screenShield.connect('wake-up-screen', () => {
+ this._dbusImpl.emit_signal('WakeUpScreen', null);
+ });
+
+ this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(ScreenSaverIface, this);
+ this._dbusImpl.export(Gio.DBus.session, '/org/gnome/ScreenSaver');
+
+ Gio.DBus.session.own_name('org.gnome.ScreenSaver', Gio.BusNameOwnerFlags.REPLACE, null, null);
+ }
+
+ LockAsync(parameters, invocation) {
+ let tmpId = this._screenShield.connect('lock-screen-shown', () => {
+ this._screenShield.disconnect(tmpId);
+
+ invocation.return_value(null);
+ });
+
+ this._screenShield.lock(true);
+ }
+
+ SetActive(active) {
+ if (active)
+ this._screenShield.activate(true);
+ else
+ this._screenShield.deactivate(false);
+ }
+
+ GetActive() {
+ return this._screenShield.active;
+ }
+
+ GetActiveTime() {
+ let started = this._screenShield.activationTime;
+ if (started > 0)
+ return Math.floor((GLib.get_monotonic_time() - started) / 1000000);
+ else
+ return 0;
+ }
+};
diff --git a/js/ui/shellEntry.js b/js/ui/shellEntry.js
new file mode 100644
index 0000000..38f5a83
--- /dev/null
+++ b/js/ui/shellEntry.js
@@ -0,0 +1,209 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported addContextMenu CapsLockWarning */
+
+const { Clutter, GObject, Pango, Shell, St } = imports.gi;
+
+const BoxPointer = imports.ui.boxpointer;
+const Main = imports.ui.main;
+const Params = imports.misc.params;
+const PopupMenu = imports.ui.popupMenu;
+
+var EntryMenu = class extends PopupMenu.PopupMenu {
+ constructor(entry) {
+ super(entry, 0, St.Side.TOP);
+
+ this._entry = entry;
+ this._clipboard = St.Clipboard.get_default();
+
+ // Populate menu
+ let item;
+ item = new PopupMenu.PopupMenuItem(_("Copy"));
+ item.connect('activate', this._onCopyActivated.bind(this));
+ this.addMenuItem(item);
+ this._copyItem = item;
+
+ item = new PopupMenu.PopupMenuItem(_("Paste"));
+ item.connect('activate', this._onPasteActivated.bind(this));
+ this.addMenuItem(item);
+ this._pasteItem = item;
+
+ if (entry instanceof St.PasswordEntry)
+ this._makePasswordItem();
+
+ Main.uiGroup.add_actor(this.actor);
+ this.actor.hide();
+ }
+
+ _makePasswordItem() {
+ let item = new PopupMenu.PopupMenuItem('');
+ item.connect('activate', this._onPasswordActivated.bind(this));
+ this.addMenuItem(item);
+ this._passwordItem = item;
+ }
+
+ open(animate) {
+ this._updatePasteItem();
+ this._updateCopyItem();
+ if (this._passwordItem)
+ this._updatePasswordItem();
+
+ super.open(animate);
+ this._entry.add_style_pseudo_class('focus');
+
+ let direction = St.DirectionType.TAB_FORWARD;
+ if (!this.actor.navigate_focus(null, direction, false))
+ this.actor.grab_key_focus();
+ }
+
+ _updateCopyItem() {
+ let selection = this._entry.clutter_text.get_selection();
+ this._copyItem.setSensitive(!this._entry.clutter_text.password_char &&
+ selection && selection != '');
+ }
+
+ _updatePasteItem() {
+ this._clipboard.get_text(St.ClipboardType.CLIPBOARD,
+ (clipboard, text) => {
+ this._pasteItem.setSensitive(text && text != '');
+ });
+ }
+
+ _updatePasswordItem() {
+ if (!this._entry.password_visible)
+ this._passwordItem.label.set_text(_("Show Text"));
+ else
+ this._passwordItem.label.set_text(_("Hide Text"));
+ }
+
+ _onCopyActivated() {
+ let selection = this._entry.clutter_text.get_selection();
+ this._clipboard.set_text(St.ClipboardType.CLIPBOARD, selection);
+ }
+
+ _onPasteActivated() {
+ this._clipboard.get_text(St.ClipboardType.CLIPBOARD,
+ (clipboard, text) => {
+ if (!text)
+ return;
+ this._entry.clutter_text.delete_selection();
+ let pos = this._entry.clutter_text.get_cursor_position();
+ this._entry.clutter_text.insert_text(text, pos);
+ });
+ }
+
+ _onPasswordActivated() {
+ this._entry.password_visible = !this._entry.password_visible;
+ }
+};
+
+function _setMenuAlignment(entry, stageX) {
+ let [success, entryX] = entry.transform_stage_point(stageX, 0);
+ if (success)
+ entry.menu.setSourceAlignment(entryX / entry.width);
+}
+
+function _onButtonPressEvent(actor, event, entry) {
+ if (entry.menu.isOpen) {
+ entry.menu.close(BoxPointer.PopupAnimation.FULL);
+ return Clutter.EVENT_STOP;
+ } else if (event.get_button() == 3) {
+ let [stageX] = event.get_coords();
+ _setMenuAlignment(entry, stageX);
+ entry.menu.open(BoxPointer.PopupAnimation.FULL);
+ return Clutter.EVENT_STOP;
+ }
+ return Clutter.EVENT_PROPAGATE;
+}
+
+function _onPopup(actor, entry) {
+ let cursorPosition = entry.clutter_text.get_cursor_position();
+ let [success, textX, textY_, lineHeight_] = entry.clutter_text.position_to_coords(cursorPosition);
+ if (success)
+ entry.menu.setSourceAlignment(textX / entry.width);
+ entry.menu.open(BoxPointer.PopupAnimation.FULL);
+}
+
+function addContextMenu(entry, params) {
+ if (entry.menu)
+ return;
+
+ params = Params.parse(params, { actionMode: Shell.ActionMode.POPUP });
+
+ entry.menu = new EntryMenu(entry);
+ entry._menuManager = new PopupMenu.PopupMenuManager(entry,
+ { actionMode: params.actionMode });
+ entry._menuManager.addMenu(entry.menu);
+
+ // Add an event handler to both the entry and its clutter_text; the former
+ // so padding is included in the clickable area, the latter because the
+ // event processing of ClutterText prevents event-bubbling.
+ entry.clutter_text.connect('button-press-event', (actor, event) => {
+ _onButtonPressEvent(actor, event, entry);
+ });
+ entry.connect('button-press-event', (actor, event) => {
+ _onButtonPressEvent(actor, event, entry);
+ });
+
+ entry.connect('popup-menu', actor => _onPopup(actor, entry));
+
+ entry.connect('destroy', () => {
+ entry.menu.destroy();
+ entry.menu = null;
+ entry._menuManager = null;
+ });
+}
+
+var CapsLockWarning = GObject.registerClass(
+class CapsLockWarning extends St.Label {
+ _init(params) {
+ let defaultParams = { style_class: 'caps-lock-warning-label' };
+ super._init(Object.assign(defaultParams, params));
+
+ this.text = _('Caps lock is on.');
+
+ this.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+ this.clutter_text.line_wrap = true;
+
+ let seat = Clutter.get_default_backend().get_default_seat();
+ this._keymap = seat.get_keymap();
+ this._stateChangedId = 0;
+
+ this.connect('notify::mapped', () => {
+ if (this.is_mapped()) {
+ this._stateChangedId = this._keymap.connect('state-changed',
+ () => this._sync(true));
+ } else {
+ this._keymap.disconnect(this._stateChangedId);
+ this._stateChangedId = 0;
+ }
+
+ this._sync(false);
+ });
+
+ this.connect('destroy', () => {
+ if (this._stateChangedId)
+ this._keymap.disconnect(this._stateChangedId);
+ });
+ }
+
+ _sync(animate) {
+ let capsLockOn = this._keymap.get_caps_lock_state();
+
+ this.remove_all_transitions();
+
+ const { naturalHeightSet } = this;
+ this.natural_height_set = false;
+ let [, height] = this.get_preferred_height(-1);
+ this.natural_height_set = naturalHeightSet;
+
+ this.ease({
+ height: capsLockOn ? height : 0,
+ opacity: capsLockOn ? 255 : 0,
+ duration: animate ? 200 : 0,
+ onComplete: () => {
+ if (capsLockOn)
+ this.height = -1;
+ },
+ });
+ }
+});
diff --git a/js/ui/shellMountOperation.js b/js/ui/shellMountOperation.js
new file mode 100644
index 0000000..2956c0e
--- /dev/null
+++ b/js/ui/shellMountOperation.js
@@ -0,0 +1,752 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported ShellMountOperation, GnomeShellMountOpHandler */
+
+const { Clutter, Gio, GLib, GObject, Pango, Shell, St } = imports.gi;
+
+const Animation = imports.ui.animation;
+const CheckBox = imports.ui.checkBox;
+const Dialog = imports.ui.dialog;
+const Main = imports.ui.main;
+const MessageTray = imports.ui.messageTray;
+const ModalDialog = imports.ui.modalDialog;
+const Params = imports.misc.params;
+const ShellEntry = imports.ui.shellEntry;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+const Util = imports.misc.util;
+
+var LIST_ITEM_ICON_SIZE = 48;
+var WORK_SPINNER_ICON_SIZE = 16;
+
+const REMEMBER_MOUNT_PASSWORD_KEY = 'remember-mount-password';
+
+/* ------ Common Utils ------- */
+function _setButtonsForChoices(dialog, oldChoices, choices) {
+ let buttons = [];
+ let buttonsChanged = oldChoices.length !== choices.length;
+
+ for (let idx = 0; idx < choices.length; idx++) {
+ let button = idx;
+
+ buttonsChanged = buttonsChanged || oldChoices[idx] !== choices[idx];
+
+ buttons.unshift({
+ label: choices[idx],
+ action: () => dialog.emit('response', button),
+ });
+ }
+
+ if (buttonsChanged)
+ dialog.setButtons(buttons);
+}
+
+function _setLabelsForMessage(content, message) {
+ let labels = message.split('\n');
+
+ content.title = labels.shift();
+ content.description = labels.join('\n');
+}
+
+/* -------------------------------------------------------- */
+
+var ShellMountOperation = class {
+ constructor(source, params) {
+ params = Params.parse(params, { existingDialog: null });
+
+ this._dialog = null;
+ this._dialogId = 0;
+ this._existingDialog = params.existingDialog;
+ this._processesDialog = null;
+
+ this.mountOp = new Shell.MountOperation();
+
+ this.mountOp.connect('ask-question',
+ this._onAskQuestion.bind(this));
+ this.mountOp.connect('ask-password',
+ this._onAskPassword.bind(this));
+ this.mountOp.connect('show-processes-2',
+ this._onShowProcesses2.bind(this));
+ this.mountOp.connect('aborted',
+ this.close.bind(this));
+ this.mountOp.connect('show-unmount-progress',
+ this._onShowUnmountProgress.bind(this));
+ }
+
+ _closeExistingDialog() {
+ if (!this._existingDialog)
+ return;
+
+ this._existingDialog.close();
+ this._existingDialog = null;
+ }
+
+ _onAskQuestion(op, message, choices) {
+ this._closeExistingDialog();
+ this._dialog = new ShellMountQuestionDialog();
+
+ this._dialogId = this._dialog.connect('response',
+ (object, choice) => {
+ this.mountOp.set_choice(choice);
+ this.mountOp.reply(Gio.MountOperationResult.HANDLED);
+
+ this.close();
+ });
+
+ this._dialog.update(message, choices);
+ this._dialog.open();
+ }
+
+ _onAskPassword(op, message, defaultUser, defaultDomain, flags) {
+ if (this._existingDialog) {
+ this._dialog = this._existingDialog;
+ this._dialog.reaskPassword();
+ } else {
+ this._dialog = new ShellMountPasswordDialog(message, flags);
+ }
+
+ this._dialogId = this._dialog.connect('response',
+ (object, choice, password, remember, hiddenVolume, systemVolume, pim) => {
+ if (choice == -1) {
+ this.mountOp.reply(Gio.MountOperationResult.ABORTED);
+ } else {
+ if (remember)
+ this.mountOp.set_password_save(Gio.PasswordSave.PERMANENTLY);
+ else
+ this.mountOp.set_password_save(Gio.PasswordSave.NEVER);
+
+ this.mountOp.set_password(password);
+ this.mountOp.set_is_tcrypt_hidden_volume(hiddenVolume);
+ this.mountOp.set_is_tcrypt_system_volume(systemVolume);
+ this.mountOp.set_pim(pim);
+ this.mountOp.reply(Gio.MountOperationResult.HANDLED);
+ }
+ });
+ this._dialog.open();
+ }
+
+ close(_op) {
+ this._closeExistingDialog();
+ this._processesDialog = null;
+
+ if (this._dialog) {
+ this._dialog.close();
+ this._dialog = null;
+ }
+
+ if (this._notifier) {
+ this._notifier.done();
+ this._notifier = null;
+ }
+ }
+
+ _onShowProcesses2(op) {
+ this._closeExistingDialog();
+
+ let processes = op.get_show_processes_pids();
+ let choices = op.get_show_processes_choices();
+ let message = op.get_show_processes_message();
+
+ if (!this._processesDialog) {
+ this._processesDialog = new ShellProcessesDialog();
+ this._dialog = this._processesDialog;
+
+ this._dialogId = this._processesDialog.connect('response',
+ (object, choice) => {
+ if (choice == -1) {
+ this.mountOp.reply(Gio.MountOperationResult.ABORTED);
+ } else {
+ this.mountOp.set_choice(choice);
+ this.mountOp.reply(Gio.MountOperationResult.HANDLED);
+ }
+
+ this.close();
+ });
+ this._processesDialog.open();
+ }
+
+ this._processesDialog.update(message, processes, choices);
+ }
+
+ _onShowUnmountProgress(op, message, timeLeft, bytesLeft) {
+ if (!this._notifier)
+ this._notifier = new ShellUnmountNotifier();
+
+ if (bytesLeft == 0)
+ this._notifier.done(message);
+ else
+ this._notifier.show(message);
+ }
+
+ borrowDialog() {
+ if (this._dialogId != 0) {
+ this._dialog.disconnect(this._dialogId);
+ this._dialogId = 0;
+ }
+
+ return this._dialog;
+ }
+};
+
+var ShellUnmountNotifier = GObject.registerClass(
+class ShellUnmountNotifier extends MessageTray.Source {
+ _init() {
+ super._init('', 'media-removable');
+
+ this._notification = null;
+ Main.messageTray.add(this);
+ }
+
+ show(message) {
+ let [header, text] = message.split('\n', 2);
+
+ if (!this._notification) {
+ this._notification = new MessageTray.Notification(this, header, text);
+ this._notification.setTransient(true);
+ this._notification.setUrgency(MessageTray.Urgency.CRITICAL);
+ } else {
+ this._notification.update(header, text);
+ }
+
+ this.showNotification(this._notification);
+ }
+
+ done(message) {
+ if (this._notification) {
+ this._notification.destroy();
+ this._notification = null;
+ }
+
+ if (message) {
+ let notification = new MessageTray.Notification(this, message, null);
+ notification.setTransient(true);
+
+ this.showNotification(notification);
+ }
+ }
+});
+
+var ShellMountQuestionDialog = GObject.registerClass({
+ Signals: { 'response': { param_types: [GObject.TYPE_INT] } },
+}, class ShellMountQuestionDialog extends ModalDialog.ModalDialog {
+ _init() {
+ super._init({ styleClass: 'mount-question-dialog' });
+
+ this._oldChoices = [];
+
+ this._content = new Dialog.MessageDialogContent();
+ this.contentLayout.add_child(this._content);
+ }
+
+ vfunc_key_release_event(event) {
+ if (event.keyval === Clutter.KEY_Escape) {
+ this.emit('response', -1);
+ return Clutter.EVENT_STOP;
+ }
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ update(message, choices) {
+ _setLabelsForMessage(this._content, message);
+ _setButtonsForChoices(this, this._oldChoices, choices);
+ this._oldChoices = choices;
+ }
+});
+
+var ShellMountPasswordDialog = GObject.registerClass({
+ Signals: { 'response': { param_types: [GObject.TYPE_INT,
+ GObject.TYPE_STRING,
+ GObject.TYPE_BOOLEAN,
+ GObject.TYPE_BOOLEAN,
+ GObject.TYPE_BOOLEAN,
+ GObject.TYPE_UINT] } },
+}, class ShellMountPasswordDialog extends ModalDialog.ModalDialog {
+ _init(message, flags) {
+ let strings = message.split('\n');
+ let title = strings.shift() || null;
+ let description = strings.shift() || null;
+ super._init({ styleClass: 'prompt-dialog' });
+
+ let disksApp = Shell.AppSystem.get_default().lookup_app('org.gnome.DiskUtility.desktop');
+
+ let content = new Dialog.MessageDialogContent({ title, description });
+
+ let passwordGridLayout = new Clutter.GridLayout({ orientation: Clutter.Orientation.VERTICAL });
+ let passwordGrid = new St.Widget({
+ style_class: 'prompt-dialog-password-grid',
+ layout_manager: passwordGridLayout,
+ });
+ passwordGridLayout.hookup_style(passwordGrid);
+
+ let rtl = passwordGrid.get_text_direction() === Clutter.TextDirection.RTL;
+ let curGridRow = 0;
+
+ if (flags & Gio.AskPasswordFlags.TCRYPT) {
+ this._hiddenVolume = new CheckBox.CheckBox(_("Hidden Volume"));
+ content.add_child(this._hiddenVolume);
+
+ this._systemVolume = new CheckBox.CheckBox(_("Windows System Volume"));
+ content.add_child(this._systemVolume);
+
+ this._keyfilesCheckbox = new CheckBox.CheckBox(_("Uses Keyfiles"));
+ this._keyfilesCheckbox.connect("clicked", this._onKeyfilesCheckboxClicked.bind(this));
+ content.add_child(this._keyfilesCheckbox);
+
+ this._keyfilesLabel = new St.Label({ visible: false });
+ this._keyfilesLabel.clutter_text.set_markup(
+ /* Translators: %s is the Disks application */
+ _('To unlock a volume that uses keyfiles, use the <i>%s</i> utility instead.')
+ .format(disksApp.get_name()));
+ this._keyfilesLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+ this._keyfilesLabel.clutter_text.line_wrap = true;
+ content.add_child(this._keyfilesLabel);
+
+ this._pimEntry = new St.PasswordEntry({
+ style_class: 'prompt-dialog-password-entry',
+ hint_text: _('PIM Number'),
+ can_focus: true,
+ x_expand: true,
+ });
+ this._pimEntry.clutter_text.connect('activate', this._onEntryActivate.bind(this));
+ ShellEntry.addContextMenu(this._pimEntry);
+
+ if (rtl)
+ passwordGridLayout.attach(this._pimEntry, 1, curGridRow, 1, 1);
+ else
+ passwordGridLayout.attach(this._pimEntry, 0, curGridRow, 1, 1);
+ curGridRow += 1;
+ } else {
+ this._hiddenVolume = null;
+ this._systemVolume = null;
+ this._pimEntry = null;
+ }
+
+ this._passwordEntry = new St.PasswordEntry({
+ style_class: 'prompt-dialog-password-entry',
+ hint_text: _('Password'),
+ can_focus: true,
+ x_expand: true,
+ });
+ this._passwordEntry.clutter_text.connect('activate', this._onEntryActivate.bind(this));
+ this.setInitialKeyFocus(this._passwordEntry);
+ ShellEntry.addContextMenu(this._passwordEntry);
+
+ this._workSpinner = new Animation.Spinner(WORK_SPINNER_ICON_SIZE, {
+ animate: true,
+ });
+
+ if (rtl) {
+ passwordGridLayout.attach(this._workSpinner, 0, curGridRow, 1, 1);
+ passwordGridLayout.attach(this._passwordEntry, 1, curGridRow, 1, 1);
+ } else {
+ passwordGridLayout.attach(this._passwordEntry, 0, curGridRow, 1, 1);
+ passwordGridLayout.attach(this._workSpinner, 1, curGridRow, 1, 1);
+ }
+ curGridRow += 1;
+
+ let warningBox = new St.BoxLayout({ vertical: true });
+
+ let capsLockWarning = new ShellEntry.CapsLockWarning();
+ warningBox.add_child(capsLockWarning);
+
+ this._errorMessageLabel = new St.Label({
+ style_class: 'prompt-dialog-error-label',
+ opacity: 0,
+ });
+ this._errorMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+ this._errorMessageLabel.clutter_text.line_wrap = true;
+ warningBox.add_child(this._errorMessageLabel);
+
+ passwordGridLayout.attach(warningBox, 0, curGridRow, 2, 1);
+
+ content.add_child(passwordGrid);
+
+ if (flags & Gio.AskPasswordFlags.SAVING_SUPPORTED) {
+ this._rememberChoice = new CheckBox.CheckBox(_("Remember Password"));
+ this._rememberChoice.checked =
+ global.settings.get_boolean(REMEMBER_MOUNT_PASSWORD_KEY);
+ content.add_child(this._rememberChoice);
+ } else {
+ this._rememberChoice = null;
+ }
+
+ this.contentLayout.add_child(content);
+
+ this._defaultButtons = [{
+ label: _("Cancel"),
+ action: this._onCancelButton.bind(this),
+ key: Clutter.KEY_Escape,
+ }, {
+ label: _("Unlock"),
+ action: this._onUnlockButton.bind(this),
+ default: true,
+ }];
+
+ this._usesKeyfilesButtons = [{
+ label: _("Cancel"),
+ action: this._onCancelButton.bind(this),
+ key: Clutter.KEY_Escape,
+ }, {
+ /* Translators: %s is the Disks application */
+ label: _("Open %s").format(disksApp.get_name()),
+ action: this._onOpenDisksButton.bind(this),
+ default: true,
+ }];
+
+ this.setButtons(this._defaultButtons);
+ }
+
+ reaskPassword() {
+ this._workSpinner.stop();
+ this._passwordEntry.set_text('');
+ this._errorMessageLabel.text = _('Sorry, that didn’t work. Please try again.');
+ this._errorMessageLabel.opacity = 255;
+
+ Util.wiggle(this._passwordEntry);
+ }
+
+ _onCancelButton() {
+ this.emit('response', -1, '', false, false, false, 0);
+ }
+
+ _onUnlockButton() {
+ this._onEntryActivate();
+ }
+
+ _onEntryActivate() {
+ let pim = 0;
+ if (this._pimEntry !== null) {
+ pim = this._pimEntry.get_text();
+
+ if (isNaN(pim)) {
+ this._pimEntry.set_text('');
+ this._errorMessageLabel.text = _('The PIM must be a number or empty.');
+ this._errorMessageLabel.opacity = 255;
+ return;
+ }
+
+ this._errorMessageLabel.opacity = 0;
+ }
+
+ global.settings.set_boolean(REMEMBER_MOUNT_PASSWORD_KEY,
+ this._rememberChoice && this._rememberChoice.checked);
+
+ this._workSpinner.play();
+ this.emit('response', 1,
+ this._passwordEntry.get_text(),
+ this._rememberChoice &&
+ this._rememberChoice.checked,
+ this._hiddenVolume &&
+ this._hiddenVolume.checked,
+ this._systemVolume &&
+ this._systemVolume.checked,
+ parseInt(pim));
+ }
+
+ _onKeyfilesCheckboxClicked() {
+ let useKeyfiles = this._keyfilesCheckbox.checked;
+ this._passwordEntry.reactive = !useKeyfiles;
+ this._passwordEntry.can_focus = !useKeyfiles;
+ this._pimEntry.reactive = !useKeyfiles;
+ this._pimEntry.can_focus = !useKeyfiles;
+ this._rememberChoice.reactive = !useKeyfiles;
+ this._rememberChoice.can_focus = !useKeyfiles;
+ this._keyfilesLabel.visible = useKeyfiles;
+ this.setButtons(useKeyfiles ? this._usesKeyfilesButtons : this._defaultButtons);
+ }
+
+ _onOpenDisksButton() {
+ let app = Shell.AppSystem.get_default().lookup_app('org.gnome.DiskUtility.desktop');
+ if (app) {
+ app.activate();
+ } else {
+ Main.notifyError(
+ /* Translators: %s is the Disks application */
+ _("Unable to start %s").format(app.get_name()),
+ /* Translators: %s is the Disks application */
+ _('Couldn’t find the %s application').format(app.get_name()));
+ }
+ this._onCancelButton();
+ }
+});
+
+var ShellProcessesDialog = GObject.registerClass({
+ Signals: { 'response': { param_types: [GObject.TYPE_INT] } },
+}, class ShellProcessesDialog extends ModalDialog.ModalDialog {
+ _init() {
+ super._init({ styleClass: 'processes-dialog' });
+
+ this._oldChoices = [];
+
+ this._content = new Dialog.MessageDialogContent();
+ this.contentLayout.add_child(this._content);
+
+ this._applicationSection = new Dialog.ListSection();
+ this._applicationSection.hide();
+ this.contentLayout.add_child(this._applicationSection);
+ }
+
+ vfunc_key_release_event(event) {
+ if (event.keyval === Clutter.KEY_Escape) {
+ this.emit('response', -1);
+ return Clutter.EVENT_STOP;
+ }
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _setAppsForPids(pids) {
+ // remove all the items
+ this._applicationSection.list.destroy_all_children();
+
+ pids.forEach(pid => {
+ let tracker = Shell.WindowTracker.get_default();
+ let app = tracker.get_app_from_pid(pid);
+
+ if (!app)
+ return;
+
+ let listItem = new Dialog.ListSectionItem({
+ icon_actor: app.create_icon_texture(LIST_ITEM_ICON_SIZE),
+ title: app.get_name(),
+ });
+ this._applicationSection.list.add_child(listItem);
+ });
+
+ this._applicationSection.visible =
+ this._applicationSection.list.get_n_children() > 0;
+ }
+
+ update(message, processes, choices) {
+ this._setAppsForPids(processes);
+ _setLabelsForMessage(this._content, message);
+ _setButtonsForChoices(this, this._oldChoices, choices);
+ this._oldChoices = choices;
+ }
+});
+
+const GnomeShellMountOpIface = loadInterfaceXML('org.Gtk.MountOperationHandler');
+
+var ShellMountOperationType = {
+ NONE: 0,
+ ASK_PASSWORD: 1,
+ ASK_QUESTION: 2,
+ SHOW_PROCESSES: 3,
+};
+
+var GnomeShellMountOpHandler = class {
+ constructor() {
+ this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(GnomeShellMountOpIface, this);
+ this._dbusImpl.export(Gio.DBus.session, '/org/gtk/MountOperationHandler');
+ Gio.bus_own_name_on_connection(Gio.DBus.session, 'org.gtk.MountOperationHandler',
+ Gio.BusNameOwnerFlags.REPLACE, null, null);
+
+ this._dialog = null;
+ this._volumeMonitor = Gio.VolumeMonitor.get();
+
+ this._ensureEmptyRequest();
+ }
+
+ _ensureEmptyRequest() {
+ this._currentId = null;
+ this._currentInvocation = null;
+ this._currentType = ShellMountOperationType.NONE;
+ }
+
+ _clearCurrentRequest(response, details) {
+ if (this._currentInvocation) {
+ this._currentInvocation.return_value(
+ GLib.Variant.new('(ua{sv})', [response, details]));
+ }
+
+ this._ensureEmptyRequest();
+ }
+
+ _setCurrentRequest(invocation, id, type) {
+ let oldId = this._currentId;
+ let oldType = this._currentType;
+ let requestId = '%s@%s'.format(id, invocation.get_sender());
+
+ this._clearCurrentRequest(Gio.MountOperationResult.UNHANDLED, {});
+
+ this._currentInvocation = invocation;
+ this._currentId = requestId;
+ this._currentType = type;
+
+ if (this._dialog && (oldId == requestId) && (oldType == type))
+ return true;
+
+ return false;
+ }
+
+ _closeDialog() {
+ if (this._dialog) {
+ this._dialog.close();
+ this._dialog = null;
+ }
+ }
+
+ /**
+ * AskPassword:
+ * @param {Array} params
+ * {string} id: an opaque ID identifying the object for which
+ * the operation is requested
+ * {string} message: the message to display
+ * {string} icon_name: the name of an icon to display
+ * {string} default_user: the default username for display
+ * {string} default_domain: the default domain for display
+ * {Gio.AskPasswordFlags} flags: a set of GAskPasswordFlags
+ * {Gio.MountOperationResults} response: a GMountOperationResult
+ * {Object} response_details: a dictionary containing response details as
+ * entered by the user. The dictionary MAY contain the following
+ * properties:
+ * - "password" -> (s): a password to be used to complete the mount operation
+ * - "password_save" -> (u): a GPasswordSave
+ * @param {Gio.DBusMethodInvocation} invocation
+ * The ID must be unique in the context of the calling process.
+ *
+ * The dialog will stay visible until clients call the Close() method, or
+ * another dialog becomes visible.
+ * Calling AskPassword again for the same id will have the effect to clear
+ * the existing dialog and update it with a message indicating the previous
+ * attempt went wrong.
+ */
+ AskPasswordAsync(params, invocation) {
+ let [id, message, iconName_, defaultUser_, defaultDomain_, flags] = params;
+
+ if (this._setCurrentRequest(invocation, id, ShellMountOperationType.ASK_PASSWORD)) {
+ this._dialog.reaskPassword();
+ return;
+ }
+
+ this._closeDialog();
+
+ this._dialog = new ShellMountPasswordDialog(message, flags);
+ this._dialog.connect('response',
+ (object, choice, password, remember, hiddenVolume, systemVolume, pim) => {
+ let details = {};
+ let response;
+
+ if (choice == -1) {
+ response = Gio.MountOperationResult.ABORTED;
+ } else {
+ response = Gio.MountOperationResult.HANDLED;
+
+ let passSave = remember ? Gio.PasswordSave.PERMANENTLY : Gio.PasswordSave.NEVER;
+ details['password_save'] = GLib.Variant.new('u', passSave);
+ details['password'] = GLib.Variant.new('s', password);
+ details['hidden_volume'] = GLib.Variant.new('b', hiddenVolume);
+ details['system_volume'] = GLib.Variant.new('b', systemVolume);
+ details['pim'] = GLib.Variant.new('u', pim);
+ }
+
+ this._clearCurrentRequest(response, details);
+ });
+ this._dialog.open();
+ }
+
+ /**
+ * AskQuestion:
+ * @param {Array} params - params
+ * {string} id: an opaque ID identifying the object for which
+ * the operation is requested
+ * The ID must be unique in the context of the calling process.
+ * {string} message: the message to display
+ * {string} icon_name: the name of an icon to display
+ * {string[]} choices: an array of choice strings
+ * @param {Gio.DBusMethodInvocation} invocation - invocation
+ *
+ * The dialog will stay visible until clients call the Close() method, or
+ * another dialog becomes visible.
+ * Calling AskQuestion again for the same id will have the effect to clear
+ * update the dialog with the new question.
+ */
+ AskQuestionAsync(params, invocation) {
+ let [id, message, iconName_, choices] = params;
+
+ if (this._setCurrentRequest(invocation, id, ShellMountOperationType.ASK_QUESTION)) {
+ this._dialog.update(message, choices);
+ return;
+ }
+
+ this._closeDialog();
+
+ this._dialog = new ShellMountQuestionDialog(message);
+ this._dialog.connect('response', (object, choice) => {
+ let response;
+ let details = {};
+
+ if (choice == -1) {
+ response = Gio.MountOperationResult.ABORTED;
+ } else {
+ response = Gio.MountOperationResult.HANDLED;
+ details['choice'] = GLib.Variant.new('i', choice);
+ }
+
+ this._clearCurrentRequest(response, details);
+ });
+
+ this._dialog.update(message, choices);
+ this._dialog.open();
+ }
+
+ /**
+ * ShowProcesses:
+ * @param {Array} params - params
+ * {string} id: an opaque ID identifying the object for which
+ * the operation is requested
+ * The ID must be unique in the context of the calling process.
+ * {string} message: the message to display
+ * {string} icon_name: the name of an icon to display
+ * {number[]} application_pids: the PIDs of the applications to display
+ * {string[]} choices: an array of choice strings
+ * @param {Gio.DBusMethodInvocation} invocation - invocation
+ *
+ * The dialog will stay visible until clients call the Close() method, or
+ * another dialog becomes visible.
+ * Calling ShowProcesses again for the same id will have the effect to clear
+ * the existing dialog and update it with the new message and the new list
+ * of processes.
+ */
+ ShowProcessesAsync(params, invocation) {
+ let [id, message, iconName_, applicationPids, choices] = params;
+
+ if (this._setCurrentRequest(invocation, id, ShellMountOperationType.SHOW_PROCESSES)) {
+ this._dialog.update(message, applicationPids, choices);
+ return;
+ }
+
+ this._closeDialog();
+
+ this._dialog = new ShellProcessesDialog();
+ this._dialog.connect('response', (object, choice) => {
+ let response;
+ let details = {};
+
+ if (choice == -1) {
+ response = Gio.MountOperationResult.ABORTED;
+ } else {
+ response = Gio.MountOperationResult.HANDLED;
+ details['choice'] = GLib.Variant.new('i', choice);
+ }
+
+ this._clearCurrentRequest(response, details);
+ });
+
+ this._dialog.update(message, applicationPids, choices);
+ this._dialog.open();
+ }
+
+ /**
+ * Close:
+ * @param {Array} _params - params
+ * @param {Gio.DBusMethodInvocation} _invocation - invocation
+ *
+ * Closes a dialog previously opened by AskPassword, AskQuestion or ShowProcesses.
+ * If no dialog is open, does nothing.
+ */
+ Close(_params, _invocation) {
+ this._clearCurrentRequest(Gio.MountOperationResult.UNHANDLED, {});
+ this._closeDialog();
+ }
+};
diff --git a/js/ui/slider.js b/js/ui/slider.js
new file mode 100644
index 0000000..ba3a233
--- /dev/null
+++ b/js/ui/slider.js
@@ -0,0 +1,213 @@
+/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */
+/* exported Slider */
+
+const { Atk, Clutter, GObject } = imports.gi;
+
+const BarLevel = imports.ui.barLevel;
+
+var SLIDER_SCROLL_STEP = 0.02; /* Slider scrolling step in % */
+
+var Slider = GObject.registerClass({
+ Signals: {
+ 'drag-begin': {},
+ 'drag-end': {},
+ },
+}, class Slider extends BarLevel.BarLevel {
+ _init(value) {
+ super._init({
+ value,
+ style_class: 'slider',
+ can_focus: true,
+ reactive: true,
+ accessible_role: Atk.Role.SLIDER,
+ x_expand: true,
+ });
+
+ this._releaseId = 0;
+ this._dragging = false;
+
+ this._customAccessible.connect('get-minimum-increment', this._getMinimumIncrement.bind(this));
+ }
+
+ vfunc_repaint() {
+ super.vfunc_repaint();
+
+ // Add handle
+ let cr = this.get_context();
+ let themeNode = this.get_theme_node();
+ let [width, height] = this.get_surface_size();
+
+ let handleRadius = themeNode.get_length('-slider-handle-radius');
+
+ let handleBorderWidth = themeNode.get_length('-slider-handle-border-width');
+ let [hasHandleColor, handleBorderColor] =
+ themeNode.lookup_color('-slider-handle-border-color', false);
+
+ const ceiledHandleRadius = Math.ceil(handleRadius + handleBorderWidth);
+ const handleX = ceiledHandleRadius +
+ (width - 2 * ceiledHandleRadius) * this._value / this._maxValue;
+ const handleY = height / 2;
+
+ let color = themeNode.get_foreground_color();
+ Clutter.cairo_set_source_color(cr, color);
+ cr.arc(handleX, handleY, handleRadius, 0, 2 * Math.PI);
+ cr.fillPreserve();
+ if (hasHandleColor && handleBorderWidth) {
+ Clutter.cairo_set_source_color(cr, handleBorderColor);
+ cr.setLineWidth(handleBorderWidth);
+ cr.stroke();
+ }
+ cr.$dispose();
+ }
+
+ vfunc_button_press_event() {
+ return this.startDragging(Clutter.get_current_event());
+ }
+
+ startDragging(event) {
+ if (this._dragging)
+ return Clutter.EVENT_PROPAGATE;
+
+ this._dragging = true;
+
+ let device = event.get_device();
+ let sequence = event.get_event_sequence();
+
+ if (sequence != null)
+ device.sequence_grab(sequence, this);
+ else
+ device.grab(this);
+
+ this._grabbedDevice = device;
+ this._grabbedSequence = sequence;
+
+ // We need to emit 'drag-begin' before moving the handle to make
+ // sure that no 'notify::value' signal is emitted before this one.
+ this.emit('drag-begin');
+
+ let absX, absY;
+ [absX, absY] = event.get_coords();
+ this._moveHandle(absX, absY);
+ return Clutter.EVENT_STOP;
+ }
+
+ _endDragging() {
+ if (this._dragging) {
+ if (this._releaseId) {
+ this.disconnect(this._releaseId);
+ this._releaseId = 0;
+ }
+
+ if (this._grabbedSequence != null)
+ this._grabbedDevice.sequence_ungrab(this._grabbedSequence);
+ else
+ this._grabbedDevice.ungrab();
+
+ this._grabbedSequence = null;
+ this._grabbedDevice = null;
+ this._dragging = false;
+
+ this.emit('drag-end');
+ }
+ return Clutter.EVENT_STOP;
+ }
+
+ vfunc_button_release_event() {
+ if (this._dragging && !this._grabbedSequence)
+ return this._endDragging();
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_touch_event() {
+ let event = Clutter.get_current_event();
+ let device = event.get_device();
+ let sequence = event.get_event_sequence();
+
+ if (!this._dragging &&
+ event.type() == Clutter.EventType.TOUCH_BEGIN) {
+ this.startDragging(event);
+ return Clutter.EVENT_STOP;
+ } else if (device.sequence_get_grabbed_actor(sequence) == this) {
+ if (event.type() == Clutter.EventType.TOUCH_UPDATE)
+ return this._motionEvent(this, event);
+ else if (event.type() == Clutter.EventType.TOUCH_END)
+ return this._endDragging();
+ }
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ scroll(event) {
+ let direction = event.get_scroll_direction();
+ let delta;
+
+ if (event.is_pointer_emulated())
+ return Clutter.EVENT_PROPAGATE;
+
+ if (direction == Clutter.ScrollDirection.DOWN) {
+ delta = -SLIDER_SCROLL_STEP;
+ } else if (direction == Clutter.ScrollDirection.UP) {
+ delta = SLIDER_SCROLL_STEP;
+ } else if (direction == Clutter.ScrollDirection.SMOOTH) {
+ let [, dy] = event.get_scroll_delta();
+ // Even though the slider is horizontal, use dy to match
+ // the UP/DOWN above.
+ delta = -dy * SLIDER_SCROLL_STEP;
+ }
+
+ this.value = Math.min(Math.max(0, this._value + delta), this._maxValue);
+
+ return Clutter.EVENT_STOP;
+ }
+
+ vfunc_scroll_event() {
+ return this.scroll(Clutter.get_current_event());
+ }
+
+ vfunc_motion_event() {
+ if (this._dragging && !this._grabbedSequence)
+ return this._motionEvent(this, Clutter.get_current_event());
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _motionEvent(actor, event) {
+ let absX, absY;
+ [absX, absY] = event.get_coords();
+ this._moveHandle(absX, absY);
+ return Clutter.EVENT_STOP;
+ }
+
+ vfunc_key_press_event(keyPressEvent) {
+ let key = keyPressEvent.keyval;
+ if (key == Clutter.KEY_Right || key == Clutter.KEY_Left) {
+ let delta = key == Clutter.KEY_Right ? 0.1 : -0.1;
+ this.value = Math.max(0, Math.min(this._value + delta, this._maxValue));
+ return Clutter.EVENT_STOP;
+ }
+ return super.vfunc_key_press_event(keyPressEvent);
+ }
+
+ _moveHandle(absX, _absY) {
+ let relX, sliderX;
+ [sliderX] = this.get_transformed_position();
+ relX = absX - sliderX;
+
+ let width = this._barLevelWidth;
+ let handleRadius = this.get_theme_node().get_length('-slider-handle-radius');
+
+ let newvalue;
+ if (relX < handleRadius)
+ newvalue = 0;
+ else if (relX > width - handleRadius)
+ newvalue = 1;
+ else
+ newvalue = (relX - handleRadius) / (width - 2 * handleRadius);
+ this.value = newvalue * this._maxValue;
+ }
+
+ _getMinimumIncrement() {
+ return 0.1;
+ }
+});
diff --git a/js/ui/status/accessibility.js b/js/ui/status/accessibility.js
new file mode 100644
index 0000000..04406e2
--- /dev/null
+++ b/js/ui/status/accessibility.js
@@ -0,0 +1,200 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported ATIndicator */
+
+const { Gio, GLib, GObject, St } = imports.gi;
+
+const PanelMenu = imports.ui.panelMenu;
+const PopupMenu = imports.ui.popupMenu;
+
+const A11Y_SCHEMA = 'org.gnome.desktop.a11y';
+const KEY_ALWAYS_SHOW = 'always-show-universal-access-status';
+
+const A11Y_KEYBOARD_SCHEMA = 'org.gnome.desktop.a11y.keyboard';
+const KEY_STICKY_KEYS_ENABLED = 'stickykeys-enable';
+const KEY_BOUNCE_KEYS_ENABLED = 'bouncekeys-enable';
+const KEY_SLOW_KEYS_ENABLED = 'slowkeys-enable';
+const KEY_MOUSE_KEYS_ENABLED = 'mousekeys-enable';
+
+const APPLICATIONS_SCHEMA = 'org.gnome.desktop.a11y.applications';
+
+var DPI_FACTOR_LARGE = 1.25;
+
+const WM_SCHEMA = 'org.gnome.desktop.wm.preferences';
+const KEY_VISUAL_BELL = 'visual-bell';
+
+const DESKTOP_INTERFACE_SCHEMA = 'org.gnome.desktop.interface';
+const KEY_GTK_THEME = 'gtk-theme';
+const KEY_ICON_THEME = 'icon-theme';
+const KEY_TEXT_SCALING_FACTOR = 'text-scaling-factor';
+
+const HIGH_CONTRAST_THEME = 'HighContrast';
+
+var ATIndicator = GObject.registerClass(
+class ATIndicator extends PanelMenu.Button {
+ _init() {
+ super._init(0.5, _("Accessibility"));
+
+ this._hbox = new St.BoxLayout({ style_class: 'panel-status-menu-box' });
+ this._hbox.add_child(new St.Icon({ style_class: 'system-status-icon',
+ icon_name: 'preferences-desktop-accessibility-symbolic' }));
+ this._hbox.add_child(PopupMenu.arrowIcon(St.Side.BOTTOM));
+
+ this.add_child(this._hbox);
+
+ this._a11ySettings = new Gio.Settings({ schema_id: A11Y_SCHEMA });
+ this._a11ySettings.connect('changed::%s'.format(KEY_ALWAYS_SHOW), this._queueSyncMenuVisibility.bind(this));
+
+ let highContrast = this._buildHCItem();
+ this.menu.addMenuItem(highContrast);
+
+ let magnifier = this._buildItem(_("Zoom"), APPLICATIONS_SCHEMA,
+ 'screen-magnifier-enabled');
+ this.menu.addMenuItem(magnifier);
+
+ let textZoom = this._buildFontItem();
+ this.menu.addMenuItem(textZoom);
+
+ let screenReader = this._buildItem(_("Screen Reader"), APPLICATIONS_SCHEMA,
+ 'screen-reader-enabled');
+ this.menu.addMenuItem(screenReader);
+
+ let screenKeyboard = this._buildItem(_("Screen Keyboard"), APPLICATIONS_SCHEMA,
+ 'screen-keyboard-enabled');
+ this.menu.addMenuItem(screenKeyboard);
+
+ let visualBell = this._buildItem(_("Visual Alerts"), WM_SCHEMA, KEY_VISUAL_BELL);
+ this.menu.addMenuItem(visualBell);
+
+ let stickyKeys = this._buildItem(_("Sticky Keys"), A11Y_KEYBOARD_SCHEMA, KEY_STICKY_KEYS_ENABLED);
+ this.menu.addMenuItem(stickyKeys);
+
+ let slowKeys = this._buildItem(_("Slow Keys"), A11Y_KEYBOARD_SCHEMA, KEY_SLOW_KEYS_ENABLED);
+ this.menu.addMenuItem(slowKeys);
+
+ let bounceKeys = this._buildItem(_("Bounce Keys"), A11Y_KEYBOARD_SCHEMA, KEY_BOUNCE_KEYS_ENABLED);
+ this.menu.addMenuItem(bounceKeys);
+
+ let mouseKeys = this._buildItem(_("Mouse Keys"), A11Y_KEYBOARD_SCHEMA, KEY_MOUSE_KEYS_ENABLED);
+ this.menu.addMenuItem(mouseKeys);
+
+ this._syncMenuVisibility();
+ }
+
+ _syncMenuVisibility() {
+ this._syncMenuVisibilityIdle = 0;
+
+ let alwaysShow = this._a11ySettings.get_boolean(KEY_ALWAYS_SHOW);
+ let items = this.menu._getMenuItems();
+
+ this.visible = alwaysShow || items.some(f => !!f.state);
+
+ return GLib.SOURCE_REMOVE;
+ }
+
+ _queueSyncMenuVisibility() {
+ if (this._syncMenuVisibilityIdle)
+ return;
+
+ this._syncMenuVisibilityIdle = GLib.idle_add(GLib.PRIORITY_DEFAULT, this._syncMenuVisibility.bind(this));
+ GLib.Source.set_name_by_id(this._syncMenuVisibilityIdle, '[gnome-shell] this._syncMenuVisibility');
+ }
+
+ _buildItemExtended(string, initialValue, writable, onSet) {
+ let widget = new PopupMenu.PopupSwitchMenuItem(string, initialValue);
+ if (!writable) {
+ widget.reactive = false;
+ } else {
+ widget.connect('toggled', item => {
+ onSet(item.state);
+ });
+ }
+ return widget;
+ }
+
+ _buildItem(string, schema, key) {
+ let settings = new Gio.Settings({ schema_id: schema });
+ let widget = this._buildItemExtended(string,
+ settings.get_boolean(key),
+ settings.is_writable(key),
+ enabled => settings.set_boolean(key, enabled));
+
+ settings.connect('changed::%s'.format(key), () => {
+ widget.setToggleState(settings.get_boolean(key));
+
+ this._queueSyncMenuVisibility();
+ });
+
+ return widget;
+ }
+
+ _buildHCItem() {
+ let interfaceSettings = new Gio.Settings({ schema_id: DESKTOP_INTERFACE_SCHEMA });
+ let gtkTheme = interfaceSettings.get_string(KEY_GTK_THEME);
+ let iconTheme = interfaceSettings.get_string(KEY_ICON_THEME);
+ let hasHC = gtkTheme == HIGH_CONTRAST_THEME;
+ let highContrast = this._buildItemExtended(
+ _("High Contrast"),
+ hasHC,
+ interfaceSettings.is_writable(KEY_GTK_THEME) &&
+ interfaceSettings.is_writable(KEY_ICON_THEME),
+ enabled => {
+ if (enabled) {
+ interfaceSettings.set_string(KEY_ICON_THEME, HIGH_CONTRAST_THEME);
+ interfaceSettings.set_string(KEY_GTK_THEME, HIGH_CONTRAST_THEME);
+ } else if (!hasHC) {
+ interfaceSettings.set_string(KEY_ICON_THEME, iconTheme);
+ interfaceSettings.set_string(KEY_GTK_THEME, gtkTheme);
+ } else {
+ interfaceSettings.reset(KEY_ICON_THEME);
+ interfaceSettings.reset(KEY_GTK_THEME);
+ }
+ });
+
+ interfaceSettings.connect('changed::%s'.format(KEY_GTK_THEME), () => {
+ let value = interfaceSettings.get_string(KEY_GTK_THEME);
+ if (value == HIGH_CONTRAST_THEME) {
+ highContrast.setToggleState(true);
+ } else {
+ highContrast.setToggleState(false);
+ gtkTheme = value;
+ }
+
+ this._queueSyncMenuVisibility();
+ });
+
+ interfaceSettings.connect('changed::%s'.format(KEY_ICON_THEME), () => {
+ let value = interfaceSettings.get_string(KEY_ICON_THEME);
+ if (value != HIGH_CONTRAST_THEME)
+ iconTheme = value;
+ });
+
+ return highContrast;
+ }
+
+ _buildFontItem() {
+ let settings = new Gio.Settings({ schema_id: DESKTOP_INTERFACE_SCHEMA });
+ let factor = settings.get_double(KEY_TEXT_SCALING_FACTOR);
+ let initialSetting = factor > 1.0;
+ let widget = this._buildItemExtended(_("Large Text"),
+ initialSetting,
+ settings.is_writable(KEY_TEXT_SCALING_FACTOR),
+ enabled => {
+ if (enabled) {
+ settings.set_double(
+ KEY_TEXT_SCALING_FACTOR, DPI_FACTOR_LARGE);
+ } else {
+ settings.reset(KEY_TEXT_SCALING_FACTOR);
+ }
+ });
+
+ settings.connect('changed::%s'.format(KEY_TEXT_SCALING_FACTOR), () => {
+ factor = settings.get_double(KEY_TEXT_SCALING_FACTOR);
+ let active = factor > 1.0;
+ widget.setToggleState(active);
+
+ this._queueSyncMenuVisibility();
+ });
+
+ return widget;
+ }
+});
diff --git a/js/ui/status/bluetooth.js b/js/ui/status/bluetooth.js
new file mode 100644
index 0000000..98ccc3d
--- /dev/null
+++ b/js/ui/status/bluetooth.js
@@ -0,0 +1,158 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+const { Gio, GLib, GnomeBluetooth, GObject } = imports.gi;
+
+const Main = imports.ui.main;
+const PanelMenu = imports.ui.panelMenu;
+const PopupMenu = imports.ui.popupMenu;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+const BUS_NAME = 'org.gnome.SettingsDaemon.Rfkill';
+const OBJECT_PATH = '/org/gnome/SettingsDaemon/Rfkill';
+
+const RfkillManagerInterface = loadInterfaceXML('org.gnome.SettingsDaemon.Rfkill');
+const RfkillManagerProxy = Gio.DBusProxy.makeProxyWrapper(RfkillManagerInterface);
+
+const HAD_BLUETOOTH_DEVICES_SETUP = 'had-bluetooth-devices-setup';
+
+var Indicator = GObject.registerClass(
+class Indicator extends PanelMenu.SystemIndicator {
+ _init() {
+ super._init();
+
+ this._indicator = this._addIndicator();
+ this._indicator.icon_name = 'bluetooth-active-symbolic';
+ this._hadSetupDevices = global.settings.get_boolean(HAD_BLUETOOTH_DEVICES_SETUP);
+
+ this._proxy = new RfkillManagerProxy(Gio.DBus.session, BUS_NAME, OBJECT_PATH,
+ (proxy, error) => {
+ if (error) {
+ log(error.message);
+ return;
+ }
+
+ this._sync();
+ });
+ this._proxy.connect('g-properties-changed', this._queueSync.bind(this));
+
+ this._item = new PopupMenu.PopupSubMenuMenuItem(_("Bluetooth"), true);
+ this._item.icon.icon_name = 'bluetooth-active-symbolic';
+
+ this._toggleItem = new PopupMenu.PopupMenuItem('');
+ this._toggleItem.connect('activate', () => {
+ this._proxy.BluetoothAirplaneMode = !this._proxy.BluetoothAirplaneMode;
+ });
+ this._item.menu.addMenuItem(this._toggleItem);
+
+ this._item.menu.addSettingsAction(_("Bluetooth Settings"), 'gnome-bluetooth-panel.desktop');
+ this.menu.addMenuItem(this._item);
+
+ this._syncId = 0;
+ this._adapter = null;
+
+ this._client = new GnomeBluetooth.Client();
+ this._model = this._client.get_model();
+ this._model.connect('row-deleted', this._queueSync.bind(this));
+ this._model.connect('row-changed', this._queueSync.bind(this));
+ this._model.connect('row-inserted', this._sync.bind(this));
+ Main.sessionMode.connect('updated', this._sync.bind(this));
+ this._sync();
+ }
+
+ _setHadSetupDevices(value) {
+ if (this._hadSetupDevices === value)
+ return;
+
+ this._hadSetupDevices = value;
+ global.settings.set_boolean(
+ HAD_BLUETOOTH_DEVICES_SETUP, this._hadSetupDevices);
+ }
+
+ _getDefaultAdapter() {
+ let [ret, iter] = this._model.get_iter_first();
+ while (ret) {
+ let isDefault = this._model.get_value(iter,
+ GnomeBluetooth.Column.DEFAULT);
+ let isPowered = this._model.get_value(iter,
+ GnomeBluetooth.Column.POWERED);
+ if (isDefault && isPowered)
+ return iter;
+ ret = this._model.iter_next(iter);
+ }
+ return null;
+ }
+
+ _getDeviceInfos(adapter) {
+ if (!adapter)
+ return [];
+
+ let deviceInfos = [];
+ let [ret, iter] = this._model.iter_children(adapter);
+ while (ret) {
+ const isPaired = this._model.get_value(iter,
+ GnomeBluetooth.Column.PAIRED);
+ const isTrusted = this._model.get_value(iter,
+ GnomeBluetooth.Column.TRUSTED);
+
+ if (isPaired || isTrusted) {
+ deviceInfos.push({
+ connected: this._model.get_value(iter,
+ GnomeBluetooth.Column.CONNECTED),
+ name: this._model.get_value(iter,
+ GnomeBluetooth.Column.ALIAS),
+ });
+ }
+
+ ret = this._model.iter_next(iter);
+ }
+
+ return deviceInfos;
+ }
+
+ _queueSync() {
+ if (this._syncId)
+ return;
+ this._syncId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
+ this._syncId = 0;
+ this._sync();
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+
+ _sync() {
+ let adapter = this._getDefaultAdapter();
+ let devices = this._getDeviceInfos(adapter);
+ const connectedDevices = devices.filter(dev => dev.connected);
+ const nConnectedDevices = connectedDevices.length;
+
+ if (adapter && this._adapter)
+ this._setHadSetupDevices(devices.length > 0);
+ this._adapter = adapter;
+
+ let sensitive = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter;
+
+ this.menu.setSensitive(sensitive);
+ this._indicator.visible = nConnectedDevices > 0;
+
+ // Remember if there were setup devices and show the menu
+ // if we've seen setup devices and we're not hard blocked
+ if (this._hadSetupDevices)
+ this._item.visible = !this._proxy.BluetoothHardwareAirplaneMode;
+ else
+ this._item.visible = this._proxy.BluetoothHasAirplaneMode && !this._proxy.BluetoothAirplaneMode;
+
+ if (nConnectedDevices > 1)
+ /* Translators: this is the number of connected bluetooth devices */
+ this._item.label.text = ngettext('%d Connected', '%d Connected', nConnectedDevices).format(nConnectedDevices);
+ else if (nConnectedDevices === 1)
+ this._item.label.text = connectedDevices[0].name;
+ else if (adapter === null)
+ this._item.label.text = _('Bluetooth Off');
+ else
+ this._item.label.text = _('Bluetooth On');
+
+ this._toggleItem.label.text = this._proxy.BluetoothAirplaneMode ? _('Turn On') : _('Turn Off');
+ }
+});
diff --git a/js/ui/status/brightness.js b/js/ui/status/brightness.js
new file mode 100644
index 0000000..1724788
--- /dev/null
+++ b/js/ui/status/brightness.js
@@ -0,0 +1,73 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+const { Gio, GObject, St } = imports.gi;
+
+const PanelMenu = imports.ui.panelMenu;
+const PopupMenu = imports.ui.popupMenu;
+const Slider = imports.ui.slider;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+const BUS_NAME = 'org.gnome.SettingsDaemon.Power';
+const OBJECT_PATH = '/org/gnome/SettingsDaemon/Power';
+
+const BrightnessInterface = loadInterfaceXML('org.gnome.SettingsDaemon.Power.Screen');
+const BrightnessProxy = Gio.DBusProxy.makeProxyWrapper(BrightnessInterface);
+
+var Indicator = GObject.registerClass(
+class Indicator extends PanelMenu.SystemIndicator {
+ _init() {
+ super._init();
+ this._proxy = new BrightnessProxy(Gio.DBus.session, BUS_NAME, OBJECT_PATH,
+ (proxy, error) => {
+ if (error) {
+ log(error.message);
+ return;
+ }
+
+ this._proxy.connect('g-properties-changed', this._sync.bind(this));
+ this._sync();
+ });
+
+ this._item = new PopupMenu.PopupBaseMenuItem({ activate: false });
+ this.menu.addMenuItem(this._item);
+
+ this._slider = new Slider.Slider(0);
+ this._sliderChangedId = this._slider.connect('notify::value',
+ this._sliderChanged.bind(this));
+ this._slider.accessible_name = _("Brightness");
+
+ let icon = new St.Icon({ icon_name: 'display-brightness-symbolic',
+ style_class: 'popup-menu-icon' });
+ this._item.add(icon);
+ this._item.add_child(this._slider);
+ this._item.connect('button-press-event', (actor, event) => {
+ return this._slider.startDragging(event);
+ });
+ this._item.connect('key-press-event', (actor, event) => {
+ return this._slider.emit('key-press-event', event);
+ });
+ this._item.connect('scroll-event', (actor, event) => {
+ return this._slider.emit('scroll-event', event);
+ });
+ }
+
+ _sliderChanged() {
+ let percent = this._slider.value * 100;
+ this._proxy.Brightness = percent;
+ }
+
+ _changeSlider(value) {
+ this._slider.block_signal_handler(this._sliderChangedId);
+ this._slider.value = value;
+ this._slider.unblock_signal_handler(this._sliderChangedId);
+ }
+
+ _sync() {
+ let visible = this._proxy.Brightness >= 0;
+ this._item.visible = visible;
+ if (visible)
+ this._changeSlider(this._proxy.Brightness / 100.0);
+ }
+});
diff --git a/js/ui/status/dwellClick.js b/js/ui/status/dwellClick.js
new file mode 100644
index 0000000..ce13f73
--- /dev/null
+++ b/js/ui/status/dwellClick.js
@@ -0,0 +1,86 @@
+/* exported DwellClickIndicator */
+const { Clutter, Gio, GLib, GObject, St } = imports.gi;
+
+const PanelMenu = imports.ui.panelMenu;
+const PopupMenu = imports.ui.popupMenu;
+
+const MOUSE_A11Y_SCHEMA = 'org.gnome.desktop.a11y.mouse';
+const KEY_DWELL_CLICK_ENABLED = 'dwell-click-enabled';
+const KEY_DWELL_MODE = 'dwell-mode';
+const DWELL_MODE_WINDOW = 'window';
+const DWELL_CLICK_MODES = {
+ primary: {
+ name: _("Single Click"),
+ icon: 'pointer-primary-click-symbolic',
+ type: Clutter.PointerA11yDwellClickType.PRIMARY,
+ },
+ double: {
+ name: _("Double Click"),
+ icon: 'pointer-double-click-symbolic',
+ type: Clutter.PointerA11yDwellClickType.DOUBLE,
+ },
+ drag: {
+ name: _("Drag"),
+ icon: 'pointer-drag-symbolic',
+ type: Clutter.PointerA11yDwellClickType.DRAG,
+ },
+ secondary: {
+ name: _("Secondary Click"),
+ icon: 'pointer-secondary-click-symbolic',
+ type: Clutter.PointerA11yDwellClickType.SECONDARY,
+ },
+};
+
+var DwellClickIndicator = GObject.registerClass(
+class DwellClickIndicator extends PanelMenu.Button {
+ _init() {
+ super._init(0.5, _("Dwell Click"));
+
+ this._hbox = new St.BoxLayout({ style_class: 'panel-status-menu-box' });
+ this._icon = new St.Icon({ style_class: 'system-status-icon',
+ icon_name: 'pointer-primary-click-symbolic' });
+ this._hbox.add_child(this._icon);
+ this._hbox.add_child(PopupMenu.arrowIcon(St.Side.BOTTOM));
+
+ this.add_child(this._hbox);
+
+ this._a11ySettings = new Gio.Settings({ schema_id: MOUSE_A11Y_SCHEMA });
+ this._a11ySettings.connect('changed::%s'.format(KEY_DWELL_CLICK_ENABLED), this._syncMenuVisibility.bind(this));
+ this._a11ySettings.connect('changed::%s'.format(KEY_DWELL_MODE), this._syncMenuVisibility.bind(this));
+
+ this._seat = Clutter.get_default_backend().get_default_seat();
+ this._seat.connect('ptr-a11y-dwell-click-type-changed', this._updateClickType.bind(this));
+
+ this._addDwellAction(DWELL_CLICK_MODES.primary);
+ this._addDwellAction(DWELL_CLICK_MODES.double);
+ this._addDwellAction(DWELL_CLICK_MODES.drag);
+ this._addDwellAction(DWELL_CLICK_MODES.secondary);
+
+ this._setClickType(DWELL_CLICK_MODES.primary);
+ this._syncMenuVisibility();
+ }
+
+ _syncMenuVisibility() {
+ this.visible =
+ this._a11ySettings.get_boolean(KEY_DWELL_CLICK_ENABLED) &&
+ this._a11ySettings.get_string(KEY_DWELL_MODE) == DWELL_MODE_WINDOW;
+
+ return GLib.SOURCE_REMOVE;
+ }
+
+ _addDwellAction(mode) {
+ this.menu.addAction(mode.name, this._setClickType.bind(this, mode), mode.icon);
+ }
+
+ _updateClickType(manager, clickType) {
+ for (let mode in DWELL_CLICK_MODES) {
+ if (DWELL_CLICK_MODES[mode].type == clickType)
+ this._icon.icon_name = DWELL_CLICK_MODES[mode].icon;
+ }
+ }
+
+ _setClickType(mode) {
+ this._seat.set_pointer_a11y_dwell_click_type(mode.type);
+ this._icon.icon_name = mode.icon;
+ }
+});
diff --git a/js/ui/status/keyboard.js b/js/ui/status/keyboard.js
new file mode 100644
index 0000000..43fd89d
--- /dev/null
+++ b/js/ui/status/keyboard.js
@@ -0,0 +1,1079 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported InputSourceIndicator */
+
+const { Clutter, Gio, GLib, GObject, IBus, Meta, Shell, St } = imports.gi;
+const Gettext = imports.gettext;
+const Signals = imports.signals;
+
+const IBusManager = imports.misc.ibusManager;
+const KeyboardManager = imports.misc.keyboardManager;
+const Main = imports.ui.main;
+const PopupMenu = imports.ui.popupMenu;
+const PanelMenu = imports.ui.panelMenu;
+const SwitcherPopup = imports.ui.switcherPopup;
+const Util = imports.misc.util;
+
+var INPUT_SOURCE_TYPE_XKB = 'xkb';
+var INPUT_SOURCE_TYPE_IBUS = 'ibus';
+
+var LayoutMenuItem = GObject.registerClass(
+class LayoutMenuItem extends PopupMenu.PopupBaseMenuItem {
+ _init(displayName, shortName) {
+ super._init();
+
+ this.label = new St.Label({
+ text: displayName,
+ x_expand: true,
+ });
+ this.indicator = new St.Label({ text: shortName });
+ this.add_child(this.label);
+ this.add(this.indicator);
+ this.label_actor = this.label;
+ }
+});
+
+var InputSource = class {
+ constructor(type, id, displayName, shortName, index) {
+ this.type = type;
+ this.id = id;
+ this.displayName = displayName;
+ this._shortName = shortName;
+ this.index = index;
+
+ this.properties = null;
+
+ this.xkbId = this._getXkbId();
+ }
+
+ get shortName() {
+ return this._shortName;
+ }
+
+ set shortName(v) {
+ this._shortName = v;
+ this.emit('changed');
+ }
+
+ activate(interactive) {
+ this.emit('activate', !!interactive);
+ }
+
+ _getXkbId() {
+ let engineDesc = IBusManager.getIBusManager().getEngineDesc(this.id);
+ if (!engineDesc)
+ return this.id;
+
+ if (engineDesc.variant && engineDesc.variant.length > 0)
+ return '%s+%s'.format(engineDesc.layout, engineDesc.variant);
+ else
+ return engineDesc.layout;
+ }
+};
+Signals.addSignalMethods(InputSource.prototype);
+
+var InputSourcePopup = GObject.registerClass(
+class InputSourcePopup extends SwitcherPopup.SwitcherPopup {
+ _init(items, action, actionBackward) {
+ super._init(items);
+
+ this._action = action;
+ this._actionBackward = actionBackward;
+
+ this._switcherList = new InputSourceSwitcher(this._items);
+ }
+
+ _keyPressHandler(keysym, action) {
+ if (action == this._action)
+ this._select(this._next());
+ else if (action == this._actionBackward)
+ this._select(this._previous());
+ else if (keysym == Clutter.KEY_Left)
+ this._select(this._previous());
+ else if (keysym == Clutter.KEY_Right)
+ this._select(this._next());
+ else
+ return Clutter.EVENT_PROPAGATE;
+
+ return Clutter.EVENT_STOP;
+ }
+
+ _finish() {
+ super._finish();
+
+ this._items[this._selectedIndex].activate(true);
+ }
+});
+
+var InputSourceSwitcher = GObject.registerClass(
+class InputSourceSwitcher extends SwitcherPopup.SwitcherList {
+ _init(items) {
+ super._init(true);
+
+ for (let i = 0; i < items.length; i++)
+ this._addIcon(items[i]);
+ }
+
+ _addIcon(item) {
+ let box = new St.BoxLayout({ vertical: true });
+
+ let bin = new St.Bin({ style_class: 'input-source-switcher-symbol' });
+ let symbol = new St.Label({
+ text: item.shortName,
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ bin.set_child(symbol);
+ box.add_child(bin);
+
+ let text = new St.Label({
+ text: item.displayName,
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+ box.add_child(text);
+
+ this.addItem(box, text);
+ }
+});
+
+var InputSourceSettings = class {
+ constructor() {
+ if (this.constructor === InputSourceSettings)
+ throw new TypeError('Cannot instantiate abstract class %s'.format(this.constructor.name));
+ }
+
+ _emitInputSourcesChanged() {
+ this.emit('input-sources-changed');
+ }
+
+ _emitKeyboardOptionsChanged() {
+ this.emit('keyboard-options-changed');
+ }
+
+ _emitPerWindowChanged() {
+ this.emit('per-window-changed');
+ }
+
+ get inputSources() {
+ return [];
+ }
+
+ get mruSources() {
+ return [];
+ }
+
+ set mruSources(sourcesList) {
+ // do nothing
+ }
+
+ get keyboardOptions() {
+ return [];
+ }
+
+ get perWindow() {
+ return false;
+ }
+};
+Signals.addSignalMethods(InputSourceSettings.prototype);
+
+var InputSourceSystemSettings = class extends InputSourceSettings {
+ constructor() {
+ super();
+
+ this._BUS_NAME = 'org.freedesktop.locale1';
+ this._BUS_PATH = '/org/freedesktop/locale1';
+ this._BUS_IFACE = 'org.freedesktop.locale1';
+ this._BUS_PROPS_IFACE = 'org.freedesktop.DBus.Properties';
+
+ this._layouts = '';
+ this._variants = '';
+ this._options = '';
+
+ this._reload();
+
+ Gio.DBus.system.signal_subscribe(this._BUS_NAME,
+ this._BUS_PROPS_IFACE,
+ 'PropertiesChanged',
+ this._BUS_PATH,
+ null,
+ Gio.DBusSignalFlags.NONE,
+ this._reload.bind(this));
+ }
+
+ async _reload() {
+ let props;
+ try {
+ const result = await Gio.DBus.system.call(
+ this._BUS_NAME,
+ this._BUS_PATH,
+ this._BUS_PROPS_IFACE,
+ 'GetAll',
+ new GLib.Variant('(s)', [this._BUS_IFACE]),
+ null, Gio.DBusCallFlags.NONE, -1, null);
+ [props] = result.deep_unpack();
+ } catch (e) {
+ log('Could not get properties from %s'.format(this._BUS_NAME));
+ return;
+ }
+
+ const layouts = props['X11Layout'].unpack();
+ const variants = props['X11Variant'].unpack();
+ const options = props['X11Options'].unpack();
+
+ if (layouts !== this._layouts ||
+ variants !== this._variants) {
+ this._layouts = layouts;
+ this._variants = variants;
+ this._emitInputSourcesChanged();
+ }
+ if (options !== this._options) {
+ this._options = options;
+ this._emitKeyboardOptionsChanged();
+ }
+ }
+
+ get inputSources() {
+ let sourcesList = [];
+ let layouts = this._layouts.split(',');
+ let variants = this._variants.split(',');
+
+ for (let i = 0; i < layouts.length && !!layouts[i]; i++) {
+ let id = layouts[i];
+ if (variants[i])
+ id += '+%s'.format(variants[i]);
+ sourcesList.push({ type: INPUT_SOURCE_TYPE_XKB, id });
+ }
+ return sourcesList;
+ }
+
+ get keyboardOptions() {
+ return this._options.split(',');
+ }
+};
+
+var InputSourceSessionSettings = class extends InputSourceSettings {
+ constructor() {
+ super();
+
+ this._DESKTOP_INPUT_SOURCES_SCHEMA = 'org.gnome.desktop.input-sources';
+ this._KEY_INPUT_SOURCES = 'sources';
+ this._KEY_MRU_SOURCES = 'mru-sources';
+ this._KEY_KEYBOARD_OPTIONS = 'xkb-options';
+ this._KEY_PER_WINDOW = 'per-window';
+
+ this._settings = new Gio.Settings({ schema_id: this._DESKTOP_INPUT_SOURCES_SCHEMA });
+ this._settings.connect('changed::%s'.format(this._KEY_INPUT_SOURCES), this._emitInputSourcesChanged.bind(this));
+ this._settings.connect('changed::%s'.format(this._KEY_KEYBOARD_OPTIONS), this._emitKeyboardOptionsChanged.bind(this));
+ this._settings.connect('changed::%s'.format(this._KEY_PER_WINDOW), this._emitPerWindowChanged.bind(this));
+ }
+
+ _getSourcesList(key) {
+ let sourcesList = [];
+ let sources = this._settings.get_value(key);
+ let nSources = sources.n_children();
+
+ for (let i = 0; i < nSources; i++) {
+ let [type, id] = sources.get_child_value(i).deep_unpack();
+ sourcesList.push({ type, id });
+ }
+ return sourcesList;
+ }
+
+ get inputSources() {
+ return this._getSourcesList(this._KEY_INPUT_SOURCES);
+ }
+
+ get mruSources() {
+ return this._getSourcesList(this._KEY_MRU_SOURCES);
+ }
+
+ set mruSources(sourcesList) {
+ let sources = GLib.Variant.new('a(ss)', sourcesList);
+ this._settings.set_value(this._KEY_MRU_SOURCES, sources);
+ }
+
+ get keyboardOptions() {
+ return this._settings.get_strv(this._KEY_KEYBOARD_OPTIONS);
+ }
+
+ get perWindow() {
+ return this._settings.get_boolean(this._KEY_PER_WINDOW);
+ }
+};
+
+var InputSourceManager = class {
+ constructor() {
+ // All valid input sources currently in the gsettings
+ // KEY_INPUT_SOURCES list indexed by their index there
+ this._inputSources = {};
+ // All valid input sources currently in the gsettings
+ // KEY_INPUT_SOURCES list of type INPUT_SOURCE_TYPE_IBUS
+ // indexed by the IBus ID
+ this._ibusSources = {};
+
+ this._currentSource = null;
+
+ // All valid input sources currently in the gsettings
+ // KEY_INPUT_SOURCES list ordered by most recently used
+ this._mruSources = [];
+ this._mruSourcesBackup = null;
+ this._keybindingAction =
+ Main.wm.addKeybinding('switch-input-source',
+ new Gio.Settings({ schema_id: "org.gnome.desktop.wm.keybindings" }),
+ Meta.KeyBindingFlags.NONE,
+ Shell.ActionMode.ALL,
+ this._switchInputSource.bind(this));
+ this._keybindingActionBackward =
+ Main.wm.addKeybinding('switch-input-source-backward',
+ new Gio.Settings({ schema_id: "org.gnome.desktop.wm.keybindings" }),
+ Meta.KeyBindingFlags.IS_REVERSED,
+ Shell.ActionMode.ALL,
+ this._switchInputSource.bind(this));
+ if (Main.sessionMode.isGreeter)
+ this._settings = new InputSourceSystemSettings();
+ else
+ this._settings = new InputSourceSessionSettings();
+ this._settings.connect('input-sources-changed', this._inputSourcesChanged.bind(this));
+ this._settings.connect('keyboard-options-changed', this._keyboardOptionsChanged.bind(this));
+
+ this._xkbInfo = KeyboardManager.getXkbInfo();
+ this._keyboardManager = KeyboardManager.getKeyboardManager();
+
+ this._ibusReady = false;
+ this._ibusManager = IBusManager.getIBusManager();
+ this._ibusManager.connect('ready', this._ibusReadyCallback.bind(this));
+ this._ibusManager.connect('properties-registered', this._ibusPropertiesRegistered.bind(this));
+ this._ibusManager.connect('property-updated', this._ibusPropertyUpdated.bind(this));
+ this._ibusManager.connect('set-content-type', this._ibusSetContentType.bind(this));
+
+ global.display.connect('modifiers-accelerator-activated', this._modifiersSwitcher.bind(this));
+
+ this._sourcesPerWindow = false;
+ this._focusWindowNotifyId = 0;
+ this._overviewShowingId = 0;
+ this._overviewHiddenId = 0;
+ this._settings.connect('per-window-changed', this._sourcesPerWindowChanged.bind(this));
+ this._sourcesPerWindowChanged();
+ this._disableIBus = false;
+ this._reloading = false;
+ }
+
+ reload() {
+ this._reloading = true;
+ this._keyboardManager.setKeyboardOptions(this._settings.keyboardOptions);
+ this._inputSourcesChanged();
+ this._reloading = false;
+ }
+
+ _ibusReadyCallback(im, ready) {
+ if (this._ibusReady == ready)
+ return;
+
+ this._ibusReady = ready;
+ this._mruSources = [];
+ this._inputSourcesChanged();
+ }
+
+ _modifiersSwitcher() {
+ let sourceIndexes = Object.keys(this._inputSources);
+ if (sourceIndexes.length == 0) {
+ KeyboardManager.releaseKeyboard();
+ return true;
+ }
+
+ let is = this._currentSource;
+ if (!is)
+ is = this._inputSources[sourceIndexes[0]];
+
+ let nextIndex = is.index + 1;
+ if (nextIndex > sourceIndexes[sourceIndexes.length - 1])
+ nextIndex = 0;
+
+ while (!(is = this._inputSources[nextIndex]))
+ nextIndex += 1;
+
+ is.activate(true);
+ return true;
+ }
+
+ _switchInputSource(display, window, binding) {
+ if (this._mruSources.length < 2)
+ return;
+
+ // HACK: Fall back on simple input source switching since we
+ // can't show a popup switcher while a GrabHelper grab is in
+ // effect without considerable work to consolidate the usage
+ // of pushModal/popModal and grabHelper. See
+ // https://bugzilla.gnome.org/show_bug.cgi?id=695143 .
+ if (Main.actionMode == Shell.ActionMode.POPUP) {
+ this._modifiersSwitcher();
+ return;
+ }
+
+ let popup = new InputSourcePopup(this._mruSources, this._keybindingAction, this._keybindingActionBackward);
+ if (!popup.show(binding.is_reversed(), binding.get_name(), binding.get_mask()))
+ popup.fadeAndDestroy();
+ }
+
+ _keyboardOptionsChanged() {
+ this._keyboardManager.setKeyboardOptions(this._settings.keyboardOptions);
+ this._keyboardManager.reapply();
+ }
+
+ _updateMruSettings() {
+ // If IBus is not ready we don't have a full picture of all
+ // the available sources, so don't update the setting
+ if (!this._ibusReady)
+ return;
+
+ // If IBus is temporarily disabled, don't update the setting
+ if (this._disableIBus)
+ return;
+
+ let sourcesList = [];
+ for (let i = 0; i < this._mruSources.length; ++i) {
+ let source = this._mruSources[i];
+ sourcesList.push([source.type, source.id]);
+ }
+
+ this._settings.mruSources = sourcesList;
+ }
+
+ _currentInputSourceChanged(newSource) {
+ let oldSource;
+ [oldSource, this._currentSource] = [this._currentSource, newSource];
+
+ this.emit('current-source-changed', oldSource);
+
+ for (let i = 1; i < this._mruSources.length; ++i) {
+ if (this._mruSources[i] == newSource) {
+ let currentSource = this._mruSources.splice(i, 1);
+ this._mruSources = currentSource.concat(this._mruSources);
+ break;
+ }
+ }
+ this._changePerWindowSource();
+ }
+
+ activateInputSource(is, interactive) {
+ // The focus changes during holdKeyboard/releaseKeyboard may trick
+ // the client into hiding UI containing the currently focused entry.
+ // So holdKeyboard/releaseKeyboard are not called when
+ // 'set-content-type' signal is received.
+ // E.g. Focusing on a password entry in a popup in Xorg Firefox
+ // will emit 'set-content-type' signal.
+ // https://gitlab.gnome.org/GNOME/gnome-shell/issues/391
+ if (!this._reloading)
+ KeyboardManager.holdKeyboard();
+ this._keyboardManager.apply(is.xkbId);
+
+ // All the "xkb:..." IBus engines simply "echo" back symbols,
+ // despite their naming implying differently, so we always set
+ // one in order for XIM applications to work given that we set
+ // XMODIFIERS=@im=ibus in the first place so that they can
+ // work without restarting when/if the user adds an IBus input
+ // source.
+ let engine;
+ if (is.type == INPUT_SOURCE_TYPE_IBUS)
+ engine = is.id;
+ else
+ engine = 'xkb:us::eng';
+
+ if (!this._reloading)
+ this._ibusManager.setEngine(engine, KeyboardManager.releaseKeyboard);
+ else
+ this._ibusManager.setEngine(engine);
+ this._currentInputSourceChanged(is);
+
+ if (interactive)
+ this._updateMruSettings();
+ }
+
+ _updateMruSources() {
+ let sourcesList = [];
+ for (let i in this._inputSources)
+ sourcesList.push(this._inputSources[i]);
+
+ this._keyboardManager.setUserLayouts(sourcesList.map(x => x.xkbId));
+
+ if (!this._disableIBus && this._mruSourcesBackup) {
+ this._mruSources = this._mruSourcesBackup;
+ this._mruSourcesBackup = null;
+ }
+
+ // Initialize from settings when we have no MRU sources list
+ if (this._mruSources.length == 0) {
+ let mruSettings = this._settings.mruSources;
+ for (let i = 0; i < mruSettings.length; i++) {
+ let mruSettingSource = mruSettings[i];
+ let mruSource = null;
+
+ for (let j = 0; j < sourcesList.length; j++) {
+ let source = sourcesList[j];
+ if (source.type == mruSettingSource.type &&
+ source.id == mruSettingSource.id) {
+ mruSource = source;
+ break;
+ }
+ }
+
+ if (mruSource)
+ this._mruSources.push(mruSource);
+ }
+ }
+
+ let mruSources = [];
+ for (let i = 0; i < this._mruSources.length; i++) {
+ for (let j = 0; j < sourcesList.length; j++) {
+ if (this._mruSources[i].type == sourcesList[j].type &&
+ this._mruSources[i].id == sourcesList[j].id) {
+ mruSources = mruSources.concat(sourcesList.splice(j, 1));
+ break;
+ }
+ }
+ }
+ this._mruSources = mruSources.concat(sourcesList);
+ }
+
+ _inputSourcesChanged() {
+ let sources = this._settings.inputSources;
+ let nSources = sources.length;
+
+ this._currentSource = null;
+ this._inputSources = {};
+ this._ibusSources = {};
+
+ let infosList = [];
+ for (let i = 0; i < nSources; i++) {
+ let displayName;
+ let shortName;
+ let type = sources[i].type;
+ let id = sources[i].id;
+ let exists = false;
+
+ if (type == INPUT_SOURCE_TYPE_XKB) {
+ [exists, displayName, shortName] =
+ this._xkbInfo.get_layout_info(id);
+ } else if (type == INPUT_SOURCE_TYPE_IBUS) {
+ if (this._disableIBus)
+ continue;
+ let engineDesc = this._ibusManager.getEngineDesc(id);
+ if (engineDesc) {
+ let language = IBus.get_language_name(engineDesc.get_language());
+ let longName = engineDesc.get_longname();
+ let textdomain = engineDesc.get_textdomain();
+ if (textdomain != '')
+ longName = Gettext.dgettext(textdomain, longName);
+ exists = true;
+ displayName = '%s (%s)'.format(language, longName);
+ shortName = this._makeEngineShortName(engineDesc);
+ }
+ }
+
+ if (exists)
+ infosList.push({ type, id, displayName, shortName });
+ }
+
+ if (infosList.length == 0) {
+ let type = INPUT_SOURCE_TYPE_XKB;
+ let id = KeyboardManager.DEFAULT_LAYOUT;
+ let [, displayName, shortName] = this._xkbInfo.get_layout_info(id);
+ infosList.push({ type, id, displayName, shortName });
+ }
+
+ let inputSourcesByShortName = {};
+ for (let i = 0; i < infosList.length; i++) {
+ let is = new InputSource(infosList[i].type,
+ infosList[i].id,
+ infosList[i].displayName,
+ infosList[i].shortName,
+ i);
+ is.connect('activate', this.activateInputSource.bind(this));
+
+ if (!(is.shortName in inputSourcesByShortName))
+ inputSourcesByShortName[is.shortName] = [];
+ inputSourcesByShortName[is.shortName].push(is);
+
+ this._inputSources[is.index] = is;
+
+ if (is.type == INPUT_SOURCE_TYPE_IBUS)
+ this._ibusSources[is.id] = is;
+ }
+
+ for (let i in this._inputSources) {
+ let is = this._inputSources[i];
+ if (inputSourcesByShortName[is.shortName].length > 1) {
+ let sub = inputSourcesByShortName[is.shortName].indexOf(is) + 1;
+ is.shortName += String.fromCharCode(0x2080 + sub);
+ }
+ }
+
+ this.emit('sources-changed');
+
+ this._updateMruSources();
+
+ if (this._mruSources.length > 0)
+ this._mruSources[0].activate(false);
+
+ // All ibus engines are preloaded here to reduce the launching time
+ // when users switch the input sources.
+ this._ibusManager.preloadEngines(Object.keys(this._ibusSources));
+ }
+
+ _makeEngineShortName(engineDesc) {
+ let symbol = engineDesc.get_symbol();
+ if (symbol && symbol[0])
+ return symbol;
+
+ let langCode = engineDesc.get_language().split('_', 1)[0];
+ if (langCode.length == 2 || langCode.length == 3)
+ return langCode.toLowerCase();
+
+ return String.fromCharCode(0x2328); // keyboard glyph
+ }
+
+ _ibusPropertiesRegistered(im, engineName, props) {
+ let source = this._ibusSources[engineName];
+ if (!source)
+ return;
+
+ source.properties = props;
+
+ if (source == this._currentSource)
+ this.emit('current-source-changed', null);
+ }
+
+ _ibusPropertyUpdated(im, engineName, prop) {
+ let source = this._ibusSources[engineName];
+ if (!source)
+ return;
+
+ if (this._updateSubProperty(source.properties, prop) &&
+ source == this._currentSource)
+ this.emit('current-source-changed', null);
+ }
+
+ _updateSubProperty(props, prop) {
+ if (!props)
+ return false;
+
+ let p;
+ for (let i = 0; (p = props.get(i)) != null; ++i) {
+ if (p.get_key() == prop.get_key() && p.get_prop_type() == prop.get_prop_type()) {
+ p.update(prop);
+ return true;
+ } else if (p.get_prop_type() == IBus.PropType.MENU) {
+ if (this._updateSubProperty(p.get_sub_props(), prop))
+ return true;
+ }
+ }
+ return false;
+ }
+
+ _ibusSetContentType(im, purpose, _hints) {
+ if (purpose == IBus.InputPurpose.PASSWORD) {
+ if (Object.keys(this._inputSources).length == Object.keys(this._ibusSources).length)
+ return;
+
+ if (this._disableIBus)
+ return;
+ this._disableIBus = true;
+ this._mruSourcesBackup = this._mruSources.slice();
+ } else {
+ if (!this._disableIBus)
+ return;
+ this._disableIBus = false;
+ }
+ this.reload();
+ }
+
+ _getNewInputSource(current) {
+ let sourceIndexes = Object.keys(this._inputSources);
+ if (sourceIndexes.length == 0)
+ return null;
+
+ if (current) {
+ for (let i in this._inputSources) {
+ let is = this._inputSources[i];
+ if (is.type == current.type &&
+ is.id == current.id)
+ return is;
+ }
+ }
+
+ return this._inputSources[sourceIndexes[0]];
+ }
+
+ _getCurrentWindow() {
+ if (Main.overview.visible)
+ return Main.overview;
+ else
+ return global.display.focus_window;
+ }
+
+ _setPerWindowInputSource() {
+ let window = this._getCurrentWindow();
+ if (!window)
+ return;
+
+ if (!window._inputSources ||
+ window._inputSources !== this._inputSources) {
+ window._inputSources = this._inputSources;
+ window._currentSource = this._getNewInputSource(window._currentSource);
+ }
+
+ if (window._currentSource)
+ window._currentSource.activate(false);
+ }
+
+ _sourcesPerWindowChanged() {
+ this._sourcesPerWindow = this._settings.perWindow;
+
+ if (this._sourcesPerWindow && this._focusWindowNotifyId == 0) {
+ this._focusWindowNotifyId = global.display.connect('notify::focus-window',
+ this._setPerWindowInputSource.bind(this));
+ this._overviewShowingId = Main.overview.connect('showing',
+ this._setPerWindowInputSource.bind(this));
+ this._overviewHiddenId = Main.overview.connect('hidden',
+ this._setPerWindowInputSource.bind(this));
+ } else if (!this._sourcesPerWindow && this._focusWindowNotifyId != 0) {
+ global.display.disconnect(this._focusWindowNotifyId);
+ this._focusWindowNotifyId = 0;
+ Main.overview.disconnect(this._overviewShowingId);
+ this._overviewShowingId = 0;
+ Main.overview.disconnect(this._overviewHiddenId);
+ this._overviewHiddenId = 0;
+
+ let windows = global.get_window_actors().map(w => w.meta_window);
+ for (let i = 0; i < windows.length; ++i) {
+ delete windows[i]._inputSources;
+ delete windows[i]._currentSource;
+ }
+ delete Main.overview._inputSources;
+ delete Main.overview._currentSource;
+ }
+ }
+
+ _changePerWindowSource() {
+ if (!this._sourcesPerWindow)
+ return;
+
+ let window = this._getCurrentWindow();
+ if (!window)
+ return;
+
+ window._inputSources = this._inputSources;
+ window._currentSource = this._currentSource;
+ }
+
+ get currentSource() {
+ return this._currentSource;
+ }
+
+ get inputSources() {
+ return this._inputSources;
+ }
+};
+Signals.addSignalMethods(InputSourceManager.prototype);
+
+let _inputSourceManager = null;
+
+function getInputSourceManager() {
+ if (_inputSourceManager == null)
+ _inputSourceManager = new InputSourceManager();
+ return _inputSourceManager;
+}
+
+var InputSourceIndicatorContainer = GObject.registerClass(
+class InputSourceIndicatorContainer extends St.Widget {
+
+ vfunc_get_preferred_width(forHeight) {
+ // Here, and in vfunc_get_preferred_height, we need to query
+ // for the height of all children, but we ignore the results
+ // for those we don't actually display.
+ return this.get_children().reduce((maxWidth, child) => {
+ let width = child.get_preferred_width(forHeight);
+ return [Math.max(maxWidth[0], width[0]),
+ Math.max(maxWidth[1], width[1])];
+ }, [0, 0]);
+ }
+
+ vfunc_get_preferred_height(forWidth) {
+ return this.get_children().reduce((maxHeight, child) => {
+ let height = child.get_preferred_height(forWidth);
+ return [Math.max(maxHeight[0], height[0]),
+ Math.max(maxHeight[1], height[1])];
+ }, [0, 0]);
+ }
+
+ vfunc_allocate(box) {
+ this.set_allocation(box);
+
+ // translate box to (0, 0)
+ box.x2 -= box.x1;
+ box.x1 = 0;
+ box.y2 -= box.y1;
+ box.y1 = 0;
+
+ this.get_children().forEach(c => {
+ c.allocate_align_fill(box, 0.5, 0.5, false, false);
+ });
+ }
+});
+
+var InputSourceIndicator = GObject.registerClass(
+class InputSourceIndicator extends PanelMenu.Button {
+ _init() {
+ super._init(0.5, _("Keyboard"));
+
+ this.connect('destroy', this._onDestroy.bind(this));
+
+ this._menuItems = {};
+ this._indicatorLabels = {};
+
+ this._container = new InputSourceIndicatorContainer();
+
+ this._hbox = new St.BoxLayout({ style_class: 'panel-status-menu-box' });
+ this._hbox.add_child(this._container);
+ this._hbox.add_child(PopupMenu.arrowIcon(St.Side.BOTTOM));
+
+ this.add_child(this._hbox);
+
+ this._propSeparator = new PopupMenu.PopupSeparatorMenuItem();
+ this.menu.addMenuItem(this._propSeparator);
+ this._propSection = new PopupMenu.PopupMenuSection();
+ this.menu.addMenuItem(this._propSection);
+ this._propSection.actor.hide();
+
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+ this._showLayoutItem = this.menu.addAction(_("Show Keyboard Layout"), this._showLayout.bind(this));
+
+ Main.sessionMode.connect('updated', this._sessionUpdated.bind(this));
+ this._sessionUpdated();
+
+ this._inputSourceManager = getInputSourceManager();
+ this._inputSourceManagerSourcesChangedId =
+ this._inputSourceManager.connect('sources-changed', this._sourcesChanged.bind(this));
+ this._inputSourceManagerCurrentSourceChangedId =
+ this._inputSourceManager.connect('current-source-changed', this._currentSourceChanged.bind(this));
+ this._inputSourceManager.reload();
+ }
+
+ _onDestroy() {
+ if (this._inputSourceManager) {
+ this._inputSourceManager.disconnect(this._inputSourceManagerSourcesChangedId);
+ this._inputSourceManager.disconnect(this._inputSourceManagerCurrentSourceChangedId);
+ this._inputSourceManager = null;
+ }
+ }
+
+ _sessionUpdated() {
+ // re-using "allowSettings" for the keyboard layout is a bit shady,
+ // but at least for now it is used as "allow popping up windows
+ // from shell menus"; we can always add a separate sessionMode
+ // option if need arises.
+ this._showLayoutItem.visible = Main.sessionMode.allowSettings;
+ }
+
+ _sourcesChanged() {
+ for (let i in this._menuItems)
+ this._menuItems[i].destroy();
+ for (let i in this._indicatorLabels)
+ this._indicatorLabels[i].destroy();
+
+ this._menuItems = {};
+ this._indicatorLabels = {};
+
+ let menuIndex = 0;
+ for (let i in this._inputSourceManager.inputSources) {
+ let is = this._inputSourceManager.inputSources[i];
+
+ let menuItem = new LayoutMenuItem(is.displayName, is.shortName);
+ menuItem.connect('activate', () => is.activate(true));
+
+ let indicatorLabel = new St.Label({ text: is.shortName,
+ visible: false });
+
+ this._menuItems[i] = menuItem;
+ this._indicatorLabels[i] = indicatorLabel;
+ is.connect('changed', () => {
+ menuItem.indicator.set_text(is.shortName);
+ indicatorLabel.set_text(is.shortName);
+ });
+
+ this.menu.addMenuItem(menuItem, menuIndex++);
+ this._container.add_actor(indicatorLabel);
+ }
+ }
+
+ _currentSourceChanged(manager, oldSource) {
+ let nVisibleSources = Object.keys(this._inputSourceManager.inputSources).length;
+ let newSource = this._inputSourceManager.currentSource;
+
+ if (oldSource) {
+ this._menuItems[oldSource.index].setOrnament(PopupMenu.Ornament.NONE);
+ this._indicatorLabels[oldSource.index].hide();
+ }
+
+ if (!newSource || (nVisibleSources < 2 && !newSource.properties)) {
+ // This source index might be invalid if we weren't able
+ // to build a menu item for it, so we hide ourselves since
+ // we can't fix it here. *shrug*
+
+ // We also hide if we have only one visible source unless
+ // it's an IBus source with properties.
+ this.menu.close();
+ this.hide();
+ return;
+ }
+
+ this.show();
+
+ this._buildPropSection(newSource.properties);
+
+ this._menuItems[newSource.index].setOrnament(PopupMenu.Ornament.DOT);
+ this._indicatorLabels[newSource.index].show();
+ }
+
+ _buildPropSection(properties) {
+ this._propSeparator.hide();
+ this._propSection.actor.hide();
+ this._propSection.removeAll();
+
+ this._buildPropSubMenu(this._propSection, properties);
+
+ if (!this._propSection.isEmpty()) {
+ this._propSection.actor.show();
+ this._propSeparator.show();
+ }
+ }
+
+ _buildPropSubMenu(menu, props) {
+ if (!props)
+ return;
+
+ let ibusManager = IBusManager.getIBusManager();
+ let radioGroup = [];
+ let p;
+ for (let i = 0; (p = props.get(i)) != null; ++i) {
+ let prop = p;
+
+ if (!prop.get_visible())
+ continue;
+
+ if (prop.get_key() == 'InputMode') {
+ let text;
+ if (prop.get_symbol)
+ text = prop.get_symbol().get_text();
+ else
+ text = prop.get_label().get_text();
+
+ let currentSource = this._inputSourceManager.currentSource;
+ if (currentSource) {
+ let indicatorLabel = this._indicatorLabels[currentSource.index];
+ if (text && text.length > 0 && text.length < 3)
+ indicatorLabel.set_text(text);
+ }
+ }
+
+ let item;
+ let type = prop.get_prop_type();
+ switch (type) {
+ case IBus.PropType.MENU:
+ item = new PopupMenu.PopupSubMenuMenuItem(prop.get_label().get_text());
+ this._buildPropSubMenu(item.menu, prop.get_sub_props());
+ break;
+
+ case IBus.PropType.RADIO:
+ item = new PopupMenu.PopupMenuItem(prop.get_label().get_text());
+ item.prop = prop;
+ radioGroup.push(item);
+ item.radioGroup = radioGroup;
+ item.setOrnament(prop.get_state() == IBus.PropState.CHECKED
+ ? PopupMenu.Ornament.DOT : PopupMenu.Ornament.NONE);
+ item.connect('activate', () => {
+ if (item.prop.get_state() == IBus.PropState.CHECKED)
+ return;
+
+ let group = item.radioGroup;
+ for (let j = 0; j < group.length; ++j) {
+ if (group[j] == item) {
+ item.setOrnament(PopupMenu.Ornament.DOT);
+ item.prop.set_state(IBus.PropState.CHECKED);
+ ibusManager.activateProperty(item.prop.get_key(),
+ IBus.PropState.CHECKED);
+ } else {
+ group[j].setOrnament(PopupMenu.Ornament.NONE);
+ group[j].prop.set_state(IBus.PropState.UNCHECKED);
+ ibusManager.activateProperty(group[j].prop.get_key(),
+ IBus.PropState.UNCHECKED);
+ }
+ }
+ });
+ break;
+
+ case IBus.PropType.TOGGLE:
+ item = new PopupMenu.PopupSwitchMenuItem(prop.get_label().get_text(), prop.get_state() == IBus.PropState.CHECKED);
+ item.prop = prop;
+ item.connect('toggled', () => {
+ if (item.state) {
+ item.prop.set_state(IBus.PropState.CHECKED);
+ ibusManager.activateProperty(item.prop.get_key(),
+ IBus.PropState.CHECKED);
+ } else {
+ item.prop.set_state(IBus.PropState.UNCHECKED);
+ ibusManager.activateProperty(item.prop.get_key(),
+ IBus.PropState.UNCHECKED);
+ }
+ });
+ break;
+
+ case IBus.PropType.NORMAL:
+ item = new PopupMenu.PopupMenuItem(prop.get_label().get_text());
+ item.prop = prop;
+ item.connect('activate', () => {
+ ibusManager.activateProperty(item.prop.get_key(),
+ item.prop.get_state());
+ });
+ break;
+
+ case IBus.PropType.SEPARATOR:
+ item = new PopupMenu.PopupSeparatorMenuItem();
+ break;
+
+ default:
+ log('IBus property %s has invalid type %d'.format(prop.get_key(), type));
+ continue;
+ }
+
+ item.setSensitive(prop.get_sensitive());
+ menu.addMenuItem(item);
+ }
+ }
+
+ _showLayout() {
+ Main.overview.hide();
+
+ let source = this._inputSourceManager.currentSource;
+ let xkbLayout = '';
+ let xkbVariant = '';
+
+ if (source.type == INPUT_SOURCE_TYPE_XKB) {
+ [, , , xkbLayout, xkbVariant] = KeyboardManager.getXkbInfo().get_layout_info(source.id);
+ } else if (source.type == INPUT_SOURCE_TYPE_IBUS) {
+ let engineDesc = IBusManager.getIBusManager().getEngineDesc(source.id);
+ if (engineDesc) {
+ xkbLayout = engineDesc.get_layout();
+ xkbVariant = engineDesc.get_layout_variant();
+ }
+ }
+
+ if (!xkbLayout || xkbLayout.length == 0)
+ return;
+
+ let description = xkbLayout;
+ if (xkbVariant.length > 0)
+ description = '%s\t%s'.format(description, xkbVariant);
+
+ Util.spawn(['gkbd-keyboard-display', '-l', description]);
+ }
+});
diff --git a/js/ui/status/location.js b/js/ui/status/location.js
new file mode 100644
index 0000000..4250ed0
--- /dev/null
+++ b/js/ui/status/location.js
@@ -0,0 +1,387 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+const { Clutter, Gio, GLib, GObject, Shell, St } = imports.gi;
+
+const Dialog = imports.ui.dialog;
+const Main = imports.ui.main;
+const PanelMenu = imports.ui.panelMenu;
+const PopupMenu = imports.ui.popupMenu;
+const ModalDialog = imports.ui.modalDialog;
+const PermissionStore = imports.misc.permissionStore;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+const LOCATION_SCHEMA = 'org.gnome.system.location';
+const MAX_ACCURACY_LEVEL = 'max-accuracy-level';
+const ENABLED = 'enabled';
+
+const APP_PERMISSIONS_TABLE = 'gnome';
+const APP_PERMISSIONS_ID = 'geolocation';
+
+var GeoclueAccuracyLevel = {
+ NONE: 0,
+ COUNTRY: 1,
+ CITY: 4,
+ NEIGHBORHOOD: 5,
+ STREET: 6,
+ EXACT: 8,
+};
+
+function accuracyLevelToString(accuracyLevel) {
+ for (let key in GeoclueAccuracyLevel) {
+ if (GeoclueAccuracyLevel[key] == accuracyLevel)
+ return key;
+ }
+
+ return 'NONE';
+}
+
+var GeoclueIface = loadInterfaceXML('org.freedesktop.GeoClue2.Manager');
+const GeoclueManager = Gio.DBusProxy.makeProxyWrapper(GeoclueIface);
+
+var AgentIface = loadInterfaceXML('org.freedesktop.GeoClue2.Agent');
+
+var Indicator = GObject.registerClass(
+class Indicator extends PanelMenu.SystemIndicator {
+ _init() {
+ super._init();
+
+ this._settings = new Gio.Settings({ schema_id: LOCATION_SCHEMA });
+ this._settings.connect('changed::%s'.format(ENABLED),
+ this._onMaxAccuracyLevelChanged.bind(this));
+ this._settings.connect('changed::%s'.format(MAX_ACCURACY_LEVEL),
+ this._onMaxAccuracyLevelChanged.bind(this));
+
+ this._indicator = this._addIndicator();
+ this._indicator.icon_name = 'find-location-symbolic';
+
+ this._item = new PopupMenu.PopupSubMenuMenuItem('', true);
+ this._item.icon.icon_name = 'find-location-symbolic';
+
+ this._agent = Gio.DBusExportedObject.wrapJSObject(AgentIface, this);
+ this._agent.export(Gio.DBus.system, '/org/freedesktop/GeoClue2/Agent');
+
+ this._item.label.text = _("Location Enabled");
+ this._onOffAction = this._item.menu.addAction(_("Disable"), this._onOnOffAction.bind(this));
+ this._item.menu.addSettingsAction(_('Privacy Settings'), 'gnome-location-panel.desktop');
+
+ this.menu.addMenuItem(this._item);
+
+ this._watchId = Gio.bus_watch_name(Gio.BusType.SYSTEM,
+ 'org.freedesktop.GeoClue2',
+ 0,
+ this._connectToGeoclue.bind(this),
+ this._onGeoclueVanished.bind(this));
+ Main.sessionMode.connect('updated', this._onSessionUpdated.bind(this));
+ this._onSessionUpdated();
+ this._onMaxAccuracyLevelChanged();
+ this._connectToGeoclue();
+ this._connectToPermissionStore();
+ }
+
+ get MaxAccuracyLevel() {
+ return this._getMaxAccuracyLevel();
+ }
+
+ AuthorizeAppAsync(params, invocation) {
+ let [desktopId, reqAccuracyLevel] = params;
+
+ let authorizer = new AppAuthorizer(desktopId,
+ reqAccuracyLevel,
+ this._permStoreProxy,
+ this._getMaxAccuracyLevel());
+
+ authorizer.authorize(accuracyLevel => {
+ let ret = accuracyLevel != GeoclueAccuracyLevel.NONE;
+ invocation.return_value(GLib.Variant.new('(bu)',
+ [ret, accuracyLevel]));
+ });
+ }
+
+ _syncIndicator() {
+ if (this._managerProxy == null) {
+ this._indicator.visible = false;
+ this._item.visible = false;
+ return;
+ }
+
+ this._indicator.visible = this._managerProxy.InUse;
+ this._item.visible = this._indicator.visible;
+ this._updateMenuLabels();
+ }
+
+ _connectToGeoclue() {
+ if (this._managerProxy != null || this._connecting)
+ return false;
+
+ this._connecting = true;
+ new GeoclueManager(Gio.DBus.system,
+ 'org.freedesktop.GeoClue2',
+ '/org/freedesktop/GeoClue2/Manager',
+ this._onManagerProxyReady.bind(this));
+ return true;
+ }
+
+ _onManagerProxyReady(proxy, error) {
+ if (error != null) {
+ log(error.message);
+ this._connecting = false;
+ return;
+ }
+
+ this._managerProxy = proxy;
+ this._propertiesChangedId = this._managerProxy.connect('g-properties-changed',
+ this._onGeocluePropsChanged.bind(this));
+
+ this._syncIndicator();
+
+ this._managerProxy.AddAgentRemote('gnome-shell', this._onAgentRegistered.bind(this));
+ }
+
+ _onAgentRegistered(result, error) {
+ this._connecting = false;
+ this._notifyMaxAccuracyLevel();
+
+ if (error != null)
+ log(error.message);
+ }
+
+ _onGeoclueVanished() {
+ if (this._propertiesChangedId) {
+ this._managerProxy.disconnect(this._propertiesChangedId);
+ this._propertiesChangedId = 0;
+ }
+ this._managerProxy = null;
+
+ this._syncIndicator();
+ }
+
+ _onOnOffAction() {
+ let enabled = this._settings.get_boolean(ENABLED);
+ this._settings.set_boolean(ENABLED, !enabled);
+ }
+
+ _onSessionUpdated() {
+ let sensitive = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter;
+ this.menu.setSensitive(sensitive);
+ }
+
+ _updateMenuLabels() {
+ if (this._settings.get_boolean(ENABLED)) {
+ this._item.label.text = this._indicator.visible
+ ? _("Location In Use")
+ : _("Location Enabled");
+ this._onOffAction.label.text = _("Disable");
+ } else {
+ this._item.label.text = _("Location Disabled");
+ this._onOffAction.label.text = _("Enable");
+ }
+ }
+
+ _onMaxAccuracyLevelChanged() {
+ this._updateMenuLabels();
+
+ // Gotta ensure geoclue is up and we are registered as agent to it
+ // before we emit the notify for this property change.
+ if (!this._connectToGeoclue())
+ this._notifyMaxAccuracyLevel();
+ }
+
+ _getMaxAccuracyLevel() {
+ if (this._settings.get_boolean(ENABLED)) {
+ let level = this._settings.get_string(MAX_ACCURACY_LEVEL);
+
+ return GeoclueAccuracyLevel[level.toUpperCase()] ||
+ GeoclueAccuracyLevel.NONE;
+ } else {
+ return GeoclueAccuracyLevel.NONE;
+ }
+ }
+
+ _notifyMaxAccuracyLevel() {
+ let variant = new GLib.Variant('u', this._getMaxAccuracyLevel());
+ this._agent.emit_property_changed('MaxAccuracyLevel', variant);
+ }
+
+ _onGeocluePropsChanged(proxy, properties) {
+ let unpacked = properties.deep_unpack();
+ if ("InUse" in unpacked)
+ this._syncIndicator();
+ }
+
+ _connectToPermissionStore() {
+ this._permStoreProxy = null;
+ new PermissionStore.PermissionStore(this._onPermStoreProxyReady.bind(this));
+ }
+
+ _onPermStoreProxyReady(proxy, error) {
+ if (error != null) {
+ log(error.message);
+ return;
+ }
+
+ this._permStoreProxy = proxy;
+ }
+});
+
+var AppAuthorizer = class {
+ constructor(desktopId, reqAccuracyLevel, permStoreProxy, maxAccuracyLevel) {
+ this.desktopId = desktopId;
+ this.reqAccuracyLevel = reqAccuracyLevel;
+ this._permStoreProxy = permStoreProxy;
+ this._maxAccuracyLevel = maxAccuracyLevel;
+ this._permissions = {};
+
+ this._accuracyLevel = GeoclueAccuracyLevel.NONE;
+ }
+
+ authorize(onAuthDone) {
+ this._onAuthDone = onAuthDone;
+
+ let appSystem = Shell.AppSystem.get_default();
+ this._app = appSystem.lookup_app('%s.desktop'.format(this.desktopId));
+ if (this._app == null || this._permStoreProxy == null) {
+ this._completeAuth();
+
+ return;
+ }
+
+ this._permStoreProxy.LookupRemote(APP_PERMISSIONS_TABLE,
+ APP_PERMISSIONS_ID,
+ this._onPermLookupDone.bind(this));
+ }
+
+ _onPermLookupDone(result, error) {
+ if (error != null) {
+ if (error.domain == Gio.DBusError) {
+ // Likely no xdg-app installed, just authorize the app
+ this._accuracyLevel = this.reqAccuracyLevel;
+ this._permStoreProxy = null;
+ this._completeAuth();
+ } else {
+ // Currently xdg-app throws an error if we lookup for
+ // unknown ID (which would be the case first time this code
+ // runs) so we continue with user authorization as normal
+ // and ID is added to the store if user says "yes".
+ log(error.message);
+ this._permissions = {};
+ this._userAuthorizeApp();
+ }
+
+ return;
+ }
+
+ [this._permissions] = result;
+ let permission = this._permissions[this.desktopId];
+
+ if (permission == null) {
+ this._userAuthorizeApp();
+ } else {
+ let [levelStr] = permission || ['NONE'];
+ this._accuracyLevel = GeoclueAccuracyLevel[levelStr] ||
+ GeoclueAccuracyLevel.NONE;
+ this._completeAuth();
+ }
+ }
+
+ _userAuthorizeApp() {
+ let name = this._app.get_name();
+ let appInfo = this._app.get_app_info();
+ let reason = appInfo.get_locale_string("X-Geoclue-Reason");
+
+ this._showAppAuthDialog(name, reason);
+ }
+
+ _showAppAuthDialog(name, reason) {
+ this._dialog = new GeolocationDialog(name,
+ reason,
+ this.reqAccuracyLevel);
+
+ let responseId = this._dialog.connect('response', (dialog, level) => {
+ this._dialog.disconnect(responseId);
+ this._accuracyLevel = level;
+ this._completeAuth();
+ });
+
+ this._dialog.open();
+ }
+
+ _completeAuth() {
+ if (this._accuracyLevel != GeoclueAccuracyLevel.NONE) {
+ this._accuracyLevel = Math.clamp(this._accuracyLevel,
+ 0, this._maxAccuracyLevel);
+ }
+ this._saveToPermissionStore();
+
+ this._onAuthDone(this._accuracyLevel);
+ }
+
+ _saveToPermissionStore() {
+ if (this._permStoreProxy == null)
+ return;
+
+ let levelStr = accuracyLevelToString(this._accuracyLevel);
+ let dateStr = Math.round(Date.now() / 1000).toString();
+ this._permissions[this.desktopId] = [levelStr, dateStr];
+
+ let data = GLib.Variant.new('av', {});
+
+ this._permStoreProxy.SetRemote(APP_PERMISSIONS_TABLE,
+ true,
+ APP_PERMISSIONS_ID,
+ this._permissions,
+ data,
+ (result, error) => {
+ if (error != null)
+ log(error.message);
+ });
+ }
+};
+
+var GeolocationDialog = GObject.registerClass({
+ Signals: { 'response': { param_types: [GObject.TYPE_UINT] } },
+}, class GeolocationDialog extends ModalDialog.ModalDialog {
+ _init(name, reason, reqAccuracyLevel) {
+ super._init({ styleClass: 'geolocation-dialog' });
+ this.reqAccuracyLevel = reqAccuracyLevel;
+
+ let content = new Dialog.MessageDialogContent({
+ title: _('Allow location access'),
+ /* Translators: %s is an application name */
+ description: _('The app %s wants to access your location').format(name),
+ });
+
+ let reasonLabel = new St.Label({
+ text: reason,
+ style_class: 'message-dialog-description',
+ });
+ content.add_child(reasonLabel);
+
+ let infoLabel = new St.Label({
+ text: _('Location access can be changed at any time from the privacy settings.'),
+ style_class: 'message-dialog-description',
+ });
+ content.add_child(infoLabel);
+
+ this.contentLayout.add_child(content);
+
+ let button = this.addButton({ label: _("Deny Access"),
+ action: this._onDenyClicked.bind(this),
+ key: Clutter.KEY_Escape });
+ this.addButton({ label: _("Grant Access"),
+ action: this._onGrantClicked.bind(this) });
+
+ this.setInitialKeyFocus(button);
+ }
+
+ _onGrantClicked() {
+ this.emit('response', this.reqAccuracyLevel);
+ this.close();
+ }
+
+ _onDenyClicked() {
+ this.emit('response', GeoclueAccuracyLevel.NONE);
+ this.close();
+ }
+});
diff --git a/js/ui/status/network.js b/js/ui/status/network.js
new file mode 100644
index 0000000..377f44e
--- /dev/null
+++ b/js/ui/status/network.js
@@ -0,0 +1,2101 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported NMApplet */
+const { Clutter, Gio, GLib, GObject, NM, St } = imports.gi;
+const Signals = imports.signals;
+
+const Animation = imports.ui.animation;
+const Main = imports.ui.main;
+const PanelMenu = imports.ui.panelMenu;
+const PopupMenu = imports.ui.popupMenu;
+const MessageTray = imports.ui.messageTray;
+const ModalDialog = imports.ui.modalDialog;
+const ModemManager = imports.misc.modemManager;
+const Rfkill = imports.ui.status.rfkill;
+const Util = imports.misc.util;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+Gio._promisify(Gio.DBusConnection.prototype, 'call', 'call_finish');
+Gio._promisify(NM.Client, 'new_async', 'new_finish');
+Gio._promisify(NM.Client.prototype,
+ 'check_connectivity_async', 'check_connectivity_finish');
+
+const NMConnectionCategory = {
+ INVALID: 'invalid',
+ WIRED: 'wired',
+ WIRELESS: 'wireless',
+ WWAN: 'wwan',
+ VPN: 'vpn',
+};
+
+const NMAccessPointSecurity = {
+ NONE: 1,
+ WEP: 2,
+ WPA_PSK: 3,
+ WPA2_PSK: 4,
+ WPA_ENT: 5,
+ WPA2_ENT: 6,
+};
+
+var MAX_DEVICE_ITEMS = 4;
+
+// small optimization, to avoid using [] all the time
+const NM80211Mode = NM['80211Mode'];
+const NM80211ApFlags = NM['80211ApFlags'];
+const NM80211ApSecurityFlags = NM['80211ApSecurityFlags'];
+
+var PortalHelperResult = {
+ CANCELLED: 0,
+ COMPLETED: 1,
+ RECHECK: 2,
+};
+
+const PortalHelperIface = loadInterfaceXML('org.gnome.Shell.PortalHelper');
+const PortalHelperProxy = Gio.DBusProxy.makeProxyWrapper(PortalHelperIface);
+
+function signalToIcon(value) {
+ if (value > 80)
+ return 'excellent';
+ if (value > 55)
+ return 'good';
+ if (value > 30)
+ return 'ok';
+ if (value > 5)
+ return 'weak';
+ return 'none';
+}
+
+function ssidToLabel(ssid) {
+ let label = NM.utils_ssid_to_utf8(ssid.get_data());
+ if (!label)
+ label = _("<unknown>");
+ return label;
+}
+
+function ensureActiveConnectionProps(active) {
+ if (!active._primaryDevice) {
+ let devices = active.get_devices();
+ if (devices.length > 0) {
+ // This list is guaranteed to have at most one device in it.
+ let device = devices[0]._delegate;
+ active._primaryDevice = device;
+ }
+ }
+}
+
+function launchSettingsPanel(panel, ...args) {
+ const param = new GLib.Variant('(sav)',
+ [panel, args.map(s => new GLib.Variant('s', s))]);
+ const platformData = {
+ 'desktop-startup-id': new GLib.Variant('s',
+ '_TIME%s'.format(global.get_current_time())),
+ };
+ try {
+ Gio.DBus.session.call(
+ 'org.gnome.ControlCenter',
+ '/org/gnome/ControlCenter',
+ 'org.freedesktop.Application',
+ 'ActivateAction',
+ new GLib.Variant('(sava{sv})',
+ ['launch-panel', [param], platformData]),
+ null,
+ Gio.DBusCallFlags.NONE,
+ -1,
+ null);
+ } catch (e) {
+ log('Failed to launch Settings panel: %s'.format(e.message));
+ }
+}
+
+var NMConnectionItem = class {
+ constructor(section, connection) {
+ this._section = section;
+ this._connection = connection;
+ this._activeConnection = null;
+ this._activeConnectionChangedId = 0;
+
+ this._buildUI();
+ this._sync();
+ }
+
+ _buildUI() {
+ this.labelItem = new PopupMenu.PopupMenuItem('');
+ this.labelItem.connect('activate', this._toggle.bind(this));
+
+ this.radioItem = new PopupMenu.PopupMenuItem(this._connection.get_id(), false);
+ this.radioItem.connect('activate', this._activate.bind(this));
+ }
+
+ destroy() {
+ this.labelItem.destroy();
+ this.radioItem.destroy();
+ }
+
+ updateForConnection(connection) {
+ // connection should always be the same object
+ // (and object path) as this._connection, but
+ // this can be false if NetworkManager was restarted
+ // and picked up connections in a different order
+ // Just to be safe, we set it here again
+
+ this._connection = connection;
+ this.radioItem.label.text = connection.get_id();
+ this._sync();
+ this.emit('name-changed');
+ }
+
+ getName() {
+ return this._connection.get_id();
+ }
+
+ isActive() {
+ if (this._activeConnection == null)
+ return false;
+
+ return this._activeConnection.state <= NM.ActiveConnectionState.ACTIVATED;
+ }
+
+ _sync() {
+ let isActive = this.isActive();
+ this.labelItem.label.text = isActive ? _("Turn Off") : this._section.getConnectLabel();
+ this.radioItem.setOrnament(isActive ? PopupMenu.Ornament.DOT : PopupMenu.Ornament.NONE);
+ this.emit('icon-changed');
+ }
+
+ _toggle() {
+ if (this._activeConnection == null)
+ this._section.activateConnection(this._connection);
+ else
+ this._section.deactivateConnection(this._activeConnection);
+
+ this._sync();
+ }
+
+ _activate() {
+ if (this._activeConnection == null)
+ this._section.activateConnection(this._connection);
+
+ this._sync();
+ }
+
+ _connectionStateChanged(_ac, _newstate, _reason) {
+ this._sync();
+ }
+
+ setActiveConnection(activeConnection) {
+ if (this._activeConnectionChangedId > 0) {
+ this._activeConnection.disconnect(this._activeConnectionChangedId);
+ this._activeConnectionChangedId = 0;
+ }
+
+ this._activeConnection = activeConnection;
+
+ if (this._activeConnection) {
+ this._activeConnectionChangedId = this._activeConnection.connect('notify::state',
+ this._connectionStateChanged.bind(this));
+ }
+
+ this._sync();
+ }
+};
+Signals.addSignalMethods(NMConnectionItem.prototype);
+
+var NMConnectionSection = class NMConnectionSection {
+ constructor(client) {
+ if (this.constructor === NMConnectionSection)
+ throw new TypeError('Cannot instantiate abstract type %s'.format(this.constructor.name));
+
+ this._client = client;
+
+ this._connectionItems = new Map();
+ this._connections = [];
+
+ this._labelSection = new PopupMenu.PopupMenuSection();
+ this._radioSection = new PopupMenu.PopupMenuSection();
+
+ this.item = new PopupMenu.PopupSubMenuMenuItem('', true);
+ this.item.menu.addMenuItem(this._labelSection);
+ this.item.menu.addMenuItem(this._radioSection);
+
+ this._notifyConnectivityId = this._client.connect('notify::connectivity', this._iconChanged.bind(this));
+ }
+
+ destroy() {
+ if (this._notifyConnectivityId != 0) {
+ this._client.disconnect(this._notifyConnectivityId);
+ this._notifyConnectivityId = 0;
+ }
+
+ this.item.destroy();
+ }
+
+ _iconChanged() {
+ this._sync();
+ this.emit('icon-changed');
+ }
+
+ _sync() {
+ let nItems = this._connectionItems.size;
+
+ this._radioSection.actor.visible = nItems > 1;
+ this._labelSection.actor.visible = nItems == 1;
+
+ this.item.label.text = this._getStatus();
+ this.item.icon.icon_name = this._getMenuIcon();
+ }
+
+ _getMenuIcon() {
+ return this.getIndicatorIcon();
+ }
+
+ getConnectLabel() {
+ return _("Connect");
+ }
+
+ _connectionValid(_connection) {
+ return true;
+ }
+
+ _connectionSortFunction(one, two) {
+ return GLib.utf8_collate(one.get_id(), two.get_id());
+ }
+
+ _makeConnectionItem(connection) {
+ return new NMConnectionItem(this, connection);
+ }
+
+ checkConnection(connection) {
+ if (!this._connectionValid(connection))
+ return;
+
+ // This function is called every time the connection is added or updated.
+ // In the usual case, we already added this connection and UUID
+ // didn't change. So we need to check if we already have an item,
+ // and update it for properties in the connection that changed
+ // (the only one we care about is the name)
+ // But it's also possible we didn't know about this connection
+ // (eg, during coldplug, or because it was updated and suddenly
+ // it's valid for this device), in which case we add a new item.
+
+ let item = this._connectionItems.get(connection.get_uuid());
+ if (item)
+ this._updateForConnection(item, connection);
+ else
+ this._addConnection(connection);
+ }
+
+ _updateForConnection(item, connection) {
+ let pos = this._connections.indexOf(connection);
+
+ this._connections.splice(pos, 1);
+ pos = Util.insertSorted(this._connections, connection, this._connectionSortFunction.bind(this));
+ this._labelSection.moveMenuItem(item.labelItem, pos);
+ this._radioSection.moveMenuItem(item.radioItem, pos);
+
+ item.updateForConnection(connection);
+ }
+
+ _addConnection(connection) {
+ let item = this._makeConnectionItem(connection);
+ if (!item)
+ return;
+
+ item.connect('icon-changed', () => this._iconChanged());
+ item.connect('activation-failed', (o, reason) => {
+ this.emit('activation-failed', reason);
+ });
+ item.connect('name-changed', this._sync.bind(this));
+
+ let pos = Util.insertSorted(this._connections, connection, this._connectionSortFunction.bind(this));
+ this._labelSection.addMenuItem(item.labelItem, pos);
+ this._radioSection.addMenuItem(item.radioItem, pos);
+ this._connectionItems.set(connection.get_uuid(), item);
+ this._sync();
+ }
+
+ removeConnection(connection) {
+ let uuid = connection.get_uuid();
+ let item = this._connectionItems.get(uuid);
+ if (item == undefined)
+ return;
+
+ item.destroy();
+ this._connectionItems.delete(uuid);
+
+ let pos = this._connections.indexOf(connection);
+ this._connections.splice(pos, 1);
+
+ this._sync();
+ }
+};
+Signals.addSignalMethods(NMConnectionSection.prototype);
+
+var NMConnectionDevice = class NMConnectionDevice extends NMConnectionSection {
+ constructor(client, device) {
+ super(client);
+
+ if (this.constructor === NMConnectionDevice)
+ throw new TypeError('Cannot instantiate abstract type %s'.format(this.constructor.name));
+
+ this._device = device;
+ this._description = '';
+
+ this._autoConnectItem = this.item.menu.addAction(_("Connect"), this._autoConnect.bind(this));
+ this._deactivateItem = this._radioSection.addAction(_("Turn Off"), this.deactivateConnection.bind(this));
+
+ this._stateChangedId = this._device.connect('state-changed', this._deviceStateChanged.bind(this));
+ this._activeConnectionChangedId = this._device.connect('notify::active-connection', this._activeConnectionChanged.bind(this));
+ }
+
+ _canReachInternet() {
+ if (this._client.primary_connection != this._device.active_connection)
+ return true;
+
+ return this._client.connectivity == NM.ConnectivityState.FULL;
+ }
+
+ _autoConnect() {
+ let connection = new NM.SimpleConnection();
+ this._client.add_and_activate_connection_async(connection, this._device, null, null, null);
+ }
+
+ destroy() {
+ if (this._stateChangedId) {
+ GObject.signal_handler_disconnect(this._device, this._stateChangedId);
+ this._stateChangedId = 0;
+ }
+ if (this._activeConnectionChangedId) {
+ GObject.signal_handler_disconnect(this._device, this._activeConnectionChangedId);
+ this._activeConnectionChangedId = 0;
+ }
+
+ super.destroy();
+ }
+
+ _activeConnectionChanged() {
+ if (this._activeConnection) {
+ let item = this._connectionItems.get(this._activeConnection.connection.get_uuid());
+ item.setActiveConnection(null);
+ this._activeConnection = null;
+ }
+
+ this._sync();
+ }
+
+ _deviceStateChanged(device, newstate, oldstate, reason) {
+ if (newstate == oldstate) {
+ log('device emitted state-changed without actually changing state');
+ return;
+ }
+
+ /* Emit a notification if activation fails, but don't do it
+ if the reason is no secrets, as that indicates the user
+ cancelled the agent dialog */
+ if (newstate == NM.DeviceState.FAILED &&
+ reason != NM.DeviceStateReason.NO_SECRETS)
+ this.emit('activation-failed', reason);
+
+ this._sync();
+ }
+
+ _connectionValid(connection) {
+ return this._device.connection_valid(connection);
+ }
+
+ activateConnection(connection) {
+ this._client.activate_connection_async(connection, this._device, null, null, null);
+ }
+
+ deactivateConnection(_activeConnection) {
+ this._device.disconnect(null);
+ }
+
+ setDeviceDescription(desc) {
+ this._description = desc;
+ this._sync();
+ }
+
+ _getDescription() {
+ return this._description;
+ }
+
+ _sync() {
+ let nItems = this._connectionItems.size;
+ this._autoConnectItem.visible = nItems == 0;
+ this._deactivateItem.visible = this._device.state > NM.DeviceState.DISCONNECTED;
+
+ if (this._activeConnection == null) {
+ let activeConnection = this._device.active_connection;
+ if (activeConnection && activeConnection.connection) {
+ let item = this._connectionItems.get(activeConnection.connection.get_uuid());
+ if (item) {
+ this._activeConnection = activeConnection;
+ ensureActiveConnectionProps(this._activeConnection);
+ item.setActiveConnection(this._activeConnection);
+ }
+ }
+ }
+
+ super._sync();
+ }
+
+ _getStatus() {
+ if (!this._device)
+ return '';
+
+ switch (this._device.state) {
+ case NM.DeviceState.DISCONNECTED:
+ /* Translators: %s is a network identifier */
+ return _("%s Off").format(this._getDescription());
+ case NM.DeviceState.ACTIVATED:
+ /* Translators: %s is a network identifier */
+ return _("%s Connected").format(this._getDescription());
+ case NM.DeviceState.UNMANAGED:
+ /* Translators: this is for network devices that are physically present but are not
+ under NetworkManager's control (and thus cannot be used in the menu);
+ %s is a network identifier */
+ return _("%s Unmanaged").format(this._getDescription());
+ case NM.DeviceState.DEACTIVATING:
+ /* Translators: %s is a network identifier */
+ return _("%s Disconnecting").format(this._getDescription());
+ case NM.DeviceState.PREPARE:
+ case NM.DeviceState.CONFIG:
+ case NM.DeviceState.IP_CONFIG:
+ case NM.DeviceState.IP_CHECK:
+ case NM.DeviceState.SECONDARIES:
+ /* Translators: %s is a network identifier */
+ return _("%s Connecting").format(this._getDescription());
+ case NM.DeviceState.NEED_AUTH:
+ /* Translators: this is for network connections that require some kind of key or password; %s is a network identifier */
+ return _("%s Requires Authentication").format(this._getDescription());
+ case NM.DeviceState.UNAVAILABLE:
+ // This state is actually a compound of various states (generically unavailable,
+ // firmware missing), that are exposed by different properties (whose state may
+ // or may not updated when we receive state-changed).
+ if (this._device.firmware_missing) {
+ /* Translators: this is for devices that require some kind of firmware or kernel
+ module, which is missing; %s is a network identifier */
+ return _("Firmware Missing For %s").format(this._getDescription());
+ }
+ /* Translators: this is for a network device that cannot be activated (for example it
+ is disabled by rfkill, or it has no coverage; %s is a network identifier */
+ return _("%s Unavailable").format(this._getDescription());
+ case NM.DeviceState.FAILED:
+ /* Translators: %s is a network identifier */
+ return _("%s Connection Failed").format(this._getDescription());
+ default:
+ log('Device state invalid, is %d'.format(this._device.state));
+ return 'invalid';
+ }
+ }
+};
+
+var NMDeviceWired = class extends NMConnectionDevice {
+ constructor(client, device) {
+ super(client, device);
+
+ this.item.menu.addSettingsAction(_("Wired Settings"), 'gnome-network-panel.desktop');
+ }
+
+ get category() {
+ return NMConnectionCategory.WIRED;
+ }
+
+ _hasCarrier() {
+ if (this._device instanceof NM.DeviceEthernet)
+ return this._device.carrier;
+ else
+ return true;
+ }
+
+ _sync() {
+ this.item.visible = this._hasCarrier();
+ super._sync();
+ }
+
+ getIndicatorIcon() {
+ if (this._device.active_connection) {
+ let state = this._device.active_connection.state;
+
+ if (state == NM.ActiveConnectionState.ACTIVATING) {
+ return 'network-wired-acquiring-symbolic';
+ } else if (state == NM.ActiveConnectionState.ACTIVATED) {
+ if (this._canReachInternet())
+ return 'network-wired-symbolic';
+ else
+ return 'network-wired-no-route-symbolic';
+ } else {
+ return 'network-wired-disconnected-symbolic';
+ }
+ } else {
+ return 'network-wired-disconnected-symbolic';
+ }
+ }
+};
+
+var NMDeviceModem = class extends NMConnectionDevice {
+ constructor(client, device) {
+ super(client, device);
+
+ this.item.menu.addSettingsAction(_("Mobile Broadband Settings"), 'gnome-network-panel.desktop');
+
+ this._mobileDevice = null;
+
+ let capabilities = device.current_capabilities;
+ if (device.udi.indexOf('/org/freedesktop/ModemManager1/Modem') == 0)
+ this._mobileDevice = new ModemManager.BroadbandModem(device.udi, capabilities);
+ else if (capabilities & NM.DeviceModemCapabilities.GSM_UMTS)
+ this._mobileDevice = new ModemManager.ModemGsm(device.udi);
+ else if (capabilities & NM.DeviceModemCapabilities.CDMA_EVDO)
+ this._mobileDevice = new ModemManager.ModemCdma(device.udi);
+ else if (capabilities & NM.DeviceModemCapabilities.LTE)
+ this._mobileDevice = new ModemManager.ModemGsm(device.udi);
+
+ if (this._mobileDevice) {
+ this._operatorNameId = this._mobileDevice.connect('notify::operator-name', this._sync.bind(this));
+ this._signalQualityId = this._mobileDevice.connect('notify::signal-quality', () => {
+ this._iconChanged();
+ });
+ }
+ }
+
+ get category() {
+ return NMConnectionCategory.WWAN;
+ }
+
+ _autoConnect() {
+ launchSettingsPanel('network', 'connect-3g', this._device.get_path());
+ }
+
+ destroy() {
+ if (this._operatorNameId) {
+ this._mobileDevice.disconnect(this._operatorNameId);
+ this._operatorNameId = 0;
+ }
+ if (this._signalQualityId) {
+ this._mobileDevice.disconnect(this._signalQualityId);
+ this._signalQualityId = 0;
+ }
+
+ super.destroy();
+ }
+
+ _getStatus() {
+ if (!this._client.wwan_hardware_enabled)
+ /* Translators: %s is a network identifier */
+ return _("%s Hardware Disabled").format(this._getDescription());
+ else if (!this._client.wwan_enabled)
+ /* Translators: this is for a network device that cannot be activated
+ because it's disabled by rfkill (airplane mode); %s is a network identifier */
+ return _("%s Disabled").format(this._getDescription());
+ else if (this._device.state == NM.DeviceState.ACTIVATED &&
+ this._mobileDevice && this._mobileDevice.operator_name)
+ return this._mobileDevice.operator_name;
+ else
+ return super._getStatus();
+ }
+
+ getIndicatorIcon() {
+ if (this._device.active_connection) {
+ if (this._device.active_connection.state == NM.ActiveConnectionState.ACTIVATING)
+ return 'network-cellular-acquiring-symbolic';
+
+ return this._getSignalIcon();
+ } else {
+ return 'network-cellular-signal-none-symbolic';
+ }
+ }
+
+ _getSignalIcon() {
+ return 'network-cellular-signal-%s-symbolic'.format(
+ signalToIcon(this._mobileDevice.signal_quality));
+ }
+};
+
+var NMDeviceBluetooth = class extends NMConnectionDevice {
+ constructor(client, device) {
+ super(client, device);
+
+ this.item.menu.addSettingsAction(_("Bluetooth Settings"), 'gnome-network-panel.desktop');
+ }
+
+ get category() {
+ return NMConnectionCategory.WWAN;
+ }
+
+ _getDescription() {
+ return this._device.name;
+ }
+
+ getConnectLabel() {
+ return _("Connect to Internet");
+ }
+
+ getIndicatorIcon() {
+ if (this._device.active_connection) {
+ let state = this._device.active_connection.state;
+ if (state == NM.ActiveConnectionState.ACTIVATING)
+ return 'network-cellular-acquiring-symbolic';
+ else if (state == NM.ActiveConnectionState.ACTIVATED)
+ return 'network-cellular-connected-symbolic';
+ else
+ return 'network-cellular-signal-none-symbolic';
+ } else {
+ return 'network-cellular-signal-none-symbolic';
+ }
+ }
+};
+
+var NMWirelessDialogItem = GObject.registerClass({
+ Signals: {
+ 'selected': {},
+ },
+}, class NMWirelessDialogItem extends St.BoxLayout {
+ _init(network) {
+ this._network = network;
+ this._ap = network.accessPoints[0];
+
+ super._init({ style_class: 'nm-dialog-item',
+ can_focus: true,
+ reactive: true });
+
+ let action = new Clutter.ClickAction();
+ action.connect('clicked', () => this.grab_key_focus());
+ this.add_action(action);
+
+ let title = ssidToLabel(this._ap.get_ssid());
+ this._label = new St.Label({
+ text: title,
+ x_expand: true,
+ });
+
+ this.label_actor = this._label;
+ this.add_child(this._label);
+
+ this._selectedIcon = new St.Icon({ style_class: 'nm-dialog-icon',
+ icon_name: 'object-select-symbolic' });
+ this.add(this._selectedIcon);
+
+ this._icons = new St.BoxLayout({
+ style_class: 'nm-dialog-icons',
+ x_align: Clutter.ActorAlign.END,
+ });
+ this.add_child(this._icons);
+
+ this._secureIcon = new St.Icon({ style_class: 'nm-dialog-icon' });
+ if (this._ap._secType != NMAccessPointSecurity.NONE)
+ this._secureIcon.icon_name = 'network-wireless-encrypted-symbolic';
+ this._icons.add_actor(this._secureIcon);
+
+ this._signalIcon = new St.Icon({ style_class: 'nm-dialog-icon' });
+ this._icons.add_actor(this._signalIcon);
+
+ this._sync();
+ }
+
+ vfunc_key_focus_in() {
+ this.emit('selected');
+ }
+
+ _sync() {
+ this._signalIcon.icon_name = this._getSignalIcon();
+ }
+
+ updateBestAP(ap) {
+ this._ap = ap;
+ this._sync();
+ }
+
+ setActive(isActive) {
+ this._selectedIcon.opacity = isActive ? 255 : 0;
+ }
+
+ _getSignalIcon() {
+ if (this._ap.mode == NM80211Mode.ADHOC) {
+ return 'network-workgroup-symbolic';
+ } else {
+ return 'network-wireless-signal-%s-symbolic'.format(
+ signalToIcon(this._ap.strength));
+ }
+ }
+});
+
+var NMWirelessDialog = GObject.registerClass(
+class NMWirelessDialog extends ModalDialog.ModalDialog {
+ _init(client, device) {
+ super._init({ styleClass: 'nm-dialog' });
+
+ this._client = client;
+ this._device = device;
+
+ this._wirelessEnabledChangedId = this._client.connect('notify::wireless-enabled',
+ this._syncView.bind(this));
+
+ this._rfkill = Rfkill.getRfkillManager();
+ this._airplaneModeChangedId = this._rfkill.connect('airplane-mode-changed',
+ this._syncView.bind(this));
+
+ this._networks = [];
+ this._buildLayout();
+
+ let connections = client.get_connections();
+ this._connections = connections.filter(
+ connection => device.connection_valid(connection));
+
+ this._apAddedId = device.connect('access-point-added', this._accessPointAdded.bind(this));
+ this._apRemovedId = device.connect('access-point-removed', this._accessPointRemoved.bind(this));
+ this._activeApChangedId = device.connect('notify::active-access-point', this._activeApChanged.bind(this));
+
+ // accessPointAdded will also create dialog items
+ let accessPoints = device.get_access_points() || [];
+ accessPoints.forEach(ap => {
+ this._accessPointAdded(this._device, ap);
+ });
+
+ this._selectedNetwork = null;
+ this._activeApChanged();
+ this._updateSensitivity();
+ this._syncView();
+
+ this._scanTimeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 15, this._onScanTimeout.bind(this));
+ GLib.Source.set_name_by_id(this._scanTimeoutId, '[gnome-shell] this._onScanTimeout');
+ this._onScanTimeout();
+
+ let id = Main.sessionMode.connect('updated', () => {
+ if (Main.sessionMode.allowSettings)
+ return;
+
+ Main.sessionMode.disconnect(id);
+ this.close();
+ });
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ _onDestroy() {
+ if (this._apAddedId) {
+ GObject.Object.prototype.disconnect.call(this._device, this._apAddedId);
+ this._apAddedId = 0;
+ }
+ if (this._apRemovedId) {
+ GObject.Object.prototype.disconnect.call(this._device, this._apRemovedId);
+ this._apRemovedId = 0;
+ }
+ if (this._activeApChangedId) {
+ GObject.Object.prototype.disconnect.call(this._device, this._activeApChangedId);
+ this._activeApChangedId = 0;
+ }
+ if (this._wirelessEnabledChangedId) {
+ this._client.disconnect(this._wirelessEnabledChangedId);
+ this._wirelessEnabledChangedId = 0;
+ }
+ if (this._airplaneModeChangedId) {
+ this._rfkill.disconnect(this._airplaneModeChangedId);
+ this._airplaneModeChangedId = 0;
+ }
+
+ if (this._scanTimeoutId) {
+ GLib.source_remove(this._scanTimeoutId);
+ this._scanTimeoutId = 0;
+ }
+ }
+
+ _onScanTimeout() {
+ this._device.request_scan_async(null, null);
+ return GLib.SOURCE_CONTINUE;
+ }
+
+ _activeApChanged() {
+ if (this._activeNetwork)
+ this._activeNetwork.item.setActive(false);
+
+ this._activeNetwork = null;
+ if (this._device.active_access_point) {
+ let idx = this._findNetwork(this._device.active_access_point);
+ if (idx >= 0)
+ this._activeNetwork = this._networks[idx];
+ }
+
+ if (this._activeNetwork)
+ this._activeNetwork.item.setActive(true);
+ this._updateSensitivity();
+ }
+
+ _updateSensitivity() {
+ let connectSensitive = this._client.wireless_enabled && this._selectedNetwork && (this._selectedNetwork != this._activeNetwork);
+ this._connectButton.reactive = connectSensitive;
+ this._connectButton.can_focus = connectSensitive;
+ }
+
+ _syncView() {
+ if (this._rfkill.airplaneMode) {
+ this._airplaneBox.show();
+
+ this._airplaneIcon.icon_name = 'airplane-mode-symbolic';
+ this._airplaneHeadline.text = _("Airplane Mode is On");
+ this._airplaneText.text = _("Wi-Fi is disabled when airplane mode is on.");
+ this._airplaneButton.label = _("Turn Off Airplane Mode");
+
+ this._airplaneButton.visible = !this._rfkill.hwAirplaneMode;
+ this._airplaneInactive.visible = this._rfkill.hwAirplaneMode;
+ this._noNetworksBox.hide();
+ } else if (!this._client.wireless_enabled) {
+ this._airplaneBox.show();
+
+ this._airplaneIcon.icon_name = 'dialog-information-symbolic';
+ this._airplaneHeadline.text = _("Wi-Fi is Off");
+ this._airplaneText.text = _("Wi-Fi needs to be turned on in order to connect to a network.");
+ this._airplaneButton.label = _("Turn On Wi-Fi");
+
+ this._airplaneButton.show();
+ this._airplaneInactive.hide();
+ this._noNetworksBox.hide();
+ } else {
+ this._airplaneBox.hide();
+
+ this._noNetworksBox.visible = this._networks.length == 0;
+ }
+
+ if (this._noNetworksBox.visible)
+ this._noNetworksSpinner.play();
+ else
+ this._noNetworksSpinner.stop();
+ }
+
+ _buildLayout() {
+ let headline = new St.BoxLayout({ style_class: 'nm-dialog-header-hbox' });
+
+ let icon = new St.Icon({ style_class: 'nm-dialog-header-icon',
+ icon_name: 'network-wireless-signal-excellent-symbolic' });
+
+ let titleBox = new St.BoxLayout({ vertical: true });
+ let title = new St.Label({ style_class: 'nm-dialog-header',
+ text: _("Wi-Fi Networks") });
+ let subtitle = new St.Label({ style_class: 'nm-dialog-subheader',
+ text: _("Select a network") });
+ titleBox.add(title);
+ titleBox.add(subtitle);
+
+ headline.add(icon);
+ headline.add(titleBox);
+
+ this.contentLayout.style_class = 'nm-dialog-content';
+ this.contentLayout.add(headline);
+
+ this._stack = new St.Widget({
+ layout_manager: new Clutter.BinLayout(),
+ y_expand: true,
+ });
+
+ this._itemBox = new St.BoxLayout({ vertical: true });
+ this._scrollView = new St.ScrollView({ style_class: 'nm-dialog-scroll-view' });
+ this._scrollView.set_x_expand(true);
+ this._scrollView.set_y_expand(true);
+ this._scrollView.set_policy(St.PolicyType.NEVER,
+ St.PolicyType.AUTOMATIC);
+ this._scrollView.add_actor(this._itemBox);
+ this._stack.add_child(this._scrollView);
+
+ this._noNetworksBox = new St.BoxLayout({ vertical: true,
+ style_class: 'no-networks-box',
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER });
+
+ this._noNetworksSpinner = new Animation.Spinner(16);
+ this._noNetworksBox.add_actor(this._noNetworksSpinner);
+ this._noNetworksBox.add_actor(new St.Label({ style_class: 'no-networks-label',
+ text: _("No Networks") }));
+ this._stack.add_child(this._noNetworksBox);
+
+ this._airplaneBox = new St.BoxLayout({ vertical: true,
+ style_class: 'nm-dialog-airplane-box',
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER });
+ this._airplaneIcon = new St.Icon({ icon_size: 48 });
+ this._airplaneHeadline = new St.Label({ style_class: 'nm-dialog-airplane-headline headline' });
+ this._airplaneText = new St.Label({ style_class: 'nm-dialog-airplane-text' });
+
+ let airplaneSubStack = new St.Widget({ layout_manager: new Clutter.BinLayout() });
+ this._airplaneButton = new St.Button({ style_class: 'modal-dialog-button button' });
+ this._airplaneButton.connect('clicked', () => {
+ if (this._rfkill.airplaneMode)
+ this._rfkill.airplaneMode = false;
+ else
+ this._client.wireless_enabled = true;
+ });
+ airplaneSubStack.add_actor(this._airplaneButton);
+ this._airplaneInactive = new St.Label({ style_class: 'nm-dialog-airplane-text',
+ text: _("Use hardware switch to turn off") });
+ airplaneSubStack.add_actor(this._airplaneInactive);
+
+ this._airplaneBox.add_child(this._airplaneIcon);
+ this._airplaneBox.add_child(this._airplaneHeadline);
+ this._airplaneBox.add_child(this._airplaneText);
+ this._airplaneBox.add_child(airplaneSubStack);
+ this._stack.add_child(this._airplaneBox);
+
+ this.contentLayout.add_child(this._stack);
+
+ this._disconnectButton = this.addButton({ action: () => this.close(),
+ label: _("Cancel"),
+ key: Clutter.KEY_Escape });
+ this._connectButton = this.addButton({ action: this._connect.bind(this),
+ label: _("Connect"),
+ key: Clutter.KEY_Return });
+ }
+
+ _connect() {
+ let network = this._selectedNetwork;
+ if (network.connections.length > 0) {
+ let connection = network.connections[0];
+ this._client.activate_connection_async(connection, this._device, null, null, null);
+ } else {
+ let accessPoints = network.accessPoints;
+ if ((accessPoints[0]._secType == NMAccessPointSecurity.WPA2_ENT) ||
+ (accessPoints[0]._secType == NMAccessPointSecurity.WPA_ENT)) {
+ // 802.1x-enabled APs require further configuration, so they're
+ // handled in gnome-control-center
+ launchSettingsPanel('wifi', 'connect-8021x-wifi',
+ this._device.get_path(), accessPoints[0].get_path());
+ } else {
+ let connection = new NM.SimpleConnection();
+ this._client.add_and_activate_connection_async(connection, this._device, accessPoints[0].get_path(), null, null);
+ }
+ }
+
+ this.close();
+ }
+
+ _notifySsidCb(accessPoint) {
+ if (accessPoint.get_ssid() != null) {
+ accessPoint.disconnect(accessPoint._notifySsidId);
+ accessPoint._notifySsidId = 0;
+ this._accessPointAdded(this._device, accessPoint);
+ }
+ }
+
+ _getApSecurityType(accessPoint) {
+ if (accessPoint._secType)
+ return accessPoint._secType;
+
+ let flags = accessPoint.flags;
+ let wpaFlags = accessPoint.wpa_flags;
+ let rsnFlags = accessPoint.rsn_flags;
+ let type;
+ if (rsnFlags != NM80211ApSecurityFlags.NONE) {
+ /* RSN check first so that WPA+WPA2 APs are treated as RSN/WPA2 */
+ if (rsnFlags & NM80211ApSecurityFlags.KEY_MGMT_802_1X)
+ type = NMAccessPointSecurity.WPA2_ENT;
+ else if (rsnFlags & NM80211ApSecurityFlags.KEY_MGMT_PSK)
+ type = NMAccessPointSecurity.WPA2_PSK;
+ } else if (wpaFlags != NM80211ApSecurityFlags.NONE) {
+ if (wpaFlags & NM80211ApSecurityFlags.KEY_MGMT_802_1X)
+ type = NMAccessPointSecurity.WPA_ENT;
+ else if (wpaFlags & NM80211ApSecurityFlags.KEY_MGMT_PSK)
+ type = NMAccessPointSecurity.WPA_PSK;
+ } else {
+ // eslint-disable-next-line no-lonely-if
+ if (flags & NM80211ApFlags.PRIVACY)
+ type = NMAccessPointSecurity.WEP;
+ else
+ type = NMAccessPointSecurity.NONE;
+ }
+
+ // cache the found value to avoid checking flags all the time
+ accessPoint._secType = type;
+ return type;
+ }
+
+ _networkSortFunction(one, two) {
+ let oneHasConnection = one.connections.length != 0;
+ let twoHasConnection = two.connections.length != 0;
+
+ // place known connections first
+ // (-1 = good order, 1 = wrong order)
+ if (oneHasConnection && !twoHasConnection)
+ return -1;
+ else if (!oneHasConnection && twoHasConnection)
+ return 1;
+
+ let oneAp = one.accessPoints[0] || null;
+ let twoAp = two.accessPoints[0] || null;
+
+ if (oneAp != null && twoAp == null)
+ return -1;
+ else if (oneAp == null && twoAp != null)
+ return 1;
+
+ let oneStrength = oneAp.strength;
+ let twoStrength = twoAp.strength;
+
+ // place stronger connections first
+ if (oneStrength != twoStrength)
+ return oneStrength < twoStrength ? 1 : -1;
+
+ let oneHasSecurity = one.security != NMAccessPointSecurity.NONE;
+ let twoHasSecurity = two.security != NMAccessPointSecurity.NONE;
+
+ // place secure connections first
+ // (we treat WEP/WPA/WPA2 the same as there is no way to
+ // take them apart from the UI)
+ if (oneHasSecurity && !twoHasSecurity)
+ return -1;
+ else if (!oneHasSecurity && twoHasSecurity)
+ return 1;
+
+ // sort alphabetically
+ return GLib.utf8_collate(one.ssidText, two.ssidText);
+ }
+
+ _networkCompare(network, accessPoint) {
+ if (!network.ssid.equal(accessPoint.get_ssid()))
+ return false;
+ if (network.mode != accessPoint.mode)
+ return false;
+ if (network.security != this._getApSecurityType(accessPoint))
+ return false;
+
+ return true;
+ }
+
+ _findExistingNetwork(accessPoint) {
+ for (let i = 0; i < this._networks.length; i++) {
+ let network = this._networks[i];
+ for (let j = 0; j < network.accessPoints.length; j++) {
+ if (network.accessPoints[j] == accessPoint)
+ return { network: i, ap: j };
+ }
+ }
+
+ return null;
+ }
+
+ _findNetwork(accessPoint) {
+ if (accessPoint.get_ssid() == null)
+ return -1;
+
+ for (let i = 0; i < this._networks.length; i++) {
+ if (this._networkCompare(this._networks[i], accessPoint))
+ return i;
+ }
+ return -1;
+ }
+
+ _checkConnections(network, accessPoint) {
+ this._connections.forEach(connection => {
+ if (accessPoint.connection_valid(connection) &&
+ !network.connections.includes(connection))
+ network.connections.push(connection);
+ });
+ }
+
+ _accessPointAdded(device, accessPoint) {
+ if (accessPoint.get_ssid() == null) {
+ // This access point is not visible yet
+ // Wait for it to get a ssid
+ accessPoint._notifySsidId = accessPoint.connect('notify::ssid', this._notifySsidCb.bind(this));
+ return;
+ }
+
+ let pos = this._findNetwork(accessPoint);
+ let network;
+
+ if (pos != -1) {
+ network = this._networks[pos];
+ if (network.accessPoints.includes(accessPoint)) {
+ log('Access point was already seen, not adding again');
+ return;
+ }
+
+ Util.insertSorted(network.accessPoints, accessPoint, (one, two) => {
+ return two.strength - one.strength;
+ });
+ network.item.updateBestAP(network.accessPoints[0]);
+ this._checkConnections(network, accessPoint);
+
+ this._resortItems();
+ } else {
+ network = {
+ ssid: accessPoint.get_ssid(),
+ mode: accessPoint.mode,
+ security: this._getApSecurityType(accessPoint),
+ connections: [],
+ item: null,
+ accessPoints: [accessPoint],
+ };
+ network.ssidText = ssidToLabel(network.ssid);
+ this._checkConnections(network, accessPoint);
+
+ let newPos = Util.insertSorted(this._networks, network, this._networkSortFunction);
+ this._createNetworkItem(network);
+ this._itemBox.insert_child_at_index(network.item, newPos);
+ }
+
+ this._syncView();
+ }
+
+ _accessPointRemoved(device, accessPoint) {
+ let res = this._findExistingNetwork(accessPoint);
+
+ if (res == null) {
+ log('Removing an access point that was never added');
+ return;
+ }
+
+ let network = this._networks[res.network];
+ network.accessPoints.splice(res.ap, 1);
+
+ if (network.accessPoints.length == 0) {
+ network.item.destroy();
+ this._networks.splice(res.network, 1);
+ } else {
+ network.item.updateBestAP(network.accessPoints[0]);
+ this._resortItems();
+ }
+
+ this._syncView();
+ }
+
+ _resortItems() {
+ let adjustment = this._scrollView.vscroll.adjustment;
+ let scrollValue = adjustment.value;
+
+ this._itemBox.remove_all_children();
+ this._networks.forEach(network => {
+ this._itemBox.add_child(network.item);
+ });
+
+ adjustment.value = scrollValue;
+ }
+
+ _selectNetwork(network) {
+ if (this._selectedNetwork)
+ this._selectedNetwork.item.remove_style_pseudo_class('selected');
+
+ this._selectedNetwork = network;
+ this._updateSensitivity();
+
+ if (this._selectedNetwork)
+ this._selectedNetwork.item.add_style_pseudo_class('selected');
+ }
+
+ _createNetworkItem(network) {
+ network.item = new NMWirelessDialogItem(network);
+ network.item.setActive(network == this._selectedNetwork);
+ network.item.connect('selected', () => {
+ Util.ensureActorVisibleInScrollView(this._scrollView, network.item);
+ this._selectNetwork(network);
+ });
+ network.item.connect('destroy', () => {
+ let keyFocus = global.stage.key_focus;
+ if (keyFocus && keyFocus.contains(network.item))
+ this._itemBox.grab_key_focus();
+ });
+ }
+});
+
+var NMDeviceWireless = class {
+ constructor(client, device) {
+ this._client = client;
+ this._device = device;
+
+ this._description = '';
+
+ this.item = new PopupMenu.PopupSubMenuMenuItem('', true);
+ this.item.menu.addAction(_("Select Network"), this._showDialog.bind(this));
+
+ this._toggleItem = new PopupMenu.PopupMenuItem('');
+ this._toggleItem.connect('activate', this._toggleWifi.bind(this));
+ this.item.menu.addMenuItem(this._toggleItem);
+
+ this.item.menu.addSettingsAction(_("Wi-Fi Settings"), 'gnome-wifi-panel.desktop');
+
+ this._wirelessEnabledChangedId = this._client.connect('notify::wireless-enabled', this._sync.bind(this));
+ this._wirelessHwEnabledChangedId = this._client.connect('notify::wireless-hardware-enabled', this._sync.bind(this));
+ this._activeApChangedId = this._device.connect('notify::active-access-point', this._activeApChanged.bind(this));
+ this._stateChangedId = this._device.connect('state-changed', this._deviceStateChanged.bind(this));
+ this._notifyConnectivityId = this._client.connect('notify::connectivity', this._iconChanged.bind(this));
+
+ this._sync();
+ }
+
+ get category() {
+ return NMConnectionCategory.WIRELESS;
+ }
+
+ _iconChanged() {
+ this._sync();
+ this.emit('icon-changed');
+ }
+
+ destroy() {
+ if (this._activeApChangedId) {
+ GObject.signal_handler_disconnect(this._device, this._activeApChangedId);
+ this._activeApChangedId = 0;
+ }
+ if (this._stateChangedId) {
+ GObject.signal_handler_disconnect(this._device, this._stateChangedId);
+ this._stateChangedId = 0;
+ }
+ if (this._strengthChangedId > 0) {
+ this._activeAccessPoint.disconnect(this._strengthChangedId);
+ this._strengthChangedId = 0;
+ }
+ if (this._wirelessEnabledChangedId) {
+ this._client.disconnect(this._wirelessEnabledChangedId);
+ this._wirelessEnabledChangedId = 0;
+ }
+ if (this._wirelessHwEnabledChangedId) {
+ this._client.disconnect(this._wirelessHwEnabledChangedId);
+ this._wirelessHwEnabledChangedId = 0;
+ }
+ if (this._dialog) {
+ this._dialog.destroy();
+ this._dialog = null;
+ }
+ if (this._notifyConnectivityId) {
+ this._client.disconnect(this._notifyConnectivityId);
+ this._notifyConnectivityId = 0;
+ }
+
+ this.item.destroy();
+ }
+
+ _deviceStateChanged(device, newstate, oldstate, reason) {
+ if (newstate == oldstate) {
+ log('device emitted state-changed without actually changing state');
+ return;
+ }
+
+ /* Emit a notification if activation fails, but don't do it
+ if the reason is no secrets, as that indicates the user
+ cancelled the agent dialog */
+ if (newstate == NM.DeviceState.FAILED &&
+ reason != NM.DeviceStateReason.NO_SECRETS)
+ this.emit('activation-failed', reason);
+
+ this._sync();
+ }
+
+ _toggleWifi() {
+ this._client.wireless_enabled = !this._client.wireless_enabled;
+ }
+
+ _showDialog() {
+ this._dialog = new NMWirelessDialog(this._client, this._device);
+ this._dialog.connect('closed', this._dialogClosed.bind(this));
+ this._dialog.open();
+ }
+
+ _dialogClosed() {
+ this._dialog = null;
+ }
+
+ _strengthChanged() {
+ this._iconChanged();
+ }
+
+ _activeApChanged() {
+ if (this._activeAccessPoint) {
+ this._activeAccessPoint.disconnect(this._strengthChangedId);
+ this._strengthChangedId = 0;
+ }
+
+ this._activeAccessPoint = this._device.active_access_point;
+
+ if (this._activeAccessPoint) {
+ this._strengthChangedId = this._activeAccessPoint.connect('notify::strength',
+ this._strengthChanged.bind(this));
+ }
+
+ this._sync();
+ }
+
+ _sync() {
+ this._toggleItem.label.text = this._client.wireless_enabled ? _("Turn Off") : _("Turn On");
+ this._toggleItem.visible = this._client.wireless_hardware_enabled;
+
+ this.item.icon.icon_name = this._getMenuIcon();
+ this.item.label.text = this._getStatus();
+ }
+
+ setDeviceDescription(desc) {
+ this._description = desc;
+ this._sync();
+ }
+
+ _getStatus() {
+ let ap = this._device.active_access_point;
+
+ if (this._isHotSpotMaster())
+ /* Translators: %s is a network identifier */
+ return _("%s Hotspot Active").format(this._description);
+ else if (this._device.state >= NM.DeviceState.PREPARE &&
+ this._device.state < NM.DeviceState.ACTIVATED)
+ /* Translators: %s is a network identifier */
+ return _("%s Connecting").format(this._description);
+ else if (ap)
+ return ssidToLabel(ap.get_ssid());
+ else if (!this._client.wireless_hardware_enabled)
+ /* Translators: %s is a network identifier */
+ return _("%s Hardware Disabled").format(this._description);
+ else if (!this._client.wireless_enabled)
+ /* Translators: %s is a network identifier */
+ return _("%s Off").format(this._description);
+ else if (this._device.state == NM.DeviceState.DISCONNECTED)
+ /* Translators: %s is a network identifier */
+ return _("%s Not Connected").format(this._description);
+ else
+ return '';
+ }
+
+ _getMenuIcon() {
+ if (this._device.active_connection)
+ return this.getIndicatorIcon();
+ else
+ return 'network-wireless-signal-none-symbolic';
+ }
+
+ _canReachInternet() {
+ if (this._client.primary_connection != this._device.active_connection)
+ return true;
+
+ return this._client.connectivity == NM.ConnectivityState.FULL;
+ }
+
+ _isHotSpotMaster() {
+ if (!this._device.active_connection)
+ return false;
+
+ let connection = this._device.active_connection.connection;
+ if (!connection)
+ return false;
+
+ let ip4config = connection.get_setting_ip4_config();
+ if (!ip4config)
+ return false;
+
+ return ip4config.get_method() == NM.SETTING_IP4_CONFIG_METHOD_SHARED;
+ }
+
+ getIndicatorIcon() {
+ if (this._device.state < NM.DeviceState.PREPARE)
+ return 'network-wireless-disconnected-symbolic';
+ if (this._device.state < NM.DeviceState.ACTIVATED)
+ return 'network-wireless-acquiring-symbolic';
+
+ if (this._isHotSpotMaster())
+ return 'network-wireless-hotspot-symbolic';
+
+ let ap = this._device.active_access_point;
+ if (!ap) {
+ if (this._device.mode != NM80211Mode.ADHOC)
+ log('An active wireless connection, in infrastructure mode, involves no access point?');
+
+ if (this._canReachInternet())
+ return 'network-wireless-connected-symbolic';
+ else
+ return 'network-wireless-no-route-symbolic';
+ }
+
+ if (this._canReachInternet())
+ return 'network-wireless-signal-%s-symbolic'.format(signalToIcon(ap.strength));
+ else
+ return 'network-wireless-no-route-symbolic';
+ }
+};
+Signals.addSignalMethods(NMDeviceWireless.prototype);
+
+var NMVpnConnectionItem = class extends NMConnectionItem {
+ isActive() {
+ if (this._activeConnection == null)
+ return false;
+
+ return this._activeConnection.vpn_state != NM.VpnConnectionState.DISCONNECTED;
+ }
+
+ _buildUI() {
+ this.labelItem = new PopupMenu.PopupMenuItem('');
+ this.labelItem.connect('activate', this._toggle.bind(this));
+
+ this.radioItem = new PopupMenu.PopupSwitchMenuItem(this._connection.get_id(), false);
+ this.radioItem.connect('toggled', this._toggle.bind(this));
+ }
+
+ _sync() {
+ let isActive = this.isActive();
+ this.labelItem.label.text = isActive ? _("Turn Off") : this._section.getConnectLabel();
+ this.radioItem.setToggleState(isActive);
+ this.radioItem.setStatus(this._getStatus());
+ this.emit('icon-changed');
+ }
+
+ _getStatus() {
+ if (this._activeConnection == null)
+ return null;
+
+ switch (this._activeConnection.vpn_state) {
+ case NM.VpnConnectionState.DISCONNECTED:
+ case NM.VpnConnectionState.ACTIVATED:
+ return null;
+ case NM.VpnConnectionState.PREPARE:
+ case NM.VpnConnectionState.CONNECT:
+ case NM.VpnConnectionState.IP_CONFIG_GET:
+ return _("connecting…");
+ case NM.VpnConnectionState.NEED_AUTH:
+ /* Translators: this is for network connections that require some kind of key or password */
+ return _("authentication required");
+ case NM.VpnConnectionState.FAILED:
+ return _("connection failed");
+ default:
+ return 'invalid';
+ }
+ }
+
+ _connectionStateChanged(ac, newstate, reason) {
+ if (newstate == NM.VpnConnectionState.FAILED &&
+ reason != NM.VpnConnectionStateReason.NO_SECRETS) {
+ // FIXME: if we ever want to show something based on reason,
+ // we need to convert from NM.VpnConnectionStateReason
+ // to NM.DeviceStateReason
+ this.emit('activation-failed', reason);
+ }
+
+ this.emit('icon-changed');
+ super._connectionStateChanged();
+ }
+
+ setActiveConnection(activeConnection) {
+ if (this._activeConnectionChangedId > 0) {
+ this._activeConnection.disconnect(this._activeConnectionChangedId);
+ this._activeConnectionChangedId = 0;
+ }
+
+ this._activeConnection = activeConnection;
+
+ if (this._activeConnection) {
+ this._activeConnectionChangedId = this._activeConnection.connect('vpn-state-changed',
+ this._connectionStateChanged.bind(this));
+ }
+
+ this._sync();
+ }
+
+ getIndicatorIcon() {
+ if (this._activeConnection) {
+ if (this._activeConnection.vpn_state < NM.VpnConnectionState.ACTIVATED)
+ return 'network-vpn-acquiring-symbolic';
+ else
+ return 'network-vpn-symbolic';
+ } else {
+ return '';
+ }
+ }
+};
+
+var NMVpnSection = class extends NMConnectionSection {
+ constructor(client) {
+ super(client);
+
+ this.item.menu.addSettingsAction(_("VPN Settings"), 'gnome-network-panel.desktop');
+
+ this._sync();
+ }
+
+ _sync() {
+ let nItems = this._connectionItems.size;
+ this.item.visible = nItems > 0;
+
+ super._sync();
+ }
+
+ get category() {
+ return NMConnectionCategory.VPN;
+ }
+
+ _getDescription() {
+ return _("VPN");
+ }
+
+ _getStatus() {
+ let values = this._connectionItems.values();
+ for (let item of values) {
+ if (item.isActive())
+ return item.getName();
+ }
+
+ return _("VPN Off");
+ }
+
+ _getMenuIcon() {
+ return this.getIndicatorIcon() || 'network-vpn-symbolic';
+ }
+
+ activateConnection(connection) {
+ this._client.activate_connection_async(connection, null, null, null, null);
+ }
+
+ deactivateConnection(activeConnection) {
+ this._client.deactivate_connection(activeConnection, null);
+ }
+
+ setActiveConnections(vpnConnections) {
+ let connections = this._connectionItems.values();
+ for (let item of connections)
+ item.setActiveConnection(null);
+
+ vpnConnections.forEach(a => {
+ if (a.connection) {
+ let item = this._connectionItems.get(a.connection.get_uuid());
+ item.setActiveConnection(a);
+ }
+ });
+ }
+
+ _makeConnectionItem(connection) {
+ return new NMVpnConnectionItem(this, connection);
+ }
+
+ getIndicatorIcon() {
+ let items = this._connectionItems.values();
+ for (let item of items) {
+ let icon = item.getIndicatorIcon();
+ if (icon)
+ return icon;
+ }
+ return '';
+ }
+};
+Signals.addSignalMethods(NMVpnSection.prototype);
+
+var DeviceCategory = class extends PopupMenu.PopupMenuSection {
+ constructor(category) {
+ super();
+
+ this._category = category;
+
+ this.devices = [];
+
+ this.section = new PopupMenu.PopupMenuSection();
+ this.section.box.connect('actor-added', this._sync.bind(this));
+ this.section.box.connect('actor-removed', this._sync.bind(this));
+ this.addMenuItem(this.section);
+
+ this._summaryItem = new PopupMenu.PopupSubMenuMenuItem('', true);
+ this._summaryItem.icon.icon_name = this._getSummaryIcon();
+ this.addMenuItem(this._summaryItem);
+
+ this._summaryItem.menu.addSettingsAction(_('Network Settings'),
+ 'gnome-network-panel.desktop');
+ this._summaryItem.hide();
+
+ }
+
+ _sync() {
+ let nDevices = this.section.box.get_children().reduce(
+ (prev, child) => prev + (child.visible ? 1 : 0), 0);
+ this._summaryItem.label.text = this._getSummaryLabel(nDevices);
+ let shouldSummarize = nDevices > MAX_DEVICE_ITEMS;
+ this._summaryItem.visible = shouldSummarize;
+ this.section.actor.visible = !shouldSummarize;
+ }
+
+ _getSummaryIcon() {
+ switch (this._category) {
+ case NMConnectionCategory.WIRED:
+ return 'network-wired-symbolic';
+ case NMConnectionCategory.WIRELESS:
+ case NMConnectionCategory.WWAN:
+ return 'network-wireless-symbolic';
+ }
+ return '';
+ }
+
+ _getSummaryLabel(nDevices) {
+ switch (this._category) {
+ case NMConnectionCategory.WIRED:
+ return ngettext("%s Wired Connection",
+ "%s Wired Connections",
+ nDevices).format(nDevices);
+ case NMConnectionCategory.WIRELESS:
+ return ngettext("%s Wi-Fi Connection",
+ "%s Wi-Fi Connections",
+ nDevices).format(nDevices);
+ case NMConnectionCategory.WWAN:
+ return ngettext("%s Modem Connection",
+ "%s Modem Connections",
+ nDevices).format(nDevices);
+ }
+ return '';
+ }
+};
+
+var NMApplet = GObject.registerClass(
+class Indicator extends PanelMenu.SystemIndicator {
+ _init() {
+ super._init();
+
+ this._primaryIndicator = this._addIndicator();
+ this._vpnIndicator = this._addIndicator();
+
+ // Device types
+ this._dtypes = { };
+ this._dtypes[NM.DeviceType.ETHERNET] = NMDeviceWired;
+ this._dtypes[NM.DeviceType.WIFI] = NMDeviceWireless;
+ this._dtypes[NM.DeviceType.MODEM] = NMDeviceModem;
+ this._dtypes[NM.DeviceType.BT] = NMDeviceBluetooth;
+
+ // Connection types
+ this._ctypes = { };
+ this._ctypes[NM.SETTING_WIRED_SETTING_NAME] = NMConnectionCategory.WIRED;
+ this._ctypes[NM.SETTING_WIRELESS_SETTING_NAME] = NMConnectionCategory.WIRELESS;
+ this._ctypes[NM.SETTING_BLUETOOTH_SETTING_NAME] = NMConnectionCategory.WWAN;
+ this._ctypes[NM.SETTING_CDMA_SETTING_NAME] = NMConnectionCategory.WWAN;
+ this._ctypes[NM.SETTING_GSM_SETTING_NAME] = NMConnectionCategory.WWAN;
+ this._ctypes[NM.SETTING_VPN_SETTING_NAME] = NMConnectionCategory.VPN;
+
+ this._getClient();
+ }
+
+ async _getClient() {
+ this._client = await NM.Client.new_async(null);
+
+ this._activeConnections = [];
+ this._connections = [];
+ this._connectivityQueue = [];
+
+ this._mainConnection = null;
+ this._mainConnectionIconChangedId = 0;
+ this._mainConnectionStateChangedId = 0;
+
+ this._notification = null;
+
+ this._nmDevices = [];
+ this._devices = { };
+
+ let categories = [NMConnectionCategory.WIRED,
+ NMConnectionCategory.WIRELESS,
+ NMConnectionCategory.WWAN];
+ for (let category of categories) {
+ this._devices[category] = new DeviceCategory(category);
+ this.menu.addMenuItem(this._devices[category]);
+ }
+
+ this._vpnSection = new NMVpnSection(this._client);
+ this._vpnSection.connect('activation-failed', this._onActivationFailed.bind(this));
+ this._vpnSection.connect('icon-changed', this._updateIcon.bind(this));
+ this.menu.addMenuItem(this._vpnSection.item);
+
+ this._readConnections();
+ this._readDevices();
+ this._syncNMState();
+ this._syncMainConnection();
+ this._syncVpnConnections();
+
+ this._client.connect('notify::nm-running', this._syncNMState.bind(this));
+ this._client.connect('notify::networking-enabled', this._syncNMState.bind(this));
+ this._client.connect('notify::state', this._syncNMState.bind(this));
+ this._client.connect('notify::primary-connection', this._syncMainConnection.bind(this));
+ this._client.connect('notify::activating-connection', this._syncMainConnection.bind(this));
+ this._client.connect('notify::active-connections', this._syncVpnConnections.bind(this));
+ this._client.connect('notify::connectivity', this._syncConnectivity.bind(this));
+ this._client.connect('device-added', this._deviceAdded.bind(this));
+ this._client.connect('device-removed', this._deviceRemoved.bind(this));
+ this._client.connect('connection-added', this._connectionAdded.bind(this));
+ this._client.connect('connection-removed', this._connectionRemoved.bind(this));
+
+ Main.sessionMode.connect('updated', this._sessionUpdated.bind(this));
+ this._sessionUpdated();
+ }
+
+ _sessionUpdated() {
+ let sensitive = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter;
+ this.menu.setSensitive(sensitive);
+ }
+
+ _ensureSource() {
+ if (!this._source) {
+ this._source = new MessageTray.Source(_("Network Manager"),
+ 'network-transmit-receive');
+ this._source.policy = new MessageTray.NotificationApplicationPolicy('gnome-network-panel');
+
+ this._source.connect('destroy', () => (this._source = null));
+ Main.messageTray.add(this._source);
+ }
+ }
+
+ _readDevices() {
+ let devices = this._client.get_devices() || [];
+ for (let i = 0; i < devices.length; ++i) {
+ try {
+ this._deviceAdded(this._client, devices[i], true);
+ } catch (e) {
+ log('Failed to add device %s: %s'.format(devices[i], e.toString()));
+ }
+ }
+ this._syncDeviceNames();
+ }
+
+ _notify(iconName, title, text, urgency) {
+ if (this._notification)
+ this._notification.destroy();
+
+ this._ensureSource();
+
+ let gicon = new Gio.ThemedIcon({ name: iconName });
+ this._notification = new MessageTray.Notification(this._source, title, text, { gicon });
+ this._notification.setUrgency(urgency);
+ this._notification.setTransient(true);
+ this._notification.connect('destroy', () => {
+ this._notification = null;
+ });
+ this._source.showNotification(this._notification);
+ }
+
+ _onActivationFailed(_device, _reason) {
+ // XXX: nm-applet has no special text depending on reason
+ // but I'm not sure of this generic message
+ this._notify('network-error-symbolic',
+ _("Connection failed"),
+ _("Activation of network connection failed"),
+ MessageTray.Urgency.HIGH);
+ }
+
+ _syncDeviceNames() {
+ let names = NM.Device.disambiguate_names(this._nmDevices);
+ for (let i = 0; i < this._nmDevices.length; i++) {
+ let device = this._nmDevices[i];
+ let description = names[i];
+ if (device._delegate)
+ device._delegate.setDeviceDescription(description);
+ }
+ }
+
+ _deviceAdded(client, device, skipSyncDeviceNames) {
+ if (device._delegate) {
+ // already seen, not adding again
+ return;
+ }
+
+ let wrapperClass = this._dtypes[device.get_device_type()];
+ if (wrapperClass) {
+ let wrapper = new wrapperClass(this._client, device);
+ device._delegate = wrapper;
+ this._addDeviceWrapper(wrapper);
+
+ this._nmDevices.push(device);
+ this._deviceChanged(device, skipSyncDeviceNames);
+
+ device.connect('notify::interface', () => {
+ this._deviceChanged(device, false);
+ });
+ }
+ }
+
+ _deviceChanged(device, skipSyncDeviceNames) {
+ let wrapper = device._delegate;
+
+ if (!skipSyncDeviceNames)
+ this._syncDeviceNames();
+
+ if (wrapper instanceof NMConnectionSection) {
+ this._connections.forEach(connection => {
+ wrapper.checkConnection(connection);
+ });
+ }
+ }
+
+ _addDeviceWrapper(wrapper) {
+ wrapper._activationFailedId = wrapper.connect('activation-failed',
+ this._onActivationFailed.bind(this));
+
+ let section = this._devices[wrapper.category].section;
+ section.addMenuItem(wrapper.item);
+
+ let devices = this._devices[wrapper.category].devices;
+ devices.push(wrapper);
+ }
+
+ _deviceRemoved(client, device) {
+ let pos = this._nmDevices.indexOf(device);
+ if (pos != -1) {
+ this._nmDevices.splice(pos, 1);
+ this._syncDeviceNames();
+ }
+
+ let wrapper = device._delegate;
+ if (!wrapper) {
+ log('Removing a network device that was not added');
+ return;
+ }
+
+ this._removeDeviceWrapper(wrapper);
+ }
+
+ _removeDeviceWrapper(wrapper) {
+ wrapper.disconnect(wrapper._activationFailedId);
+ wrapper.destroy();
+
+ let devices = this._devices[wrapper.category].devices;
+ let pos = devices.indexOf(wrapper);
+ devices.splice(pos, 1);
+ }
+
+ _getMainConnection() {
+ let connection;
+
+ connection = this._client.get_primary_connection();
+ if (connection) {
+ ensureActiveConnectionProps(connection);
+ return connection;
+ }
+
+ connection = this._client.get_activating_connection();
+ if (connection) {
+ ensureActiveConnectionProps(connection);
+ return connection;
+ }
+
+ return null;
+ }
+
+ _syncMainConnection() {
+ if (this._mainConnectionIconChangedId > 0) {
+ this._mainConnection._primaryDevice.disconnect(this._mainConnectionIconChangedId);
+ this._mainConnectionIconChangedId = 0;
+ }
+
+ if (this._mainConnectionStateChangedId > 0) {
+ this._mainConnection.disconnect(this._mainConnectionStateChangedId);
+ this._mainConnectionStateChangedId = 0;
+ }
+
+ this._mainConnection = this._getMainConnection();
+
+ if (this._mainConnection) {
+ if (this._mainConnection._primaryDevice)
+ this._mainConnectionIconChangedId = this._mainConnection._primaryDevice.connect('icon-changed', this._updateIcon.bind(this));
+ this._mainConnectionStateChangedId = this._mainConnection.connect('notify::state', this._mainConnectionStateChanged.bind(this));
+ this._mainConnectionStateChanged();
+ }
+
+ this._updateIcon();
+ this._syncConnectivity();
+ }
+
+ _syncVpnConnections() {
+ let activeConnections = this._client.get_active_connections() || [];
+ let vpnConnections = activeConnections.filter(
+ a => a instanceof NM.VpnConnection);
+ vpnConnections.forEach(a => {
+ ensureActiveConnectionProps(a);
+ });
+ this._vpnSection.setActiveConnections(vpnConnections);
+
+ this._updateIcon();
+ }
+
+ _mainConnectionStateChanged() {
+ if (this._mainConnection.state == NM.ActiveConnectionState.ACTIVATED && this._notification)
+ this._notification.destroy();
+ }
+
+ _ignoreConnection(connection) {
+ let setting = connection.get_setting_connection();
+ if (!setting)
+ return true;
+
+ // Ignore slave connections
+ if (setting.get_master())
+ return true;
+
+ return false;
+ }
+
+ _addConnection(connection) {
+ if (this._ignoreConnection(connection))
+ return;
+ if (connection._updatedId) {
+ // connection was already seen
+ return;
+ }
+
+ connection._updatedId = connection.connect('changed', this._updateConnection.bind(this));
+
+ this._updateConnection(connection);
+ this._connections.push(connection);
+ }
+
+ _readConnections() {
+ let connections = this._client.get_connections();
+ connections.forEach(this._addConnection.bind(this));
+ }
+
+ _connectionAdded(client, connection) {
+ this._addConnection(connection);
+ }
+
+ _connectionRemoved(client, connection) {
+ let pos = this._connections.indexOf(connection);
+ if (pos != -1)
+ this._connections.splice(pos, 1);
+
+ let section = connection._section;
+
+ if (section == NMConnectionCategory.INVALID)
+ return;
+
+ if (section == NMConnectionCategory.VPN) {
+ this._vpnSection.removeConnection(connection);
+ } else {
+ let devices = this._devices[section].devices;
+ for (let i = 0; i < devices.length; i++) {
+ if (devices[i] instanceof NMConnectionSection)
+ devices[i].removeConnection(connection);
+ }
+ }
+
+ connection.disconnect(connection._updatedId);
+ connection._updatedId = 0;
+ }
+
+ _updateConnection(connection) {
+ let connectionSettings = connection.get_setting_by_name(NM.SETTING_CONNECTION_SETTING_NAME);
+ connection._type = connectionSettings.type;
+ connection._section = this._ctypes[connection._type] || NMConnectionCategory.INVALID;
+
+ let section = connection._section;
+
+ if (section == NMConnectionCategory.INVALID)
+ return;
+
+ if (section == NMConnectionCategory.VPN) {
+ this._vpnSection.checkConnection(connection);
+ } else {
+ let devices = this._devices[section].devices;
+ devices.forEach(wrapper => {
+ if (wrapper instanceof NMConnectionSection)
+ wrapper.checkConnection(connection);
+ });
+ }
+ }
+
+ _syncNMState() {
+ this.visible = this._client.nm_running;
+ this.menu.actor.visible = this._client.networking_enabled;
+
+ this._updateIcon();
+ this._syncConnectivity();
+ }
+
+ _flushConnectivityQueue() {
+ if (this._portalHelperProxy) {
+ for (let item of this._connectivityQueue)
+ this._portalHelperProxy.CloseRemote(item);
+ }
+
+ this._connectivityQueue = [];
+ }
+
+ _closeConnectivityCheck(path) {
+ let index = this._connectivityQueue.indexOf(path);
+
+ if (index >= 0) {
+ if (this._portalHelperProxy)
+ this._portalHelperProxy.CloseRemote(path);
+
+ this._connectivityQueue.splice(index, 1);
+ }
+ }
+
+ async _portalHelperDone(proxy, emitter, parameters) {
+ let [path, result] = parameters;
+
+ if (result == PortalHelperResult.CANCELLED) {
+ // Keep the connection in the queue, so the user is not
+ // spammed with more logins until we next flush the queue,
+ // which will happen once he chooses a better connection
+ // or we get to full connectivity through other means
+ } else if (result == PortalHelperResult.COMPLETED) {
+ this._closeConnectivityCheck(path);
+ } else if (result == PortalHelperResult.RECHECK) {
+ try {
+ const state = await this._client.check_connectivity_async(null);
+ if (state >= NM.ConnectivityState.FULL)
+ this._closeConnectivityCheck(path);
+ } catch (e) { }
+ } else {
+ log('Invalid result from portal helper: %s'.format(result));
+ }
+ }
+
+ _syncConnectivity() {
+ if (this._mainConnection == null ||
+ this._mainConnection.state != NM.ActiveConnectionState.ACTIVATED) {
+ this._flushConnectivityQueue();
+ return;
+ }
+
+ let isPortal = this._client.connectivity == NM.ConnectivityState.PORTAL;
+ // For testing, allow interpreting any value != FULL as PORTAL, because
+ // LIMITED (no upstream route after the default gateway) is easy to obtain
+ // with a tethered phone
+ // NONE is also possible, with a connection configured to force no default route
+ // (but in general we should only prompt a portal if we know there is a portal)
+ if (GLib.getenv('GNOME_SHELL_CONNECTIVITY_TEST') != null)
+ isPortal = isPortal || this._client.connectivity < NM.ConnectivityState.FULL;
+ if (!isPortal || Main.sessionMode.isGreeter)
+ return;
+
+ let path = this._mainConnection.get_path();
+ for (let item of this._connectivityQueue) {
+ if (item == path)
+ return;
+ }
+
+ let timestamp = global.get_current_time();
+ if (this._portalHelperProxy) {
+ this._portalHelperProxy.AuthenticateRemote(path, '', timestamp);
+ } else {
+ new PortalHelperProxy(Gio.DBus.session, 'org.gnome.Shell.PortalHelper',
+ '/org/gnome/Shell/PortalHelper', (proxy, error) => {
+ if (error) {
+ log('Error launching the portal helper: %s'.format(error));
+ return;
+ }
+
+ this._portalHelperProxy = proxy;
+ proxy.connectSignal('Done', this._portalHelperDone.bind(this));
+
+ proxy.AuthenticateRemote(path, '', timestamp);
+ });
+ }
+
+ this._connectivityQueue.push(path);
+ }
+
+ _updateIcon() {
+ if (!this._client.networking_enabled) {
+ this._primaryIndicator.visible = false;
+ } else {
+ let dev = null;
+ if (this._mainConnection)
+ dev = this._mainConnection._primaryDevice;
+
+ let state = this._client.get_state();
+ let connected = state == NM.State.CONNECTED_GLOBAL;
+ this._primaryIndicator.visible = (dev != null) || connected;
+ if (dev) {
+ this._primaryIndicator.icon_name = dev.getIndicatorIcon();
+ } else if (connected) {
+ if (this._client.connectivity == NM.ConnectivityState.FULL)
+ this._primaryIndicator.icon_name = 'network-wired-symbolic';
+ else
+ this._primaryIndicator.icon_name = 'network-wired-no-route-symbolic';
+ }
+ }
+
+ this._vpnIndicator.icon_name = this._vpnSection.getIndicatorIcon();
+ this._vpnIndicator.visible = this._vpnIndicator.icon_name !== null;
+ }
+});
diff --git a/js/ui/status/nightLight.js b/js/ui/status/nightLight.js
new file mode 100644
index 0000000..c595c3d
--- /dev/null
+++ b/js/ui/status/nightLight.js
@@ -0,0 +1,70 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+const { Gio, GObject } = imports.gi;
+
+const Main = imports.ui.main;
+const PanelMenu = imports.ui.panelMenu;
+const PopupMenu = imports.ui.popupMenu;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+const BUS_NAME = 'org.gnome.SettingsDaemon.Color';
+const OBJECT_PATH = '/org/gnome/SettingsDaemon/Color';
+
+const ColorInterface = loadInterfaceXML('org.gnome.SettingsDaemon.Color');
+const ColorProxy = Gio.DBusProxy.makeProxyWrapper(ColorInterface);
+
+var Indicator = GObject.registerClass(
+class Indicator extends PanelMenu.SystemIndicator {
+ _init() {
+ super._init();
+
+ this._indicator = this._addIndicator();
+ this._indicator.icon_name = 'night-light-symbolic';
+ this._proxy = new ColorProxy(Gio.DBus.session, BUS_NAME, OBJECT_PATH,
+ (proxy, error) => {
+ if (error) {
+ log(error.message);
+ return;
+ }
+ this._proxy.connect('g-properties-changed',
+ this._sync.bind(this));
+ this._sync();
+ });
+
+ this._item = new PopupMenu.PopupSubMenuMenuItem("", true);
+ this._item.icon.icon_name = 'night-light-symbolic';
+ this._disableItem = this._item.menu.addAction('', () => {
+ this._proxy.DisabledUntilTomorrow = !this._proxy.DisabledUntilTomorrow;
+ });
+ this._item.menu.addAction(_("Turn Off"), () => {
+ let settings = new Gio.Settings({ schema_id: 'org.gnome.settings-daemon.plugins.color' });
+ settings.set_boolean('night-light-enabled', false);
+ });
+ this._item.menu.addSettingsAction(_("Display Settings"), 'gnome-display-panel.desktop');
+ this.menu.addMenuItem(this._item);
+
+ Main.sessionMode.connect('updated', this._sessionUpdated.bind(this));
+ this._sessionUpdated();
+ this._sync();
+ }
+
+ _sessionUpdated() {
+ let sensitive = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter;
+ this.menu.setSensitive(sensitive);
+ }
+
+ _sync() {
+ let visible = this._proxy.NightLightActive;
+ let disabled = this._proxy.DisabledUntilTomorrow;
+
+ this._item.label.text = disabled
+ ? _("Night Light Disabled")
+ : _("Night Light On");
+ this._disableItem.label.text = disabled
+ ? _("Resume")
+ : _("Disable Until Tomorrow");
+ this._item.visible = this._indicator.visible = visible;
+ }
+});
diff --git a/js/ui/status/power.js b/js/ui/status/power.js
new file mode 100644
index 0000000..ca85a98
--- /dev/null
+++ b/js/ui/status/power.js
@@ -0,0 +1,155 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+const { Clutter, Gio, GObject, St, UPowerGlib: UPower } = imports.gi;
+
+const Main = imports.ui.main;
+const PanelMenu = imports.ui.panelMenu;
+const PopupMenu = imports.ui.popupMenu;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+const BUS_NAME = 'org.freedesktop.UPower';
+const OBJECT_PATH = '/org/freedesktop/UPower/devices/DisplayDevice';
+
+const DisplayDeviceInterface = loadInterfaceXML('org.freedesktop.UPower.Device');
+const PowerManagerProxy = Gio.DBusProxy.makeProxyWrapper(DisplayDeviceInterface);
+
+const SHOW_BATTERY_PERCENTAGE = 'show-battery-percentage';
+
+var Indicator = GObject.registerClass(
+class Indicator extends PanelMenu.SystemIndicator {
+ _init() {
+ super._init();
+
+ this._desktopSettings = new Gio.Settings({
+ schema_id: 'org.gnome.desktop.interface',
+ });
+ this._desktopSettings.connect(
+ 'changed::%s'.format(SHOW_BATTERY_PERCENTAGE), this._sync.bind(this));
+
+ this._indicator = this._addIndicator();
+ this._percentageLabel = new St.Label({
+ y_expand: true,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ this.add_child(this._percentageLabel);
+ this.add_style_class_name('power-status');
+
+ this._proxy = new PowerManagerProxy(Gio.DBus.system, BUS_NAME, OBJECT_PATH,
+ (proxy, error) => {
+ if (error) {
+ log(error.message);
+ } else {
+ this._proxy.connect('g-properties-changed',
+ this._sync.bind(this));
+ }
+ this._sync();
+ });
+
+ this._item = new PopupMenu.PopupSubMenuMenuItem('', true);
+ this._item.menu.addSettingsAction(_('Power Settings'),
+ 'gnome-power-panel.desktop');
+ this.menu.addMenuItem(this._item);
+
+ Main.sessionMode.connect('updated', this._sessionUpdated.bind(this));
+ this._sessionUpdated();
+ }
+
+ _sessionUpdated() {
+ let sensitive = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter;
+ this.menu.setSensitive(sensitive);
+ }
+
+ _getStatus() {
+ let seconds = 0;
+
+ if (this._proxy.State === UPower.DeviceState.FULLY_CHARGED)
+ return _('Fully Charged');
+ else if (this._proxy.State === UPower.DeviceState.CHARGING)
+ seconds = this._proxy.TimeToFull;
+ else if (this._proxy.State === UPower.DeviceState.DISCHARGING)
+ seconds = this._proxy.TimeToEmpty;
+ else if (this._proxy.State === UPower.DeviceState.PENDING_CHARGE)
+ return _('Not Charging');
+ // state is PENDING_DISCHARGE
+ else
+ return _('Estimating…');
+
+ let time = Math.round(seconds / 60);
+ if (time === 0) {
+ // 0 is reported when UPower does not have enough data
+ // to estimate battery life
+ return _('Estimating…');
+ }
+
+ let minutes = time % 60;
+ let hours = Math.floor(time / 60);
+
+ if (this._proxy.State === UPower.DeviceState.DISCHARGING) {
+ // Translators: this is <hours>:<minutes> Remaining (<percentage>)
+ return _('%d\u2236%02d Remaining (%d\u2009%%)').format(
+ hours, minutes, this._proxy.Percentage);
+ }
+
+ if (this._proxy.State === UPower.DeviceState.CHARGING) {
+ // Translators: this is <hours>:<minutes> Until Full (<percentage>)
+ return _('%d\u2236%02d Until Full (%d\u2009%%)').format(
+ hours, minutes, this._proxy.Percentage);
+ }
+
+ return null;
+ }
+
+ _sync() {
+ // Do we have batteries or a UPS?
+ let visible = this._proxy.IsPresent;
+ if (visible) {
+ this._item.show();
+ this._percentageLabel.visible =
+ this._desktopSettings.get_boolean(SHOW_BATTERY_PERCENTAGE);
+ } else {
+ // If there's no battery, then we use the power icon.
+ this._item.hide();
+ this._indicator.icon_name = 'system-shutdown-symbolic';
+ this._percentageLabel.hide();
+ return;
+ }
+
+ // The icons
+ let chargingState = this._proxy.State === UPower.DeviceState.CHARGING
+ ? '-charging' : '';
+ let fillLevel = 10 * Math.floor(this._proxy.Percentage / 10);
+ const charged =
+ this._proxy.State === UPower.DeviceState.FULLY_CHARGED ||
+ (this._proxy.State === UPower.DeviceState.CHARGING && fillLevel === 100);
+ const icon = charged
+ ? 'battery-level-100-charged-symbolic'
+ : 'battery-level-%d%s-symbolic'.format(fillLevel, chargingState);
+
+ // Make sure we fall back to fallback-icon-name and not GThemedIcon's
+ // default fallbacks
+ let gicon = new Gio.ThemedIcon({
+ name: icon,
+ use_default_fallbacks: false,
+ });
+
+ this._indicator.gicon = gicon;
+ this._item.icon.gicon = gicon;
+
+ let fallbackIcon = this._proxy.IconName;
+ this._indicator.fallback_icon_name = fallbackIcon;
+ this._item.icon.fallback_icon_name = fallbackIcon;
+
+ // The icon label
+ let label;
+ if (this._proxy.State == UPower.DeviceState.FULLY_CHARGED)
+ label = _("%d\u2009%%").format(100);
+ else
+ label = _("%d\u2009%%").format(this._proxy.Percentage);
+ this._percentageLabel.text = label;
+
+ // The status label
+ this._item.label.text = this._getStatus();
+ }
+});
diff --git a/js/ui/status/remoteAccess.js b/js/ui/status/remoteAccess.js
new file mode 100644
index 0000000..21f6581
--- /dev/null
+++ b/js/ui/status/remoteAccess.js
@@ -0,0 +1,97 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported RemoteAccessApplet */
+
+const { GObject, Meta } = imports.gi;
+
+const PanelMenu = imports.ui.panelMenu;
+const PopupMenu = imports.ui.popupMenu;
+
+var RemoteAccessApplet = GObject.registerClass(
+class RemoteAccessApplet extends PanelMenu.SystemIndicator {
+ _init() {
+ super._init();
+
+ let controller = global.backend.get_remote_access_controller();
+
+ if (!controller)
+ return;
+
+ this._handles = new Set();
+ this._sharedIndicator = null;
+ this._recordingIndicator = null;
+ this._menuSection = null;
+
+ controller.connect('new-handle', (o, handle) => {
+ this._onNewHandle(handle);
+ });
+ }
+
+ _ensureControls() {
+ if (this._sharedIndicator && this._recordingIndicator)
+ return;
+
+ this._sharedIndicator = this._addIndicator();
+ this._sharedIndicator.icon_name = 'screen-shared-symbolic';
+ this._sharedIndicator.add_style_class_name('remote-access-indicator');
+
+ this._sharedItem =
+ new PopupMenu.PopupSubMenuMenuItem(_("Screen is Being Shared"),
+ true);
+ this._sharedItem.menu.addAction(_("Turn off"),
+ () => {
+ for (let handle of this._handles) {
+ if (!handle.is_recording)
+ handle.stop();
+ }
+ });
+ this._sharedItem.icon.icon_name = 'screen-shared-symbolic';
+ this.menu.addMenuItem(this._sharedItem);
+
+ this._recordingIndicator = this._addIndicator();
+ this._recordingIndicator.icon_name = 'media-record-symbolic';
+ this._recordingIndicator.add_style_class_name('screencast-indicator');
+ }
+
+ _isScreenShared() {
+ return [...this._handles].some(handle => !handle.is_recording);
+ }
+
+ _isRecording() {
+ return [...this._handles].some(handle => handle.is_recording);
+ }
+
+ _sync() {
+ if (this._isScreenShared()) {
+ this._sharedIndicator.visible = true;
+ this._sharedItem.visible = true;
+ } else {
+ this._sharedIndicator.visible = false;
+ this._sharedItem.visible = false;
+ }
+
+ this._recordingIndicator.visible = this._isRecording();
+ }
+
+ _onStopped(handle) {
+ this._handles.delete(handle);
+ this._sync();
+ }
+
+ _onNewHandle(handle) {
+ // We can't possibly know about all types of screen sharing on X11, so
+ // showing these controls on X11 might give a false sense of security.
+ // Thus, only enable these controls when using Wayland, where we are
+ // in control of sharing.
+ //
+ // We still want to show screen recordings though, to indicate when
+ // the built in screen recorder is active, no matter the session type.
+ if (!Meta.is_wayland_compositor() && !handle.is_recording)
+ return;
+
+ this._handles.add(handle);
+ handle.connect('stopped', this._onStopped.bind(this));
+
+ this._ensureControls();
+ this._sync();
+ }
+});
diff --git a/js/ui/status/rfkill.js b/js/ui/status/rfkill.js
new file mode 100644
index 0000000..9f8b09d
--- /dev/null
+++ b/js/ui/status/rfkill.js
@@ -0,0 +1,112 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+const { Gio, GObject } = imports.gi;
+const Signals = imports.signals;
+
+const Main = imports.ui.main;
+const PanelMenu = imports.ui.panelMenu;
+const PopupMenu = imports.ui.popupMenu;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+const BUS_NAME = 'org.gnome.SettingsDaemon.Rfkill';
+const OBJECT_PATH = '/org/gnome/SettingsDaemon/Rfkill';
+
+const RfkillManagerInterface = loadInterfaceXML('org.gnome.SettingsDaemon.Rfkill');
+const RfkillManagerProxy = Gio.DBusProxy.makeProxyWrapper(RfkillManagerInterface);
+
+var RfkillManager = class {
+ constructor() {
+ this._proxy = new RfkillManagerProxy(Gio.DBus.session, BUS_NAME, OBJECT_PATH,
+ (proxy, error) => {
+ if (error) {
+ log(error.message);
+ return;
+ }
+ this._proxy.connect('g-properties-changed',
+ this._changed.bind(this));
+ this._changed();
+ });
+ }
+
+ get airplaneMode() {
+ return this._proxy.AirplaneMode;
+ }
+
+ set airplaneMode(v) {
+ this._proxy.AirplaneMode = v;
+ }
+
+ get hwAirplaneMode() {
+ return this._proxy.HardwareAirplaneMode;
+ }
+
+ get shouldShowAirplaneMode() {
+ return this._proxy.ShouldShowAirplaneMode;
+ }
+
+ _changed() {
+ this.emit('airplane-mode-changed');
+ }
+};
+Signals.addSignalMethods(RfkillManager.prototype);
+
+var _manager;
+function getRfkillManager() {
+ if (_manager != null)
+ return _manager;
+
+ _manager = new RfkillManager();
+ return _manager;
+}
+
+var Indicator = GObject.registerClass(
+class Indicator extends PanelMenu.SystemIndicator {
+ _init() {
+ super._init();
+
+ this._manager = getRfkillManager();
+ this._manager.connect('airplane-mode-changed', this._sync.bind(this));
+
+ this._indicator = this._addIndicator();
+ this._indicator.icon_name = 'airplane-mode-symbolic';
+ this._indicator.hide();
+
+ // The menu only appears when airplane mode is on, so just
+ // statically build it as if it was on, rather than dynamically
+ // changing the menu contents.
+ this._item = new PopupMenu.PopupSubMenuMenuItem(_("Airplane Mode On"), true);
+ this._item.icon.icon_name = 'airplane-mode-symbolic';
+ this._offItem = this._item.menu.addAction(_("Turn Off"), () => {
+ this._manager.airplaneMode = false;
+ });
+ this._item.menu.addSettingsAction(_("Network Settings"), 'gnome-network-panel.desktop');
+ this.menu.addMenuItem(this._item);
+
+ Main.sessionMode.connect('updated', this._sessionUpdated.bind(this));
+ this._sessionUpdated();
+
+ this._sync();
+ }
+
+ _sessionUpdated() {
+ let sensitive = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter;
+ this.menu.setSensitive(sensitive);
+ }
+
+ _sync() {
+ let airplaneMode = this._manager.airplaneMode;
+ let hwAirplaneMode = this._manager.hwAirplaneMode;
+ let showAirplaneMode = this._manager.shouldShowAirplaneMode;
+
+ this._indicator.visible = airplaneMode && showAirplaneMode;
+ this._item.visible = airplaneMode && showAirplaneMode;
+ this._offItem.setSensitive(!hwAirplaneMode);
+
+ if (hwAirplaneMode)
+ this._offItem.label.text = _("Use hardware switch to turn off");
+ else
+ this._offItem.label.text = _("Turn Off");
+ }
+});
diff --git a/js/ui/status/system.js b/js/ui/status/system.js
new file mode 100644
index 0000000..6f71109
--- /dev/null
+++ b/js/ui/status/system.js
@@ -0,0 +1,178 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+const { GObject, Shell, St } = imports.gi;
+
+const BoxPointer = imports.ui.boxpointer;
+const SystemActions = imports.misc.systemActions;
+const Main = imports.ui.main;
+const PanelMenu = imports.ui.panelMenu;
+const PopupMenu = imports.ui.popupMenu;
+
+
+var Indicator = GObject.registerClass(
+class Indicator extends PanelMenu.SystemIndicator {
+ _init() {
+ super._init();
+
+ this._systemActions = new SystemActions.getDefault();
+
+ this._createSubMenu();
+
+ this._loginScreenItem.connect('notify::visible',
+ () => this._updateSessionSubMenu());
+ this._logoutItem.connect('notify::visible',
+ () => this._updateSessionSubMenu());
+ this._suspendItem.connect('notify::visible',
+ () => this._updateSessionSubMenu());
+ this._powerOffItem.connect('notify::visible',
+ () => this._updateSessionSubMenu());
+ this._restartItem.connect('notify::visible',
+ () => this._updateSessionSubMenu());
+ // Whether shutdown is available or not depends on both lockdown
+ // settings (disable-log-out) and Polkit policy - the latter doesn't
+ // notify, so we update the menu item each time the menu opens or
+ // the lockdown setting changes, which should be close enough.
+ this.menu.connect('open-state-changed', (menu, open) => {
+ if (!open)
+ return;
+
+ this._systemActions.forceUpdate();
+ });
+ this._updateSessionSubMenu();
+
+ Main.sessionMode.connect('updated', this._sessionUpdated.bind(this));
+ this._sessionUpdated();
+ }
+
+ _sessionUpdated() {
+ this._settingsItem.visible = Main.sessionMode.allowSettings;
+ }
+
+ _updateSessionSubMenu() {
+ this._sessionSubMenu.visible =
+ this._loginScreenItem.visible ||
+ this._logoutItem.visible ||
+ this._suspendItem.visible ||
+ this._restartItem.visible ||
+ this._powerOffItem.visible;
+ }
+
+ _createSubMenu() {
+ let bindFlags = GObject.BindingFlags.DEFAULT | GObject.BindingFlags.SYNC_CREATE;
+ let item;
+
+ item = new PopupMenu.PopupImageMenuItem(
+ this._systemActions.getName('lock-orientation'),
+ this._systemActions.orientation_lock_icon);
+
+ item.connect('activate', () => {
+ this.menu.itemActivated(BoxPointer.PopupAnimation.NONE);
+ this._systemActions.activateLockOrientation();
+ });
+ this.menu.addMenuItem(item);
+ this._orientationLockItem = item;
+ this._systemActions.bind_property('can-lock-orientation',
+ this._orientationLockItem, 'visible',
+ bindFlags);
+ this._systemActions.connect('notify::orientation-lock-icon', () => {
+ let iconName = this._systemActions.orientation_lock_icon;
+ let labelText = this._systemActions.getName("lock-orientation");
+
+ this._orientationLockItem.setIcon(iconName);
+ this._orientationLockItem.label.text = labelText;
+ });
+
+ let app = this._settingsApp = Shell.AppSystem.get_default().lookup_app(
+ 'gnome-control-center.desktop');
+ if (app) {
+ const [icon] = app.app_info.get_icon().names;
+ const name = app.app_info.get_name();
+ item = new PopupMenu.PopupImageMenuItem(name, icon);
+ item.connect('activate', () => {
+ this.menu.itemActivated(BoxPointer.PopupAnimation.NONE);
+ Main.overview.hide();
+ this._settingsApp.activate();
+ });
+ this.menu.addMenuItem(item);
+ this._settingsItem = item;
+ } else {
+ log('Missing required core component Settings, expect trouble…');
+ this._settingsItem = new St.Widget();
+ }
+
+ item = new PopupMenu.PopupImageMenuItem(_('Lock'), 'changes-prevent-symbolic');
+ item.connect('activate', () => {
+ this.menu.itemActivated(BoxPointer.PopupAnimation.NONE);
+ this._systemActions.activateLockScreen();
+ });
+ this.menu.addMenuItem(item);
+ this._lockScreenItem = item;
+ this._systemActions.bind_property('can-lock-screen',
+ this._lockScreenItem, 'visible',
+ bindFlags);
+
+ this._sessionSubMenu = new PopupMenu.PopupSubMenuMenuItem(
+ _('Power Off / Log Out'), true);
+ this._sessionSubMenu.icon.icon_name = 'system-shutdown-symbolic';
+
+ item = new PopupMenu.PopupMenuItem(_('Suspend'));
+ item.connect('activate', () => {
+ this.menu.itemActivated(BoxPointer.PopupAnimation.NONE);
+ this._systemActions.activateSuspend();
+ });
+ this._sessionSubMenu.menu.addMenuItem(item);
+ this._suspendItem = item;
+ this._systemActions.bind_property('can-suspend',
+ this._suspendItem, 'visible',
+ bindFlags);
+
+ item = new PopupMenu.PopupMenuItem(_('Restart…'));
+ item.connect('activate', () => {
+ this.menu.itemActivated(BoxPointer.PopupAnimation.NONE);
+ this._systemActions.activateRestart();
+ });
+ this._sessionSubMenu.menu.addMenuItem(item);
+ this._restartItem = item;
+ this._systemActions.bind_property('can-restart',
+ this._restartItem, 'visible',
+ bindFlags);
+
+ item = new PopupMenu.PopupMenuItem(_('Power Off…'));
+ item.connect('activate', () => {
+ this.menu.itemActivated(BoxPointer.PopupAnimation.NONE);
+ this._systemActions.activatePowerOff();
+ });
+ this._sessionSubMenu.menu.addMenuItem(item);
+ this._powerOffItem = item;
+ this._systemActions.bind_property('can-power-off',
+ this._powerOffItem, 'visible',
+ bindFlags);
+
+ this._sessionSubMenu.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ item = new PopupMenu.PopupMenuItem(_('Log Out'));
+ item.connect('activate', () => {
+ this.menu.itemActivated(BoxPointer.PopupAnimation.NONE);
+ this._systemActions.activateLogout();
+ });
+ this._sessionSubMenu.menu.addMenuItem(item);
+ this._logoutItem = item;
+ this._systemActions.bind_property('can-logout',
+ this._logoutItem, 'visible',
+ bindFlags);
+
+ item = new PopupMenu.PopupMenuItem(_('Switch User…'));
+ item.connect('activate', () => {
+ this.menu.itemActivated(BoxPointer.PopupAnimation.NONE);
+ this._systemActions.activateSwitchUser();
+ });
+ this._sessionSubMenu.menu.addMenuItem(item);
+ this._loginScreenItem = item;
+ this._systemActions.bind_property('can-switch-user',
+ this._loginScreenItem, 'visible',
+ bindFlags);
+
+ this.menu.addMenuItem(this._sessionSubMenu);
+ }
+});
diff --git a/js/ui/status/thunderbolt.js b/js/ui/status/thunderbolt.js
new file mode 100644
index 0000000..d98355d
--- /dev/null
+++ b/js/ui/status/thunderbolt.js
@@ -0,0 +1,340 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+// the following is a modified version of bolt/contrib/js/client.js
+
+const { Gio, GLib, GObject, Polkit, Shell } = imports.gi;
+const Signals = imports.signals;
+
+const Main = imports.ui.main;
+const MessageTray = imports.ui.messageTray;
+const PanelMenu = imports.ui.panelMenu;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+/* Keep in sync with data/org.freedesktop.bolt.xml */
+
+const BoltClientInterface = loadInterfaceXML('org.freedesktop.bolt1.Manager');
+const BoltDeviceInterface = loadInterfaceXML('org.freedesktop.bolt1.Device');
+
+const BoltDeviceProxy = Gio.DBusProxy.makeProxyWrapper(BoltDeviceInterface);
+
+/* */
+
+var Status = {
+ DISCONNECTED: 'disconnected',
+ CONNECTING: 'connecting',
+ CONNECTED: 'connected',
+ AUTHORIZING: 'authorizing',
+ AUTH_ERROR: 'auth-error',
+ AUTHORIZED: 'authorized',
+};
+
+var Policy = {
+ DEFAULT: 'default',
+ MANUAL: 'manual',
+ AUTO: 'auto',
+};
+
+var AuthCtrl = {
+ NONE: 'none',
+};
+
+var AuthMode = {
+ DISABLED: 'disabled',
+ ENABLED: 'enabled',
+};
+
+const BOLT_DBUS_CLIENT_IFACE = 'org.freedesktop.bolt1.Manager';
+const BOLT_DBUS_NAME = 'org.freedesktop.bolt';
+const BOLT_DBUS_PATH = '/org/freedesktop/bolt';
+
+var Client = class {
+ constructor() {
+ this._proxy = null;
+ this.probing = false;
+ this._getProxy();
+ }
+
+ async _getProxy() {
+ let nodeInfo = Gio.DBusNodeInfo.new_for_xml(BoltClientInterface);
+ try {
+ this._proxy = await Gio.DBusProxy.new(
+ Gio.DBus.system,
+ Gio.DBusProxyFlags.DO_NOT_AUTO_START,
+ nodeInfo.lookup_interface(BOLT_DBUS_CLIENT_IFACE),
+ BOLT_DBUS_NAME,
+ BOLT_DBUS_PATH,
+ BOLT_DBUS_CLIENT_IFACE,
+ null);
+ } catch (e) {
+ log('error creating bolt proxy: %s'.format(e.message));
+ return;
+ }
+ this._propsChangedId = this._proxy.connect('g-properties-changed', this._onPropertiesChanged.bind(this));
+ this._deviceAddedId = this._proxy.connectSignal('DeviceAdded', this._onDeviceAdded.bind(this));
+
+ this.probing = this._proxy.Probing;
+ if (this.probing)
+ this.emit('probing-changed', this.probing);
+
+ }
+
+ _onPropertiesChanged(proxy, properties) {
+ let unpacked = properties.deep_unpack();
+ if (!('Probing' in unpacked))
+ return;
+
+ this.probing = this._proxy.Probing;
+ this.emit('probing-changed', this.probing);
+ }
+
+ _onDeviceAdded(proxy, emitter, params) {
+ let [path] = params;
+ let device = new BoltDeviceProxy(Gio.DBus.system,
+ BOLT_DBUS_NAME,
+ path);
+ this.emit('device-added', device);
+ }
+
+ /* public methods */
+ close() {
+ if (!this._proxy)
+ return;
+
+ this._proxy.disconnectSignal(this._deviceAddedId);
+ this._proxy.disconnect(this._propsChangedId);
+ this._proxy = null;
+ }
+
+ enrollDevice(id, policy, callback) {
+ this._proxy.EnrollDeviceRemote(id, policy, AuthCtrl.NONE, (res, error) => {
+ if (error) {
+ Gio.DBusError.strip_remote_error(error);
+ callback(null, error);
+ return;
+ }
+
+ let [path] = res;
+ let device = new BoltDeviceProxy(Gio.DBus.system,
+ BOLT_DBUS_NAME,
+ path);
+ callback(device, null);
+ });
+ }
+
+ get authMode() {
+ return this._proxy.AuthMode;
+ }
+};
+Signals.addSignalMethods(Client.prototype);
+
+/* helper class to automatically authorize new devices */
+var AuthRobot = class {
+ constructor(client) {
+ this._client = client;
+
+ this._devicesToEnroll = [];
+ this._enrolling = false;
+
+ this._client.connect('device-added', this._onDeviceAdded.bind(this));
+ }
+
+ close() {
+ this.disconnectAll();
+ this._client = null;
+ }
+
+ /* the "device-added" signal will be emitted by boltd for every
+ * device that is not currently stored in the database. We are
+ * only interested in those devices, because all known devices
+ * will be handled by the user himself */
+ _onDeviceAdded(cli, dev) {
+ if (dev.Status !== Status.CONNECTED)
+ return;
+
+ /* check if authorization is enabled in the daemon. if not
+ * we won't even bother authorizing, because we will only
+ * get an error back. The exact contents of AuthMode might
+ * change in the future, but must contain AuthMode.ENABLED
+ * if it is enabled. */
+ if (!cli.authMode.split('|').includes(AuthMode.ENABLED))
+ return;
+
+ /* check if we should enroll the device */
+ let res = [false];
+ this.emit('enroll-device', dev, res);
+ if (res[0] !== true)
+ return;
+
+ /* ok, we should authorize the device, add it to the back
+ * of the list */
+ this._devicesToEnroll.push(dev);
+ this._enrollDevices();
+ }
+
+ /* The enrollment queue:
+ * - new devices will be added to the end of the array.
+ * - an idle callback will be scheduled that will keep
+ * calling itself as long as there a devices to be
+ * enrolled.
+ */
+ _enrollDevices() {
+ if (this._enrolling)
+ return;
+
+ this._enrolling = true;
+ GLib.idle_add(GLib.PRIORITY_DEFAULT,
+ this._enrollDevicesIdle.bind(this));
+ }
+
+ _onEnrollDone(device, error) {
+ if (error)
+ this.emit('enroll-failed', device, error);
+
+ /* TODO: scan the list of devices to be authorized for children
+ * of this device and remove them (and their children and
+ * their children and ....) from the device queue
+ */
+ this._enrolling = this._devicesToEnroll.length > 0;
+
+ if (this._enrolling) {
+ GLib.idle_add(GLib.PRIORITY_DEFAULT,
+ this._enrollDevicesIdle.bind(this));
+ }
+ }
+
+ _enrollDevicesIdle() {
+ let devices = this._devicesToEnroll;
+
+ let dev = devices.shift();
+ if (dev === undefined)
+ return GLib.SOURCE_REMOVE;
+
+ this._client.enrollDevice(dev.Uid,
+ Policy.DEFAULT,
+ this._onEnrollDone.bind(this));
+ return GLib.SOURCE_REMOVE;
+ }
+};
+Signals.addSignalMethods(AuthRobot.prototype);
+
+/* eof client.js */
+
+var Indicator = GObject.registerClass(
+class Indicator extends PanelMenu.SystemIndicator {
+ _init() {
+ super._init();
+
+ this._indicator = this._addIndicator();
+ this._indicator.icon_name = 'thunderbolt-symbolic';
+
+ this._client = new Client();
+ this._client.connect('probing-changed', this._onProbing.bind(this));
+
+ this._robot = new AuthRobot(this._client);
+
+ this._robot.connect('enroll-device', this._onEnrollDevice.bind(this));
+ this._robot.connect('enroll-failed', this._onEnrollFailed.bind(this));
+
+ Main.sessionMode.connect('updated', this._sync.bind(this));
+ this._sync();
+
+ this._source = null;
+ this._perm = null;
+ this._createPermission();
+ }
+
+ async _createPermission() {
+ try {
+ this._perm = await Polkit.Permission.new('org.freedesktop.bolt.enroll', null, null);
+ } catch (e) {
+ log('Failed to get PolKit permission: %s'.format(e.toString()));
+ }
+ }
+
+ _onDestroy() {
+ this._robot.close();
+ this._client.close();
+ }
+
+ _ensureSource() {
+ if (!this._source) {
+ this._source = new MessageTray.Source(_("Thunderbolt"),
+ 'thunderbolt-symbolic');
+ this._source.connect('destroy', () => (this._source = null));
+
+ Main.messageTray.add(this._source);
+ }
+
+ return this._source;
+ }
+
+ _notify(title, body) {
+ if (this._notification)
+ this._notification.destroy();
+
+ let source = this._ensureSource();
+
+ this._notification = new MessageTray.Notification(source, title, body);
+ this._notification.setUrgency(MessageTray.Urgency.HIGH);
+ this._notification.connect('destroy', () => {
+ this._notification = null;
+ });
+ this._notification.connect('activated', () => {
+ let app = Shell.AppSystem.get_default().lookup_app('gnome-thunderbolt-panel.desktop');
+ if (app)
+ app.activate();
+ });
+ this._source.showNotification(this._notification);
+ }
+
+ /* Session callbacks */
+ _sync() {
+ let active = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter;
+ this._indicator.visible = active && this._client.probing;
+ }
+
+ /* Bolt.Client callbacks */
+ _onProbing(cli, probing) {
+ if (probing)
+ this._indicator.icon_name = 'thunderbolt-acquiring-symbolic';
+ else
+ this._indicator.icon_name = 'thunderbolt-symbolic';
+
+ this._sync();
+ }
+
+ /* AuthRobot callbacks */
+ _onEnrollDevice(obj, device, policy) {
+ /* only authorize new devices when in an unlocked user session */
+ let unlocked = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter;
+ /* and if we have the permission to do so, otherwise we trigger a PolKit dialog */
+ let allowed = this._perm && this._perm.allowed;
+
+ let auth = unlocked && allowed;
+ policy[0] = auth;
+
+ log('thunderbolt: [%s] auto enrollment: %s (allowed: %s)'.format(
+ device.Name, auth ? 'yes' : 'no', allowed ? 'yes' : 'no'));
+
+ if (auth)
+ return; /* we are done */
+
+ if (!unlocked) {
+ const title = _("Unknown Thunderbolt device");
+ const body = _("New device has been detected while you were away. Please disconnect and reconnect the device to start using it.");
+ this._notify(title, body);
+ } else {
+ const title = _("Unauthorized Thunderbolt device");
+ const body = _("New device has been detected and needs to be authorized by an administrator.");
+ this._notify(title, body);
+ }
+ }
+
+ _onEnrollFailed(obj, device, error) {
+ const title = _("Thunderbolt authorization error");
+ const body = _("Could not authorize the Thunderbolt device: %s").format(error.message);
+ this._notify(title, body);
+ }
+});
diff --git a/js/ui/status/volume.js b/js/ui/status/volume.js
new file mode 100644
index 0000000..7b50658
--- /dev/null
+++ b/js/ui/status/volume.js
@@ -0,0 +1,430 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+const { Clutter, Gio, GLib, GObject, Gvc, St } = imports.gi;
+const Signals = imports.signals;
+
+const Main = imports.ui.main;
+const PanelMenu = imports.ui.panelMenu;
+const PopupMenu = imports.ui.popupMenu;
+const Slider = imports.ui.slider;
+
+const ALLOW_AMPLIFIED_VOLUME_KEY = 'allow-volume-above-100-percent';
+
+// Each Gvc.MixerControl is a connection to PulseAudio,
+// so it's better to make it a singleton
+let _mixerControl;
+function getMixerControl() {
+ if (_mixerControl)
+ return _mixerControl;
+
+ _mixerControl = new Gvc.MixerControl({ name: 'GNOME Shell Volume Control' });
+ _mixerControl.open();
+
+ return _mixerControl;
+}
+
+var StreamSlider = class {
+ constructor(control) {
+ this._control = control;
+
+ this.item = new PopupMenu.PopupBaseMenuItem({ activate: false });
+
+ this._inDrag = false;
+ this._notifyVolumeChangeId = 0;
+
+ this._slider = new Slider.Slider(0);
+
+ this._soundSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.sound' });
+ this._soundSettings.connect('changed::%s'.format(ALLOW_AMPLIFIED_VOLUME_KEY), this._amplifySettingsChanged.bind(this));
+ this._amplifySettingsChanged();
+
+ this._sliderChangedId = this._slider.connect('notify::value',
+ this._sliderChanged.bind(this));
+ this._slider.connect('drag-begin', () => (this._inDrag = true));
+ this._slider.connect('drag-end', () => {
+ this._inDrag = false;
+ this._notifyVolumeChange();
+ });
+
+ this._icon = new St.Icon({ style_class: 'popup-menu-icon' });
+ this.item.add(this._icon);
+ this.item.add_child(this._slider);
+ this.item.connect('button-press-event', (actor, event) => {
+ return this._slider.startDragging(event);
+ });
+ this.item.connect('key-press-event', (actor, event) => {
+ return this._slider.emit('key-press-event', event);
+ });
+ this.item.connect('scroll-event', (actor, event) => {
+ return this._slider.emit('scroll-event', event);
+ });
+
+ this._stream = null;
+ this._volumeCancellable = null;
+ this._icons = [];
+ }
+
+ get stream() {
+ return this._stream;
+ }
+
+ set stream(stream) {
+ if (this._stream)
+ this._disconnectStream(this._stream);
+
+ this._stream = stream;
+
+ if (this._stream) {
+ this._connectStream(this._stream);
+ this._updateVolume();
+ } else {
+ this.emit('stream-updated');
+ }
+
+ this._updateVisibility();
+ }
+
+ _disconnectStream(stream) {
+ stream.disconnect(this._mutedChangedId);
+ this._mutedChangedId = 0;
+ stream.disconnect(this._volumeChangedId);
+ this._volumeChangedId = 0;
+ }
+
+ _connectStream(stream) {
+ this._mutedChangedId = stream.connect('notify::is-muted', this._updateVolume.bind(this));
+ this._volumeChangedId = stream.connect('notify::volume', this._updateVolume.bind(this));
+ }
+
+ _shouldBeVisible() {
+ return this._stream != null;
+ }
+
+ _updateVisibility() {
+ let visible = this._shouldBeVisible();
+ this.item.visible = visible;
+ }
+
+ scroll(event) {
+ return this._slider.scroll(event);
+ }
+
+ _sliderChanged() {
+ if (!this._stream)
+ return;
+
+ let value = this._slider.value;
+ let volume = value * this._control.get_vol_max_norm();
+ let prevMuted = this._stream.is_muted;
+ let prevVolume = this._stream.volume;
+ if (volume < 1) {
+ this._stream.volume = 0;
+ if (!prevMuted)
+ this._stream.change_is_muted(true);
+ } else {
+ this._stream.volume = volume;
+ if (prevMuted)
+ this._stream.change_is_muted(false);
+ }
+ this._stream.push_volume();
+
+ let volumeChanged = this._stream.volume !== prevVolume;
+ if (volumeChanged && !this._notifyVolumeChangeId && !this._inDrag) {
+ this._notifyVolumeChangeId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 30, () => {
+ this._notifyVolumeChange();
+ this._notifyVolumeChangeId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._notifyVolumeChangeId,
+ '[gnome-shell] this._notifyVolumeChangeId');
+ }
+ }
+
+ _notifyVolumeChange() {
+ if (this._volumeCancellable)
+ this._volumeCancellable.cancel();
+ this._volumeCancellable = null;
+
+ if (this._stream.state === Gvc.MixerStreamState.RUNNING)
+ return; // feedback not necessary while playing
+
+ this._volumeCancellable = new Gio.Cancellable();
+ let player = global.display.get_sound_player();
+ player.play_from_theme('audio-volume-change',
+ _("Volume changed"),
+ this._volumeCancellable);
+ }
+
+ _changeSlider(value) {
+ this._slider.block_signal_handler(this._sliderChangedId);
+ this._slider.value = value;
+ this._slider.unblock_signal_handler(this._sliderChangedId);
+ }
+
+ _updateVolume() {
+ let muted = this._stream.is_muted;
+ this._changeSlider(muted
+ ? 0 : this._stream.volume / this._control.get_vol_max_norm());
+ this.emit('stream-updated');
+ }
+
+ _amplifySettingsChanged() {
+ this._allowAmplified = this._soundSettings.get_boolean(ALLOW_AMPLIFIED_VOLUME_KEY);
+
+ this._slider.maximum_value = this._allowAmplified
+ ? this.getMaxLevel() : 1;
+
+ if (this._stream)
+ this._updateVolume();
+ }
+
+ getIcon() {
+ if (!this._stream)
+ return null;
+
+ let volume = this._stream.volume;
+ let n;
+ if (this._stream.is_muted || volume <= 0) {
+ n = 0;
+ } else {
+ n = Math.ceil(3 * volume / this._control.get_vol_max_norm());
+ n = Math.clamp(n, 1, this._icons.length - 1);
+ }
+ return this._icons[n];
+ }
+
+ getLevel() {
+ if (!this._stream)
+ return null;
+
+ return this._stream.volume / this._control.get_vol_max_norm();
+ }
+
+ getMaxLevel() {
+ let maxVolume = this._control.get_vol_max_norm();
+ if (this._allowAmplified)
+ maxVolume = this._control.get_vol_max_amplified();
+
+ return maxVolume / this._control.get_vol_max_norm();
+ }
+};
+Signals.addSignalMethods(StreamSlider.prototype);
+
+var OutputStreamSlider = class extends StreamSlider {
+ constructor(control) {
+ super(control);
+ this._slider.accessible_name = _("Volume");
+ this._icons = [
+ 'audio-volume-muted-symbolic',
+ 'audio-volume-low-symbolic',
+ 'audio-volume-medium-symbolic',
+ 'audio-volume-high-symbolic',
+ 'audio-volume-overamplified-symbolic',
+ ];
+ }
+
+ _connectStream(stream) {
+ super._connectStream(stream);
+ this._portChangedId = stream.connect('notify::port', this._portChanged.bind(this));
+ this._portChanged();
+ }
+
+ _findHeadphones(sink) {
+ // This only works for external headphones (e.g. bluetooth)
+ if (sink.get_form_factor() == 'headset' ||
+ sink.get_form_factor() == 'headphone')
+ return true;
+
+ // a bit hackish, but ALSA/PulseAudio have a number
+ // of different identifiers for headphones, and I could
+ // not find the complete list
+ if (sink.get_ports().length > 0)
+ return sink.get_port().port.includes('headphone');
+
+ return false;
+ }
+
+ _disconnectStream(stream) {
+ super._disconnectStream(stream);
+ stream.disconnect(this._portChangedId);
+ this._portChangedId = 0;
+ }
+
+ _updateSliderIcon() {
+ this._icon.icon_name = this._hasHeadphones
+ ? 'audio-headphones-symbolic'
+ : 'audio-speakers-symbolic';
+ }
+
+ _portChanged() {
+ let hasHeadphones = this._findHeadphones(this._stream);
+ if (hasHeadphones != this._hasHeadphones) {
+ this._hasHeadphones = hasHeadphones;
+ this._updateSliderIcon();
+ }
+ }
+};
+
+var InputStreamSlider = class extends StreamSlider {
+ constructor(control) {
+ super(control);
+ this._slider.accessible_name = _("Microphone");
+ this._control.connect('stream-added', this._maybeShowInput.bind(this));
+ this._control.connect('stream-removed', this._maybeShowInput.bind(this));
+ this._icon.icon_name = 'audio-input-microphone-symbolic';
+ this._icons = [
+ 'microphone-sensitivity-muted-symbolic',
+ 'microphone-sensitivity-low-symbolic',
+ 'microphone-sensitivity-medium-symbolic',
+ 'microphone-sensitivity-high-symbolic',
+ ];
+ }
+
+ _connectStream(stream) {
+ super._connectStream(stream);
+ this._maybeShowInput();
+ }
+
+ _maybeShowInput() {
+ // only show input widgets if any application is recording audio
+ let showInput = false;
+ if (this._stream) {
+ // skip gnome-volume-control and pavucontrol which appear
+ // as recording because they show the input level
+ let skippedApps = [
+ 'org.gnome.VolumeControl',
+ 'org.PulseAudio.pavucontrol',
+ ];
+
+ showInput = this._control.get_source_outputs().some(output => {
+ return !skippedApps.includes(output.get_application_id());
+ });
+ }
+
+ this._showInput = showInput;
+ this._updateVisibility();
+ }
+
+ _shouldBeVisible() {
+ return super._shouldBeVisible() && this._showInput;
+ }
+};
+
+var VolumeMenu = class extends PopupMenu.PopupMenuSection {
+ constructor(control) {
+ super();
+
+ this.hasHeadphones = false;
+
+ this._control = control;
+ this._control.connect('state-changed', this._onControlStateChanged.bind(this));
+ this._control.connect('default-sink-changed', this._readOutput.bind(this));
+ this._control.connect('default-source-changed', this._readInput.bind(this));
+
+ this._output = new OutputStreamSlider(this._control);
+ this._output.connect('stream-updated', () => {
+ this.emit('output-icon-changed');
+ });
+ this.addMenuItem(this._output.item);
+
+ this._input = new InputStreamSlider(this._control);
+ this._input.item.connect('notify::visible', () => {
+ this.emit('input-visible-changed');
+ });
+ this._input.connect('stream-updated', () => {
+ this.emit('input-icon-changed');
+ });
+ this.addMenuItem(this._input.item);
+
+ this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ this._onControlStateChanged();
+ }
+
+ scroll(event) {
+ return this._output.scroll(event);
+ }
+
+ _onControlStateChanged() {
+ if (this._control.get_state() == Gvc.MixerControlState.READY) {
+ this._readInput();
+ this._readOutput();
+ } else {
+ this.emit('output-icon-changed');
+ }
+ }
+
+ _readOutput() {
+ this._output.stream = this._control.get_default_sink();
+ }
+
+ _readInput() {
+ this._input.stream = this._control.get_default_source();
+ }
+
+ getOutputIcon() {
+ return this._output.getIcon();
+ }
+
+ getInputIcon() {
+ return this._input.getIcon();
+ }
+
+ getLevel() {
+ return this._output.getLevel();
+ }
+
+ getMaxLevel() {
+ return this._output.getMaxLevel();
+ }
+
+ getInputVisible() {
+ return this._input.item.visible;
+ }
+};
+
+var Indicator = GObject.registerClass(
+class Indicator extends PanelMenu.SystemIndicator {
+ _init() {
+ super._init();
+
+ this._primaryIndicator = this._addIndicator();
+ this._inputIndicator = this._addIndicator();
+
+ this._control = getMixerControl();
+ this._volumeMenu = new VolumeMenu(this._control);
+ this._volumeMenu.connect('output-icon-changed', () => {
+ let icon = this._volumeMenu.getOutputIcon();
+
+ if (icon != null)
+ this._primaryIndicator.icon_name = icon;
+ this._primaryIndicator.visible = icon !== null;
+ });
+
+ this._inputIndicator.visible = this._volumeMenu.getInputVisible();
+ this._volumeMenu.connect('input-visible-changed', () => {
+ this._inputIndicator.visible = this._volumeMenu.getInputVisible();
+ });
+ this._volumeMenu.connect('input-icon-changed', () => {
+ let icon = this._volumeMenu.getInputIcon();
+
+ if (icon !== null)
+ this._inputIndicator.icon_name = icon;
+ });
+
+ this.menu.addMenuItem(this._volumeMenu);
+ }
+
+ vfunc_scroll_event() {
+ let result = this._volumeMenu.scroll(Clutter.get_current_event());
+ if (result == Clutter.EVENT_PROPAGATE || this.menu.actor.mapped)
+ return result;
+
+ let gicon = new Gio.ThemedIcon({ name: this._volumeMenu.getOutputIcon() });
+ let level = this._volumeMenu.getLevel();
+ let maxLevel = this._volumeMenu.getMaxLevel();
+ Main.osdWindowManager.show(-1, gicon, null, level, maxLevel);
+ return result;
+ }
+});
diff --git a/js/ui/swipeTracker.js b/js/ui/swipeTracker.js
new file mode 100644
index 0000000..fd13fd7
--- /dev/null
+++ b/js/ui/swipeTracker.js
@@ -0,0 +1,666 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported SwipeTracker */
+
+const { Clutter, Gio, GObject, Meta } = imports.gi;
+
+const Main = imports.ui.main;
+const Params = imports.misc.params;
+
+// FIXME: ideally these values matches physical touchpad size. We can get the
+// correct values for gnome-shell specifically, since mutter uses libinput
+// directly, but GTK apps cannot get it, so use an arbitrary value so that
+// it's consistent with apps.
+const TOUCHPAD_BASE_HEIGHT = 300;
+const TOUCHPAD_BASE_WIDTH = 400;
+
+const SCROLL_MULTIPLIER = 10;
+const SWIPE_MULTIPLIER = 0.5;
+
+const MIN_ANIMATION_DURATION = 100;
+const MAX_ANIMATION_DURATION = 400;
+const VELOCITY_THRESHOLD = 0.4;
+// Derivative of easeOutCubic at t=0
+const DURATION_MULTIPLIER = 3;
+const ANIMATION_BASE_VELOCITY = 0.002;
+
+const State = {
+ NONE: 0,
+ SCROLLING: 1,
+};
+
+const TouchpadSwipeGesture = GObject.registerClass({
+ Properties: {
+ 'enabled': GObject.ParamSpec.boolean(
+ 'enabled', 'enabled', 'enabled',
+ GObject.ParamFlags.READWRITE,
+ true),
+ 'orientation': GObject.ParamSpec.enum(
+ 'orientation', 'orientation', 'orientation',
+ GObject.ParamFlags.READWRITE,
+ Clutter.Orientation, Clutter.Orientation.VERTICAL),
+ },
+ Signals: {
+ 'begin': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] },
+ 'update': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE] },
+ 'end': { param_types: [GObject.TYPE_UINT] },
+ },
+}, class TouchpadSwipeGesture extends GObject.Object {
+ _init(allowedModes) {
+ super._init();
+ this._allowedModes = allowedModes;
+ this._touchpadSettings = new Gio.Settings({
+ schema_id: 'org.gnome.desktop.peripherals.touchpad',
+ });
+ this._orientation = Clutter.Orientation.VERTICAL;
+ this._enabled = true;
+
+ this._stageCaptureEvent =
+ global.stage.connect('captured-event::touchpad', this._handleEvent.bind(this));
+ }
+
+ get enabled() {
+ return this._enabled;
+ }
+
+ set enabled(enabled) {
+ if (this._enabled === enabled)
+ return;
+
+ this._enabled = enabled;
+ this.notify('enabled');
+ }
+
+ get orientation() {
+ return this._orientation;
+ }
+
+ set orientation(orientation) {
+ if (this._orientation === orientation)
+ return;
+
+ this._orientation = orientation;
+ this.notify('orientation');
+ }
+
+ _handleEvent(actor, event) {
+ if (event.type() !== Clutter.EventType.TOUCHPAD_SWIPE)
+ return Clutter.EVENT_PROPAGATE;
+
+ if (event.get_touchpad_gesture_finger_count() !== 4)
+ return Clutter.EVENT_PROPAGATE;
+
+ if ((this._allowedModes & Main.actionMode) === 0)
+ return Clutter.EVENT_PROPAGATE;
+
+ if (!this.enabled)
+ return Clutter.EVENT_PROPAGATE;
+
+ let time = event.get_time();
+
+ let [x, y] = event.get_coords();
+ let [dx, dy] = event.get_gesture_motion_delta();
+
+ let delta;
+ if (this._orientation === Clutter.Orientation.VERTICAL)
+ delta = dy / TOUCHPAD_BASE_HEIGHT;
+ else
+ delta = dx / TOUCHPAD_BASE_WIDTH;
+
+ switch (event.get_gesture_phase()) {
+ case Clutter.TouchpadGesturePhase.BEGIN:
+ this.emit('begin', time, x, y);
+ break;
+
+ case Clutter.TouchpadGesturePhase.UPDATE:
+ if (this._touchpadSettings.get_boolean('natural-scroll'))
+ delta = -delta;
+
+ this.emit('update', time, delta * SWIPE_MULTIPLIER);
+ break;
+
+ case Clutter.TouchpadGesturePhase.END:
+ case Clutter.TouchpadGesturePhase.CANCEL:
+ this.emit('end', time);
+ break;
+ }
+
+ return Clutter.EVENT_STOP;
+ }
+
+ destroy() {
+ if (this._stageCaptureEvent) {
+ global.stage.disconnect(this._stageCaptureEvent);
+ delete this._stageCaptureEvent;
+ }
+ }
+});
+
+const TouchSwipeGesture = GObject.registerClass({
+ Properties: {
+ 'distance': GObject.ParamSpec.double(
+ 'distance', 'distance', 'distance',
+ GObject.ParamFlags.READWRITE,
+ 0, Infinity, 0),
+ 'orientation': GObject.ParamSpec.enum(
+ 'orientation', 'orientation', 'orientation',
+ GObject.ParamFlags.READWRITE,
+ Clutter.Orientation, Clutter.Orientation.VERTICAL),
+ },
+ Signals: {
+ 'begin': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] },
+ 'update': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE] },
+ 'end': { param_types: [GObject.TYPE_UINT] },
+ 'cancel': { param_types: [GObject.TYPE_UINT] },
+ },
+}, class TouchSwipeGesture extends Clutter.GestureAction {
+ _init(allowedModes, nTouchPoints, thresholdTriggerEdge) {
+ super._init();
+ this.set_n_touch_points(nTouchPoints);
+ this.set_threshold_trigger_edge(thresholdTriggerEdge);
+
+ this._allowedModes = allowedModes;
+ this._distance = global.screen_height;
+ this._orientation = Clutter.Orientation.VERTICAL;
+
+ global.display.connect('grab-op-begin', () => {
+ this.cancel();
+ });
+
+ this._lastPosition = 0;
+ }
+
+ get distance() {
+ return this._distance;
+ }
+
+ set distance(distance) {
+ if (this._distance === distance)
+ return;
+
+ this._distance = distance;
+ this.notify('distance');
+ }
+
+ get orientation() {
+ return this._orientation;
+ }
+
+ set orientation(orientation) {
+ if (this._orientation === orientation)
+ return;
+
+ this._orientation = orientation;
+ this.notify('orientation');
+ }
+
+ vfunc_gesture_prepare(actor) {
+ if (!super.vfunc_gesture_prepare(actor))
+ return false;
+
+ if ((this._allowedModes & Main.actionMode) === 0)
+ return false;
+
+ let time = this.get_last_event(0).get_time();
+ let [xPress, yPress] = this.get_press_coords(0);
+ let [x, y] = this.get_motion_coords(0);
+
+ this._lastPosition =
+ this._orientation === Clutter.Orientation.VERTICAL ? y : x;
+
+ this.emit('begin', time, xPress, yPress);
+ return true;
+ }
+
+ vfunc_gesture_progress(_actor) {
+ let [x, y] = this.get_motion_coords(0);
+ let pos = this._orientation === Clutter.Orientation.VERTICAL ? y : x;
+
+ let delta = pos - this._lastPosition;
+ this._lastPosition = pos;
+
+ let time = this.get_last_event(0).get_time();
+
+ this.emit('update', time, -delta / this._distance);
+
+ return true;
+ }
+
+ vfunc_gesture_end(_actor) {
+ let time = this.get_last_event(0).get_time();
+
+ this.emit('end', time);
+ }
+
+ vfunc_gesture_cancel(_actor) {
+ let time = Clutter.get_current_event_time();
+
+ this.emit('cancel', time);
+ }
+});
+
+const ScrollGesture = GObject.registerClass({
+ Properties: {
+ 'enabled': GObject.ParamSpec.boolean(
+ 'enabled', 'enabled', 'enabled',
+ GObject.ParamFlags.READWRITE,
+ true),
+ 'orientation': GObject.ParamSpec.enum(
+ 'orientation', 'orientation', 'orientation',
+ GObject.ParamFlags.READWRITE,
+ Clutter.Orientation, Clutter.Orientation.VERTICAL),
+ },
+ Signals: {
+ 'begin': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] },
+ 'update': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE] },
+ 'end': { param_types: [GObject.TYPE_UINT] },
+ },
+}, class ScrollGesture extends GObject.Object {
+ _init(actor, allowedModes) {
+ super._init();
+ this._allowedModes = allowedModes;
+ this._began = false;
+ this._enabled = true;
+ this._orientation = Clutter.Orientation.VERTICAL;
+
+ actor.connect('scroll-event', this._handleEvent.bind(this));
+ }
+
+ get enabled() {
+ return this._enabled;
+ }
+
+ set enabled(enabled) {
+ if (this._enabled === enabled)
+ return;
+
+ this._enabled = enabled;
+ this._began = false;
+
+ this.notify('enabled');
+ }
+
+ get orientation() {
+ return this._orientation;
+ }
+
+ set orientation(orientation) {
+ if (this._orientation === orientation)
+ return;
+
+ this._orientation = orientation;
+ this.notify('orientation');
+ }
+
+ canHandleEvent(event) {
+ if (event.type() !== Clutter.EventType.SCROLL)
+ return false;
+
+ if (event.get_scroll_source() !== Clutter.ScrollSource.FINGER &&
+ event.get_source_device().get_device_type() !== Clutter.InputDeviceType.TOUCHPAD_DEVICE)
+ return false;
+
+ if (!this.enabled)
+ return false;
+
+ if ((this._allowedModes & Main.actionMode) === 0)
+ return false;
+
+ return true;
+ }
+
+ _handleEvent(actor, event) {
+ if (!this.canHandleEvent(event))
+ return Clutter.EVENT_PROPAGATE;
+
+ if (event.get_scroll_direction() !== Clutter.ScrollDirection.SMOOTH)
+ return Clutter.EVENT_PROPAGATE;
+
+ let time = event.get_time();
+ let [dx, dy] = event.get_scroll_delta();
+ if (dx === 0 && dy === 0) {
+ this.emit('end', time);
+ this._began = false;
+ return Clutter.EVENT_STOP;
+ }
+
+ if (!this._began) {
+ let [x, y] = event.get_coords();
+ this.emit('begin', time, x, y);
+ this._began = true;
+ }
+
+ let delta;
+ if (this._orientation === Clutter.Orientation.VERTICAL)
+ delta = dy / TOUCHPAD_BASE_HEIGHT;
+ else
+ delta = dx / TOUCHPAD_BASE_WIDTH;
+
+ this.emit('update', time, delta * SCROLL_MULTIPLIER);
+
+ return Clutter.EVENT_STOP;
+ }
+});
+
+// USAGE:
+//
+// To correctly implement the gesture, there must be handlers for the following
+// signals:
+//
+// begin(tracker, monitor)
+// The handler should check whether a deceleration animation is currently
+// running. If it is, it should stop the animation (without resetting
+// progress). Then it should call:
+// tracker.confirmSwipe(distance, snapPoints, currentProgress, cancelProgress)
+// If it's not called, the swipe would be ignored.
+// The parameters are:
+// * distance: the page size;
+// * snapPoints: an (sorted with ascending order) array of snap points;
+// * currentProgress: the current progress;
+// * cancelprogress: a non-transient value that would be used if the gesture
+// is cancelled.
+// If no animation was running, currentProgress and cancelProgress should be
+// same. The handler may set 'orientation' property here.
+//
+// update(tracker, progress)
+// The handler should set the progress to the given value.
+//
+// end(tracker, duration, endProgress)
+// The handler should animate the progress to endProgress. If endProgress is
+// 0, it should do nothing after the animation, otherwise it should change the
+// state, e.g. change the current page or switch workspace.
+// NOTE: duration can be 0 in some cases, in this case it should finish
+// instantly.
+
+/** A class for handling swipe gestures */
+var SwipeTracker = GObject.registerClass({
+ Properties: {
+ 'enabled': GObject.ParamSpec.boolean(
+ 'enabled', 'enabled', 'enabled',
+ GObject.ParamFlags.READWRITE,
+ true),
+ 'orientation': GObject.ParamSpec.enum(
+ 'orientation', 'orientation', 'orientation',
+ GObject.ParamFlags.READWRITE,
+ Clutter.Orientation, Clutter.Orientation.VERTICAL),
+ 'distance': GObject.ParamSpec.double(
+ 'distance', 'distance', 'distance',
+ GObject.ParamFlags.READWRITE,
+ 0, Infinity, 0),
+ },
+ Signals: {
+ 'begin': { param_types: [GObject.TYPE_UINT] },
+ 'update': { param_types: [GObject.TYPE_DOUBLE] },
+ 'end': { param_types: [GObject.TYPE_UINT64, GObject.TYPE_DOUBLE] },
+ },
+}, class SwipeTracker extends GObject.Object {
+ _init(actor, allowedModes, params) {
+ super._init();
+ params = Params.parse(params, { allowDrag: true, allowScroll: true });
+
+ this._allowedModes = allowedModes;
+ this._enabled = true;
+ this._orientation = Clutter.Orientation.VERTICAL;
+ this._distance = global.screen_height;
+
+ this._reset();
+
+ this._touchpadGesture = new TouchpadSwipeGesture(allowedModes);
+ this._touchpadGesture.connect('begin', this._beginGesture.bind(this));
+ this._touchpadGesture.connect('update', this._updateGesture.bind(this));
+ this._touchpadGesture.connect('end', this._endGesture.bind(this));
+ this.bind_property('enabled', this._touchpadGesture, 'enabled', 0);
+ this.bind_property('orientation', this._touchpadGesture, 'orientation', 0);
+
+ this._touchGesture = new TouchSwipeGesture(allowedModes, 4,
+ Clutter.GestureTriggerEdge.NONE);
+ this._touchGesture.connect('begin', this._beginTouchSwipe.bind(this));
+ this._touchGesture.connect('update', this._updateGesture.bind(this));
+ this._touchGesture.connect('end', this._endGesture.bind(this));
+ this._touchGesture.connect('cancel', this._cancelGesture.bind(this));
+ this.bind_property('enabled', this._touchGesture, 'enabled', 0);
+ this.bind_property('orientation', this._touchGesture, 'orientation', 0);
+ this.bind_property('distance', this._touchGesture, 'distance', 0);
+ global.stage.add_action(this._touchGesture);
+
+ if (params.allowDrag) {
+ this._dragGesture = new TouchSwipeGesture(allowedModes, 1,
+ Clutter.GestureTriggerEdge.AFTER);
+ this._dragGesture.connect('begin', this._beginGesture.bind(this));
+ this._dragGesture.connect('update', this._updateGesture.bind(this));
+ this._dragGesture.connect('end', this._endGesture.bind(this));
+ this._dragGesture.connect('cancel', this._cancelGesture.bind(this));
+ this.bind_property('enabled', this._dragGesture, 'enabled', 0);
+ this.bind_property('orientation', this._dragGesture, 'orientation', 0);
+ this.bind_property('distance', this._dragGesture, 'distance', 0);
+ actor.add_action(this._dragGesture);
+ } else {
+ this._dragGesture = null;
+ }
+
+ if (params.allowScroll) {
+ this._scrollGesture = new ScrollGesture(actor, allowedModes);
+ this._scrollGesture.connect('begin', this._beginGesture.bind(this));
+ this._scrollGesture.connect('update', this._updateGesture.bind(this));
+ this._scrollGesture.connect('end', this._endGesture.bind(this));
+ this.bind_property('enabled', this._scrollGesture, 'enabled', 0);
+ this.bind_property('orientation', this._scrollGesture, 'orientation', 0);
+ } else {
+ this._scrollGesture = null;
+ }
+ }
+
+ /**
+ * canHandleScrollEvent:
+ * @param {Clutter.Event} scrollEvent: an event to check
+ * @returns {bool} whether the event can be handled by the tracker
+ *
+ * This function can be used to combine swipe gesture and mouse
+ * scrolling.
+ */
+ canHandleScrollEvent(scrollEvent) {
+ if (!this.enabled || this._scrollGesture === null)
+ return false;
+
+ return this._scrollGesture.canHandleEvent(scrollEvent);
+ }
+
+ get enabled() {
+ return this._enabled;
+ }
+
+ set enabled(enabled) {
+ if (this._enabled === enabled)
+ return;
+
+ this._enabled = enabled;
+ if (!enabled && this._state === State.SCROLLING)
+ this._interrupt();
+ this.notify('enabled');
+ }
+
+ get orientation() {
+ return this._orientation;
+ }
+
+ set orientation(orientation) {
+ if (this._orientation === orientation)
+ return;
+
+ this._orientation = orientation;
+ this.notify('orientation');
+ }
+
+ get distance() {
+ return this._distance;
+ }
+
+ set distance(distance) {
+ if (this._distance === distance)
+ return;
+
+ this._distance = distance;
+ this.notify('distance');
+ }
+
+ _reset() {
+ this._state = State.NONE;
+
+ this._snapPoints = [];
+ this._initialProgress = 0;
+ this._cancelProgress = 0;
+
+ this._prevOffset = 0;
+ this._progress = 0;
+
+ this._prevTime = 0;
+ this._velocity = 0;
+
+ this._cancelled = false;
+ }
+
+ _interrupt() {
+ this.emit('end', 0, this._cancelProgress);
+ this._reset();
+ }
+
+ _beginTouchSwipe(gesture, time, x, y) {
+ if (this._dragGesture)
+ this._dragGesture.cancel();
+
+ this._beginGesture(gesture, time, x, y);
+ }
+
+ _beginGesture(gesture, time, x, y) {
+ if (this._state === State.SCROLLING)
+ return;
+
+ this._prevTime = time;
+
+ let rect = new Meta.Rectangle({ x, y, width: 1, height: 1 });
+ let monitor = global.display.get_monitor_index_for_rect(rect);
+
+ this.emit('begin', monitor);
+ }
+
+ _updateGesture(gesture, time, delta) {
+ if (this._state !== State.SCROLLING)
+ return;
+
+ if ((this._allowedModes & Main.actionMode) === 0 || !this.enabled) {
+ this._interrupt();
+ return;
+ }
+
+ if (this.orientation === Clutter.Orientation.HORIZONTAL &&
+ Clutter.get_default_text_direction() === Clutter.TextDirection.RTL)
+ delta = -delta;
+
+ this._progress += delta;
+
+ if (time !== this._prevTime)
+ this._velocity = delta / (time - this._prevTime);
+
+ let firstPoint = this._snapPoints[0];
+ let lastPoint = this._snapPoints[this._snapPoints.length - 1];
+ this._progress = Math.clamp(this._progress, firstPoint, lastPoint);
+ this._progress = Math.clamp(this._progress,
+ this._initialProgress - 1, this._initialProgress + 1);
+
+ this.emit('update', this._progress);
+
+ this._prevTime = time;
+ }
+
+ _getClosestSnapPoints() {
+ let upper = this._snapPoints.find(p => p >= this._progress);
+ let lower = this._snapPoints.slice().reverse().find(p => p <= this._progress);
+ return [lower, upper];
+ }
+
+ _getEndProgress() {
+ if (this._cancelled)
+ return this._cancelProgress;
+
+ let [lower, upper] = this._getClosestSnapPoints();
+ let middle = (upper + lower) / 2;
+
+ if (this._progress > middle) {
+ let thresholdMet = this._velocity * this._distance > -VELOCITY_THRESHOLD;
+ return thresholdMet || this._initialProgress > upper ? upper : lower;
+ } else {
+ let thresholdMet = this._velocity * this._distance < VELOCITY_THRESHOLD;
+ return thresholdMet || this._initialProgress < lower ? lower : upper;
+ }
+ }
+
+ _endGesture(_gesture, _time) {
+ if (this._state !== State.SCROLLING)
+ return;
+
+ if ((this._allowedModes & Main.actionMode) === 0 || !this.enabled) {
+ this._interrupt();
+ return;
+ }
+
+ let endProgress = this._getEndProgress();
+
+ let velocity = ANIMATION_BASE_VELOCITY;
+ if ((endProgress - this._progress) * this._velocity > 0)
+ velocity = this._velocity;
+
+ let duration = Math.abs((this._progress - endProgress) / velocity * DURATION_MULTIPLIER);
+ if (duration > 0) {
+ duration = Math.clamp(duration,
+ MIN_ANIMATION_DURATION, MAX_ANIMATION_DURATION);
+ }
+
+ this.emit('end', duration, endProgress);
+ this._reset();
+ }
+
+ _cancelGesture(gesture, time) {
+ if (this._state !== State.SCROLLING)
+ return;
+
+ this._cancelled = true;
+ this._endGesture(gesture, time);
+ }
+
+ /**
+ * confirmSwipe:
+ * @param {number} distance: swipe distance in pixels
+ * @param {number[]} snapPoints:
+ * An array of snap points, sorted in ascending order
+ * @param {number} currentProgress: initial progress value
+ * @param {number} cancelProgress: the value to be used on cancelling
+ *
+ * Confirms a swipe. User has to call this in 'begin' signal handler,
+ * otherwise the swipe wouldn't start. If there's an animation running,
+ * it should be stopped first.
+ *
+ * @cancel_progress must always be a snap point, or a value matching
+ * some other non-transient state.
+ */
+ confirmSwipe(distance, snapPoints, currentProgress, cancelProgress) {
+ this.distance = distance;
+ this._snapPoints = snapPoints;
+ this._initialProgress = currentProgress;
+ this._progress = currentProgress;
+ this._cancelProgress = cancelProgress;
+
+ this._velocity = 0;
+ this._state = State.SCROLLING;
+ }
+
+ destroy() {
+ if (this._touchpadGesture) {
+ this._touchpadGesture.destroy();
+ delete this._touchpadGesture;
+ }
+
+ if (this._touchGesture) {
+ global.stage.remove_action(this._touchGesture);
+ delete this._touchGesture;
+ }
+ }
+});
diff --git a/js/ui/switchMonitor.js b/js/ui/switchMonitor.js
new file mode 100644
index 0000000..5ac5825
--- /dev/null
+++ b/js/ui/switchMonitor.js
@@ -0,0 +1,97 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported SwitchMonitorPopup */
+
+const { Clutter, GObject, Meta, St } = imports.gi;
+
+const SwitcherPopup = imports.ui.switcherPopup;
+
+var APP_ICON_SIZE = 96;
+
+var SwitchMonitorPopup = GObject.registerClass(
+class SwitchMonitorPopup extends SwitcherPopup.SwitcherPopup {
+ _init() {
+ let items = [{ icon: 'view-mirror-symbolic',
+ /* Translators: this is for display mirroring i.e. cloning.
+ * Try to keep it under around 15 characters.
+ */
+ label: _('Mirror') },
+ { icon: 'video-joined-displays-symbolic',
+ /* Translators: this is for the desktop spanning displays.
+ * Try to keep it under around 15 characters.
+ */
+ label: _('Join Displays') },
+ { icon: 'video-single-display-symbolic',
+ /* Translators: this is for using only an external display.
+ * Try to keep it under around 15 characters.
+ */
+ label: _('External Only') },
+ { icon: 'computer-symbolic',
+ /* Translators: this is for using only the laptop display.
+ * Try to keep it under around 15 characters.
+ */
+ label: _('Built-in Only') }];
+
+ super._init(items);
+
+ this._switcherList = new SwitchMonitorSwitcher(items);
+ }
+
+ show(backward, binding, mask) {
+ if (!Meta.MonitorManager.get().can_switch_config())
+ return false;
+
+ return super.show(backward, binding, mask);
+ }
+
+ _initialSelection() {
+ let currentConfig = Meta.MonitorManager.get().get_switch_config();
+ let selectConfig = (currentConfig + 1) % Meta.MonitorSwitchConfigType.UNKNOWN;
+ this._select(selectConfig);
+ }
+
+ _keyPressHandler(keysym, action) {
+ if (action == Meta.KeyBindingAction.SWITCH_MONITOR)
+ this._select(this._next());
+ else if (keysym == Clutter.KEY_Left)
+ this._select(this._previous());
+ else if (keysym == Clutter.KEY_Right)
+ this._select(this._next());
+ else
+ return Clutter.EVENT_PROPAGATE;
+
+ return Clutter.EVENT_STOP;
+ }
+
+ _finish() {
+ super._finish();
+
+ Meta.MonitorManager.get().switch_config(this._selectedIndex);
+ }
+});
+
+var SwitchMonitorSwitcher = GObject.registerClass(
+class SwitchMonitorSwitcher extends SwitcherPopup.SwitcherList {
+ _init(items) {
+ super._init(true);
+
+ for (let i = 0; i < items.length; i++)
+ this._addIcon(items[i]);
+ }
+
+ _addIcon(item) {
+ let box = new St.BoxLayout({ style_class: 'alt-tab-app',
+ vertical: true });
+
+ let icon = new St.Icon({ icon_name: item.icon,
+ icon_size: APP_ICON_SIZE });
+ box.add_child(icon);
+
+ let text = new St.Label({
+ text: item.label,
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+ box.add_child(text);
+
+ this.addItem(box, text);
+ }
+});
diff --git a/js/ui/switcherPopup.js b/js/ui/switcherPopup.js
new file mode 100644
index 0000000..5cc4ab2
--- /dev/null
+++ b/js/ui/switcherPopup.js
@@ -0,0 +1,674 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported SwitcherPopup, SwitcherList */
+
+const { Clutter, GLib, GObject, Meta, St } = imports.gi;
+
+const Main = imports.ui.main;
+
+var POPUP_DELAY_TIMEOUT = 150; // milliseconds
+
+var POPUP_SCROLL_TIME = 100; // milliseconds
+var POPUP_FADE_OUT_TIME = 100; // milliseconds
+
+var DISABLE_HOVER_TIMEOUT = 500; // milliseconds
+var NO_MODS_TIMEOUT = 1500; // milliseconds
+
+function mod(a, b) {
+ return (a + b) % b;
+}
+
+function primaryModifier(mask) {
+ if (mask == 0)
+ return 0;
+
+ let primary = 1;
+ while (mask > 1) {
+ mask >>= 1;
+ primary <<= 1;
+ }
+ return primary;
+}
+
+var SwitcherPopup = GObject.registerClass({
+ GTypeFlags: GObject.TypeFlags.ABSTRACT,
+}, class SwitcherPopup extends St.Widget {
+ _init(items) {
+ super._init({ style_class: 'switcher-popup',
+ reactive: true,
+ visible: false });
+
+ this._switcherList = null;
+
+ this._items = items || [];
+ this._selectedIndex = 0;
+
+ this.connect('destroy', this._onDestroy.bind(this));
+
+ Main.uiGroup.add_actor(this);
+
+ this._systemModalOpenedId =
+ Main.layoutManager.connect('system-modal-opened', () => this.destroy());
+
+ this._haveModal = false;
+ this._modifierMask = 0;
+
+ this._motionTimeoutId = 0;
+ this._initialDelayTimeoutId = 0;
+ this._noModsTimeoutId = 0;
+
+ this.add_constraint(new Clutter.BindConstraint({
+ source: global.stage,
+ coordinate: Clutter.BindCoordinate.ALL,
+ }));
+
+ // Initially disable hover so we ignore the enter-event if
+ // the switcher appears underneath the current pointer location
+ this._disableHover();
+ }
+
+ vfunc_allocate(box) {
+ this.set_allocation(box);
+
+ let childBox = new Clutter.ActorBox();
+ let primary = Main.layoutManager.primaryMonitor;
+
+ let leftPadding = this.get_theme_node().get_padding(St.Side.LEFT);
+ let rightPadding = this.get_theme_node().get_padding(St.Side.RIGHT);
+ let hPadding = leftPadding + rightPadding;
+
+ // Allocate the switcherList
+ // We select a size based on an icon size that does not overflow the screen
+ let [, childNaturalHeight] = this._switcherList.get_preferred_height(primary.width - hPadding);
+ let [, childNaturalWidth] = this._switcherList.get_preferred_width(childNaturalHeight);
+ childBox.x1 = Math.max(primary.x + leftPadding, primary.x + Math.floor((primary.width - childNaturalWidth) / 2));
+ childBox.x2 = Math.min(primary.x + primary.width - rightPadding, childBox.x1 + childNaturalWidth);
+ childBox.y1 = primary.y + Math.floor((primary.height - childNaturalHeight) / 2);
+ childBox.y2 = childBox.y1 + childNaturalHeight;
+ this._switcherList.allocate(childBox);
+ }
+
+ _initialSelection(backward, _binding) {
+ if (backward)
+ this._select(this._items.length - 1);
+ else if (this._items.length == 1)
+ this._select(0);
+ else
+ this._select(1);
+ }
+
+ show(backward, binding, mask) {
+ if (this._items.length == 0)
+ return false;
+
+ if (!Main.pushModal(this)) {
+ // Probably someone else has a pointer grab, try again with keyboard only
+ if (!Main.pushModal(this, { options: Meta.ModalOptions.POINTER_ALREADY_GRABBED }))
+ return false;
+ }
+ this._haveModal = true;
+ this._modifierMask = primaryModifier(mask);
+
+ this.add_actor(this._switcherList);
+ this._switcherList.connect('item-activated', this._itemActivated.bind(this));
+ this._switcherList.connect('item-entered', this._itemEntered.bind(this));
+ this._switcherList.connect('item-removed', this._itemRemoved.bind(this));
+
+ // Need to force an allocation so we can figure out whether we
+ // need to scroll when selecting
+ this.opacity = 0;
+ this.visible = true;
+ this.get_allocation_box();
+
+ this._initialSelection(backward, binding);
+
+ // There's a race condition; if the user released Alt before
+ // we got the grab, then we won't be notified. (See
+ // https://bugzilla.gnome.org/show_bug.cgi?id=596695 for
+ // details.) So we check now. (Have to do this after updating
+ // selection.)
+ if (this._modifierMask) {
+ let [x_, y_, mods] = global.get_pointer();
+ if (!(mods & this._modifierMask)) {
+ this._finish(global.get_current_time());
+ return true;
+ }
+ } else {
+ this._resetNoModsTimeout();
+ }
+
+ // We delay showing the popup so that fast Alt+Tab users aren't
+ // disturbed by the popup briefly flashing.
+ this._initialDelayTimeoutId = GLib.timeout_add(
+ GLib.PRIORITY_DEFAULT,
+ POPUP_DELAY_TIMEOUT,
+ () => {
+ this._showImmediately();
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._initialDelayTimeoutId, '[gnome-shell] Main.osdWindow.cancel');
+ return true;
+ }
+
+ _showImmediately() {
+ if (this._initialDelayTimeoutId === 0)
+ return;
+
+ GLib.source_remove(this._initialDelayTimeoutId);
+ this._initialDelayTimeoutId = 0;
+
+ Main.osdWindowManager.hideAll();
+ this.opacity = 255;
+ }
+
+ _next() {
+ return mod(this._selectedIndex + 1, this._items.length);
+ }
+
+ _previous() {
+ return mod(this._selectedIndex - 1, this._items.length);
+ }
+
+ _keyPressHandler(_keysym, _action) {
+ throw new GObject.NotImplementedError(`_keyPressHandler in ${this.constructor.name}`);
+ }
+
+ vfunc_key_press_event(keyEvent) {
+ let keysym = keyEvent.keyval;
+ let action = global.display.get_keybinding_action(
+ keyEvent.hardware_keycode, keyEvent.modifier_state);
+
+ this._disableHover();
+
+ if (this._keyPressHandler(keysym, action) != Clutter.EVENT_PROPAGATE) {
+ this._showImmediately();
+ return Clutter.EVENT_STOP;
+ }
+
+ // Note: pressing one of the below keys will destroy the popup only if
+ // that key is not used by the active popup's keyboard shortcut
+ if (keysym === Clutter.KEY_Escape || keysym === Clutter.KEY_Tab)
+ this.fadeAndDestroy();
+
+ // Allow to explicitly select the current item; this is particularly
+ // useful for no-modifier popups
+ if (keysym === Clutter.KEY_space ||
+ keysym === Clutter.KEY_Return ||
+ keysym === Clutter.KEY_KP_Enter ||
+ keysym === Clutter.KEY_ISO_Enter)
+ this._finish(keyEvent.time);
+
+ return Clutter.EVENT_STOP;
+ }
+
+ vfunc_key_release_event(keyEvent) {
+ if (this._modifierMask) {
+ let [x_, y_, mods] = global.get_pointer();
+ let state = mods & this._modifierMask;
+
+ if (state == 0)
+ this._finish(keyEvent.time);
+ } else {
+ this._resetNoModsTimeout();
+ }
+
+ return Clutter.EVENT_STOP;
+ }
+
+ vfunc_button_press_event() {
+ /* We clicked outside */
+ this.fadeAndDestroy();
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _scrollHandler(direction) {
+ if (direction == Clutter.ScrollDirection.UP)
+ this._select(this._previous());
+ else if (direction == Clutter.ScrollDirection.DOWN)
+ this._select(this._next());
+ }
+
+ vfunc_scroll_event(scrollEvent) {
+ this._disableHover();
+
+ this._scrollHandler(scrollEvent.direction);
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _itemActivatedHandler(n) {
+ this._select(n);
+ }
+
+ _itemActivated(switcher, n) {
+ this._itemActivatedHandler(n);
+ this._finish(global.get_current_time());
+ }
+
+ _itemEnteredHandler(n) {
+ this._select(n);
+ }
+
+ _itemEntered(switcher, n) {
+ if (!this.mouseActive)
+ return;
+ this._itemEnteredHandler(n);
+ }
+
+ _itemRemovedHandler(n) {
+ if (this._items.length > 0) {
+ let newIndex;
+
+ if (n < this._selectedIndex)
+ newIndex = this._selectedIndex - 1;
+ else if (n === this._selectedIndex)
+ newIndex = Math.min(n, this._items.length - 1);
+ else if (n > this._selectedIndex)
+ return; // No need to select something new in this case
+
+ this._select(newIndex);
+ } else {
+ this.fadeAndDestroy();
+ }
+ }
+
+ _itemRemoved(switcher, n) {
+ this._itemRemovedHandler(n);
+ }
+
+ _disableHover() {
+ this.mouseActive = false;
+
+ if (this._motionTimeoutId != 0)
+ GLib.source_remove(this._motionTimeoutId);
+
+ this._motionTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, DISABLE_HOVER_TIMEOUT, this._mouseTimedOut.bind(this));
+ GLib.Source.set_name_by_id(this._motionTimeoutId, '[gnome-shell] this._mouseTimedOut');
+ }
+
+ _mouseTimedOut() {
+ this._motionTimeoutId = 0;
+ this.mouseActive = true;
+ return GLib.SOURCE_REMOVE;
+ }
+
+ _resetNoModsTimeout() {
+ if (this._noModsTimeoutId != 0)
+ GLib.source_remove(this._noModsTimeoutId);
+
+ this._noModsTimeoutId = GLib.timeout_add(
+ GLib.PRIORITY_DEFAULT,
+ NO_MODS_TIMEOUT,
+ () => {
+ this._finish(global.display.get_current_time_roundtrip());
+ this._noModsTimeoutId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+
+ _popModal() {
+ if (this._haveModal) {
+ Main.popModal(this);
+ this._haveModal = false;
+ }
+ }
+
+ fadeAndDestroy() {
+ this._popModal();
+ if (this.opacity > 0) {
+ this.ease({
+ opacity: 0,
+ duration: POPUP_FADE_OUT_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this.destroy(),
+ });
+ } else {
+ this.destroy();
+ }
+ }
+
+ _finish(_timestamp) {
+ this.fadeAndDestroy();
+ }
+
+ _onDestroy() {
+ this._popModal();
+
+ Main.layoutManager.disconnect(this._systemModalOpenedId);
+
+ if (this._motionTimeoutId != 0)
+ GLib.source_remove(this._motionTimeoutId);
+ if (this._initialDelayTimeoutId != 0)
+ GLib.source_remove(this._initialDelayTimeoutId);
+ if (this._noModsTimeoutId != 0)
+ GLib.source_remove(this._noModsTimeoutId);
+
+ // Make sure the SwitcherList is always destroyed, it may not be
+ // a child of the actor at this point.
+ if (this._switcherList)
+ this._switcherList.destroy();
+ }
+
+ _select(num) {
+ this._selectedIndex = num;
+ this._switcherList.highlight(num);
+ }
+});
+
+var SwitcherButton = GObject.registerClass(
+class SwitcherButton extends St.Button {
+ _init(square) {
+ super._init({ style_class: 'item-box',
+ reactive: true });
+
+ this._square = square;
+ }
+
+ vfunc_get_preferred_width(forHeight) {
+ if (this._square)
+ return this.get_preferred_height(-1);
+ else
+ return super.vfunc_get_preferred_width(forHeight);
+ }
+});
+
+var SwitcherList = GObject.registerClass({
+ Signals: { 'item-activated': { param_types: [GObject.TYPE_INT] },
+ 'item-entered': { param_types: [GObject.TYPE_INT] },
+ 'item-removed': { param_types: [GObject.TYPE_INT] } },
+}, class SwitcherList extends St.Widget {
+ _init(squareItems) {
+ super._init({ style_class: 'switcher-list' });
+
+ this._list = new St.BoxLayout({ style_class: 'switcher-list-item-container',
+ vertical: false,
+ x_expand: true,
+ y_expand: true });
+
+ let layoutManager = this._list.get_layout_manager();
+
+ this._list.spacing = 0;
+ this._list.connect('style-changed', () => {
+ this._list.spacing = this._list.get_theme_node().get_length('spacing');
+ });
+
+ this._scrollView = new St.ScrollView({ style_class: 'hfade',
+ enable_mouse_scrolling: false });
+ this._scrollView.set_policy(St.PolicyType.NEVER, St.PolicyType.NEVER);
+
+ this._scrollView.add_actor(this._list);
+ this.add_actor(this._scrollView);
+
+ // Those arrows indicate whether scrolling in one direction is possible
+ this._leftArrow = new St.DrawingArea({ style_class: 'switcher-arrow',
+ pseudo_class: 'highlighted' });
+ this._leftArrow.connect('repaint', () => {
+ drawArrow(this._leftArrow, St.Side.LEFT);
+ });
+ this._rightArrow = new St.DrawingArea({ style_class: 'switcher-arrow',
+ pseudo_class: 'highlighted' });
+ this._rightArrow.connect('repaint', () => {
+ drawArrow(this._rightArrow, St.Side.RIGHT);
+ });
+
+ this.add_actor(this._leftArrow);
+ this.add_actor(this._rightArrow);
+
+ this._items = [];
+ this._highlighted = -1;
+ this._squareItems = squareItems;
+ this._scrollableRight = true;
+ this._scrollableLeft = false;
+
+ layoutManager.homogeneous = squareItems;
+ }
+
+ addItem(item, label) {
+ let bbox = new SwitcherButton(this._squareItems);
+
+ bbox.set_child(item);
+ this._list.add_actor(bbox);
+
+ bbox.connect('clicked', () => this._onItemClicked(bbox));
+ bbox.connect('motion-event', () => this._onItemEnter(bbox));
+
+ bbox.label_actor = label;
+
+ this._items.push(bbox);
+
+ return bbox;
+ }
+
+ removeItem(index) {
+ let item = this._items.splice(index, 1);
+ item[0].destroy();
+ this.emit('item-removed', index);
+ }
+
+ addAccessibleState(index, state) {
+ this._items[index].add_accessible_state(state);
+ }
+
+ removeAccessibleState(index, state) {
+ this._items[index].remove_accessible_state(state);
+ }
+
+ _onItemClicked(item) {
+ this._itemActivated(this._items.indexOf(item));
+ }
+
+ _onItemEnter(item) {
+ // Avoid reentrancy
+ if (item !== this._items[this._highlighted])
+ this._itemEntered(this._items.indexOf(item));
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ highlight(index, justOutline) {
+ if (this._items[this._highlighted]) {
+ this._items[this._highlighted].remove_style_pseudo_class('outlined');
+ this._items[this._highlighted].remove_style_pseudo_class('selected');
+ }
+
+ if (this._items[index]) {
+ if (justOutline)
+ this._items[index].add_style_pseudo_class('outlined');
+ else
+ this._items[index].add_style_pseudo_class('selected');
+ }
+
+ this._highlighted = index;
+
+ let adjustment = this._scrollView.hscroll.adjustment;
+ let [value] = adjustment.get_values();
+ let [absItemX] = this._items[index].get_transformed_position();
+ let [result_, posX, posY_] = this.transform_stage_point(absItemX, 0);
+ let [containerWidth] = this.get_transformed_size();
+ if (posX + this._items[index].get_width() > containerWidth)
+ this._scrollToRight(index);
+ else if (this._items[index].allocation.x1 - value < 0)
+ this._scrollToLeft(index);
+
+ }
+
+ _scrollToLeft(index) {
+ let adjustment = this._scrollView.hscroll.adjustment;
+ let [value, lower_, upper, stepIncrement_, pageIncrement_, pageSize] = adjustment.get_values();
+
+ let item = this._items[index];
+
+ if (item.allocation.x1 < value)
+ value = Math.max(0, item.allocation.x1);
+ else if (item.allocation.x2 > value + pageSize)
+ value = Math.min(upper, item.allocation.x2 - pageSize);
+
+ this._scrollableRight = true;
+ adjustment.ease(value, {
+ progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: POPUP_SCROLL_TIME,
+ onComplete: () => {
+ if (index === 0)
+ this._scrollableLeft = false;
+ this.queue_relayout();
+ },
+ });
+ }
+
+ _scrollToRight(index) {
+ let adjustment = this._scrollView.hscroll.adjustment;
+ let [value, lower_, upper, stepIncrement_, pageIncrement_, pageSize] = adjustment.get_values();
+
+ let item = this._items[index];
+
+ if (item.allocation.x1 < value)
+ value = Math.max(0, item.allocation.x1);
+ else if (item.allocation.x2 > value + pageSize)
+ value = Math.min(upper, item.allocation.x2 - pageSize);
+
+ this._scrollableLeft = true;
+ adjustment.ease(value, {
+ progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: POPUP_SCROLL_TIME,
+ onComplete: () => {
+ if (index === this._items.length - 1)
+ this._scrollableRight = false;
+ this.queue_relayout();
+ },
+ });
+ }
+
+ _itemActivated(n) {
+ this.emit('item-activated', n);
+ }
+
+ _itemEntered(n) {
+ this.emit('item-entered', n);
+ }
+
+ _maxChildWidth(forHeight) {
+ let maxChildMin = 0;
+ let maxChildNat = 0;
+
+ for (let i = 0; i < this._items.length; i++) {
+ let [childMin, childNat] = this._items[i].get_preferred_width(forHeight);
+ maxChildMin = Math.max(childMin, maxChildMin);
+ maxChildNat = Math.max(childNat, maxChildNat);
+
+ if (this._squareItems) {
+ [childMin, childNat] = this._items[i].get_preferred_height(-1);
+ maxChildMin = Math.max(childMin, maxChildMin);
+ maxChildNat = Math.max(childNat, maxChildNat);
+ }
+ }
+
+ return [maxChildMin, maxChildNat];
+ }
+
+ vfunc_get_preferred_width(forHeight) {
+ let themeNode = this.get_theme_node();
+ let [maxChildMin] = this._maxChildWidth(forHeight);
+ let [minListWidth] = this._list.get_preferred_width(forHeight);
+
+ return themeNode.adjust_preferred_width(maxChildMin, minListWidth);
+ }
+
+ vfunc_get_preferred_height(_forWidth) {
+ let maxChildMin = 0;
+ let maxChildNat = 0;
+
+ for (let i = 0; i < this._items.length; i++) {
+ let [childMin, childNat] = this._items[i].get_preferred_height(-1);
+ maxChildMin = Math.max(childMin, maxChildMin);
+ maxChildNat = Math.max(childNat, maxChildNat);
+ }
+
+ if (this._squareItems) {
+ let [childMin] = this._maxChildWidth(-1);
+ maxChildMin = Math.max(childMin, maxChildMin);
+ maxChildNat = maxChildMin;
+ }
+
+ let themeNode = this.get_theme_node();
+ return themeNode.adjust_preferred_height(maxChildMin, maxChildNat);
+ }
+
+ vfunc_allocate(box) {
+ this.set_allocation(box);
+
+ let contentBox = this.get_theme_node().get_content_box(box);
+ let width = contentBox.x2 - contentBox.x1;
+ let height = contentBox.y2 - contentBox.y1;
+
+ let leftPadding = this.get_theme_node().get_padding(St.Side.LEFT);
+ let rightPadding = this.get_theme_node().get_padding(St.Side.RIGHT);
+
+ let [minListWidth] = this._list.get_preferred_width(height);
+
+ let childBox = new Clutter.ActorBox();
+ let scrollable = minListWidth > width;
+
+ this._scrollView.allocate(contentBox);
+
+ let arrowWidth = Math.floor(leftPadding / 3);
+ let arrowHeight = arrowWidth * 2;
+ childBox.x1 = leftPadding / 2;
+ childBox.y1 = this.height / 2 - arrowWidth;
+ childBox.x2 = childBox.x1 + arrowWidth;
+ childBox.y2 = childBox.y1 + arrowHeight;
+ this._leftArrow.allocate(childBox);
+ this._leftArrow.opacity = this._scrollableLeft && scrollable ? 255 : 0;
+
+ arrowWidth = Math.floor(rightPadding / 3);
+ arrowHeight = arrowWidth * 2;
+ childBox.x1 = this.width - arrowWidth - rightPadding / 2;
+ childBox.y1 = this.height / 2 - arrowWidth;
+ childBox.x2 = childBox.x1 + arrowWidth;
+ childBox.y2 = childBox.y1 + arrowHeight;
+ this._rightArrow.allocate(childBox);
+ this._rightArrow.opacity = this._scrollableRight && scrollable ? 255 : 0;
+ }
+});
+
+function drawArrow(area, side) {
+ let themeNode = area.get_theme_node();
+ let borderColor = themeNode.get_border_color(side);
+ let bodyColor = themeNode.get_foreground_color();
+
+ let [width, height] = area.get_surface_size();
+ let cr = area.get_context();
+
+ cr.setLineWidth(1.0);
+ Clutter.cairo_set_source_color(cr, borderColor);
+
+ switch (side) {
+ case St.Side.TOP:
+ cr.moveTo(0, height);
+ cr.lineTo(Math.floor(width * 0.5), 0);
+ cr.lineTo(width, height);
+ break;
+
+ case St.Side.BOTTOM:
+ cr.moveTo(width, 0);
+ cr.lineTo(Math.floor(width * 0.5), height);
+ cr.lineTo(0, 0);
+ break;
+
+ case St.Side.LEFT:
+ cr.moveTo(width, height);
+ cr.lineTo(0, Math.floor(height * 0.5));
+ cr.lineTo(width, 0);
+ break;
+
+ case St.Side.RIGHT:
+ cr.moveTo(0, 0);
+ cr.lineTo(width, Math.floor(height * 0.5));
+ cr.lineTo(0, height);
+ break;
+ }
+
+ cr.strokePreserve();
+
+ Clutter.cairo_set_source_color(cr, bodyColor);
+ cr.fill();
+ cr.$dispose();
+}
+
diff --git a/js/ui/unlockDialog.js b/js/ui/unlockDialog.js
new file mode 100644
index 0000000..4f461f3
--- /dev/null
+++ b/js/ui/unlockDialog.js
@@ -0,0 +1,901 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported UnlockDialog */
+
+const { AccountsService, Atk, Clutter, Gdm, Gio,
+ GnomeDesktop, GLib, GObject, Meta, Shell, St } = imports.gi;
+
+const Background = imports.ui.background;
+const Layout = imports.ui.layout;
+const Main = imports.ui.main;
+const MessageTray = imports.ui.messageTray;
+const SwipeTracker = imports.ui.swipeTracker;
+
+const AuthPrompt = imports.gdm.authPrompt;
+
+// The timeout before going back automatically to the lock screen (in seconds)
+const IDLE_TIMEOUT = 2 * 60;
+
+// The timeout before showing the unlock hint (in seconds)
+const HINT_TIMEOUT = 4;
+
+const CROSSFADE_TIME = 300;
+const FADE_OUT_TRANSLATION = 200;
+const FADE_OUT_SCALE = 0.3;
+
+const BLUR_BRIGHTNESS = 0.55;
+const BLUR_SIGMA = 60;
+
+const SUMMARY_ICON_SIZE = 32;
+
+var NotificationsBox = GObject.registerClass({
+ Signals: { 'wake-up-screen': {} },
+}, class NotificationsBox extends St.BoxLayout {
+ _init() {
+ super._init({
+ vertical: true,
+ name: 'unlockDialogNotifications',
+ style_class: 'unlock-dialog-notifications-container',
+ });
+
+ this._scrollView = new St.ScrollView({ hscrollbar_policy: St.PolicyType.NEVER });
+ this._notificationBox = new St.BoxLayout({
+ vertical: true,
+ style_class: 'unlock-dialog-notifications-container',
+ });
+ this._scrollView.add_actor(this._notificationBox);
+
+ this.add_child(this._scrollView);
+
+ this._sources = new Map();
+ Main.messageTray.getSources().forEach(source => {
+ this._sourceAdded(Main.messageTray, source, true);
+ });
+ this._updateVisibility();
+
+ this._sourceAddedId = Main.messageTray.connect('source-added', this._sourceAdded.bind(this));
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ _onDestroy() {
+ if (this._sourceAddedId) {
+ Main.messageTray.disconnect(this._sourceAddedId);
+ this._sourceAddedId = 0;
+ }
+
+ let items = this._sources.entries();
+ for (let [source, obj] of items)
+ this._removeSource(source, obj);
+ }
+
+ _updateVisibility() {
+ this._notificationBox.visible =
+ this._notificationBox.get_children().some(a => a.visible);
+
+ this.visible = this._notificationBox.visible;
+ }
+
+ _makeNotificationSource(source, box) {
+ let sourceActor = new MessageTray.SourceActor(source, SUMMARY_ICON_SIZE);
+ box.add_child(sourceActor);
+
+ let textBox = new St.BoxLayout({
+ x_expand: true,
+ y_expand: true,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ box.add_child(textBox);
+
+ let title = new St.Label({
+ text: source.title,
+ style_class: 'unlock-dialog-notification-label',
+ x_expand: true,
+ x_align: Clutter.ActorAlign.START,
+ });
+ textBox.add(title);
+
+ let count = source.unseenCount;
+ let countLabel = new St.Label({
+ text: count.toString(),
+ visible: count > 1,
+ style_class: 'unlock-dialog-notification-count-text',
+ });
+ textBox.add(countLabel);
+
+ box.visible = count !== 0;
+ return [title, countLabel];
+ }
+
+ _makeNotificationDetailedSource(source, box) {
+ let sourceActor = new MessageTray.SourceActor(source, SUMMARY_ICON_SIZE);
+ let sourceBin = new St.Bin({ child: sourceActor });
+ box.add(sourceBin);
+
+ let textBox = new St.BoxLayout({ vertical: true });
+ box.add_child(textBox);
+
+ let title = new St.Label({
+ text: source.title.replace(/\n/g, ' '),
+ style_class: 'unlock-dialog-notification-label',
+ });
+ textBox.add(title);
+
+ let visible = false;
+ for (let i = 0; i < source.notifications.length; i++) {
+ let n = source.notifications[i];
+
+ if (n.acknowledged)
+ continue;
+
+ let body = '';
+ if (n.bannerBodyText) {
+ const bodyText = n.bannerBodyText.replace(/\n/g, ' ');
+ body = n.bannerBodyMarkup
+ ? bodyText
+ : GLib.markup_escape_text(bodyText, -1);
+ }
+
+ let label = new St.Label({ style_class: 'unlock-dialog-notification-count-text' });
+ label.clutter_text.set_markup('<b>%s</b> %s'.format(n.title, body));
+ textBox.add(label);
+
+ visible = true;
+ }
+
+ box.visible = visible;
+ return [title, null];
+ }
+
+ _shouldShowDetails(source) {
+ return source.policy.detailsInLockScreen ||
+ source.narrowestPrivacyScope === MessageTray.PrivacyScope.SYSTEM;
+ }
+
+ _updateSourceBoxStyle(source, obj, box) {
+ let hasCriticalNotification =
+ source.notifications.some(n => n.urgency === MessageTray.Urgency.CRITICAL);
+
+ if (hasCriticalNotification !== obj.hasCriticalNotification) {
+ obj.hasCriticalNotification = hasCriticalNotification;
+
+ if (hasCriticalNotification)
+ box.add_style_class_name('critical');
+ else
+ box.remove_style_class_name('critical');
+ }
+ }
+
+ _showSource(source, obj, box) {
+ if (obj.detailed)
+ [obj.titleLabel, obj.countLabel] = this._makeNotificationDetailedSource(source, box);
+ else
+ [obj.titleLabel, obj.countLabel] = this._makeNotificationSource(source, box);
+
+ box.visible = obj.visible && (source.unseenCount > 0);
+
+ this._updateSourceBoxStyle(source, obj, box);
+ }
+
+ _sourceAdded(tray, source, initial) {
+ let obj = {
+ visible: source.policy.showInLockScreen,
+ detailed: this._shouldShowDetails(source),
+ sourceDestroyId: 0,
+ sourceCountChangedId: 0,
+ sourceTitleChangedId: 0,
+ sourceUpdatedId: 0,
+ sourceBox: null,
+ titleLabel: null,
+ countLabel: null,
+ hasCriticalNotification: false,
+ };
+
+ obj.sourceBox = new St.BoxLayout({
+ style_class: 'unlock-dialog-notification-source',
+ x_expand: true,
+ });
+ this._showSource(source, obj, obj.sourceBox);
+ this._notificationBox.add_child(obj.sourceBox);
+
+ obj.sourceCountChangedId = source.connect('notify::count', () => {
+ this._countChanged(source, obj);
+ });
+ obj.sourceTitleChangedId = source.connect('notify::title', () => {
+ this._titleChanged(source, obj);
+ });
+ obj.policyChangedId = source.policy.connect('notify', (policy, pspec) => {
+ if (pspec.name === 'show-in-lock-screen')
+ this._visibleChanged(source, obj);
+ else
+ this._detailedChanged(source, obj);
+ });
+ obj.sourceDestroyId = source.connect('destroy', () => {
+ this._onSourceDestroy(source, obj);
+ });
+
+ this._sources.set(source, obj);
+
+ if (!initial) {
+ // block scrollbars while animating, if they're not needed now
+ let boxHeight = this._notificationBox.height;
+ if (this._scrollView.height >= boxHeight)
+ this._scrollView.vscrollbar_policy = St.PolicyType.NEVER;
+
+ let widget = obj.sourceBox;
+ let [, natHeight] = widget.get_preferred_height(-1);
+ widget.height = 0;
+ widget.ease({
+ height: natHeight,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: 250,
+ onComplete: () => {
+ this._scrollView.vscrollbar_policy = St.PolicyType.AUTOMATIC;
+ widget.set_height(-1);
+ },
+ });
+
+ this._updateVisibility();
+ if (obj.sourceBox.visible)
+ this.emit('wake-up-screen');
+ }
+ }
+
+ _titleChanged(source, obj) {
+ obj.titleLabel.text = source.title;
+ }
+
+ _countChanged(source, obj) {
+ // A change in the number of notifications may change whether we show
+ // details.
+ let newDetailed = this._shouldShowDetails(source);
+ let oldDetailed = obj.detailed;
+
+ obj.detailed = newDetailed;
+
+ if (obj.detailed || oldDetailed !== newDetailed) {
+ // A new notification was pushed, or a previous notification was destroyed.
+ // Give up, and build the list again.
+
+ obj.sourceBox.destroy_all_children();
+ obj.titleLabel = obj.countLabel = null;
+ this._showSource(source, obj, obj.sourceBox);
+ } else {
+ let count = source.unseenCount;
+ obj.countLabel.text = count.toString();
+ obj.countLabel.visible = count > 1;
+ }
+
+ obj.sourceBox.visible = obj.visible && (source.unseenCount > 0);
+
+ this._updateVisibility();
+ if (obj.sourceBox.visible)
+ this.emit('wake-up-screen');
+ }
+
+ _visibleChanged(source, obj) {
+ if (obj.visible === source.policy.showInLockScreen)
+ return;
+
+ obj.visible = source.policy.showInLockScreen;
+ obj.sourceBox.visible = obj.visible && source.unseenCount > 0;
+
+ this._updateVisibility();
+ if (obj.sourceBox.visible)
+ this.emit('wake-up-screen');
+ }
+
+ _detailedChanged(source, obj) {
+ let newDetailed = this._shouldShowDetails(source);
+ if (obj.detailed === newDetailed)
+ return;
+
+ obj.detailed = newDetailed;
+
+ obj.sourceBox.destroy_all_children();
+ obj.titleLabel = obj.countLabel = null;
+ this._showSource(source, obj, obj.sourceBox);
+ }
+
+ _onSourceDestroy(source, obj) {
+ this._removeSource(source, obj);
+ this._updateVisibility();
+ }
+
+ _removeSource(source, obj) {
+ obj.sourceBox.destroy();
+ obj.sourceBox = obj.titleLabel = obj.countLabel = null;
+
+ source.disconnect(obj.sourceDestroyId);
+ source.disconnect(obj.sourceCountChangedId);
+ source.disconnect(obj.sourceTitleChangedId);
+ source.policy.disconnect(obj.policyChangedId);
+
+ this._sources.delete(source);
+ }
+});
+
+var Clock = GObject.registerClass(
+class UnlockDialogClock extends St.BoxLayout {
+ _init() {
+ super._init({ style_class: 'unlock-dialog-clock', vertical: true });
+
+ this._time = new St.Label({
+ style_class: 'unlock-dialog-clock-time',
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+ this._date = new St.Label({
+ style_class: 'unlock-dialog-clock-date',
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+ this._hint = new St.Label({
+ style_class: 'unlock-dialog-clock-hint',
+ x_align: Clutter.ActorAlign.CENTER,
+ opacity: 0,
+ });
+
+ this.add_child(this._time);
+ this.add_child(this._date);
+ this.add_child(this._hint);
+
+ this._wallClock = new GnomeDesktop.WallClock({ time_only: true });
+ this._wallClock.connect('notify::clock', this._updateClock.bind(this));
+
+ this._seat = Clutter.get_default_backend().get_default_seat();
+ this._touchModeChangedId = this._seat.connect('notify::touch-mode',
+ this._updateHint.bind(this));
+
+ this._monitorManager = Meta.MonitorManager.get();
+ this._powerModeChangedId = this._monitorManager.connect(
+ 'power-save-mode-changed', () => (this._hint.opacity = 0));
+
+ this._idleMonitor = Meta.IdleMonitor.get_core();
+ this._idleWatchId = this._idleMonitor.add_idle_watch(HINT_TIMEOUT * 1000, () => {
+ this._hint.ease({
+ opacity: 255,
+ duration: CROSSFADE_TIME,
+ });
+ });
+
+ this._updateClock();
+ this._updateHint();
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ _updateClock() {
+ this._time.text = this._wallClock.clock;
+
+ let date = new Date();
+ /* Translators: This is a time format for a date in
+ long format */
+ let dateFormat = Shell.util_translate_time_string(N_('%A %B %-d'));
+ this._date.text = date.toLocaleFormat(dateFormat);
+ }
+
+ _updateHint() {
+ this._hint.text = this._seat.touch_mode
+ ? _('Swipe up to unlock')
+ : _('Click or press a key to unlock');
+ }
+
+ _onDestroy() {
+ this._wallClock.run_dispose();
+
+ this._seat.disconnect(this._touchModeChangedId);
+ this._idleMonitor.remove_watch(this._idleWatchId);
+ this._monitorManager.disconnect(this._powerModeChangedId);
+ }
+});
+
+var UnlockDialogLayout = GObject.registerClass(
+class UnlockDialogLayout extends Clutter.LayoutManager {
+ _init(stack, notifications, switchUserButton) {
+ super._init();
+
+ this._stack = stack;
+ this._notifications = notifications;
+ this._switchUserButton = switchUserButton;
+ }
+
+ vfunc_get_preferred_width(container, forHeight) {
+ return this._stack.get_preferred_width(forHeight);
+ }
+
+ vfunc_get_preferred_height(container, forWidth) {
+ return this._stack.get_preferred_height(forWidth);
+ }
+
+ vfunc_allocate(container, box) {
+ let [width, height] = box.get_size();
+
+ let tenthOfHeight = height / 10.0;
+ let thirdOfHeight = height / 3.0;
+
+ let [, , stackWidth, stackHeight] =
+ this._stack.get_preferred_size();
+
+ let [, , notificationsWidth, notificationsHeight] =
+ this._notifications.get_preferred_size();
+
+ let columnWidth = Math.max(stackWidth, notificationsWidth);
+
+ let columnX1 = Math.floor((width - columnWidth) / 2.0);
+ let actorBox = new Clutter.ActorBox();
+
+ // Notifications
+ let maxNotificationsHeight = Math.min(
+ notificationsHeight,
+ height - tenthOfHeight - stackHeight);
+
+ actorBox.x1 = columnX1;
+ actorBox.y1 = height - maxNotificationsHeight;
+ actorBox.x2 = columnX1 + columnWidth;
+ actorBox.y2 = actorBox.y1 + maxNotificationsHeight;
+
+ this._notifications.allocate(actorBox);
+
+ // Authentication Box
+ let stackY = Math.min(
+ thirdOfHeight,
+ height - stackHeight - maxNotificationsHeight);
+
+ actorBox.x1 = columnX1;
+ actorBox.y1 = stackY;
+ actorBox.x2 = columnX1 + columnWidth;
+ actorBox.y2 = stackY + stackHeight;
+
+ this._stack.allocate(actorBox);
+
+ // Switch User button
+ if (this._switchUserButton.visible) {
+ let [, , natWidth, natHeight] =
+ this._switchUserButton.get_preferred_size();
+
+ const textDirection = this._switchUserButton.get_text_direction();
+ if (textDirection === Clutter.TextDirection.RTL)
+ actorBox.x1 = box.x1 + natWidth;
+ else
+ actorBox.x1 = box.x2 - (natWidth * 2);
+
+ actorBox.y1 = box.y2 - (natHeight * 2);
+ actorBox.x2 = actorBox.x1 + natWidth;
+ actorBox.y2 = actorBox.y1 + natHeight;
+
+ this._switchUserButton.allocate(actorBox);
+ }
+ }
+});
+
+var UnlockDialog = GObject.registerClass({
+ Signals: {
+ 'failed': {},
+ 'wake-up-screen': {},
+ },
+}, class UnlockDialog extends St.Widget {
+ _init(parentActor) {
+ super._init({
+ accessible_role: Atk.Role.WINDOW,
+ style_class: 'login-dialog',
+ visible: false,
+ reactive: true,
+ });
+
+ parentActor.add_child(this);
+
+ this._gdmClient = new Gdm.Client();
+
+ this._adjustment = new St.Adjustment({
+ actor: this,
+ lower: 0,
+ upper: 2,
+ page_size: 1,
+ page_increment: 1,
+ });
+ this._adjustment.connect('notify::value', () => {
+ this._setTransitionProgress(this._adjustment.value);
+ });
+
+ this._swipeTracker = new SwipeTracker.SwipeTracker(
+ this, Shell.ActionMode.UNLOCK_SCREEN);
+ this._swipeTracker.connect('begin', this._swipeBegin.bind(this));
+ this._swipeTracker.connect('update', this._swipeUpdate.bind(this));
+ this._swipeTracker.connect('end', this._swipeEnd.bind(this));
+
+ this.connect('scroll-event', (o, event) => {
+ if (this._swipeTracker.canHandleScrollEvent(event))
+ return Clutter.EVENT_PROPAGATE;
+
+ let direction = event.get_scroll_direction();
+ if (direction === Clutter.ScrollDirection.UP)
+ this._showClock();
+ else if (direction === Clutter.ScrollDirection.DOWN)
+ this._showPrompt();
+ return Clutter.EVENT_STOP;
+ });
+
+ this._activePage = null;
+
+ let tapAction = new Clutter.TapAction();
+ tapAction.connect('tap', this._showPrompt.bind(this));
+ this.add_action(tapAction);
+
+ // Background
+ this._backgroundGroup = new Clutter.Actor();
+ this.add_child(this._backgroundGroup);
+
+ this._bgManagers = [];
+
+ const themeContext = St.ThemeContext.get_for_stage(global.stage);
+ this._scaleChangedId = themeContext.connect('notify::scale-factor',
+ () => this._updateBackgroundEffects());
+
+ this._updateBackgrounds();
+ this._monitorsChangedId =
+ Main.layoutManager.connect('monitors-changed', this._updateBackgrounds.bind(this));
+
+ this._userManager = AccountsService.UserManager.get_default();
+ this._userName = GLib.get_user_name();
+ this._user = this._userManager.get_user(this._userName);
+
+ // Authentication & Clock stack
+ this._stack = new Shell.Stack();
+
+ this._promptBox = new St.BoxLayout({ vertical: true });
+ this._promptBox.set_pivot_point(0.5, 0.5);
+ this._promptBox.hide();
+ this._stack.add_child(this._promptBox);
+
+ this._clock = new Clock();
+ this._clock.set_pivot_point(0.5, 0.5);
+ this._stack.add_child(this._clock);
+ this._showClock();
+
+ this.allowCancel = false;
+
+ Main.ctrlAltTabManager.addGroup(this, _('Unlock Window'), 'dialog-password-symbolic');
+
+ // Notifications
+ this._notificationsBox = new NotificationsBox();
+ this._notificationsBox.connect('wake-up-screen', () => this.emit('wake-up-screen'));
+
+ // Switch User button
+ this._otherUserButton = new St.Button({
+ style_class: 'modal-dialog-button button switch-user-button',
+ accessible_name: _('Log in as another user'),
+ reactive: false,
+ opacity: 0,
+ x_align: Clutter.ActorAlign.END,
+ y_align: Clutter.ActorAlign.END,
+ child: new St.Icon({ icon_name: 'system-users-symbolic' }),
+ });
+ this._otherUserButton.set_pivot_point(0.5, 0.5);
+ this._otherUserButton.connect('clicked', this._otherUserClicked.bind(this));
+
+ this._screenSaverSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.screensaver' });
+
+ this._userSwitchEnabledId = this._screenSaverSettings.connect('changed::user-switch-enabled',
+ this._updateUserSwitchVisibility.bind(this));
+
+ this._userLoadedId = this._user.connect('notify::is-loaded',
+ this._updateUserSwitchVisibility.bind(this));
+
+ this._updateUserSwitchVisibility();
+
+ // Main Box
+ let mainBox = new St.Widget();
+ mainBox.add_constraint(new Layout.MonitorConstraint({ primary: true }));
+ mainBox.add_child(this._stack);
+ mainBox.add_child(this._notificationsBox);
+ mainBox.add_child(this._otherUserButton);
+ mainBox.layout_manager = new UnlockDialogLayout(
+ this._stack,
+ this._notificationsBox,
+ this._otherUserButton);
+ this.add_child(mainBox);
+
+ this._idleMonitor = Meta.IdleMonitor.get_core();
+ this._idleWatchId = this._idleMonitor.add_idle_watch(IDLE_TIMEOUT * 1000, this._escape.bind(this));
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ vfunc_key_press_event(keyEvent) {
+ if (this._activePage === this._promptBox ||
+ (this._promptBox && this._promptBox.visible))
+ return Clutter.EVENT_PROPAGATE;
+
+ const { keyval } = keyEvent;
+ if (keyval === Clutter.KEY_Shift_L ||
+ keyval === Clutter.KEY_Shift_R ||
+ keyval === Clutter.KEY_Shift_Lock ||
+ keyval === Clutter.KEY_Caps_Lock)
+ return Clutter.EVENT_PROPAGATE;
+
+ let unichar = keyEvent.unicode_value;
+
+ this._showPrompt();
+
+ if (GLib.unichar_isgraph(unichar))
+ this._authPrompt.addCharacter(unichar);
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _createBackground(monitorIndex) {
+ let monitor = Main.layoutManager.monitors[monitorIndex];
+ let widget = new St.Widget({
+ style_class: 'screen-shield-background',
+ x: monitor.x,
+ y: monitor.y,
+ width: monitor.width,
+ height: monitor.height,
+ effect: new Shell.BlurEffect({ name: 'blur' }),
+ });
+
+ let bgManager = new Background.BackgroundManager({
+ container: widget,
+ monitorIndex,
+ controlPosition: false,
+ });
+
+ this._bgManagers.push(bgManager);
+
+ this._backgroundGroup.add_child(widget);
+ }
+
+ _updateBackgroundEffects() {
+ const themeContext = St.ThemeContext.get_for_stage(global.stage);
+
+ for (const widget of this._backgroundGroup) {
+ const effect = widget.get_effect('blur');
+
+ if (effect) {
+ effect.set({
+ brightness: BLUR_BRIGHTNESS,
+ sigma: BLUR_SIGMA * themeContext.scale_factor,
+ });
+ }
+ }
+ }
+
+ _updateBackgrounds() {
+ for (let i = 0; i < this._bgManagers.length; i++)
+ this._bgManagers[i].destroy();
+
+ this._bgManagers = [];
+ this._backgroundGroup.destroy_all_children();
+
+ for (let i = 0; i < Main.layoutManager.monitors.length; i++)
+ this._createBackground(i);
+ this._updateBackgroundEffects();
+ }
+
+ _ensureAuthPrompt() {
+ if (this._authPrompt)
+ return;
+
+ this._authPrompt = new AuthPrompt.AuthPrompt(this._gdmClient,
+ AuthPrompt.AuthPromptMode.UNLOCK_ONLY);
+ this._authPrompt.connect('failed', this._fail.bind(this));
+ this._authPrompt.connect('cancelled', this._fail.bind(this));
+ this._authPrompt.connect('reset', this._onReset.bind(this));
+
+ this._promptBox.add_child(this._authPrompt);
+
+ this._authPrompt.reset();
+ this._authPrompt.updateSensitivity(true);
+ }
+
+ _maybeDestroyAuthPrompt() {
+ let focus = global.stage.key_focus;
+ if (focus === null ||
+ (this._authPrompt && this._authPrompt.contains(focus)) ||
+ (this._otherUserButton && focus === this._otherUserButton))
+ this.grab_key_focus();
+
+ if (this._authPrompt) {
+ this._authPrompt.destroy();
+ this._authPrompt = null;
+ }
+ }
+
+ _showClock() {
+ if (this._activePage === this._clock)
+ return;
+
+ this._activePage = this._clock;
+
+ this._adjustment.ease(0, {
+ duration: CROSSFADE_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this._maybeDestroyAuthPrompt(),
+ });
+ }
+
+ _showPrompt() {
+ this._ensureAuthPrompt();
+
+ if (this._activePage === this._promptBox)
+ return;
+
+ this._activePage = this._promptBox;
+
+ this._adjustment.ease(1, {
+ duration: CROSSFADE_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ _setTransitionProgress(progress) {
+ this._promptBox.visible = progress > 0;
+ this._clock.visible = progress < 1;
+
+ this._otherUserButton.set({
+ reactive: progress > 0,
+ can_focus: progress > 0,
+ });
+
+ const { scaleFactor } = St.ThemeContext.get_for_stage(global.stage);
+
+ this._promptBox.set({
+ opacity: 255 * progress,
+ scale_x: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * progress,
+ scale_y: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * progress,
+ translation_y: FADE_OUT_TRANSLATION * (1 - progress) * scaleFactor,
+ });
+
+ this._clock.set({
+ opacity: 255 * (1 - progress),
+ scale_x: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * (1 - progress),
+ scale_y: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * (1 - progress),
+ translation_y: -FADE_OUT_TRANSLATION * progress * scaleFactor,
+ });
+
+ this._otherUserButton.set({
+ opacity: 255 * progress,
+ scale_x: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * progress,
+ scale_y: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * progress,
+ });
+ }
+
+ _fail() {
+ this._showClock();
+ this.emit('failed');
+ }
+
+ _onReset(authPrompt, beginRequest) {
+ let userName;
+ if (beginRequest == AuthPrompt.BeginRequestType.PROVIDE_USERNAME) {
+ this._authPrompt.setUser(this._user);
+ userName = this._userName;
+ } else {
+ userName = null;
+ }
+
+ this._authPrompt.begin({ userName });
+ }
+
+ _escape() {
+ if (this._authPrompt && this.allowCancel)
+ this._authPrompt.cancel();
+ }
+
+ _swipeBegin(tracker, monitor) {
+ if (monitor !== Main.layoutManager.primaryIndex)
+ return;
+
+ this._adjustment.remove_transition('value');
+
+ this._ensureAuthPrompt();
+
+ let progress = this._adjustment.value;
+ tracker.confirmSwipe(this._stack.height,
+ [0, 1],
+ progress,
+ Math.round(progress));
+ }
+
+ _swipeUpdate(tracker, progress) {
+ this._adjustment.value = progress;
+ }
+
+ _swipeEnd(tracker, duration, endProgress) {
+ this._activePage = endProgress
+ ? this._promptBox
+ : this._clock;
+
+ this._adjustment.ease(endProgress, {
+ mode: Clutter.AnimationMode.EASE_OUT_CUBIC,
+ duration,
+ onComplete: () => {
+ if (this._activePage === this._clock)
+ this._maybeDestroyAuthPrompt();
+ },
+ });
+ }
+
+ _otherUserClicked() {
+ Gdm.goto_login_session_sync(null);
+
+ this._authPrompt.cancel();
+ }
+
+ _onDestroy() {
+ this.popModal();
+
+ if (this._idleWatchId) {
+ this._idleMonitor.remove_watch(this._idleWatchId);
+ this._idleWatchId = 0;
+ }
+
+ if (this._monitorsChangedId) {
+ Main.layoutManager.disconnect(this._monitorsChangedId);
+ delete this._monitorsChangedId;
+ }
+
+ let themeContext = St.ThemeContext.get_for_stage(global.stage);
+ if (this._scaleChangedId) {
+ themeContext.disconnect(this._scaleChangedId);
+ delete this._scaleChangedId;
+ }
+
+ if (this._gdmClient) {
+ this._gdmClient = null;
+ delete this._gdmClient;
+ }
+
+ if (this._userLoadedId) {
+ this._user.disconnect(this._userLoadedId);
+ this._userLoadedId = 0;
+ }
+
+ if (this._userSwitchEnabledId) {
+ this._screenSaverSettings.disconnect(this._userSwitchEnabledId);
+ this._userSwitchEnabledId = 0;
+ }
+ }
+
+ _updateUserSwitchVisibility() {
+ this._otherUserButton.visible = this._userManager.can_switch() &&
+ this._screenSaverSettings.get_boolean('user-switch-enabled');
+ }
+
+ cancel() {
+ if (this._authPrompt)
+ this._authPrompt.cancel();
+ }
+
+ finish(onComplete) {
+ this._ensureAuthPrompt();
+ this._authPrompt.finish(onComplete);
+ }
+
+ open(timestamp) {
+ this.show();
+
+ if (this._isModal)
+ return true;
+
+ let modalParams = {
+ timestamp,
+ actionMode: Shell.ActionMode.UNLOCK_SCREEN,
+ };
+ if (!Main.pushModal(this, modalParams))
+ return false;
+
+ this._isModal = true;
+
+ return true;
+ }
+
+ activate() {
+ this._showPrompt();
+ }
+
+ popModal(timestamp) {
+ if (this._isModal) {
+ Main.popModal(this, timestamp);
+ this._isModal = false;
+ }
+ }
+});
diff --git a/js/ui/userWidget.js b/js/ui/userWidget.js
new file mode 100644
index 0000000..5540ea6
--- /dev/null
+++ b/js/ui/userWidget.js
@@ -0,0 +1,250 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+//
+// A widget showing the user avatar and name
+/* exported UserWidget */
+
+const { Clutter, GLib, GObject, St } = imports.gi;
+
+const Params = imports.misc.params;
+
+var AVATAR_ICON_SIZE = 64;
+
+// Adapted from gdm/gui/user-switch-applet/applet.c
+//
+// Copyright (C) 2004-2005 James M. Cape <jcape@ignore-your.tv>.
+// Copyright (C) 2008,2009 Red Hat, Inc.
+
+var Avatar = GObject.registerClass(
+class Avatar extends St.Bin {
+ _init(user, params) {
+ let themeContext = St.ThemeContext.get_for_stage(global.stage);
+ params = Params.parse(params, {
+ styleClass: 'user-icon',
+ reactive: false,
+ iconSize: AVATAR_ICON_SIZE,
+ });
+
+ super._init({
+ style_class: params.styleClass,
+ reactive: params.reactive,
+ width: params.iconSize * themeContext.scaleFactor,
+ height: params.iconSize * themeContext.scaleFactor,
+ });
+
+ this._iconSize = params.iconSize;
+ this._user = user;
+
+ this.bind_property('reactive', this, 'track-hover',
+ GObject.BindingFlags.SYNC_CREATE);
+ this.bind_property('reactive', this, 'can-focus',
+ GObject.BindingFlags.SYNC_CREATE);
+
+ // Monitor the scaling factor to make sure we recreate the avatar when needed.
+ this._scaleFactorChangeId =
+ themeContext.connect('notify::scale-factor', this.update.bind(this));
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ vfunc_style_changed() {
+ super.vfunc_style_changed();
+
+ let node = this.get_theme_node();
+ let [found, iconSize] = node.lookup_length('icon-size', false);
+
+ if (!found)
+ return;
+
+ let themeContext = St.ThemeContext.get_for_stage(global.stage);
+
+ // node.lookup_length() returns a scaled value, but we
+ // need unscaled
+ this._iconSize = iconSize / themeContext.scaleFactor;
+ this.update();
+ }
+
+ _onDestroy() {
+ if (this._scaleFactorChangeId) {
+ let themeContext = St.ThemeContext.get_for_stage(global.stage);
+ themeContext.disconnect(this._scaleFactorChangeId);
+ delete this._scaleFactorChangeId;
+ }
+ }
+
+ setSensitive(sensitive) {
+ this.reactive = sensitive;
+ }
+
+ update() {
+ let iconFile = null;
+ if (this._user) {
+ iconFile = this._user.get_icon_file();
+ if (iconFile && !GLib.file_test(iconFile, GLib.FileTest.EXISTS))
+ iconFile = null;
+ }
+
+ let { scaleFactor } = St.ThemeContext.get_for_stage(global.stage);
+ this.set_size(
+ this._iconSize * scaleFactor,
+ this._iconSize * scaleFactor);
+
+ if (iconFile) {
+ this.child = null;
+ this.style = `
+ background-image: url("${iconFile}");
+ background-size: cover;`;
+ } else {
+ this.style = null;
+ this.child = new St.Icon({
+ icon_name: 'avatar-default-symbolic',
+ icon_size: this._iconSize,
+ });
+ }
+ }
+});
+
+var UserWidgetLabel = GObject.registerClass(
+class UserWidgetLabel extends St.Widget {
+ _init(user) {
+ super._init({ layout_manager: new Clutter.BinLayout() });
+
+ this._user = user;
+
+ this._realNameLabel = new St.Label({ style_class: 'user-widget-label',
+ y_align: Clutter.ActorAlign.CENTER });
+ this.add_child(this._realNameLabel);
+
+ this._userNameLabel = new St.Label({ style_class: 'user-widget-label',
+ y_align: Clutter.ActorAlign.CENTER });
+ this.add_child(this._userNameLabel);
+
+ this._currentLabel = null;
+
+ this._userLoadedId = this._user.connect('notify::is-loaded', this._updateUser.bind(this));
+ this._userChangedId = this._user.connect('changed', this._updateUser.bind(this));
+ this._updateUser();
+
+ // We can't override the destroy vfunc because that might be called during
+ // object finalization, and we can't call any JS inside a GC finalize callback,
+ // so we use a signal, that will be disconnected by GObject the first time
+ // the actor is destroyed (which is guaranteed to be as part of a normal
+ // destroy() call from JS, possibly from some ancestor)
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ _onDestroy() {
+ if (this._userLoadedId != 0) {
+ this._user.disconnect(this._userLoadedId);
+ this._userLoadedId = 0;
+ }
+
+ if (this._userChangedId != 0) {
+ this._user.disconnect(this._userChangedId);
+ this._userChangedId = 0;
+ }
+ }
+
+ vfunc_allocate(box) {
+ this.set_allocation(box);
+
+ let availWidth = box.x2 - box.x1;
+ let availHeight = box.y2 - box.y1;
+
+ let [, , natRealNameWidth] = this._realNameLabel.get_preferred_size();
+
+ let childBox = new Clutter.ActorBox();
+
+ let hiddenLabel;
+ if (natRealNameWidth <= availWidth) {
+ this._currentLabel = this._realNameLabel;
+ hiddenLabel = this._userNameLabel;
+ } else {
+ this._currentLabel = this._userNameLabel;
+ hiddenLabel = this._realNameLabel;
+ }
+ this.label_actor = this._currentLabel;
+
+ hiddenLabel.allocate(childBox);
+
+ childBox.set_size(availWidth, availHeight);
+
+ this._currentLabel.allocate(childBox);
+ }
+
+ vfunc_paint(paintContext) {
+ this._currentLabel.paint(paintContext);
+ }
+
+ _updateUser() {
+ if (this._user.is_loaded) {
+ this._realNameLabel.text = this._user.get_real_name();
+ this._userNameLabel.text = this._user.get_user_name();
+ } else {
+ this._realNameLabel.text = '';
+ this._userNameLabel.text = '';
+ }
+ }
+});
+
+var UserWidget = GObject.registerClass(
+class UserWidget extends St.BoxLayout {
+ _init(user, orientation = Clutter.Orientation.HORIZONTAL) {
+ // If user is null, that implies a username-based login authorization.
+ this._user = user;
+
+ let vertical = orientation == Clutter.Orientation.VERTICAL;
+ let xAlign = vertical ? Clutter.ActorAlign.CENTER : Clutter.ActorAlign.START;
+ let styleClass = vertical ? 'user-widget vertical' : 'user-widget horizontal';
+
+ super._init({
+ styleClass,
+ vertical,
+ xAlign,
+ });
+
+ this.connect('destroy', this._onDestroy.bind(this));
+
+ this._avatar = new Avatar(user);
+ this._avatar.x_align = Clutter.ActorAlign.CENTER;
+ this.add_child(this._avatar);
+
+ this._userLoadedId = 0;
+ this._userChangedId = 0;
+ if (user) {
+ this._label = new UserWidgetLabel(user);
+ this.add_child(this._label);
+
+ this._label.bind_property('label-actor', this, 'label-actor',
+ GObject.BindingFlags.SYNC_CREATE);
+
+ this._userLoadedId = this._user.connect('notify::is-loaded', this._updateUser.bind(this));
+ this._userChangedId = this._user.connect('changed', this._updateUser.bind(this));
+ } else {
+ this._label = new St.Label({
+ style_class: 'user-widget-label',
+ text: 'Empty User',
+ opacity: 0,
+ });
+ this.add_child(this._label);
+
+ }
+
+ this._updateUser();
+ }
+
+ _onDestroy() {
+ if (this._userLoadedId != 0) {
+ this._user.disconnect(this._userLoadedId);
+ this._userLoadedId = 0;
+ }
+
+ if (this._userChangedId != 0) {
+ this._user.disconnect(this._userChangedId);
+ this._userChangedId = 0;
+ }
+ }
+
+ _updateUser() {
+ this._avatar.update();
+ }
+});
diff --git a/js/ui/viewSelector.js b/js/ui/viewSelector.js
new file mode 100644
index 0000000..bfb02a5
--- /dev/null
+++ b/js/ui/viewSelector.js
@@ -0,0 +1,609 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported ViewSelector */
+
+const { Clutter, Gio, GObject, Meta, Shell, St } = imports.gi;
+const Signals = imports.signals;
+
+const AppDisplay = imports.ui.appDisplay;
+const Main = imports.ui.main;
+const OverviewControls = imports.ui.overviewControls;
+const Params = imports.misc.params;
+const Search = imports.ui.search;
+const ShellEntry = imports.ui.shellEntry;
+const WorkspacesView = imports.ui.workspacesView;
+const EdgeDragAction = imports.ui.edgeDragAction;
+const IconGrid = imports.ui.iconGrid;
+
+const SHELL_KEYBINDINGS_SCHEMA = 'org.gnome.shell.keybindings';
+var PINCH_GESTURE_THRESHOLD = 0.7;
+
+var ViewPage = {
+ WINDOWS: 1,
+ APPS: 2,
+ SEARCH: 3,
+};
+
+var FocusTrap = GObject.registerClass(
+class FocusTrap extends St.Widget {
+ vfunc_navigate_focus(from, direction) {
+ if (direction == St.DirectionType.TAB_FORWARD ||
+ direction == St.DirectionType.TAB_BACKWARD)
+ return super.vfunc_navigate_focus(from, direction);
+ return false;
+ }
+});
+
+function getTermsForSearchString(searchString) {
+ searchString = searchString.replace(/^\s+/g, '').replace(/\s+$/g, '');
+ if (searchString == '')
+ return [];
+
+ let terms = searchString.split(/\s+/);
+ return terms;
+}
+
+var TouchpadShowOverviewAction = class {
+ constructor(actor) {
+ actor.connect('captured-event::touchpad', this._handleEvent.bind(this));
+ }
+
+ _handleEvent(actor, event) {
+ if (event.type() != Clutter.EventType.TOUCHPAD_PINCH)
+ return Clutter.EVENT_PROPAGATE;
+
+ if (event.get_touchpad_gesture_finger_count() != 3)
+ return Clutter.EVENT_PROPAGATE;
+
+ if (event.get_gesture_phase() == Clutter.TouchpadGesturePhase.END)
+ this.emit('activated', event.get_gesture_pinch_scale());
+
+ return Clutter.EVENT_STOP;
+ }
+};
+Signals.addSignalMethods(TouchpadShowOverviewAction.prototype);
+
+var ShowOverviewAction = GObject.registerClass({
+ Signals: { 'activated': { param_types: [GObject.TYPE_DOUBLE] } },
+}, class ShowOverviewAction extends Clutter.GestureAction {
+ _init() {
+ super._init();
+ this.set_n_touch_points(3);
+
+ global.display.connect('grab-op-begin', () => {
+ this.cancel();
+ });
+ }
+
+ vfunc_gesture_prepare(_actor) {
+ return Main.actionMode == Shell.ActionMode.NORMAL &&
+ this.get_n_current_points() == this.get_n_touch_points();
+ }
+
+ _getBoundingRect(motion) {
+ let minX, minY, maxX, maxY;
+
+ for (let i = 0; i < this.get_n_current_points(); i++) {
+ let x, y;
+
+ if (motion == true)
+ [x, y] = this.get_motion_coords(i);
+ else
+ [x, y] = this.get_press_coords(i);
+
+ if (i == 0) {
+ minX = maxX = x;
+ minY = maxY = y;
+ } else {
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x);
+ maxY = Math.max(maxY, y);
+ }
+ }
+
+ return new Meta.Rectangle({ x: minX,
+ y: minY,
+ width: maxX - minX,
+ height: maxY - minY });
+ }
+
+ vfunc_gesture_begin(_actor) {
+ this._initialRect = this._getBoundingRect(false);
+ return true;
+ }
+
+ vfunc_gesture_end(_actor) {
+ let rect = this._getBoundingRect(true);
+ let oldArea = this._initialRect.width * this._initialRect.height;
+ let newArea = rect.width * rect.height;
+ let areaDiff = newArea / oldArea;
+
+ this.emit('activated', areaDiff);
+ }
+});
+
+var ViewSelector = GObject.registerClass({
+ Signals: {
+ 'page-changed': {},
+ 'page-empty': {},
+ },
+}, class ViewSelector extends Shell.Stack {
+ _init(searchEntry, workspaceAdjustment, showAppsButton) {
+ super._init({
+ name: 'viewSelector',
+ x_expand: true,
+ visible: false,
+ });
+
+ this._showAppsButton = showAppsButton;
+ this._showAppsButton.connect('notify::checked', this._onShowAppsButtonToggled.bind(this));
+
+ this._activePage = null;
+
+ this._searchActive = false;
+
+ this._entry = searchEntry;
+ ShellEntry.addContextMenu(this._entry);
+
+ this._text = this._entry.clutter_text;
+ this._text.connect('text-changed', this._onTextChanged.bind(this));
+ this._text.connect('key-press-event', this._onKeyPress.bind(this));
+ this._text.connect('key-focus-in', () => {
+ this._searchResults.highlightDefault(true);
+ });
+ this._text.connect('key-focus-out', () => {
+ this._searchResults.highlightDefault(false);
+ });
+ this._entry.connect('popup-menu', () => {
+ if (!this._searchActive)
+ return;
+
+ this._entry.menu.close();
+ this._searchResults.popupMenuDefault();
+ });
+ this._entry.connect('notify::mapped', this._onMapped.bind(this));
+ global.stage.connect('notify::key-focus', this._onStageKeyFocusChanged.bind(this));
+
+ this._entry.set_primary_icon(new St.Icon({ style_class: 'search-entry-icon',
+ icon_name: 'edit-find-symbolic' }));
+ this._clearIcon = new St.Icon({ style_class: 'search-entry-icon',
+ icon_name: 'edit-clear-symbolic' });
+
+ this._iconClickedId = 0;
+ this._capturedEventId = 0;
+
+ this._workspacesDisplay =
+ new WorkspacesView.WorkspacesDisplay(workspaceAdjustment);
+ this._workspacesPage = this._addPage(this._workspacesDisplay,
+ _("Windows"), 'focus-windows-symbolic');
+
+ this.appDisplay = new AppDisplay.AppDisplay();
+ this._appsPage = this._addPage(this.appDisplay,
+ _("Applications"), 'view-app-grid-symbolic');
+
+ this._searchResults = new Search.SearchResultsView();
+ this._searchPage = this._addPage(this._searchResults,
+ _("Search"), 'edit-find-symbolic',
+ { a11yFocus: this._entry });
+
+ // Since the entry isn't inside the results container we install this
+ // dummy widget as the last results container child so that we can
+ // include the entry in the keynav tab path
+ this._focusTrap = new FocusTrap({ can_focus: true });
+ this._focusTrap.connect('key-focus-in', () => {
+ this._entry.grab_key_focus();
+ });
+ this._searchResults.add_actor(this._focusTrap);
+
+ global.focus_manager.add_group(this._searchResults);
+
+ this._stageKeyPressId = 0;
+ Main.overview.connect('showing', () => {
+ this._stageKeyPressId = global.stage.connect('key-press-event',
+ this._onStageKeyPress.bind(this));
+ });
+ Main.overview.connect('hiding', () => {
+ if (this._stageKeyPressId != 0) {
+ global.stage.disconnect(this._stageKeyPressId);
+ this._stageKeyPressId = 0;
+ }
+ });
+ Main.overview.connect('shown', () => {
+ // If we were animating from the desktop view to the
+ // apps page the workspace page was visible, allowing
+ // the windows to animate, but now we no longer want to
+ // show it given that we are now on the apps page or
+ // search page.
+ if (this._activePage != this._workspacesPage) {
+ this._workspacesPage.opacity = 0;
+ this._workspacesPage.hide();
+ }
+ });
+
+ Main.wm.addKeybinding('toggle-application-view',
+ new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._toggleAppsPage.bind(this));
+
+ Main.wm.addKeybinding('toggle-overview',
+ new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ Main.overview.toggle.bind(Main.overview));
+
+ let side;
+ if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL)
+ side = St.Side.RIGHT;
+ else
+ side = St.Side.LEFT;
+ let gesture = new EdgeDragAction.EdgeDragAction(side,
+ Shell.ActionMode.NORMAL);
+ gesture.connect('activated', () => {
+ if (Main.overview.visible)
+ Main.overview.hide();
+ else
+ this.showApps();
+ });
+ global.stage.add_action(gesture);
+
+ gesture = new ShowOverviewAction();
+ gesture.connect('activated', this._pinchGestureActivated.bind(this));
+ global.stage.add_action(gesture);
+
+ gesture = new TouchpadShowOverviewAction(global.stage);
+ gesture.connect('activated', this._pinchGestureActivated.bind(this));
+ }
+
+ _pinchGestureActivated(action, scale) {
+ if (scale < PINCH_GESTURE_THRESHOLD)
+ Main.overview.show();
+ }
+
+ _toggleAppsPage() {
+ this._showAppsButton.checked = !this._showAppsButton.checked;
+ Main.overview.show();
+ }
+
+ showApps() {
+ this._showAppsButton.checked = true;
+ Main.overview.show();
+ }
+
+ animateToOverview() {
+ this.show();
+ this.reset();
+ this._workspacesDisplay.animateToOverview(this._showAppsButton.checked);
+ this._activePage = null;
+ if (this._showAppsButton.checked)
+ this._showPage(this._appsPage);
+ else
+ this._showPage(this._workspacesPage);
+
+ if (!this._workspacesDisplay.activeWorkspaceHasMaximizedWindows())
+ Main.overview.fadeOutDesktop();
+ }
+
+ animateFromOverview() {
+ // Make sure workspace page is fully visible to allow
+ // workspace.js do the animation of the windows
+ this._workspacesPage.opacity = 255;
+
+ this._workspacesDisplay.animateFromOverview(this._activePage != this._workspacesPage);
+
+ this._showAppsButton.checked = false;
+
+ if (!this._workspacesDisplay.activeWorkspaceHasMaximizedWindows())
+ Main.overview.fadeInDesktop();
+ }
+
+ vfunc_hide() {
+ this.reset();
+ this._workspacesDisplay.hide();
+
+ super.vfunc_hide();
+ }
+
+ _addPage(actor, name, a11yIcon, params) {
+ params = Params.parse(params, { a11yFocus: null });
+
+ let page = new St.Bin({ child: actor });
+
+ if (params.a11yFocus) {
+ Main.ctrlAltTabManager.addGroup(params.a11yFocus, name, a11yIcon);
+ } else {
+ Main.ctrlAltTabManager.addGroup(actor, name, a11yIcon, {
+ proxy: this,
+ focusCallback: () => this._a11yFocusPage(page),
+ });
+ }
+ page.hide();
+ this.add_actor(page);
+ return page;
+ }
+
+ _fadePageIn() {
+ this._activePage.ease({
+ opacity: 255,
+ duration: OverviewControls.SIDE_CONTROLS_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ _fadePageOut(page) {
+ let oldPage = page;
+ page.ease({
+ opacity: 0,
+ duration: OverviewControls.SIDE_CONTROLS_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onStopped: () => this._animateIn(oldPage),
+ });
+ }
+
+ _animateIn(oldPage) {
+ if (oldPage)
+ oldPage.hide();
+
+ this.emit('page-empty');
+
+ this._activePage.show();
+
+ if (this._activePage == this._appsPage && oldPage == this._workspacesPage) {
+ // Restore opacity, in case we animated via _fadePageOut
+ this._activePage.opacity = 255;
+ this.appDisplay.animate(IconGrid.AnimationDirection.IN);
+ } else {
+ this._fadePageIn();
+ }
+ }
+
+ _animateOut(page) {
+ let oldPage = page;
+ if (page == this._appsPage &&
+ this._activePage == this._workspacesPage &&
+ !Main.overview.animationInProgress) {
+ this.appDisplay.animate(IconGrid.AnimationDirection.OUT, () => {
+ this._animateIn(oldPage);
+ });
+ } else {
+ this._fadePageOut(page);
+ }
+ }
+
+ _showPage(page) {
+ if (!Main.overview.visible)
+ return;
+
+ if (page == this._activePage)
+ return;
+
+ let oldPage = this._activePage;
+ this._activePage = page;
+ this.emit('page-changed');
+
+ if (oldPage)
+ this._animateOut(oldPage);
+ else
+ this._animateIn();
+ }
+
+ _a11yFocusPage(page) {
+ this._showAppsButton.checked = page == this._appsPage;
+ page.navigate_focus(null, St.DirectionType.TAB_FORWARD, false);
+ }
+
+ _onShowAppsButtonToggled() {
+ this._showPage(this._showAppsButton.checked
+ ? this._appsPage : this._workspacesPage);
+ }
+
+ _onStageKeyPress(actor, event) {
+ // Ignore events while anything but the overview has
+ // pushed a modal (system modals, looking glass, ...)
+ if (Main.modalCount > 1)
+ return Clutter.EVENT_PROPAGATE;
+
+ let symbol = event.get_key_symbol();
+
+ if (symbol === Clutter.KEY_Escape) {
+ if (this._searchActive)
+ this.reset();
+ else if (this._showAppsButton.checked)
+ this._showAppsButton.checked = false;
+ else
+ Main.overview.hide();
+ return Clutter.EVENT_STOP;
+ } else if (this._shouldTriggerSearch(symbol)) {
+ this.startSearch(event);
+ } else if (!this._searchActive && !global.stage.key_focus) {
+ if (symbol === Clutter.KEY_Tab || symbol === Clutter.KEY_Down) {
+ this._activePage.navigate_focus(null, St.DirectionType.TAB_FORWARD, false);
+ return Clutter.EVENT_STOP;
+ } else if (symbol === Clutter.KEY_ISO_Left_Tab) {
+ this._activePage.navigate_focus(null, St.DirectionType.TAB_BACKWARD, false);
+ return Clutter.EVENT_STOP;
+ }
+ }
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _searchCancelled() {
+ this._showPage(this._showAppsButton.checked
+ ? this._appsPage
+ : this._workspacesPage);
+
+ // Leave the entry focused when it doesn't have any text;
+ // when replacing a selected search term, Clutter emits
+ // two 'text-changed' signals, one for deleting the previous
+ // text and one for the new one - the second one is handled
+ // incorrectly when we remove focus
+ // (https://bugzilla.gnome.org/show_bug.cgi?id=636341) */
+ if (this._text.text != '')
+ this.reset();
+ }
+
+ reset() {
+ // Don't drop the key focus on Clutter's side if anything but the
+ // overview has pushed a modal (e.g. system modals when activated using
+ // the overview).
+ if (Main.modalCount <= 1)
+ global.stage.set_key_focus(null);
+
+ this._entry.text = '';
+
+ this._text.set_cursor_visible(true);
+ this._text.set_selection(0, 0);
+ }
+
+ _onStageKeyFocusChanged() {
+ let focus = global.stage.get_key_focus();
+ let appearFocused = this._entry.contains(focus) ||
+ this._searchResults.contains(focus);
+
+ this._text.set_cursor_visible(appearFocused);
+
+ if (appearFocused)
+ this._entry.add_style_pseudo_class('focus');
+ else
+ this._entry.remove_style_pseudo_class('focus');
+ }
+
+ _onMapped() {
+ if (this._entry.mapped) {
+ // Enable 'find-as-you-type'
+ this._capturedEventId = global.stage.connect('captured-event',
+ this._onCapturedEvent.bind(this));
+ this._text.set_cursor_visible(true);
+ this._text.set_selection(0, 0);
+ } else {
+ // Disable 'find-as-you-type'
+ if (this._capturedEventId > 0)
+ global.stage.disconnect(this._capturedEventId);
+ this._capturedEventId = 0;
+ }
+ }
+
+ _shouldTriggerSearch(symbol) {
+ if (symbol === Clutter.KEY_Multi_key)
+ return true;
+
+ if (symbol === Clutter.KEY_BackSpace && this._searchActive)
+ return true;
+
+ let unicode = Clutter.keysym_to_unicode(symbol);
+ if (unicode == 0)
+ return false;
+
+ if (getTermsForSearchString(String.fromCharCode(unicode)).length > 0)
+ return true;
+
+ return false;
+ }
+
+ startSearch(event) {
+ global.stage.set_key_focus(this._text);
+
+ let synthEvent = event.copy();
+ synthEvent.set_source(this._text);
+ this._text.event(synthEvent, false);
+ }
+
+ // the entry does not show the hint
+ _isActivated() {
+ return this._text.text == this._entry.get_text();
+ }
+
+ _onTextChanged() {
+ let terms = getTermsForSearchString(this._entry.get_text());
+
+ this._searchActive = terms.length > 0;
+ this._searchResults.setTerms(terms);
+
+ if (this._searchActive) {
+ this._showPage(this._searchPage);
+
+ this._entry.set_secondary_icon(this._clearIcon);
+
+ if (this._iconClickedId == 0) {
+ this._iconClickedId = this._entry.connect('secondary-icon-clicked',
+ this.reset.bind(this));
+ }
+ } else {
+ if (this._iconClickedId > 0) {
+ this._entry.disconnect(this._iconClickedId);
+ this._iconClickedId = 0;
+ }
+
+ this._entry.set_secondary_icon(null);
+ this._searchCancelled();
+ }
+ }
+
+ _onKeyPress(entry, event) {
+ let symbol = event.get_key_symbol();
+ if (symbol === Clutter.KEY_Escape) {
+ if (this._isActivated()) {
+ this.reset();
+ return Clutter.EVENT_STOP;
+ }
+ } else if (this._searchActive) {
+ let arrowNext, nextDirection;
+ if (entry.get_text_direction() == Clutter.TextDirection.RTL) {
+ arrowNext = Clutter.KEY_Left;
+ nextDirection = St.DirectionType.LEFT;
+ } else {
+ arrowNext = Clutter.KEY_Right;
+ nextDirection = St.DirectionType.RIGHT;
+ }
+
+ if (symbol === Clutter.KEY_Tab) {
+ this._searchResults.navigateFocus(St.DirectionType.TAB_FORWARD);
+ return Clutter.EVENT_STOP;
+ } else if (symbol === Clutter.KEY_ISO_Left_Tab) {
+ this._focusTrap.can_focus = false;
+ this._searchResults.navigateFocus(St.DirectionType.TAB_BACKWARD);
+ this._focusTrap.can_focus = true;
+ return Clutter.EVENT_STOP;
+ } else if (symbol === Clutter.KEY_Down) {
+ this._searchResults.navigateFocus(St.DirectionType.DOWN);
+ return Clutter.EVENT_STOP;
+ } else if (symbol == arrowNext && this._text.position == -1) {
+ this._searchResults.navigateFocus(nextDirection);
+ return Clutter.EVENT_STOP;
+ } else if (symbol === Clutter.KEY_Return || symbol === Clutter.KEY_KP_Enter) {
+ this._searchResults.activateDefault();
+ return Clutter.EVENT_STOP;
+ }
+ }
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _onCapturedEvent(actor, event) {
+ if (event.type() == Clutter.EventType.BUTTON_PRESS) {
+ let source = event.get_source();
+ if (source != this._text &&
+ this._text.has_key_focus() &&
+ this._text.text == '' &&
+ !this._text.has_preedit() &&
+ !Main.layoutManager.keyboardBox.contains(source)) {
+ // the user clicked outside after activating the entry, but
+ // with no search term entered and no keyboard button pressed
+ // - cancel the search
+ this.reset();
+ }
+ }
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ getActivePage() {
+ if (this._activePage == this._workspacesPage)
+ return ViewPage.WINDOWS;
+ else if (this._activePage == this._appsPage)
+ return ViewPage.APPS;
+ else
+ return ViewPage.SEARCH;
+ }
+});
diff --git a/js/ui/windowAttentionHandler.js b/js/ui/windowAttentionHandler.js
new file mode 100644
index 0000000..346fad8
--- /dev/null
+++ b/js/ui/windowAttentionHandler.js
@@ -0,0 +1,106 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported WindowAttentionHandler */
+
+const { GObject, Shell } = imports.gi;
+
+const Main = imports.ui.main;
+const MessageTray = imports.ui.messageTray;
+
+var WindowAttentionHandler = class {
+ constructor() {
+ this._tracker = Shell.WindowTracker.get_default();
+ this._windowDemandsAttentionId = global.display.connect('window-demands-attention',
+ this._onWindowDemandsAttention.bind(this));
+ this._windowMarkedUrgentId = global.display.connect('window-marked-urgent',
+ this._onWindowDemandsAttention.bind(this));
+ }
+
+ _getTitleAndBanner(app, window) {
+ let title = app.get_name();
+ let banner = _("“%s” is ready").format(window.get_title());
+ return [title, banner];
+ }
+
+ _onWindowDemandsAttention(display, window) {
+ // We don't want to show the notification when the window is already focused,
+ // because this is rather pointless.
+ // Some apps (like GIMP) do things like setting the urgency hint on the
+ // toolbar windows which would result into a notification even though GIMP itself is
+ // focused.
+ // We are just ignoring the hint on skip_taskbar windows for now.
+ // (Which is the same behaviour as with metacity + panel)
+
+ if (!window || window.has_focus() || window.is_skip_taskbar())
+ return;
+
+ let app = this._tracker.get_window_app(window);
+ let source = new WindowAttentionSource(app, window);
+ Main.messageTray.add(source);
+
+ let [title, banner] = this._getTitleAndBanner(app, window);
+
+ let notification = new MessageTray.Notification(source, title, banner);
+ notification.connect('activated', () => {
+ source.open();
+ });
+ notification.setForFeedback(true);
+
+ source.showNotification(notification);
+
+ source.signalIDs.push(window.connect('notify::title', () => {
+ [title, banner] = this._getTitleAndBanner(app, window);
+ notification.update(title, banner);
+ }));
+ }
+};
+
+var WindowAttentionSource = GObject.registerClass(
+class WindowAttentionSource extends MessageTray.Source {
+ _init(app, window) {
+ this._window = window;
+ this._app = app;
+
+ super._init(app.get_name());
+
+ this.signalIDs = [];
+ this.signalIDs.push(this._window.connect('notify::demands-attention',
+ this._sync.bind(this)));
+ this.signalIDs.push(this._window.connect('notify::urgent',
+ this._sync.bind(this)));
+ this.signalIDs.push(this._window.connect('focus',
+ () => this.destroy()));
+ this.signalIDs.push(this._window.connect('unmanaged',
+ () => this.destroy()));
+ }
+
+ _sync() {
+ if (this._window.demands_attention || this._window.urgent)
+ return;
+ this.destroy();
+ }
+
+ _createPolicy() {
+ if (this._app && this._app.get_app_info()) {
+ let id = this._app.get_id().replace(/\.desktop$/, '');
+ return new MessageTray.NotificationApplicationPolicy(id);
+ } else {
+ return new MessageTray.NotificationGenericPolicy();
+ }
+ }
+
+ createIcon(size) {
+ return this._app.create_icon_texture(size);
+ }
+
+ destroy(params) {
+ for (let i = 0; i < this.signalIDs.length; i++)
+ this._window.disconnect(this.signalIDs[i]);
+ this.signalIDs = [];
+
+ super.destroy(params);
+ }
+
+ open() {
+ Main.activateWindow(this._window);
+ }
+});
diff --git a/js/ui/windowManager.js b/js/ui/windowManager.js
new file mode 100644
index 0000000..a5f371c
--- /dev/null
+++ b/js/ui/windowManager.js
@@ -0,0 +1,2254 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported WindowManager */
+
+const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi;
+
+const AltTab = imports.ui.altTab;
+const AppFavorites = imports.ui.appFavorites;
+const Dialog = imports.ui.dialog;
+const WorkspaceSwitcherPopup = imports.ui.workspaceSwitcherPopup;
+const InhibitShortcutsDialog = imports.ui.inhibitShortcutsDialog;
+const Main = imports.ui.main;
+const ModalDialog = imports.ui.modalDialog;
+const WindowMenu = imports.ui.windowMenu;
+const PadOsd = imports.ui.padOsd;
+const EdgeDragAction = imports.ui.edgeDragAction;
+const CloseDialog = imports.ui.closeDialog;
+const SwipeTracker = imports.ui.swipeTracker;
+const SwitchMonitor = imports.ui.switchMonitor;
+const IBusManager = imports.misc.ibusManager;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+var SHELL_KEYBINDINGS_SCHEMA = 'org.gnome.shell.keybindings';
+var MINIMIZE_WINDOW_ANIMATION_TIME = 200;
+var SHOW_WINDOW_ANIMATION_TIME = 150;
+var DIALOG_SHOW_WINDOW_ANIMATION_TIME = 100;
+var DESTROY_WINDOW_ANIMATION_TIME = 150;
+var DIALOG_DESTROY_WINDOW_ANIMATION_TIME = 100;
+var WINDOW_ANIMATION_TIME = 250;
+var DIM_BRIGHTNESS = -0.3;
+var DIM_TIME = 500;
+var UNDIM_TIME = 250;
+var APP_MOTION_THRESHOLD = 30;
+
+var ONE_SECOND = 1000; // in ms
+
+const GSD_WACOM_BUS_NAME = 'org.gnome.SettingsDaemon.Wacom';
+const GSD_WACOM_OBJECT_PATH = '/org/gnome/SettingsDaemon/Wacom';
+
+const GsdWacomIface = loadInterfaceXML('org.gnome.SettingsDaemon.Wacom');
+const GsdWacomProxy = Gio.DBusProxy.makeProxyWrapper(GsdWacomIface);
+
+const WINDOW_DIMMER_EFFECT_NAME = "gnome-shell-window-dimmer";
+
+Gio._promisify(Shell,
+ 'util_start_systemd_unit', 'util_start_systemd_unit_finish');
+Gio._promisify(Shell,
+ 'util_stop_systemd_unit', 'util_stop_systemd_unit_finish');
+
+var DisplayChangeDialog = GObject.registerClass(
+class DisplayChangeDialog extends ModalDialog.ModalDialog {
+ _init(wm) {
+ super._init();
+
+ this._wm = wm;
+
+ this._countDown = Meta.MonitorManager.get_display_configuration_timeout();
+
+ // Translators: This string should be shorter than 30 characters
+ let title = _('Keep these display settings?');
+ let description = this._formatCountDown();
+
+ this._content = new Dialog.MessageDialogContent({ title, description });
+ this.contentLayout.add_child(this._content);
+
+ /* Translators: this and the following message should be limited in length,
+ to avoid ellipsizing the labels.
+ */
+ this._cancelButton = this.addButton({ label: _("Revert Settings"),
+ action: this._onFailure.bind(this),
+ key: Clutter.KEY_Escape });
+ this._okButton = this.addButton({ label: _("Keep Changes"),
+ action: this._onSuccess.bind(this),
+ default: true });
+
+ this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, ONE_SECOND, this._tick.bind(this));
+ GLib.Source.set_name_by_id(this._timeoutId, '[gnome-shell] this._tick');
+ }
+
+ close(timestamp) {
+ if (this._timeoutId > 0) {
+ GLib.source_remove(this._timeoutId);
+ this._timeoutId = 0;
+ }
+
+ super.close(timestamp);
+ }
+
+ _formatCountDown() {
+ const fmt = ngettext(
+ 'Settings changes will revert in %d second',
+ 'Settings changes will revert in %d seconds',
+ this._countDown);
+ return fmt.format(this._countDown);
+ }
+
+ _tick() {
+ this._countDown--;
+
+ if (this._countDown == 0) {
+ /* mutter already takes care of failing at timeout */
+ this._timeoutId = 0;
+ this.close();
+ return GLib.SOURCE_REMOVE;
+ }
+
+ this._content.description = this._formatCountDown();
+ return GLib.SOURCE_CONTINUE;
+ }
+
+ _onFailure() {
+ this._wm.complete_display_change(false);
+ this.close();
+ }
+
+ _onSuccess() {
+ this._wm.complete_display_change(true);
+ this.close();
+ }
+});
+
+var WindowDimmer = GObject.registerClass(
+class WindowDimmer extends Clutter.BrightnessContrastEffect {
+ _init() {
+ super._init({
+ name: WINDOW_DIMMER_EFFECT_NAME,
+ enabled: false,
+ });
+ this._enabled = true;
+ }
+
+ _syncEnabled() {
+ let transitionName = '@effects.%s.brightness'.format(this.name);
+ let animating = this.actor.get_transition(transitionName) != null;
+ let dimmed = this.brightness.red != 127;
+ this.enabled = this._enabled && (animating || dimmed);
+ }
+
+ setEnabled(enabled) {
+ this._enabled = enabled;
+ this._syncEnabled();
+ }
+
+ setDimmed(dimmed, animate) {
+ let val = 127 * (1 + (dimmed ? 1 : 0) * DIM_BRIGHTNESS);
+ let color = Clutter.Color.new(val, val, val, 255);
+
+ let transitionName = '@effects.%s.brightness'.format(this.name);
+ this.actor.ease_property(transitionName, color, {
+ mode: Clutter.AnimationMode.LINEAR,
+ duration: (dimmed ? DIM_TIME : UNDIM_TIME) * (animate ? 1 : 0),
+ onComplete: () => this._syncEnabled(),
+ });
+
+ this._syncEnabled();
+ }
+});
+
+function getWindowDimmer(actor) {
+ let enabled = Meta.prefs_get_attach_modal_dialogs();
+ let effect = actor.get_effect(WINDOW_DIMMER_EFFECT_NAME);
+
+ if (effect) {
+ effect.setEnabled(enabled);
+ } else if (enabled) {
+ effect = new WindowDimmer();
+ actor.add_effect(effect);
+ }
+ return effect;
+}
+
+/*
+ * When the last window closed on a workspace is a dialog or splash
+ * screen, we assume that it might be an initial window shown before
+ * the main window of an application, and give the app a grace period
+ * where it can map another window before we remove the workspace.
+ */
+var LAST_WINDOW_GRACE_TIME = 1000;
+
+var WorkspaceTracker = class {
+ constructor(wm) {
+ this._wm = wm;
+
+ this._workspaces = [];
+ this._checkWorkspacesId = 0;
+
+ this._pauseWorkspaceCheck = false;
+
+ let tracker = Shell.WindowTracker.get_default();
+ tracker.connect('startup-sequence-changed', this._queueCheckWorkspaces.bind(this));
+
+ let workspaceManager = global.workspace_manager;
+ workspaceManager.connect('notify::n-workspaces',
+ this._nWorkspacesChanged.bind(this));
+ workspaceManager.connect('workspaces-reordered', () => {
+ this._workspaces.sort((a, b) => a.index() - b.index());
+ });
+ global.window_manager.connect('switch-workspace',
+ this._queueCheckWorkspaces.bind(this));
+
+ global.display.connect('window-entered-monitor',
+ this._windowEnteredMonitor.bind(this));
+ global.display.connect('window-left-monitor',
+ this._windowLeftMonitor.bind(this));
+ global.display.connect('restacked',
+ this._windowsRestacked.bind(this));
+
+ this._workspaceSettings = new Gio.Settings({ schema_id: 'org.gnome.mutter' });
+ this._workspaceSettings.connect('changed::dynamic-workspaces', this._queueCheckWorkspaces.bind(this));
+
+ this._nWorkspacesChanged();
+ }
+
+ blockUpdates() {
+ this._pauseWorkspaceCheck = true;
+ }
+
+ unblockUpdates() {
+ this._pauseWorkspaceCheck = false;
+ }
+
+ _checkWorkspaces() {
+ let workspaceManager = global.workspace_manager;
+ let i;
+ let emptyWorkspaces = [];
+
+ if (!Meta.prefs_get_dynamic_workspaces()) {
+ this._checkWorkspacesId = 0;
+ return false;
+ }
+
+ // Update workspaces only if Dynamic Workspace Management has not been paused by some other function
+ if (this._pauseWorkspaceCheck)
+ return true;
+
+ for (i = 0; i < this._workspaces.length; i++) {
+ let lastRemoved = this._workspaces[i]._lastRemovedWindow;
+ if ((lastRemoved &&
+ (lastRemoved.get_window_type() == Meta.WindowType.SPLASHSCREEN ||
+ lastRemoved.get_window_type() == Meta.WindowType.DIALOG ||
+ lastRemoved.get_window_type() == Meta.WindowType.MODAL_DIALOG)) ||
+ this._workspaces[i]._keepAliveId)
+ emptyWorkspaces[i] = false;
+ else
+ emptyWorkspaces[i] = true;
+ }
+
+ let sequences = Shell.WindowTracker.get_default().get_startup_sequences();
+ for (i = 0; i < sequences.length; i++) {
+ let index = sequences[i].get_workspace();
+ if (index >= 0 && index <= workspaceManager.n_workspaces)
+ emptyWorkspaces[index] = false;
+ }
+
+ let windows = global.get_window_actors();
+ for (i = 0; i < windows.length; i++) {
+ let actor = windows[i];
+ let win = actor.get_meta_window();
+
+ if (win.is_on_all_workspaces())
+ continue;
+
+ let workspaceIndex = win.get_workspace().index();
+ emptyWorkspaces[workspaceIndex] = false;
+ }
+
+ // If we don't have an empty workspace at the end, add one
+ if (!emptyWorkspaces[emptyWorkspaces.length - 1]) {
+ workspaceManager.append_new_workspace(false, global.get_current_time());
+ emptyWorkspaces.push(true);
+ }
+
+ let lastIndex = emptyWorkspaces.length - 1;
+ let lastEmptyIndex = emptyWorkspaces.lastIndexOf(false) + 1;
+ let activeWorkspaceIndex = workspaceManager.get_active_workspace_index();
+ emptyWorkspaces[activeWorkspaceIndex] = false;
+
+ // Delete empty workspaces except for the last one; do it from the end
+ // to avoid index changes
+ for (i = lastIndex; i >= 0; i--) {
+ if (emptyWorkspaces[i] && i != lastEmptyIndex)
+ workspaceManager.remove_workspace(this._workspaces[i], global.get_current_time());
+ }
+
+ this._checkWorkspacesId = 0;
+ return false;
+ }
+
+ keepWorkspaceAlive(workspace, duration) {
+ if (workspace._keepAliveId)
+ GLib.source_remove(workspace._keepAliveId);
+
+ workspace._keepAliveId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, duration, () => {
+ workspace._keepAliveId = 0;
+ this._queueCheckWorkspaces();
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(workspace._keepAliveId, '[gnome-shell] this._queueCheckWorkspaces');
+ }
+
+ _windowRemoved(workspace, window) {
+ workspace._lastRemovedWindow = window;
+ this._queueCheckWorkspaces();
+ let id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, LAST_WINDOW_GRACE_TIME, () => {
+ if (workspace._lastRemovedWindow == window) {
+ workspace._lastRemovedWindow = null;
+ this._queueCheckWorkspaces();
+ }
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(id, '[gnome-shell] this._queueCheckWorkspaces');
+ }
+
+ _windowLeftMonitor(metaDisplay, monitorIndex, _metaWin) {
+ // If the window left the primary monitor, that
+ // might make that workspace empty
+ if (monitorIndex == Main.layoutManager.primaryIndex)
+ this._queueCheckWorkspaces();
+ }
+
+ _windowEnteredMonitor(metaDisplay, monitorIndex, _metaWin) {
+ // If the window entered the primary monitor, that
+ // might make that workspace non-empty
+ if (monitorIndex == Main.layoutManager.primaryIndex)
+ this._queueCheckWorkspaces();
+ }
+
+ _windowsRestacked() {
+ // Figure out where the pointer is in case we lost track of
+ // it during a grab. (In particular, if a trayicon popup menu
+ // is dismissed, see if we need to close the message tray.)
+ global.sync_pointer();
+ }
+
+ _queueCheckWorkspaces() {
+ if (this._checkWorkspacesId == 0)
+ this._checkWorkspacesId = Meta.later_add(Meta.LaterType.BEFORE_REDRAW, this._checkWorkspaces.bind(this));
+ }
+
+ _nWorkspacesChanged() {
+ let workspaceManager = global.workspace_manager;
+ let oldNumWorkspaces = this._workspaces.length;
+ let newNumWorkspaces = workspaceManager.n_workspaces;
+
+ if (oldNumWorkspaces == newNumWorkspaces)
+ return false;
+
+ if (newNumWorkspaces > oldNumWorkspaces) {
+ let w;
+
+ // Assume workspaces are only added at the end
+ for (w = oldNumWorkspaces; w < newNumWorkspaces; w++)
+ this._workspaces[w] = workspaceManager.get_workspace_by_index(w);
+
+ for (w = oldNumWorkspaces; w < newNumWorkspaces; w++) {
+ let workspace = this._workspaces[w];
+ workspace._windowAddedId = workspace.connect('window-added', this._queueCheckWorkspaces.bind(this));
+ workspace._windowRemovedId = workspace.connect('window-removed', this._windowRemoved.bind(this));
+ }
+
+ } else {
+ // Assume workspaces are only removed sequentially
+ // (e.g. 2,3,4 - not 2,4,7)
+ let removedIndex;
+ let removedNum = oldNumWorkspaces - newNumWorkspaces;
+ for (let w = 0; w < oldNumWorkspaces; w++) {
+ let workspace = workspaceManager.get_workspace_by_index(w);
+ if (this._workspaces[w] != workspace) {
+ removedIndex = w;
+ break;
+ }
+ }
+
+ let lostWorkspaces = this._workspaces.splice(removedIndex, removedNum);
+ lostWorkspaces.forEach(workspace => {
+ workspace.disconnect(workspace._windowAddedId);
+ workspace.disconnect(workspace._windowRemovedId);
+ });
+ }
+
+ this._queueCheckWorkspaces();
+
+ return false;
+ }
+};
+
+var TilePreview = GObject.registerClass(
+class TilePreview extends St.Widget {
+ _init() {
+ super._init();
+ global.window_group.add_actor(this);
+
+ this._reset();
+ this._showing = false;
+ }
+
+ open(window, tileRect, monitorIndex) {
+ let windowActor = window.get_compositor_private();
+ if (!windowActor)
+ return;
+
+ global.window_group.set_child_below_sibling(this, windowActor);
+
+ if (this._rect && this._rect.equal(tileRect))
+ return;
+
+ let changeMonitor = this._monitorIndex == -1 ||
+ this._monitorIndex != monitorIndex;
+
+ this._monitorIndex = monitorIndex;
+ this._rect = tileRect;
+
+ let monitor = Main.layoutManager.monitors[monitorIndex];
+
+ this._updateStyle(monitor);
+
+ if (!this._showing || changeMonitor) {
+ let monitorRect = new Meta.Rectangle({ x: monitor.x,
+ y: monitor.y,
+ width: monitor.width,
+ height: monitor.height });
+ let [, rect] = window.get_frame_rect().intersect(monitorRect);
+ this.set_size(rect.width, rect.height);
+ this.set_position(rect.x, rect.y);
+ this.opacity = 0;
+ }
+
+ this._showing = true;
+ this.show();
+ this.ease({
+ x: tileRect.x,
+ y: tileRect.y,
+ width: tileRect.width,
+ height: tileRect.height,
+ opacity: 255,
+ duration: WINDOW_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ close() {
+ if (!this._showing)
+ return;
+
+ this._showing = false;
+ this.ease({
+ opacity: 0,
+ duration: WINDOW_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this._reset(),
+ });
+ }
+
+ _reset() {
+ this.hide();
+ this._rect = null;
+ this._monitorIndex = -1;
+ }
+
+ _updateStyle(monitor) {
+ let styles = ['tile-preview'];
+ if (this._monitorIndex == Main.layoutManager.primaryIndex)
+ styles.push('on-primary');
+ if (this._rect.x == monitor.x)
+ styles.push('tile-preview-left');
+ if (this._rect.x + this._rect.width == monitor.x + monitor.width)
+ styles.push('tile-preview-right');
+
+ this.style_class = styles.join(' ');
+ }
+});
+
+var AppSwitchAction = GObject.registerClass({
+ Signals: { 'activated': {} },
+}, class AppSwitchAction extends Clutter.GestureAction {
+ _init() {
+ super._init();
+ this.set_n_touch_points(3);
+
+ global.display.connect('grab-op-begin', () => {
+ this.cancel();
+ });
+ }
+
+ vfunc_gesture_prepare(_actor) {
+ if (Main.actionMode != Shell.ActionMode.NORMAL) {
+ this.cancel();
+ return false;
+ }
+
+ return this.get_n_current_points() <= 4;
+ }
+
+ vfunc_gesture_begin(_actor) {
+ // in milliseconds
+ const LONG_PRESS_TIMEOUT = 250;
+
+ let nPoints = this.get_n_current_points();
+ let event = this.get_last_event(nPoints - 1);
+
+ if (nPoints == 3) {
+ this._longPressStartTime = event.get_time();
+ } else if (nPoints == 4) {
+ // Check whether the 4th finger press happens after a 3-finger long press,
+ // this only needs to be checked on the first 4th finger press
+ if (this._longPressStartTime != null &&
+ event.get_time() < this._longPressStartTime + LONG_PRESS_TIMEOUT) {
+ this.cancel();
+ } else {
+ this._longPressStartTime = null;
+ this.emit('activated');
+ }
+ }
+
+ return this.get_n_current_points() <= 4;
+ }
+
+ vfunc_gesture_progress(_actor) {
+
+ if (this.get_n_current_points() == 3) {
+ for (let i = 0; i < this.get_n_current_points(); i++) {
+ let [startX, startY] = this.get_press_coords(i);
+ let [x, y] = this.get_motion_coords(i);
+
+ if (Math.abs(x - startX) > APP_MOTION_THRESHOLD ||
+ Math.abs(y - startY) > APP_MOTION_THRESHOLD)
+ return false;
+ }
+
+ }
+
+ return true;
+ }
+});
+
+var ResizePopup = GObject.registerClass(
+class ResizePopup extends St.Widget {
+ _init() {
+ super._init({ layout_manager: new Clutter.BinLayout() });
+ this._label = new St.Label({ style_class: 'resize-popup',
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER,
+ x_expand: true, y_expand: true });
+ this.add_child(this._label);
+ Main.uiGroup.add_actor(this);
+ }
+
+ set(rect, displayW, displayH) {
+ /* Translators: This represents the size of a window. The first number is
+ * the width of the window and the second is the height. */
+ let text = _("%d × %d").format(displayW, displayH);
+ this._label.set_text(text);
+
+ this.set_position(rect.x, rect.y);
+ this.set_size(rect.width, rect.height);
+ }
+});
+
+var WindowManager = class {
+ constructor() {
+ this._shellwm = global.window_manager;
+
+ this._minimizing = new Set();
+ this._unminimizing = new Set();
+ this._mapping = new Set();
+ this._resizing = new Set();
+ this._resizePending = new Set();
+ this._destroying = new Set();
+ this._movingWindow = null;
+
+ this._dimmedWindows = [];
+
+ this._skippedActors = new Set();
+
+ this._allowedKeybindings = {};
+
+ this._isWorkspacePrepended = false;
+
+ this._switchData = null;
+ this._shellwm.connect('kill-switch-workspace', shellwm => {
+ if (this._switchData) {
+ if (this._switchData.inProgress)
+ this._switchWorkspaceDone(shellwm);
+ else if (!this._switchData.gestureActivated)
+ this._finishWorkspaceSwitch(this._switchData);
+ }
+ });
+ this._shellwm.connect('kill-window-effects', (shellwm, actor) => {
+ this._minimizeWindowDone(shellwm, actor);
+ this._mapWindowDone(shellwm, actor);
+ this._destroyWindowDone(shellwm, actor);
+ this._sizeChangeWindowDone(shellwm, actor);
+ });
+
+ this._shellwm.connect('switch-workspace', this._switchWorkspace.bind(this));
+ this._shellwm.connect('show-tile-preview', this._showTilePreview.bind(this));
+ this._shellwm.connect('hide-tile-preview', this._hideTilePreview.bind(this));
+ this._shellwm.connect('show-window-menu', this._showWindowMenu.bind(this));
+ this._shellwm.connect('minimize', this._minimizeWindow.bind(this));
+ this._shellwm.connect('unminimize', this._unminimizeWindow.bind(this));
+ this._shellwm.connect('size-change', this._sizeChangeWindow.bind(this));
+ this._shellwm.connect('size-changed', this._sizeChangedWindow.bind(this));
+ this._shellwm.connect('map', this._mapWindow.bind(this));
+ this._shellwm.connect('destroy', this._destroyWindow.bind(this));
+ this._shellwm.connect('filter-keybinding', this._filterKeybinding.bind(this));
+ this._shellwm.connect('confirm-display-change', this._confirmDisplayChange.bind(this));
+ this._shellwm.connect('create-close-dialog', this._createCloseDialog.bind(this));
+ this._shellwm.connect('create-inhibit-shortcuts-dialog', this._createInhibitShortcutsDialog.bind(this));
+ global.display.connect('restacked', this._syncStacking.bind(this));
+
+ this._workspaceSwitcherPopup = null;
+ this._tilePreview = null;
+
+ this.allowKeybinding('switch-to-session-1', Shell.ActionMode.ALL);
+ this.allowKeybinding('switch-to-session-2', Shell.ActionMode.ALL);
+ this.allowKeybinding('switch-to-session-3', Shell.ActionMode.ALL);
+ this.allowKeybinding('switch-to-session-4', Shell.ActionMode.ALL);
+ this.allowKeybinding('switch-to-session-5', Shell.ActionMode.ALL);
+ this.allowKeybinding('switch-to-session-6', Shell.ActionMode.ALL);
+ this.allowKeybinding('switch-to-session-7', Shell.ActionMode.ALL);
+ this.allowKeybinding('switch-to-session-8', Shell.ActionMode.ALL);
+ this.allowKeybinding('switch-to-session-9', Shell.ActionMode.ALL);
+ this.allowKeybinding('switch-to-session-10', Shell.ActionMode.ALL);
+ this.allowKeybinding('switch-to-session-11', Shell.ActionMode.ALL);
+ this.allowKeybinding('switch-to-session-12', Shell.ActionMode.ALL);
+
+ this.setCustomKeybindingHandler('switch-to-workspace-left',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-to-workspace-right',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-to-workspace-up',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-to-workspace-down',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-to-workspace-last',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('move-to-workspace-left',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('move-to-workspace-right',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('move-to-workspace-up',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('move-to-workspace-down',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-to-workspace-1',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-to-workspace-2',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-to-workspace-3',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-to-workspace-4',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-to-workspace-5',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-to-workspace-6',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-to-workspace-7',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-to-workspace-8',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-to-workspace-9',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-to-workspace-10',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-to-workspace-11',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-to-workspace-12',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('move-to-workspace-1',
+ Shell.ActionMode.NORMAL,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('move-to-workspace-2',
+ Shell.ActionMode.NORMAL,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('move-to-workspace-3',
+ Shell.ActionMode.NORMAL,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('move-to-workspace-4',
+ Shell.ActionMode.NORMAL,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('move-to-workspace-5',
+ Shell.ActionMode.NORMAL,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('move-to-workspace-6',
+ Shell.ActionMode.NORMAL,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('move-to-workspace-7',
+ Shell.ActionMode.NORMAL,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('move-to-workspace-8',
+ Shell.ActionMode.NORMAL,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('move-to-workspace-9',
+ Shell.ActionMode.NORMAL,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('move-to-workspace-10',
+ Shell.ActionMode.NORMAL,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('move-to-workspace-11',
+ Shell.ActionMode.NORMAL,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('move-to-workspace-12',
+ Shell.ActionMode.NORMAL,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('move-to-workspace-last',
+ Shell.ActionMode.NORMAL,
+ this._showWorkspaceSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-applications',
+ Shell.ActionMode.NORMAL,
+ this._startSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-group',
+ Shell.ActionMode.NORMAL,
+ this._startSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-applications-backward',
+ Shell.ActionMode.NORMAL,
+ this._startSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-group-backward',
+ Shell.ActionMode.NORMAL,
+ this._startSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-windows',
+ Shell.ActionMode.NORMAL,
+ this._startSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-windows-backward',
+ Shell.ActionMode.NORMAL,
+ this._startSwitcher.bind(this));
+ this.setCustomKeybindingHandler('cycle-windows',
+ Shell.ActionMode.NORMAL,
+ this._startSwitcher.bind(this));
+ this.setCustomKeybindingHandler('cycle-windows-backward',
+ Shell.ActionMode.NORMAL,
+ this._startSwitcher.bind(this));
+ this.setCustomKeybindingHandler('cycle-group',
+ Shell.ActionMode.NORMAL,
+ this._startSwitcher.bind(this));
+ this.setCustomKeybindingHandler('cycle-group-backward',
+ Shell.ActionMode.NORMAL,
+ this._startSwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-panels',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW |
+ Shell.ActionMode.LOCK_SCREEN |
+ Shell.ActionMode.UNLOCK_SCREEN |
+ Shell.ActionMode.LOGIN_SCREEN,
+ this._startA11ySwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-panels-backward',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW |
+ Shell.ActionMode.LOCK_SCREEN |
+ Shell.ActionMode.UNLOCK_SCREEN |
+ Shell.ActionMode.LOGIN_SCREEN,
+ this._startA11ySwitcher.bind(this));
+ this.setCustomKeybindingHandler('switch-monitor',
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._startSwitcher.bind(this));
+
+ this.addKeybinding('open-application-menu',
+ new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.POPUP,
+ this._toggleAppMenu.bind(this));
+
+ this.addKeybinding('toggle-message-tray',
+ new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW |
+ Shell.ActionMode.POPUP,
+ this._toggleCalendar.bind(this));
+
+ this.addKeybinding('switch-to-application-1',
+ new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._switchToApplication.bind(this));
+
+ this.addKeybinding('switch-to-application-2',
+ new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._switchToApplication.bind(this));
+
+ this.addKeybinding('switch-to-application-3',
+ new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._switchToApplication.bind(this));
+
+ this.addKeybinding('switch-to-application-4',
+ new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._switchToApplication.bind(this));
+
+ this.addKeybinding('switch-to-application-5',
+ new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._switchToApplication.bind(this));
+
+ this.addKeybinding('switch-to-application-6',
+ new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._switchToApplication.bind(this));
+
+ this.addKeybinding('switch-to-application-7',
+ new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._switchToApplication.bind(this));
+
+ this.addKeybinding('switch-to-application-8',
+ new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._switchToApplication.bind(this));
+
+ this.addKeybinding('switch-to-application-9',
+ new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._switchToApplication.bind(this));
+
+ global.display.connect('show-resize-popup', this._showResizePopup.bind(this));
+ global.display.connect('show-pad-osd', this._showPadOsd.bind(this));
+ global.display.connect('show-osd', (display, monitorIndex, iconName, label) => {
+ let icon = Gio.Icon.new_for_string(iconName);
+ Main.osdWindowManager.show(monitorIndex, icon, label, null);
+ });
+
+ this._gsdWacomProxy = new GsdWacomProxy(Gio.DBus.session, GSD_WACOM_BUS_NAME,
+ GSD_WACOM_OBJECT_PATH,
+ (proxy, error) => {
+ if (error)
+ log(error.message);
+ });
+
+ global.display.connect('pad-mode-switch', (display, pad, group, mode) => {
+ let labels = [];
+
+ // FIXME: Fix num buttons
+ for (let i = 0; i < 50; i++) {
+ let str = display.get_pad_action_label(pad, Meta.PadActionType.BUTTON, i);
+ labels.push(str ? str : '');
+ }
+
+ if (this._gsdWacomProxy) {
+ this._gsdWacomProxy.SetOLEDLabelsRemote(pad.get_device_node(), labels);
+ this._gsdWacomProxy.SetGroupModeLEDRemote(pad.get_device_node(), group, mode);
+ }
+ });
+
+ global.display.connect('init-xserver', (display, task) => {
+ IBusManager.getIBusManager().restartDaemon(['--xim']);
+
+ /* Timeout waiting for start job completion after 5 seconds */
+ let cancellable = new Gio.Cancellable();
+ GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 5, () => {
+ cancellable.cancel();
+ return GLib.SOURCE_REMOVE;
+ });
+
+ this._startX11Services(task, cancellable);
+
+ return true;
+ });
+ global.display.connect('x11-display-closing', () => {
+ if (!Meta.is_wayland_compositor())
+ return;
+
+ this._stopX11Services(null);
+
+ IBusManager.getIBusManager().restartDaemon();
+ });
+
+ Main.overview.connect('showing', () => {
+ for (let i = 0; i < this._dimmedWindows.length; i++)
+ this._undimWindow(this._dimmedWindows[i]);
+
+ if (this._switchData) {
+ if (this._switchData.gestureActivated)
+ this._switchWorkspaceStop();
+ this._swipeTracker.enabled = false;
+ }
+ });
+ Main.overview.connect('hiding', () => {
+ for (let i = 0; i < this._dimmedWindows.length; i++)
+ this._dimWindow(this._dimmedWindows[i]);
+ this._swipeTracker.enabled = true;
+ });
+
+ this._windowMenuManager = new WindowMenu.WindowMenuManager();
+
+ if (Main.sessionMode.hasWorkspaces)
+ this._workspaceTracker = new WorkspaceTracker(this);
+
+ global.workspace_manager.override_workspace_layout(Meta.DisplayCorner.TOPLEFT,
+ false, -1, 1);
+
+ let swipeTracker = new SwipeTracker.SwipeTracker(global.stage,
+ Shell.ActionMode.NORMAL, { allowDrag: false, allowScroll: false });
+ swipeTracker.connect('begin', this._switchWorkspaceBegin.bind(this));
+ swipeTracker.connect('update', this._switchWorkspaceUpdate.bind(this));
+ swipeTracker.connect('end', this._switchWorkspaceEnd.bind(this));
+ this._swipeTracker = swipeTracker;
+
+ let appSwitchAction = new AppSwitchAction();
+ appSwitchAction.connect('activated', this._switchApp.bind(this));
+ global.stage.add_action(appSwitchAction);
+
+ let mode = Shell.ActionMode.ALL & ~Shell.ActionMode.LOCK_SCREEN;
+ let bottomDragAction = new EdgeDragAction.EdgeDragAction(St.Side.BOTTOM, mode);
+ bottomDragAction.connect('activated', () => {
+ Main.keyboard.open(Main.layoutManager.bottomIndex);
+ });
+ Main.layoutManager.connect('keyboard-visible-changed', (manager, visible) => {
+ bottomDragAction.cancel();
+ bottomDragAction.set_enabled(!visible);
+ });
+ global.stage.add_action(bottomDragAction);
+
+ let topDragAction = new EdgeDragAction.EdgeDragAction(St.Side.TOP, mode);
+ topDragAction.connect('activated', () => {
+ let currentWindow = global.display.focus_window;
+ if (currentWindow)
+ currentWindow.unmake_fullscreen();
+ });
+
+ let updateUnfullscreenGesture = () => {
+ let currentWindow = global.display.focus_window;
+ topDragAction.enabled = currentWindow && currentWindow.is_fullscreen();
+ };
+
+ global.display.connect('notify::focus-window', updateUnfullscreenGesture);
+ global.display.connect('in-fullscreen-changed', updateUnfullscreenGesture);
+
+ global.stage.add_action(topDragAction);
+ }
+
+ async _startX11Services(task, cancellable) {
+ try {
+ await Shell.util_start_systemd_unit(
+ 'gnome-session-x11-services-ready.target', 'fail', cancellable);
+ } catch (e) {
+ // Ignore NOT_SUPPORTED error, which indicates we are not systemd
+ // managed and gnome-session will have taken care of everything
+ // already.
+ // Note that we do log cancellation from here.
+ if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_SUPPORTED))
+ log('Error starting X11 services: %s'.format(e.message));
+ } finally {
+ task.return_boolean(true);
+ }
+ }
+
+ async _stopX11Services(cancellable) {
+ try {
+ await Shell.util_stop_systemd_unit(
+ 'gnome-session-x11-services.target', 'fail', cancellable);
+ } catch (e) {
+ // Ignore NOT_SUPPORTED error, which indicates we are not systemd
+ // managed and gnome-session will have taken care of everything
+ // already.
+ // Note that we do log cancellation from here.
+ if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_SUPPORTED))
+ log('Error stopping X11 services: %s'.format(e.message));
+ }
+ }
+
+ _showPadOsd(display, device, settings, imagePath, editionMode, monitorIndex) {
+ this._currentPadOsd = new PadOsd.PadOsd(device, settings, imagePath, editionMode, monitorIndex);
+ this._currentPadOsd.connect('closed', () => (this._currentPadOsd = null));
+
+ return this._currentPadOsd;
+ }
+
+ _lookupIndex(windows, metaWindow) {
+ for (let i = 0; i < windows.length; i++) {
+ if (windows[i].metaWindow == metaWindow)
+ return i;
+ }
+ return -1;
+ }
+
+ _switchApp() {
+ let windows = global.get_window_actors().filter(actor => {
+ let win = actor.metaWindow;
+ let workspaceManager = global.workspace_manager;
+ let activeWorkspace = workspaceManager.get_active_workspace();
+ return !win.is_override_redirect() &&
+ win.located_on_workspace(activeWorkspace);
+ });
+
+ if (windows.length == 0)
+ return;
+
+ let focusWindow = global.display.focus_window;
+ let nextWindow;
+
+ if (focusWindow == null) {
+ nextWindow = windows[0].metaWindow;
+ } else {
+ let index = this._lookupIndex(windows, focusWindow) + 1;
+
+ if (index >= windows.length)
+ index = 0;
+
+ nextWindow = windows[index].metaWindow;
+ }
+
+ Main.activateWindow(nextWindow);
+ }
+
+ insertWorkspace(pos) {
+ let workspaceManager = global.workspace_manager;
+
+ if (!Meta.prefs_get_dynamic_workspaces())
+ return;
+
+ workspaceManager.append_new_workspace(false, global.get_current_time());
+
+ let windows = global.get_window_actors().map(a => a.meta_window);
+
+ // To create a new workspace, we slide all the windows on workspaces
+ // below us to the next workspace, leaving a blank workspace for us
+ // to recycle.
+ windows.forEach(window => {
+ // If the window is attached to an ancestor, we don't need/want
+ // to move it
+ if (window.get_transient_for() != null)
+ return;
+ // Same for OR windows
+ if (window.is_override_redirect())
+ return;
+ // Sticky windows don't need moving, in fact moving would
+ // unstick them
+ if (window.on_all_workspaces)
+ return;
+ // Windows on workspaces below pos don't need moving
+ let index = window.get_workspace().index();
+ if (index < pos)
+ return;
+ window.change_workspace_by_index(index + 1, true);
+ });
+
+ // If the new workspace was inserted before the active workspace,
+ // activate the workspace to which its windows went
+ let activeIndex = workspaceManager.get_active_workspace_index();
+ if (activeIndex >= pos) {
+ let newWs = workspaceManager.get_workspace_by_index(activeIndex + 1);
+ this._blockAnimations = true;
+ newWs.activate(global.get_current_time());
+ this._blockAnimations = false;
+ }
+ }
+
+ keepWorkspaceAlive(workspace, duration) {
+ if (!this._workspaceTracker)
+ return;
+
+ this._workspaceTracker.keepWorkspaceAlive(workspace, duration);
+ }
+
+ skipNextEffect(actor) {
+ this._skippedActors.add(actor);
+ }
+
+ setCustomKeybindingHandler(name, modes, handler) {
+ if (Meta.keybindings_set_custom_handler(name, handler))
+ this.allowKeybinding(name, modes);
+ }
+
+ addKeybinding(name, settings, flags, modes, handler) {
+ let action = global.display.add_keybinding(name, settings, flags, handler);
+ if (action != Meta.KeyBindingAction.NONE)
+ this.allowKeybinding(name, modes);
+ return action;
+ }
+
+ removeKeybinding(name) {
+ if (global.display.remove_keybinding(name))
+ this.allowKeybinding(name, Shell.ActionMode.NONE);
+ }
+
+ allowKeybinding(name, modes) {
+ this._allowedKeybindings[name] = modes;
+ }
+
+ _shouldAnimate() {
+ return !(Main.overview.visible ||
+ (this._switchData && this._switchData.gestureActivated));
+ }
+
+ _shouldAnimateActor(actor, types) {
+ if (this._skippedActors.delete(actor))
+ return false;
+
+ if (!this._shouldAnimate())
+ return false;
+
+ if (!actor.get_texture())
+ return false;
+
+ let type = actor.meta_window.get_window_type();
+ return types.includes(type);
+ }
+
+ _minimizeWindow(shellwm, actor) {
+ let types = [Meta.WindowType.NORMAL,
+ Meta.WindowType.MODAL_DIALOG,
+ Meta.WindowType.DIALOG];
+ if (!this._shouldAnimateActor(actor, types)) {
+ shellwm.completed_minimize(actor);
+ return;
+ }
+
+ actor.set_scale(1.0, 1.0);
+
+ this._minimizing.add(actor);
+
+ if (actor.meta_window.is_monitor_sized()) {
+ actor.ease({
+ opacity: 0,
+ duration: MINIMIZE_WINDOW_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onStopped: () => this._minimizeWindowDone(shellwm, actor),
+ });
+ } else {
+ let xDest, yDest, xScale, yScale;
+ let [success, geom] = actor.meta_window.get_icon_geometry();
+ if (success) {
+ xDest = geom.x;
+ yDest = geom.y;
+ xScale = geom.width / actor.width;
+ yScale = geom.height / actor.height;
+ } else {
+ let monitor = Main.layoutManager.monitors[actor.meta_window.get_monitor()];
+ if (!monitor) {
+ this._minimizeWindowDone();
+ return;
+ }
+ xDest = monitor.x;
+ yDest = monitor.y;
+ if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL)
+ xDest += monitor.width;
+ xScale = 0;
+ yScale = 0;
+ }
+
+ actor.ease({
+ scale_x: xScale,
+ scale_y: yScale,
+ x: xDest,
+ y: yDest,
+ duration: MINIMIZE_WINDOW_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_IN_EXPO,
+ onStopped: () => this._minimizeWindowDone(shellwm, actor),
+ });
+ }
+ }
+
+ _minimizeWindowDone(shellwm, actor) {
+ if (this._minimizing.delete(actor)) {
+ actor.remove_all_transitions();
+ actor.set_scale(1.0, 1.0);
+ actor.set_opacity(255);
+ actor.set_pivot_point(0, 0);
+
+ shellwm.completed_minimize(actor);
+ }
+ }
+
+ _unminimizeWindow(shellwm, actor) {
+ let types = [Meta.WindowType.NORMAL,
+ Meta.WindowType.MODAL_DIALOG,
+ Meta.WindowType.DIALOG];
+ if (!this._shouldAnimateActor(actor, types)) {
+ shellwm.completed_unminimize(actor);
+ return;
+ }
+
+ this._unminimizing.add(actor);
+
+ if (actor.meta_window.is_monitor_sized()) {
+ actor.opacity = 0;
+ actor.set_scale(1.0, 1.0);
+ actor.ease({
+ opacity: 255,
+ duration: MINIMIZE_WINDOW_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onStopped: () => this._unminimizeWindowDone(shellwm, actor),
+ });
+ } else {
+ let [success, geom] = actor.meta_window.get_icon_geometry();
+ if (success) {
+ actor.set_position(geom.x, geom.y);
+ actor.set_scale(geom.width / actor.width,
+ geom.height / actor.height);
+ } else {
+ let monitor = Main.layoutManager.monitors[actor.meta_window.get_monitor()];
+ if (!monitor) {
+ actor.show();
+ this._unminimizeWindowDone();
+ return;
+ }
+ actor.set_position(monitor.x, monitor.y);
+ if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL)
+ actor.x += monitor.width;
+ actor.set_scale(0, 0);
+ }
+
+ let rect = actor.meta_window.get_frame_rect();
+ let [xDest, yDest] = [rect.x, rect.y];
+
+ actor.show();
+ actor.ease({
+ scale_x: 1,
+ scale_y: 1,
+ x: xDest,
+ y: yDest,
+ duration: MINIMIZE_WINDOW_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_IN_EXPO,
+ onStopped: () => this._unminimizeWindowDone(shellwm, actor),
+ });
+ }
+ }
+
+ _unminimizeWindowDone(shellwm, actor) {
+ if (this._unminimizing.delete(actor)) {
+ actor.remove_all_transitions();
+ actor.set_scale(1.0, 1.0);
+ actor.set_opacity(255);
+ actor.set_pivot_point(0, 0);
+
+ shellwm.completed_unminimize(actor);
+ }
+ }
+
+ _sizeChangeWindow(shellwm, actor, whichChange, oldFrameRect, _oldBufferRect) {
+ const types = [Meta.WindowType.NORMAL];
+ const shouldAnimate =
+ this._shouldAnimateActor(actor, types) &&
+ oldFrameRect.width > 0 &&
+ oldFrameRect.height > 0;
+
+ if (shouldAnimate)
+ this._prepareAnimationInfo(shellwm, actor, oldFrameRect, whichChange);
+ else
+ shellwm.completed_size_change(actor);
+ }
+
+ _prepareAnimationInfo(shellwm, actor, oldFrameRect, _change) {
+ // Position a clone of the window on top of the old position,
+ // while actor updates are frozen.
+ let actorContent = Shell.util_get_content_for_window_actor(actor, oldFrameRect);
+ let actorClone = new St.Widget({ content: actorContent });
+ actorClone.set_offscreen_redirect(Clutter.OffscreenRedirect.ALWAYS);
+ actorClone.set_position(oldFrameRect.x, oldFrameRect.y);
+ actorClone.set_size(oldFrameRect.width, oldFrameRect.height);
+
+ actor.freeze();
+
+ if (this._clearAnimationInfo(actor)) {
+ log('Old animationInfo removed from actor %s'.format(actor));
+ this._shellwm.completed_size_change(actor);
+ }
+
+ let destroyId = actor.connect('destroy', () => {
+ this._clearAnimationInfo(actor);
+ });
+
+ this._resizePending.add(actor);
+ actor.__animationInfo = {
+ clone: actorClone,
+ oldRect: oldFrameRect,
+ frozen: true,
+ destroyId,
+ };
+ }
+
+ _sizeChangedWindow(shellwm, actor) {
+ if (!actor.__animationInfo)
+ return;
+ if (this._resizing.has(actor))
+ return;
+
+ let actorClone = actor.__animationInfo.clone;
+ let targetRect = actor.meta_window.get_frame_rect();
+ let sourceRect = actor.__animationInfo.oldRect;
+
+ let scaleX = targetRect.width / sourceRect.width;
+ let scaleY = targetRect.height / sourceRect.height;
+
+ this._resizePending.delete(actor);
+ this._resizing.add(actor);
+
+ Main.uiGroup.add_child(actorClone);
+
+ // Now scale and fade out the clone
+ actorClone.ease({
+ x: targetRect.x,
+ y: targetRect.y,
+ scale_x: scaleX,
+ scale_y: scaleY,
+ opacity: 0,
+ duration: WINDOW_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+
+ actor.translation_x = -targetRect.x + sourceRect.x;
+ actor.translation_y = -targetRect.y + sourceRect.y;
+
+ // Now set scale the actor to size it as the clone.
+ actor.scale_x = 1 / scaleX;
+ actor.scale_y = 1 / scaleY;
+
+ // Scale it to its actual new size
+ actor.ease({
+ scale_x: 1,
+ scale_y: 1,
+ translation_x: 0,
+ translation_y: 0,
+ duration: WINDOW_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onStopped: () => this._sizeChangeWindowDone(shellwm, actor),
+ });
+
+ // ease didn't animate and cleared the info, we are done
+ if (!actor.__animationInfo)
+ return;
+
+ // Now unfreeze actor updates, to get it to the new size.
+ // It's important that we don't wait until the animation is completed to
+ // do this, otherwise our scale will be applied to the old texture size.
+ actor.thaw();
+ actor.__animationInfo.frozen = false;
+ }
+
+ _clearAnimationInfo(actor) {
+ if (actor.__animationInfo) {
+ actor.__animationInfo.clone.destroy();
+ actor.disconnect(actor.__animationInfo.destroyId);
+ if (actor.__animationInfo.frozen)
+ actor.thaw();
+
+ delete actor.__animationInfo;
+ return true;
+ }
+ return false;
+ }
+
+ _sizeChangeWindowDone(shellwm, actor) {
+ if (this._resizing.delete(actor)) {
+ actor.remove_all_transitions();
+ actor.scale_x = 1.0;
+ actor.scale_y = 1.0;
+ actor.translation_x = 0;
+ actor.translation_y = 0;
+ this._clearAnimationInfo(actor);
+ this._shellwm.completed_size_change(actor);
+ }
+
+ if (this._resizePending.delete(actor)) {
+ this._clearAnimationInfo(actor);
+ this._shellwm.completed_size_change(actor);
+ }
+ }
+
+ _hasAttachedDialogs(window, ignoreWindow) {
+ var count = 0;
+ window.foreach_transient(win => {
+ if (win != ignoreWindow &&
+ win.is_attached_dialog() &&
+ win.get_transient_for() == window) {
+ count++;
+ return false;
+ }
+ return true;
+ });
+ return count != 0;
+ }
+
+ _checkDimming(window, ignoreWindow) {
+ let shouldDim = this._hasAttachedDialogs(window, ignoreWindow);
+
+ if (shouldDim && !window._dimmed) {
+ window._dimmed = true;
+ this._dimmedWindows.push(window);
+ this._dimWindow(window);
+ } else if (!shouldDim && window._dimmed) {
+ window._dimmed = false;
+ this._dimmedWindows =
+ this._dimmedWindows.filter(win => win != window);
+ this._undimWindow(window);
+ }
+ }
+
+ _dimWindow(window) {
+ let actor = window.get_compositor_private();
+ if (!actor)
+ return;
+ let dimmer = getWindowDimmer(actor);
+ if (!dimmer)
+ return;
+ dimmer.setDimmed(true, this._shouldAnimate());
+ }
+
+ _undimWindow(window) {
+ let actor = window.get_compositor_private();
+ if (!actor)
+ return;
+ let dimmer = getWindowDimmer(actor);
+ if (!dimmer)
+ return;
+ dimmer.setDimmed(false, this._shouldAnimate());
+ }
+
+ _mapWindow(shellwm, actor) {
+ actor._windowType = actor.meta_window.get_window_type();
+ actor._notifyWindowTypeSignalId =
+ actor.meta_window.connect('notify::window-type', () => {
+ let type = actor.meta_window.get_window_type();
+ if (type == actor._windowType)
+ return;
+ if (type == Meta.WindowType.MODAL_DIALOG ||
+ actor._windowType == Meta.WindowType.MODAL_DIALOG) {
+ let parent = actor.get_meta_window().get_transient_for();
+ if (parent)
+ this._checkDimming(parent);
+ }
+
+ actor._windowType = type;
+ });
+ actor.meta_window.connect('unmanaged', window => {
+ let parent = window.get_transient_for();
+ if (parent)
+ this._checkDimming(parent);
+ });
+
+ if (actor.meta_window.is_attached_dialog())
+ this._checkDimming(actor.get_meta_window().get_transient_for());
+
+ let types = [Meta.WindowType.NORMAL,
+ Meta.WindowType.DIALOG,
+ Meta.WindowType.MODAL_DIALOG];
+ if (!this._shouldAnimateActor(actor, types)) {
+ shellwm.completed_map(actor);
+ return;
+ }
+
+ switch (actor._windowType) {
+ case Meta.WindowType.NORMAL:
+ actor.set_pivot_point(0.5, 1.0);
+ actor.scale_x = 0.01;
+ actor.scale_y = 0.05;
+ actor.opacity = 0;
+ actor.show();
+ this._mapping.add(actor);
+
+ actor.ease({
+ opacity: 255,
+ scale_x: 1,
+ scale_y: 1,
+ duration: SHOW_WINDOW_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_EXPO,
+ onStopped: () => this._mapWindowDone(shellwm, actor),
+ });
+ break;
+ case Meta.WindowType.MODAL_DIALOG:
+ case Meta.WindowType.DIALOG:
+ actor.set_pivot_point(0.5, 0.5);
+ actor.scale_y = 0;
+ actor.opacity = 0;
+ actor.show();
+ this._mapping.add(actor);
+
+ actor.ease({
+ opacity: 255,
+ scale_x: 1,
+ scale_y: 1,
+ duration: DIALOG_SHOW_WINDOW_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onStopped: () => this._mapWindowDone(shellwm, actor),
+ });
+ break;
+ default:
+ shellwm.completed_map(actor);
+ }
+ }
+
+ _mapWindowDone(shellwm, actor) {
+ if (this._mapping.delete(actor)) {
+ actor.remove_all_transitions();
+ actor.opacity = 255;
+ actor.set_pivot_point(0, 0);
+ actor.scale_y = 1;
+ actor.scale_x = 1;
+ actor.translation_y = 0;
+ actor.translation_x = 0;
+ shellwm.completed_map(actor);
+ }
+ }
+
+ _destroyWindow(shellwm, actor) {
+ let window = actor.meta_window;
+ if (actor._notifyWindowTypeSignalId) {
+ window.disconnect(actor._notifyWindowTypeSignalId);
+ actor._notifyWindowTypeSignalId = 0;
+ }
+ if (window._dimmed) {
+ this._dimmedWindows =
+ this._dimmedWindows.filter(win => win != window);
+ }
+
+ if (window.is_attached_dialog())
+ this._checkDimming(window.get_transient_for(), window);
+
+ let types = [Meta.WindowType.NORMAL,
+ Meta.WindowType.DIALOG,
+ Meta.WindowType.MODAL_DIALOG];
+ if (!this._shouldAnimateActor(actor, types)) {
+ shellwm.completed_destroy(actor);
+ return;
+ }
+
+ switch (actor.meta_window.window_type) {
+ case Meta.WindowType.NORMAL:
+ actor.set_pivot_point(0.5, 0.5);
+ this._destroying.add(actor);
+
+ actor.ease({
+ opacity: 0,
+ scale_x: 0.8,
+ scale_y: 0.8,
+ duration: DESTROY_WINDOW_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onStopped: () => this._destroyWindowDone(shellwm, actor),
+ });
+ break;
+ case Meta.WindowType.MODAL_DIALOG:
+ case Meta.WindowType.DIALOG:
+ actor.set_pivot_point(0.5, 0.5);
+ this._destroying.add(actor);
+
+ if (window.is_attached_dialog()) {
+ let parent = window.get_transient_for();
+ actor._parentDestroyId = parent.connect('unmanaged', () => {
+ actor.remove_all_transitions();
+ this._destroyWindowDone(shellwm, actor);
+ });
+ }
+
+ actor.ease({
+ scale_y: 0,
+ duration: DIALOG_DESTROY_WINDOW_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onStopped: () => this._destroyWindowDone(shellwm, actor),
+ });
+ break;
+ default:
+ shellwm.completed_destroy(actor);
+ }
+ }
+
+ _destroyWindowDone(shellwm, actor) {
+ if (this._destroying.delete(actor)) {
+ const parent = actor.get_meta_window()?.get_transient_for();
+ if (parent && actor._parentDestroyId) {
+ parent.disconnect(actor._parentDestroyId);
+ actor._parentDestroyId = 0;
+ }
+ shellwm.completed_destroy(actor);
+ }
+ }
+
+ _filterKeybinding(shellwm, binding) {
+ if (Main.actionMode == Shell.ActionMode.NONE)
+ return true;
+
+ // There's little sense in implementing a keybinding in mutter and
+ // not having it work in NORMAL mode; handle this case generically
+ // so we don't have to explicitly allow all builtin keybindings in
+ // NORMAL mode.
+ if (Main.actionMode == Shell.ActionMode.NORMAL &&
+ binding.is_builtin())
+ return false;
+
+ return !(this._allowedKeybindings[binding.get_name()] & Main.actionMode);
+ }
+
+ _syncStacking() {
+ if (this._switchData == null)
+ return;
+
+ let windows = global.get_window_actors();
+ let lastCurSibling = null;
+ let lastDirSibling = [];
+ for (let i = 0; i < windows.length; i++) {
+ if (windows[i].get_parent() == this._switchData.curGroup) {
+ this._switchData.curGroup.set_child_above_sibling(windows[i], lastCurSibling);
+ lastCurSibling = windows[i];
+ } else {
+ for (let dir of Object.values(Meta.MotionDirection)) {
+ let info = this._switchData.surroundings[dir];
+ if (!info || windows[i].get_parent() != info.actor)
+ continue;
+
+ let sibling = lastDirSibling[dir];
+ if (sibling == undefined)
+ sibling = null;
+
+ info.actor.set_child_above_sibling(windows[i], sibling);
+ lastDirSibling[dir] = windows[i];
+ break;
+ }
+ }
+ }
+ }
+
+ _getPositionForDirection(direction, fromWs, toWs) {
+ let xDest = 0, yDest = 0;
+
+ let oldWsIsFullscreen = fromWs.list_windows().some(w => w.is_fullscreen());
+ let newWsIsFullscreen = toWs.list_windows().some(w => w.is_fullscreen());
+
+ // We have to shift windows up or down by the height of the panel to prevent having a
+ // visible gap between the windows while switching workspaces. Since fullscreen windows
+ // hide the panel, they don't need to be shifted up or down.
+ let shiftHeight = Main.panel.height;
+
+ if (direction == Meta.MotionDirection.UP ||
+ direction == Meta.MotionDirection.UP_LEFT ||
+ direction == Meta.MotionDirection.UP_RIGHT)
+ yDest = -global.screen_height + (oldWsIsFullscreen ? 0 : shiftHeight);
+ else if (direction == Meta.MotionDirection.DOWN ||
+ direction == Meta.MotionDirection.DOWN_LEFT ||
+ direction == Meta.MotionDirection.DOWN_RIGHT)
+ yDest = global.screen_height - (newWsIsFullscreen ? 0 : shiftHeight);
+
+ if (direction == Meta.MotionDirection.LEFT ||
+ direction == Meta.MotionDirection.UP_LEFT ||
+ direction == Meta.MotionDirection.DOWN_LEFT)
+ xDest = -global.screen_width;
+ else if (direction == Meta.MotionDirection.RIGHT ||
+ direction == Meta.MotionDirection.UP_RIGHT ||
+ direction == Meta.MotionDirection.DOWN_RIGHT)
+ xDest = global.screen_width;
+
+ return [xDest, yDest];
+ }
+
+ _prepareWorkspaceSwitch(from, to, direction) {
+ if (this._switchData)
+ return;
+
+ let wgroup = global.window_group;
+ let windows = global.get_window_actors();
+ let switchData = {};
+
+ this._switchData = switchData;
+ switchData.curGroup = new Clutter.Actor();
+ switchData.movingWindowBin = new Clutter.Actor();
+ switchData.windows = [];
+ switchData.surroundings = {};
+ switchData.gestureActivated = false;
+ switchData.inProgress = false;
+
+ switchData.container = new Clutter.Actor();
+ switchData.container.add_actor(switchData.curGroup);
+
+ wgroup.add_actor(switchData.movingWindowBin);
+ wgroup.add_actor(switchData.container);
+
+ let workspaceManager = global.workspace_manager;
+ let curWs = workspaceManager.get_workspace_by_index(from);
+
+ for (let dir of Object.values(Meta.MotionDirection)) {
+ let ws = null;
+
+ if (to < 0)
+ ws = curWs.get_neighbor(dir);
+ else if (dir == direction)
+ ws = workspaceManager.get_workspace_by_index(to);
+
+ if (ws == null || ws == curWs) {
+ switchData.surroundings[dir] = null;
+ continue;
+ }
+
+ let [x, y] = this._getPositionForDirection(dir, curWs, ws);
+ let info = {
+ index: ws.index(),
+ actor: new Clutter.Actor(),
+ xDest: x,
+ yDest: y,
+ };
+ switchData.surroundings[dir] = info;
+ switchData.container.add_actor(info.actor);
+ switchData.container.set_child_above_sibling(info.actor, null);
+
+ info.actor.set_position(x, y);
+ }
+
+ wgroup.set_child_above_sibling(switchData.movingWindowBin, null);
+
+ for (let i = 0; i < windows.length; i++) {
+ let actor = windows[i];
+ let window = actor.get_meta_window();
+
+ if (!window.showing_on_its_workspace())
+ continue;
+
+ if (window.is_on_all_workspaces())
+ continue;
+
+ let record = { window: actor,
+ parent: actor.get_parent() };
+
+ if (this._movingWindow && window == this._movingWindow) {
+ record.parent.remove_child(actor);
+ switchData.movingWindow = record;
+ switchData.windows.push(switchData.movingWindow);
+ switchData.movingWindowBin.add_child(actor);
+ } else if (window.get_workspace().index() == from) {
+ record.parent.remove_child(actor);
+ switchData.windows.push(record);
+ switchData.curGroup.add_child(actor);
+ } else {
+ let visible = false;
+ for (let dir of Object.values(Meta.MotionDirection)) {
+ let info = switchData.surroundings[dir];
+
+ if (!info || info.index != window.get_workspace().index())
+ continue;
+
+ record.parent.remove_child(actor);
+ switchData.windows.push(record);
+ info.actor.add_child(actor);
+ visible = true;
+ break;
+ }
+
+ actor.visible = visible;
+ }
+ }
+
+ for (let i = 0; i < switchData.windows.length; i++) {
+ let w = switchData.windows[i];
+
+ w.windowDestroyId = w.window.connect('destroy', () => {
+ switchData.windows.splice(switchData.windows.indexOf(w), 1);
+ });
+ }
+
+ Meta.disable_unredirect_for_display(global.display);
+ }
+
+ _finishWorkspaceSwitch(switchData) {
+ Meta.enable_unredirect_for_display(global.display);
+
+ this._switchData = null;
+
+ for (let i = 0; i < switchData.windows.length; i++) {
+ let w = switchData.windows[i];
+
+ w.window.disconnect(w.windowDestroyId);
+ w.window.get_parent().remove_child(w.window);
+ w.parent.add_child(w.window);
+
+ if (!w.window.get_meta_window().get_workspace().active)
+ w.window.hide();
+ }
+ switchData.container.destroy();
+ switchData.movingWindowBin.destroy();
+
+ this._movingWindow = null;
+ }
+
+ _switchWorkspace(shellwm, from, to, direction) {
+ if (!Main.sessionMode.hasWorkspaces || !this._shouldAnimate()) {
+ shellwm.completed_switch_workspace();
+ return;
+ }
+
+ this._prepareWorkspaceSwitch(from, to, direction);
+ this._switchData.inProgress = true;
+
+ let workspaceManager = global.workspace_manager;
+ let fromWs = workspaceManager.get_workspace_by_index(from);
+ let toWs = workspaceManager.get_workspace_by_index(to);
+
+ let [xDest, yDest] = this._getPositionForDirection(direction, fromWs, toWs);
+
+ /* @direction is the direction that the "camera" moves, so the
+ * screen contents have to move one screen's worth in the
+ * opposite direction.
+ */
+ xDest = -xDest;
+ yDest = -yDest;
+
+ this._switchData.container.ease({
+ x: xDest,
+ y: yDest,
+ duration: WINDOW_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_CUBIC,
+ onComplete: () => this._switchWorkspaceDone(shellwm),
+ });
+ }
+
+ _switchWorkspaceDone(shellwm) {
+ this._finishWorkspaceSwitch(this._switchData);
+ shellwm.completed_switch_workspace();
+ }
+
+ _directionForProgress(progress) {
+ if (global.workspace_manager.layout_rows === -1) {
+ return progress > 0
+ ? Meta.MotionDirection.DOWN
+ : Meta.MotionDirection.UP;
+ } else if (Clutter.get_default_text_direction() === Clutter.TextDirection.RTL) {
+ return progress > 0
+ ? Meta.MotionDirection.LEFT
+ : Meta.MotionDirection.RIGHT;
+ } else {
+ return progress > 0
+ ? Meta.MotionDirection.RIGHT
+ : Meta.MotionDirection.LEFT;
+ }
+ }
+
+ _getProgressRange() {
+ if (!this._switchData)
+ return [0, 0];
+
+ let lower = 0;
+ let upper = 0;
+
+ let horiz = global.workspace_manager.layout_rows !== -1;
+ let baseDistance;
+ if (horiz)
+ baseDistance = global.screen_width;
+ else
+ baseDistance = global.screen_height;
+
+ let direction = this._directionForProgress(-1);
+ let info = this._switchData.surroundings[direction];
+ if (info !== null) {
+ let distance = horiz ? info.xDest : info.yDest;
+ lower = -Math.abs(distance) / baseDistance;
+ }
+
+ direction = this._directionForProgress(1);
+ info = this._switchData.surroundings[direction];
+ if (info !== null) {
+ let distance = horiz ? info.xDest : info.yDest;
+ upper = Math.abs(distance) / baseDistance;
+ }
+
+ return [lower, upper];
+ }
+
+ _switchWorkspaceBegin(tracker, monitor) {
+ if (Meta.prefs_get_workspaces_only_on_primary() &&
+ monitor !== Main.layoutManager.primaryIndex)
+ return;
+
+ let workspaceManager = global.workspace_manager;
+ let horiz = workspaceManager.layout_rows !== -1;
+ tracker.orientation = horiz
+ ? Clutter.Orientation.HORIZONTAL
+ : Clutter.Orientation.VERTICAL;
+
+ let activeWorkspace = workspaceManager.get_active_workspace();
+
+ let baseDistance;
+ if (horiz)
+ baseDistance = global.screen_width;
+ else
+ baseDistance = global.screen_height;
+
+ let progress;
+ if (this._switchData && this._switchData.gestureActivated) {
+ this._switchData.container.remove_all_transitions();
+ if (!horiz)
+ progress = -this._switchData.container.y / baseDistance;
+ else if (Clutter.get_default_text_direction() === Clutter.TextDirection.RTL)
+ progress = this._switchData.container.x / baseDistance;
+ else
+ progress = -this._switchData.container.x / baseDistance;
+ } else {
+ this._prepareWorkspaceSwitch(activeWorkspace.index(), -1);
+ progress = 0;
+ }
+
+ let points = [];
+ let [lower, upper] = this._getProgressRange();
+
+ if (lower !== 0)
+ points.push(lower);
+
+ points.push(0);
+
+ if (upper !== 0)
+ points.push(upper);
+
+ tracker.confirmSwipe(baseDistance, points, progress, 0);
+ }
+
+ _switchWorkspaceUpdate(tracker, progress) {
+ if (!this._switchData)
+ return;
+
+ let direction = this._directionForProgress(progress);
+ let info = this._switchData.surroundings[direction];
+ let xPos = 0;
+ let yPos = 0;
+ if (info) {
+ if (global.workspace_manager.layout_rows === -1)
+ yPos = -Math.round(progress * global.screen_height);
+ else if (Clutter.get_default_text_direction() === Clutter.TextDirection.RTL)
+ xPos = Math.round(progress * global.screen_width);
+ else
+ xPos = -Math.round(progress * global.screen_width);
+ }
+
+ this._switchData.container.set_position(xPos, yPos);
+ }
+
+ _switchWorkspaceEnd(tracker, duration, endProgress) {
+ if (!this._switchData)
+ return;
+
+ let workspaceManager = global.workspace_manager;
+ let activeWorkspace = workspaceManager.get_active_workspace();
+ let newWs = activeWorkspace;
+ let xDest = 0;
+ let yDest = 0;
+ if (endProgress !== 0) {
+ let direction = this._directionForProgress(endProgress);
+ newWs = activeWorkspace.get_neighbor(direction);
+ xDest = -this._switchData.surroundings[direction].xDest;
+ yDest = -this._switchData.surroundings[direction].yDest;
+ }
+
+ let switchData = this._switchData;
+ switchData.gestureActivated = true;
+
+ this._switchData.container.ease({
+ x: xDest,
+ y: yDest,
+ duration,
+ mode: Clutter.AnimationMode.EASE_OUT_CUBIC,
+ onComplete: () => {
+ if (!newWs.active)
+ this.actionMoveWorkspace(newWs);
+ this._finishWorkspaceSwitch(switchData);
+ },
+ });
+ }
+
+ _switchWorkspaceStop() {
+ this._switchData.container.x = 0;
+ this._switchData.container.y = 0;
+ this._finishWorkspaceSwitch(this._switchData);
+ }
+
+ _showTilePreview(shellwm, window, tileRect, monitorIndex) {
+ if (!this._tilePreview)
+ this._tilePreview = new TilePreview();
+ this._tilePreview.open(window, tileRect, monitorIndex);
+ }
+
+ _hideTilePreview() {
+ if (!this._tilePreview)
+ return;
+ this._tilePreview.close();
+ }
+
+ _showWindowMenu(shellwm, window, menu, rect) {
+ this._windowMenuManager.showWindowMenuForWindow(window, menu, rect);
+ }
+
+ _startSwitcher(display, window, binding) {
+ let constructor = null;
+ switch (binding.get_name()) {
+ case 'switch-applications':
+ case 'switch-applications-backward':
+ case 'switch-group':
+ case 'switch-group-backward':
+ constructor = AltTab.AppSwitcherPopup;
+ break;
+ case 'switch-windows':
+ case 'switch-windows-backward':
+ constructor = AltTab.WindowSwitcherPopup;
+ break;
+ case 'cycle-windows':
+ case 'cycle-windows-backward':
+ constructor = AltTab.WindowCyclerPopup;
+ break;
+ case 'cycle-group':
+ case 'cycle-group-backward':
+ constructor = AltTab.GroupCyclerPopup;
+ break;
+ case 'switch-monitor':
+ constructor = SwitchMonitor.SwitchMonitorPopup;
+ break;
+ }
+
+ if (!constructor)
+ return;
+
+ /* prevent a corner case where both popups show up at once */
+ if (this._workspaceSwitcherPopup != null)
+ this._workspaceSwitcherPopup.destroy();
+
+ let tabPopup = new constructor();
+
+ if (!tabPopup.show(binding.is_reversed(), binding.get_name(), binding.get_mask()))
+ tabPopup.destroy();
+ }
+
+ _startA11ySwitcher(display, window, binding) {
+ Main.ctrlAltTabManager.popup(binding.is_reversed(), binding.get_name(), binding.get_mask());
+ }
+
+ _allowFavoriteShortcuts() {
+ return Main.sessionMode.hasOverview;
+ }
+
+ _switchToApplication(display, window, binding) {
+ if (!this._allowFavoriteShortcuts())
+ return;
+
+ let [, , , target] = binding.get_name().split('-');
+ let apps = AppFavorites.getAppFavorites().getFavorites();
+ let app = apps[target - 1];
+ if (app)
+ app.activate();
+ }
+
+ _toggleAppMenu() {
+ Main.panel.toggleAppMenu();
+ }
+
+ _toggleCalendar() {
+ Main.panel.toggleCalendar();
+ }
+
+ _showWorkspaceSwitcher(display, window, binding) {
+ let workspaceManager = display.get_workspace_manager();
+
+ if (!Main.sessionMode.hasWorkspaces)
+ return;
+
+ if (workspaceManager.n_workspaces == 1)
+ return;
+
+ let [action,,, target] = binding.get_name().split('-');
+ let newWs;
+ let direction;
+ let vertical = workspaceManager.layout_rows == -1;
+ let rtl = Clutter.get_default_text_direction() == Clutter.TextDirection.RTL;
+
+ if (action == 'move') {
+ // "Moving" a window to another workspace doesn't make sense when
+ // it cannot be unstuck, and is potentially confusing if a new
+ // workspaces is added at the start/end
+ if (window.is_always_on_all_workspaces() ||
+ (Meta.prefs_get_workspaces_only_on_primary() &&
+ window.get_monitor() != Main.layoutManager.primaryIndex))
+ return;
+ }
+
+ if (target == 'last') {
+ if (vertical)
+ direction = Meta.MotionDirection.DOWN;
+ else if (rtl)
+ direction = Meta.MotionDirection.LEFT;
+ else
+ direction = Meta.MotionDirection.RIGHT;
+ newWs = workspaceManager.get_workspace_by_index(workspaceManager.n_workspaces - 1);
+ } else if (isNaN(target)) {
+ // Prepend a new workspace dynamically
+ let prependTarget;
+ if (vertical)
+ prependTarget = 'up';
+ else if (rtl)
+ prependTarget = 'right';
+ else
+ prependTarget = 'left';
+ if (workspaceManager.get_active_workspace_index() === 0 &&
+ action === 'move' && target === prependTarget &&
+ this._isWorkspacePrepended === false) {
+ this.insertWorkspace(0);
+ this._isWorkspacePrepended = true;
+ }
+
+ direction = Meta.MotionDirection[target.toUpperCase()];
+ newWs = workspaceManager.get_active_workspace().get_neighbor(direction);
+ } else if ((target > 0) && (target <= workspaceManager.n_workspaces)) {
+ target--;
+ newWs = workspaceManager.get_workspace_by_index(target);
+
+ if (workspaceManager.get_active_workspace().index() > target) {
+ if (vertical)
+ direction = Meta.MotionDirection.UP;
+ else if (rtl)
+ direction = Meta.MotionDirection.RIGHT;
+ else
+ direction = Meta.MotionDirection.LEFT;
+ } else {
+ if (vertical) // eslint-disable-line no-lonely-if
+ direction = Meta.MotionDirection.DOWN;
+ else if (rtl)
+ direction = Meta.MotionDirection.LEFT;
+ else
+ direction = Meta.MotionDirection.RIGHT;
+ }
+ }
+
+ if (workspaceManager.layout_rows == -1 &&
+ direction != Meta.MotionDirection.UP &&
+ direction != Meta.MotionDirection.DOWN)
+ return;
+
+ if (workspaceManager.layout_columns == -1 &&
+ direction != Meta.MotionDirection.LEFT &&
+ direction != Meta.MotionDirection.RIGHT)
+ return;
+
+ if (action == 'switch')
+ this.actionMoveWorkspace(newWs);
+ else
+ this.actionMoveWindow(window, newWs);
+
+ if (!Main.overview.visible) {
+ if (this._workspaceSwitcherPopup == null) {
+ this._workspaceTracker.blockUpdates();
+ this._workspaceSwitcherPopup = new WorkspaceSwitcherPopup.WorkspaceSwitcherPopup();
+ this._workspaceSwitcherPopup.connect('destroy', () => {
+ this._workspaceTracker.unblockUpdates();
+ this._workspaceSwitcherPopup = null;
+ this._isWorkspacePrepended = false;
+ });
+ }
+ this._workspaceSwitcherPopup.display(direction, newWs.index());
+ }
+ }
+
+ actionMoveWorkspace(workspace) {
+ if (!Main.sessionMode.hasWorkspaces)
+ return;
+
+ if (!workspace.active)
+ workspace.activate(global.get_current_time());
+ }
+
+ actionMoveWindow(window, workspace) {
+ if (!Main.sessionMode.hasWorkspaces)
+ return;
+
+ if (!workspace.active) {
+ // This won't have any effect for "always sticky" windows
+ // (like desktop windows or docks)
+
+ this._movingWindow = window;
+ window.change_workspace(workspace);
+
+ global.display.clear_mouse_mode();
+ workspace.activate_with_focus(window, global.get_current_time());
+ }
+ }
+
+ _confirmDisplayChange() {
+ let dialog = new DisplayChangeDialog(this._shellwm);
+ dialog.open();
+ }
+
+ _createCloseDialog(shellwm, window) {
+ return new CloseDialog.CloseDialog(window);
+ }
+
+ _createInhibitShortcutsDialog(shellwm, window) {
+ return new InhibitShortcutsDialog.InhibitShortcutsDialog(window);
+ }
+
+ _showResizePopup(display, show, rect, displayW, displayH) {
+ if (show) {
+ if (!this._resizePopup)
+ this._resizePopup = new ResizePopup();
+
+ this._resizePopup.set(rect, displayW, displayH);
+ } else {
+ if (!this._resizePopup)
+ return;
+
+ this._resizePopup.destroy();
+ this._resizePopup = null;
+ }
+ }
+};
diff --git a/js/ui/windowMenu.js b/js/ui/windowMenu.js
new file mode 100644
index 0000000..bb6a8df
--- /dev/null
+++ b/js/ui/windowMenu.js
@@ -0,0 +1,238 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*
+/* exported WindowMenuManager */
+
+const { GLib, Meta, St } = imports.gi;
+
+const BoxPointer = imports.ui.boxpointer;
+const Main = imports.ui.main;
+const PopupMenu = imports.ui.popupMenu;
+
+var WindowMenu = class extends PopupMenu.PopupMenu {
+ constructor(window, sourceActor) {
+ super(sourceActor, 0, St.Side.TOP);
+
+ this.actor.add_style_class_name('window-menu');
+
+ Main.layoutManager.uiGroup.add_actor(this.actor);
+ this.actor.hide();
+
+ this._buildMenu(window);
+ }
+
+ _buildMenu(window) {
+ let type = window.get_window_type();
+
+ let item;
+
+ item = this.addAction(_("Minimize"), () => {
+ window.minimize();
+ });
+ if (!window.can_minimize())
+ item.setSensitive(false);
+
+ if (window.get_maximized()) {
+ item = this.addAction(_("Unmaximize"), () => {
+ window.unmaximize(Meta.MaximizeFlags.BOTH);
+ });
+ } else {
+ item = this.addAction(_("Maximize"), () => {
+ window.maximize(Meta.MaximizeFlags.BOTH);
+ });
+ }
+ if (!window.can_maximize())
+ item.setSensitive(false);
+
+ item = this.addAction(_("Move"), event => {
+ this._grabAction(window, Meta.GrabOp.KEYBOARD_MOVING, event.get_time());
+ });
+ if (!window.allows_move())
+ item.setSensitive(false);
+
+ item = this.addAction(_("Resize"), event => {
+ this._grabAction(window, Meta.GrabOp.KEYBOARD_RESIZING_UNKNOWN, event.get_time());
+ });
+ if (!window.allows_resize())
+ item.setSensitive(false);
+
+ if (!window.titlebar_is_onscreen() && type != Meta.WindowType.DOCK && type != Meta.WindowType.DESKTOP) {
+ this.addAction(_("Move Titlebar Onscreen"), () => {
+ window.shove_titlebar_onscreen();
+ });
+ }
+
+ item = this.addAction(_("Always on Top"), () => {
+ if (window.is_above())
+ window.unmake_above();
+ else
+ window.make_above();
+ });
+ if (window.is_above())
+ item.setOrnament(PopupMenu.Ornament.CHECK);
+ if (window.get_maximized() == Meta.MaximizeFlags.BOTH ||
+ type == Meta.WindowType.DOCK ||
+ type == Meta.WindowType.DESKTOP ||
+ type == Meta.WindowType.SPLASHSCREEN)
+ item.setSensitive(false);
+
+ if (Main.sessionMode.hasWorkspaces &&
+ (!Meta.prefs_get_workspaces_only_on_primary() ||
+ window.is_on_primary_monitor())) {
+ let isSticky = window.is_on_all_workspaces();
+
+ item = this.addAction(_("Always on Visible Workspace"), () => {
+ if (isSticky)
+ window.unstick();
+ else
+ window.stick();
+ });
+ if (isSticky)
+ item.setOrnament(PopupMenu.Ornament.CHECK);
+ if (window.is_always_on_all_workspaces())
+ item.setSensitive(false);
+
+ if (!isSticky) {
+ let workspace = window.get_workspace();
+ if (workspace != workspace.get_neighbor(Meta.MotionDirection.LEFT)) {
+ this.addAction(_("Move to Workspace Left"), () => {
+ let dir = Meta.MotionDirection.LEFT;
+ window.change_workspace(workspace.get_neighbor(dir));
+ });
+ }
+ if (workspace != workspace.get_neighbor(Meta.MotionDirection.RIGHT)) {
+ this.addAction(_("Move to Workspace Right"), () => {
+ let dir = Meta.MotionDirection.RIGHT;
+ window.change_workspace(workspace.get_neighbor(dir));
+ });
+ }
+ if (workspace != workspace.get_neighbor(Meta.MotionDirection.UP)) {
+ this.addAction(_("Move to Workspace Up"), () => {
+ let dir = Meta.MotionDirection.UP;
+ window.change_workspace(workspace.get_neighbor(dir));
+ });
+ }
+ if (workspace != workspace.get_neighbor(Meta.MotionDirection.DOWN)) {
+ this.addAction(_("Move to Workspace Down"), () => {
+ let dir = Meta.MotionDirection.DOWN;
+ window.change_workspace(workspace.get_neighbor(dir));
+ });
+ }
+ }
+ }
+
+ let display = global.display;
+ let nMonitors = display.get_n_monitors();
+ let monitorIndex = window.get_monitor();
+ if (nMonitors > 1 && monitorIndex >= 0) {
+ this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ let dir = Meta.DisplayDirection.UP;
+ let upMonitorIndex =
+ display.get_monitor_neighbor_index(monitorIndex, dir);
+ if (upMonitorIndex != -1) {
+ this.addAction(_("Move to Monitor Up"), () => {
+ window.move_to_monitor(upMonitorIndex);
+ });
+ }
+
+ dir = Meta.DisplayDirection.DOWN;
+ let downMonitorIndex =
+ display.get_monitor_neighbor_index(monitorIndex, dir);
+ if (downMonitorIndex != -1) {
+ this.addAction(_("Move to Monitor Down"), () => {
+ window.move_to_monitor(downMonitorIndex);
+ });
+ }
+
+ dir = Meta.DisplayDirection.LEFT;
+ let leftMonitorIndex =
+ display.get_monitor_neighbor_index(monitorIndex, dir);
+ if (leftMonitorIndex != -1) {
+ this.addAction(_("Move to Monitor Left"), () => {
+ window.move_to_monitor(leftMonitorIndex);
+ });
+ }
+
+ dir = Meta.DisplayDirection.RIGHT;
+ let rightMonitorIndex =
+ display.get_monitor_neighbor_index(monitorIndex, dir);
+ if (rightMonitorIndex != -1) {
+ this.addAction(_("Move to Monitor Right"), () => {
+ window.move_to_monitor(rightMonitorIndex);
+ });
+ }
+ }
+
+ this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ item = this.addAction(_("Close"), event => {
+ window.delete(event.get_time());
+ });
+ if (!window.can_close())
+ item.setSensitive(false);
+ }
+
+ _grabAction(window, grabOp, time) {
+ if (global.display.get_grab_op() == Meta.GrabOp.NONE) {
+ window.begin_grab_op(grabOp, true, time);
+ return;
+ }
+
+ let waitId = 0;
+ let id = global.display.connect('grab-op-end', display => {
+ display.disconnect(id);
+ GLib.source_remove(waitId);
+
+ window.begin_grab_op(grabOp, true, time);
+ });
+
+ waitId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, () => {
+ global.display.disconnect(id);
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+};
+
+var WindowMenuManager = class {
+ constructor() {
+ this._manager = new PopupMenu.PopupMenuManager(Main.layoutManager.dummyCursor);
+
+ this._sourceActor = new St.Widget({ reactive: true, visible: false });
+ this._sourceActor.connect('button-press-event', () => {
+ this._manager.activeMenu.toggle();
+ });
+ Main.uiGroup.add_actor(this._sourceActor);
+ }
+
+ showWindowMenuForWindow(window, type, rect) {
+ if (!Main.sessionMode.hasWmMenus)
+ return;
+
+ if (type != Meta.WindowMenuType.WM)
+ throw new Error('Unsupported window menu type');
+ let menu = new WindowMenu(window, this._sourceActor);
+
+ this._manager.addMenu(menu);
+
+ menu.connect('activate', () => {
+ window.check_alive(global.get_current_time());
+ });
+ let destroyId = window.connect('unmanaged', () => {
+ menu.close();
+ });
+
+ this._sourceActor.set_size(Math.max(1, rect.width), Math.max(1, rect.height));
+ this._sourceActor.set_position(rect.x, rect.y);
+ this._sourceActor.show();
+
+ menu.open(BoxPointer.PopupAnimation.FADE);
+ menu.actor.navigate_focus(null, St.DirectionType.TAB_FORWARD, false);
+ menu.connect('open-state-changed', (menu_, isOpen) => {
+ if (isOpen)
+ return;
+
+ this._sourceActor.hide();
+ menu.destroy();
+ window.disconnect(destroyId);
+ });
+ }
+};
diff --git a/js/ui/windowPreview.js b/js/ui/windowPreview.js
new file mode 100644
index 0000000..d379703
--- /dev/null
+++ b/js/ui/windowPreview.js
@@ -0,0 +1,773 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported WindowPreview */
+
+const { Atk, Clutter, GLib, GObject,
+ Graphene, Meta, Pango, Shell, St } = imports.gi;
+
+const DND = imports.ui.dnd;
+
+var WINDOW_DND_SIZE = 256;
+
+var WINDOW_OVERLAY_IDLE_HIDE_TIMEOUT = 750;
+var WINDOW_OVERLAY_FADE_TIME = 200;
+
+var DRAGGING_WINDOW_OPACITY = 100;
+
+var WindowPreviewLayout = GObject.registerClass({
+ Properties: {
+ 'bounding-box': GObject.ParamSpec.boxed(
+ 'bounding-box', 'Bounding box', 'Bounding box',
+ GObject.ParamFlags.READABLE,
+ Clutter.ActorBox.$gtype),
+ },
+}, class WindowPreviewLayout extends Clutter.LayoutManager {
+ _init() {
+ super._init();
+
+ this._container = null;
+ this._boundingBox = new Clutter.ActorBox();
+ this._windows = new Map();
+ }
+
+ _layoutChanged() {
+ let frameRect;
+
+ for (const windowInfo of this._windows.values()) {
+ const frame = windowInfo.metaWindow.get_frame_rect();
+ frameRect = frameRect ? frameRect.union(frame) : frame;
+ }
+
+ if (!frameRect)
+ frameRect = new Meta.Rectangle();
+
+ const oldBox = this._boundingBox.copy();
+ this._boundingBox.set_origin(frameRect.x, frameRect.y);
+ this._boundingBox.set_size(frameRect.width, frameRect.height);
+
+ if (!this._boundingBox.equal(oldBox))
+ this.notify('bounding-box');
+
+ // Always call layout_changed(), a size or position change of an
+ // attached dialog might not affect the boundingBox
+ this.layout_changed();
+ }
+
+ vfunc_set_container(container) {
+ this._container = container;
+ }
+
+ vfunc_get_preferred_height(_container, _forWidth) {
+ return [0, this._boundingBox.get_height()];
+ }
+
+ vfunc_get_preferred_width(_container, _forHeight) {
+ return [0, this._boundingBox.get_width()];
+ }
+
+ vfunc_allocate(container, box) {
+ // If the scale isn't 1, we weren't allocated our preferred size
+ // and have to scale the children allocations accordingly.
+ const scaleX = this._boundingBox.get_width() > 0
+ ? box.get_width() / this._boundingBox.get_width()
+ : 1;
+ const scaleY = this._boundingBox.get_height() > 0
+ ? box.get_height() / this._boundingBox.get_height()
+ : 1;
+
+ const childBox = new Clutter.ActorBox();
+
+ for (const child of container) {
+ if (!child.visible)
+ continue;
+
+ const windowInfo = this._windows.get(child);
+ if (windowInfo) {
+ const bufferRect = windowInfo.metaWindow.get_buffer_rect();
+ childBox.set_origin(
+ bufferRect.x - this._boundingBox.x1,
+ bufferRect.y - this._boundingBox.y1);
+
+ const [, , natWidth, natHeight] = child.get_preferred_size();
+ childBox.set_size(natWidth, natHeight);
+
+ childBox.x1 *= scaleX;
+ childBox.x2 *= scaleX;
+ childBox.y1 *= scaleY;
+ childBox.y2 *= scaleY;
+
+ child.allocate(childBox);
+ } else {
+ child.allocate_preferred_size(0, 0);
+ }
+ }
+ }
+
+ /**
+ * addWindow:
+ * @param {Meta.Window} window: the MetaWindow instance
+ *
+ * Creates a ClutterActor drawing the texture of @window and adds it
+ * to the container. If @window is already part of the preview, this
+ * function will do nothing.
+ *
+ * @returns {Clutter.Actor} The newly created actor drawing @window
+ */
+ addWindow(window) {
+ const index = [...this._windows.values()].findIndex(info =>
+ info.metaWindow === window);
+
+ if (index !== -1)
+ return null;
+
+ const windowActor = window.get_compositor_private();
+ const actor = new Clutter.Clone({ source: windowActor });
+
+ this._windows.set(actor, {
+ metaWindow: window,
+ windowActor,
+ sizeChangedId: window.connect('size-changed', () =>
+ this._layoutChanged()),
+ positionChangedId: window.connect('position-changed', () =>
+ this._layoutChanged()),
+ windowActorDestroyId: windowActor.connect('destroy', () =>
+ actor.destroy()),
+ destroyId: actor.connect('destroy', () =>
+ this.removeWindow(window)),
+ });
+
+ this._container.add_child(actor);
+
+ this._layoutChanged();
+
+ return actor;
+ }
+
+ /**
+ * removeWindow:
+ * @param {Meta.Window} window: the window to remove from the preview
+ *
+ * Removes a MetaWindow @window from the preview which has been added
+ * previously using addWindow(). If @window is not part of preview,
+ * this function will do nothing.
+ */
+ removeWindow(window) {
+ const entry = [...this._windows].find(
+ ([, i]) => i.metaWindow === window);
+
+ if (!entry)
+ return;
+
+ const [actor, windowInfo] = entry;
+
+ windowInfo.metaWindow.disconnect(windowInfo.sizeChangedId);
+ windowInfo.metaWindow.disconnect(windowInfo.positionChangedId);
+ windowInfo.windowActor.disconnect(windowInfo.windowActorDestroyId);
+ actor.disconnect(windowInfo.destroyId);
+
+ this._windows.delete(actor);
+ this._container.remove_child(actor);
+
+ this._layoutChanged();
+ }
+
+ /**
+ * getWindows:
+ *
+ * Gets an array of all MetaWindows that were added to the layout
+ * using addWindow(), ordered by the insertion order.
+ *
+ * @returns {Array} An array including all windows
+ */
+ getWindows() {
+ return [...this._windows.values()].map(i => i.metaWindow);
+ }
+
+ // eslint-disable-next-line camelcase
+ get bounding_box() {
+ return this._boundingBox;
+ }
+});
+
+var WindowPreview = GObject.registerClass({
+ Properties: {
+ 'overlay-enabled': GObject.ParamSpec.boolean(
+ 'overlay-enabled', 'overlay-enabled', 'overlay-enabled',
+ GObject.ParamFlags.READWRITE,
+ true),
+ },
+ Signals: {
+ 'drag-begin': {},
+ 'drag-cancelled': {},
+ 'drag-end': {},
+ 'selected': { param_types: [GObject.TYPE_UINT] },
+ 'show-chrome': {},
+ 'size-changed': {},
+ },
+}, class WindowPreview extends St.Widget {
+ _init(metaWindow, workspace) {
+ this.metaWindow = metaWindow;
+ this.metaWindow._delegate = this;
+ this._windowActor = metaWindow.get_compositor_private();
+ this._workspace = workspace;
+
+ super._init({
+ reactive: true,
+ can_focus: true,
+ accessible_role: Atk.Role.PUSH_BUTTON,
+ offscreen_redirect: Clutter.OffscreenRedirect.AUTOMATIC_FOR_OPACITY,
+ });
+
+ this._windowContainer = new Clutter.Actor();
+ // gjs currently can't handle setting an actors layout manager during
+ // the initialization of the actor if that layout manager keeps track
+ // of its container, so set the layout manager after creating the
+ // container
+ this._windowContainer.layout_manager = new WindowPreviewLayout();
+ this.add_child(this._windowContainer);
+
+ this._addWindow(metaWindow);
+
+ this._delegate = this;
+
+ this._stackAbove = null;
+
+ this._windowContainer.layout_manager.connect(
+ 'notify::bounding-box', layout => {
+ // A bounding box of 0x0 means all windows were removed
+ if (layout.bounding_box.get_area() > 0)
+ this.emit('size-changed');
+ });
+
+ this._windowDestroyId =
+ this._windowActor.connect('destroy', () => this.destroy());
+
+ this._updateAttachedDialogs();
+
+ let clickAction = new Clutter.ClickAction();
+ clickAction.connect('clicked', () => this._activate());
+ clickAction.connect('long-press', this._onLongPress.bind(this));
+ this.add_action(clickAction);
+ this.connect('destroy', this._onDestroy.bind(this));
+
+ this._draggable = DND.makeDraggable(this,
+ { restoreOnSuccess: true,
+ manualMode: true,
+ dragActorMaxSize: WINDOW_DND_SIZE,
+ dragActorOpacity: DRAGGING_WINDOW_OPACITY });
+ this._draggable.connect('drag-begin', this._onDragBegin.bind(this));
+ this._draggable.connect('drag-cancelled', this._onDragCancelled.bind(this));
+ this._draggable.connect('drag-end', this._onDragEnd.bind(this));
+ this.inDrag = false;
+
+ this._selected = false;
+ this._overlayEnabled = true;
+ this._closeRequested = false;
+ this._idleHideOverlayId = 0;
+
+ this._border = new St.Widget({
+ visible: false,
+ style_class: 'window-clone-border',
+ });
+ this._borderConstraint = new Clutter.BindConstraint({
+ source: this._windowContainer,
+ coordinate: Clutter.BindCoordinate.SIZE,
+ });
+ this._border.add_constraint(this._borderConstraint);
+ this._border.add_constraint(new Clutter.AlignConstraint({
+ source: this._windowContainer,
+ align_axis: Clutter.AlignAxis.BOTH,
+ factor: 0.5,
+ }));
+ this._borderCenter = new Clutter.Actor();
+ this._border.bind_property('visible', this._borderCenter, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+ this._borderCenterConstraint = new Clutter.BindConstraint({
+ source: this._windowContainer,
+ coordinate: Clutter.BindCoordinate.SIZE,
+ });
+ this._borderCenter.add_constraint(this._borderCenterConstraint);
+ this._borderCenter.add_constraint(new Clutter.AlignConstraint({
+ source: this._windowContainer,
+ align_axis: Clutter.AlignAxis.BOTH,
+ factor: 0.5,
+ }));
+ this._border.connect('style-changed',
+ this._onBorderStyleChanged.bind(this));
+
+ this._title = new St.Label({
+ visible: false,
+ style_class: 'window-caption',
+ text: this._getCaption(),
+ reactive: true,
+ });
+ this._title.add_constraint(new Clutter.BindConstraint({
+ source: this._borderCenter,
+ coordinate: Clutter.BindCoordinate.POSITION,
+ }));
+ this._title.add_constraint(new Clutter.AlignConstraint({
+ source: this._borderCenter,
+ align_axis: Clutter.AlignAxis.X_AXIS,
+ factor: 0.5,
+ }));
+ this._title.add_constraint(new Clutter.AlignConstraint({
+ source: this._borderCenter,
+ align_axis: Clutter.AlignAxis.Y_AXIS,
+ pivot_point: new Graphene.Point({ x: -1, y: 0.5 }),
+ factor: 1,
+ }));
+ this._title.clutter_text.ellipsize = Pango.EllipsizeMode.END;
+ this.label_actor = this._title;
+ this._updateCaptionId = this.metaWindow.connect('notify::title', () => {
+ this._title.text = this._getCaption();
+ });
+
+ const layout = Meta.prefs_get_button_layout();
+ this._closeButtonSide =
+ layout.left_buttons.includes(Meta.ButtonFunction.CLOSE)
+ ? St.Side.LEFT : St.Side.RIGHT;
+
+ this._closeButton = new St.Button({
+ visible: false,
+ style_class: 'window-close',
+ child: new St.Icon({ icon_name: 'window-close-symbolic' }),
+ });
+ this._closeButton.add_constraint(new Clutter.BindConstraint({
+ source: this._borderCenter,
+ coordinate: Clutter.BindCoordinate.POSITION,
+ }));
+ this._closeButton.add_constraint(new Clutter.AlignConstraint({
+ source: this._borderCenter,
+ align_axis: Clutter.AlignAxis.X_AXIS,
+ pivot_point: new Graphene.Point({ x: 0.5, y: -1 }),
+ factor: this._closeButtonSide === St.Side.LEFT ? 0 : 1,
+ }));
+ this._closeButton.add_constraint(new Clutter.AlignConstraint({
+ source: this._borderCenter,
+ align_axis: Clutter.AlignAxis.Y_AXIS,
+ pivot_point: new Graphene.Point({ x: -1, y: 0.5 }),
+ factor: 0,
+ }));
+ this._closeButton.connect('clicked', () => this._deleteAll());
+
+ this.add_child(this._borderCenter);
+ this.add_child(this._border);
+ this.add_child(this._title);
+ this.add_child(this._closeButton);
+
+ this.connect('notify::realized', () => {
+ if (!this.realized)
+ return;
+
+ this._border.ensure_style();
+ this._title.ensure_style();
+ });
+ }
+
+ vfunc_get_preferred_width(forHeight) {
+ const themeNode = this.get_theme_node();
+
+ // Only include window previews in size request, not chrome
+ const [minWidth, natWidth] =
+ this._windowContainer.get_preferred_width(
+ themeNode.adjust_for_height(forHeight));
+
+ return themeNode.adjust_preferred_width(minWidth, natWidth);
+ }
+
+ vfunc_get_preferred_height(forWidth) {
+ const themeNode = this.get_theme_node();
+ const [minHeight, natHeight] =
+ this._windowContainer.get_preferred_height(
+ themeNode.adjust_for_width(forWidth));
+
+ return themeNode.adjust_preferred_height(minHeight, natHeight);
+ }
+
+ vfunc_allocate(box) {
+ this.set_allocation(box);
+
+ for (const child of this)
+ child.allocate_available_size(0, 0, box.get_width(), box.get_height());
+ }
+
+ _onBorderStyleChanged() {
+ let borderNode = this._border.get_theme_node();
+ this._borderSize = borderNode.get_border_width(St.Side.TOP);
+
+ // Increase the size of the border actor so the border outlines
+ // the bounding box
+ this._borderConstraint.offset = this._borderSize * 2;
+ this._borderCenterConstraint.offset = this._borderSize;
+ }
+
+ _windowCanClose() {
+ return this.metaWindow.can_close() &&
+ !this._hasAttachedDialogs();
+ }
+
+ _getCaption() {
+ if (this.metaWindow.title)
+ return this.metaWindow.title;
+
+ let tracker = Shell.WindowTracker.get_default();
+ let app = tracker.get_window_app(this.metaWindow);
+ return app.get_name();
+ }
+
+ chromeHeights() {
+ const [, closeButtonHeight] = this._closeButton.get_preferred_height(-1);
+ const [, titleHeight] = this._title.get_preferred_height(-1);
+
+ const topOversize = (this._borderSize / 2) + (closeButtonHeight / 2);
+ const bottomOversize = Math.max(
+ this._borderSize,
+ (titleHeight / 2) + (this._borderSize / 2));
+
+ return [topOversize, bottomOversize];
+ }
+
+ chromeWidths() {
+ const [, closeButtonWidth] = this._closeButton.get_preferred_width(-1);
+
+ const leftOversize = this._closeButtonSide === St.Side.LEFT
+ ? (this._borderSize / 2) + (closeButtonWidth / 2)
+ : this._borderSize;
+ const rightOversize = this._closeButtonSide === St.Side.LEFT
+ ? this._borderSize
+ : (this._borderSize / 2) + (closeButtonWidth / 2);
+
+ return [leftOversize, rightOversize];
+ }
+
+ showOverlay(animate) {
+ if (!this._overlayEnabled)
+ return;
+
+ const ongoingTransition = this._border.get_transition('opacity');
+
+ // Don't do anything if we're fully visible already
+ if (this._border.visible && !ongoingTransition)
+ return;
+
+ // If we're supposed to animate and an animation in our direction
+ // is already happening, let that one continue
+ if (animate &&
+ ongoingTransition &&
+ ongoingTransition.get_interval().peek_final_value() === 255)
+ return;
+
+ const toShow = this._windowCanClose()
+ ? [this._border, this._title, this._closeButton]
+ : [this._border, this._title];
+
+ toShow.forEach(a => {
+ a.opacity = 0;
+ a.show();
+ a.ease({
+ opacity: 255,
+ duration: animate ? WINDOW_OVERLAY_FADE_TIME : 0,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ });
+
+ this.emit('show-chrome');
+ }
+
+ hideOverlay(animate) {
+ const ongoingTransition = this._border.get_transition('opacity');
+
+ // Don't do anything if we're fully hidden already
+ if (!this._border.visible && !ongoingTransition)
+ return;
+
+ // If we're supposed to animate and an animation in our direction
+ // is already happening, let that one continue
+ if (animate &&
+ ongoingTransition &&
+ ongoingTransition.get_interval().peek_final_value() === 0)
+ return;
+
+ [this._border, this._title, this._closeButton].forEach(a => {
+ a.opacity = 255;
+ a.ease({
+ opacity: 0,
+ duration: animate ? WINDOW_OVERLAY_FADE_TIME : 0,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => a.hide(),
+ });
+ });
+ }
+
+ _addWindow(metaWindow) {
+ const clone = this._windowContainer.layout_manager.addWindow(metaWindow);
+ if (!clone)
+ return;
+
+ // We expect this to be used for all interaction rather than
+ // the ClutterClone; as the former is reactive and the latter
+ // is not, this just works for most cases. However, for DND all
+ // actors are picked, so DND operations would operate on the clone.
+ // To avoid this, we hide it from pick.
+ Shell.util_set_hidden_from_pick(clone, true);
+ }
+
+ vfunc_has_overlaps() {
+ return this._hasAttachedDialogs();
+ }
+
+ _deleteAll() {
+ const windows = this._windowContainer.layout_manager.getWindows();
+
+ // Delete all windows, starting from the bottom-most (most-modal) one
+ for (const window of windows.reverse())
+ window.delete(global.get_current_time());
+
+ this._closeRequested = true;
+ }
+
+ addDialog(win) {
+ let parent = win.get_transient_for();
+ while (parent.is_attached_dialog())
+ parent = parent.get_transient_for();
+
+ // Display dialog if it is attached to our metaWindow
+ if (win.is_attached_dialog() && parent == this.metaWindow)
+ this._addWindow(win);
+
+ // The dialog popped up after the user tried to close the window,
+ // assume it's a close confirmation and leave the overview
+ if (this._closeRequested)
+ this._activate();
+ }
+
+ _hasAttachedDialogs() {
+ return this._windowContainer.layout_manager.getWindows().length > 1;
+ }
+
+ _updateAttachedDialogs() {
+ let iter = win => {
+ let actor = win.get_compositor_private();
+
+ if (!actor)
+ return false;
+ if (!win.is_attached_dialog())
+ return false;
+
+ this._addWindow(win);
+ win.foreach_transient(iter);
+ return true;
+ };
+ this.metaWindow.foreach_transient(iter);
+ }
+
+ get boundingBox() {
+ const box = this._windowContainer.layout_manager.bounding_box;
+
+ return {
+ x: box.x1,
+ y: box.y1,
+ width: box.get_width(),
+ height: box.get_height(),
+ };
+ }
+
+ get windowCenter() {
+ const box = this._windowContainer.layout_manager.bounding_box;
+
+ return new Graphene.Point({
+ x: box.get_x() + box.get_width() / 2,
+ y: box.get_y() + box.get_height() / 2,
+ });
+ }
+
+ // eslint-disable-next-line camelcase
+ get overlay_enabled() {
+ return this._overlayEnabled;
+ }
+
+ // eslint-disable-next-line camelcase
+ set overlay_enabled(enabled) {
+ if (this._overlayEnabled === enabled)
+ return;
+
+ this._overlayEnabled = enabled;
+ this.notify('overlay-enabled');
+
+ if (!enabled)
+ this.hideOverlay(false);
+ else if (this['has-pointer'] || global.stage.key_focus === this)
+ this.showOverlay(true);
+ }
+
+ // Find the actor just below us, respecting reparenting done by DND code
+ _getActualStackAbove() {
+ if (this._stackAbove == null)
+ return null;
+
+ if (this.inDrag) {
+ if (this._stackAbove._delegate)
+ return this._stackAbove._delegate._getActualStackAbove();
+ else
+ return null;
+ } else {
+ return this._stackAbove;
+ }
+ }
+
+ setStackAbove(actor) {
+ this._stackAbove = actor;
+ if (this.inDrag)
+ // We'll fix up the stack after the drag
+ return;
+
+ let parent = this.get_parent();
+ let actualAbove = this._getActualStackAbove();
+ if (actualAbove == null)
+ parent.set_child_below_sibling(this, null);
+ else
+ parent.set_child_above_sibling(this, actualAbove);
+ }
+
+ _onDestroy() {
+ this._windowActor.disconnect(this._windowDestroyId);
+
+ this.metaWindow._delegate = null;
+ this._delegate = null;
+
+ this.metaWindow.disconnect(this._updateCaptionId);
+
+ if (this._longPressLater) {
+ Meta.later_remove(this._longPressLater);
+ delete this._longPressLater;
+ }
+
+ if (this._idleHideOverlayId > 0) {
+ GLib.source_remove(this._idleHideOverlayId);
+ this._idleHideOverlayId = 0;
+ }
+
+ if (this.inDrag) {
+ this.emit('drag-end');
+ this.inDrag = false;
+ }
+ }
+
+ _activate() {
+ this._selected = true;
+ this.emit('selected', global.get_current_time());
+ }
+
+ vfunc_enter_event(crossingEvent) {
+ this.showOverlay(true);
+ return super.vfunc_enter_event(crossingEvent);
+ }
+
+ vfunc_leave_event(crossingEvent) {
+ if (this._idleHideOverlayId > 0)
+ GLib.source_remove(this._idleHideOverlayId);
+
+ this._idleHideOverlayId = GLib.timeout_add(
+ GLib.PRIORITY_DEFAULT,
+ WINDOW_OVERLAY_IDLE_HIDE_TIMEOUT, () => {
+ if (this._closeButton['has-pointer'] ||
+ this._title['has-pointer'])
+ return GLib.SOURCE_CONTINUE;
+
+ if (!this['has-pointer'])
+ this.hideOverlay(true);
+
+ this._idleHideOverlayId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+
+ GLib.Source.set_name_by_id(this._idleHideOverlayId, '[gnome-shell] this._idleHideOverlayId');
+
+ return super.vfunc_leave_event(crossingEvent);
+ }
+
+ vfunc_key_focus_in() {
+ super.vfunc_key_focus_in();
+ this.showOverlay(true);
+ }
+
+ vfunc_key_focus_out() {
+ super.vfunc_key_focus_out();
+ this.hideOverlay(true);
+ }
+
+ vfunc_key_press_event(keyEvent) {
+ let symbol = keyEvent.keyval;
+ let isEnter = symbol == Clutter.KEY_Return || symbol == Clutter.KEY_KP_Enter;
+ if (isEnter) {
+ this._activate();
+ return true;
+ }
+
+ return super.vfunc_key_press_event(keyEvent);
+ }
+
+ _onLongPress(action, actor, state) {
+ // Take advantage of the Clutter policy to consider
+ // a long-press canceled when the pointer movement
+ // exceeds dnd-drag-threshold to manually start the drag
+ if (state == Clutter.LongPressState.CANCEL) {
+ let event = Clutter.get_current_event();
+ this._dragTouchSequence = event.get_event_sequence();
+
+ if (this._longPressLater)
+ return true;
+
+ // A click cancels a long-press before any click handler is
+ // run - make sure to not start a drag in that case
+ this._longPressLater = Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
+ delete this._longPressLater;
+ if (this._selected)
+ return;
+ let [x, y] = action.get_coords();
+ action.release();
+ this._draggable.startDrag(x, y, global.get_current_time(), this._dragTouchSequence, event.get_device());
+ });
+ } else {
+ this.showOverlay(true);
+ }
+ return true;
+ }
+
+ _onDragBegin(_draggable, _time) {
+ this.inDrag = true;
+ this.hideOverlay(false);
+ this.emit('drag-begin');
+ }
+
+ handleDragOver(source, actor, x, y, time) {
+ return this._workspace.handleDragOver(source, actor, x, y, time);
+ }
+
+ acceptDrop(source, actor, x, y, time) {
+ return this._workspace.acceptDrop(source, actor, x, y, time);
+ }
+
+ _onDragCancelled(_draggable, _time) {
+ this.emit('drag-cancelled');
+ }
+
+ _onDragEnd(_draggable, _time, _snapback) {
+ this.inDrag = false;
+
+ // We may not have a parent if DnD completed successfully, in
+ // which case our clone will shortly be destroyed and replaced
+ // with a new one on the target workspace.
+ let parent = this.get_parent();
+ if (parent !== null) {
+ if (this._stackAbove == null)
+ parent.set_child_below_sibling(this, null);
+ else
+ parent.set_child_above_sibling(this, this._stackAbove);
+ }
+
+ if (this['has-pointer'])
+ this.showOverlay(true);
+
+ this.emit('drag-end');
+ }
+});
diff --git a/js/ui/workspace.js b/js/ui/workspace.js
new file mode 100644
index 0000000..6cd6a98
--- /dev/null
+++ b/js/ui/workspace.js
@@ -0,0 +1,1353 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Workspace */
+
+const { Clutter, GLib, GObject, St } = imports.gi;
+
+const DND = imports.ui.dnd;
+const Main = imports.ui.main;
+const Overview = imports.ui.overview;
+const { WindowPreview } = imports.ui.windowPreview;
+
+var WINDOW_PREVIEW_MAXIMUM_SCALE = 1.0;
+
+var WINDOW_REPOSITIONING_DELAY = 750;
+
+// When calculating a layout, we calculate the scale of windows and the percent
+// of the available area the new layout uses. If the values for the new layout,
+// when weighted with the values as below, are worse than the previous layout's,
+// we stop looking for a new layout and use the previous layout.
+// Otherwise, we keep looking for a new layout.
+var LAYOUT_SCALE_WEIGHT = 1;
+var LAYOUT_SPACE_WEIGHT = 0.1;
+
+var WINDOW_ANIMATION_MAX_NUMBER_BLENDING = 3;
+
+function _interpolate(start, end, step) {
+ return start + (end - start) * step;
+}
+
+// Window Thumbnail Layout Algorithm
+// =================================
+//
+// General overview
+// ----------------
+//
+// The window thumbnail layout algorithm calculates some optimal layout
+// by computing layouts with some number of rows, calculating how good
+// each layout is, and stopping iterating when it finds one that is worse
+// than the previous layout. A layout consists of which windows are in
+// which rows, row sizes and other general state tracking that would make
+// calculating window positions from this information fairly easy.
+//
+// After a layout is computed that's considered the best layout, we
+// compute the layout scale to fit it in the area, and then compute
+// slots (sizes and positions) for each thumbnail.
+//
+// Layout generation
+// -----------------
+//
+// Layout generation is naive and simple: we simply add windows to a row
+// until we've added too many windows to a row, and then make a new row,
+// until we have our required N rows. The potential issue with this strategy
+// is that we may have too many windows at the bottom in some pathological
+// cases, which tends to make the thumbnails have the shape of a pile of
+// sand with a peak, with one window at the top.
+//
+// Scaling factors
+// ---------------
+//
+// Thumbnail position is mostly straightforward -- the main issue is
+// computing an optimal scale for each window that fits the constraints,
+// and doesn't make the thumbnail too small to see. There are two factors
+// involved in thumbnail scale to make sure that these two goals are met:
+// the window scale (calculated by _computeWindowScale) and the layout
+// scale (calculated by computeSizeAndScale).
+//
+// The calculation logic becomes slightly more complicated because row
+// and column spacing are not scaled, they're constant, so we can't
+// simply generate a bunch of window positions and then scale it. In
+// practice, it's not too bad -- we can simply try to fit the layout
+// in the input area minus whatever spacing we have, and then add
+// it back afterwards.
+//
+// The window scale is constant for the window's size regardless of the
+// input area or the layout scale or rows or anything else, and right
+// now just enlarges the window if it's too small. The fact that this
+// factor is stable makes it easy to calculate, so there's no sense
+// in not applying it in most calculations.
+//
+// The layout scale depends on the input area, the rows, etc, but is the
+// same for the entire layout, rather than being per-window. After
+// generating the rows of windows, we basically do some basic math to
+// fit the full, unscaled layout to the input area, as described above.
+//
+// With these two factors combined, the final scale of each thumbnail is
+// simply windowScale * layoutScale... almost.
+//
+// There's one additional constraint: the thumbnail scale must never be
+// larger than WINDOW_PREVIEW_MAXIMUM_SCALE, which means that the inequality:
+//
+// windowScale * layoutScale <= WINDOW_PREVIEW_MAXIMUM_SCALE
+//
+// must always be true. This is for each individual window -- while we
+// could adjust layoutScale to make the largest thumbnail smaller than
+// WINDOW_PREVIEW_MAXIMUM_SCALE, it would shrink windows which are already
+// under the inequality. To solve this, we simply cheat: we simply keep
+// each window's "cell" area to be the same, but we shrink the thumbnail
+// and center it horizontally, and align it to the bottom vertically.
+
+var LayoutStrategy = class {
+ constructor(monitor, rowSpacing, columnSpacing) {
+ if (this.constructor === LayoutStrategy)
+ throw new TypeError(`Cannot instantiate abstract type ${this.constructor.name}`);
+
+ this._monitor = monitor;
+ this._rowSpacing = rowSpacing;
+ this._columnSpacing = columnSpacing;
+ }
+
+ _newRow() {
+ // Row properties:
+ //
+ // * x, y are the position of row, relative to area
+ //
+ // * width, height are the scaled versions of fullWidth, fullHeight
+ //
+ // * width also has the spacing in between windows. It's not in
+ // fullWidth, as the spacing is constant, whereas fullWidth is
+ // meant to be scaled
+ //
+ // * neither height/fullHeight have any sort of spacing or padding
+ return { x: 0, y: 0,
+ width: 0, height: 0,
+ fullWidth: 0, fullHeight: 0,
+ windows: [] };
+ }
+
+ // Computes and returns an individual scaling factor for @window,
+ // to be applied in addition to the overall layout scale.
+ _computeWindowScale(window) {
+ // Since we align windows next to each other, the height of the
+ // thumbnails is much more important to preserve than the width of
+ // them, so two windows with equal height, but maybe differering
+ // widths line up.
+ let ratio = window.boundingBox.height / this._monitor.height;
+
+ // The purpose of this manipulation here is to prevent windows
+ // from getting too small. For something like a calculator window,
+ // we need to bump up the size just a bit to make sure it looks
+ // good. We'll use a multiplier of 1.5 for this.
+
+ // Map from [0, 1] to [1.5, 1]
+ return _interpolate(1.5, 1, ratio);
+ }
+
+ // Compute the size of each row, by assigning to the properties
+ // row.width, row.height, row.fullWidth, row.fullHeight, and
+ // (optionally) for each row in @layout.rows. This method is
+ // intended to be called by subclasses.
+ _computeRowSizes(_layout) {
+ throw new GObject.NotImplementedError(`_computeRowSizes in ${this.constructor.name}`);
+ }
+
+ // Compute strategy-specific window slots for each window in
+ // @windows, given the @layout. The strategy may also use @layout
+ // as strategy-specific storage.
+ //
+ // This must calculate:
+ // * maxColumns - The maximum number of columns used by the layout.
+ // * gridWidth - The total width used by the grid, unscaled, unspaced.
+ // * gridHeight - The totial height used by the grid, unscaled, unspaced.
+ // * rows - A list of rows, which should be instantiated by _newRow.
+ computeLayout(_windows, _layout) {
+ throw new GObject.NotImplementedError(`computeLayout in ${this.constructor.name}`);
+ }
+
+ // Given @layout, compute the overall scale and space of the layout.
+ // The scale is the individual, non-fancy scale of each window, and
+ // the space is the percentage of the available area eventually
+ // used by the layout.
+
+ // This method does not return anything, but instead installs
+ // the properties "scale" and "space" on @layout directly.
+ //
+ // Make sure to call this methods before calling computeWindowSlots(),
+ // as it depends on the scale property installed in @layout here.
+ computeScaleAndSpace(layout) {
+ let area = layout.area;
+
+ let hspacing = (layout.maxColumns - 1) * this._columnSpacing;
+ let vspacing = (layout.numRows - 1) * this._rowSpacing;
+
+ let spacedWidth = area.width - hspacing;
+ let spacedHeight = area.height - vspacing;
+
+ let horizontalScale = spacedWidth / layout.gridWidth;
+ let verticalScale = spacedHeight / layout.gridHeight;
+
+ // Thumbnails should be less than 70% of the original size
+ let scale = Math.min(
+ horizontalScale, verticalScale, WINDOW_PREVIEW_MAXIMUM_SCALE);
+
+ let scaledLayoutWidth = layout.gridWidth * scale + hspacing;
+ let scaledLayoutHeight = layout.gridHeight * scale + vspacing;
+ let space = (scaledLayoutWidth * scaledLayoutHeight) / (area.width * area.height);
+
+ layout.scale = scale;
+ layout.space = space;
+ }
+
+ computeWindowSlots(layout, area) {
+ this._computeRowSizes(layout);
+
+ let { rows, scale } = layout;
+
+ let slots = [];
+
+ // Do this in three parts.
+ let heightWithoutSpacing = 0;
+ for (let i = 0; i < rows.length; i++) {
+ let row = rows[i];
+ heightWithoutSpacing += row.height;
+ }
+
+ let verticalSpacing = (rows.length - 1) * this._rowSpacing;
+ let additionalVerticalScale = Math.min(1, (area.height - verticalSpacing) / heightWithoutSpacing);
+
+ // keep track how much smaller the grid becomes due to scaling
+ // so it can be centered again
+ let compensation = 0;
+ let y = 0;
+
+ for (let i = 0; i < rows.length; i++) {
+ let row = rows[i];
+
+ // If this window layout row doesn't fit in the actual
+ // geometry, then apply an additional scale to it.
+ let horizontalSpacing = (row.windows.length - 1) * this._columnSpacing;
+ let widthWithoutSpacing = row.width - horizontalSpacing;
+ let additionalHorizontalScale = Math.min(1, (area.width - horizontalSpacing) / widthWithoutSpacing);
+
+ if (additionalHorizontalScale < additionalVerticalScale) {
+ row.additionalScale = additionalHorizontalScale;
+ // Only consider the scaling in addition to the vertical scaling for centering.
+ compensation += (additionalVerticalScale - additionalHorizontalScale) * row.height;
+ } else {
+ row.additionalScale = additionalVerticalScale;
+ // No compensation when scaling vertically since centering based on a too large
+ // height would undo what vertical scaling is trying to achieve.
+ }
+
+ row.x = area.x + (Math.max(area.width - (widthWithoutSpacing * row.additionalScale + horizontalSpacing), 0) / 2);
+ row.y = area.y + (Math.max(area.height - (heightWithoutSpacing + verticalSpacing), 0) / 2) + y;
+ y += row.height * row.additionalScale + this._rowSpacing;
+ }
+
+ compensation /= 2;
+
+ for (let i = 0; i < rows.length; i++) {
+ let row = rows[i];
+ let x = row.x;
+ for (let j = 0; j < row.windows.length; j++) {
+ let window = row.windows[j];
+
+ let s = scale * this._computeWindowScale(window) * row.additionalScale;
+ let cellWidth = window.boundingBox.width * s;
+ let cellHeight = window.boundingBox.height * s;
+
+ s = Math.min(s, WINDOW_PREVIEW_MAXIMUM_SCALE);
+ let cloneWidth = window.boundingBox.width * s;
+ const cloneHeight = window.boundingBox.height * s;
+
+ let cloneX = x + (cellWidth - cloneWidth) / 2;
+ let cloneY = row.y + row.height * row.additionalScale - cellHeight + compensation;
+
+ // Align with the pixel grid to prevent blurry windows at scale = 1
+ cloneX = Math.floor(cloneX);
+ cloneY = Math.floor(cloneY);
+
+ slots.push([cloneX, cloneY, cloneWidth, cloneHeight, window]);
+ x += cellWidth + this._columnSpacing;
+ }
+ }
+ return slots;
+ }
+};
+
+var UnalignedLayoutStrategy = class extends LayoutStrategy {
+ _computeRowSizes(layout) {
+ let { rows, scale } = layout;
+ for (let i = 0; i < rows.length; i++) {
+ let row = rows[i];
+ row.width = row.fullWidth * scale + (row.windows.length - 1) * this._columnSpacing;
+ row.height = row.fullHeight * scale;
+ }
+ }
+
+ _keepSameRow(row, window, width, idealRowWidth) {
+ if (row.fullWidth + width <= idealRowWidth)
+ return true;
+
+ let oldRatio = row.fullWidth / idealRowWidth;
+ let newRatio = (row.fullWidth + width) / idealRowWidth;
+
+ if (Math.abs(1 - newRatio) < Math.abs(1 - oldRatio))
+ return true;
+
+ return false;
+ }
+
+ _sortRow(row) {
+ // Sort windows horizontally to minimize travel distance.
+ // This affects in what order the windows end up in a row.
+ row.windows.sort((a, b) => a.windowCenter.x - b.windowCenter.x);
+ }
+
+ computeLayout(windows, layout) {
+ let numRows = layout.numRows;
+
+ let rows = [];
+ let totalWidth = 0;
+ for (let i = 0; i < windows.length; i++) {
+ let window = windows[i];
+ let s = this._computeWindowScale(window);
+ totalWidth += window.boundingBox.width * s;
+ }
+
+ let idealRowWidth = totalWidth / numRows;
+
+ // Sort windows vertically to minimize travel distance.
+ // This affects what rows the windows get placed in.
+ let sortedWindows = windows.slice();
+ sortedWindows.sort((a, b) => a.windowCenter.y - b.windowCenter.y);
+
+ let windowIdx = 0;
+ for (let i = 0; i < numRows; i++) {
+ let row = this._newRow();
+ rows.push(row);
+
+ for (; windowIdx < sortedWindows.length; windowIdx++) {
+ let window = sortedWindows[windowIdx];
+ let s = this._computeWindowScale(window);
+ let width = window.boundingBox.width * s;
+ let height = window.boundingBox.height * s;
+ row.fullHeight = Math.max(row.fullHeight, height);
+
+ // either new width is < idealWidth or new width is nearer from idealWidth then oldWidth
+ if (this._keepSameRow(row, window, width, idealRowWidth) || (i == numRows - 1)) {
+ row.windows.push(window);
+ row.fullWidth += width;
+ } else {
+ break;
+ }
+ }
+ }
+
+ let gridHeight = 0;
+ let maxRow;
+ for (let i = 0; i < numRows; i++) {
+ let row = rows[i];
+ this._sortRow(row);
+
+ if (!maxRow || row.fullWidth > maxRow.fullWidth)
+ maxRow = row;
+ gridHeight += row.fullHeight;
+ }
+
+ layout.rows = rows;
+ layout.maxColumns = maxRow.windows.length;
+ layout.gridWidth = maxRow.fullWidth;
+ layout.gridHeight = gridHeight;
+ }
+};
+
+function animateAllocation(actor, box) {
+ if (actor.allocation.equal(box) ||
+ actor.allocation.get_width() === 0 ||
+ actor.allocation.get_height() === 0) {
+ actor.allocate(box);
+ return null;
+ }
+
+ actor.save_easing_state();
+ actor.set_easing_mode(Clutter.AnimationMode.EASE_OUT_QUAD);
+ actor.set_easing_duration(200);
+
+ actor.allocate(box);
+
+ actor.restore_easing_state();
+
+ return actor.get_transition('allocation');
+}
+
+var WorkspaceLayout = GObject.registerClass({
+ Properties: {
+ 'spacing': GObject.ParamSpec.double(
+ 'spacing', 'Spacing', 'Spacing',
+ GObject.ParamFlags.READWRITE,
+ 0, Infinity, 20),
+ 'layout-frozen': GObject.ParamSpec.boolean(
+ 'layout-frozen', 'Layout frozen', 'Layout frozen',
+ GObject.ParamFlags.READWRITE,
+ false),
+ },
+}, class WorkspaceLayout extends Clutter.LayoutManager {
+ _init(metaWorkspace, monitorIndex) {
+ super._init();
+
+ this._spacing = 20;
+ this._layoutFrozen = false;
+
+ this._monitorIndex = monitorIndex;
+ this._workarea = metaWorkspace
+ ? metaWorkspace.get_work_area_for_monitor(this._monitorIndex)
+ : Main.layoutManager.getWorkAreaForMonitor(this._monitorIndex);
+
+ this._container = null;
+ this._windows = new Map();
+ this._sortedWindows = [];
+ this._lastBox = null;
+ this._windowSlots = [];
+ this._layout = null;
+
+ this._stateAdjustment = new St.Adjustment({
+ value: 0,
+ lower: 0,
+ upper: 1,
+ });
+
+ this._stateAdjustment.connect('notify::value', () => {
+ [...this._windows.keys()].forEach(
+ preview => this._syncOverlay(preview));
+ this.layout_changed();
+ });
+ }
+
+ _isBetterLayout(oldLayout, newLayout) {
+ if (oldLayout.scale === undefined)
+ return true;
+
+ let spacePower = (newLayout.space - oldLayout.space) * LAYOUT_SPACE_WEIGHT;
+ let scalePower = (newLayout.scale - oldLayout.scale) * LAYOUT_SCALE_WEIGHT;
+
+ if (newLayout.scale > oldLayout.scale && newLayout.space > oldLayout.space) {
+ // Win win -- better scale and better space
+ return true;
+ } else if (newLayout.scale > oldLayout.scale && newLayout.space <= oldLayout.space) {
+ // Keep new layout only if scale gain outweighs aspect space loss
+ return scalePower > spacePower;
+ } else if (newLayout.scale <= oldLayout.scale && newLayout.space > oldLayout.space) {
+ // Keep new layout only if aspect space gain outweighs scale loss
+ return spacePower > scalePower;
+ } else {
+ // Lose -- worse scale and space
+ return false;
+ }
+ }
+
+ _adjustSpacingAndPadding(rowSpacing, colSpacing, containerBox) {
+ if (this._sortedWindows.length === 0)
+ return [colSpacing, rowSpacing, containerBox];
+
+ // All of the overlays have the same chrome sizes,
+ // so just pick the first one.
+ const window = this._sortedWindows[0];
+
+ const [topOversize, bottomOversize] = window.chromeHeights();
+ const [leftOversize, rightOversize] = window.chromeWidths();
+
+ if (rowSpacing)
+ rowSpacing += Math.max(topOversize, bottomOversize);
+ if (colSpacing)
+ colSpacing += Math.max(leftOversize, rightOversize);
+
+ if (containerBox) {
+ containerBox.x1 += leftOversize;
+ containerBox.x2 -= rightOversize;
+ containerBox.y1 += topOversize;
+ containerBox.y2 -= bottomOversize;
+ }
+
+ return [rowSpacing, colSpacing, containerBox];
+ }
+
+ _createBestLayout(area) {
+ const [rowSpacing, colSpacing] =
+ this._adjustSpacingAndPadding(this._spacing, this._spacing, null);
+
+ // We look for the largest scale that allows us to fit the
+ // largest row/tallest column on the workspace.
+ const strategy = new UnalignedLayoutStrategy(
+ Main.layoutManager.monitors[this._monitorIndex],
+ rowSpacing,
+ colSpacing);
+
+ let lastLayout = {};
+
+ for (let numRows = 1; ; numRows++) {
+ let numColumns = Math.ceil(this._sortedWindows.length / numRows);
+
+ // If adding a new row does not change column count just stop
+ // (for instance: 9 windows, with 3 rows -> 3 columns, 4 rows ->
+ // 3 columns as well => just use 3 rows then)
+ if (numColumns === lastLayout.numColumns)
+ break;
+
+ let layout = { area, strategy, numRows, numColumns };
+ strategy.computeLayout(this._sortedWindows, layout);
+ strategy.computeScaleAndSpace(layout);
+
+ if (!this._isBetterLayout(lastLayout, layout))
+ break;
+
+ lastLayout = layout;
+ }
+
+ return lastLayout;
+ }
+
+ _getWindowSlots(containerBox) {
+ [, , containerBox] =
+ this._adjustSpacingAndPadding(null, null, containerBox);
+
+ const availArea = {
+ x: parseInt(containerBox.x1),
+ y: parseInt(containerBox.y1),
+ width: parseInt(containerBox.get_width()),
+ height: parseInt(containerBox.get_height()),
+ };
+
+ return this._layout.strategy.computeWindowSlots(this._layout, availArea);
+ }
+
+ _getAdjustedWorkarea(container) {
+ const workarea = this._workarea.copy();
+
+ if (container instanceof St.Widget) {
+ const themeNode = container.get_theme_node();
+ workarea.width -= themeNode.get_horizontal_padding();
+ workarea.height -= themeNode.get_vertical_padding();
+ }
+
+ return workarea;
+ }
+
+ vfunc_set_container(container) {
+ this._container = container;
+ this._stateAdjustment.actor = container;
+ }
+
+ vfunc_get_preferred_width(container, forHeight) {
+ const workarea = this._getAdjustedWorkarea(container);
+ if (forHeight === -1)
+ return [0, workarea.width];
+
+ const workAreaAspectRatio = workarea.width / workarea.height;
+ const widthPreservingAspectRatio = forHeight * workAreaAspectRatio;
+
+ return [0, widthPreservingAspectRatio];
+ }
+
+ vfunc_get_preferred_height(container, forWidth) {
+ const workarea = this._getAdjustedWorkarea(container);
+ if (forWidth === -1)
+ return [0, workarea.height];
+
+ const workAreaAspectRatio = workarea.width / workarea.height;
+ const heightPreservingAspectRatio = forWidth / workAreaAspectRatio;
+
+ return [0, heightPreservingAspectRatio];
+ }
+
+ vfunc_allocate(container, box) {
+ const containerBox = container.allocation;
+ const containerAllocationChanged =
+ this._lastBox === null || !this._lastBox.equal(containerBox);
+ this._lastBox = containerBox.copy();
+
+ // If the containers size changed, we can no longer keep around
+ // the old windowSlots, so we must unfreeze the layout.
+ //
+ // However, if the overview animation is in progress, don't unfreeze
+ // the layout. This is needed to prevent windows "snapping" to their
+ // new positions during the overview closing animation when the
+ // allocation subtly expands every frame.
+ if (this._layoutFrozen && containerAllocationChanged && !Main.overview.animationInProgress) {
+ this._layoutFrozen = false;
+ this.notify('layout-frozen');
+ }
+
+ let layoutChanged = false;
+ if (!this._layoutFrozen) {
+ if (this._layout === null) {
+ this._layout = this._createBestLayout(this._workarea);
+ layoutChanged = true;
+ }
+
+ if (layoutChanged || containerAllocationChanged)
+ this._windowSlots = this._getWindowSlots(box.copy());
+ }
+
+ const allocationScale = containerBox.get_width() / this._workarea.width;
+
+ const workspaceBox = new Clutter.ActorBox();
+ const layoutBox = new Clutter.ActorBox();
+ let childBox = new Clutter.ActorBox();
+
+ for (const child of container) {
+ if (!child.visible)
+ continue;
+
+ // The fifth element in the slot array is the WindowPreview
+ const index = this._windowSlots.findIndex(s => s[4] === child);
+ if (index === -1) {
+ log('Couldn\'t find child %s in window slots'.format(child));
+ child.allocate(childBox);
+ continue;
+ }
+
+ const [x, y, width, height] = this._windowSlots[index];
+ const windowInfo = this._windows.get(child);
+
+ if (windowInfo.metaWindow.showing_on_its_workspace()) {
+ workspaceBox.x1 = child.boundingBox.x - this._workarea.x;
+ workspaceBox.x2 = workspaceBox.x1 + child.boundingBox.width;
+ workspaceBox.y1 = child.boundingBox.y - this._workarea.y;
+ workspaceBox.y2 = workspaceBox.y1 + child.boundingBox.height;
+ } else {
+ workspaceBox.set_origin(this._workarea.x, this._workarea.y);
+ workspaceBox.set_size(0, 0);
+
+ child.opacity = this._stateAdjustment.value * 255;
+ }
+
+ workspaceBox.scale(allocationScale);
+ // don't allow the scaled floating size to drop below
+ // the target layout size
+ workspaceBox.set_size(
+ Math.max(workspaceBox.get_width(), width),
+ Math.max(workspaceBox.get_height(), height));
+
+ layoutBox.x1 = x;
+ layoutBox.x2 = layoutBox.x1 + width;
+ layoutBox.y1 = y;
+ layoutBox.y2 = layoutBox.y1 + height;
+
+ childBox = workspaceBox.interpolate(layoutBox,
+ this._stateAdjustment.value);
+
+ if (windowInfo.currentTransition) {
+ windowInfo.currentTransition.get_interval().set_final(childBox);
+
+ // The timeline of the transition might not have been updated
+ // before this allocation cycle, so make sure the child
+ // still updates needs_allocation to FALSE.
+ // Unfortunately, this relies on the fast paths in
+ // clutter_actor_allocate(), otherwise we'd start a new
+ // transition on the child, replacing the current one.
+ child.allocate(child.allocation);
+ continue;
+ }
+
+ // We want layout changes (ie. larger changes to the layout like
+ // reshuffling the window order) to be animated, but small changes
+ // like changes to the container size to happen immediately (for
+ // example if the container height is being animated, we want to
+ // avoid animating the children allocations to make sure they
+ // don't "lag behind" the other animation).
+ if (layoutChanged && !Main.overview.animationInProgress) {
+ const transition = animateAllocation(child, childBox);
+ if (transition) {
+ windowInfo.currentTransition = transition;
+ windowInfo.currentTransition.connect('stopped', () => {
+ windowInfo.currentTransition = null;
+ });
+ }
+ } else {
+ child.allocate(childBox);
+ }
+ }
+ }
+
+ _syncOverlay(preview) {
+ preview.overlay_enabled = this._stateAdjustment.value === 1;
+ }
+
+ /**
+ * addWindow:
+ * @param {WindowPreview} window: the window to add
+ * @param {Meta.Window} metaWindow: the MetaWindow of the window
+ *
+ * Adds @window to the workspace, it will be shown immediately if
+ * the layout isn't frozen using the layout-frozen property.
+ *
+ * If @window is already part of the workspace, nothing will happen.
+ */
+ addWindow(window, metaWindow) {
+ if (this._windows.has(window))
+ return;
+
+ this._windows.set(window, {
+ metaWindow,
+ sizeChangedId: metaWindow.connect('size-changed', () => {
+ this._layout = null;
+ this.layout_changed();
+ }),
+ destroyId: window.connect('destroy', () =>
+ this.removeWindow(window)),
+ currentTransition: null,
+ });
+
+ this._sortedWindows.push(window);
+ this._sortedWindows.sort((a, b) => {
+ const winA = this._windows.get(a).metaWindow;
+ const winB = this._windows.get(b).metaWindow;
+
+ return winA.get_stable_sequence() - winB.get_stable_sequence();
+ });
+
+ this._syncOverlay(window);
+ this._container.add_child(window);
+
+ this._layout = null;
+ this.layout_changed();
+ }
+
+ /**
+ * removeWindow:
+ * @param {WindowPreview} window: the window to remove
+ *
+ * Removes @window from the workspace if @window is a part of the
+ * workspace. If the layout-frozen property is set to true, the
+ * window will still be visible until the property is set to false.
+ */
+ removeWindow(window) {
+ const windowInfo = this._windows.get(window);
+ if (!windowInfo)
+ return;
+
+ windowInfo.metaWindow.disconnect(windowInfo.sizeChangedId);
+ window.disconnect(windowInfo.destroyId);
+ if (windowInfo.currentTransition)
+ window.remove_transition('allocation');
+
+ this._windows.delete(window);
+ this._sortedWindows.splice(this._sortedWindows.indexOf(window), 1);
+
+ // The layout might be frozen and we might not update the windowSlots
+ // on the next allocation, so remove the slot now already
+ const index = this._windowSlots.findIndex(s => s[4] === window);
+ if (index !== -1)
+ this._windowSlots.splice(index, 1);
+
+ // The window might have been reparented by DND
+ if (window.get_parent() === this._container)
+ this._container.remove_child(window);
+
+ this._layout = null;
+ this.layout_changed();
+ }
+
+ syncStacking(stackIndices) {
+ const windows = [...this._windows.keys()];
+ windows.sort((a, b) => {
+ const seqA = this._windows.get(a).metaWindow.get_stable_sequence();
+ const seqB = this._windows.get(b).metaWindow.get_stable_sequence();
+
+ return stackIndices[seqA] - stackIndices[seqB];
+ });
+
+ let lastWindow = null;
+ for (const window of windows) {
+ window.setStackAbove(lastWindow);
+ lastWindow = window;
+ }
+
+ this._layout = null;
+ this.layout_changed();
+ }
+
+ /**
+ * getFocusChain:
+ *
+ * Gets the focus chain of the workspace. This function will return
+ * an empty array if the floating window layout is used.
+ *
+ * @returns {Array} an array of {Clutter.Actor}s
+ */
+ getFocusChain() {
+ if (this._stateAdjustment.value === 0)
+ return [];
+
+ // The fifth element in the slot array is the WindowPreview
+ return this._windowSlots.map(s => s[4]);
+ }
+
+ /**
+ * An StAdjustment for controlling and transitioning between
+ * the alignment of windows using the layout strategy and the
+ * floating window layout.
+ *
+ * A value of 0 of the adjustment completely uses the floating
+ * window layout while a value of 1 completely aligns windows using
+ * the layout strategy.
+ *
+ * @type {St.Adjustment}
+ */
+ get stateAdjustment() {
+ return this._stateAdjustment;
+ }
+
+ get spacing() {
+ return this._spacing;
+ }
+
+ set spacing(s) {
+ if (this._spacing === s)
+ return;
+
+ this._spacing = s;
+
+ this._layout = null;
+ this.notify('spacing');
+ this.layout_changed();
+ }
+
+ // eslint-disable-next-line camelcase
+ get layout_frozen() {
+ return this._layoutFrozen;
+ }
+
+ // eslint-disable-next-line camelcase
+ set layout_frozen(f) {
+ if (this._layoutFrozen === f)
+ return;
+
+ this._layoutFrozen = f;
+
+ this.notify('layout-frozen');
+ if (!this._layoutFrozen)
+ this.layout_changed();
+ }
+});
+
+/**
+ * @metaWorkspace: a #Meta.Workspace, or null
+ */
+var Workspace = GObject.registerClass(
+class Workspace extends St.Widget {
+ _init(metaWorkspace, monitorIndex) {
+ super._init({
+ style_class: 'window-picker',
+ layout_manager: new WorkspaceLayout(metaWorkspace, monitorIndex),
+ });
+
+ this.metaWorkspace = metaWorkspace;
+
+ this.monitorIndex = monitorIndex;
+ this._monitor = Main.layoutManager.monitors[this.monitorIndex];
+
+ if (monitorIndex != Main.layoutManager.primaryIndex)
+ this.add_style_class_name('external-monitor');
+
+ this.connect('style-changed', this._onStyleChanged.bind(this));
+ this.connect('destroy', this._onDestroy.bind(this));
+
+ const windows = global.get_window_actors().map(a => a.meta_window)
+ .filter(this._isMyWindow, this);
+
+ // Create clones for windows that should be
+ // visible in the Overview
+ this._windows = [];
+ for (let i = 0; i < windows.length; i++) {
+ if (this._isOverviewWindow(windows[i]))
+ this._addWindowClone(windows[i]);
+ }
+
+ // Track window changes
+ if (this.metaWorkspace) {
+ this._windowAddedId = this.metaWorkspace.connect('window-added',
+ this._windowAdded.bind(this));
+ this._windowRemovedId = this.metaWorkspace.connect('window-removed',
+ this._windowRemoved.bind(this));
+ }
+ this._windowEnteredMonitorId = global.display.connect('window-entered-monitor',
+ this._windowEnteredMonitor.bind(this));
+ this._windowLeftMonitorId = global.display.connect('window-left-monitor',
+ this._windowLeftMonitor.bind(this));
+ this._layoutFrozenId = 0;
+
+ // DND requires this to be set
+ this._delegate = this;
+ }
+
+ vfunc_get_focus_chain() {
+ return this.layout_manager.getFocusChain();
+ }
+
+ _lookupIndex(metaWindow) {
+ return this._windows.findIndex(w => w.metaWindow == metaWindow);
+ }
+
+ containsMetaWindow(metaWindow) {
+ return this._lookupIndex(metaWindow) >= 0;
+ }
+
+ isEmpty() {
+ return this._windows.length == 0;
+ }
+
+ syncStacking(stackIndices) {
+ this.layout_manager.syncStacking(stackIndices);
+ }
+
+ _doRemoveWindow(metaWin) {
+ let clone = this._removeWindowClone(metaWin);
+
+ if (!clone)
+ return;
+
+ clone.destroy();
+
+ // We need to reposition the windows; to avoid shuffling windows
+ // around while the user is interacting with the workspace, we delay
+ // the positioning until the pointer remains still for at least 750 ms
+ // or is moved outside the workspace
+ this.layout_manager.layout_frozen = true;
+
+ if (this._layoutFrozenId > 0) {
+ GLib.source_remove(this._layoutFrozenId);
+ this._layoutFrozenId = 0;
+ }
+
+ let [oldX, oldY] = global.get_pointer();
+
+ this._layoutFrozenId = GLib.timeout_add(
+ GLib.PRIORITY_DEFAULT,
+ WINDOW_REPOSITIONING_DELAY,
+ () => {
+ const [newX, newY] = global.get_pointer();
+ const pointerHasMoved = oldX !== newX || oldY !== newY;
+ const actorUnderPointer = global.stage.get_actor_at_pos(
+ Clutter.PickMode.REACTIVE, newX, newY);
+
+ if ((pointerHasMoved && this.contains(actorUnderPointer)) ||
+ this._windows.some(w => w.contains(actorUnderPointer))) {
+ oldX = newX;
+ oldY = newY;
+ return GLib.SOURCE_CONTINUE;
+ }
+
+ this.layout_manager.layout_frozen = false;
+ this._layoutFrozenId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+
+ GLib.Source.set_name_by_id(this._layoutFrozenId,
+ '[gnome-shell] this._layoutFrozenId');
+ }
+
+ _doAddWindow(metaWin) {
+ let win = metaWin.get_compositor_private();
+
+ if (!win) {
+ // Newly-created windows are added to a workspace before
+ // the compositor finds out about them...
+ let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
+ if (metaWin.get_compositor_private() &&
+ metaWin.get_workspace() == this.metaWorkspace)
+ this._doAddWindow(metaWin);
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(id, '[gnome-shell] this._doAddWindow');
+ return;
+ }
+
+ // We might have the window in our list already if it was on all workspaces and
+ // now was moved to this workspace
+ if (this._lookupIndex(metaWin) != -1)
+ return;
+
+ if (!this._isMyWindow(metaWin))
+ return;
+
+ if (!this._isOverviewWindow(metaWin)) {
+ if (metaWin.get_transient_for() == null)
+ return;
+
+ // Let the top-most ancestor handle all transients
+ let parent = metaWin.find_root_ancestor();
+ let clone = this._windows.find(c => c.metaWindow == parent);
+
+ // If no clone was found, the parent hasn't been created yet
+ // and will take care of the dialog when added
+ if (clone)
+ clone.addDialog(metaWin);
+
+ return;
+ }
+
+ const clone = this._addWindowClone(metaWin);
+
+ clone.set_pivot_point(0.5, 0.5);
+ clone.scale_x = 0;
+ clone.scale_y = 0;
+ clone.ease({
+ scale_x: 1,
+ scale_y: 1,
+ duration: 250,
+ onStopped: () => clone.set_pivot_point(0, 0),
+ });
+
+ if (this._layoutFrozenId > 0) {
+ // If a window was closed before, unfreeze the layout to ensure
+ // the new window is immediately shown
+ this.layout_manager.layout_frozen = false;
+
+ GLib.source_remove(this._layoutFrozenId);
+ this._layoutFrozenId = 0;
+ }
+ }
+
+ _windowAdded(metaWorkspace, metaWin) {
+ this._doAddWindow(metaWin);
+ }
+
+ _windowRemoved(metaWorkspace, metaWin) {
+ this._doRemoveWindow(metaWin);
+ }
+
+ _windowEnteredMonitor(metaDisplay, monitorIndex, metaWin) {
+ if (monitorIndex == this.monitorIndex)
+ this._doAddWindow(metaWin);
+ }
+
+ _windowLeftMonitor(metaDisplay, monitorIndex, metaWin) {
+ if (monitorIndex == this.monitorIndex)
+ this._doRemoveWindow(metaWin);
+ }
+
+ // check for maximized windows on the workspace
+ hasMaximizedWindows() {
+ for (let i = 0; i < this._windows.length; i++) {
+ let metaWindow = this._windows[i].metaWindow;
+ if (metaWindow.showing_on_its_workspace() &&
+ metaWindow.maximized_horizontally &&
+ metaWindow.maximized_vertically)
+ return true;
+ }
+ return false;
+ }
+
+ fadeToOverview() {
+ // We don't want to reposition windows while animating in this way.
+ this.layout_manager.layout_frozen = true;
+ this._overviewShownId = Main.overview.connect('shown', this._doneShowingOverview.bind(this));
+ if (this._windows.length == 0)
+ return;
+
+ if (this.metaWorkspace !== null && !this.metaWorkspace.active)
+ return;
+
+ this.layout_manager.stateAdjustment.value = 0;
+
+ // Special case maximized windows, since it doesn't make sense
+ // to animate windows below in the stack
+ let topMaximizedWindow;
+ // It is ok to treat the case where there is no maximized
+ // window as if the bottom-most window was maximized given that
+ // it won't affect the result of the animation
+ for (topMaximizedWindow = this._windows.length - 1; topMaximizedWindow > 0; topMaximizedWindow--) {
+ let metaWindow = this._windows[topMaximizedWindow].metaWindow;
+ if (metaWindow.maximized_horizontally && metaWindow.maximized_vertically)
+ break;
+ }
+
+ let nTimeSlots = Math.min(WINDOW_ANIMATION_MAX_NUMBER_BLENDING + 1, this._windows.length - topMaximizedWindow);
+ let windowBaseTime = Overview.ANIMATION_TIME / nTimeSlots;
+
+ let topIndex = this._windows.length - 1;
+ for (let i = 0; i < this._windows.length; i++) {
+ if (i < topMaximizedWindow) {
+ // below top-most maximized window, don't animate
+ this._windows[i].hideOverlay(false);
+ this._windows[i].opacity = 0;
+ } else {
+ let fromTop = topIndex - i;
+ let time;
+ if (fromTop < nTimeSlots) // animate top-most windows gradually
+ time = windowBaseTime * (nTimeSlots - fromTop);
+ else
+ time = windowBaseTime;
+
+ this._windows[i].opacity = 255;
+ this._fadeWindow(i, time, 0);
+ }
+ }
+ }
+
+ fadeFromOverview() {
+ this.layout_manager.layout_frozen = true;
+ this._overviewHiddenId = Main.overview.connect('hidden', this._doneLeavingOverview.bind(this));
+ if (this._windows.length == 0)
+ return;
+
+ for (let i = 0; i < this._windows.length; i++)
+ this._windows[i].remove_all_transitions();
+
+ if (this._layoutFrozenId > 0) {
+ GLib.source_remove(this._layoutFrozenId);
+ this._layoutFrozenId = 0;
+ }
+
+ if (this.metaWorkspace !== null && !this.metaWorkspace.active)
+ return;
+
+ this.layout_manager.stateAdjustment.value = 0;
+
+ // Special case maximized windows, since it doesn't make sense
+ // to animate windows below in the stack
+ let topMaximizedWindow;
+ // It is ok to treat the case where there is no maximized
+ // window as if the bottom-most window was maximized given that
+ // it won't affect the result of the animation
+ for (topMaximizedWindow = this._windows.length - 1; topMaximizedWindow > 0; topMaximizedWindow--) {
+ let metaWindow = this._windows[topMaximizedWindow].metaWindow;
+ if (metaWindow.maximized_horizontally && metaWindow.maximized_vertically)
+ break;
+ }
+
+ let nTimeSlots = Math.min(WINDOW_ANIMATION_MAX_NUMBER_BLENDING + 1, this._windows.length - topMaximizedWindow);
+ let windowBaseTime = Overview.ANIMATION_TIME / nTimeSlots;
+
+ let topIndex = this._windows.length - 1;
+ for (let i = 0; i < this._windows.length; i++) {
+ if (i < topMaximizedWindow) {
+ // below top-most maximized window, don't animate
+ this._windows[i].hideOverlay(false);
+ this._windows[i].opacity = 0;
+ } else {
+ let fromTop = topIndex - i;
+ let time;
+ if (fromTop < nTimeSlots) // animate top-most windows gradually
+ time = windowBaseTime * (fromTop + 1);
+ else
+ time = windowBaseTime * nTimeSlots;
+
+ this._windows[i].opacity = 0;
+ this._fadeWindow(i, time, 255);
+ }
+ }
+ }
+
+ _fadeWindow(index, duration, opacity) {
+ let clone = this._windows[index];
+ clone.hideOverlay(false);
+
+ if (clone.metaWindow.showing_on_its_workspace()) {
+ clone.ease({
+ opacity,
+ duration,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ } else {
+ // The window is hidden
+ clone.opacity = 0;
+ }
+ }
+
+ zoomToOverview() {
+ const animate =
+ this.metaWorkspace === null || this.metaWorkspace.active;
+
+ const adj = this.layout_manager.stateAdjustment;
+ adj.ease(1, {
+ duration: animate ? Overview.ANIMATION_TIME : 0,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ zoomFromOverview() {
+ for (let i = 0; i < this._windows.length; i++)
+ this._windows[i].remove_all_transitions();
+
+ if (this._layoutFrozenId > 0) {
+ GLib.source_remove(this._layoutFrozenId);
+ this._layoutFrozenId = 0;
+ }
+
+ this.layout_manager.layout_frozen = true;
+ this._overviewHiddenId = Main.overview.connect('hidden', this._doneLeavingOverview.bind(this));
+
+ if (this.metaWorkspace !== null && !this.metaWorkspace.active)
+ return;
+
+ this.layout_manager.stateAdjustment.ease(0, {
+ duration: Overview.ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ _onDestroy() {
+ if (this._overviewHiddenId) {
+ Main.overview.disconnect(this._overviewHiddenId);
+ this._overviewHiddenId = 0;
+ }
+
+ if (this._overviewShownId) {
+ Main.overview.disconnect(this._overviewShownId);
+ this._overviewShownId = 0;
+ }
+
+ if (this.metaWorkspace) {
+ this.metaWorkspace.disconnect(this._windowAddedId);
+ this.metaWorkspace.disconnect(this._windowRemovedId);
+ }
+ global.display.disconnect(this._windowEnteredMonitorId);
+ global.display.disconnect(this._windowLeftMonitorId);
+
+ if (this._layoutFrozenId > 0) {
+ GLib.source_remove(this._layoutFrozenId);
+ this._layoutFrozenId = 0;
+ }
+
+ this._windows = [];
+ }
+
+ _doneLeavingOverview() {
+ this.layout_manager.layout_frozen = false;
+ this.layout_manager.stateAdjustment.value = 0;
+ this._windows.forEach(w => (w.opacity = 255));
+ }
+
+ _doneShowingOverview() {
+ this.layout_manager.layout_frozen = false;
+ this.layout_manager.stateAdjustment.value = 1;
+ this._windows.forEach(w => (w.opacity = 255));
+ }
+
+ _isMyWindow(window) {
+ const isOnWorkspace = this.metaWorkspace === null ||
+ window.located_on_workspace(this.metaWorkspace);
+ const isOnMonitor = window.get_monitor() === this.monitorIndex;
+
+ return isOnWorkspace && isOnMonitor;
+ }
+
+ _isOverviewWindow(window) {
+ return !window.skip_taskbar;
+ }
+
+ // Create a clone of a (non-desktop) window and add it to the window list
+ _addWindowClone(metaWindow) {
+ let clone = new WindowPreview(metaWindow, this);
+
+ clone.connect('selected',
+ this._onCloneSelected.bind(this));
+ clone.connect('drag-begin', () => {
+ Main.overview.beginWindowDrag(metaWindow);
+ });
+ clone.connect('drag-cancelled', () => {
+ Main.overview.cancelledWindowDrag(metaWindow);
+ });
+ clone.connect('drag-end', () => {
+ Main.overview.endWindowDrag(metaWindow);
+ });
+ clone.connect('show-chrome', () => {
+ let focus = global.stage.key_focus;
+ if (focus == null || this.contains(focus))
+ clone.grab_key_focus();
+
+ this._windows.forEach(c => {
+ if (c !== clone)
+ c.hideOverlay(true);
+ });
+ });
+ clone.connect('destroy', () => {
+ this._doRemoveWindow(metaWindow);
+ });
+
+ this.layout_manager.addWindow(clone, metaWindow);
+
+ if (this._windows.length == 0)
+ clone.setStackAbove(null);
+ else
+ clone.setStackAbove(this._windows[this._windows.length - 1]);
+
+ this._windows.push(clone);
+
+ return clone;
+ }
+
+ _removeWindowClone(metaWin) {
+ // find the position of the window in our list
+ let index = this._lookupIndex(metaWin);
+
+ if (index == -1)
+ return null;
+
+ this.layout_manager.removeWindow(this._windows[index]);
+
+ return this._windows.splice(index, 1).pop();
+ }
+
+ _onStyleChanged() {
+ const themeNode = this.get_theme_node();
+ this.layout_manager.spacing = themeNode.get_length('spacing');
+ }
+
+ _onCloneSelected(clone, time) {
+ let wsIndex;
+ if (this.metaWorkspace)
+ wsIndex = this.metaWorkspace.index();
+ Main.activateWindow(clone.metaWindow, time, wsIndex);
+ }
+
+ // Draggable target interface
+ handleDragOver(source, _actor, _x, _y, _time) {
+ if (source.metaWindow && !this._isMyWindow(source.metaWindow))
+ return DND.DragMotionResult.MOVE_DROP;
+ if (source.app && source.app.can_open_new_window())
+ return DND.DragMotionResult.COPY_DROP;
+ if (!source.app && source.shellWorkspaceLaunch)
+ return DND.DragMotionResult.COPY_DROP;
+
+ return DND.DragMotionResult.CONTINUE;
+ }
+
+ acceptDrop(source, actor, x, y, time) {
+ let workspaceManager = global.workspace_manager;
+ let workspaceIndex = this.metaWorkspace
+ ? this.metaWorkspace.index()
+ : workspaceManager.get_active_workspace_index();
+
+ if (source.metaWindow) {
+ const window = source.metaWindow;
+ if (this._isMyWindow(window))
+ return false;
+
+ // We need to move the window before changing the workspace, because
+ // the move itself could cause a workspace change if the window enters
+ // the primary monitor
+ if (window.get_monitor() != this.monitorIndex)
+ window.move_to_monitor(this.monitorIndex);
+
+ window.change_workspace_by_index(workspaceIndex, false);
+ return true;
+ } else if (source.app && source.app.can_open_new_window()) {
+ if (source.animateLaunchAtPos)
+ source.animateLaunchAtPos(actor.x, actor.y);
+
+ source.app.open_new_window(workspaceIndex);
+ return true;
+ } else if (!source.app && source.shellWorkspaceLaunch) {
+ // While unused in our own drag sources, shellWorkspaceLaunch allows
+ // extensions to define custom actions for their drag sources.
+ source.shellWorkspaceLaunch({ workspace: workspaceIndex,
+ timestamp: time });
+ return true;
+ }
+
+ return false;
+ }
+});
diff --git a/js/ui/workspaceSwitcherPopup.js b/js/ui/workspaceSwitcherPopup.js
new file mode 100644
index 0000000..4613e96
--- /dev/null
+++ b/js/ui/workspaceSwitcherPopup.js
@@ -0,0 +1,229 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported WorkspaceSwitcherPopup */
+
+const { Clutter, GLib, GObject, Meta, St } = imports.gi;
+
+const Main = imports.ui.main;
+
+var ANIMATION_TIME = 100;
+var DISPLAY_TIMEOUT = 600;
+
+var WorkspaceSwitcherPopupList = GObject.registerClass(
+class WorkspaceSwitcherPopupList extends St.Widget {
+ _init() {
+ super._init({
+ style_class: 'workspace-switcher',
+ offscreen_redirect: Clutter.OffscreenRedirect.ALWAYS,
+ });
+
+ this._itemSpacing = 0;
+ this._childHeight = 0;
+ this._childWidth = 0;
+ this._orientation = global.workspace_manager.layout_rows == -1
+ ? Clutter.Orientation.VERTICAL
+ : Clutter.Orientation.HORIZONTAL;
+
+ this.connect('style-changed', () => {
+ this._itemSpacing = this.get_theme_node().get_length('spacing');
+ });
+ }
+
+ _getPreferredSizeForOrientation(_forSize) {
+ let workArea = Main.layoutManager.getWorkAreaForMonitor(Main.layoutManager.primaryIndex);
+ let themeNode = this.get_theme_node();
+
+ let availSize;
+ if (this._orientation == Clutter.Orientation.HORIZONTAL)
+ availSize = workArea.width - themeNode.get_horizontal_padding();
+ else
+ availSize = workArea.height - themeNode.get_vertical_padding();
+
+ let size = 0;
+ for (let child of this.get_children()) {
+ let [, childNaturalHeight] = child.get_preferred_height(-1);
+ let height = childNaturalHeight * workArea.width / workArea.height;
+
+ if (this._orientation == Clutter.Orientation.HORIZONTAL)
+ size += height * workArea.width / workArea.height;
+ else
+ size += height;
+ }
+
+ let workspaceManager = global.workspace_manager;
+ let spacing = this._itemSpacing * (workspaceManager.n_workspaces - 1);
+ size += spacing;
+ size = Math.min(size, availSize);
+
+ if (this._orientation == Clutter.Orientation.HORIZONTAL) {
+ this._childWidth = (size - spacing) / workspaceManager.n_workspaces;
+ return themeNode.adjust_preferred_width(size, size);
+ } else {
+ this._childHeight = (size - spacing) / workspaceManager.n_workspaces;
+ return themeNode.adjust_preferred_height(size, size);
+ }
+ }
+
+ _getSizeForOppositeOrientation() {
+ let workArea = Main.layoutManager.getWorkAreaForMonitor(Main.layoutManager.primaryIndex);
+
+ if (this._orientation == Clutter.Orientation.HORIZONTAL) {
+ this._childHeight = Math.round(this._childWidth * workArea.height / workArea.width);
+ return [this._childHeight, this._childHeight];
+ } else {
+ this._childWidth = Math.round(this._childHeight * workArea.width / workArea.height);
+ return [this._childWidth, this._childWidth];
+ }
+ }
+
+ vfunc_get_preferred_height(forWidth) {
+ if (this._orientation == Clutter.Orientation.HORIZONTAL)
+ return this._getSizeForOppositeOrientation();
+ else
+ return this._getPreferredSizeForOrientation(forWidth);
+ }
+
+ vfunc_get_preferred_width(forHeight) {
+ if (this._orientation == Clutter.Orientation.HORIZONTAL)
+ return this._getPreferredSizeForOrientation(forHeight);
+ else
+ return this._getSizeForOppositeOrientation();
+ }
+
+ vfunc_allocate(box) {
+ this.set_allocation(box);
+
+ let themeNode = this.get_theme_node();
+ box = themeNode.get_content_box(box);
+
+ let childBox = new Clutter.ActorBox();
+
+ let rtl = this.text_direction == Clutter.TextDirection.RTL;
+ let x = rtl ? box.x2 - this._childWidth : box.x1;
+ let y = box.y1;
+ for (let child of this.get_children()) {
+ childBox.x1 = Math.round(x);
+ childBox.x2 = Math.round(x + this._childWidth);
+ childBox.y1 = Math.round(y);
+ childBox.y2 = Math.round(y + this._childHeight);
+
+ if (this._orientation == Clutter.Orientation.HORIZONTAL) {
+ if (rtl)
+ x -= this._childWidth + this._itemSpacing;
+ else
+ x += this._childWidth + this._itemSpacing;
+ } else {
+ y += this._childHeight + this._itemSpacing;
+ }
+ child.allocate(childBox);
+ }
+ }
+});
+
+var WorkspaceSwitcherPopup = GObject.registerClass(
+class WorkspaceSwitcherPopup extends St.Widget {
+ _init() {
+ super._init({ x: 0,
+ y: 0,
+ width: global.screen_width,
+ height: global.screen_height,
+ style_class: 'workspace-switcher-group' });
+
+ Main.uiGroup.add_actor(this);
+
+ this._timeoutId = 0;
+
+ this._container = new St.BoxLayout({ style_class: 'workspace-switcher-container' });
+ this.add_child(this._container);
+
+ this._list = new WorkspaceSwitcherPopupList();
+ this._container.add_child(this._list);
+
+ this._redisplay();
+
+ this.hide();
+
+ let workspaceManager = global.workspace_manager;
+ this._workspaceManagerSignals = [];
+ this._workspaceManagerSignals.push(workspaceManager.connect('workspace-added',
+ this._redisplay.bind(this)));
+ this._workspaceManagerSignals.push(workspaceManager.connect('workspace-removed',
+ this._redisplay.bind(this)));
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ _redisplay() {
+ let workspaceManager = global.workspace_manager;
+
+ this._list.destroy_all_children();
+
+ for (let i = 0; i < workspaceManager.n_workspaces; i++) {
+ let indicator = null;
+
+ if (i == this._activeWorkspaceIndex && this._direction == Meta.MotionDirection.UP)
+ indicator = new St.Bin({ style_class: 'ws-switcher-active-up' });
+ else if (i == this._activeWorkspaceIndex && this._direction == Meta.MotionDirection.DOWN)
+ indicator = new St.Bin({ style_class: 'ws-switcher-active-down' });
+ else if (i == this._activeWorkspaceIndex && this._direction == Meta.MotionDirection.LEFT)
+ indicator = new St.Bin({ style_class: 'ws-switcher-active-left' });
+ else if (i == this._activeWorkspaceIndex && this._direction == Meta.MotionDirection.RIGHT)
+ indicator = new St.Bin({ style_class: 'ws-switcher-active-right' });
+ else
+ indicator = new St.Bin({ style_class: 'ws-switcher-box' });
+
+ this._list.add_actor(indicator);
+
+ }
+
+ let workArea = Main.layoutManager.getWorkAreaForMonitor(Main.layoutManager.primaryIndex);
+ let [, containerNatHeight] = this._container.get_preferred_height(global.screen_width);
+ let [, containerNatWidth] = this._container.get_preferred_width(containerNatHeight);
+ this._container.x = workArea.x + Math.floor((workArea.width - containerNatWidth) / 2);
+ this._container.y = workArea.y + Math.floor((workArea.height - containerNatHeight) / 2);
+ }
+
+ _show() {
+ this._container.ease({
+ opacity: 255,
+ duration: ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ this.show();
+ }
+
+ display(direction, activeWorkspaceIndex) {
+ this._direction = direction;
+ this._activeWorkspaceIndex = activeWorkspaceIndex;
+
+ this._redisplay();
+ if (this._timeoutId != 0)
+ GLib.source_remove(this._timeoutId);
+ this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, DISPLAY_TIMEOUT, this._onTimeout.bind(this));
+ GLib.Source.set_name_by_id(this._timeoutId, '[gnome-shell] this._onTimeout');
+ this._show();
+ }
+
+ _onTimeout() {
+ GLib.source_remove(this._timeoutId);
+ this._timeoutId = 0;
+ this._container.ease({
+ opacity: 0.0,
+ duration: ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this.destroy(),
+ });
+ return GLib.SOURCE_REMOVE;
+ }
+
+ _onDestroy() {
+ if (this._timeoutId)
+ GLib.source_remove(this._timeoutId);
+ this._timeoutId = 0;
+
+ let workspaceManager = global.workspace_manager;
+ for (let i = 0; i < this._workspaceManagerSignals.length; i++)
+ workspaceManager.disconnect(this._workspaceManagerSignals[i]);
+
+ this._workspaceManagerSignals = [];
+ }
+});
diff --git a/js/ui/workspaceThumbnail.js b/js/ui/workspaceThumbnail.js
new file mode 100644
index 0000000..9d7a863
--- /dev/null
+++ b/js/ui/workspaceThumbnail.js
@@ -0,0 +1,1362 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported WorkspaceThumbnail, ThumbnailsBox */
+
+const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi;
+
+const Background = imports.ui.background;
+const DND = imports.ui.dnd;
+const Main = imports.ui.main;
+const Workspace = imports.ui.workspace;
+
+// The maximum size of a thumbnail is 1/10 the width and height of the screen
+let MAX_THUMBNAIL_SCALE = 1 / 10.;
+
+var RESCALE_ANIMATION_TIME = 200;
+var SLIDE_ANIMATION_TIME = 200;
+
+// When we create workspaces by dragging, we add a "cut" into the top and
+// bottom of each workspace so that the user doesn't have to hit the
+// placeholder exactly.
+var WORKSPACE_CUT_SIZE = 10;
+
+var WORKSPACE_KEEP_ALIVE_TIME = 100;
+
+var MUTTER_SCHEMA = 'org.gnome.mutter';
+
+/* A layout manager that requests size only for primary_actor, but then allocates
+ all using a fixed layout */
+var PrimaryActorLayout = GObject.registerClass(
+class PrimaryActorLayout extends Clutter.FixedLayout {
+ _init(primaryActor) {
+ super._init();
+
+ this.primaryActor = primaryActor;
+ }
+
+ vfunc_get_preferred_width(container, forHeight) {
+ return this.primaryActor.get_preferred_width(forHeight);
+ }
+
+ vfunc_get_preferred_height(container, forWidth) {
+ return this.primaryActor.get_preferred_height(forWidth);
+ }
+});
+
+var WindowClone = GObject.registerClass({
+ Signals: {
+ 'drag-begin': {},
+ 'drag-cancelled': {},
+ 'drag-end': {},
+ 'selected': { param_types: [GObject.TYPE_UINT] },
+ },
+}, class WindowClone extends Clutter.Actor {
+ _init(realWindow) {
+ let clone = new Clutter.Clone({ source: realWindow });
+ super._init({
+ layout_manager: new PrimaryActorLayout(clone),
+ reactive: true,
+ });
+ this._delegate = this;
+
+ this.add_child(clone);
+ this.realWindow = realWindow;
+ this.metaWindow = realWindow.meta_window;
+
+ clone._updateId = this.realWindow.connect('notify::position',
+ this._onPositionChanged.bind(this));
+ clone._destroyId = this.realWindow.connect('destroy', () => {
+ // First destroy the clone and then destroy everything
+ // This will ensure that we never see it in the _disconnectSignals loop
+ clone.destroy();
+ this.destroy();
+ });
+ this._onPositionChanged();
+
+ this.connect('destroy', this._onDestroy.bind(this));
+
+ this._draggable = DND.makeDraggable(this,
+ { restoreOnSuccess: true,
+ dragActorMaxSize: Workspace.WINDOW_DND_SIZE,
+ dragActorOpacity: Workspace.DRAGGING_WINDOW_OPACITY });
+ this._draggable.connect('drag-begin', this._onDragBegin.bind(this));
+ this._draggable.connect('drag-cancelled', this._onDragCancelled.bind(this));
+ this._draggable.connect('drag-end', this._onDragEnd.bind(this));
+ this.inDrag = false;
+
+ let iter = win => {
+ let actor = win.get_compositor_private();
+
+ if (!actor)
+ return false;
+ if (!win.is_attached_dialog())
+ return false;
+
+ this._doAddAttachedDialog(win, actor);
+ win.foreach_transient(iter);
+
+ return true;
+ };
+ this.metaWindow.foreach_transient(iter);
+ }
+
+ // Find the actor just below us, respecting reparenting done
+ // by DND code
+ getActualStackAbove() {
+ if (this._stackAbove == null)
+ return null;
+
+ if (this.inDrag) {
+ if (this._stackAbove._delegate)
+ return this._stackAbove._delegate.getActualStackAbove();
+ else
+ return null;
+ } else {
+ return this._stackAbove;
+ }
+ }
+
+ setStackAbove(actor) {
+ this._stackAbove = actor;
+
+ // Don't apply the new stacking now, it will be applied
+ // when dragging ends and window are stacked again
+ if (actor.inDrag)
+ return;
+
+ let parent = this.get_parent();
+ let actualAbove = this.getActualStackAbove();
+ if (actualAbove == null)
+ parent.set_child_below_sibling(this, null);
+ else
+ parent.set_child_above_sibling(this, actualAbove);
+ }
+
+ addAttachedDialog(win) {
+ this._doAddAttachedDialog(win, win.get_compositor_private());
+ }
+
+ _doAddAttachedDialog(metaDialog, realDialog) {
+ let clone = new Clutter.Clone({ source: realDialog });
+ this._updateDialogPosition(realDialog, clone);
+
+ clone._updateId = realDialog.connect('notify::position', dialog => {
+ this._updateDialogPosition(dialog, clone);
+ });
+ clone._destroyId = realDialog.connect('destroy', () => {
+ clone.destroy();
+ });
+ this.add_child(clone);
+ }
+
+ _updateDialogPosition(realDialog, cloneDialog) {
+ let metaDialog = realDialog.meta_window;
+ let dialogRect = metaDialog.get_frame_rect();
+ let rect = this.metaWindow.get_frame_rect();
+
+ cloneDialog.set_position(dialogRect.x - rect.x, dialogRect.y - rect.y);
+ }
+
+ _onPositionChanged() {
+ this.set_position(this.realWindow.x, this.realWindow.y);
+ }
+
+ _disconnectSignals() {
+ this.get_children().forEach(child => {
+ let realWindow = child.source;
+
+ realWindow.disconnect(child._updateId);
+ realWindow.disconnect(child._destroyId);
+ });
+ }
+
+ _onDestroy() {
+ this._disconnectSignals();
+
+ this._delegate = null;
+
+ if (this.inDrag) {
+ this.emit('drag-end');
+ this.inDrag = false;
+ }
+ }
+
+ vfunc_button_press_event() {
+ return Clutter.EVENT_STOP;
+ }
+
+ vfunc_button_release_event(buttonEvent) {
+ this.emit('selected', buttonEvent.time);
+
+ return Clutter.EVENT_STOP;
+ }
+
+ vfunc_touch_event(touchEvent) {
+ if (touchEvent.type != Clutter.EventType.TOUCH_END ||
+ !global.display.is_pointer_emulating_sequence(touchEvent.sequence))
+ return Clutter.EVENT_PROPAGATE;
+
+ this.emit('selected', touchEvent.time);
+ return Clutter.EVENT_STOP;
+ }
+
+ _onDragBegin(_draggable, _time) {
+ this.inDrag = true;
+ this.emit('drag-begin');
+ }
+
+ _onDragCancelled(_draggable, _time) {
+ this.emit('drag-cancelled');
+ }
+
+ _onDragEnd(_draggable, _time, _snapback) {
+ this.inDrag = false;
+
+ // We may not have a parent if DnD completed successfully, in
+ // which case our clone will shortly be destroyed and replaced
+ // with a new one on the target workspace.
+ let parent = this.get_parent();
+ if (parent !== null) {
+ if (this._stackAbove == null)
+ parent.set_child_below_sibling(this, null);
+ else
+ parent.set_child_above_sibling(this, this._stackAbove);
+ }
+
+
+ this.emit('drag-end');
+ }
+});
+
+
+var ThumbnailState = {
+ NEW: 0,
+ ANIMATING_IN: 1,
+ NORMAL: 2,
+ REMOVING: 3,
+ ANIMATING_OUT: 4,
+ ANIMATED_OUT: 5,
+ COLLAPSING: 6,
+ DESTROYED: 7,
+};
+
+/**
+ * @metaWorkspace: a #Meta.Workspace
+ */
+var WorkspaceThumbnail = GObject.registerClass({
+ Properties: {
+ 'collapse-fraction': GObject.ParamSpec.double(
+ 'collapse-fraction', 'collapse-fraction', 'collapse-fraction',
+ GObject.ParamFlags.READWRITE,
+ 0, 1, 0),
+ 'slide-position': GObject.ParamSpec.double(
+ 'slide-position', 'slide-position', 'slide-position',
+ GObject.ParamFlags.READWRITE,
+ 0, 1, 0),
+ },
+}, class WorkspaceThumbnail extends St.Widget {
+ _init(metaWorkspace) {
+ super._init({
+ clip_to_allocation: true,
+ style_class: 'workspace-thumbnail',
+ });
+ this._delegate = this;
+
+ this.metaWorkspace = metaWorkspace;
+ this.monitorIndex = Main.layoutManager.primaryIndex;
+
+ this._removed = false;
+
+ this._contents = new Clutter.Actor();
+ this.add_child(this._contents);
+
+ this.connect('destroy', this._onDestroy.bind(this));
+
+ this._createBackground();
+
+ let workArea = Main.layoutManager.getWorkAreaForMonitor(this.monitorIndex);
+ this.setPorthole(workArea.x, workArea.y, workArea.width, workArea.height);
+
+ let windows = global.get_window_actors().filter(actor => {
+ let win = actor.meta_window;
+ return win.located_on_workspace(metaWorkspace);
+ });
+
+ // Create clones for windows that should be visible in the Overview
+ this._windows = [];
+ this._allWindows = [];
+ this._minimizedChangedIds = [];
+ for (let i = 0; i < windows.length; i++) {
+ let minimizedChangedId =
+ windows[i].meta_window.connect('notify::minimized',
+ this._updateMinimized.bind(this));
+ this._allWindows.push(windows[i].meta_window);
+ this._minimizedChangedIds.push(minimizedChangedId);
+
+ if (this._isMyWindow(windows[i]) && this._isOverviewWindow(windows[i]))
+ this._addWindowClone(windows[i]);
+ }
+
+ // Track window changes
+ this._windowAddedId = this.metaWorkspace.connect('window-added',
+ this._windowAdded.bind(this));
+ this._windowRemovedId = this.metaWorkspace.connect('window-removed',
+ this._windowRemoved.bind(this));
+ this._windowEnteredMonitorId = global.display.connect('window-entered-monitor',
+ this._windowEnteredMonitor.bind(this));
+ this._windowLeftMonitorId = global.display.connect('window-left-monitor',
+ this._windowLeftMonitor.bind(this));
+
+ this.state = ThumbnailState.NORMAL;
+ this._slidePosition = 0; // Fully slid in
+ this._collapseFraction = 0; // Not collapsed
+ }
+
+ _createBackground() {
+ this._bgManager = new Background.BackgroundManager({ monitorIndex: Main.layoutManager.primaryIndex,
+ container: this._contents,
+ vignette: false });
+ }
+
+ setPorthole(x, y, width, height) {
+ this.set_size(width, height);
+ this._contents.set_position(-x, -y);
+ }
+
+ _lookupIndex(metaWindow) {
+ return this._windows.findIndex(w => w.metaWindow == metaWindow);
+ }
+
+ syncStacking(stackIndices) {
+ this._windows.sort((a, b) => {
+ let indexA = stackIndices[a.metaWindow.get_stable_sequence()];
+ let indexB = stackIndices[b.metaWindow.get_stable_sequence()];
+ return indexA - indexB;
+ });
+
+ for (let i = 0; i < this._windows.length; i++) {
+ let clone = this._windows[i];
+ if (i == 0) {
+ clone.setStackAbove(this._bgManager.backgroundActor);
+ } else {
+ let previousClone = this._windows[i - 1];
+ clone.setStackAbove(previousClone);
+ }
+ }
+ }
+
+ // eslint-disable-next-line camelcase
+ set slide_position(slidePosition) {
+ if (this._slidePosition == slidePosition)
+ return;
+ this._slidePosition = slidePosition;
+ this.notify('slide-position');
+ this.queue_relayout();
+ }
+
+ // eslint-disable-next-line camelcase
+ get slide_position() {
+ return this._slidePosition;
+ }
+
+ // eslint-disable-next-line camelcase
+ set collapse_fraction(collapseFraction) {
+ if (this._collapseFraction == collapseFraction)
+ return;
+ this._collapseFraction = collapseFraction;
+ this.notify('collapse-fraction');
+ this.queue_relayout();
+ }
+
+ // eslint-disable-next-line camelcase
+ get collapse_fraction() {
+ return this._collapseFraction;
+ }
+
+ _doRemoveWindow(metaWin) {
+ let clone = this._removeWindowClone(metaWin);
+ if (clone)
+ clone.destroy();
+ }
+
+ _doAddWindow(metaWin) {
+ if (this._removed)
+ return;
+
+ let win = metaWin.get_compositor_private();
+
+ if (!win) {
+ // Newly-created windows are added to a workspace before
+ // the compositor finds out about them...
+ let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
+ if (!this._removed &&
+ metaWin.get_compositor_private() &&
+ metaWin.get_workspace() == this.metaWorkspace)
+ this._doAddWindow(metaWin);
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(id, '[gnome-shell] this._doAddWindow');
+ return;
+ }
+
+ if (!this._allWindows.includes(metaWin)) {
+ let minimizedChangedId = metaWin.connect('notify::minimized',
+ this._updateMinimized.bind(this));
+ this._allWindows.push(metaWin);
+ this._minimizedChangedIds.push(minimizedChangedId);
+ }
+
+ // We might have the window in our list already if it was on all workspaces and
+ // now was moved to this workspace
+ if (this._lookupIndex(metaWin) != -1)
+ return;
+
+ if (!this._isMyWindow(win))
+ return;
+
+ if (this._isOverviewWindow(win)) {
+ this._addWindowClone(win);
+ } else if (metaWin.is_attached_dialog()) {
+ let parent = metaWin.get_transient_for();
+ while (parent.is_attached_dialog())
+ parent = parent.get_transient_for();
+
+ let idx = this._lookupIndex(parent);
+ if (idx < 0) {
+ // parent was not created yet, it will take care
+ // of the dialog when created
+ return;
+ }
+
+ let clone = this._windows[idx];
+ clone.addAttachedDialog(metaWin);
+ }
+ }
+
+ _windowAdded(metaWorkspace, metaWin) {
+ this._doAddWindow(metaWin);
+ }
+
+ _windowRemoved(metaWorkspace, metaWin) {
+ let index = this._allWindows.indexOf(metaWin);
+ if (index != -1) {
+ metaWin.disconnect(this._minimizedChangedIds[index]);
+ this._allWindows.splice(index, 1);
+ this._minimizedChangedIds.splice(index, 1);
+ }
+
+ this._doRemoveWindow(metaWin);
+ }
+
+ _windowEnteredMonitor(metaDisplay, monitorIndex, metaWin) {
+ if (monitorIndex == this.monitorIndex)
+ this._doAddWindow(metaWin);
+ }
+
+ _windowLeftMonitor(metaDisplay, monitorIndex, metaWin) {
+ if (monitorIndex == this.monitorIndex)
+ this._doRemoveWindow(metaWin);
+ }
+
+ _updateMinimized(metaWin) {
+ if (metaWin.minimized)
+ this._doRemoveWindow(metaWin);
+ else
+ this._doAddWindow(metaWin);
+ }
+
+ workspaceRemoved() {
+ if (this._removed)
+ return;
+
+ this._removed = true;
+
+ this.metaWorkspace.disconnect(this._windowAddedId);
+ this.metaWorkspace.disconnect(this._windowRemovedId);
+ global.display.disconnect(this._windowEnteredMonitorId);
+ global.display.disconnect(this._windowLeftMonitorId);
+
+ for (let i = 0; i < this._allWindows.length; i++)
+ this._allWindows[i].disconnect(this._minimizedChangedIds[i]);
+ }
+
+ _onDestroy() {
+ this.workspaceRemoved();
+
+ if (this._bgManager) {
+ this._bgManager.destroy();
+ this._bgManager = null;
+ }
+
+ this._windows = [];
+ }
+
+ // Tests if @actor belongs to this workspace and monitor
+ _isMyWindow(actor) {
+ let win = actor.meta_window;
+ return win.located_on_workspace(this.metaWorkspace) &&
+ (win.get_monitor() == this.monitorIndex);
+ }
+
+ // Tests if @win should be shown in the Overview
+ _isOverviewWindow(win) {
+ return !win.get_meta_window().skip_taskbar &&
+ win.get_meta_window().showing_on_its_workspace();
+ }
+
+ // Create a clone of a (non-desktop) window and add it to the window list
+ _addWindowClone(win) {
+ let clone = new WindowClone(win);
+
+ clone.connect('selected', (o, time) => {
+ this.activate(time);
+ });
+ clone.connect('drag-begin', () => {
+ Main.overview.beginWindowDrag(clone.metaWindow);
+ });
+ clone.connect('drag-cancelled', () => {
+ Main.overview.cancelledWindowDrag(clone.metaWindow);
+ });
+ clone.connect('drag-end', () => {
+ Main.overview.endWindowDrag(clone.metaWindow);
+ });
+ clone.connect('destroy', () => {
+ this._removeWindowClone(clone.metaWindow);
+ });
+ this._contents.add_actor(clone);
+
+ if (this._windows.length == 0)
+ clone.setStackAbove(this._bgManager.backgroundActor);
+ else
+ clone.setStackAbove(this._windows[this._windows.length - 1]);
+
+ this._windows.push(clone);
+
+ return clone;
+ }
+
+ _removeWindowClone(metaWin) {
+ // find the position of the window in our list
+ let index = this._lookupIndex(metaWin);
+
+ if (index == -1)
+ return null;
+
+ return this._windows.splice(index, 1).pop();
+ }
+
+ activate(time) {
+ if (this.state > ThumbnailState.NORMAL)
+ return;
+
+ // a click on the already current workspace should go back to the main view
+ if (this.metaWorkspace.active)
+ Main.overview.hide();
+ else
+ this.metaWorkspace.activate(time);
+ }
+
+ // Draggable target interface used only by ThumbnailsBox
+ handleDragOverInternal(source, actor, time) {
+ if (source == Main.xdndHandler) {
+ this.metaWorkspace.activate(time);
+ return DND.DragMotionResult.CONTINUE;
+ }
+
+ if (this.state > ThumbnailState.NORMAL)
+ return DND.DragMotionResult.CONTINUE;
+
+ if (source.metaWindow &&
+ !this._isMyWindow(source.metaWindow.get_compositor_private()))
+ return DND.DragMotionResult.MOVE_DROP;
+ if (source.app && source.app.can_open_new_window())
+ return DND.DragMotionResult.COPY_DROP;
+ if (!source.app && source.shellWorkspaceLaunch)
+ return DND.DragMotionResult.COPY_DROP;
+
+ return DND.DragMotionResult.CONTINUE;
+ }
+
+ acceptDropInternal(source, actor, time) {
+ if (this.state > ThumbnailState.NORMAL)
+ return false;
+
+ if (source.metaWindow) {
+ let win = source.metaWindow.get_compositor_private();
+ if (this._isMyWindow(win))
+ return false;
+
+ let metaWindow = win.get_meta_window();
+
+ // We need to move the window before changing the workspace, because
+ // the move itself could cause a workspace change if the window enters
+ // the primary monitor
+ if (metaWindow.get_monitor() != this.monitorIndex)
+ metaWindow.move_to_monitor(this.monitorIndex);
+
+ metaWindow.change_workspace_by_index(this.metaWorkspace.index(), false);
+ return true;
+ } else if (source.app && source.app.can_open_new_window()) {
+ if (source.animateLaunchAtPos)
+ source.animateLaunchAtPos(actor.x, actor.y);
+
+ source.app.open_new_window(this.metaWorkspace.index());
+ return true;
+ } else if (!source.app && source.shellWorkspaceLaunch) {
+ // While unused in our own drag sources, shellWorkspaceLaunch allows
+ // extensions to define custom actions for their drag sources.
+ source.shellWorkspaceLaunch({ workspace: this.metaWorkspace.index(),
+ timestamp: time });
+ return true;
+ }
+
+ return false;
+ }
+});
+
+
+var ThumbnailsBox = GObject.registerClass({
+ Properties: {
+ 'indicator-y': GObject.ParamSpec.double(
+ 'indicator-y', 'indicator-y', 'indicator-y',
+ GObject.ParamFlags.READWRITE,
+ 0, Infinity, 0),
+ 'scale': GObject.ParamSpec.double(
+ 'scale', 'scale', 'scale',
+ GObject.ParamFlags.READWRITE,
+ 0, Infinity, 0),
+ },
+}, class ThumbnailsBox extends St.Widget {
+ _init(scrollAdjustment) {
+ super._init({ reactive: true,
+ style_class: 'workspace-thumbnails',
+ request_mode: Clutter.RequestMode.WIDTH_FOR_HEIGHT });
+
+ this._delegate = this;
+
+ let indicator = new St.Bin({ style_class: 'workspace-thumbnail-indicator' });
+
+ // We don't want the indicator to affect drag-and-drop
+ Shell.util_set_hidden_from_pick(indicator, true);
+
+ this._indicator = indicator;
+ this.add_actor(indicator);
+
+ // The porthole is the part of the screen we're showing in the thumbnails
+ this._porthole = { width: global.stage.width, height: global.stage.height,
+ x: global.stage.x, y: global.stage.y };
+
+ this._dropWorkspace = -1;
+ this._dropPlaceholderPos = -1;
+ this._dropPlaceholder = new St.Bin({ style_class: 'placeholder' });
+ this.add_actor(this._dropPlaceholder);
+ this._spliceIndex = -1;
+
+ this._targetScale = 0;
+ this._scale = 0;
+ this._pendingScaleUpdate = false;
+ this._stateUpdateQueued = false;
+ this._animatingIndicator = false;
+
+ this._stateCounts = {};
+ for (let key in ThumbnailState)
+ this._stateCounts[ThumbnailState[key]] = 0;
+
+ this._thumbnails = [];
+
+ Main.overview.connect('showing',
+ this._createThumbnails.bind(this));
+ Main.overview.connect('hidden',
+ this._destroyThumbnails.bind(this));
+
+ Main.overview.connect('item-drag-begin',
+ this._onDragBegin.bind(this));
+ Main.overview.connect('item-drag-end',
+ this._onDragEnd.bind(this));
+ Main.overview.connect('item-drag-cancelled',
+ this._onDragCancelled.bind(this));
+ Main.overview.connect('window-drag-begin',
+ this._onDragBegin.bind(this));
+ Main.overview.connect('window-drag-end',
+ this._onDragEnd.bind(this));
+ Main.overview.connect('window-drag-cancelled',
+ this._onDragCancelled.bind(this));
+
+ this._settings = new Gio.Settings({ schema_id: MUTTER_SCHEMA });
+ this._settings.connect('changed::dynamic-workspaces',
+ this._updateSwitcherVisibility.bind(this));
+
+ Main.layoutManager.connect('monitors-changed', () => {
+ this._destroyThumbnails();
+ if (Main.overview.visible)
+ this._createThumbnails();
+ });
+
+ global.display.connect('workareas-changed',
+ this._updatePorthole.bind(this));
+
+ this._switchWorkspaceNotifyId = 0;
+ this._nWorkspacesNotifyId = 0;
+ this._syncStackingId = 0;
+ this._workareasChangedId = 0;
+
+ this._scrollAdjustment = scrollAdjustment;
+
+ this._scrollAdjustment.connect('notify::value', adj => {
+ let workspaceManager = global.workspace_manager;
+ let activeIndex = workspaceManager.get_active_workspace_index();
+
+ this._animatingIndicator = adj.value !== activeIndex;
+
+ if (!this._animatingIndicator)
+ this._queueUpdateStates();
+
+ this.queue_relayout();
+ });
+ }
+
+ _updateSwitcherVisibility() {
+ let workspaceManager = global.workspace_manager;
+
+ this.visible =
+ this._settings.get_boolean('dynamic-workspaces') ||
+ workspaceManager.n_workspaces > 1;
+ }
+
+ _activateThumbnailAtPoint(stageX, stageY, time) {
+ let [r_, x_, y] = this.transform_stage_point(stageX, stageY);
+
+ let thumbnail = this._thumbnails.find(t => {
+ let [, h] = t.get_transformed_size();
+ return y >= t.y && y <= t.y + h;
+ });
+ if (thumbnail)
+ thumbnail.activate(time);
+ }
+
+ vfunc_button_release_event(buttonEvent) {
+ let { x, y } = buttonEvent;
+ this._activateThumbnailAtPoint(x, y, buttonEvent.time);
+ return Clutter.EVENT_STOP;
+ }
+
+ vfunc_touch_event(touchEvent) {
+ if (touchEvent.type == Clutter.EventType.TOUCH_END &&
+ global.display.is_pointer_emulating_sequence(touchEvent.sequence)) {
+ let { x, y } = touchEvent;
+ this._activateThumbnailAtPoint(x, y, touchEvent.time);
+ }
+
+ return Clutter.EVENT_STOP;
+ }
+
+ _onDragBegin() {
+ this._dragCancelled = false;
+ this._dragMonitor = {
+ dragMotion: this._onDragMotion.bind(this),
+ };
+ DND.addDragMonitor(this._dragMonitor);
+ }
+
+ _onDragEnd() {
+ if (this._dragCancelled)
+ return;
+
+ this._endDrag();
+ }
+
+ _onDragCancelled() {
+ this._dragCancelled = true;
+ this._endDrag();
+ }
+
+ _endDrag() {
+ this._clearDragPlaceholder();
+ DND.removeDragMonitor(this._dragMonitor);
+ }
+
+ _onDragMotion(dragEvent) {
+ if (!this.contains(dragEvent.targetActor))
+ this._onLeave();
+ return DND.DragMotionResult.CONTINUE;
+ }
+
+ _onLeave() {
+ this._clearDragPlaceholder();
+ }
+
+ _clearDragPlaceholder() {
+ if (this._dropPlaceholderPos == -1)
+ return;
+
+ this._dropPlaceholderPos = -1;
+ this.queue_relayout();
+ }
+
+ // Draggable target interface
+ handleDragOver(source, actor, x, y, time) {
+ if (!source.metaWindow &&
+ (!source.app || !source.app.can_open_new_window()) &&
+ (source.app || !source.shellWorkspaceLaunch) &&
+ source != Main.xdndHandler)
+ return DND.DragMotionResult.CONTINUE;
+
+ let canCreateWorkspaces = Meta.prefs_get_dynamic_workspaces();
+ let spacing = this.get_theme_node().get_length('spacing');
+
+ this._dropWorkspace = -1;
+ let placeholderPos = -1;
+ let targetBase;
+ if (this._dropPlaceholderPos == 0)
+ targetBase = this._dropPlaceholder.y;
+ else
+ targetBase = this._thumbnails[0].y;
+ let targetTop = targetBase - spacing - WORKSPACE_CUT_SIZE;
+ let length = this._thumbnails.length;
+ for (let i = 0; i < length; i++) {
+ // Allow the reorder target to have a 10px "cut" into
+ // each side of the thumbnail, to make dragging onto the
+ // placeholder easier
+ let [, h] = this._thumbnails[i].get_transformed_size();
+ let targetBottom = targetBase + WORKSPACE_CUT_SIZE;
+ let nextTargetBase = targetBase + h + spacing;
+ let nextTargetTop = nextTargetBase - spacing - (i == length - 1 ? 0 : WORKSPACE_CUT_SIZE);
+
+ // Expand the target to include the placeholder, if it exists.
+ if (i == this._dropPlaceholderPos)
+ targetBottom += this._dropPlaceholder.get_height();
+
+ if (y > targetTop && y <= targetBottom && source != Main.xdndHandler && canCreateWorkspaces) {
+ placeholderPos = i;
+ break;
+ } else if (y > targetBottom && y <= nextTargetTop) {
+ this._dropWorkspace = i;
+ break;
+ }
+
+ targetBase = nextTargetBase;
+ targetTop = nextTargetTop;
+ }
+
+ if (this._dropPlaceholderPos != placeholderPos) {
+ this._dropPlaceholderPos = placeholderPos;
+ this.queue_relayout();
+ }
+
+ if (this._dropWorkspace != -1)
+ return this._thumbnails[this._dropWorkspace].handleDragOverInternal(source, actor, time);
+ else if (this._dropPlaceholderPos != -1)
+ return source.metaWindow ? DND.DragMotionResult.MOVE_DROP : DND.DragMotionResult.COPY_DROP;
+ else
+ return DND.DragMotionResult.CONTINUE;
+ }
+
+ acceptDrop(source, actor, x, y, time) {
+ if (this._dropWorkspace != -1) {
+ return this._thumbnails[this._dropWorkspace].acceptDropInternal(source, actor, time);
+ } else if (this._dropPlaceholderPos != -1) {
+ if (!source.metaWindow &&
+ (!source.app || !source.app.can_open_new_window()) &&
+ (source.app || !source.shellWorkspaceLaunch))
+ return false;
+
+ let isWindow = !!source.metaWindow;
+
+ let newWorkspaceIndex;
+ [newWorkspaceIndex, this._dropPlaceholderPos] = [this._dropPlaceholderPos, -1];
+ this._spliceIndex = newWorkspaceIndex;
+
+ Main.wm.insertWorkspace(newWorkspaceIndex);
+
+ if (isWindow) {
+ // Move the window to our monitor first if necessary.
+ let thumbMonitor = this._thumbnails[newWorkspaceIndex].monitorIndex;
+ if (source.metaWindow.get_monitor() != thumbMonitor)
+ source.metaWindow.move_to_monitor(thumbMonitor);
+ source.metaWindow.change_workspace_by_index(newWorkspaceIndex, true);
+ } else if (source.app && source.app.can_open_new_window()) {
+ if (source.animateLaunchAtPos)
+ source.animateLaunchAtPos(actor.x, actor.y);
+
+ source.app.open_new_window(newWorkspaceIndex);
+ } else if (!source.app && source.shellWorkspaceLaunch) {
+ // While unused in our own drag sources, shellWorkspaceLaunch allows
+ // extensions to define custom actions for their drag sources.
+ source.shellWorkspaceLaunch({ workspace: newWorkspaceIndex,
+ timestamp: time });
+ }
+
+ if (source.app || (!source.app && source.shellWorkspaceLaunch)) {
+ // This new workspace will be automatically removed if the application fails
+ // to open its first window within some time, as tracked by Shell.WindowTracker.
+ // Here, we only add a very brief timeout to avoid the _immediate_ removal of the
+ // workspace while we wait for the startup sequence to load.
+ let workspaceManager = global.workspace_manager;
+ Main.wm.keepWorkspaceAlive(workspaceManager.get_workspace_by_index(newWorkspaceIndex),
+ WORKSPACE_KEEP_ALIVE_TIME);
+ }
+
+ // Start the animation on the workspace (which is actually
+ // an old one which just became empty)
+ let thumbnail = this._thumbnails[newWorkspaceIndex];
+ this._setThumbnailState(thumbnail, ThumbnailState.NEW);
+ thumbnail.slide_position = 1;
+
+ this._queueUpdateStates();
+
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ _createThumbnails() {
+ let workspaceManager = global.workspace_manager;
+
+ this._nWorkspacesNotifyId =
+ workspaceManager.connect('notify::n-workspaces',
+ this._workspacesChanged.bind(this));
+ this._workspacesReorderedId =
+ workspaceManager.connect('workspaces-reordered', () => {
+ this._thumbnails.sort((a, b) => {
+ return a.metaWorkspace.index() - b.metaWorkspace.index();
+ });
+ this.queue_relayout();
+ });
+ this._syncStackingId =
+ Main.overview.connect('windows-restacked',
+ this._syncStacking.bind(this));
+
+ this._targetScale = 0;
+ this._scale = 0;
+ this._pendingScaleUpdate = false;
+ this._stateUpdateQueued = false;
+
+ this._stateCounts = {};
+ for (let key in ThumbnailState)
+ this._stateCounts[ThumbnailState[key]] = 0;
+
+ this.addThumbnails(0, workspaceManager.n_workspaces);
+
+ this._updateSwitcherVisibility();
+ }
+
+ _destroyThumbnails() {
+ if (this._thumbnails.length == 0)
+ return;
+
+ if (this._nWorkspacesNotifyId > 0) {
+ let workspaceManager = global.workspace_manager;
+ workspaceManager.disconnect(this._nWorkspacesNotifyId);
+ this._nWorkspacesNotifyId = 0;
+ }
+ if (this._workspacesReorderedId > 0) {
+ let workspaceManager = global.workspace_manager;
+ workspaceManager.disconnect(this._workspacesReorderedId);
+ this._workspacesReorderedId = 0;
+ }
+
+ if (this._syncStackingId > 0) {
+ Main.overview.disconnect(this._syncStackingId);
+ this._syncStackingId = 0;
+ }
+
+ for (let w = 0; w < this._thumbnails.length; w++)
+ this._thumbnails[w].destroy();
+ this._thumbnails = [];
+ }
+
+ _workspacesChanged() {
+ let validThumbnails =
+ this._thumbnails.filter(t => t.state <= ThumbnailState.NORMAL);
+ let workspaceManager = global.workspace_manager;
+ let oldNumWorkspaces = validThumbnails.length;
+ let newNumWorkspaces = workspaceManager.n_workspaces;
+
+ if (newNumWorkspaces > oldNumWorkspaces) {
+ this.addThumbnails(oldNumWorkspaces, newNumWorkspaces - oldNumWorkspaces);
+ } else {
+ let removedIndex;
+ let removedNum = oldNumWorkspaces - newNumWorkspaces;
+ for (let w = 0; w < oldNumWorkspaces; w++) {
+ let metaWorkspace = workspaceManager.get_workspace_by_index(w);
+ if (this._thumbnails[w].metaWorkspace != metaWorkspace) {
+ removedIndex = w;
+ break;
+ }
+ }
+
+ this.removeThumbnails(removedIndex, removedNum);
+ }
+
+ this._updateSwitcherVisibility();
+ }
+
+ addThumbnails(start, count) {
+ let workspaceManager = global.workspace_manager;
+
+ for (let k = start; k < start + count; k++) {
+ let metaWorkspace = workspaceManager.get_workspace_by_index(k);
+ let thumbnail = new WorkspaceThumbnail(metaWorkspace);
+ thumbnail.setPorthole(this._porthole.x, this._porthole.y,
+ this._porthole.width, this._porthole.height);
+ this._thumbnails.push(thumbnail);
+ this.add_actor(thumbnail);
+
+ if (start > 0 && this._spliceIndex == -1) {
+ // not the initial fill, and not splicing via DND
+ thumbnail.state = ThumbnailState.NEW;
+ thumbnail.slide_position = 1; // start slid out
+ this._haveNewThumbnails = true;
+ } else {
+ thumbnail.state = ThumbnailState.NORMAL;
+ }
+
+ this._stateCounts[thumbnail.state]++;
+ }
+
+ this._queueUpdateStates();
+
+ // The thumbnails indicator actually needs to be on top of the thumbnails
+ this.set_child_above_sibling(this._indicator, null);
+
+ // Clear the splice index, we got the message
+ this._spliceIndex = -1;
+ }
+
+ removeThumbnails(start, count) {
+ let currentPos = 0;
+ for (let k = 0; k < this._thumbnails.length; k++) {
+ let thumbnail = this._thumbnails[k];
+
+ if (thumbnail.state > ThumbnailState.NORMAL)
+ continue;
+
+ if (currentPos >= start && currentPos < start + count) {
+ thumbnail.workspaceRemoved();
+ this._setThumbnailState(thumbnail, ThumbnailState.REMOVING);
+ }
+
+ currentPos++;
+ }
+
+ this._queueUpdateStates();
+ }
+
+ _syncStacking(overview, stackIndices) {
+ for (let i = 0; i < this._thumbnails.length; i++)
+ this._thumbnails[i].syncStacking(stackIndices);
+ }
+
+ set scale(scale) {
+ if (this._scale == scale)
+ return;
+
+ this._scale = scale;
+ this.notify('scale');
+ this.queue_relayout();
+ }
+
+ get scale() {
+ return this._scale;
+ }
+
+ _setThumbnailState(thumbnail, state) {
+ this._stateCounts[thumbnail.state]--;
+ thumbnail.state = state;
+ this._stateCounts[thumbnail.state]++;
+ }
+
+ _iterateStateThumbnails(state, callback) {
+ if (this._stateCounts[state] == 0)
+ return;
+
+ for (let i = 0; i < this._thumbnails.length; i++) {
+ if (this._thumbnails[i].state == state)
+ callback.call(this, this._thumbnails[i]);
+ }
+ }
+
+ _updateStates() {
+ this._stateUpdateQueued = false;
+
+ // If we are animating the indicator, wait
+ if (this._animatingIndicator)
+ return;
+
+ // Then slide out any thumbnails that have been destroyed
+ this._iterateStateThumbnails(ThumbnailState.REMOVING, thumbnail => {
+ this._setThumbnailState(thumbnail, ThumbnailState.ANIMATING_OUT);
+
+ thumbnail.ease_property('slide-position', 1, {
+ duration: SLIDE_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.LINEAR,
+ onComplete: () => {
+ this._setThumbnailState(thumbnail, ThumbnailState.ANIMATED_OUT);
+ this._queueUpdateStates();
+ },
+ });
+ });
+
+ // As long as things are sliding out, don't proceed
+ if (this._stateCounts[ThumbnailState.ANIMATING_OUT] > 0)
+ return;
+
+ // Once that's complete, we can start scaling to the new size and collapse any removed thumbnails
+ this._iterateStateThumbnails(ThumbnailState.ANIMATED_OUT, thumbnail => {
+ this._setThumbnailState(thumbnail, ThumbnailState.COLLAPSING);
+ thumbnail.ease_property('collapse-fraction', 1, {
+ duration: RESCALE_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ this._stateCounts[thumbnail.state]--;
+ thumbnail.state = ThumbnailState.DESTROYED;
+
+ let index = this._thumbnails.indexOf(thumbnail);
+ this._thumbnails.splice(index, 1);
+ thumbnail.destroy();
+
+ this._queueUpdateStates();
+ },
+ });
+ });
+
+ if (this._pendingScaleUpdate) {
+ this.ease_property('scale', this._targetScale, {
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: RESCALE_ANIMATION_TIME,
+ onComplete: () => this._queueUpdateStates(),
+ });
+ this._pendingScaleUpdate = false;
+ }
+
+ // Wait until that's done
+ if (this._scale != this._targetScale || this._stateCounts[ThumbnailState.COLLAPSING] > 0)
+ return;
+
+ // And then slide in any new thumbnails
+ this._iterateStateThumbnails(ThumbnailState.NEW, thumbnail => {
+ this._setThumbnailState(thumbnail, ThumbnailState.ANIMATING_IN);
+ thumbnail.ease_property('slide-position', 0, {
+ duration: SLIDE_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ this._setThumbnailState(thumbnail, ThumbnailState.NORMAL);
+ },
+ });
+ });
+ }
+
+ _queueUpdateStates() {
+ if (this._stateUpdateQueued)
+ return;
+
+ Meta.later_add(Meta.LaterType.BEFORE_REDRAW,
+ this._updateStates.bind(this));
+
+ this._stateUpdateQueued = true;
+ }
+
+ vfunc_get_preferred_height(_forWidth) {
+ // Note that for getPreferredWidth/Height we cheat a bit and skip propagating
+ // the size request to our children because we know how big they are and know
+ // that the actors aren't depending on the virtual functions being called.
+ let workspaceManager = global.workspace_manager;
+ let themeNode = this.get_theme_node();
+
+ let spacing = themeNode.get_length('spacing');
+ let nWorkspaces = workspaceManager.n_workspaces;
+ let totalSpacing = (nWorkspaces - 1) * spacing;
+
+ let naturalHeight = totalSpacing + nWorkspaces * this._porthole.height * MAX_THUMBNAIL_SCALE;
+
+ return themeNode.adjust_preferred_height(totalSpacing, naturalHeight);
+ }
+
+ vfunc_get_preferred_width(forHeight) {
+ let workspaceManager = global.workspace_manager;
+ let themeNode = this.get_theme_node();
+
+ forHeight = themeNode.adjust_for_height(forHeight);
+
+ let spacing = themeNode.get_length('spacing');
+ let nWorkspaces = workspaceManager.n_workspaces;
+ let totalSpacing = (nWorkspaces - 1) * spacing;
+
+ let avail = forHeight - totalSpacing;
+
+ let scale = (avail / nWorkspaces) / this._porthole.height;
+ scale = Math.min(scale, MAX_THUMBNAIL_SCALE);
+
+ let width = Math.round(this._porthole.width * scale);
+
+ return themeNode.adjust_preferred_width(width, width);
+ }
+
+ _updatePorthole() {
+ if (!Main.layoutManager.primaryMonitor) {
+ this._porthole = { width: global.stage.width, height: global.stage.height,
+ x: global.stage.x, y: global.stage.y };
+ } else {
+ this._porthole = Main.layoutManager.getWorkAreaForMonitor(Main.layoutManager.primaryIndex);
+ }
+
+ this.queue_relayout();
+ }
+
+ vfunc_allocate(box) {
+ this.set_allocation(box);
+
+ let rtl = Clutter.get_default_text_direction() == Clutter.TextDirection.RTL;
+
+ if (this._thumbnails.length == 0) // not visible
+ return;
+
+ let workspaceManager = global.workspace_manager;
+ let themeNode = this.get_theme_node();
+
+ box = themeNode.get_content_box(box);
+
+ let portholeWidth = this._porthole.width;
+ let portholeHeight = this._porthole.height;
+ let spacing = themeNode.get_length('spacing');
+
+ // Compute the scale we'll need once everything is updated
+ let nWorkspaces = workspaceManager.n_workspaces;
+ let totalSpacing = (nWorkspaces - 1) * spacing;
+ let avail = (box.y2 - box.y1) - totalSpacing;
+
+ let newScale = (avail / nWorkspaces) / portholeHeight;
+ newScale = Math.min(newScale, MAX_THUMBNAIL_SCALE);
+
+ if (newScale != this._targetScale) {
+ if (this._targetScale > 0) {
+ // We don't do the tween immediately because we need to observe the ordering
+ // in queueUpdateStates - if workspaces have been removed we need to slide them
+ // out as the first thing.
+ this._targetScale = newScale;
+ this._pendingScaleUpdate = true;
+ } else {
+ this._targetScale = this._scale = newScale;
+ }
+
+ this._queueUpdateStates();
+ }
+
+ let thumbnailHeight = portholeHeight * this._scale;
+ let thumbnailWidth = Math.round(portholeWidth * this._scale);
+ let roundedHScale = thumbnailWidth / portholeWidth;
+
+ let slideOffset; // X offset when thumbnail is fully slid offscreen
+ if (rtl)
+ slideOffset = -(thumbnailWidth + themeNode.get_padding(St.Side.LEFT));
+ else
+ slideOffset = thumbnailWidth + themeNode.get_padding(St.Side.RIGHT);
+
+ let indicatorValue = this._scrollAdjustment.value;
+ let indicatorUpperWs = Math.ceil(indicatorValue);
+ let indicatorLowerWs = Math.floor(indicatorValue);
+
+ let indicatorLowerY1 = 0;
+ let indicatorLowerY2 = 0;
+ let indicatorUpperY1 = 0;
+ let indicatorUpperY2 = 0;
+
+ let indicatorThemeNode = this._indicator.get_theme_node();
+ let indicatorTopFullBorder = indicatorThemeNode.get_padding(St.Side.TOP) + indicatorThemeNode.get_border_width(St.Side.TOP);
+ let indicatorBottomFullBorder = indicatorThemeNode.get_padding(St.Side.BOTTOM) + indicatorThemeNode.get_border_width(St.Side.BOTTOM);
+ let indicatorLeftFullBorder = indicatorThemeNode.get_padding(St.Side.LEFT) + indicatorThemeNode.get_border_width(St.Side.LEFT);
+ let indicatorRightFullBorder = indicatorThemeNode.get_padding(St.Side.RIGHT) + indicatorThemeNode.get_border_width(St.Side.RIGHT);
+
+ let y = box.y1;
+
+ if (this._dropPlaceholderPos == -1) {
+ this._dropPlaceholder.allocate_preferred_size(
+ ...this._dropPlaceholder.get_position());
+
+ Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
+ this._dropPlaceholder.hide();
+ });
+ }
+
+ let childBox = new Clutter.ActorBox();
+
+ for (let i = 0; i < this._thumbnails.length; i++) {
+ let thumbnail = this._thumbnails[i];
+
+ if (i > 0)
+ y += spacing - Math.round(thumbnail.collapse_fraction * spacing);
+
+ let x1, x2;
+ if (rtl) {
+ x1 = box.x1 + slideOffset * thumbnail.slide_position;
+ x2 = x1 + thumbnailWidth;
+ } else {
+ x1 = box.x2 - thumbnailWidth + slideOffset * thumbnail.slide_position;
+ x2 = x1 + thumbnailWidth;
+ }
+
+ if (i == this._dropPlaceholderPos) {
+ let [, placeholderHeight] = this._dropPlaceholder.get_preferred_height(-1);
+ childBox.x1 = x1;
+ childBox.x2 = x2;
+ childBox.y1 = Math.round(y);
+ childBox.y2 = Math.round(y + placeholderHeight);
+ this._dropPlaceholder.allocate(childBox);
+ Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
+ this._dropPlaceholder.show();
+ });
+ y += placeholderHeight + spacing;
+ }
+
+ // We might end up with thumbnailHeight being something like 99.33
+ // pixels. To make this work and not end up with a gap at the bottom,
+ // we need some thumbnails to be 99 pixels and some 100 pixels height;
+ // we compute an actual scale separately for each thumbnail.
+ let y1 = Math.round(y);
+ let y2 = Math.round(y + thumbnailHeight);
+ let roundedVScale = (y2 - y1) / portholeHeight;
+
+ if (i === indicatorUpperWs) {
+ indicatorUpperY1 = y1;
+ indicatorUpperY2 = y2;
+ }
+ if (i === indicatorLowerWs) {
+ indicatorLowerY1 = y1;
+ indicatorLowerY2 = y2;
+ }
+
+ // Allocating a scaled actor is funny - x1/y1 correspond to the origin
+ // of the actor, but x2/y2 are increased by the *unscaled* size.
+ childBox.x1 = x1;
+ childBox.x2 = x1 + portholeWidth;
+ childBox.y1 = y1;
+ childBox.y2 = y1 + portholeHeight;
+
+ thumbnail.set_scale(roundedHScale, roundedVScale);
+ thumbnail.allocate(childBox);
+
+ // We round the collapsing portion so that we don't get thumbnails resizing
+ // during an animation due to differences in rounded, but leave the uncollapsed
+ // portion unrounded so that non-animating we end up with the right total
+ y += thumbnailHeight - Math.round(thumbnailHeight * thumbnail.collapse_fraction);
+ }
+
+ if (rtl) {
+ childBox.x1 = box.x1;
+ childBox.x2 = box.x1 + thumbnailWidth;
+ } else {
+ childBox.x1 = box.x2 - thumbnailWidth;
+ childBox.x2 = box.x2;
+ }
+ let indicatorY1 = indicatorLowerY1 +
+ (indicatorUpperY1 - indicatorLowerY1) * (indicatorValue % 1);
+ let indicatorY2 = indicatorLowerY2 +
+ (indicatorUpperY2 - indicatorLowerY2) * (indicatorValue % 1);
+
+ childBox.x1 -= indicatorLeftFullBorder;
+ childBox.x2 += indicatorRightFullBorder;
+ childBox.y1 = indicatorY1 - indicatorTopFullBorder;
+ childBox.y2 = indicatorY2 + indicatorBottomFullBorder;
+ this._indicator.allocate(childBox);
+ }
+});
diff --git a/js/ui/workspacesView.js b/js/ui/workspacesView.js
new file mode 100644
index 0000000..eba43ad
--- /dev/null
+++ b/js/ui/workspacesView.js
@@ -0,0 +1,805 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported WorkspacesView, WorkspacesDisplay */
+
+const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi;
+
+const Main = imports.ui.main;
+const SwipeTracker = imports.ui.swipeTracker;
+const Workspace = imports.ui.workspace;
+
+var { ANIMATION_TIME } = imports.ui.overview;
+var WORKSPACE_SWITCH_TIME = 250;
+var SCROLL_TIMEOUT_TIME = 150;
+
+var AnimationType = {
+ ZOOM: 0,
+ FADE: 1,
+};
+
+const MUTTER_SCHEMA = 'org.gnome.mutter';
+
+var WorkspacesViewBase = GObject.registerClass({
+ GTypeFlags: GObject.TypeFlags.ABSTRACT,
+}, class WorkspacesViewBase extends St.Widget {
+ _init(monitorIndex) {
+ const { x, y, width, height } =
+ Main.layoutManager.getWorkAreaForMonitor(monitorIndex);
+
+ super._init({
+ style_class: 'workspaces-view',
+ x, y, width, height,
+ });
+ this.connect('destroy', this._onDestroy.bind(this));
+ global.focus_manager.add_group(this);
+
+ this._monitorIndex = monitorIndex;
+
+ this._inDrag = false;
+ this._windowDragBeginId = Main.overview.connect('window-drag-begin', this._dragBegin.bind(this));
+ this._windowDragEndId = Main.overview.connect('window-drag-end', this._dragEnd.bind(this));
+ }
+
+ _onDestroy() {
+ this._dragEnd();
+
+ if (this._windowDragBeginId > 0) {
+ Main.overview.disconnect(this._windowDragBeginId);
+ this._windowDragBeginId = 0;
+ }
+ if (this._windowDragEndId > 0) {
+ Main.overview.disconnect(this._windowDragEndId);
+ this._windowDragEndId = 0;
+ }
+ }
+
+ _dragBegin() {
+ this._inDrag = true;
+ }
+
+ _dragEnd() {
+ this._inDrag = false;
+ }
+
+ vfunc_allocate(box) {
+ this.set_allocation(box);
+
+ for (const child of this)
+ child.allocate_available_size(0, 0, box.get_width(), box.get_height());
+ }
+});
+
+var WorkspacesView = GObject.registerClass(
+class WorkspacesView extends WorkspacesViewBase {
+ _init(monitorIndex, scrollAdjustment) {
+ let workspaceManager = global.workspace_manager;
+
+ super._init(monitorIndex);
+ this.clip_to_allocation = true;
+
+ this._animating = false; // tweening
+ this._gestureActive = false; // touch(pad) gestures
+
+ this._scrollAdjustment = scrollAdjustment;
+ this._onScrollId = this._scrollAdjustment.connect('notify::value',
+ this._onScrollAdjustmentChanged.bind(this));
+
+ this._workspaces = [];
+ this._updateWorkspaces();
+ this._updateWorkspacesId =
+ workspaceManager.connect('notify::n-workspaces',
+ this._updateWorkspaces.bind(this));
+ this._reorderWorkspacesId =
+ workspaceManager.connect('workspaces-reordered', () => {
+ this._workspaces.sort((a, b) => {
+ return a.metaWorkspace.index() - b.metaWorkspace.index();
+ });
+ this._workspaces.forEach(
+ (ws, i) => this.set_child_at_index(ws, i));
+ });
+
+ this._switchWorkspaceNotifyId =
+ global.window_manager.connect('switch-workspace',
+ this._activeWorkspaceChanged.bind(this));
+ }
+
+ vfunc_allocate(box) {
+ this.set_allocation(box);
+
+ if (this.get_n_children() === 0)
+ return;
+
+ const { workspaceManager } = global;
+ const { nWorkspaces } = workspaceManager;
+
+ const vertical = workspaceManager.layout_rows === -1;
+ const rtl = this.text_direction === Clutter.TextDirection.RTL;
+
+ this._workspaces.forEach((child, index) => {
+ if (rtl && !vertical)
+ index = nWorkspaces - index - 1;
+
+ const x = vertical ? 0 : index * this.width;
+ const y = vertical ? index * this.height : 0;
+
+ child.allocate_available_size(x, y, box.get_width(), box.get_height());
+ });
+
+ this._updateScrollPosition();
+ }
+
+ getActiveWorkspace() {
+ let workspaceManager = global.workspace_manager;
+ let active = workspaceManager.get_active_workspace_index();
+ return this._workspaces[active];
+ }
+
+ animateToOverview(animationType) {
+ for (let w = 0; w < this._workspaces.length; w++) {
+ if (animationType == AnimationType.ZOOM)
+ this._workspaces[w].zoomToOverview();
+ else
+ this._workspaces[w].fadeToOverview();
+ }
+ this._updateScrollPosition();
+ this._updateVisibility();
+ }
+
+ animateFromOverview(animationType) {
+ for (let w = 0; w < this._workspaces.length; w++) {
+ if (animationType == AnimationType.ZOOM)
+ this._workspaces[w].zoomFromOverview();
+ else
+ this._workspaces[w].fadeFromOverview();
+ }
+ }
+
+ syncStacking(stackIndices) {
+ for (let i = 0; i < this._workspaces.length; i++)
+ this._workspaces[i].syncStacking(stackIndices);
+ }
+
+ _scrollToActive() {
+ const { workspaceManager } = global;
+ const active = workspaceManager.get_active_workspace_index();
+
+ this._animating = true;
+ this._updateVisibility();
+
+ this._scrollAdjustment.remove_transition('value');
+ this._scrollAdjustment.ease(active, {
+ duration: WORKSPACE_SWITCH_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_CUBIC,
+ onComplete: () => {
+ this._animating = false;
+ this._updateVisibility();
+ },
+ });
+ }
+
+ _updateVisibility() {
+ let workspaceManager = global.workspace_manager;
+ let active = workspaceManager.get_active_workspace_index();
+
+ for (let w = 0; w < this._workspaces.length; w++) {
+ let workspace = this._workspaces[w];
+
+ if (this._animating || this._gestureActive)
+ workspace.show();
+ else if (this._inDrag)
+ workspace.visible = Math.abs(w - active) <= 1;
+ else
+ workspace.visible = w == active;
+ }
+ }
+
+ _updateWorkspaces() {
+ let workspaceManager = global.workspace_manager;
+ let newNumWorkspaces = workspaceManager.n_workspaces;
+
+ for (let j = 0; j < newNumWorkspaces; j++) {
+ let metaWorkspace = workspaceManager.get_workspace_by_index(j);
+ let workspace;
+
+ if (j >= this._workspaces.length) { /* added */
+ workspace = new Workspace.Workspace(metaWorkspace, this._monitorIndex);
+ this.add_actor(workspace);
+ this._workspaces[j] = workspace;
+ } else {
+ workspace = this._workspaces[j];
+
+ if (workspace.metaWorkspace != metaWorkspace) { /* removed */
+ workspace.destroy();
+ this._workspaces.splice(j, 1);
+ } /* else kept */
+ }
+ }
+
+ this._updateScrollPosition();
+ }
+
+ _activeWorkspaceChanged(_wm, _from, _to, _direction) {
+ if (this._scrolling)
+ return;
+
+ this._scrollToActive();
+ }
+
+ _onDestroy() {
+ super._onDestroy();
+
+ this._scrollAdjustment.disconnect(this._onScrollId);
+ global.window_manager.disconnect(this._switchWorkspaceNotifyId);
+ let workspaceManager = global.workspace_manager;
+ workspaceManager.disconnect(this._updateWorkspacesId);
+ workspaceManager.disconnect(this._reorderWorkspacesId);
+ }
+
+ startTouchGesture() {
+ this._gestureActive = true;
+
+ this._updateVisibility();
+ }
+
+ endTouchGesture() {
+ this._gestureActive = false;
+
+ // Make sure title captions etc are shown as necessary
+ this._scrollToActive();
+ this._updateVisibility();
+ }
+
+ // sync the workspaces' positions to the value of the scroll adjustment
+ // and change the active workspace if appropriate
+ _onScrollAdjustmentChanged() {
+ if (!this.has_allocation())
+ return;
+
+ const adj = this._scrollAdjustment;
+ const allowSwitch =
+ adj.get_transition('value') === null && !this._gestureActive;
+
+ let workspaceManager = global.workspace_manager;
+ let active = workspaceManager.get_active_workspace_index();
+ let current = Math.round(adj.value);
+
+ if (allowSwitch && active !== current) {
+ if (!this._workspaces[current]) {
+ // The current workspace was destroyed. This could happen
+ // when you are on the last empty workspace, and consolidate
+ // windows using the thumbnail bar.
+ // In that case, the intended behavior is to stay on the empty
+ // workspace, which is the last one, so pick it.
+ current = this._workspaces.length - 1;
+ }
+
+ let metaWorkspace = this._workspaces[current].metaWorkspace;
+ metaWorkspace.activate(global.get_current_time());
+ }
+
+ this._updateScrollPosition();
+ }
+
+ _updateScrollPosition() {
+ if (!this.has_allocation())
+ return;
+
+ const adj = this._scrollAdjustment;
+
+ if (adj.upper == 1)
+ return;
+
+ const workspaceManager = global.workspace_manager;
+ const vertical = workspaceManager.layout_rows === -1;
+ const rtl = this.text_direction === Clutter.TextDirection.RTL;
+ const progress = vertical || !rtl
+ ? adj.value : adj.upper - adj.value - 1;
+
+ for (const ws of this._workspaces) {
+ if (vertical)
+ ws.translation_y = -progress * this.height;
+ else
+ ws.translation_x = -progress * this.width;
+ }
+ }
+});
+
+var ExtraWorkspaceView = GObject.registerClass(
+class ExtraWorkspaceView extends WorkspacesViewBase {
+ _init(monitorIndex) {
+ super._init(monitorIndex);
+ this._workspace = new Workspace.Workspace(null, monitorIndex);
+ this.add_actor(this._workspace);
+ }
+
+ getActiveWorkspace() {
+ return this._workspace;
+ }
+
+ animateToOverview(animationType) {
+ if (animationType == AnimationType.ZOOM)
+ this._workspace.zoomToOverview();
+ else
+ this._workspace.fadeToOverview();
+ }
+
+ animateFromOverview(animationType) {
+ if (animationType == AnimationType.ZOOM)
+ this._workspace.zoomFromOverview();
+ else
+ this._workspace.fadeFromOverview();
+ }
+
+ syncStacking(stackIndices) {
+ this._workspace.syncStacking(stackIndices);
+ }
+
+ startTouchGesture() {
+ }
+
+ endTouchGesture() {
+ }
+});
+
+var WorkspacesDisplay = GObject.registerClass(
+class WorkspacesDisplay extends St.Widget {
+ _init(scrollAdjustment) {
+ super._init({
+ visible: false,
+ clip_to_allocation: true,
+ });
+ this.connect('notify::allocation', this._updateWorkspacesActualGeometry.bind(this));
+
+ Main.overview.connect('relayout',
+ () => this._updateWorkspacesActualGeometry());
+
+ let workspaceManager = global.workspace_manager;
+ this._scrollAdjustment = scrollAdjustment;
+
+ this._switchWorkspaceId =
+ global.window_manager.connect('switch-workspace',
+ this._activeWorkspaceChanged.bind(this));
+
+ this._reorderWorkspacesdId =
+ workspaceManager.connect('workspaces-reordered',
+ this._workspacesReordered.bind(this));
+
+ let clickAction = new Clutter.ClickAction();
+ clickAction.connect('clicked', action => {
+ // Only switch to the workspace when there's no application
+ // windows open. The problem is that it's too easy to miss
+ // an app window and get the wrong one focused.
+ let event = Clutter.get_current_event();
+ let index = this._getMonitorIndexForEvent(event);
+ if ((action.get_button() == 1 || action.get_button() == 0) &&
+ this._workspacesViews[index].getActiveWorkspace().isEmpty())
+ Main.overview.hide();
+ });
+ Main.overview.addAction(clickAction);
+ this.bind_property('mapped', clickAction, 'enabled', GObject.BindingFlags.SYNC_CREATE);
+ this._clickAction = clickAction;
+
+ this._swipeTracker = new SwipeTracker.SwipeTracker(
+ Main.layoutManager.overviewGroup, Shell.ActionMode.OVERVIEW);
+ this._swipeTracker.connect('begin', this._switchWorkspaceBegin.bind(this));
+ this._swipeTracker.connect('update', this._switchWorkspaceUpdate.bind(this));
+ this._swipeTracker.connect('end', this._switchWorkspaceEnd.bind(this));
+ this.connect('notify::mapped', this._updateSwipeTracker.bind(this));
+
+ this._windowDragBeginId =
+ Main.overview.connect('window-drag-begin',
+ this._windowDragBegin.bind(this));
+ this._windowDragEndId =
+ Main.overview.connect('window-drag-end',
+ this._windowDragEnd.bind(this));
+ this._overviewShownId = Main.overview.connect('shown', () => {
+ this._inWindowFade = false;
+ this._syncWorkspacesActualGeometry();
+ });
+
+ this._primaryIndex = Main.layoutManager.primaryIndex;
+ this._workspacesViews = [];
+
+ this._settings = new Gio.Settings({ schema_id: MUTTER_SCHEMA });
+ this._settings.connect('changed::workspaces-only-on-primary',
+ this._workspacesOnlyOnPrimaryChanged.bind(this));
+ this._workspacesOnlyOnPrimaryChanged();
+
+ this._notifyOpacityId = 0;
+ this._restackedNotifyId = 0;
+ this._scrollEventId = 0;
+ this._keyPressEventId = 0;
+ this._scrollTimeoutId = 0;
+ this._syncActualGeometryLater = 0;
+
+ this._actualGeometry = null;
+ this._inWindowDrag = false;
+ this._inWindowFade = false;
+
+ this._gestureActive = false; // touch(pad) gestures
+ this._canScroll = true; // limiting scrolling speed
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ _onDestroy() {
+ if (this._notifyOpacityId) {
+ let parent = this.get_parent();
+ if (parent)
+ parent.disconnect(this._notifyOpacityId);
+ this._notifyOpacityId = 0;
+ }
+
+ if (this._parentSetLater) {
+ Meta.later_remove(this._parentSetLater);
+ this._parentSetLater = 0;
+ }
+
+ if (this._syncActualGeometryLater) {
+ Meta.later_remove(this._syncActualGeometryLater);
+ this._syncActualGeometryLater = 0;
+ }
+
+ if (this._scrollTimeoutId !== 0) {
+ GLib.source_remove(this._scrollTimeoutId);
+ this._scrollTimeoutId = 0;
+ }
+
+ global.window_manager.disconnect(this._switchWorkspaceId);
+ global.workspace_manager.disconnect(this._reorderWorkspacesdId);
+ Main.overview.disconnect(this._windowDragBeginId);
+ Main.overview.disconnect(this._windowDragEndId);
+ Main.overview.disconnect(this._overviewShownId);
+ }
+
+ _windowDragBegin() {
+ this._inWindowDrag = true;
+ this._updateSwipeTracker();
+ }
+
+ _windowDragEnd() {
+ this._inWindowDrag = false;
+ this._updateSwipeTracker();
+ }
+
+ _updateSwipeTracker() {
+ this._swipeTracker.enabled = this.mapped && !this._inWindowDrag;
+ }
+
+ _workspacesReordered() {
+ let workspaceManager = global.workspace_manager;
+
+ this._scrollAdjustment.value =
+ workspaceManager.get_active_workspace_index();
+ }
+
+ _activeWorkspaceChanged(_wm, _from, to, _direction) {
+ if (this._gestureActive)
+ return;
+
+ this._scrollAdjustment.ease(to, {
+ mode: Clutter.AnimationMode.EASE_OUT_CUBIC,
+ duration: WORKSPACE_SWITCH_TIME,
+ });
+ }
+
+ _directionForProgress(progress) {
+ if (global.workspace_manager.layout_rows === -1) {
+ return progress > 0
+ ? Meta.MotionDirection.DOWN
+ : Meta.MotionDirection.UP;
+ } else if (this.text_direction === Clutter.TextDirection.RTL) {
+ return progress > 0
+ ? Meta.MotionDirection.LEFT
+ : Meta.MotionDirection.RIGHT;
+ } else {
+ return progress > 0
+ ? Meta.MotionDirection.RIGHT
+ : Meta.MotionDirection.LEFT;
+ }
+ }
+
+ _switchWorkspaceBegin(tracker, monitor) {
+ if (this._workspacesOnlyOnPrimary && monitor !== this._primaryIndex)
+ return;
+
+ let workspaceManager = global.workspace_manager;
+ let adjustment = this._scrollAdjustment;
+ if (this._gestureActive)
+ adjustment.remove_transition('value');
+
+ tracker.orientation = workspaceManager.layout_rows !== -1
+ ? Clutter.Orientation.HORIZONTAL
+ : Clutter.Orientation.VERTICAL;
+
+ for (let i = 0; i < this._workspacesViews.length; i++)
+ this._workspacesViews[i].startTouchGesture();
+
+ let distance = global.workspace_manager.layout_rows === -1
+ ? this.height : this.width;
+
+ let progress = adjustment.value / adjustment.page_size;
+ let points = Array.from(
+ { length: workspaceManager.n_workspaces }, (v, i) => i);
+
+ tracker.confirmSwipe(distance, points, progress, Math.round(progress));
+
+ this._gestureActive = true;
+ }
+
+ _switchWorkspaceUpdate(tracker, progress) {
+ let adjustment = this._scrollAdjustment;
+ adjustment.value = progress * adjustment.page_size;
+ }
+
+ _switchWorkspaceEnd(tracker, duration, endProgress) {
+ this._clickAction.release();
+
+ let workspaceManager = global.workspace_manager;
+ let newWs = workspaceManager.get_workspace_by_index(endProgress);
+
+ this._scrollAdjustment.ease(endProgress, {
+ mode: Clutter.AnimationMode.EASE_OUT_CUBIC,
+ duration,
+ onComplete: () => {
+ if (!newWs.active)
+ newWs.activate(global.get_current_time());
+ this._endTouchGesture();
+ },
+ });
+ }
+
+ _endTouchGesture() {
+ for (let i = 0; i < this._workspacesViews.length; i++)
+ this._workspacesViews[i].endTouchGesture();
+ this._gestureActive = false;
+ }
+
+ vfunc_navigate_focus(from, direction) {
+ return this._getPrimaryView().navigate_focus(from, direction, false);
+ }
+
+ animateToOverview(fadeOnPrimary) {
+ this.show();
+ this._updateWorkspacesViews();
+
+ for (let i = 0; i < this._workspacesViews.length; i++) {
+ let animationType;
+ if (fadeOnPrimary && i == this._primaryIndex)
+ animationType = AnimationType.FADE;
+ else
+ animationType = AnimationType.ZOOM;
+ this._workspacesViews[i].animateToOverview(animationType);
+ }
+
+ this._inWindowFade = fadeOnPrimary;
+
+ if (this._actualGeometry && !fadeOnPrimary)
+ this._syncWorkspacesActualGeometry();
+
+ this._restackedNotifyId =
+ Main.overview.connect('windows-restacked',
+ this._onRestacked.bind(this));
+ if (this._scrollEventId == 0)
+ this._scrollEventId = Main.overview.connect('scroll-event', this._onScrollEvent.bind(this));
+
+ if (this._keyPressEventId == 0)
+ this._keyPressEventId = global.stage.connect('key-press-event', this._onKeyPressEvent.bind(this));
+ }
+
+ animateFromOverview(fadeOnPrimary) {
+ for (let i = 0; i < this._workspacesViews.length; i++) {
+ let animationType;
+ if (fadeOnPrimary && i == this._primaryIndex)
+ animationType = AnimationType.FADE;
+ else
+ animationType = AnimationType.ZOOM;
+ this._workspacesViews[i].animateFromOverview(animationType);
+ }
+
+ this._inWindowFade = fadeOnPrimary;
+
+ const { primaryIndex } = Main.layoutManager;
+ const { x, y, width, height } =
+ Main.layoutManager.getWorkAreaForMonitor(primaryIndex);
+ this._getPrimaryView().ease({
+ x, y, width, height,
+ duration: fadeOnPrimary ? 0 : ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ vfunc_hide() {
+ if (this._restackedNotifyId > 0) {
+ Main.overview.disconnect(this._restackedNotifyId);
+ this._restackedNotifyId = 0;
+ }
+ if (this._scrollEventId > 0) {
+ Main.overview.disconnect(this._scrollEventId);
+ this._scrollEventId = 0;
+ }
+ if (this._keyPressEventId > 0) {
+ global.stage.disconnect(this._keyPressEventId);
+ this._keyPressEventId = 0;
+ }
+ for (let i = 0; i < this._workspacesViews.length; i++)
+ this._workspacesViews[i].destroy();
+ this._workspacesViews = [];
+
+ super.vfunc_hide();
+ }
+
+ _workspacesOnlyOnPrimaryChanged() {
+ this._workspacesOnlyOnPrimary = this._settings.get_boolean('workspaces-only-on-primary');
+
+ if (!Main.overview.visible)
+ return;
+
+ this._updateWorkspacesViews();
+ this._syncWorkspacesActualGeometry();
+ }
+
+ _updateWorkspacesViews() {
+ for (let i = 0; i < this._workspacesViews.length; i++)
+ this._workspacesViews[i].destroy();
+
+ this._primaryIndex = Main.layoutManager.primaryIndex;
+ this._workspacesViews = [];
+ let monitors = Main.layoutManager.monitors;
+ for (let i = 0; i < monitors.length; i++) {
+ let view;
+ if (this._workspacesOnlyOnPrimary && i != this._primaryIndex)
+ view = new ExtraWorkspaceView(i);
+ else
+ view = new WorkspacesView(i, this._scrollAdjustment);
+
+ this._workspacesViews.push(view);
+ Main.layoutManager.overviewGroup.add_actor(view);
+ }
+ }
+
+ _getMonitorIndexForEvent(event) {
+ let [x, y] = event.get_coords();
+ let rect = new Meta.Rectangle({ x, y, width: 1, height: 1 });
+ return global.display.get_monitor_index_for_rect(rect);
+ }
+
+ _getPrimaryView() {
+ if (!this._workspacesViews.length)
+ return null;
+ return this._workspacesViews[this._primaryIndex];
+ }
+
+ activeWorkspaceHasMaximizedWindows() {
+ return this._getPrimaryView().getActiveWorkspace().hasMaximizedWindows();
+ }
+
+ vfunc_parent_set(oldParent) {
+ if (oldParent && this._notifyOpacityId)
+ oldParent.disconnect(this._notifyOpacityId);
+ this._notifyOpacityId = 0;
+
+ if (this._parentSetLater)
+ return;
+
+ this._parentSetLater = Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
+ this._parentSetLater = 0;
+ let newParent = this.get_parent();
+ if (!newParent)
+ return;
+
+ // This is kinda hackish - we want the primary view to
+ // appear as parent of this, though in reality it
+ // is added directly to Main.layoutManager.overviewGroup
+ this._notifyOpacityId = newParent.connect('notify::opacity', () => {
+ let opacity = this.get_parent().opacity;
+ let primaryView = this._getPrimaryView();
+ if (!primaryView)
+ return;
+ primaryView.opacity = opacity;
+ primaryView.visible = opacity != 0;
+ });
+ });
+ }
+
+ _updateWorkspacesActualGeometry() {
+ const [x, y] = this.get_transformed_position();
+ const width = this.allocation.get_width();
+ const height = this.allocation.get_height();
+
+ this._actualGeometry = { x, y, width, height };
+
+ if (this._syncActualGeometryLater > 0)
+ return;
+
+ this._syncActualGeometryLater =
+ Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
+ this._syncWorkspacesActualGeometry();
+
+ this._syncActualGeometryLater = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+
+ _syncWorkspacesActualGeometry() {
+ const primaryView = this._getPrimaryView();
+ if (!primaryView || this._inWindowFade)
+ return;
+
+ primaryView.ease({
+ ...this._actualGeometry,
+ duration: Main.overview.animationInProgress ? ANIMATION_TIME : 0,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ _onRestacked(overview, stackIndices) {
+ for (let i = 0; i < this._workspacesViews.length; i++)
+ this._workspacesViews[i].syncStacking(stackIndices);
+ }
+
+ _onScrollEvent(actor, event) {
+ if (this._swipeTracker.canHandleScrollEvent(event))
+ return Clutter.EVENT_PROPAGATE;
+
+ if (!this.mapped)
+ return Clutter.EVENT_PROPAGATE;
+
+ if (this._workspacesOnlyOnPrimary &&
+ this._getMonitorIndexForEvent(event) != this._primaryIndex)
+ return Clutter.EVENT_PROPAGATE;
+
+ if (!this._canScroll)
+ return Clutter.EVENT_PROPAGATE;
+
+ let workspaceManager = global.workspace_manager;
+ let activeWs = workspaceManager.get_active_workspace();
+ let ws;
+ switch (event.get_scroll_direction()) {
+ case Clutter.ScrollDirection.UP:
+ ws = activeWs.get_neighbor(Meta.MotionDirection.UP);
+ break;
+ case Clutter.ScrollDirection.DOWN:
+ ws = activeWs.get_neighbor(Meta.MotionDirection.DOWN);
+ break;
+ case Clutter.ScrollDirection.LEFT:
+ ws = activeWs.get_neighbor(Meta.MotionDirection.LEFT);
+ break;
+ case Clutter.ScrollDirection.RIGHT:
+ ws = activeWs.get_neighbor(Meta.MotionDirection.RIGHT);
+ break;
+ default:
+ return Clutter.EVENT_PROPAGATE;
+ }
+ Main.wm.actionMoveWorkspace(ws);
+
+ this._canScroll = false;
+ this._scrollTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT,
+ SCROLL_TIMEOUT_TIME, () => {
+ this._canScroll = true;
+ this._scrollTimeoutId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+
+ return Clutter.EVENT_STOP;
+ }
+
+ _onKeyPressEvent(actor, event) {
+ if (!this.mapped)
+ return Clutter.EVENT_PROPAGATE;
+ let workspaceManager = global.workspace_manager;
+ let activeWs = workspaceManager.get_active_workspace();
+ let ws;
+ switch (event.get_key_symbol()) {
+ case Clutter.KEY_Page_Up:
+ ws = activeWs.get_neighbor(Meta.MotionDirection.UP);
+ break;
+ case Clutter.KEY_Page_Down:
+ ws = activeWs.get_neighbor(Meta.MotionDirection.DOWN);
+ break;
+ default:
+ return Clutter.EVENT_PROPAGATE;
+ }
+ Main.wm.actionMoveWorkspace(ws);
+ return Clutter.EVENT_STOP;
+ }
+});
diff --git a/js/ui/xdndHandler.js b/js/ui/xdndHandler.js
new file mode 100644
index 0000000..13d012d
--- /dev/null
+++ b/js/ui/xdndHandler.js
@@ -0,0 +1,118 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+
+const { Clutter } = imports.gi;
+const Signals = imports.signals;
+
+const DND = imports.ui.dnd;
+const Main = imports.ui.main;
+
+var XdndHandler = class {
+ constructor() {
+ // Used to display a clone of the cursor window when the
+ // window group is hidden (like it happens in the overview)
+ this._cursorWindowClone = null;
+
+ // Used as a drag actor in case we don't have a cursor window clone
+ this._dummy = new Clutter.Actor({ width: 1, height: 1, opacity: 0 });
+ Main.uiGroup.add_actor(this._dummy);
+ this._dummy.hide();
+
+ var dnd = global.backend.get_dnd();
+ dnd.connect('dnd-enter', this._onEnter.bind(this));
+ dnd.connect('dnd-position-change', this._onPositionChanged.bind(this));
+ dnd.connect('dnd-leave', this._onLeave.bind(this));
+
+ this._windowGroupVisibilityHandlerId = 0;
+ }
+
+ // Called when the user cancels the drag (i.e release the button)
+ _onLeave() {
+ if (this._windowGroupVisibilityHandlerId != 0) {
+ global.window_group.disconnect(this._windowGroupVisibilityHandlerId);
+ this._windowGroupVisibilityHandlerId = 0;
+ }
+ if (this._cursorWindowClone) {
+ this._cursorWindowClone.destroy();
+ this._cursorWindowClone = null;
+ }
+
+ this.emit('drag-end');
+ }
+
+ _onEnter() {
+ this._windowGroupVisibilityHandlerId =
+ global.window_group.connect('notify::visible',
+ this._onWindowGroupVisibilityChanged.bind(this));
+
+ this.emit('drag-begin', global.get_current_time());
+ }
+
+ _onWindowGroupVisibilityChanged() {
+ if (!global.window_group.visible) {
+ if (this._cursorWindowClone)
+ return;
+
+ let windows = global.get_window_actors();
+ let cursorWindow = windows[windows.length - 1];
+
+ // FIXME: more reliable way?
+ if (!cursorWindow.get_meta_window().is_override_redirect())
+ return;
+
+ let constraintPosition = new Clutter.BindConstraint({ coordinate: Clutter.BindCoordinate.POSITION,
+ source: cursorWindow });
+
+ this._cursorWindowClone = new Clutter.Clone({ source: cursorWindow });
+ Main.uiGroup.add_actor(this._cursorWindowClone);
+
+ // Make sure that the clone has the same position as the source
+ this._cursorWindowClone.add_constraint(constraintPosition);
+ } else {
+ if (!this._cursorWindowClone)
+ return;
+
+ this._cursorWindowClone.destroy();
+ this._cursorWindowClone = null;
+ }
+ }
+
+ _onPositionChanged(obj, x, y) {
+ let pickedActor = global.stage.get_actor_at_pos(Clutter.PickMode.REACTIVE, x, y);
+
+ // Make sure that the cursor window is on top
+ if (this._cursorWindowClone)
+ Main.uiGroup.set_child_above_sibling(this._cursorWindowClone, null);
+
+ let dragEvent = {
+ x,
+ y,
+ dragActor: this._cursorWindowClone ? this._cursorWindowClone : this._dummy,
+ source: this,
+ targetActor: pickedActor,
+ };
+
+ for (let i = 0; i < DND.dragMonitors.length; i++) {
+ let motionFunc = DND.dragMonitors[i].dragMotion;
+ if (motionFunc) {
+ let result = motionFunc(dragEvent);
+ if (result != DND.DragMotionResult.CONTINUE)
+ return;
+ }
+ }
+
+ while (pickedActor) {
+ if (pickedActor._delegate && pickedActor._delegate.handleDragOver) {
+ let [r_, targX, targY] = pickedActor.transform_stage_point(x, y);
+ let result = pickedActor._delegate.handleDragOver(this,
+ dragEvent.dragActor,
+ targX,
+ targY,
+ global.get_current_time());
+ if (result != DND.DragMotionResult.CONTINUE)
+ return;
+ }
+ pickedActor = pickedActor.get_parent();
+ }
+ }
+};
+Signals.addSignalMethods(XdndHandler.prototype);