summaryrefslogtreecommitdiffstats
path: root/js/ui
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:54:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:54:43 +0000
commite4283f6d48b98e764b988b43bbc86b9d52e6ec94 (patch)
treec8f7f7a6c2f5faa2942d27cefc6fd46cca492656 /js/ui
parentInitial commit. (diff)
downloadgnome-shell-e4283f6d48b98e764b988b43bbc86b9d52e6ec94.tar.xz
gnome-shell-e4283f6d48b98e764b988b43bbc86b9d52e6ec94.zip
Adding upstream version 43.9.upstream/43.9upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r--js/ui/accessDialog.js160
-rw-r--r--js/ui/altTab.js1134
-rw-r--r--js/ui/animation.js196
-rw-r--r--js/ui/appDisplay.js3273
-rw-r--r--js/ui/appFavorites.js212
-rw-r--r--js/ui/appMenu.js287
-rw-r--r--js/ui/audioDeviceSelection.js207
-rw-r--r--js/ui/background.js842
-rw-r--r--js/ui/backgroundMenu.js67
-rw-r--r--js/ui/barLevel.js262
-rw-r--r--js/ui/boxpointer.js654
-rw-r--r--js/ui/calendar.js1031
-rw-r--r--js/ui/checkBox.js40
-rw-r--r--js/ui/closeDialog.js207
-rw-r--r--js/ui/components/__init__.js58
-rw-r--r--js/ui/components/automountManager.js256
-rw-r--r--js/ui/components/autorunManager.js345
-rw-r--r--js/ui/components/keyring.js229
-rw-r--r--js/ui/components/networkAgent.js877
-rw-r--r--js/ui/components/polkitAgent.js471
-rw-r--r--js/ui/components/telepathyClient.js1019
-rw-r--r--js/ui/ctrlAltTab.js203
-rw-r--r--js/ui/dash.js992
-rw-r--r--js/ui/dateMenu.js980
-rw-r--r--js/ui/dialog.js359
-rw-r--r--js/ui/dnd.js841
-rw-r--r--js/ui/edgeDragAction.js89
-rw-r--r--js/ui/endSessionDialog.js798
-rw-r--r--js/ui/environment.js470
-rw-r--r--js/ui/extensionDownloader.js282
-rw-r--r--js/ui/extensionSystem.js687
-rw-r--r--js/ui/focusCaretTracker.js91
-rw-r--r--js/ui/grabHelper.js291
-rw-r--r--js/ui/ibusCandidatePopup.js359
-rw-r--r--js/ui/iconGrid.js1415
-rw-r--r--js/ui/inhibitShortcutsDialog.js160
-rw-r--r--js/ui/init.js6
-rw-r--r--js/ui/kbdA11yDialog.js76
-rw-r--r--js/ui/keyboard.js2275
-rw-r--r--js/ui/layout.js1451
-rw-r--r--js/ui/lightbox.js289
-rw-r--r--js/ui/locatePointer.js39
-rw-r--r--js/ui/lookingGlass.js1670
-rw-r--r--js/ui/magnifier.js2093
-rw-r--r--js/ui/main.js958
-rw-r--r--js/ui/messageList.js760
-rw-r--r--js/ui/messageTray.js1423
-rw-r--r--js/ui/modalDialog.js288
-rw-r--r--js/ui/mpris.js297
-rw-r--r--js/ui/notificationDaemon.js771
-rw-r--r--js/ui/osdMonitorLabeler.js117
-rw-r--r--js/ui/osdWindow.js192
-rw-r--r--js/ui/overview.js715
-rw-r--r--js/ui/overviewControls.js867
-rw-r--r--js/ui/padOsd.js991
-rw-r--r--js/ui/pageIndicators.js116
-rw-r--r--js/ui/panel.js774
-rw-r--r--js/ui/panelMenu.js233
-rw-r--r--js/ui/pointerA11yTimeout.js134
-rw-r--r--js/ui/pointerWatcher.js125
-rw-r--r--js/ui/popupMenu.js1415
-rw-r--r--js/ui/quickSettings.js717
-rw-r--r--js/ui/remoteSearch.js332
-rw-r--r--js/ui/ripples.js110
-rw-r--r--js/ui/runDialog.js256
-rw-r--r--js/ui/screenShield.js686
-rw-r--r--js/ui/screenshot.js2897
-rw-r--r--js/ui/scripting.js340
-rw-r--r--js/ui/search.js945
-rw-r--r--js/ui/searchController.js325
-rw-r--r--js/ui/sessionMode.js206
-rw-r--r--js/ui/shellDBus.js540
-rw-r--r--js/ui/shellEntry.js206
-rw-r--r--js/ui/shellMountOperation.js752
-rw-r--r--js/ui/slider.js218
-rw-r--r--js/ui/status/accessibility.js153
-rw-r--r--js/ui/status/autoRotate.js45
-rw-r--r--js/ui/status/bluetooth.js211
-rw-r--r--js/ui/status/brightness.js64
-rw-r--r--js/ui/status/darkMode.js49
-rw-r--r--js/ui/status/dwellClick.js83
-rw-r--r--js/ui/status/keyboard.js1095
-rw-r--r--js/ui/status/location.js371
-rw-r--r--js/ui/status/network.js2095
-rw-r--r--js/ui/status/nightLight.js70
-rw-r--r--js/ui/status/powerProfiles.js126
-rw-r--r--js/ui/status/remoteAccess.js230
-rw-r--r--js/ui/status/rfkill.js136
-rw-r--r--js/ui/status/system.js348
-rw-r--r--js/ui/status/thunderbolt.js332
-rw-r--r--js/ui/status/volume.js458
-rw-r--r--js/ui/swipeTracker.js787
-rw-r--r--js/ui/switchMonitor.js122
-rw-r--r--js/ui/switcherPopup.js688
-rw-r--r--js/ui/unlockDialog.js899
-rw-r--r--js/ui/userWidget.js212
-rw-r--r--js/ui/welcomeDialog.js64
-rw-r--r--js/ui/windowAttentionHandler.js100
-rw-r--r--js/ui/windowManager.js1927
-rw-r--r--js/ui/windowMenu.js252
-rw-r--r--js/ui/windowPreview.js681
-rw-r--r--js/ui/workspace.js1457
-rw-r--r--js/ui/workspaceAnimation.js496
-rw-r--r--js/ui/workspaceSwitcherPopup.js101
-rw-r--r--js/ui/workspaceThumbnail.js1436
-rw-r--r--js/ui/workspacesView.js1156
-rw-r--r--js/ui/xdndHandler.js116
107 files changed, 62809 insertions, 0 deletions
diff --git a/js/ui/accessDialog.js b/js/ui/accessDialog.js
new file mode 100644
index 0000000..8788e47
--- /dev/null
+++ b/js/ui/accessDialog.js
@@ -0,0 +1,160 @@
+/* exported AccessDialogDBus */
+const { Clutter, Gio, GLib, GObject, Pango, 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].deepUnpack();
+
+ 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,
+ });
+ bodyLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+ bodyLabel.clutter_text.line_wrap = true;
+ 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() {
+ if (!super.open())
+ return false;
+
+ let connection = this._invocation.get_connection();
+ this._requestExported = this._request.export(connection, this._handle);
+ return true;
+ }
+
+ 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.gnome.Shell.Portal', 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 && `${appId}.desktop` !== 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..a3daebc
--- /dev/null
+++ b/js/ui/altTab.js
@@ -0,0 +1,1134 @@
+// -*- 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) {
+ const rtl = Clutter.get_default_text_direction() === Clutter.TextDirection.RTL;
+ 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, rtl ? this._nextWindow() : this._previousWindow());
+ else if (keysym === Clutter.KEY_Right)
+ this._select(this._selectedIndex, rtl ? this._previousWindow() : 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(rtl ? this._next() : this._previous());
+ } else if (keysym == Clutter.KEY_Right) {
+ this._select(rtl ? this._previous() : 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);
+ }
+
+ _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('destroy', this._onDestroy.bind(this));
+ }
+
+ set window(w) {
+ if (this._window == w)
+ return;
+
+ this._window?.disconnectObject(this);
+
+ this._window = w;
+
+ if (this._clone.source)
+ this._clone.source.sync_visibility();
+
+ const windowActor = this._window?.get_compositor_private() ?? null;
+
+ if (windowActor)
+ windowActor.hide();
+
+ this._clone.source = windowActor;
+
+ if (this._window) {
+ this._onSizeChanged();
+ this._window.connectObject('size-changed',
+ this._onSizeChanged.bind(this), this);
+ } else {
+ this._highlight.set_size(0, 0);
+ this._highlight.hide();
+ }
+ }
+
+ _onSizeChanged() {
+ const bufferRect = this._window.get_buffer_rect();
+ const rect = this._window.get_frame_rect();
+ this._highlight.set_size(rect.width, rect.height);
+ this._highlight.set_position(
+ rect.x - bufferRect.x,
+ rect.y - bufferRect.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?.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) {
+ const rtl = Clutter.get_default_text_direction() === Clutter.TextDirection.RTL;
+ 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(rtl ? this._next() : this._previous());
+ else if (keysym == Clutter.KEY_Right)
+ this._select(rtl ? this._previous() : 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 = getWindows(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._altTabPopup = altTabPopup;
+ this._delayedHighlighted = -1;
+ 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.disconnectObject(this));
+ }
+
+ _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) {
+ if (!this._iconSize)
+ 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 _onItemMotion method to delay
+ // activation when the thumbnail list is open
+ _onItemMotion(item) {
+ if (item === this._items[this._highlighted] ||
+ item === this._items[this._delayedHighlighted])
+ return Clutter.EVENT_PROPAGATE;
+
+ const index = this._items.indexOf(item);
+
+ if (this._mouseTimeOutId !== 0) {
+ GLib.source_remove(this._mouseTimeOutId);
+ this._delayedHighlighted = -1;
+ this._mouseTimeOutId = 0;
+ }
+
+ if (this._altTabPopup.thumbnailsVisible) {
+ this._delayedHighlighted = index;
+ this._mouseTimeOutId = GLib.timeout_add(
+ GLib.PRIORITY_DEFAULT,
+ APP_ICON_HOVER_TIMEOUT,
+ () => {
+ this._enterItem(index);
+ this._delayedHighlighted = -1;
+ this._mouseTimeOutId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._mouseTimeOutId, '[gnome-shell] this._enterItem');
+ } else {
+ this._itemEntered(index);
+ }
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _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._highlighted]) {
+ if (this.icons[this._highlighted].cachedWindows.length === 1)
+ this._arrows[this._highlighted].hide();
+ else
+ this._arrows[this._highlighted].remove_style_pseudo_class('highlighted');
+ }
+
+ super.highlight(n, justOutline);
+
+ if (this._highlighted !== -1) {
+ if (justOutline && this.icons[this._highlighted].cachedWindows.length === 1)
+ this._arrows[this._highlighted].show();
+ else
+ this._arrows[this._highlighted].add_style_pseudo_class('highlighted');
+ }
+ }
+
+ _addIcon(appIcon) {
+ this.icons.push(appIcon);
+ let item = this.addItem(appIcon, appIcon.label);
+
+ appIcon.app.connectObject('notify::state', app => {
+ if (app.state != Shell.AppState.RUNNING)
+ this._removeIcon(app);
+ }, this);
+
+ 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++) {
+ const 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);
+
+ const title = windows[i].get_title();
+ const 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);
+ }
+
+ 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);
+
+ mutterWindow.connectObject('destroy',
+ source => this._removeThumbnail(source, clone), this);
+ 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 => clone?.source.disconnectObject(this));
+ }
+});
+
+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.window.connectObject('unmanaged',
+ window => this._removeWindow(window), this);
+ }
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ _onDestroy() {
+ this.icons.forEach(
+ icon => icon.window.disconnectObject(this));
+ }
+
+ 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..c2ed248
--- /dev/null
+++ b/js/ui/animation.js
@@ -0,0 +1,196 @@
+// -*- 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));
+
+ themeContext.connectObject('notify::scale-factor',
+ () => {
+ this._loadFile(file, width, height);
+ this.set_size(width * themeContext.scale_factor, height * themeContext.scale_factor);
+ }, this);
+
+ 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();
+ }
+});
+
+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..61fd0bc
--- /dev/null
+++ b/js/ui/appDisplay.js
@@ -0,0 +1,3273 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported AppDisplay, AppSearchProvider */
+
+const {
+ Clutter, Gio, GLib, GObject, Graphene, Pango, Shell, St,
+} = imports.gi;
+
+const AppFavorites = imports.ui.appFavorites;
+const { AppMenu } = imports.ui.appMenu;
+const BoxPointer = imports.ui.boxpointer;
+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;
+
+var APP_ICON_TITLE_EXPAND_TIME = 200;
+var APP_ICON_TITLE_COLLAPSE_TIME = 100;
+
+const FOLDER_DIALOG_ANIMATION_TIME = 200;
+
+const PAGE_PREVIEW_ANIMATION_TIME = 150;
+const PAGE_INDICATOR_FADE_TIME = 200;
+const PAGE_PREVIEW_RATIO = 0.20;
+
+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);
+
+const DEFAULT_FOLDERS = {
+ 'Utilities': {
+ name: 'X-GNOME-Utilities.directory',
+ categories: ['X-GNOME-Utilities'],
+ apps: [
+ 'gnome-abrt.desktop',
+ 'gnome-system-log.desktop',
+ 'nm-connection-editor.desktop',
+ 'org.gnome.baobab.desktop',
+ 'org.gnome.Connections.desktop',
+ 'org.gnome.DejaDup.desktop',
+ 'org.gnome.Dictionary.desktop',
+ 'org.gnome.DiskUtility.desktop',
+ 'org.gnome.eog.desktop',
+ 'org.gnome.Evince.desktop',
+ 'org.gnome.FileRoller.desktop',
+ 'org.gnome.fonts.desktop',
+ 'org.gnome.seahorse.Application.desktop',
+ 'org.gnome.tweaks.desktop',
+ 'org.gnome.Usage.desktop',
+ 'vinagre.desktop',
+ ],
+ },
+ 'YaST': {
+ name: 'suse-yast.directory',
+ categories: ['X-SuSE-YaST'],
+ },
+};
+
+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 = `${category}.directory`;
+ const translated = Shell.util_get_translated_folder_name(directory);
+ if (translated !== null)
+ return translated;
+ }
+
+ return null;
+}
+
+const AppGrid = GObject.registerClass({
+ Properties: {
+ 'indicators-padding': GObject.ParamSpec.boxed('indicators-padding',
+ 'Indicators padding', 'Indicators padding',
+ GObject.ParamFlags.READWRITE,
+ Clutter.Margin.$gtype),
+ },
+}, class AppGrid extends IconGrid.IconGrid {
+ _init(layoutParams) {
+ super._init(layoutParams);
+
+ this._indicatorsPadding = new Clutter.Margin();
+ }
+
+ _updatePadding() {
+ const node = this.get_theme_node();
+ const {rowSpacing, columnSpacing} = this.layoutManager;
+
+ const padding = this._indicatorsPadding.copy();
+ padding.left += rowSpacing;
+ padding.right += rowSpacing;
+ padding.top += columnSpacing;
+ padding.bottom += columnSpacing;
+ ['top', 'right', 'bottom', 'left'].forEach(side => {
+ padding[side] += node.get_length(`page-padding-${side}`);
+ });
+
+ this.layoutManager.pagePadding = padding;
+ }
+
+ vfunc_style_changed() {
+ super.vfunc_style_changed();
+ this._updatePadding();
+ }
+
+ get indicatorsPadding() {
+ return this._indicatorsPadding;
+ }
+
+ set indicatorsPadding(v) {
+ if (this._indicatorsPadding === v)
+ return;
+
+ this._indicatorsPadding = v ? v : new Clutter.Margin();
+ this._updatePadding();
+ }
+});
+
+const BaseAppViewGridLayout = GObject.registerClass(
+class BaseAppViewGridLayout extends Clutter.BinLayout {
+ _init(grid, scrollView, nextPageIndicator, nextPageArrow,
+ previousPageIndicator, previousPageArrow) {
+ if (!(grid instanceof AppGrid))
+ throw new Error('Grid must be an AppGrid subclass');
+
+ super._init();
+
+ this._grid = grid;
+ this._scrollView = scrollView;
+ this._previousPageIndicator = previousPageIndicator;
+ this._previousPageArrow = previousPageArrow;
+ this._nextPageIndicator = nextPageIndicator;
+ this._nextPageArrow = nextPageArrow;
+
+ grid.connect('pages-changed', () => this._syncPageIndicatorsVisibility());
+
+ this._pageIndicatorsAdjustment = new St.Adjustment({
+ lower: 0,
+ upper: 1,
+ });
+ this._pageIndicatorsAdjustment.connect(
+ 'notify::value', () => this._syncPageIndicators());
+
+ this._showIndicators = false;
+ this._currentPage = 0;
+ this._pageWidth = 0;
+ }
+
+ _getIndicatorsWidth(box) {
+ const [width, height] = box.get_size();
+ const arrows = [
+ this._nextPageArrow,
+ this._previousPageArrow,
+ ];
+
+ const minArrowsWidth = arrows.reduce(
+ (previousWidth, accessory) => {
+ const [min] = accessory.get_preferred_width(height);
+ return Math.max(previousWidth, min);
+ }, 0);
+
+ const idealIndicatorWidth = (width * PAGE_PREVIEW_RATIO) / 2;
+
+ return Math.max(idealIndicatorWidth, minArrowsWidth);
+ }
+
+ _syncPageIndicatorsVisibility(animate = true) {
+ const previousIndicatorsVisible =
+ this._currentPage > 0 && this._showIndicators;
+
+ if (previousIndicatorsVisible)
+ this._previousPageIndicator.show();
+
+ this._previousPageIndicator.ease({
+ opacity: previousIndicatorsVisible ? 255 : 0,
+ duration: animate ? PAGE_INDICATOR_FADE_TIME : 0,
+ onComplete: () => {
+ if (!previousIndicatorsVisible)
+ this._previousPageIndicator.hide();
+ },
+ });
+
+ const previousArrowVisible =
+ this._currentPage > 0 && !previousIndicatorsVisible;
+
+ if (previousArrowVisible)
+ this._previousPageArrow.show();
+
+ this._previousPageArrow.ease({
+ opacity: previousArrowVisible ? 255 : 0,
+ duration: animate ? PAGE_INDICATOR_FADE_TIME : 0,
+ onComplete: () => {
+ if (!previousArrowVisible)
+ this._previousPageArrow.hide();
+ },
+ });
+
+ // Always show the next page indicator to allow dropping
+ // icons into new pages
+ const {allowIncompletePages, nPages} = this._grid.layoutManager;
+ const nextIndicatorsVisible = this._showIndicators &&
+ (allowIncompletePages ? true : this._currentPage < nPages - 1);
+
+ if (nextIndicatorsVisible)
+ this._nextPageIndicator.show();
+
+ this._nextPageIndicator.ease({
+ opacity: nextIndicatorsVisible ? 255 : 0,
+ duration: animate ? PAGE_INDICATOR_FADE_TIME : 0,
+ onComplete: () => {
+ if (!nextIndicatorsVisible)
+ this._nextPageIndicator.hide();
+ },
+ });
+
+ const nextArrowVisible =
+ this._currentPage < nPages - 1 &&
+ !nextIndicatorsVisible;
+
+ if (nextArrowVisible)
+ this._nextPageArrow.show();
+
+ this._nextPageArrow.ease({
+ opacity: nextArrowVisible ? 255 : 0,
+ duration: animate ? PAGE_INDICATOR_FADE_TIME : 0,
+ onComplete: () => {
+ if (!nextArrowVisible)
+ this._nextPageArrow.hide();
+ },
+ });
+ }
+
+ _getEndIcon(icons) {
+ const {columnsPerPage} = this._grid.layoutManager;
+ const index = Math.min(icons.length, columnsPerPage);
+ return icons[Math.max(index - 1, 0)];
+ }
+
+ _translatePreviousPageIcons(value, ltr) {
+ if (this._currentPage === 0)
+ return;
+
+ const previousPage = this._currentPage - 1;
+ const icons = this._grid.getItemsAtPage(previousPage).filter(i => i.visible);
+ if (icons.length === 0)
+ return;
+
+ const {left, right} = this._grid.indicatorsPadding;
+ const {columnSpacing} = this._grid.layoutManager;
+ const endIcon = this._getEndIcon(icons);
+ let iconOffset;
+
+ if (ltr) {
+ const currentPageOffset = this._pageWidth * this._currentPage;
+ iconOffset = currentPageOffset - endIcon.allocation.x2 + left - columnSpacing;
+ } else {
+ const rtlPage = this._grid.nPages - previousPage - 1;
+ const pageOffset = this._pageWidth * rtlPage;
+ iconOffset = pageOffset - endIcon.allocation.x1 - right + columnSpacing;
+ }
+
+ for (const icon of icons)
+ icon.translationX = iconOffset * value;
+ }
+
+ _translateNextPageIcons(value, ltr) {
+ if (this._currentPage >= this._grid.nPages - 1)
+ return;
+
+ const nextPage = this._currentPage + 1;
+ const icons = this._grid.getItemsAtPage(nextPage).filter(i => i.visible);
+ if (icons.length === 0)
+ return;
+
+ const {left, right} = this._grid.indicatorsPadding;
+ const {columnSpacing} = this._grid.layoutManager;
+ let iconOffset;
+
+ if (ltr) {
+ const pageOffset = this._pageWidth * nextPage;
+ iconOffset = pageOffset - icons[0].allocation.x1 - right + columnSpacing;
+ } else {
+ const rtlPage = this._grid.nPages - this._currentPage - 1;
+ const currentPageOffset = this._pageWidth * rtlPage;
+ iconOffset = currentPageOffset - icons[0].allocation.x2 + left - columnSpacing;
+ }
+
+ for (const icon of icons)
+ icon.translationX = iconOffset * value;
+ }
+
+ _syncPageIndicators() {
+ if (!this._container)
+ return;
+
+ const {value} = this._pageIndicatorsAdjustment;
+
+ const ltr = this._container.get_text_direction() !== Clutter.TextDirection.RTL;
+ const {left, right} = this._grid.indicatorsPadding;
+ const leftIndicatorOffset = -left * (1 - value);
+ const rightIndicatorOffset = right * (1 - value);
+
+ this._previousPageIndicator.translationX =
+ ltr ? leftIndicatorOffset : rightIndicatorOffset;
+ this._nextPageIndicator.translationX =
+ ltr ? rightIndicatorOffset : leftIndicatorOffset;
+
+ const leftArrowOffset = -left * value;
+ const rightArrowOffset = right * value;
+
+ this._previousPageArrow.translationX =
+ ltr ? leftArrowOffset : rightArrowOffset;
+ this._nextPageArrow.translationX =
+ ltr ? rightArrowOffset : leftArrowOffset;
+
+ // Page icons
+ this._translatePreviousPageIcons(value, ltr);
+ this._translateNextPageIcons(value, ltr);
+
+ if (this._grid.nPages > 0) {
+ this._grid.getItemsAtPage(this._currentPage).forEach(icon => {
+ icon.translationX = 0;
+ });
+ }
+ }
+
+ vfunc_set_container(container) {
+ this._container = container;
+ this._pageIndicatorsAdjustment.actor = container;
+ this._syncPageIndicators();
+ }
+
+ vfunc_allocate(container, box) {
+ const ltr = container.get_text_direction() !== Clutter.TextDirection.RTL;
+ const indicatorsWidth = this._getIndicatorsWidth(box);
+
+ this._grid.indicatorsPadding = new Clutter.Margin({
+ left: indicatorsWidth,
+ right: indicatorsWidth,
+ });
+
+ this._scrollView.allocate(box);
+
+ const leftBox = box.copy();
+ leftBox.x2 = leftBox.x1 + indicatorsWidth;
+
+ const rightBox = box.copy();
+ rightBox.x1 = rightBox.x2 - indicatorsWidth;
+
+ this._previousPageIndicator.allocate(ltr ? leftBox : rightBox);
+ this._previousPageArrow.allocate_align_fill(ltr ? leftBox : rightBox,
+ 0.5, 0.5, false, false);
+ this._nextPageIndicator.allocate(ltr ? rightBox : leftBox);
+ this._nextPageArrow.allocate_align_fill(ltr ? rightBox : leftBox,
+ 0.5, 0.5, false, false);
+
+ this._pageWidth = box.get_width();
+ }
+
+ goToPage(page, animate = true) {
+ if (this._currentPage === page)
+ return;
+
+ this._currentPage = page;
+ this._syncPageIndicatorsVisibility(animate);
+ this._syncPageIndicators();
+ }
+
+ showPageIndicators() {
+ if (this._showIndicators)
+ return;
+
+ this._pageIndicatorsAdjustment.ease(1, {
+ duration: PAGE_PREVIEW_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_CUBIC,
+ });
+
+ this._grid.clipToView = false;
+ this._showIndicators = true;
+ this._syncPageIndicatorsVisibility();
+ }
+
+ hidePageIndicators() {
+ if (!this._showIndicators)
+ return;
+
+ this._pageIndicatorsAdjustment.ease(0, {
+ duration: PAGE_PREVIEW_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_CUBIC,
+ onComplete: () => {
+ this._grid.clipToView = true;
+ },
+ });
+
+ this._showIndicators = false;
+ this._syncPageIndicatorsVisibility();
+ }
+});
+
+var BaseAppView = GObject.registerClass({
+ GTypeFlags: GObject.TypeFlags.ABSTRACT,
+ Properties: {
+ 'gesture-modes': GObject.ParamSpec.flags(
+ 'gesture-modes', 'gesture-modes', 'gesture-modes',
+ GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
+ Shell.ActionMode, Shell.ActionMode.OVERVIEW),
+ },
+ Signals: {
+ 'view-loaded': {},
+ },
+}, class BaseAppView extends St.Widget {
+ _init(params = {}) {
+ 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.goToPage(this._grid.currentPage);
+ this._pageIndicators.setNPages(this._grid.nPages);
+ this._pageIndicators.setCurrentPosition(this._grid.currentPage);
+ });
+
+ // Scroll View
+ this._scrollView = new St.ScrollView({
+ style_class: 'apps-scroll-view',
+ clip_to_allocation: true,
+ x_expand: true,
+ y_expand: true,
+ reactive: true,
+ enable_mouse_scrolling: false,
+ });
+ this._scrollView.set_policy(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 = this._scrollView.hscroll;
+ this._adjustment = scroll.adjustment;
+ this._adjustment.connect('notify::value', adj => {
+ const value = adj.value / adj.page_size;
+ this._pageIndicators.setCurrentPosition(value);
+ });
+
+ // Page Indicators
+ this._pageIndicators =
+ new PageIndicators.PageIndicators(Clutter.Orientation.HORIZONTAL);
+
+ this._pageIndicators.y_expand = false;
+ this._pageIndicators.connect('page-activated',
+ (indicators, pageIndex) => {
+ this.goToPage(pageIndex);
+ });
+ this._pageIndicators.connect('scroll-event', (actor, event) => {
+ this._scrollView.event(event, false);
+ });
+
+ // Navigation indicators
+ this._nextPageIndicator = new St.Widget({
+ style_class: 'page-navigation-hint next',
+ opacity: 0,
+ visible: false,
+ reactive: true,
+ x_expand: true,
+ y_expand: true,
+ x_align: Clutter.ActorAlign.FILL,
+ y_align: Clutter.ActorAlign.FILL,
+ });
+
+ this._prevPageIndicator = new St.Widget({
+ style_class: 'page-navigation-hint previous',
+ opacity: 0,
+ visible: false,
+ reactive: true,
+ x_expand: true,
+ y_expand: true,
+ x_align: Clutter.ActorAlign.FILL,
+ y_align: Clutter.ActorAlign.FILL,
+ });
+
+ // Next/prev page arrows
+ const rtl = this.get_text_direction() === Clutter.TextDirection.RTL;
+ this._nextPageArrow = new St.Button({
+ style_class: 'page-navigation-arrow',
+ icon_name: rtl
+ ? 'carousel-arrow-previous-symbolic'
+ : 'carousel-arrow-next-symbolic',
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ this._nextPageArrow.connect('clicked',
+ () => this.goToPage(this._grid.currentPage + 1));
+
+ this._prevPageArrow = new St.Button({
+ style_class: 'page-navigation-arrow',
+ icon_name: rtl
+ ? 'carousel-arrow-next-symbolic'
+ : 'carousel-arrow-previous-symbolic',
+ opacity: 0,
+ visible: false,
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ this._prevPageArrow.connect('clicked',
+ () => this.goToPage(this._grid.currentPage - 1));
+
+ const scrollContainer = new St.Widget({
+ clip_to_allocation: true,
+ y_expand: true,
+ });
+ scrollContainer.add_child(this._scrollView);
+ scrollContainer.add_child(this._prevPageIndicator);
+ scrollContainer.add_child(this._nextPageIndicator);
+ scrollContainer.add_child(this._nextPageArrow);
+ scrollContainer.add_child(this._prevPageArrow);
+ scrollContainer.layoutManager = new BaseAppViewGridLayout(
+ this._grid,
+ this._scrollView,
+ this._nextPageIndicator,
+ this._nextPageArrow,
+ this._prevPageIndicator,
+ this._prevPageArrow);
+ this._appGridLayout = scrollContainer.layoutManager;
+ scrollContainer._delegate = this;
+
+ this._box = new St.BoxLayout({
+ vertical: true,
+ x_expand: true,
+ y_expand: true,
+ });
+ this._box.add_child(scrollContainer);
+ this._box.add_child(this._pageIndicators);
+
+ // Swipe
+ this._swipeTracker = new SwipeTracker.SwipeTracker(this._scrollView,
+ Clutter.Orientation.HORIZONTAL, this.gestureModes);
+ this._swipeTracker.orientation = Clutter.Orientation.HORIZONTAL;
+ 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._orientation = Clutter.Orientation.HORIZONTAL;
+
+ this._items = new Map();
+ this._orderedItems = [];
+
+ // Filter the apps through the user’s parental controls.
+ this._parentalControlsManager = ParentalControlsManager.getDefault();
+ this._parentalControlsManager.connectObject('app-filter-changed',
+ () => this._redisplay(), this);
+
+ // Don't duplicate favorites
+ this._appFavorites = AppFavorites.getAppFavorites();
+ this._appFavorites.connectObject('changed',
+ () => this._redisplay(), this);
+
+ // Drag n' Drop
+ this._overshootTimeoutId = 0;
+ this._delayedMoveData = null;
+
+ this._dragBeginId = 0;
+ this._dragEndId = 0;
+ this._dragCancelledId = 0;
+
+ this.connect('destroy', this._onDestroy.bind(this));
+
+ this._previewedPages = new Map();
+ }
+
+ _onDestroy() {
+ if (this._swipeTracker) {
+ this._swipeTracker.destroy();
+ delete this._swipeTracker;
+ }
+
+ this._removeDelayedMove();
+ this._disconnectDnD();
+ }
+
+ _createGrid() {
+ return new AppGrid({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;
+
+ if (this._dragFocus) {
+ this._dragFocus.cancelActions();
+ this._dragFocus = null;
+ }
+
+ 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._grid.allocation.get_height() : this._grid.allocation.get_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._overshootTimeoutId)
+ GLib.source_remove(this._overshootTimeoutId);
+ this._overshootTimeoutId = 0;
+ }
+
+ _dragWithinOvershootRegion(dragEvent) {
+ const rtl = this.get_text_direction() === Clutter.TextDirection.RTL;
+ const {x, y, targetActor: indicator} = dragEvent;
+ const [indicatorX, indicatorY] = indicator.get_transformed_position();
+ const [indicatorWidth, indicatorHeight] = indicator.get_transformed_size();
+
+ let overshootX = indicatorX;
+ if (indicator === this._nextPageIndicator || rtl)
+ overshootX += indicatorWidth - OVERSHOOT_THRESHOLD;
+
+ const overshootBox = new Clutter.ActorBox();
+ overshootBox.set_origin(overshootX, indicatorY);
+ overshootBox.set_size(OVERSHOOT_THRESHOLD, indicatorHeight);
+
+ return overshootBox.contains(x, y);
+ }
+
+ _handleDragOvershoot(dragEvent) {
+ // Already animating
+ if (this._adjustment.get_transition('value') !== null)
+ return;
+
+ const {targetActor} = dragEvent;
+
+ if (targetActor !== this._prevPageIndicator &&
+ targetActor !== this._nextPageIndicator) {
+ this._resetOvershoot();
+ return;
+ }
+
+ if (this._overshootTimeoutId > 0)
+ return;
+
+ let targetPage;
+ if (dragEvent.targetActor === this._prevPageIndicator)
+ targetPage = this._grid.currentPage - 1;
+ else
+ targetPage = this._grid.currentPage + 1;
+
+ if (targetPage < 0 || targetPage >= this._grid.nPages)
+ return; // don't go beyond first/last page
+
+ // If dragging over the drag overshoot threshold region, immediately
+ // switch pages
+ if (this._dragWithinOvershootRegion(dragEvent)) {
+ this._resetOvershoot();
+ this.goToPage(targetPage);
+ }
+
+ this._overshootTimeoutId =
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT, OVERSHOOT_TIMEOUT, () => {
+ this._resetOvershoot();
+ this.goToPage(targetPage);
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._overshootTimeoutId,
+ '[gnome-shell] this._overshootTimeoutId');
+ }
+
+ _onDragBegin() {
+ this._dragMonitor = {
+ dragMotion: this._onDragMotion.bind(this),
+ dragDrop: this._onDragDrop.bind(this),
+ };
+ DND.addDragMonitor(this._dragMonitor);
+ this._appGridLayout.showPageIndicators();
+ this._dragFocus = null;
+ this._swipeTracker.enabled = false;
+ }
+
+ _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;
+ }
+
+ _onDragDrop(dropEvent) {
+ // Because acceptDrop() does not receive the target actor, store it
+ // here and use this value in the acceptDrop() implementation below.
+ this._dropTarget = dropEvent.targetActor;
+ return DND.DragMotionResult.CONTINUE;
+ }
+
+ _onDragEnd() {
+ if (this._dragMonitor) {
+ DND.removeDragMonitor(this._dragMonitor);
+ this._dragMonitor = null;
+ }
+
+ this._resetOvershoot();
+ this._appGridLayout.hidePageIndicators();
+ this._swipeTracker.enabled = true;
+ }
+
+ _onDragCancelled() {
+ // At this point, the positions aren't stored yet, thus _redisplay()
+ // will move all items to their original positions
+ this._redisplay();
+ this._appGridLayout.hidePageIndicators();
+ this._swipeTracker.enabled = true;
+ }
+
+ _canAccept(source) {
+ return source instanceof AppViewItem;
+ }
+
+ handleDragOver(source) {
+ if (!this._canAccept(source))
+ return DND.DragMotionResult.NO_DROP;
+
+ return DND.DragMotionResult.MOVE_DROP;
+ }
+
+ acceptDrop(source) {
+ const dropTarget = this._dropTarget;
+ delete this._dropTarget;
+
+ if (!this._canAccept(source))
+ return false;
+
+ if (dropTarget === this._prevPageIndicator ||
+ dropTarget === this._nextPageIndicator) {
+ const increment = dropTarget === this._prevPageIndicator ? -1 : 1;
+ const { currentPage, nPages } = this._grid;
+ const page = Math.min(currentPage + increment, nPages);
+ const position = page < nPages ? -1 : 0;
+
+ this._moveItem(source, page, position);
+ this.goToPage(page);
+ } else if (this._delayedMoveData) {
+ // Dropped before the icon was moved
+ 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.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 ${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);
+ });
+ }
+ }
+
+ _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_map() {
+ this._swipeTracker.enabled = true;
+ this._connectDnD();
+ super.vfunc_map();
+ }
+
+ vfunc_unmap() {
+ if (this._swipeTracker)
+ this._swipeTracker.enabled = false;
+ 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, Math.max(this._grid.nPages - 1, 0));
+
+ if (this._grid.currentPage === pageNumber)
+ return;
+
+ this._appGridLayout.goToPage(pageNumber, animate);
+ this._grid.goToPage(pageNumber, animate);
+ }
+
+ updateDragFocus(dragFocus) {
+ this._dragFocus = dragFocus;
+ }
+});
+
+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.add_child(this._box);
+
+ this._folderIcons = [];
+
+ this._currentDialog = null;
+ this._displayingDialog = false;
+
+ this._placeholder = null;
+
+ this._overviewHiddenId = 0;
+ this._redisplayWorkId = Main.initializeDeferredWork(this, () => {
+ this._redisplay();
+ if (this._overviewHiddenId === 0)
+ this._overviewHiddenId = Main.overview.connect('hidden', () => this.goToPage(0));
+ });
+
+ Shell.AppSystem.get_default().connect('installed-changed', () => {
+ Main.queueDeferredWork(this._redisplayWorkId);
+ });
+ this._folderSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.app-folders' });
+ this._ensureDefaultFolders();
+ this._folderSettings.connect('changed::folder-children', () => {
+ Main.queueDeferredWork(this._redisplayWorkId);
+ });
+ }
+
+ _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;
+ }
+
+ _ensureDefaultFolders() {
+ if (this._folderSettings.get_strv('folder-children').length > 0)
+ return;
+
+ const folders = Object.keys(DEFAULT_FOLDERS);
+ this._folderSettings.set_strv('folder-children', folders);
+
+ const { path } = this._folderSettings;
+ for (const folder of folders) {
+ const { name, categories, apps } = DEFAULT_FOLDERS[folder];
+ const child = new Gio.Settings({
+ schema_id: 'org.gnome.desktop.app-folders.folder',
+ path: `${path}folders/${folder}/`,
+ });
+ child.set_string('name', name);
+ child.set_boolean('translate', true);
+ child.set_strv('categories', categories);
+ if (apps)
+ child.set_strv('apps', apps);
+ }
+ }
+
+ _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.connect('notify::pressed', icon => {
+ if (icon.pressed)
+ this.updateDragFocus(icon);
+ });
+ 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._appFavorites.isFavorite(appInfo.get_id()) &&
+ 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 = `${this._folderSettings.path}folders/${id}/`;
+ let icon = this._items.get(id);
+ if (!icon) {
+ icon = new FolderIcon(id, path, this);
+ icon.connect('apps-changed', () => {
+ this._redisplay();
+ this._savePages();
+ });
+ icon.connect('notify::pressed', () => {
+ if (icon.pressed)
+ this.updateDragFocus(icon);
+ });
+ }
+
+ // 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 });
+ icon.connect('notify::pressed', () => {
+ if (icon.pressed)
+ this.updateDragFocus(icon);
+ });
+ }
+
+ appIcons.push(icon);
+ });
+
+ // At last, if there's a placeholder available, add it
+ if (this._placeholder)
+ appIcons.push(this._placeholder);
+
+ return appIcons;
+ }
+
+ 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),
+ });
+ }
+ }
+
+ goToPage(pageNumber, animate = true) {
+ pageNumber = Math.clamp(pageNumber, 0, Math.max(this._grid.nPages - 1, 0));
+
+ 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;
+ } else if (event.get_key_symbol() === Clutter.KEY_Home) {
+ this.goToPage(0);
+ return Clutter.EVENT_STOP;
+ } else if (event.get_key_symbol() === Clutter.KEY_End) {
+ this.goToPage(this._grid.nPages - 1);
+ return Clutter.EVENT_STOP;
+ }
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ addFolderDialog(dialog) {
+ Main.layoutManager.overviewGroup.add_child(dialog);
+ dialog.connect('open-state-changed', (o, isOpen) => {
+ this._currentDialog?.disconnectObject(this);
+
+ this._currentDialog = null;
+
+ if (isOpen) {
+ this._currentDialog = dialog;
+ this._currentDialog.connectObject('destroy',
+ () => (this._currentDialog = null), this);
+ }
+ 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._appFavorites.isFavorite(source.id))
+ this._ensurePlaceholder(source);
+ }
+
+ _onDragMotion(dragEvent) {
+ if (this._currentDialog)
+ return DND.DragMotionResult.CONTINUE;
+
+ return super._onDragMotion(dragEvent);
+ }
+
+ _onDragEnd() {
+ super._onDragEnd();
+ this._removePlaceholder();
+ this._savePages();
+ }
+
+ _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();
+
+ if (this._appFavorites.isFavorite(source.id))
+ this._appFavorites.removeFavorite(source.id);
+
+ 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;
+ try {
+ newFolderSettings = new Gio.Settings({
+ schema_id: 'org.gnome.desktop.app-folders.folder',
+ path: newFolderPath,
+ });
+ } catch (e) {
+ 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) {
+ const { scaleFactor } = St.ThemeContext.get_for_stage(global.stage);
+ 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);
+
+ const createIcon = size => new St.Icon({
+ icon_name: iconName,
+ width: size * scaleFactor,
+ height: size * scaleFactor,
+ style_class: 'system-action-icon',
+ });
+
+ metas.push({ id, name, createIcon });
+ }
+ }
+
+ return new Promise(resolve => resolve(metas));
+ }
+
+ filterResults(results, maxNumber) {
+ return results.slice(0, maxNumber);
+ }
+
+ getInitialResultSet(terms, cancellable) {
+ // Defer until the parental controls manager is initialised, so the
+ // results can be filtered correctly.
+ if (!this._parentalControlsManager.initialized) {
+ return new Promise(resolve => {
+ let initializedId = this._parentalControlsManager.connect('app-filter-changed', async () => {
+ if (this._parentalControlsManager.initialized) {
+ this._parentalControlsManager.disconnect(initializedId);
+ resolve(await this.getInitialResultSet(terms, cancellable));
+ }
+ });
+ });
+ }
+
+ 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));
+ return new Promise(resolve => resolve(results));
+ }
+
+ getSubsearchResultSet(previousResults, terms, cancellable) {
+ return this.getInitialResultSet(terms, cancellable);
+ }
+
+ createResultObject(resultMeta) {
+ if (resultMeta.id.endsWith('.desktop')) {
+ return new AppIcon(this._appSys.lookup_app(resultMeta['id']), {
+ expandTitleOnHover: false,
+ });
+ } else {
+ return new SystemActionIcon(this, resultMeta);
+ }
+ }
+};
+
+var AppViewItem = GObject.registerClass(
+class AppViewItem extends St.Button {
+ _init(params = {}, isDraggable = true, expandTitleOnHover = 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, { timeoutThreshold: 200 });
+
+ 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._expandTitleOnHover = expandTitleOnHover;
+
+ if (expandTitleOnHover)
+ this.connect('notify::hover', this._onHover.bind(this));
+ 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;
+ }
+ }
+
+ _updateMultiline() {
+ if (!this._expandTitleOnHover || !this.icon.label)
+ return;
+
+ const { label } = this.icon;
+ const { clutterText } = label;
+ const layout = clutterText.get_layout();
+ if (!layout.is_wrapped() && !layout.is_ellipsized())
+ return;
+
+ label.remove_transition('allocation');
+
+ const id = label.connect('notify::allocation', () => {
+ label.restore_easing_state();
+ label.disconnect(id);
+ });
+
+ const expand = this._forcedHighlight || this.hover || this.has_key_focus();
+ label.save_easing_state();
+ label.set_easing_duration(expand
+ ? APP_ICON_TITLE_EXPAND_TIME
+ : APP_ICON_TITLE_COLLAPSE_TIME);
+ clutterText.set({
+ line_wrap: expand,
+ line_wrap_mode: expand ? Pango.WrapMode.WORD_CHAR : Pango.WrapMode.NONE,
+ ellipsize: expand ? Pango.EllipsizeMode.NONE : Pango.EllipsizeMode.END,
+ });
+ }
+
+ _onHover() {
+ this._updateMultiline();
+ }
+
+ _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;
+ }
+
+ vfunc_key_focus_in() {
+ this._updateMultiline();
+ super.vfunc_key_focus_in();
+ }
+
+ vfunc_key_focus_out() {
+ this._updateMultiline();
+ super.vfunc_key_focus_out();
+ }
+
+ 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;
+ }
+
+ cancelActions() {
+ if (this._draggable)
+ this._draggable.fakeRelease();
+ this.fake_release();
+ }
+
+ get id() {
+ return this._id;
+ }
+
+ get name() {
+ return this._name;
+ }
+
+ setForcedHighlight(highlighted) {
+ this._forcedHighlight = highlighted;
+ this.set({
+ track_hover: !highlighted,
+ hover: highlighted,
+ });
+ }
+});
+
+var FolderGrid = GObject.registerClass(
+class FolderGrid extends AppGrid {
+ _init() {
+ super._init({
+ allow_incomplete_pages: false,
+ columns_per_page: 3,
+ rows_per_page: 3,
+ page_halign: Clutter.ActorAlign.CENTER,
+ page_valign: Clutter.ActorAlign.CENTER,
+ });
+
+ this.setGridModes([
+ {
+ rows: 3,
+ columns: 3,
+ },
+ ]);
+ }
+});
+
+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,
+ gesture_modes: Shell.ActionMode.POPUP,
+ });
+
+ // 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;
+
+ this.add_child(this._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;
+
+ if (this._appFavorites.isFavorite(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;
+ }
+
+ createFolderIcon(size) {
+ const layout = new Clutter.GridLayout({
+ row_homogeneous: true,
+ column_homogeneous: true,
+ });
+ let icon = new St.Widget({
+ layout_manager: layout,
+ x_align: Clutter.ActorAlign.CENTER,
+ style: `width: ${size}px; height: ${size}px;`,
+ });
+
+ 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: ${subSize}px; height: ${subSize}px;`;
+ 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;
+ }
+
+ _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._folder.connectObject(
+ 'changed', this._sync.bind(this), this);
+ this._sync();
+ }
+
+ _onDestroy() {
+ super._onDestroy();
+
+ if (this._dialog)
+ this._dialog.destroy();
+ else
+ this.view.destroy();
+ }
+
+ 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 }));
+
+ 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.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,
+ icon_name: 'document-edit-symbolic',
+ });
+
+ 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'];
+ const expandTitleOnHover = appIconParams['expandTitleOnHover'];
+ delete iconParams['expandTitleOnHover'];
+
+ super._init({ style_class: 'app-well-app' }, isDraggable, expandTitleOnHover);
+
+ 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.app.connectObject('notify::state',
+ () => this._updateRunningStyle(), this);
+ this._updateRunningStyle();
+ }
+
+ _onDestroy() {
+ super._onDestroy();
+
+ if (this._folderPreviewId > 0) {
+ GLib.source_remove(this._folderPreviewId);
+ this._folderPreviewId = 0;
+ }
+
+ this._removeMenuTimeout();
+ }
+
+ _onDragBegin() {
+ if (this._menu)
+ this._menu.close(true);
+ 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(side = St.Side.LEFT) {
+ this.setForcedHighlight(true);
+ this._removeMenuTimeout();
+ this.fake_release();
+
+ if (!this._menu) {
+ this._menu = new AppMenu(this, side, {
+ favoritesSection: true,
+ showSingleWindows: true,
+ });
+ this._menu.setApp(this.app);
+ this._menu.connect('open-state-changed', (menu, isPoppedUp) => {
+ if (!isPoppedUp)
+ this._onMenuPoppedDown();
+ });
+ Main.overview.connectObject('hiding',
+ () => this._menu.close(), this);
+
+ Main.uiGroup.add_actor(this._menu.actor);
+ this._menuManager.addMenu(this._menu);
+ }
+
+ this.emit('menu-state-changed', true);
+
+ this._menu.open(BoxPointer.PopupAnimation.FULL);
+ this._menuManager.ignoreRelease();
+ this.emit('sync-tooltip');
+
+ return false;
+ }
+
+ _onMenuPoppedDown() {
+ this.setForcedHighlight(false);
+ 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${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);
+ }
+
+ cancelActions() {
+ if (this._menu)
+ this._menu.close(true);
+ this._removeMenuTimeout();
+ super.cancelActions();
+ }
+});
+
+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..d8a3018
--- /dev/null
+++ b/js/ui/appFavorites.js
@@ -0,0 +1,212 @@
+// -*- 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.misc.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 extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ // 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::${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 pinned to the dash.').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 unpinned from the dash.').format(app.get_name());
+ Main.overview.setMessage(msg, {
+ forFeedback: true,
+ undoCallback: () => this._addFavorite(appId, pos),
+ });
+ }
+}
+
+var appFavoritesInstance = null;
+function getAppFavorites() {
+ if (appFavoritesInstance == null)
+ appFavoritesInstance = new AppFavorites();
+ return appFavoritesInstance;
+}
diff --git a/js/ui/appMenu.js b/js/ui/appMenu.js
new file mode 100644
index 0000000..010fdb3
--- /dev/null
+++ b/js/ui/appMenu.js
@@ -0,0 +1,287 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported AppMenu */
+const { Clutter, Gio, GLib, Meta, Shell, St } = imports.gi;
+
+const AppFavorites = imports.ui.appFavorites;
+const Main = imports.ui.main;
+const ParentalControlsManager = imports.misc.parentalControlsManager;
+const PopupMenu = imports.ui.popupMenu;
+
+var AppMenu = class AppMenu extends PopupMenu.PopupMenu {
+ /**
+ * @param {Clutter.Actor} sourceActor - actor the menu is attached to
+ * @param {St.Side} side - arrow side
+ * @param {object} params - options
+ * @param {bool} params.favoritesSection - show items to add/remove favorite
+ * @param {bool} params.showSingleWindow - show window section for a single window
+ */
+ constructor(sourceActor, side = St.Side.TOP, params = {}) {
+ if (Clutter.get_default_text_direction() === Clutter.TextDirection.RTL) {
+ if (side === St.Side.LEFT)
+ side = St.Side.RIGHT;
+ else if (side === St.Side.RIGHT)
+ side = St.Side.LEFT;
+ }
+
+ super(sourceActor, 0.5, side);
+
+ this.actor.add_style_class_name('app-menu');
+
+ const {
+ favoritesSection = false,
+ showSingleWindows = false,
+ } = params;
+
+ this._app = null;
+ this._appSystem = Shell.AppSystem.get_default();
+ this._parentalControlsManager = ParentalControlsManager.getDefault();
+ this._appFavorites = AppFavorites.getAppFavorites();
+ this._enableFavorites = favoritesSection;
+ this._showSingleWindows = showSingleWindows;
+
+ this._windowsChangedId = 0;
+ this._updateWindowsLaterId = 0;
+
+ /* Translators: This is the heading of a list of open windows */
+ this._openWindowsHeader = new PopupMenu.PopupSeparatorMenuItem(_('Open Windows'));
+ this.addMenuItem(this._openWindowsHeader);
+
+ this._windowSection = new PopupMenu.PopupMenuSection();
+ this.addMenuItem(this._windowSection);
+
+ this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ this._newWindowItem = this.addAction(_('New Window'), () => {
+ this._animateLaunch();
+ this._app.open_new_window(-1);
+ Main.overview.hide();
+ });
+
+ this._actionSection = new PopupMenu.PopupMenuSection();
+ this.addMenuItem(this._actionSection);
+
+ this._onGpuMenuItem = this.addAction('', () => {
+ this._animateLaunch();
+ this._app.launch(0, -1, this._getNonDefaultLaunchGpu());
+ Main.overview.hide();
+ });
+
+ this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ this._toggleFavoriteItem = this.addAction('', () => {
+ const appId = this._app.get_id();
+ if (this._appFavorites.isFavorite(appId))
+ this._appFavorites.removeFavorite(appId);
+ else
+ this._appFavorites.addFavorite(appId);
+ });
+
+ this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ this._detailsItem = this.addAction(_('Show Details'), async () => {
+ const id = this._app.get_id();
+ const 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);
+ Main.overview.hide();
+ });
+
+ this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ this._quitItem =
+ this.addAction(_('Quit'), () => this._app.request_quit());
+
+ this._appSystem.connectObject(
+ 'installed-changed', () => this._updateDetailsVisibility(),
+ 'app-state-changed', this._onAppStateChanged.bind(this),
+ this.actor);
+
+ this._parentalControlsManager.connectObject(
+ 'app-filter-changed', () => this._updateFavoriteItem(), this.actor);
+
+ this._appFavorites.connectObject(
+ 'changed', () => this._updateFavoriteItem(), this.actor);
+
+ global.settings.connectObject(
+ 'writable-changed::favorite-apps', () => this._updateFavoriteItem(),
+ this.actor);
+
+ global.connectObject(
+ 'notify::switcheroo-control', () => this._updateGpuItem(),
+ this.actor);
+
+ this._updateQuitItem();
+ this._updateFavoriteItem();
+ this._updateGpuItem();
+ this._updateDetailsVisibility();
+ }
+
+ _onAppStateChanged(sys, app) {
+ if (this._app !== app)
+ return;
+
+ this._updateQuitItem();
+ this._updateNewWindowItem();
+ this._updateGpuItem();
+ }
+
+ _updateQuitItem() {
+ this._quitItem.visible = this._app?.state === Shell.AppState.RUNNING;
+ }
+
+ _updateNewWindowItem() {
+ const actions = this._app?.appInfo?.list_actions() ?? [];
+ this._newWindowItem.visible =
+ this._app?.can_open_new_window() && !actions.includes('new-window');
+ }
+
+ _updateFavoriteItem() {
+ const appInfo = this._app?.app_info;
+ const canFavorite = appInfo &&
+ this._enableFavorites &&
+ global.settings.is_writable('favorite-apps') &&
+ this._parentalControlsManager.shouldShowApp(appInfo);
+
+ this._toggleFavoriteItem.visible = canFavorite;
+
+ if (!canFavorite)
+ return;
+
+ const { id } = this._app;
+ this._toggleFavoriteItem.label.text = this._appFavorites.isFavorite(id)
+ ? _('Unpin')
+ : _('Pin to Dash');
+ }
+
+ _updateGpuItem() {
+ const proxy = global.get_switcheroo_control();
+ const hasDualGpu = proxy?.get_cached_property('HasDualGpu')?.unpack();
+
+ const showItem =
+ this._app?.state === Shell.AppState.STOPPED && hasDualGpu;
+
+ this._onGpuMenuItem.visible = showItem;
+
+ if (!showItem)
+ return;
+
+ const launchGpu = this._getNonDefaultLaunchGpu();
+ this._onGpuMenuItem.label.text = launchGpu === Shell.AppLaunchGpu.DEFAULT
+ ? _('Launch using Integrated Graphics Card')
+ : _('Launch using Discrete Graphics Card');
+ }
+
+ _updateDetailsVisibility() {
+ const sw = this._appSystem.lookup_app('org.gnome.Software.desktop');
+ this._detailsItem.visible = sw !== null;
+ }
+
+ _animateLaunch() {
+ if (this.sourceActor.animateLaunch)
+ this.sourceActor.animateLaunch();
+ }
+
+ _getNonDefaultLaunchGpu() {
+ return this._app.appInfo.get_boolean('PrefersNonDefaultGPU')
+ ? Shell.AppLaunchGpu.DEFAULT
+ : Shell.AppLaunchGpu.DISCRETE;
+ }
+
+ /** */
+ destroy() {
+ super.destroy();
+
+ this.setApp(null);
+ }
+
+ /**
+ * @returns {bool} - true if the menu is empty
+ */
+ isEmpty() {
+ if (!this._app)
+ return true;
+ return super.isEmpty();
+ }
+
+ /**
+ * @param {Shell.App} app - the app the menu represents
+ */
+ setApp(app) {
+ if (this._app === app)
+ return;
+
+ this._app?.disconnectObject(this);
+
+ this._app = app;
+
+ this._app?.connectObject('windows-changed',
+ () => this._queueUpdateWindowsSection(), this);
+
+ this._updateWindowsSection();
+
+ const appInfo = app?.app_info;
+ const actions = appInfo?.list_actions() ?? [];
+
+ this._actionSection.removeAll();
+ actions.forEach(action => {
+ const label = appInfo.get_action_name(action);
+ this._actionSection.addAction(label, event => {
+ if (action === 'new-window')
+ this._animateLaunch();
+
+ this._app.launch_action(action, event.get_time(), -1);
+ Main.overview.hide();
+ });
+ });
+
+ this._updateQuitItem();
+ this._updateNewWindowItem();
+ this._updateFavoriteItem();
+ this._updateGpuItem();
+ }
+
+ _queueUpdateWindowsSection() {
+ if (this._updateWindowsLaterId)
+ return;
+
+ this._updateWindowsLaterId = Meta.later_add(
+ Meta.LaterType.BEFORE_REDRAW, () => {
+ this._updateWindowsSection();
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+
+ _updateWindowsSection() {
+ if (this._updateWindowsLaterId)
+ Meta.later_remove(this._updateWindowsLaterId);
+ this._updateWindowsLaterId = 0;
+
+ this._windowSection.removeAll();
+ this._openWindowsHeader.hide();
+
+ if (!this._app)
+ return;
+
+ const minWindows = this._showSingleWindows ? 1 : 2;
+ const windows = this._app.get_windows().filter(w => !w.skip_taskbar);
+ if (windows.length < minWindows)
+ return;
+
+ this._openWindowsHeader.show();
+
+ windows.forEach(window => {
+ const title = window.title || this._app.get_name();
+ const item = this._windowSection.addAction(title, event => {
+ Main.activateWindow(window, event.get_time());
+ });
+ window.connectObject('notify::title', () => {
+ item.label.text = window.title || this._app.get_name();
+ }, item);
+ });
+ }
+};
diff --git a/js/ui/audioDeviceSelection.js b/js/ui/audioDeviceSelection.js
new file mode 100644
index 0000000..e284772
--- /dev/null
+++ b/js/ui/audioDeviceSelection.js
@@ -0,0 +1,207 @@
+/* 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) {
+ const 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;
+ });
+ });
+
+ const icon = new St.Icon({
+ style_class: 'audio-selection-device-icon',
+ icon_name: this._getDeviceIcon(device),
+ });
+ box.add(icon);
+
+ const label = new St.Label({
+ style_class: 'audio-selection-device-label',
+ text: this._getDeviceLabel(device),
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+ box.add(label);
+
+ const 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 ${desktopFile} could not be loaded!`);
+ 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..829ffb4
--- /dev/null
+++ b/js/ui/background.js
@@ -0,0 +1,842 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported SystemBackground, BackgroundManager */
+
+// 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.misc.signals;
+
+const LoginManager = imports.misc.loginManager;
+const Main = imports.ui.main;
+const Params = imports.misc.params;
+
+Gio._promisify(Gio.File.prototype, 'query_info_async');
+
+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';
+const PICTURE_URI_DARK_KEY = 'picture-uri-dark';
+
+const INTERFACE_SCHEMA = 'org.gnome.desktop.interface';
+const COLOR_SCHEME_KEY = 'color-scheme';
+
+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 extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ 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_async(null, () => {
+ 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();
+ }
+ }
+ }
+};
+
+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._interfaceSettings = new Gio.Settings({ schema_id: INTERFACE_SCHEMA });
+
+ this._clock = new GnomeDesktop.WallClock();
+ this._clock.connectObject('notify::timezone',
+ () => {
+ if (this._animation)
+ this._loadAnimation(this._animation.file);
+ }, this);
+
+ let loginManager = LoginManager.getLoginManager();
+ loginManager.connectObject('prepare-for-sleep',
+ (lm, aboutToSuspend) => {
+ if (aboutToSuspend)
+ return;
+ this._refreshAnimation();
+ }, this);
+
+ this._settings.connectObject('changed',
+ this._emitChangedSignal.bind(this), this);
+
+ this._interfaceSettings.connectObject(`changed::${COLOR_SCHEME_KEY}`,
+ this._emitChangedSignal.bind(this), 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;
+
+ this._clock.disconnectObject(this);
+ this._clock = null;
+
+ LoginManager.getLoginManager().disconnectObject(this);
+ this._settings.disconnectObject(this);
+ this._interfaceSettings.disconnectObject(this);
+
+ 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;
+ if (this._cancellable?.is_cancelled())
+ return;
+
+ 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);
+ }
+
+ _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);
+ });
+ }
+ }
+
+ async _loadFile(file) {
+ let info;
+ try {
+ info = await file.query_info_async(
+ Gio.FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE,
+ Gio.FileQueryInfoFlags.NONE,
+ 0,
+ this._cancellable);
+ } catch (e) {
+ this._setLoaded();
+ return;
+ }
+
+ const contentType = info.get_content_type();
+ if (contentType === 'application/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));
+
+ this._interfaceSettings = new Gio.Settings({ schema_id: INTERFACE_SCHEMA });
+ }
+
+ _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) {
+ const colorScheme = this._interfaceSettings.get_enum('color-scheme');
+ const uri = this._settings.get_string(
+ colorScheme === GDesktopEnums.ColorScheme.PREFER_DARK
+ ? PICTURE_URI_DARK_KEY
+ : 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;
+ }
+
+ // eslint-disable-next-line camelcase
+ load_async(cancellable, callback) {
+ super.load_async(cancellable, () => {
+ this.loaded = true;
+
+ 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 extends Signals.EventEmitter {
+ constructor(params) {
+ super();
+ params = Params.parse(params, {
+ container: null,
+ layoutManager: Main.layoutManager,
+ monitorIndex: null,
+ vignette: false,
+ controlPosition: true,
+ settingsSchema: BACKGROUND_SCHEMA,
+ useContentSize: true,
+ });
+
+ 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._useContentSize = params.useContentSize;
+
+ 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');
+
+ if (Main.layoutManager.screenTransition.visible) {
+ oldBackgroundActor.destroy();
+ return;
+ }
+
+ 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,
+ request_mode: this._useContentSize
+ ? Clutter.RequestMode.CONTENT_SIZE
+ : Clutter.RequestMode.HEIGHT_FOR_WIDTH,
+ x_expand: !this._useContentSize,
+ y_expand: !this._useContentSize,
+ });
+ 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;
+ }
+};
diff --git a/js/ui/backgroundMenu.js b/js/ui/backgroundMenu.js
new file mode 100644
index 0000000..4c7372a
--- /dev/null
+++ b/js/ui/backgroundMenu.js
@@ -0,0 +1,67 @@
+// -*- 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'), 'org.gnome.Settings.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);
+
+ global.display.connectObject('grab-op-begin',
+ () => clickAction.release(), actor);
+
+ actor.connect('destroy', () => {
+ actor._backgroundMenu.destroy();
+ actor._backgroundMenu = null;
+ actor._backgroundManager = null;
+ });
+}
diff --git a/js/ui/barLevel.js b/js/ui/barLevel.js
new file mode 100644
index 0000000..da5b34a
--- /dev/null
+++ b/js/ui/barLevel.js
@@ -0,0 +1,262 @@
+/* -*- 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();
+ }
+
+ get maximumValue() {
+ return this._maxValue;
+ }
+
+ set maximumValue(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();
+ }
+
+ get overdriveStart() {
+ return this._overdriveStart;
+ }
+
+ set overdriveStart(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();
+ const rtl = this.get_text_direction() === Clutter.TextDirection.RTL;
+
+ 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) {
+ let progress = this._value / this._maxValue;
+ if (rtl)
+ progress = 1 - progress;
+ endX = barLevelBorderRadius + (width - 2 * barLevelBorderRadius) * progress;
+ }
+
+ let overdriveRatio = this._overdriveStart / this._maxValue;
+ if (rtl)
+ overdriveRatio = 1 - overdriveRatio;
+ let overdriveSeparatorX = barLevelBorderRadius + (width - 2 * barLevelBorderRadius) * overdriveRatio;
+
+ let overdriveActive = this._overdriveStart !== this._maxValue;
+ let overdriveSeparatorWidth = 0;
+ if (overdriveActive)
+ overdriveSeparatorWidth = themeNode.get_length('-barlevel-overdrive-separator-width');
+
+ let xcArcStart = barLevelBorderRadius + barLevelBorderWidth;
+ let xcArcEnd = width - xcArcStart;
+ if (rtl)
+ [xcArcStart, xcArcEnd] = [xcArcEnd, xcArcStart];
+
+ /* background bar */
+ if (!rtl)
+ cr.arc(xcArcEnd, height / 2, barLevelBorderRadius, TAU * (3 / 4), TAU * (1 / 4));
+ else
+ cr.arcNegative(xcArcEnd, height / 2, barLevelBorderRadius, TAU * (3 / 4), TAU * (1 / 4));
+ cr.lineTo(endX, (height + barLevelHeight) / 2);
+ cr.lineTo(endX, (height - barLevelHeight) / 2);
+ cr.lineTo(xcArcEnd, (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 = 0;
+ if (!rtl) {
+ x = Math.min(endX, overdriveSeparatorX - overdriveSeparatorWidth / 2);
+ cr.arc(xcArcStart, height / 2, barLevelBorderRadius, TAU * (1 / 4), TAU * (3 / 4));
+ } else {
+ x = Math.max(endX, overdriveSeparatorX + overdriveSeparatorWidth / 2);
+ cr.arcNegative(xcArcStart, height / 2, barLevelBorderRadius, TAU * (1 / 4), TAU * (3 / 4));
+ }
+ cr.lineTo(x, (height - barLevelHeight) / 2);
+ cr.lineTo(x, (height + barLevelHeight) / 2);
+ cr.lineTo(xcArcStart, (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 */
+ if (!rtl)
+ x = Math.min(endX, overdriveSeparatorX) + overdriveSeparatorWidth / 2;
+ else
+ x = Math.max(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);
+ if (!rtl) {
+ 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);
+ } else {
+ cr.arcNegative(endX, height / 2, barLevelBorderRadius, TAU * (3 / 4), TAU * (1 / 4));
+ cr.lineTo(Math.ceil(endX), (height + barLevelHeight) / 2);
+ cr.lineTo(Math.ceil(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..3987d62
--- /dev/null
+++ b/js/ui/boxpointer.js
@@ -0,0 +1,654 @@
+// -*- 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._muteKeys = true;
+ 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);
+ });
+ }
+
+ vfunc_captured_event(event) {
+ if (event.type() === Clutter.EventType.ENTER ||
+ event.type() === Clutter.EventType.LEAVE)
+ return Clutter.EVENT_PROPAGATE;
+
+ let mute = event.type() === Clutter.EventType.KEY_PRESS ||
+ event.type() === Clutter.EventType.KEY_RELEASE
+ ? this._muteKeys : this._muteInput;
+
+ if (mute)
+ return Clutter.EVENT_STOP;
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ 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._muteKeys = false;
+ 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._muteKeys = 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 [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);
+ }
+
+ const [hasColor, bgColor] =
+ themeNode.lookup_color('-arrow-background-color', false);
+ if (hasColor) {
+ Clutter.cairo_set_source_color(cr, bgColor);
+ 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) {
+ this._sourceActor?.disconnectObject(this);
+
+ this._sourceActor = sourceActor;
+
+ this._sourceActor?.connectObject('destroy',
+ () => (this._sourceActor = null), this);
+ }
+
+ 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
+ const sourceAllocation = sourceActor.get_allocation_box();
+ const sourceContentBox = sourceActor instanceof St.Widget
+ ? sourceActor.get_theme_node().get_content_box(sourceAllocation)
+ : new Clutter.ActorBox({
+ x2: sourceAllocation.get_width(),
+ y2: sourceAllocation.get_height(),
+ });
+ 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)
+ resY -= 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..9851536
--- /dev/null
+++ b/js/ui/calendar.js
@@ -0,0 +1,1031 @@
+// -*- 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 SHOW_WEEKDATE_KEY = 'show-weekdate';
+
+var MESSAGE_ICON_SIZE = -1; // pick up from CSS
+
+var NC_ = (context, str) => `${context}\u0004${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) {
+ const ret = _getBeginningOfDay(date);
+ ret.setDate(ret.getDate() + 1);
+ 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) {
+ this.id = id;
+ this.date = date;
+ this.end = end;
+ this.summary = summary;
+ }
+};
+
+// 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 ${this.constructor.name}`);
+ }
+
+ get hasCalendars() {
+ throw new GObject.NotImplementedError(`hasCalendars in ${this.constructor.name}`);
+ }
+
+ destroy() {
+ }
+
+ requestRange(_begin, _end) {
+ throw new GObject.NotImplementedError(`requestRange in ${this.constructor.name}`);
+ }
+
+ getEvents(_begin, _end) {
+ throw new GObject.NotImplementedError(`getEvents in ${this.constructor.name}`);
+ }
+
+ hasEvents(_day) {
+ throw new GObject.NotImplementedError(`hasEvents in ${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;
+}
+
+/**
+ * Checks whether an event overlaps a given interval
+ *
+ * @param {Date} e0 Beginning of the event
+ * @param {Date} e1 End of the event
+ * @param {Date} i0 Beginning of the interval
+ * @param {Date} i1 End of the interval
+ * @returns {boolean} Whether there was an overlap
+ */
+function _eventOverlapsInterval(e0, e1, i0, i1) {
+ // This also ensures zero-length events are included
+ if (e0 >= i0 && e1 < i1)
+ return true;
+
+ if (e1 <= i0)
+ return false;
+ if (i1 <= e0)
+ return false;
+
+ 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: ${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;
+ }
+
+ _removeMatching(uidPrefix) {
+ let changed = false;
+ for (const id of this._events.keys()) {
+ if (id.startsWith(uidPrefix))
+ changed = this._events.delete(id) || changed;
+ }
+ return changed;
+ }
+
+ _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;
+ const handledRemovals = new Set();
+
+ for (let n = 0; n < appointments.length; n++) {
+ const [id, summary, startTime, endTime] = appointments[n];
+ const date = new Date(startTime * 1000);
+ const end = new Date(endTime * 1000);
+ let event = new CalendarEvent(id, date, end, summary);
+ /* It's a recurring event */
+ if (!id.endsWith('\n')) {
+ const parentId = id.substr(0, id.lastIndexOf('\n') + 1);
+ if (!handledRemovals.has(parentId)) {
+ handledRemovals.add(parentId);
+ this._removeMatching(parentId);
+ }
+ }
+ 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._removeMatching(id) || changed;
+
+ if (changed)
+ this.emit('changed');
+ }
+
+ _onClientDisappeared(dbusProxy, nameOwner, argArray) {
+ let [sourceUid = ''] = argArray;
+ sourceUid += '\n';
+
+ if (this._removeMatching(sourceUid))
+ 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.SetTimeRangeAsync(
+ this._curRequestBegin.getTime() / 1000,
+ this._curRequestEnd.getTime() / 1000,
+ forceReload,
+ Gio.DBusCallFlags.NONE).catch(logError);
+ }
+ }
+
+ 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 (_eventOverlapsInterval(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::${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({ style_class: 'calendar-month-header' });
+ layout.attach(this._topBox, 0, 0, offsetCols + 7, 1);
+
+ this._backButton = new St.Button({
+ style_class: 'calendar-change-month-back pager-button',
+ icon_name: 'pan-start-symbolic',
+ accessible_name: _('Previous month'),
+ can_focus: true,
+ });
+ 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,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ this._topBox.add_child(this._monthLabel);
+
+ this._forwardButton = new St.Button({
+ style_class: 'calendar-change-month-forward pager-button',
+ icon_name: 'pan-end-symbolic',
+ accessible_name: _('Next month'),
+ can_focus: true,
+ });
+ 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.setDate(iter.getDate() + 1);
+ }
+
+ // 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.getFullYear(), this._selectedDate.getMonth(), 1);
+
+ 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.setDate(beginDate.getDate() - (weekPadding + daysToWeekStart));
+
+ 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) {
+ let button = new St.Button({
+ // xgettext:no-javascript-format
+ 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 ${styleClass}`;
+
+ let leftMost = rtl
+ ? iter.getDay() == (this._weekStart + 6) % 7
+ : iter.getDay() == this._weekStart;
+ if (leftMost)
+ styleClass = `calendar-day-left ${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) {
+ const label = new St.Label({
+ text: iter.toLocaleFormat('%V'),
+ style_class: '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.setDate(iter.getDate() + 1);
+
+ 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);
+ });
+ notification.connectObject(
+ 'updated', this._onUpdated.bind(this),
+ 'destroy', () => {
+ this.notification = null;
+ if (!this._closed)
+ this.close();
+ }, 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();
+ }
+
+ 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._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) {
+ source.connectObject('notification-added',
+ this._onNotificationAdded.bind(this), this);
+ }
+
+ _onNotificationAdded(source, notification) {
+ let message = new NotificationMessage(notification);
+ message.setSecondaryActor(new TimeLabel(notification.datetime));
+
+ let isUrgent = notification.urgency == MessageTray.Urgency.CRITICAL;
+
+ notification.connectObject(
+ 'destroy', () => {
+ if (isUrgent)
+ this._nUrgent--;
+ },
+ 'updated', () => {
+ message.setSecondaryActor(new TimeLabel(notification.datetime));
+ this.moveMessage(message, isUrgent ? 0 : this._nUrgent, this.mapped);
+ }, this);
+
+ 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);
+ }
+
+ 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();
+
+ this._icon = new St.Icon({ icon_name: 'no-notifications-symbolic' });
+ 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', () => {
+ Gio.Settings.unbind(this, 'state');
+ 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({
+ style_class: 'dnd-button',
+ can_focus: true,
+ toggle_mode: true,
+ child: this._dndSwitch,
+ label_actor: dndLabel,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ 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.connectObject(
+ 'actor-added', this._sync.bind(this),
+ 'actor-removed', this._sync.bind(this),
+ 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) {
+ section.connectObject(
+ 'notify::visible', this._sync.bind(this),
+ 'notify::empty', this._sync.bind(this),
+ 'notify::can-clear', this._sync.bind(this),
+ 'destroy', () => this._sectionList.remove_actor(section),
+ 'message-focused', (_s, messageActor) => {
+ Util.ensureActorVisibleInScrollView(this._scrollView, messageActor);
+ }, this);
+ 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..f5ddecd
--- /dev/null
+++ b/js/ui/closeDialog.js
@@ -0,0 +1,207 @@
+// -*- 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;
+ }
+
+ 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;
+ });
+
+ global.display.connectObject(
+ 'notify::focus-window', this._onFocusChanged.bind(this), this);
+
+ global.stage.connectObject(
+ 'notify::key-focus', this._onFocusChanged.bind(this), 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.disconnectObject(this);
+ global.stage.disconnectObject(this);
+
+ 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..4c0c223
--- /dev/null
+++ b/js/ui/components/automountManager.js
@@ -0,0 +1,256 @@
+// -*- 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._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._volumeMonitor.connectObject(
+ 'volume-added', this._onVolumeAdded.bind(this),
+ 'volume-removed', this._onVolumeRemoved.bind(this),
+ 'drive-connected', this._onDriveConnected.bind(this),
+ 'drive-disconnected', this._onDriveDisconnected.bind(this),
+ 'drive-eject-button', this._onDriveEjectButton.bind(this), 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.disconnectObject(this);
+
+ if (this._mountAllId > 0) {
+ GLib.source_remove(this._mountAllId);
+ this._mountAllId = 0;
+ }
+ }
+
+ async _InhibitorsChanged(_object, _senderName, [_inhibitor]) {
+ try {
+ const [inhibited] =
+ await this._session.IsInhibitedAsync(GNOME_SESSION_AUTOMOUNT_INHIBIT);
+ this._inhibited = inhibited;
+ } catch (e) {}
+ }
+
+ _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 ${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 ${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);
+
+ const mountOp = 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 ${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;
+ }
+ }
+
+ _reaskPassword(volume) {
+ let prevOperation = this._activeOperations.get(volume);
+ const existingDialog = prevOperation?.borrowDialog();
+ 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..d94be39
--- /dev/null
+++ b/js/ui/components/autorunManager.js
@@ -0,0 +1,345 @@
+// -*- 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;
+
+Gio._promisify(Gio.Mount.prototype, 'guess_content_type');
+
+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 ${app.get_name()}: ${e}`);
+ }
+
+ 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() {
+ this._settings = new Gio.Settings({ schema_id: SETTINGS_SCHEMA });
+ }
+
+ async guessContentTypes(mount) {
+ let autorunEnabled = !this._settings.get_boolean(SETTING_DISABLE_AUTORUN);
+ let shouldScan = autorunEnabled && !isMountNonLocal(mount);
+
+ let contentTypes = [];
+ if (shouldScan) {
+ try {
+ contentTypes = await mount.guess_content_type(false, null);
+ } catch (e) {
+ log(`Unable to guess content types on added mount ${mount.get_name()}: ${e}`);
+ }
+
+ if (contentTypes.length === 0) {
+ const root = mount.get_root();
+ const hotplugSniffer = new HotplugSniffer();
+ [contentTypes] = await hotplugSniffer.SniffURIAsync(root.get_uri());
+ }
+ }
+
+ // we're not interested in win32 software content types here
+ contentTypes = contentTypes.filter(
+ type => type !== 'x-content/win32-software');
+
+ const apps = [];
+ contentTypes.forEach(type => {
+ const 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));
+
+ return [apps, contentTypes];
+ }
+};
+
+var AutorunManager = class {
+ constructor() {
+ this._session = new GnomeSession.SessionManager();
+ this._volumeMonitor = Gio.VolumeMonitor.get();
+
+ this._dispatcher = new AutorunDispatcher(this);
+ }
+
+ enable() {
+ this._volumeMonitor.connectObject(
+ 'mount-added', this._onMountAdded.bind(this),
+ 'mount-removed', this._onMountRemoved.bind(this), this);
+ }
+
+ disable() {
+ this._volumeMonitor.disconnectObject(this);
+ }
+
+ async _onMountAdded(monitor, mount) {
+ // don't do anything if our session is not the currently
+ // active one
+ if (!this._session.SessionIsActive)
+ return;
+
+ const discoverer = new ContentTypeDiscoverer();
+ const [apps, contentTypes] = await discoverer.guessContentTypes(mount);
+ this._dispatcher.addMount(mount, apps, contentTypes);
+ }
+
+ _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,
+ });
+ const 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);
+
+ const 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..cd7a81e
--- /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 && this.prompt.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..ba02f88
--- /dev/null
+++ b/js/ui/components/networkAgent.js
@@ -0,0 +1,877 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Component */
+
+const { Clutter, Gio, GLib, GObject, NM, Pango, Shell, St } = imports.gi;
+const Signals = imports.misc.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');
+Gio._promisify(Shell.NetworkAgent.prototype, 'search_vpn_plugin');
+
+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 &&= 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 &&= 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${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: ${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: ${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: ${connectionType}`);
+ }
+
+ return content;
+ }
+});
+
+var VPNRequestHandler = class extends Signals.EventEmitter {
+ constructor(agent, requestId, authHelper, serviceType, connection, hints, flags) {
+ super();
+
+ 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();
+
+ const 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,
+ () => {
+ try {
+ global.context.restore_rlimit_nofile();
+ } catch (err) {
+ }
+ });
+
+ 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;
+ }
+
+ const decoder = new TextDecoder();
+ this._vpnChildProcessLineOldStyle(decoder.decode(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 = new GLib.Bytes(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=${key}\n`, null);
+ this._stdin.write(`DATA_VAL=${value || ''}\n\n`, null);
+ });
+ vpnSetting.foreach_secret((key, value) => {
+ this._stdin.write(`SECRET_KEY=${key}\n`, null);
+ this._stdin.write(`SECRET_VAL=${value || ''}\n\n`, 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();
+ }
+ }
+};
+
+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: ${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 ${fileName} is not executable`);
+ return null;
+ }
+
+ const prop = plugin.lookup_property('GNOME', 'supports-external-ui-mode');
+ const trimmedProp = 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..1da02e5
--- /dev/null
+++ b/js/ui/components/polkitAgent.js
@@ -0,0 +1,471 @@
+// -*- 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;
+
+ Main.sessionMode.connectObject('updated', () => {
+ this.visible = !Main.sessionMode.isLocked;
+ }, this);
+
+ 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 ${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._user.connectObject(
+ 'notify::is-loaded', this._onUserChanged.bind(this),
+ 'changed', this._onUserChanged.bind(this), this);
+ this._onUserChanged();
+ }
+
+ _initiateSession() {
+ this._destroySession(DELAYED_RESET_TIMEOUT);
+
+ this._session = new PolkitAgent.Session({
+ identity: this._identityToAuth,
+ cookie: this._cookie,
+ });
+ this._session.connectObject(
+ 'completed', this._onSessionCompleted.bind(this),
+ 'request', this._onSessionRequest.bind(this),
+ 'show-error', this._onSessionShowError.bind(this),
+ 'show-info', this._onSessionShowInfo.bind(this), 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 ${this.actionId} ` +
+ `cookie ${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) {
+ this._session?.disconnectObject(this);
+
+ 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._emitDone(true);
+ }
+
+ _onDialogClosed() {
+ Main.sessionMode.disconnectObject(this);
+
+ if (this._sessionRequestTimeoutId)
+ GLib.source_remove(this._sessionRequestTimeoutId);
+ this._sessionRequestTimeoutId = 0;
+
+ this._user?.disconnectObject(this);
+ 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) {
+ Main.sessionMode.connectObject('updated', () => {
+ Main.sessionMode.disconnectObject(this);
+
+ this._onInitiate(nativeAgent, actionId, message, iconName, cookie, userNames);
+ }, this);
+ 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;
+
+ Main.sessionMode.disconnectObject(this);
+
+ 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..d317822
--- /dev/null
+++ b/js/ui/components/telepathyClient.js
@@ -0,0 +1,1019 @@
+// -*- 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');
+ Gio._promisify(Tp.TextChannel.prototype, 'send_message_async');
+ Gio._promisify(Tp.ChannelDispatchOperation.prototype, 'claim_with_async');
+ Gio._promisify(Tpl.LogManager.prototype, 'get_filtered_events_async');
+} 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: ${e}`);
+ }
+
+ 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: ${err}`);
+ }
+ }
+
+ _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._notifyTimeoutId = 0;
+
+ this._presence = contact.get_presence_type();
+
+ this._channel.connectObject(
+ 'invalidated', this._channelClosed.bind(this),
+ 'message-sent', this._messageSent.bind(this),
+ 'message-received', this._messageReceived.bind(this),
+ 'pending-message-removed', this._pendingRemoved.bind(this), this);
+
+ this._contact.connectObject(
+ 'notify::alias', this._updateAlias.bind(this),
+ 'notify::avatar-file', this._updateAvatarIcon.bind(this),
+ 'presence-changed', this._presenceChanged.bind(this), this);
+
+ // Add ourselves as a source.
+ Main.messageTray.add(this);
+
+ this._getLogMessages();
+ }
+
+ _ensureNotification() {
+ if (this._notification)
+ return;
+
+ this._notification = new ChatNotification(this);
+ this._notification.connectObject(
+ 'activated', this.open.bind(this),
+ 'destroy', () => (this._notification = null),
+ 'updated', () => {
+ if (this._banner && this._banner.expanded)
+ this._ackMessages();
+ }, this);
+ 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
+ this._banner.connectObject(
+ 'expanded', this._ackMessages.bind(this),
+ 'destroy', () => (this._banner = null), this);
+
+ 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._notification) {
+ 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.disconnectObject(this);
+ this._contact.disconnectObject(this);
+
+ 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>${senderAlias}</i> ${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. */
+ const 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.notification.connectObject(
+ 'timestamp-changed', (n, message) => this._updateTimestamp(message),
+ 'message-added', (n, message) => this._addMessage(message),
+ 'message-removed', (n, message) => {
+ let actor = this._messageActors.get(message);
+ if (this._messageActors.delete(message))
+ actor.destroy();
+ }, this);
+
+ for (let i = this.notification.messages.length - 1; i >= 0; i--)
+ this._addMessage(this.notification.messages[i]);
+ }
+
+ 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..421fecf
--- /dev/null
+++ b/js/ui/ctrlAltTab.js
@@ -0,0 +1,203 @@
+// -*- 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) {
+ const 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) {
+ const 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..165f8ea
--- /dev/null
+++ b/js/ui/dash.js
@@ -0,0 +1,992 @@
+// -*- 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;
+const Overview = imports.ui.overview;
+
+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,
+ });
+ }
+
+ popupMenu() {
+ super.popupMenu(St.Side.BOTTOM);
+ }
+
+ // 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 }),
+ layout_manager: new Clutter.BinLayout(),
+ 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.connectObject('destroy', () => (this.label = null), this);
+ 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();
+
+ const itemWidth = this.allocation.get_width();
+
+ const labelWidth = this.label.get_width();
+ const xOffset = Math.floor((itemWidth - labelWidth) / 2);
+ const x = Math.clamp(stageX + xOffset, 0, global.stage.width - labelWidth);
+
+ let node = this.label.get_theme_node();
+ const yOffset = node.get_length('-y-offset');
+
+ const y = stageY - this.label.height - yOffset;
+
+ 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.child.y_expand = true;
+ 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.icon.y_align = Clutter.ActorAlign.CENTER;
+
+ 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(_('Unpin'));
+ 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' }));
+ }
+});
+
+const DashIconsLayout = GObject.registerClass(
+class DashIconsLayout extends Clutter.BoxLayout {
+ _init() {
+ super._init({
+ orientation: Clutter.Orientation.HORIZONTAL,
+ });
+ }
+
+ vfunc_get_preferred_width(container, forHeight) {
+ const [, natWidth] = super.vfunc_get_preferred_width(container, forHeight);
+ return [0, natWidth];
+ }
+});
+
+const baseIconSizes = [16, 22, 24, 32, 48, 64];
+
+var Dash = GObject.registerClass({
+ Signals: { 'icon-size-changed': {} },
+}, class Dash extends St.Widget {
+ _init() {
+ this._maxWidth = -1;
+ this._maxHeight = -1;
+ this.iconSize = 64;
+ this._shownInitially = false;
+
+ this._separator = null;
+ this._dragPlaceholder = null;
+ this._dragPlaceholderPos = -1;
+ this._animatingPlaceholdersCount = 0;
+ this._showLabelTimeoutId = 0;
+ this._resetHoverTimeoutId = 0;
+ this._labelShowing = false;
+
+ super._init({
+ name: 'dash',
+ offscreen_redirect: Clutter.OffscreenRedirect.ALWAYS,
+ layout_manager: new Clutter.BinLayout(),
+ });
+
+ this._dashContainer = new St.BoxLayout({
+ x_align: Clutter.ActorAlign.CENTER,
+ y_expand: true,
+ });
+
+ this._box = new St.Widget({
+ clip_to_allocation: true,
+ layout_manager: new DashIconsLayout(),
+ y_expand: true,
+ });
+ this._box._delegate = this;
+
+ this._dashContainer.add_child(this._box);
+
+ this._showAppsIcon = new ShowAppsIcon();
+ this._showAppsIcon.show(false);
+ this._showAppsIcon.icon.setIconSize(this.iconSize);
+ this._hookUpLabel(this._showAppsIcon);
+ this._dashContainer.add_child(this._showAppsIcon);
+
+ this.showAppsButton = this._showAppsIcon.toggleButton;
+
+ this._background = new St.Widget({
+ style_class: 'dash-background',
+ });
+
+ const sizerBox = new Clutter.Actor();
+ sizerBox.add_constraint(new Clutter.BindConstraint({
+ source: this._showAppsIcon.icon,
+ coordinate: Clutter.BindCoordinate.HEIGHT,
+ }));
+ sizerBox.add_constraint(new Clutter.BindConstraint({
+ source: this._dashContainer,
+ coordinate: Clutter.BindCoordinate.WIDTH,
+ }));
+ this._background.add_child(sizerBox);
+
+ this.add_child(this._background);
+ this.add_child(this._dashContainer);
+
+ 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._onItemDragBegin.bind(this));
+ Main.overview.connect('item-drag-end',
+ this._onItemDragEnd.bind(this));
+ Main.overview.connect('item-drag-cancelled',
+ this._onItemDragCancelled.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));
+
+ // Translators: this is the name of the dock/favorites area on
+ // the left of the overview
+ Main.ctrlAltTabManager.addGroup(this, _("Dash"), 'user-bookmarks-symbolic');
+ }
+
+ _onItemDragBegin() {
+ this._dragCancelled = false;
+ this._dragMonitor = {
+ dragMotion: this._onItemDragMotion.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);
+ }
+ }
+
+ _onItemDragCancelled() {
+ this._dragCancelled = true;
+ this._endItemDrag();
+ }
+
+ _onItemDragEnd() {
+ if (this._dragCancelled)
+ return;
+
+ this._endItemDrag();
+ }
+
+ _endItemDrag() {
+ this._clearDragPlaceholder();
+ this._clearEmptyDropTarget();
+ this._showAppsIcon.setDragApp(null);
+ DND.removeDragMonitor(this._dragMonitor);
+ }
+
+ _onItemDragMotion(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;
+ }
+
+ _onWindowDragBegin() {
+ this.ease({
+ opacity: 128,
+ duration: Overview.ANIMATION_TIME / 2,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ _onWindowDragEnd() {
+ this.ease({
+ opacity: 255,
+ duration: Overview.ANIMATION_TIME / 2,
+ mode: Clutter.AnimationMode.EASE_IN_QUAD,
+ });
+ }
+
+ _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();
+ });
+
+ Main.overview.connectObject('hiding', () => {
+ this._labelShowing = false;
+ item.hideLabel();
+ }, item.child);
+
+ 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._maxWidth === -1 || this._maxHeight === -1)
+ return;
+
+ const themeNode = this.get_theme_node();
+ const maxAllocation = new Clutter.ActorBox({
+ x1: 0,
+ y1: 0,
+ x2: this._maxWidth,
+ y2: 42, /* whatever */
+ });
+ let maxContent = themeNode.get_content_box(maxAllocation);
+ let availWidth = maxContent.x2 - maxContent.x1;
+ 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();
+ const [, , iconWidth, iconHeight] = firstIcon.icon.get_preferred_size();
+ const [, , buttonWidth, buttonHeight] = firstButton.get_preferred_size();
+
+ // Subtract icon padding and box spacing from the available width
+ availWidth -= iconChildren.length * (buttonWidth - iconWidth) +
+ (iconChildren.length - 1) * spacing;
+
+ let availHeight = this._maxHeight;
+ availHeight -= this.margin_top + this.margin_bottom;
+ availHeight -= this._background.get_theme_node().get_vertical_padding();
+ availHeight -= themeNode.get_vertical_padding();
+ availHeight -= buttonHeight - iconHeight;
+
+ const maxIconSize = Math.min(availWidth / iconChildren.length, availHeight);
+
+ 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] <= maxIconSize)
+ 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,
+ });
+ }
+
+ if (this._separator) {
+ this._separator.ease({
+ height: this.iconSize,
+ 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);
+
+ // Update separator
+ const nFavorites = Object.keys(favorites).length;
+ const nIcons = children.length + addedItems.length - removedActors.length;
+ if (nFavorites > 0 && nFavorites < nIcons) {
+ if (!this._separator) {
+ this._separator = new St.Widget({
+ style_class: 'dash-separator',
+ y_align: Clutter.ActorAlign.CENTER,
+ height: this.iconSize,
+ });
+ this._box.add_child(this._separator);
+ }
+ let pos = nFavorites + this._animatingPlaceholdersCount;
+ if (this._dragPlaceholder)
+ pos++;
+ this._box.set_child_at_index(this._separator, pos);
+ } else if (this._separator) {
+ this._separator.destroy();
+ this._separator = null;
+ }
+
+ // 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.connect('destroy', () => {
+ this._animatingPlaceholdersCount--;
+ });
+ this._dragPlaceholder.animateOutAndDestroy();
+ 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 boxWidth = this._box.width;
+
+ // 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) {
+ boxWidth -= this._dragPlaceholder.width;
+ numChildren--;
+ }
+
+ // Same with the separator
+ if (this._separator) {
+ boxWidth -= this._separator.width;
+ numChildren--;
+ }
+
+ let pos;
+ if (this._emptyDropTarget)
+ pos = 0; // always insert at the start when dash is empty
+ else if (this.text_direction === Clutter.TextDirection.RTL)
+ pos = numChildren - Math.floor(x * numChildren / boxWidth);
+ else
+ pos = Math.floor(x * numChildren / boxWidth);
+
+ // 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;
+ }
+
+ setMaxSize(maxWidth, maxHeight) {
+ if (this._maxWidth === maxWidth &&
+ this._maxHeight === maxHeight)
+ return;
+
+ this._maxWidth = maxWidth;
+ this._maxHeight = maxHeight;
+ this._queueRedisplay();
+ }
+});
diff --git a/js/ui/dateMenu.js b/js/ui/dateMenu.js
new file mode 100644
index 0000000..2c44f0c
--- /dev/null
+++ b/js/ui/dateMenu.js
@@ -0,0 +1,980 @@
+// -*- 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) => `${context}\u0004${str}`;
+const T_ = Shell.util_translate_time_string;
+
+const MAX_FORECASTS = 5;
+const EN_CHAR = '\u2013';
+
+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) {
+ this._startDate =
+ new Date(date.getFullYear(), date.getMonth(), date.getDate());
+ this._endDate =
+ new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1);
+
+ 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);
+ }
+
+ _isAtMidnight(eventTime) {
+ return eventTime.getHours() === 0 && eventTime.getMinutes() === 0 && eventTime.getSeconds() === 0;
+ }
+
+ _formatEventTime(event) {
+ const eventStart = event.date;
+ let eventEnd = event.end;
+
+ const allDay =
+ eventStart.getTime() === this._startDate.getTime() && eventEnd.getTime() === this._endDate.getTime();
+
+ const startsBeforeToday = eventStart < this._startDate;
+ const endsAfterToday = eventEnd > this._endDate;
+
+ const startTimeOnly = Util.formatTime(eventStart, { timeOnly: true });
+ const endTimeOnly = Util.formatTime(eventEnd, { timeOnly: true });
+
+ const rtl = Clutter.get_default_text_direction() === Clutter.TextDirection.RTL;
+
+ 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 if (startsBeforeToday || endsAfterToday) {
+ const now = new Date();
+ const thisYear = now.getFullYear();
+
+ const startsAtMidnight = this._isAtMidnight(eventStart);
+ const endsAtMidnight = this._isAtMidnight(eventEnd);
+
+ const startYear = eventStart.getFullYear();
+
+ if (endsAtMidnight) {
+ eventEnd = new Date(eventEnd);
+ eventEnd.setDate(eventEnd.getDate() - 1);
+ }
+
+ const endYear = eventEnd.getFullYear();
+
+ let format;
+ if (startYear === thisYear && thisYear === endYear)
+ /* Translators: Shown in calendar event list as the start/end of events
+ * that only show day and month
+ */
+ format = T_(N_('%m/%d'));
+ else
+ format = '%x';
+
+ const startDateOnly = eventStart.toLocaleFormat(format);
+ const endDateOnly = eventEnd.toLocaleFormat(format);
+
+ if (startsAtMidnight && endsAtMidnight)
+ title = `${rtl ? endDateOnly : startDateOnly} ${EN_CHAR} ${rtl ? startDateOnly : endDateOnly}`;
+ else if (rtl)
+ title = `${endTimeOnly} ${endDateOnly} ${EN_CHAR} ${startTimeOnly} ${startDateOnly}`;
+ else
+ title = `${startDateOnly} ${startTimeOnly} ${EN_CHAR} ${endDateOnly} ${endTimeOnly}`;
+ } else if (eventStart === eventEnd) {
+ title = startTimeOnly;
+ } else {
+ title = `${rtl ? endTimeOnly : startTimeOnly} ${EN_CHAR} ${rtl ? startTimeOnly : endTimeOnly}`;
+ }
+
+ 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').deepUnpack();
+ 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 });
+ }
+
+ const unixtime = GLib.DateTime.new_now_local().to_unix();
+ this._locations.sort((a, b) => {
+ const tzA = a.location.get_timezone();
+ const tzB = b.location.get_timezone();
+ const intA = tzA.find_interval(GLib.TimeType.STANDARD, unixtime);
+ const intB = tzB.find_interval(GLib.TimeType.STANDARD, unixtime);
+ return tzA.get_offset(intA) - tzB.get_offset(intB);
+ });
+
+ let layout = this._grid.layout_manager;
+ let title = this._locations.length == 0
+ ? _("Add world clocks…")
+ : _("World Clocks");
+ const header = new St.Label({
+ style_class: 'world-clocks-header',
+ x_align: Clutter.ActorAlign.START,
+ text: title,
+ });
+ if (this._grid.text_direction === Clutter.TextDirection.RTL)
+ layout.attach(header, 2, 0, 1, 1);
+ else
+ 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();
+ const 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 tz = location.get_timezone();
+ const localOffset = GLib.DateTime.new_now_local().get_utc_offset();
+ const utcOffset = GLib.DateTime.new_now(tz).get_utc_offset();
+ const offsetCurrentTz = utcOffset - localOffset;
+ const offsetHours =
+ Math.floor(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
+ ? `${prefix}${offsetHours}`
+ : `${prefix}${offsetHours}\u2236${offsetMinutes}`;
+ return text;
+ }
+
+ _updateTimeLabels() {
+ for (let i = 0; i < this._locations.length; i++) {
+ let l = this._locations[i];
+ const now = GLib.DateTime.new_now(l.location.get_timezone());
+ 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: ${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: `${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;
+
+ 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]);
+ const 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);
+
+ const 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..414a3e4
--- /dev/null
+++ b/js/ui/dialog.js
@@ -0,0 +1,359 @@
+// -*- 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(),
+ reactive: true,
+ });
+ this.connect('destroy', this._onDestroy.bind(this));
+
+ this._initialKeyFocus = null;
+ 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._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() {
+ this.buttonLayout.get_children().forEach(c => c.set_reactive(false));
+ }
+
+ _onDestroy() {
+ this.makeInactive();
+ }
+
+ vfunc_event(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) {
+ this._initialKeyFocus?.disconnectObject(this);
+
+ this._initialKeyFocus = actor;
+
+ actor.connectObject('destroy',
+ () => (this._initialKeyFocus = null), this);
+ }
+
+ 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);
+ }
+
+ get iconActor() {
+ return this._iconActorBin.get_child();
+ }
+
+ set iconActor(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..613402d
--- /dev/null
+++ b/js/ui/dnd.js
@@ -0,0 +1,841 @@
+// -*- 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.misc.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, reactive: true });
+ 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 _getRealActorScale(actor) {
+ let scale = 1.0;
+ while (actor) {
+ scale *= actor.scale_x;
+ actor = actor.get_parent();
+ }
+ return scale;
+}
+
+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 extends Signals.EventEmitter {
+ constructor(actor, params) {
+ super();
+
+ params = Params.parse(params, {
+ manualMode: false,
+ timeoutThreshold: 0,
+ 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._dragTimeoutThreshold = params.timeoutThreshold;
+
+ 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;
+ }
+
+ _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;
+ this._dragStartTime = event.get_time();
+ this._dragThresholdIgnored = false;
+
+ 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());
+ this._dragStartTime = event.get_time();
+ this._dragThresholdIgnored = false;
+
+ let [stageX, stageY] = event.get_coords();
+ this._dragStartX = stageX;
+ this._dragStartY = stageY;
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _grabDevice(actor, pointer, touchSequence) {
+ this._grab = global.stage.grab(actor);
+ this._grabbedDevice = pointer;
+ this._touchSequence = touchSequence;
+ }
+
+ _ungrabDevice() {
+ if (this._grab) {
+ this._grab.dismiss();
+ this._grab = null;
+ }
+ 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._eventsGrab) {
+ let grab = Main.pushModal(_getEventHandlerActor());
+ if ((grab.get_seat_state() & Clutter.GrabState.POINTER) !== 0) {
+ this._grabDevice(_getEventHandlerActor(), device, touchSequence);
+ this._eventsGrab = grab;
+ } else {
+ Main.popModal(grab);
+ }
+ }
+ }
+
+ _ungrabEvents() {
+ if (this._eventsGrab) {
+ this._ungrabDevice();
+ Main.popModal(this._eventsGrab);
+ this._eventsGrab = null;
+ }
+ }
+
+ _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;
+
+ let scaledWidth, scaledHeight;
+
+ 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);
+
+ this._dragActorSourceDestroyId = this._dragActorSource.connect('destroy', () => {
+ this._dragActorSource = null;
+ });
+ } else {
+ this._dragActorSource = this.actor;
+ }
+ this._dragOrigParent = undefined;
+
+ this._dragOffsetX = this._dragActor.x - this._dragStartX;
+ this._dragOffsetY = this._dragActor.y - this._dragStartY;
+
+ [scaledWidth, scaledHeight] = this._dragActor.get_transformed_size();
+ } 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._dragActorHadNatWidth = this._dragActor.natural_width_set;
+ this._dragActorHadNatHeight = this._dragActor.natural_height_set;
+ this._dragOrigWidth = this._dragActor.allocation.get_width();
+ this._dragOrigHeight = this._dragActor.allocation.get_height();
+ this._dragOrigScale = this._dragActor.scale_x;
+
+ // Ensure actors with an allocation smaller than their natural size
+ // retain their size
+ this._dragActor.set_size(...this._dragActor.allocation.get_size());
+
+ const transformedExtents = this._dragActor.get_transformed_extents();
+
+ this._dragOffsetX = transformedExtents.origin.x - this._dragStartX;
+ this._dragOffsetY = transformedExtents.origin.y - this._dragStartY;
+
+ scaledWidth = transformedExtents.get_width();
+ scaledHeight = transformedExtents.get_height();
+
+ this._dragActor.scale_x = scaledWidth / this._dragOrigWidth;
+ this._dragActor.scale_y = scaledHeight / this._dragOrigHeight;
+
+ 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._dragOrigParentDestroyId = this._dragOrigParent.connect('destroy', () => {
+ this._dragOrigParent = null;
+ });
+ }
+
+ 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 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,
+ onComplete: () => {
+ this._updateActorPosition(origScale,
+ origDragOffsetX, origDragOffsetY, transX, transY);
+ },
+ });
+
+ this._dragActor.get_transition('scale-x').connect('new-frame', () => {
+ this._updateActorPosition(origScale,
+ origDragOffsetX, origDragOffsetY, transX, transY);
+ });
+ }
+ }
+ }
+
+ _updateActorPosition(origScale, origDragOffsetX, origDragOffsetY, transX, transY) {
+ const 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();
+
+ if (this._dragThresholdIgnored)
+ return Clutter.EVENT_PROPAGATE;
+
+ // 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)) {
+ const deviceType = event.get_source_device().get_device_type();
+ const isPointerOrTouchpad =
+ deviceType === Clutter.InputDeviceType.POINTER_DEVICE ||
+ deviceType === Clutter.InputDeviceType.TOUCHPAD_DEVICE;
+ const ellapsedTime = event.get_time() - this._dragStartTime;
+
+ // Pointer devices (e.g. mouse) start the drag immediately
+ if (isPointerOrTouchpad || ellapsedTime > this._dragTimeoutThreshold) {
+ this.startDrag(stageX, stageY, event.get_time(), this._touchSequence, event.get_device());
+ this._updateDragPosition(event);
+ } else {
+ this._dragThresholdIgnored = true;
+ this._ungrabActor();
+ return Clutter.EVENT_PROPAGATE;
+ }
+ }
+
+ return Clutter.EVENT_STOP;
+ }
+
+ _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]);
+ dragEvent.targetActor.disconnect(targetActorDestroyHandlerId);
+ 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 parentScale = _getRealActorScale(this._dragOrigParent);
+
+ x = parentX + parentScale * this._dragOrigX;
+ y = parentY + parentScale * this._dragOrigY;
+ scale = this._dragOrigScale * parentScale;
+ } 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;
+ if (this._dragActorHadNatWidth)
+ this._dragActor.set_width(-1);
+ if (this._dragActorHadNatHeight)
+ this._dragActor.set_height(-1);
+ } 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();
+
+ if (this._updateHoverId) {
+ GLib.source_remove(this._updateHoverId);
+ this._updateHoverId = 0;
+ }
+
+ if (this._dragActor) {
+ this._dragActor.disconnect(this._dragActorDestroyId);
+ this._dragActor = null;
+ }
+
+ if (this._dragOrigParent) {
+ this._dragOrigParent.disconnect(this._dragOrigParentDestroyId);
+ this._dragOrigParent = null;
+ }
+
+ if (this._dragActorSource) {
+ this._dragActorSource.disconnect(this._dragActorSourceDestroyId);
+ this._dragActorSource = null;
+ }
+
+ this._dragState = DragState.INIT;
+ currentDraggable = null;
+ }
+};
+
+/**
+ * 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..c0f9e4e
--- /dev/null
+++ b/js/ui/edgeDragAction.js
@@ -0,0 +1,89 @@
+// -*- 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': {},
+ 'progress': { param_types: [GObject.TYPE_DOUBLE] },
+ },
+}, class EdgeDragAction extends Clutter.GestureAction {
+ _init(side, allowedModes) {
+ super._init();
+ this._side = side;
+ this._allowedModes = allowedModes;
+ this.set_n_touch_points(1);
+ this.set_threshold_trigger_edge(Clutter.GestureTriggerEdge.AFTER);
+
+ 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;
+ }
+
+ if (this._side === St.Side.TOP ||
+ this._side === St.Side.BOTTOM)
+ this.emit('progress', offsetY);
+ else
+ this.emit('progress', offsetX);
+
+ 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');
+ else
+ this.cancel();
+ }
+});
diff --git a/js/ui/endSessionDialog.js b/js/ui/endSessionDialog.js
new file mode 100644
index 0000000..ca24d06
--- /dev/null
+++ b/js/ui/endSessionDialog.js
@@ -0,0 +1,798 @@
+// -*- 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: ${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._canRebootToBootLoaderMenu = false;
+ this._getCanRebootToBootLoaderMenu();
+
+ 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('opened',
+ this._onOpened.bind(this));
+
+ this._user.connectObject(
+ 'notify::is-loaded', this._sync.bind(this),
+ 'changed', this._sync.bind(this), 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 _getCanRebootToBootLoaderMenu() {
+ const {canRebootToBootLoaderMenu} = await this._loginManager.canRebootToBootLoaderMenu();
+ this._canRebootToBootLoaderMenu = canRebootToBootLoaderMenu;
+ }
+
+ 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: ${e}`);
+ }
+ }
+
+ _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 = this.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');
+ }
+
+ async _confirm(signal) {
+ if (this._checkBox.visible) {
+ // Trigger the offline update as requested
+ if (this._checkBox.checked) {
+ switch (signal) {
+ case 'ConfirmedReboot':
+ await this._triggerOfflineUpdateReboot();
+ 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';
+ await this._triggerOfflineUpdateShutdown();
+ break;
+ default:
+ break;
+ }
+ } else {
+ await this._triggerOfflineUpdateCancel();
+ }
+ }
+
+ this._fadeOutDialog();
+ this._stopTimer();
+ this._stopAltCapture();
+ this._dbusImpl.emit_signal(signal, null);
+ }
+
+ _onOpened() {
+ this._sync();
+ }
+
+ async _triggerOfflineUpdateReboot() {
+ // Handle this gracefully if PackageKit is not available.
+ if (!this._pkOfflineProxy)
+ return;
+
+ try {
+ await this._pkOfflineProxy.TriggerAsync('reboot');
+ } catch (error) {
+ log(error.message);
+ }
+ }
+
+ async _triggerOfflineUpdateShutdown() {
+ // Handle this gracefully if PackageKit is not available.
+ if (!this._pkOfflineProxy)
+ return;
+
+ try {
+ await this._pkOfflineProxy.TriggerAsync('power-off');
+ } catch (error) {
+ log(error.message);
+ }
+ }
+
+ async _triggerOfflineUpdateCancel() {
+ // Handle this gracefully if PackageKit is not available.
+ if (!this._pkOfflineProxy)
+ return;
+
+ try {
+ await this._pkOfflineProxy.CancelAsync();
+ } catch (error) {
+ log(error.message);
+ }
+ }
+
+ _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);
+ const [flags] = app ? inhibitor.GetFlagsSync() : [0];
+
+ if (app && flags & GnomeSession.InhibitFlags.LOGOUT) {
+ 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) or is not
+ // inhibiting logout/shutdown
+ this._applications.splice(this._applications.indexOf(inhibitor), 1);
+ }
+
+ this._sync();
+ }
+
+ async _loadSessions() {
+ let sessionId = GLib.getenv('XDG_SESSION_ID');
+ if (!sessionId) {
+ const currentSessionProxy = await this._loginManager.getCurrentSessionProxy();
+ sessionId = currentSessionProxy.Id;
+ log(`endSessionDialog: No XDG_SESSION_ID, fetched from logind: ${sessionId}`);
+ }
+
+ const sessions = await this._loginManager.listSessions();
+ for (const [id_, uid_, userName, seat_, sessionPath] of sessions) {
+ let proxy = new LogindSession(Gio.DBus.system, 'org.freedesktop.login1', sessionPath);
+
+ if (proxy.Class !== 'user')
+ continue;
+
+ if (proxy.State === 'closing')
+ continue;
+
+ if (proxy.Id === sessionId)
+ continue;
+
+ const session = {
+ user: this._userManager.get_user(userName),
+ username: userName,
+ type: proxy.Type,
+ remote: proxy.Remote,
+ };
+ const nSessions = this._sessions.push(session);
+
+ let userAvatar = new UserWidget.Avatar(session.user, {
+ iconSize: _ITEM_ICON_SIZE,
+ });
+ userAvatar.update();
+
+ const displayUserName =
+ 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(displayUserName);
+ else if (session.type === 'tty')
+ /* Translators: Console here refers to a tty like a VT console */
+ userLabelText = _('%s (console)').format(displayUserName);
+ else
+ userLabelText = userName;
+
+ let listItem = new Dialog.ListSectionItem({
+ icon_actor: userAvatar,
+ title: userLabelText,
+ });
+ this._sessionSection.list.add_child(listItem);
+
+ // limit the number of entries
+ if (nSessions === 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: ${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..8c790da
--- /dev/null
+++ b/js/ui/environment.js
@@ -0,0 +1,470 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported init */
+
+const Config = imports.misc.config;
+
+imports.gi.versions.AccountsService = '1.0';
+imports.gi.versions.Atk = '1.0';
+imports.gi.versions.Atspi = '2.0';
+imports.gi.versions.Clutter = Config.LIBMUTTER_API_VERSION;
+imports.gi.versions.Cogl = Config.LIBMUTTER_API_VERSION;
+imports.gi.versions.Gcr = '4';
+imports.gi.versions.Gdk = '3.0';
+imports.gi.versions.Gdm = '1.0';
+imports.gi.versions.Geoclue = '2.0';
+imports.gi.versions.Gio = '2.0';
+imports.gi.versions.GDesktopEnums = '3.0';
+imports.gi.versions.GdkPixbuf = '2.0';
+imports.gi.versions.GnomeBluetooth = '3.0';
+imports.gi.versions.GnomeDesktop = '3.0';
+imports.gi.versions.Graphene = '1.0';
+imports.gi.versions.Gtk = '3.0';
+imports.gi.versions.GWeather = '4.0';
+imports.gi.versions.IBus = '1.0';
+imports.gi.versions.Malcontent = '0';
+imports.gi.versions.NM = '1.0';
+imports.gi.versions.NMA = '1.0';
+imports.gi.versions.Pango = '1.0';
+imports.gi.versions.Polkit = '1.0';
+imports.gi.versions.PolkitAgent = '1.0';
+imports.gi.versions.Rsvg = '2.0';
+imports.gi.versions.Soup = '3.0';
+imports.gi.versions.TelepathyGLib = '0.12';
+imports.gi.versions.TelepathyLogger = '0.2';
+imports.gi.versions.UPowerGlib = '1.0';
+
+try {
+ if (Config.HAVE_SOUP2)
+ throw new Error('Soup3 support not enabled');
+ const Soup_ = imports.gi.Soup;
+} catch (e) {
+ imports.gi.versions.Soup = '2.4';
+ const { Soup } = imports.gi;
+ _injectSoup3Compat(Soup);
+}
+
+const { Clutter, Gio, GLib, GObject, Meta, Polkit, Shell, St } = imports.gi;
+const Gettext = imports.gettext;
+const System = imports.system;
+const SignalTracker = imports.misc.signalTracker;
+
+Gio._promisify(Gio.DataInputStream.prototype, 'fill_async');
+Gio._promisify(Gio.DataInputStream.prototype, 'read_line_async');
+Gio._promisify(Gio.DBus, 'get');
+Gio._promisify(Gio.DBusConnection.prototype, 'call');
+Gio._promisify(Gio.DBusProxy, 'new');
+Gio._promisify(Gio.DBusProxy.prototype, 'init_async');
+Gio._promisify(Gio.DBusProxy.prototype, 'call_with_unix_fd_list');
+Gio._promisify(Polkit.Permission, 'new');
+
+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;
+ }
+ });
+ };
+ }
+}
+
+/**
+ * Mimick the Soup 3 APIs we use when falling back to Soup 2.4
+ *
+ * @param {object} Soup 2.4 namespace
+ * @returns {void}
+ */
+function _injectSoup3Compat(Soup) {
+ Soup.StatusCode = Soup.KnownStatusCode;
+
+ Soup.Message.new_from_encoded_form =
+ function (method, uri, form) {
+ const soupUri = new Soup.URI(uri);
+ soupUri.set_query(form);
+ return Soup.Message.new_from_uri(method, soupUri);
+ };
+ Soup.Message.prototype.set_request_body_from_bytes =
+ function (contentType, bytes) {
+ this.set_request(
+ contentType,
+ Soup.MemoryUse.COPY,
+ new TextDecoder().decode(bytes.get_data()));
+ };
+
+ Soup.Session.prototype.send_and_read_async =
+ function (message, prio, cancellable, callback) {
+ this.queue_message(message, () => callback(this, message));
+ };
+ Soup.Session.prototype.send_and_read_finish =
+ function (message) {
+ if (message.status_code !== Soup.KnownStatusCode.OK)
+ return null;
+
+ return message.response_body.flatten().get_as_bytes();
+ };
+}
+
+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();
+
+ const transitions = animatedProps
+ .map(p => actor.get_transition(p))
+ .filter(t => t !== null);
+
+ transitions.forEach(t => t.set({ repeatCount, autoReverse }));
+
+ const [transition] = transitions;
+
+ if (transition && transition.delay)
+ transition.connect('started', () => prepare());
+ else
+ prepare();
+
+ if (transition)
+ 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 init() {
+ // Add some bindings to the global JS namespace
+ globalThis.global = Shell.Global.get();
+
+ globalThis._ = Gettext.gettext;
+ globalThis.C_ = Gettext.pgettext;
+ globalThis.ngettext = Gettext.ngettext;
+ globalThis.N_ = s => s;
+
+ GObject.gtypeNameBasedOnJSPath = true;
+
+ GObject.Object.prototype.connectObject = function (...args) {
+ SignalTracker.connectObject(this, ...args);
+ };
+ GObject.Object.prototype.connect_object = function (...args) {
+ SignalTracker.connectObject(this, ...args);
+ };
+ GObject.Object.prototype.disconnectObject = function (...args) {
+ SignalTracker.disconnectObject(this, ...args);
+ };
+ GObject.Object.prototype.disconnect_object = function (...args) {
+ SignalTracker.disconnectObject(this, ...args);
+ };
+
+ SignalTracker.registerDestroyableType(Clutter.Actor);
+
+ // 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.File.prototype.touch_async = function (callback) {
+ Shell.util_touch_file_async(this, callback);
+ };
+ Gio.File.prototype.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?.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 Math.min(msecs, 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..94ba8fa
--- /dev/null
+++ b/js/ui/extensionDownloader.js
@@ -0,0 +1,282 @@
+// -*- 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;
+
+Gio._promisify(Soup.Session.prototype, 'send_and_read_async');
+Gio._promisify(Gio.OutputStream.prototype, 'write_bytes_async');
+Gio._promisify(Gio.IOStream.prototype, 'close_async');
+Gio._promisify(Gio.Subprocess.prototype, 'wait_check_async');
+
+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;
+
+/**
+ * @param {string} uuid - extension uuid
+ * @param {Gio.DBusMethodInvocation} invocation - the caller
+ * @returns {void}
+ */
+async function installExtension(uuid, invocation) {
+ const params = {
+ uuid,
+ shell_version: Config.PACKAGE_VERSION,
+ };
+
+ const message = Soup.Message.new_from_encoded_form('GET',
+ REPOSITORY_URL_INFO,
+ Soup.form_encode_hash(params));
+
+ let info;
+ try {
+ const bytes = await _httpSession.send_and_read_async(
+ message,
+ GLib.PRIORITY_DEFAULT,
+ null);
+ checkResponse(message);
+ const decoder = new TextDecoder();
+ info = JSON.parse(decoder.decode(bytes.get_data()));
+ } catch (e) {
+ Main.extensionManager.logExtensionError(uuid, e);
+ invocation.return_dbus_error(
+ 'org.gnome.Shell.ExtensionError', e.message);
+ return;
+ }
+
+ const 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;
+}
+
+/**
+ * Check return status of reponse
+ *
+ * @param {Soup.Message} message - an http response
+ * @returns {void}
+ * @throws
+ */
+function checkResponse(message) {
+ const { statusCode } = message;
+ const phrase = Soup.Status.get_phrase(statusCode);
+ if (statusCode !== Soup.Status.OK)
+ throw new Error(`Unexpected response: ${phrase}`);
+}
+
+/**
+ * @param {GLib.Bytes} bytes - archive data
+ * @param {Gio.File} dir - target directory
+ * @returns {void}
+ */
+async function extractExtensionArchive(bytes, dir) {
+ if (!dir.query_exists(null))
+ dir.make_directory_with_parents(null);
+
+ const [file, stream] = Gio.File.new_tmp('XXXXXX.shell-extension.zip');
+ await stream.output_stream.write_bytes_async(bytes,
+ GLib.PRIORITY_DEFAULT, null);
+ stream.close_async(GLib.PRIORITY_DEFAULT, null);
+
+ const unzip = Gio.Subprocess.new(
+ ['unzip', '-uod', dir.get_path(), '--', file.get_path()],
+ Gio.SubprocessFlags.NONE);
+ await unzip.wait_check_async(null);
+}
+
+/**
+ * @param {string} uuid - extension uuid
+ * @returns {void}
+ */
+async function downloadExtensionUpdate(uuid) {
+ if (!Main.extensionManager.updatesSupported)
+ return;
+
+ const dir = Gio.File.new_for_path(
+ GLib.build_filenamev([global.userdatadir, 'extension-updates', uuid]));
+
+ const params = { shell_version: Config.PACKAGE_VERSION };
+ const message = Soup.Message.new_from_encoded_form('GET',
+ REPOSITORY_URL_DOWNLOAD.format(uuid),
+ Soup.form_encode_hash(params));
+
+ try {
+ const bytes = await _httpSession.send_and_read_async(
+ message,
+ GLib.PRIORITY_DEFAULT,
+ null);
+ checkResponse(message);
+
+ await extractExtensionArchive(bytes, dir);
+ Main.extensionManager.notifyExtensionUpdate(uuid);
+ } catch (e) {
+ log(`Error while downloading update for extension ${uuid}: (${e.message})`);
+ }
+}
+
+/**
+ * Check extensions.gnome.org for updates
+ *
+ * @returns {void}
+ */
+async 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
+
+ const versionCheck = global.settings.get_boolean(
+ 'disable-extension-version-validation');
+ const params = {
+ shell_version: Config.PACKAGE_VERSION,
+ disable_version_validation: `${versionCheck}`,
+ };
+ const requestBody = new GLib.Bytes(JSON.stringify(metadatas));
+
+ const message = Soup.Message.new('POST',
+ `${REPOSITORY_URL_UPDATE}?${Soup.form_encode_hash(params)}`);
+ message.set_request_body_from_bytes('application/json', requestBody);
+
+ let json;
+ try {
+ const bytes = await _httpSession.send_and_read_async(
+ message,
+ GLib.PRIORITY_DEFAULT,
+ null);
+ checkResponse(message);
+ json = new TextDecoder().decode(bytes.get_data());
+ } catch (e) {
+ log(`Update check failed: ${e.message}`);
+ return;
+ }
+
+ const operations = JSON.parse(json);
+ const updates = [];
+ for (const uuid in operations) {
+ const operation = operations[uuid];
+ if (operation === 'upgrade' || operation === 'downgrade')
+ updates.push(uuid);
+ }
+
+ try {
+ await Promise.allSettled(
+ updates.map(uuid => downloadExtensionUpdate(uuid)));
+ } catch (e) {
+ log(`Some extension updates failed to download: ${e.message}`);
+ }
+}
+
+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']));
+ }
+
+ async _onInstallButtonPressed() {
+ this.close();
+
+ const params = { shell_version: Config.PACKAGE_VERSION };
+ const message = Soup.Message.new_from_encoded_form('GET',
+ REPOSITORY_URL_DOWNLOAD.format(this._uuid),
+ Soup.form_encode_hash(params));
+
+ const dir = Gio.File.new_for_path(
+ GLib.build_filenamev([global.userdatadir, 'extensions', this._uuid]));
+
+ try {
+ const bytes = await _httpSession.send_and_read_async(
+ message,
+ GLib.PRIORITY_DEFAULT,
+ null);
+ checkResponse(message);
+
+ await extractExtensionArchive(bytes, dir);
+
+ const extension = Main.extensionManager.createExtensionObject(
+ this._uuid, dir, ExtensionUtils.ExtensionType.PER_USER);
+ Main.extensionManager.loadExtension(extension);
+ if (!Main.extensionManager.enableExtension(this._uuid))
+ throw new Error(`Cannot enable ${this._uuid}`);
+
+ this._invocation.return_value(new GLib.Variant('(s)', ['successful']));
+ } catch (e) {
+ log(`Error while installing ${this._uuid}: ${e.message}`);
+ this._invocation.return_dbus_error(
+ 'org.gnome.Shell.ExtensionError', e.message);
+ }
+ }
+});
+
+function init() {
+ _httpSession = new Soup.Session();
+}
diff --git a/js/ui/extensionSystem.js b/js/ui/extensionSystem.js
new file mode 100644
index 0000000..c21cc7c
--- /dev/null
+++ b/js/ui/extensionSystem.js
@@ -0,0 +1,687 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported init connect disconnect ExtensionManager */
+
+const { GLib, Gio, GObject, Shell, St } = imports.gi;
+const Signals = imports.misc.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 extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ this._initialized = false;
+ this._updateNotified = false;
+
+ this._extensions = new Map();
+ this._unloadedExtensions = new Map();
+ this._enabledExtensions = [];
+ this._extensionOrder = [];
+ this._checkVersion = false;
+
+ 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 ${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) ||
+ (appSys.lookup_app('com.mattjakeman.ExtensionManager.desktop') !== null);
+ }
+
+ lookup(uuid) {
+ return this._extensions.get(uuid);
+ }
+
+ getUuids() {
+ return [...this._extensions.keys()];
+ }
+
+ _extensionSupportsSessionMode(uuid) {
+ const extension = this.lookup(uuid);
+
+ if (!extension)
+ return false;
+
+ if (extension.sessionModes.includes(Main.sessionMode.currentMode))
+ return true;
+
+ if (extension.sessionModes.includes(Main.sessionMode.parentMode))
+ return true;
+
+ return false;
+ }
+
+ _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 (!this._extensionSupportsSessionMode(uuid))
+ 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 = [`${global.session_mode}.css`, '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 ${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 = new TextDecoder().decode(metadataContents);
+ } catch (e) {
+ throw new Error(`Failed to load metadata.json: ${e}`);
+ }
+ let meta;
+ try {
+ meta = JSON.parse(metadataContents);
+ } catch (e) {
+ throw new Error(`Failed to parse metadata.json: ${e}`);
+ }
+
+ const requiredProperties = [{
+ prop: 'uuid',
+ typeName: 'string',
+ }, {
+ prop: 'name',
+ typeName: 'string',
+ }, {
+ prop: 'description',
+ typeName: 'string',
+ }, {
+ prop: 'shell-version',
+ typeName: 'string array',
+ typeCheck: v => Array.isArray(v) && v.length > 0 && v.every(e => typeof e === 'string'),
+ }];
+ for (let i = 0; i < requiredProperties.length; i++) {
+ const {
+ prop, typeName, typeCheck = v => typeof v === typeName,
+ } = requiredProperties[i];
+
+ if (!meta[prop])
+ throw new Error(`missing "${prop}" property in metadata.json`);
+ if (!typeCheck(meta[prop]))
+ throw new Error(`property "${prop}" is not of type ${typeName}`);
+ }
+
+ if (uuid != meta.uuid)
+ throw new Error(`uuid "${meta.uuid}" from metadata.json does not match directory name "${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,
+ sessionModes: meta['session-modes'] ? meta['session-modes'] : ['user'],
+ };
+ 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;
+
+ if (this._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) &&
+ this._extensionSupportsSessionMode(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 (!this._extensionSupportsSessionMode(uuid))
+ 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) &&
+ this._extensionSupportsSessionMode(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) ||
+ !this._extensionSupportsSessionMode(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() {
+ const checkVersion = !global.settings.get_boolean(EXTENSION_DISABLE_VERSION_CHECK_KEY);
+ if (checkVersion === this._checkVersion)
+ return;
+
+ this._checkVersion = checkVersion;
+
+ // 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 ${uuid}`);
+ } finally {
+ FileUtils.recursivelyDeleteDir(dir, true);
+ }
+ });
+ }
+
+ _loadExtensions() {
+ global.settings.connect(`changed::${ENABLED_EXTENSIONS_KEY}`,
+ this._onEnabledExtensionsChanged.bind(this));
+ global.settings.connect(`changed::${DISABLED_EXTENSIONS_KEY}`,
+ this._onEnabledExtensionsChanged.bind(this));
+ global.settings.connect(`changed::${DISABLE_USER_EXTENSIONS_KEY}`,
+ this._onUserExtensionsEnabledChanged.bind(this));
+ global.settings.connect(`changed::${EXTENSION_DISABLE_VERSION_CHECK_KEY}`,
+ this._onVersionValidationChanged.bind(this));
+ global.settings.connect(`writable-changed::${ENABLED_EXTENSIONS_KEY}`,
+ this._onSettingsWritableChanged.bind(this));
+ global.settings.connect(`writable-changed::${DISABLED_EXTENSIONS_KEY}`,
+ this._onSettingsWritableChanged.bind(this));
+
+ this._onVersionValidationChanged();
+
+ 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 ${uuid} already installed in ${existing.path}. ${dir.get_path()} will not be loaded`);
+ 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 ${uuid}`);
+ return;
+ }
+ this.loadExtension(extension);
+ });
+ }
+
+ _enableAllExtensions() {
+ if (!this._initialized) {
+ this._loadExtensions();
+ this._initialized = true;
+ } else {
+ this._enabledExtensions.forEach(uuid => {
+ this._callExtensionEnable(uuid);
+ });
+ }
+ }
+
+ _disableAllExtensions() {
+ if (this._initialized) {
+ this._extensionOrder.slice().reverse().forEach(uuid => {
+ this._callExtensionDisable(uuid);
+ });
+ }
+ }
+
+ _sessionUpdated() {
+ // Take care of added or removed sessionMode extensions
+ this._onEnabledExtensionsChanged();
+ this._enableAllExtensions();
+ }
+};
+
+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');
+ if (!this._app)
+ this._app = appSys.lookup_app('com.mattjakeman.ExtensionManager.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..5cfe7a8
--- /dev/null
+++ b/js/ui/focusCaretTracker.js
@@ -0,0 +1,91 @@
+/** -*- 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>
+ */
+/* exported FocusCaretTracker */
+
+const Atspi = imports.gi.Atspi;
+const Signals = imports.misc.signals;
+
+const CARETMOVED = 'object:text-caret-moved';
+const STATECHANGED = 'object:state-changed';
+
+var FocusCaretTracker = class FocusCaretTracker extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ 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;
+ }
+};
diff --git a/js/ui/grabHelper.js b/js/ui/grabHelper.js
new file mode 100644
index 0000000..650bec4
--- /dev/null
+++ b/js/ui/grabHelper.js
@@ -0,0 +1,291 @@
+// -*- 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;
+
+// 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._ignoreUntilRelease = false;
+
+ this._modalCount = 0;
+ }
+
+ _isWithinGrabbedActor(actor) {
+ let currentActor = this.currentGrab.actor;
+ while (actor) {
+ 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) {
+ let grab = Main.pushModal(this._owner, this._modalParams);
+ if (grab.get_seat_state() !== Clutter.GrabState.ALL) {
+ Main.popModal(grab);
+ return false;
+ }
+
+ this._grab = grab;
+ this._capturedEventId = this._owner.connect('captured-event',
+ (actor, event) => {
+ return this.onCapturedEvent(event);
+ });
+ }
+
+ this._modalCount++;
+ return true;
+ }
+
+ _releaseModalGrab() {
+ this._modalCount--;
+ if (this._modalCount > 0)
+ return;
+
+ this._owner.disconnect(this._capturedEventId);
+ this._ignoreUntilRelease = false;
+
+ Main.popModal(this._grab);
+ this._grab = null;
+ }
+
+ // 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_PROPAGATE;
+ }
+
+ const targetActor = global.stage.get_event_actor(event);
+
+ if (type === Clutter.EventType.ENTER ||
+ type === Clutter.EventType.LEAVE ||
+ this.currentGrab.actor.contains(targetActor))
+ return Clutter.EVENT_PROPAGATE;
+
+ if (Main.keyboard.maybeHandleEvent(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(targetActor) + 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..268b324
--- /dev/null
+++ b/js/ui/ibusCandidatePopup.js
@@ -0,0 +1,359 @@
+// -*- 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) {
+ const 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._buttonBox.add_child(this._previousButton);
+
+ this._nextButton = new St.Button({
+ style_class: 'candidate-page-button candidate-page-button-next button',
+ x_expand: true,
+ });
+ this._buttonBox.add_child(this._nextButton);
+
+ this.add(this._buttonBox);
+
+ this._previousButton.connect('button-press-event', () => {
+ this.emit('previous-page');
+ return Clutter.EVENT_STOP;
+ });
+ this._previousButton.connect('touch-event', (actor, event) => {
+ if (event.type() === Clutter.EventType.TOUCH_BEGIN) {
+ this.emit('previous-page');
+ return Clutter.EVENT_STOP;
+ }
+ return Clutter.EVENT_PROPAGATE;
+ });
+ this._nextButton.connect('button-press-event', () => {
+ this.emit('next-page');
+ return Clutter.EVENT_STOP;
+ });
+ this._nextButton.connect('touch-event', (actor, event) => {
+ if (event.type() === Clutter.EventType.TOUCH_BEGIN) {
+ this.emit('next-page');
+ return Clutter.EVENT_STOP;
+ }
+ return Clutter.EVENT_PROPAGATE;
+ });
+
+ 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.icon_name = 'go-previous-symbolic';
+ this._nextButton.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.icon_name = 'go-up-symbolic';
+ this._nextButton.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 Clutter.Actor({ opacity: 0 });
+ Main.layoutManager.uiGroup.add_actor(this._dummyCursor);
+
+ Main.layoutManager.addTopChrome(this);
+
+ const 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();
+ Main.keyboard.setSuggestionsVisible(visible);
+
+ 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', () => {
+ Main.keyboard.setSuggestionsVisible(true);
+ this._candidateArea.show();
+ this._updateVisibility();
+ });
+ panelService.connect('hide-lookup-table', () => {
+ Main.keyboard.setSuggestionsVisible(false);
+ 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);
+ // We shouldn't be above some components like the screenshot UI,
+ // so don't raise to the top.
+ // The on-screen keyboard is expected to be above any entries,
+ // so just above the keyboard gets us to the right layer.
+ const { keyboardBox } = Main.layoutManager;
+ this.get_parent().set_child_above_sibling(this, keyboardBox);
+ } 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..ac8d3ec
--- /dev/null
+++ b/js/ui/iconGrid.js
@@ -0,0 +1,1415 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported BaseIcon, IconGrid, IconGridLayout */
+
+const { Clutter, GLib, GObject, Meta, Shell, St } = imports.gi;
+
+const Params = imports.misc.params;
+const Main = imports.ui.main;
+
+var ICON_SIZE = 96;
+
+var PAGE_SWITCH_TIME = 300;
+
+var IconSize = {
+ LARGE: 96,
+ MEDIUM: 64,
+ MEDIUM_SMALL: 48,
+ SMALL: 32,
+ SMALLER: 24,
+ 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 Shell.SquareBin {
+ _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._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();
+ cache.connectObject(
+ 'icon-theme-changed', this._onIconThemeChanged.bind(this), this);
+ }
+
+ // 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);
+ }
+
+ _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) {
+ const monitor = Main.layoutManager.findMonitorForActor(actor);
+ if (!monitor)
+ return;
+
+ const 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 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, 6),
+ '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-padding': GObject.ParamSpec.boxed('page-padding',
+ 'Page padding', 'Page padding',
+ GObject.ParamFlags.READWRITE,
+ Clutter.Margin.$gtype),
+ '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, 4),
+ },
+ Signals: {
+ 'pages-changed': {},
+ },
+}, class IconGridLayout extends Clutter.LayoutManager {
+ _init(params = {}) {
+ this._orientation = params.orientation ?? Clutter.Orientation.VERTICAL;
+
+ super._init(params);
+
+ if (!this.pagePadding)
+ this.pagePadding = new Clutter.Margin();
+
+ 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._childrenMaxSize = -1;
+ }
+
+ _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 -
+ this.pagePadding.left - this.pagePadding.right;
+ const emptyVSpace =
+ this._pageHeight - usedHeight - rowSpacingPerPage -
+ this.pagePadding.top - this.pagePadding.bottom;
+
+ if (emptyHSpace >= 0 && emptyVSpace > 0)
+ return size;
+ }
+
+ return IconSize.TINY;
+ }
+
+ _getChildrenMaxSize() {
+ if (this._childrenMaxSize === -1) {
+ let minWidth = 0;
+ let minHeight = 0;
+
+ const nPages = this._pages.length;
+ for (let pageIndex = 0; pageIndex < nPages; pageIndex++) {
+ const page = this._pages[pageIndex];
+ const nVisibleItems = page.visibleChildren.length;
+ for (let itemIndex = 0; itemIndex < nVisibleItems; itemIndex++) {
+ const item = page.visibleChildren[itemIndex];
+
+ const childMinHeight = item.get_preferred_height(-1)[0];
+ const childMinWidth = item.get_preferred_width(-1)[0];
+
+ minWidth = Math.max(minWidth, childMinWidth);
+ minHeight = Math.max(minHeight, childMinHeight);
+ }
+ }
+
+ this._childrenMaxSize = Math.max(minWidth, minHeight);
+ }
+
+ return this._childrenMaxSize;
+ }
+
+ _updateVisibleChildrenForPage(pageIndex) {
+ this._pages[pageIndex].visibleChildren =
+ 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);
+ item.disconnect(itemData.queueRelayoutId);
+
+ 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._pages[pageIndex].visibleChildren;
+ const itemsPerPage = this.columnsPerPage * this.rowsPerPage;
+
+ // No reduce needed
+ if (visiblePageItems.length === itemsPerPage)
+ return;
+
+ const visibleNextPageItems = this._pages[pageIndex + 1].visibleChildren;
+ 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);
+
+ this._updateVisibleChildrenForPage(pageIndex);
+
+ // Delete the page if this is the last icon in it
+ const visibleItems = this._pages[pageIndex].visibleChildren;
+ if (visibleItems.length === 0)
+ this._removePage(pageIndex);
+
+ if (!this.allowIncompletePages)
+ this._fillItemVacancies(pageIndex);
+ }
+
+ _relocateSurplusItems(pageIndex) {
+ const visiblePageItems = this._pages[pageIndex].visibleChildren;
+ 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);
+
+ this._updateVisibleChildrenForPage(itemData.pageIndex);
+
+ if (item.visible)
+ this._relocateSurplusItems(itemData.pageIndex);
+ else if (!this.allowIncompletePages)
+ this._fillItemVacancies(itemData.pageIndex);
+ }),
+ queueRelayoutId: item.connect('queue-relayout', () => {
+ this._childrenMaxSize = -1;
+ }),
+ });
+
+ item.icon.setIconSize(this._iconSize);
+
+ this._pages[pageIndex].children.splice(index, 0, item);
+ this._updateVisibleChildrenForPage(pageIndex);
+ 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);
+
+ const emptyHSpace =
+ this._pageWidth - usedWidth - columnSpacingPerPage -
+ this.pagePadding.left - this.pagePadding.right;
+ const emptyVSpace =
+ this._pageHeight - usedHeight - rowSpacingPerPage -
+ this.pagePadding.top - this.pagePadding.bottom;
+ let leftEmptySpace = this.pagePadding.left;
+ let topEmptySpace = this.pagePadding.top;
+ let hSpacing;
+ let vSpacing;
+
+ switch (this.pageHalign) {
+ case Clutter.ActorAlign.START:
+ 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:
+ 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:
+ 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:
+ 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(align, items, itemIndex, childSize, spacing) {
+ if (align === Clutter.ActorAlign.START ||
+ align === Clutter.ActorAlign.FILL)
+ return 0;
+
+ 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 (align) {
+ case Clutter.ActorAlign.CENTER:
+ rowAlign = availableWidth / 2;
+ break;
+ case Clutter.ActorAlign.END:
+ rowAlign = availableWidth;
+ break;
+ // START and FILL align are handled at the beginning of the function
+ }
+
+ return isRtl ? rowAlign * -1 : rowAlign;
+ }
+
+ _onDestroy() {
+ if (this._updateIconSizesLaterId >= 0) {
+ Meta.later_remove(this._updateIconSizesLaterId);
+ this._updateIconSizesLaterId = 0;
+ }
+ }
+
+ vfunc_set_container(container) {
+ this._container?.disconnectObject(this);
+
+ this._container = container;
+
+ if (this._container)
+ this._container.connectObject('destroy', this._onDestroy.bind(this), 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');
+
+ 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();
+
+ let nChangedIcons = 0;
+ const columnsPerPage = this.columnsPerPage;
+ const orientation = this._orientation;
+ const pageWidth = this._pageWidth;
+ const pageHeight = this._pageHeight;
+ const pageSizeChanged = this._pageSizeChanged;
+ const lastRowAlign = this.lastRowAlign;
+ const shouldEaseItems = this._shouldEaseItems;
+
+ this._pages.forEach((page, pageIndex) => {
+ if (isRtl && orientation === Clutter.Orientation.HORIZONTAL)
+ pageIndex = swap(pageIndex, this._pages.length);
+
+ page.visibleChildren.forEach((item, itemIndex) => {
+ const row = Math.floor(itemIndex / columnsPerPage);
+ let column = itemIndex % columnsPerPage;
+
+ if (isRtl)
+ column = swap(column, columnsPerPage);
+
+ const rowPadding = this._getRowPadding(lastRowAlign,
+ page.visibleChildren, itemIndex, childSize, hSpacing);
+
+ // Icon position
+ let x = leftEmptySpace + rowPadding + column * (childSize + hSpacing);
+ let y = topEmptySpace + row * (childSize + vSpacing);
+
+ // Page start
+ switch (orientation) {
+ case Clutter.Orientation.HORIZONTAL:
+ x += pageIndex * pageWidth;
+ break;
+ case Clutter.Orientation.VERTICAL:
+ y += pageIndex * pageHeight;
+ break;
+ }
+
+ childBox.set_origin(Math.floor(x), Math.floor(y));
+
+ const [,, naturalWidth, naturalHeight] = item.get_preferred_size();
+ childBox.set_size(
+ Math.max(childSize, naturalWidth),
+ Math.max(childSize, naturalHeight));
+
+ if (!shouldEaseItems || pageSizeChanged)
+ item.allocate(childBox);
+ else if (animateIconPosition(item, childBox, nChangedIcons))
+ nChangedIcons++;
+ });
+ });
+
+ this._pageSizeChanged = false;
+ this._shouldEaseItems = false;
+ }
+
+ /**
+ * 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._shouldEaseItems = true;
+
+ 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._shouldEaseItems = true;
+
+ 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._shouldEaseItems = true;
+
+ 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._pages[itemData.pageIndex].visibleChildren;
+
+ 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._pages[page].visibleChildren;
+
+ 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
+ if (this._orientation === Clutter.Orientation.HORIZONTAL)
+ x %= this._pageWidth;
+ else
+ 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._pages[page].visibleChildren;
+
+ 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];
+ }
+
+ get iconSize() {
+ return this._iconSize;
+ }
+
+ get nPages() {
+ return this._pages.length;
+ }
+
+ 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': {},
+ },
+}, class IconGrid extends St.Viewport {
+ _init(layoutParams = {}) {
+ layoutParams = Params.parse(layoutParams, {
+ allow_incomplete_pages: false,
+ orientation: Clutter.Orientation.HORIZONTAL,
+ columns_per_page: 6,
+ rows_per_page: 4,
+ page_halign: Clutter.ActorAlign.FILL,
+ page_padding: new Clutter.Margin(),
+ page_valign: Clutter.ActorAlign.FILL,
+ 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.connect('actor-added', this._childAdded.bind(this));
+ this.connect('actor-removed', this._childRemoved.bind(this));
+ this.connect('destroy', () => layoutManager.disconnect(pagesChangedId));
+ }
+
+ _childAdded(grid, child) {
+ child._iconGridKeyFocusInId = child.connect('key-focus-in', () => {
+ this._ensureItemIsVisible(child);
+ });
+ }
+
+ _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 { pagePadding } = this.layout_manager;
+ width -= pagePadding.left + pagePadding.right;
+ height -= pagePadding.top + pagePadding.bottom;
+
+ 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;
+ }
+
+ vfunc_allocate(box) {
+ const [width, height] = box.get_size();
+ this._findBestModeForSize(width, height);
+ this.layout_manager.adaptToSize(width, height);
+ super.vfunc_allocate(box);
+ }
+
+ 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;
+
+ const padding = new Clutter.Margin();
+ ['top', 'right', 'bottom', 'left'].forEach(side => {
+ padding[side] = node.get_length(`page-padding-${side}`);
+ });
+ this.layout_manager.page_padding = padding;
+ }
+
+ /**
+ * 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;
+ }
+
+ 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..7c3d159
--- /dev/null
+++ b/js/ui/inhibitShortcutsDialog.js
@@ -0,0 +1,160 @@
+/* exported InhibitShortcutsDialog */
+const {Clutter, Gio, 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 = ['org.gnome.Settings.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();
+ }
+
+ async _saveToPermissionStore(grant) {
+ if (!this._shouldUsePermStore() || this._permStore == null)
+ return;
+
+ try {
+ await this._permStore.SetPermissionAsync(APP_PERMISSIONS_TABLE,
+ true,
+ APP_PERMISSIONS_ID,
+ this._app.get_id(),
+ [grant]);
+ } catch (error) {
+ log(error.message);
+ }
+ }
+
+ _buildLayout() {
+ const name = 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(async (proxy, error) => {
+ if (error) {
+ log(error.message);
+ this._dialog.open();
+ return;
+ }
+
+ try {
+ const [permissions] = await this._permStore.LookupAsync(
+ APP_PERMISSIONS_TABLE, APP_PERMISSIONS_ID);
+
+ if (permissions[appId] === undefined) // Not found
+ this._dialog.open();
+ else if (permissions[appId][0] === GRANTED)
+ this._emitResponse(DialogResponse.ALLOW);
+ else
+ this._emitResponse(DialogResponse.DENY);
+ } catch (err) {
+ this._dialog.open();
+ log(err.message);
+ }
+ });
+ }
+
+ vfunc_hide() {
+ this._dialog.close();
+ }
+});
diff --git a/js/ui/init.js b/js/ui/init.js
new file mode 100644
index 0000000..a0fe633
--- /dev/null
+++ b/js/ui/init.js
@@ -0,0 +1,6 @@
+import { setConsoleLogDomain } from 'console';
+
+setConsoleLogDomain('GNOME Shell');
+
+imports.ui.environment.init();
+imports.ui.main.start();
diff --git a/js/ui/kbdA11yDialog.js b/js/ui/kbdA11yDialog.js
new file mode 100644
index 0000000..6d1608c
--- /dev/null
+++ b/js/ui/kbdA11yDialog.js
@@ -0,0 +1,76 @@
+/* exported KbdA11yDialog */
+const { Clutter, Gio, GObject, Meta } = 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 & Meta.KeyboardA11yFlags.SLOW_KEYS_ENABLED) {
+ key = KEY_SLOW_KEYS_ENABLED;
+ enabled = (newFlags & Meta.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 & Meta.KeyboardA11yFlags.STICKY_KEYS_ENABLED) {
+ key = KEY_STICKY_KEYS_ENABLED;
+ enabled = (newFlags & Meta.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..be128d3
--- /dev/null
+++ b/js/ui/keyboard.js
@@ -0,0 +1,2275 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported KeyboardManager */
+
+const {Clutter, Gio, GLib, GObject, Graphene, IBus, Meta, Shell, St} = imports.gi;
+const Signals = imports.misc.signals;
+
+const EdgeDragAction = imports.ui.edgeDragAction;
+const InputSourceManager = imports.ui.status.keyboard;
+const IBusManager = imports.misc.ibusManager;
+const BoxPointer = imports.ui.boxpointer;
+const Main = imports.ui.main;
+const PageIndicators = imports.ui.pageIndicators;
+const PopupMenu = imports.ui.popupMenu;
+const SwipeTracker = imports.ui.swipeTracker;
+
+var KEYBOARD_ANIMATION_TIME = 150;
+var KEYBOARD_REST_TIME = KEYBOARD_ANIMATION_TIME * 2;
+var KEY_LONG_PRESS_TIME = 250;
+
+const A11Y_APPLICATIONS_SCHEMA = 'org.gnome.desktop.a11y.applications';
+const SHOW_KEYBOARD = 'screen-keyboard-enabled';
+const EMOJI_PAGE_SEPARATION = 32;
+
+/* KeyContainer puts keys in a grid where a 1:1 key takes this size */
+const KEY_SIZE = 2;
+
+const KEY_RELEASE_TIMEOUT = 50;
+const BACKSPACE_WORD_DELETE_THRESHOLD = 50;
+
+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);
+ }
+ }
+
+ super.vfunc_allocate(box);
+ }
+});
+
+var KeyContainer = GObject.registerClass(
+class KeyContainer extends St.Widget {
+ _init() {
+ const 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() {
+ 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;
+ }
+ }
+
+ getRatio() {
+ return [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('button-press-event', () => {
+ callback();
+ return Clutter.EVENT_STOP;
+ });
+ button.connect('touch-event', (actor, event) => {
+ if (event.type() !== Clutter.EventType.TOUCH_BEGIN)
+ return Clutter.EVENT_PROPAGATE;
+
+ callback();
+ return Clutter.EVENT_STOP;
+ });
+ this.add_child(button);
+ }
+
+ clear() {
+ this.remove_all_children();
+ }
+
+ setVisible(visible) {
+ for (const child of this)
+ child.visible = visible;
+ }
+});
+
+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;
+ item.setOrnament(is === inputSourceManager.currentSource
+ ? PopupMenu.Ornament.DOT
+ : PopupMenu.Ornament.NONE);
+ }
+
+ this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+ item = this.addSettingsAction(_("Region & Language Settings"), 'gnome-region-panel.desktop');
+ item.can_focus = false;
+
+ actor.connectObject('notify::mapped', () => {
+ if (!actor.is_mapped())
+ this.close(true);
+ }, this);
+ }
+
+ _onCapturedEvent(actor, event) {
+ const targetActor = global.stage.get_event_actor(event);
+
+ if (targetActor === this.actor ||
+ this.actor.contains(targetActor))
+ 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);
+ global.stage.connectObject(
+ 'captured-event', this._onCapturedEvent.bind(this), this);
+ }
+
+ close(animate) {
+ super.close(animate);
+ global.stage.disconnectObject(this);
+ }
+
+ destroy() {
+ global.stage.disconnectObject(this);
+ this.sourceActor.disconnectObject(this);
+ super.destroy();
+ }
+};
+
+var Key = GObject.registerClass({
+ Signals: {
+ 'long-press': {},
+ 'pressed': {},
+ 'released': {},
+ 'commit': {param_types: [GObject.TYPE_UINT, GObject.TYPE_STRING]},
+ },
+}, class Key extends St.BoxLayout {
+ _init(params, extendedKeys = []) {
+ const {label, iconName, commitString, keyval} = {keyval: 0, ...params};
+ super._init({ style_class: 'key-container' });
+
+ this._keyval = parseInt(keyval, 16);
+ this.keyButton = this._makeKey(commitString, label, iconName);
+
+ /* 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;
+ }
+
+ get iconName() {
+ return this._icon.icon_name;
+ }
+
+ set iconName(value) {
+ this._icon.icon_name = value;
+ }
+
+ _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;
+ }
+
+ _getKeyvalFromString(string) {
+ let unicode = string?.length ? string.charCodeAt(0) : undefined;
+ return Clutter.unicode_to_keysym(unicode);
+ }
+
+ _press(button) {
+ if (button === this.keyButton) {
+ this._pressTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT,
+ KEY_LONG_PRESS_TIME,
+ () => {
+ this._pressTimeoutId = 0;
+
+ this.emit('long-press');
+
+ if (this._extendedKeys.length > 0) {
+ this._touchPressSlot = null;
+ this._ensureExtendedKeysPopup();
+ this.keyButton.set_hover(false);
+ this.keyButton.fake_release();
+ this._showSubkeys();
+ }
+
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+
+ this.emit('pressed');
+ this._pressed = true;
+ }
+
+ _release(button, commitString) {
+ if (this._pressTimeoutId != 0) {
+ GLib.source_remove(this._pressTimeoutId);
+ this._pressTimeoutId = 0;
+ }
+
+ let keyval;
+ if (button === this.keyButton)
+ keyval = this._keyval;
+ if (!keyval && commitString)
+ keyval = this._getKeyvalFromString(commitString);
+ console.assert(keyval !== undefined, 'Need keyval or commitString');
+
+ if (this._pressed && (commitString || keyval))
+ this.emit('commit', keyval, commitString || '');
+
+ this.emit('released');
+ this._hideSubkeys();
+ this._pressed = false;
+ }
+
+ cancel() {
+ if (this._pressTimeoutId != 0) {
+ GLib.source_remove(this._pressTimeoutId);
+ this._pressTimeoutId = 0;
+ }
+ this._touchPressSlot = null;
+ 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;
+ const targetActor = global.stage.get_event_actor(event);
+
+ if (targetActor === this._boxPointer.bin ||
+ this._boxPointer.bin.contains(targetActor))
+ 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);
+ global.stage.connectObject(
+ 'captured-event', this._onCapturedEvent.bind(this), this);
+ this.keyButton.connectObject('notify::mapped', () => {
+ if (!this.keyButton.is_mapped())
+ this._hideSubkeys();
+ }, this);
+ }
+
+ _hideSubkeys() {
+ if (this._boxPointer)
+ this._boxPointer.close(BoxPointer.PopupAnimation.FULL);
+ global.stage.disconnectObject(this);
+ this.keyButton.disconnectObject(this);
+ this._capturedPress = false;
+ }
+
+ _makeKey(commitString, label, icon) {
+ let button = new St.Button({
+ style_class: 'keyboard-key',
+ x_expand: true,
+ });
+
+ if (icon) {
+ const child = new St.Icon({icon_name: icon});
+ button.set_child(child);
+ this._icon = child;
+ } else if (label) {
+ button.set_label(label);
+ } else if (commitString) {
+ const str = GLib.markup_escape_text(commitString, -1);
+ button.set_label(str);
+ }
+
+ button.keyWidth = 1;
+ button.connect('button-press-event', () => {
+ this._press(button, commitString);
+ button.add_style_pseudo_class('active');
+ return Clutter.EVENT_STOP;
+ });
+ button.connect('button-release-event', () => {
+ this._release(button, commitString);
+ button.remove_style_pseudo_class('active');
+ return Clutter.EVENT_STOP;
+ });
+ 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;
+
+ const slot = event.get_event_sequence().get_slot();
+
+ if (!this._touchPressSlot &&
+ event.type() == Clutter.EventType.TOUCH_BEGIN) {
+ this._touchPressSlot = slot;
+ this._press(button, commitString);
+ button.add_style_pseudo_class('active');
+ } else if (event.type() === Clutter.EventType.TOUCH_END) {
+ if (!this._touchPressSlot ||
+ this._touchPressSlot === slot) {
+ this._release(button, commitString);
+ button.remove_style_pseudo_class('active');
+ }
+
+ if (this._touchPressSlot === slot)
+ this._touchPressSlot = null;
+ }
+ return Clutter.EVENT_STOP;
+ });
+
+ 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 (latched)
+ this.keyButton.add_style_pseudo_class('latched');
+ else
+ this.keyButton.remove_style_pseudo_class('latched');
+ }
+});
+
+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) {
+ const file = Gio.File.new_for_uri(
+ `resource:///org/gnome/shell/osk-layouts/${groupName}.json`);
+ let [success_, contents] = file.load_contents(null);
+
+ const decoder = new TextDecoder();
+ return JSON.parse(decoder.decode(contents));
+ }
+
+ getLevels() {
+ return this._model.levels;
+ }
+
+ getKeysForLevel(levelName) {
+ return this._model.levels.find(level => level == levelName);
+ }
+};
+
+var FocusTracker = class extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ this._rect = null;
+
+ global.display.connectObject(
+ 'notify::focus-window', () => {
+ this._setCurrentWindow(global.display.focus_window);
+ this.emit('window-changed', this._currentWindow);
+ },
+ 'grab-op-begin', (display, window, op) => {
+ if (window === this._currentWindow &&
+ (op === Meta.GrabOp.MOVING || op === Meta.GrabOp.KEYBOARD_MOVING))
+ this.emit('window-grabbed');
+ }, this);
+
+ this._setCurrentWindow(global.display.focus_window);
+
+ /* Valid for wayland clients */
+ Main.inputMethod.connectObject('cursor-location-changed',
+ (o, rect) => this._setCurrentRect(rect), this);
+
+ this._ibusManager = IBusManager.getIBusManager();
+ this._ibusManager.connectObject(
+ 'set-cursor-location', (manager, rect) => {
+ /* Valid for X11 clients only */
+ if (Main.inputMethod.currentFocus)
+ return;
+
+ const grapheneRect = new Graphene.Rect();
+ grapheneRect.init(rect.x, rect.y, rect.width, rect.height);
+
+ this._setCurrentRect(grapheneRect);
+ },
+ 'focus-in', () => this.emit('focus-changed', true),
+ 'focus-out', () => this.emit('focus-changed', false),
+ this);
+ }
+
+ destroy() {
+ this._currentWindow?.disconnectObject(this);
+ global.display.disconnectObject(this);
+ Main.inputMethod.disconnectObject(this);
+ this._ibusManager.disconnectObject(this);
+ }
+
+ get currentWindow() {
+ return this._currentWindow;
+ }
+
+ _setCurrentWindow(window) {
+ this._currentWindow?.disconnectObject(this);
+
+ this._currentWindow = window;
+
+ if (this._currentWindow) {
+ this._currentWindow.connectObject(
+ 'position-changed', () => this.emit('window-moved'), this);
+ }
+ }
+
+ _setCurrentRect(rect) {
+ // Some clients give us 0-sized rects, in that case set size to 1
+ if (rect.size.width <= 0)
+ rect.size.width = 1;
+ if (rect.size.height <= 0)
+ rect.size.height = 1;
+
+ if (this._currentWindow) {
+ const frameRect = this._currentWindow.get_frame_rect();
+ const grapheneFrameRect = new Graphene.Rect();
+ grapheneFrameRect.init(frameRect.x, frameRect.y,
+ frameRect.width, frameRect.height);
+
+ const rectInsideFrameRect = grapheneFrameRect.intersection(rect)[0];
+ if (!rectInsideFrameRect)
+ return;
+ }
+
+ if (this._rect && this._rect.equal(rect))
+ return;
+
+ this._rect = rect;
+ this.emit('position-changed');
+ }
+
+ getCurrentRect() {
+ const rect = {
+ x: this._rect.origin.x,
+ y: this._rect.origin.y,
+ width: this._rect.size.width,
+ height: this._rect.size.height,
+ };
+
+ return rect;
+ }
+};
+
+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) {
+ super._init({
+ layout_manager: new Clutter.BinLayout(),
+ reactive: true,
+ clip_to_allocation: true,
+ y_expand: true,
+ });
+ this._sections = sections;
+
+ this._pages = [];
+ this._panel = null;
+ this._curPage = null;
+ this._followingPage = null;
+ this._followingPanel = null;
+ this._currentKey = null;
+ this._delta = 0;
+ this._width = null;
+
+ const swipeTracker = new SwipeTracker.SwipeTracker(this,
+ Clutter.Orientation.HORIZONTAL,
+ Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW,
+ {allowDrag: true, allowScroll: true});
+ swipeTracker.connect('begin', this._onSwipeBegin.bind(this));
+ swipeTracker.connect('update', this._onSwipeUpdate.bind(this));
+ swipeTracker.connect('end', this._onSwipeEnd.bind(this));
+ this._swipeTracker = swipeTracker;
+
+ this.connect('destroy', () => this._onDestroy());
+
+ this.bind_property(
+ 'visible', this._swipeTracker, 'enabled',
+ GObject.BindingFlags.DEFAULT);
+ }
+
+ _onDestroy() {
+ if (this._swipeTracker) {
+ this._swipeTracker.destroy();
+ delete this._swipeTracker;
+ }
+ }
+
+ get delta() {
+ return this._delta;
+ }
+
+ set delta(value) {
+ if (this._delta == value)
+ return;
+
+ this._delta = value;
+ this.notify('delta');
+
+ 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.add_child(this._followingPanel);
+ }
+
+ this._followingPage = followingPage;
+ }
+
+ const multiplier = this.text_direction === Clutter.TextDirection.RTL
+ ? -1 : 1;
+
+ this._panel.translation_x = value * multiplier;
+ if (this._followingPanel) {
+ const translation = value < 0
+ ? this._width + EMOJI_PAGE_SEPARATION
+ : -this._width - EMOJI_PAGE_SEPARATION;
+
+ this._followingPanel.translation_x =
+ (value * multiplier) + (translation * multiplier);
+ }
+ }
+
+ _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)
+ return this._nextPage(this._curPage);
+ else
+ return this._prevPage(this._curPage);
+ }
+
+ _onSwipeUpdate(tracker, progress) {
+ this.delta = -progress * this._width;
+
+ if (this._currentKey != null) {
+ this._currentKey.cancel();
+ this._currentKey = null;
+ }
+
+ return false;
+ }
+
+ _onSwipeBegin(tracker) {
+ this._width = this.width;
+ const points = [-1, 0, 1];
+ tracker.confirmSwipe(this._width, points, 0, 0);
+ }
+
+ _onSwipeEnd(tracker, duration, endProgress) {
+ this.remove_all_transitions();
+ if (endProgress === 0) {
+ this.ease_property('delta', 0, {duration});
+ } else {
+ const value = endProgress < 0
+ ? this._width + EMOJI_PAGE_SEPARATION
+ : -this._width - EMOJI_PAGE_SEPARATION;
+ this.ease_property('delta', value, {
+ duration,
+ onComplete: () => {
+ this.setCurrentPage(this.getFollowingPage());
+ },
+ });
+ }
+ }
+
+ _initPagingInfo() {
+ this._pages = [];
+
+ 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) {
+ const gridLayout = new Clutter.GridLayout({
+ orientation: Clutter.Orientation.HORIZONTAL,
+ column_homogeneous: true,
+ row_homogeneous: true,
+ });
+ const 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({commitString: modelKey.label}, modelKey.variants);
+
+ key.keyButton.set_button_mask(0);
+
+ key.connect('pressed', () => {
+ this._currentKey = key;
+ });
+ key.connect('commit', (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;
+ }
+ }
+ }
+
+ setRatio(nCols, nRows) {
+ this._nCols = nCols;
+ this._nRows = nRows;
+ this._initPagingInfo();
+ }
+});
+
+var EmojiSelection = GObject.registerClass({
+ Signals: {
+ 'emoji-selected': { param_types: [GObject.TYPE_STRING] },
+ 'close-request': {},
+ 'toggle': {},
+ },
+}, class EmojiSelection extends St.Widget {
+ _init() {
+ const gridLayout = new Clutter.GridLayout({
+ orientation: Clutter.Orientation.HORIZONTAL,
+ column_homogeneous: true,
+ row_homogeneous: true,
+ });
+ super._init({
+ layout_manager: gridLayout,
+ style_class: 'emoji-panel',
+ x_expand: true,
+ y_expand: true,
+ text_direction: global.stage.text_direction,
+ });
+
+ 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._gridLayout = gridLayout;
+ this._populateSections();
+
+ this._pagerBox = new Clutter.Actor({
+ layout_manager: new Clutter.BoxLayout({
+ orientation: Clutter.Orientation.VERTICAL,
+ }),
+ });
+
+ this._emojiPager = new EmojiPager(this._sections);
+ 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._pagerBox.add_child(this._emojiPager);
+
+ this._pageIndicator = new PageIndicators.PageIndicators(
+ Clutter.Orientation.HORIZONTAL);
+ this._pageIndicator.y_expand = false;
+ this._pageIndicator.y_align = Clutter.ActorAlign.START;
+ this._pagerBox.add_child(this._pageIndicator);
+ this._pageIndicator.setReactive(false);
+
+ this._emojiPager.connect('notify::delta', () => {
+ this._updateIndicatorPosition();
+ });
+
+ this._bottomRow = this._createBottomRow();
+
+ 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);
+
+ let emoji = JSON.parse(new TextDecoder().decode(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({label: '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({label: section.label}, []);
+ key.connect('released', () => this._emojiPager.setCurrentSection(section, 0));
+ row.appendKey(key);
+
+ section.button = key;
+ }
+
+ key = new Key({iconName: '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();
+
+ const actor = new AspectContainer({
+ layout_manager: new Clutter.BinLayout(),
+ x_expand: true,
+ y_expand: true,
+ });
+ actor.add_child(row);
+
+ return actor;
+ }
+
+ setRatio(nCols, nRows) {
+ this._emojiPager.setRatio(Math.floor(nCols), Math.floor(nRows) - 1);
+ this._bottomRow.setRatio(nCols, 1);
+
+ // (Re)attach actors so the emoji panel fits the ratio and
+ // the bottom row is ensured to take 1 row high.
+ if (this._pagerBox.get_parent())
+ this.remove_child(this._pagerBox);
+ if (this._bottomRow.get_parent())
+ this.remove_child(this._bottomRow);
+
+ this._gridLayout.attach(this._pagerBox, 0, 0, 1, Math.floor(nRows) - 1);
+ this._gridLayout.attach(this._bottomRow, 0, Math.floor(nRows) - 1, 1, 1);
+ }
+});
+
+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,
+ });
+
+ const 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({
+ label: cur.label,
+ iconName: 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 extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ 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();
+ });
+
+ const mode = Shell.ActionMode.ALL & ~Shell.ActionMode.LOCK_SCREEN;
+ const bottomDragAction = new EdgeDragAction.EdgeDragAction(St.Side.BOTTOM, mode);
+ bottomDragAction.connect('activated', () => {
+ if (this._keyboard)
+ this._keyboard.gestureActivate(Main.layoutManager.bottomIndex);
+ });
+ bottomDragAction.connect('progress', (_action, progress) => {
+ if (this._keyboard)
+ this._keyboard.gestureProgress(progress);
+ });
+ bottomDragAction.connect('gesture-cancel', () => {
+ if (this._keyboard)
+ this._keyboard.gestureCancel();
+ });
+ global.stage.add_action_full('osk', Clutter.EventPhase.CAPTURE, bottomDragAction);
+ this._bottomDragAction = bottomDragAction;
+
+ 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();
+ this._keyboard.connect('visibility-changed', () => {
+ this.emit('visibility-changed');
+ this._bottomDragAction.enabled = !this._keyboard.visible;
+ });
+ } else if (!enabled && this._keyboard) {
+ this._keyboard.setCursorLocation(null);
+ this._keyboard.destroy();
+ this._keyboard = null;
+ this._bottomDragAction.enabled = true;
+ }
+ }
+
+ get keyboardActor() {
+ return this._keyboard;
+ }
+
+ get visible() {
+ return this._keyboard && this._keyboard.visible;
+ }
+
+ open(monitor) {
+ Main.layoutManager.keyboardIndex = monitor;
+
+ if (this._keyboard)
+ this._keyboard.open();
+ }
+
+ 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();
+ }
+
+ setSuggestionsVisible(visible) {
+ this._keyboard?.setSuggestionsVisible(visible);
+ }
+
+ maybeHandleEvent(event) {
+ if (!this._keyboard)
+ return false;
+
+ const actor = global.stage.get_event_actor(event);
+
+ if (Main.layoutManager.keyboardBox.contains(actor) ||
+ !!actor._extendedKeys || !!actor.extendedKey) {
+ actor.event(event, true);
+ actor.event(event, false);
+ return true;
+ }
+
+ return false;
+ }
+};
+
+var Keyboard = GObject.registerClass({
+ Signals: {
+ 'visibility-changed': {},
+ },
+}, class Keyboard extends St.BoxLayout {
+ _init() {
+ super._init({
+ name: 'keyboard',
+ reactive: true,
+ // Keyboard models are defined in LTR, we must override
+ // the locale setting in order to avoid flipping the
+ // keyboard on RTL locales.
+ text_direction: Clutter.TextDirection.LTR,
+ vertical: true,
+ });
+ this._focusInExtendedKeys = false;
+ this._emojiActive = false;
+
+ this._languagePopup = null;
+ this._focusWindow = null;
+ this._focusWindowStartY = null;
+
+ this._latched = false; // current level is latched
+ this._modifiers = new Set();
+ this._modifierKeys = new Map();
+
+ this._suggestions = null;
+ this._emojiKeyVisible = Meta.is_wayland_compositor();
+
+ this._focusTracker = new FocusTracker();
+ this._focusTracker.connectObject(
+ 'position-changed', this._onFocusPositionChanged.bind(this),
+ 'window-grabbed', this._onFocusWindowMoving.bind(this), this);
+
+ this._windowMovedId = this._focusTracker.connect('window-moved',
+ this._onFocusWindowMoving.bind(this));
+
+ // Valid only for X11
+ if (!Meta.is_wayland_compositor()) {
+ this._focusTracker.connectObject('focus-changed', (_tracker, focused) => {
+ if (focused)
+ this.open(Main.layoutManager.focusIndex);
+ else
+ this.close();
+ }, this);
+ }
+
+ this._showIdleId = 0;
+
+ this._keyboardVisible = false;
+ this._keyboardRequested = false;
+ this._keyboardRestingId = 0;
+
+ Main.layoutManager.connectObject('monitors-changed',
+ this._relayout.bind(this), this);
+
+ this._setupKeyboard();
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ 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() {
+ if (this._windowMovedId) {
+ this._focusTracker.disconnect(this._windowMovedId);
+ delete this._windowMovedId;
+ }
+
+ if (this._focusTracker) {
+ this._focusTracker.destroy();
+ delete this._focusTracker;
+ }
+
+ this._clearShowIdle();
+
+ this._keyboardController.destroy();
+
+ Main.layoutManager.untrackChrome(this);
+ Main.layoutManager.keyboardBox.remove_actor(this);
+ Main.layoutManager.keyboardBox.hide();
+
+ if (this._languagePopup) {
+ this._languagePopup.destroy();
+ this._languagePopup = null;
+ }
+
+ IBusManager.getIBusManager().setCompletionEnabled(false, () => Main.inputMethod.update());
+ }
+
+ _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._emojiSelection.hide();
+ this._aspectContainer.add_child(this._emojiSelection);
+
+ this._keypad = new Keypad();
+ this._keypad.connectObject('keyval', (_keypad, keyval) => {
+ this._keyboardController.keyvalPress(keyval);
+ this._keyboardController.keyvalRelease(keyval);
+ }, this);
+ this._aspectContainer.add_child(this._keypad);
+ this._keypad.hide();
+ this._keypadVisible = false;
+
+ this._ensureKeysForGroup(this._keyboardController.getCurrentGroup());
+ this._setActiveLayer(0);
+
+ Main.inputMethod.connectObject(
+ 'terminal-mode-changed', this._onTerminalModeChanged.bind(this),
+ this);
+
+ this._keyboardController.connectObject(
+ 'active-group', this._onGroupChanged.bind(this),
+ 'groups-changed', this._onKeyboardGroupsChanged.bind(this),
+ 'panel-state', this._onKeyboardStateChanged.bind(this),
+ 'keypad-visible', this._onKeypadVisible.bind(this),
+ this);
+ global.stage.connectObject('notify::key-focus',
+ this._onKeyFocusChanged.bind(this), this);
+
+ if (Meta.is_wayland_compositor()) {
+ this._keyboardController.connectObject('emoji-visible',
+ this._onEmojiKeyVisible.bind(this), 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 = [];
+ layout.mode = currentLevel.mode;
+
+ this._loadRows(currentLevel, level, levels.length, layout);
+ layers[level] = layout;
+ this._aspectContainer.add_child(layout);
+ layout.layoutButtons();
+
+ 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) {
+ const key = keys[i];
+ const {strings} = key;
+ const commitString = strings?.shift();
+
+ let button = new Key({
+ commitString,
+ label: key.label,
+ iconName: key.iconName,
+ keyval: key.keyval,
+ }, strings);
+
+ if (key.width !== null)
+ button.setWidth(key.width);
+
+ if (key.action !== 'modifier') {
+ button.connect('commit', (_actor, keyval, str) => {
+ this._commitAction(keyval, str).then(() => {
+ if (layout.mode === 'latched' && !this._latched)
+ this._setActiveLayer(0);
+ });
+ });
+ }
+
+ if (key.action !== null) {
+ button.connect('released', () => {
+ if (key.action === 'hide') {
+ this.close();
+ } else if (key.action === 'languageMenu') {
+ this._popupLanguageMenu(button);
+ } else if (key.action === 'emoji') {
+ this._toggleEmoji();
+ } else if (key.action === 'modifier') {
+ this._toggleModifier(key.keyval);
+ } else if (key.action === 'delete') {
+ this._toggleDelete(true);
+ this._toggleDelete(false);
+ } else if (!this._longPressed && key.action === 'levelSwitch') {
+ this._setActiveLayer(key.level);
+ this._setLatched(
+ key.level === 1 &&
+ key.iconName === 'keyboard-caps-lock-symbolic');
+ }
+
+ this._longPressed = false;
+ });
+ }
+
+ if (key.action === 'levelSwitch' &&
+ key.iconName === 'keyboard-shift-symbolic') {
+ layout.shiftKeys.push(button);
+ if (key.level === 1) {
+ button.connect('long-press', () => {
+ this._setActiveLayer(key.level);
+ this._setLatched(true);
+ this._longPressed = true;
+ });
+ }
+ }
+
+ if (key.action === 'delete') {
+ button.connect('long-press',
+ () => this._toggleDelete(true));
+ }
+
+ if (key.action === 'modifier') {
+ let modifierKeys = this._modifierKeys[key.keyval] || [];
+ modifierKeys.push(button);
+ this._modifierKeys[key.keyval] = modifierKeys;
+ }
+
+ if (key.action || key.keyval)
+ button.keyButton.add_style_class_name('default-key');
+
+ layout.appendKey(button, button.keyButton.keyWidth);
+ }
+ }
+
+ async _commitAction(keyval, str) {
+ if (this._modifiers.size === 0 && str !== '' &&
+ keyval && this._oskCompletionEnabled) {
+ if (await Main.inputMethod.handleVirtualKey(keyval))
+ return;
+ }
+
+ if (str === '' || !Main.inputMethod.currentFocus ||
+ (keyval && this._oskCompletionEnabled) ||
+ this._modifiers.size > 0 ||
+ !this._keyboardController.commitString(str, true)) {
+ if (keyval !== 0) {
+ this._forwardModifiers(this._modifiers, Clutter.EventType.KEY_PRESS);
+ this._keyboardController.keyvalPress(keyval);
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT, KEY_RELEASE_TIMEOUT, () => {
+ this._keyboardController.keyvalRelease(keyval);
+ this._forwardModifiers(this._modifiers, Clutter.EventType.KEY_RELEASE);
+ this._disableAllModifiers();
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+ }
+ }
+
+ _previousWordPosition(text, cursor) {
+ /* Skip word prior to cursor */
+ let pos = Math.max(0, text.slice(0, cursor).search(/\s+\S+\s*$/));
+ if (pos < 0)
+ return 0;
+
+ /* Skip contiguous spaces */
+ for (; pos >= 0; pos--) {
+ if (text.charAt(pos) !== ' ')
+ return GLib.utf8_strlen(text.slice(0, pos + 1), -1);
+ }
+
+ return 0;
+ }
+
+ _toggleDelete(enabled) {
+ if (this._deleteEnabled === enabled)
+ return;
+
+ this._deleteEnabled = enabled;
+ this._timesDeleted = 0;
+
+ if (!Main.inputMethod.currentFocus ||
+ Main.inputMethod.hasPreedit() ||
+ Main.inputMethod.terminalMode) {
+ /* If there is no IM focus or are in the middle of preedit,
+ * fallback to keypresses */
+ if (enabled)
+ this._keyboardController.keyvalPress(Clutter.KEY_BackSpace);
+ else
+ this._keyboardController.keyvalRelease(Clutter.KEY_BackSpace);
+ return;
+ }
+
+ if (enabled) {
+ let func = (text, cursor) => {
+ if (cursor === 0)
+ return;
+
+ let encoder = new TextEncoder();
+ let decoder = new TextDecoder();
+
+ /* Find cursor/anchor position in characters */
+ const cursorIdx = GLib.utf8_strlen(decoder.decode(encoder.encode(
+ text).slice(0, cursor)), -1);
+ const anchorIdx = this._timesDeleted < BACKSPACE_WORD_DELETE_THRESHOLD
+ ? cursorIdx - 1
+ : this._previousWordPosition(text, cursor);
+ /* Now get offset from cursor */
+ const offset = anchorIdx - cursorIdx;
+
+ this._timesDeleted++;
+ Main.inputMethod.delete_surrounding(offset, Math.abs(offset));
+ };
+
+ this._surroundingUpdateId = Main.inputMethod.connect(
+ 'surrounding-text-set', () => {
+ let [text, cursor] = Main.inputMethod.getSurroundingText();
+ if (this._timesDeleted === 0) {
+ func(text, cursor);
+ } else {
+ if (this._surroundingUpdateTimeoutId > 0) {
+ GLib.source_remove(this._surroundingUpdateTimeoutId);
+ this._surroundingUpdateTimeoutId = 0;
+ }
+ this._surroundingUpdateTimeoutId =
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT, KEY_RELEASE_TIMEOUT, () => {
+ func(text, cursor);
+ this._surroundingUpdateTimeoutId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+ });
+
+ let [text, cursor] = Main.inputMethod.getSurroundingText();
+ if (text)
+ func(text, cursor);
+ else
+ Main.inputMethod.request_surrounding();
+ } else {
+ if (this._surroundingUpdateId > 0) {
+ Main.inputMethod.disconnect(this._surroundingUpdateId);
+ this._surroundingUpdateId = 0;
+ }
+ if (this._surroundingUpdateTimeoutId > 0) {
+ GLib.source_remove(this._surroundingUpdateTimeoutId);
+ this._surroundingUpdateTimeoutId = 0;
+ }
+ }
+ }
+
+ _setLatched(latched) {
+ this._latched = latched;
+ this._setCurrentLevelLatched(this._currentPage, this._latched);
+ }
+
+ _setModifierEnabled(keyval, enabled) {
+ if (enabled)
+ this._modifiers.add(keyval);
+ else
+ this._modifiers.delete(keyval);
+
+ for (const key of this._modifierKeys[keyval])
+ key.setLatched(enabled);
+ }
+
+ _toggleModifier(keyval) {
+ const isActive = this._modifiers.has(keyval);
+ this._setModifierEnabled(keyval, !isActive);
+ }
+
+ _forwardModifiers(modifiers, type) {
+ for (const keyval of modifiers) {
+ if (type === Clutter.EventType.KEY_PRESS)
+ this._keyboardController.keyvalPress(keyval);
+ else if (type === Clutter.EventType.KEY_RELEASE)
+ this._keyboardController.keyvalRelease(keyval);
+ }
+ }
+
+ _disableAllModifiers() {
+ for (const keyval of this._modifiers)
+ this._setModifierEnabled(keyval, false);
+ }
+
+ _popupLanguageMenu(keyActor) {
+ if (this._languagePopup)
+ this._languagePopup.destroy();
+
+ this._languagePopup = new LanguageSelectionPopup(keyActor);
+ Main.layoutManager.addTopChrome(this._languagePopup.actor);
+ this._languagePopup.open(true);
+ }
+
+ _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);
+ key.iconName = latched
+ ? 'keyboard-caps-lock-symbolic' : 'keyboard-shift-symbolic';
+ }
+ }
+
+ _loadRows(model, level, numLevels, layout) {
+ let rows = model.rows;
+ for (let i = 0; i < rows.length; ++i) {
+ layout.appendRow();
+ this._addRowKeys(rows[i], layout);
+ }
+ }
+
+ _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;
+
+ this.width = monitor.width;
+
+ if (monitor.width > monitor.height)
+ this.height = monitor.height / 3;
+ else
+ this.height = monitor.height / 4;
+ }
+
+ _updateKeys() {
+ this._ensureKeysForGroup(this._keyboardController.getCurrentGroup());
+ this._setActiveLayer(0);
+ }
+
+ _onGroupChanged() {
+ this._updateKeys();
+ }
+
+ _onTerminalModeChanged() {
+ this._updateKeys();
+ }
+
+ _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._disableAllModifiers();
+ this._currentPage = currentPage;
+ this._currentPage._destroyID = this._currentPage.connect('destroy', () => {
+ this._currentPage = null;
+ });
+ this._updateCurrentPageVisible();
+ this._aspectContainer.setRatio(...this._currentPage.getRatio());
+ this._emojiSelection.setRatio(...this._currentPage.getRatio());
+ }
+
+ _clearKeyboardRestTimer() {
+ if (!this._keyboardRestingId)
+ return;
+ GLib.source_remove(this._keyboardRestingId);
+ this._keyboardRestingId = 0;
+ }
+
+ open(immediate = false) {
+ this._clearShowIdle();
+ this._keyboardRequested = true;
+
+ if (this._keyboardVisible) {
+ this._relayout();
+ return;
+ }
+
+ this._oskCompletionEnabled =
+ IBusManager.getIBusManager().setCompletionEnabled(true, () => Main.inputMethod.update());
+ this._clearKeyboardRestTimer();
+
+ if (immediate) {
+ this._open();
+ return;
+ }
+
+ this._keyboardRestingId = GLib.timeout_add(GLib.PRIORITY_DEFAULT,
+ KEYBOARD_REST_TIME,
+ () => {
+ this._clearKeyboardRestTimer();
+ this._open();
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._keyboardRestingId, '[gnome-shell] this._clearKeyboardRestTimer');
+ }
+
+ _open() {
+ if (!this._keyboardRequested)
+ return;
+
+ this._relayout();
+ this._animateShow();
+
+ this._setEmojiActive(false);
+ }
+
+ close(immediate = false) {
+ this._clearShowIdle();
+ this._keyboardRequested = false;
+
+ if (!this._keyboardVisible)
+ return;
+
+ IBusManager.getIBusManager().setCompletionEnabled(false, () => Main.inputMethod.update());
+ this._oskCompletionEnabled = false;
+ this._clearKeyboardRestTimer();
+
+ if (immediate) {
+ this._close();
+ return;
+ }
+
+ 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;
+
+ this._animateHide();
+ this.setCursorLocation(null);
+ this._disableAllModifiers();
+ }
+
+ _animateShow() {
+ if (this._focusWindow)
+ this._animateWindow(this._focusWindow, true);
+
+ Main.layoutManager.keyboardBox.show();
+ this.ease({
+ translation_y: -this.height,
+ opacity: 255,
+ duration: KEYBOARD_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ this._animateShowComplete();
+ },
+ });
+ this._keyboardVisible = true;
+ this.emit('visibility-changed');
+ }
+
+ _animateShowComplete() {
+ let keyboardBox = Main.layoutManager.keyboardBox;
+ this._keyboardHeightNotifyId = keyboardBox.connect('notify::height', () => {
+ this.translation_y = -this.height;
+ });
+
+ // Toggle visibility so the keyboardBox can update its chrome region.
+ if (!Meta.is_wayland_compositor()) {
+ keyboardBox.hide();
+ keyboardBox.show();
+ }
+ }
+
+ _animateHide() {
+ if (this._focusWindow)
+ this._animateWindow(this._focusWindow, false);
+
+ if (this._keyboardHeightNotifyId) {
+ Main.layoutManager.keyboardBox.disconnect(this._keyboardHeightNotifyId);
+ this._keyboardHeightNotifyId = 0;
+ }
+ this.ease({
+ translation_y: 0,
+ opacity: 0,
+ duration: KEYBOARD_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_IN_QUAD,
+ onComplete: () => {
+ this._animateHideComplete();
+ },
+ });
+
+ this._keyboardVisible = false;
+ this.emit('visibility-changed');
+ }
+
+ _animateHideComplete() {
+ Main.layoutManager.keyboardBox.hide();
+ }
+
+ gestureProgress(delta) {
+ this._gestureInProgress = true;
+ Main.layoutManager.keyboardBox.show();
+ let progress = Math.min(delta, this.height) / this.height;
+ this.translation_y = -this.height * progress;
+ this.opacity = 255 * progress;
+ const windowActor = this._focusWindow?.get_compositor_private();
+ if (windowActor)
+ windowActor.y = this._focusWindowStartY - (this.height * progress);
+ }
+
+ gestureActivate() {
+ this.open(true);
+ this._gestureInProgress = false;
+ }
+
+ gestureCancel() {
+ if (this._gestureInProgress)
+ this._animateHide();
+ this._gestureInProgress = false;
+ }
+
+ resetSuggestions() {
+ if (this._suggestions)
+ this._suggestions.clear();
+ }
+
+ setSuggestionsVisible(visible) {
+ this._suggestions?.setVisible(visible);
+ }
+
+ 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, finalY) {
+ // Synchronize window positions again.
+ const frameRect = window.get_frame_rect();
+ const bufferRect = window.get_buffer_rect();
+
+ finalY += frameRect.y - bufferRect.y;
+
+ frameRect.y = finalY;
+
+ this._focusTracker.disconnect(this._windowMovedId);
+ window.move_frame(true, frameRect.x, frameRect.y);
+ this._windowMovedId = this._focusTracker.connect('window-moved',
+ this._onFocusWindowMoving.bind(this));
+ }
+
+ _animateWindow(window, show) {
+ let windowActor = window.get_compositor_private();
+ if (!windowActor)
+ return;
+
+ const finalY = show
+ ? this._focusWindowStartY - Main.layoutManager.keyboardBox.height
+ : this._focusWindowStartY;
+
+ windowActor.ease({
+ y: finalY,
+ duration: KEYBOARD_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onStopped: () => {
+ windowActor.y = finalY;
+ this._windowSlideAnimationComplete(window, finalY);
+ },
+ });
+ }
+
+ _onFocusWindowMoving() {
+ if (this._focusTracker.currentWindow === this._focusWindow) {
+ // Don't use _setFocusWindow() here because that would move the
+ // window while the user has grabbed it. Instead we simply "let go"
+ // of the window.
+ this._focusWindow = null;
+ this._focusWindowStartY = null;
+ }
+
+ this.close(true);
+ }
+
+ _setFocusWindow(window) {
+ if (this._focusWindow === window)
+ return;
+
+ if (this._keyboardVisible && this._focusWindow)
+ this._animateWindow(this._focusWindow, false);
+
+ const windowActor = window?.get_compositor_private();
+ windowActor?.remove_transition('y');
+ this._focusWindowStartY = windowActor ? windowActor.y : null;
+
+ if (this._keyboardVisible && window)
+ this._animateWindow(window, true);
+
+ this._focusWindow = window;
+ }
+
+ setCursorLocation(window, x, y, w, h) {
+ let monitor = Main.layoutManager.keyboardMonitor;
+
+ if (window && monitor) {
+ const keyboardHeight = Main.layoutManager.keyboardBox.height;
+ const keyboardY1 = (monitor.y + monitor.height) - keyboardHeight;
+
+ if (this._focusWindow === window) {
+ if (y + h + keyboardHeight < keyboardY1)
+ this._setFocusWindow(null);
+
+ return;
+ }
+
+ if (y + h >= keyboardY1)
+ this._setFocusWindow(window);
+ else
+ this._setFocusWindow(null);
+ } else {
+ this._setFocusWindow(null);
+ }
+ }
+});
+
+var KeyboardController = class extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ let seat = Clutter.get_default_backend().get_default_seat();
+ this._virtualDevice = seat.create_virtual_device(Clutter.InputDeviceType.KEYBOARD_DEVICE);
+
+ this._inputSourceManager = InputSourceManager.getInputSourceManager();
+ this._inputSourceManager.connectObject(
+ 'current-source-changed', this._onSourceChanged.bind(this),
+ 'sources-changed', this._onSourcesModified.bind(this), this);
+ this._currentSource = this._inputSourceManager.currentSource;
+
+ Main.inputMethod.connectObject(
+ 'notify::content-purpose', this._onContentPurposeHintsChanged.bind(this),
+ 'notify::content-hints', this._onContentPurposeHintsChanged.bind(this),
+ 'input-panel-state', (o, state) => this.emit('panel-state', state), this);
+ }
+
+ destroy() {
+ this._inputSourceManager.disconnectObject(this);
+ Main.inputMethod.disconnectObject(this);
+
+ // 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() {
+ if (Main.inputMethod.terminalMode)
+ return 'us-extended';
+
+ // Special case for Korean, if Hangul mode is disabled, use the 'us' keymap
+ if (this._currentSource.id === 'hangul') {
+ const inputSourceManager = InputSourceManager.getInputSourceManager();
+ const currentSource = inputSourceManager.currentSource;
+ let prop;
+ for (let i = 0; (prop = currentSource.properties.get(i)) !== null; ++i) {
+ if (prop.get_key() === 'InputMode' &&
+ prop.get_prop_type() === IBus.PropType.TOGGLE &&
+ prop.get_state() !== IBus.PropState.CHECKED)
+ return 'us';
+ }
+ }
+
+ 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() * 1000,
+ keyval, Clutter.KeyState.PRESSED);
+ }
+
+ keyvalRelease(keyval) {
+ this._virtualDevice.notify_keyval(Clutter.get_current_event_time() * 1000,
+ keyval, Clutter.KeyState.RELEASED);
+ }
+};
diff --git a/js/ui/layout.js b/js/ui/layout.js
new file mode 100644
index 0000000..69bf148
--- /dev/null
+++ b/js/ui/layout.js
@@ -0,0 +1,1451 @@
+// -*- 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.misc.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 BACKGROUND_FADE_ANIMATION_TIME = 1000;
+
+var HOT_CORNER_PRESSURE_THRESHOLD = 100; // pixels
+var HOT_CORNER_PRESSURE_TIMEOUT = 1000; // ms
+
+const SCREEN_TRANSITION_DELAY = 250; // ms
+const SCREEN_TRANSITION_DURATION = 500; // 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');
+ }
+
+ get workArea() {
+ return this._workArea;
+ }
+
+ set workArea(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': {},
+ },
+}, 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,
+ constraints: new Clutter.BindConstraint({
+ source: this.uiGroup,
+ coordinate: Clutter.BindCoordinate.ALL,
+ }),
+ });
+ this.addChrome(this.overviewGroup);
+
+ this.screenShieldGroup = new St.Widget({
+ name: 'screenShieldGroup',
+ visible: false,
+ clip_to_allocation: true,
+ layout_manager: new Clutter.BinLayout(),
+ constraints: new Clutter.BindConstraint({
+ source: this.uiGroup,
+ coordinate: Clutter.BindCoordinate.ALL,
+ }),
+ });
+ 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;
+
+ this.screenshotUIGroup = new St.Widget({
+ name: 'screenshotUIGroup',
+ layout_manager: new Clutter.BinLayout(),
+ });
+ this.addTopChrome(this.screenshotUIGroup);
+
+ // 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.screenTransition = new ScreenTransition();
+ this.uiGroup.add_child(this.screenTransition);
+ this.screenTransition.add_constraint(new Clutter.BindConstraint({
+ source: this.uiGroup,
+ coordinate: Clutter.BindCoordinate.ALL,
+ }));
+ }
+
+ // 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.screenTransition.hide();
+
+ this._inOverview = true;
+ this._updateVisibility();
+ }
+
+ hideOverview() {
+ this.overviewGroup.hide();
+ this.screenTransition.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) {
+ const 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() {
+ 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);
+
+ const 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, () => {
+ if (this.primaryMonitor) {
+ this._systemBackground.show();
+ global.stage.show();
+ this._prepareStartupAnimation();
+ return GLib.SOURCE_REMOVE;
+ } else {
+ return GLib.SOURCE_CONTINUE;
+ }
+ });
+ 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);
+
+ // Force an update of the regions before we scale the UI group to
+ // get the correct allocation for the struts.
+ // Do this even when we don't animate on restart, so that maximized
+ // windows restore to the right size.
+ this._updateRegions();
+
+ if (Meta.is_restart()) {
+ // On restart, we don't do an animation.
+ } else if (Main.sessionMode.isGreeter) {
+ this.panelBox.translation_y = -this.panelBox.height;
+ } else {
+ this.keyboardBox.hide();
+
+ let monitor = this.primaryMonitor;
+
+ if (!Main.sessionMode.hasOverview) {
+ const x = monitor.x + monitor.width / 2.0;
+ const 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,
+ onStopped: () => this._startupAnimationComplete(),
+ });
+ }
+
+ _startupAnimationSession() {
+ const onStopped = () => this._startupAnimationComplete();
+ if (Main.sessionMode.hasOverview) {
+ Main.overview.runStartupAnimation(onStopped);
+ } else {
+ this.uiGroup.ease({
+ scale_x: 1,
+ scale_y: 1,
+ opacity: 255,
+ duration: STARTUP_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onStopped,
+ });
+ }
+ }
+
+ _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');
+ }
+
+ // 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;
+ actor.connectObject(
+ 'notify::visible', this._queueUpdateRegions.bind(this),
+ 'notify::allocation', this._queueUpdateRegions.bind(this),
+ 'destroy', this._untrackActor.bind(this), 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;
+
+ this._trackedActors.splice(i, 1);
+ actor.disconnectObject(this);
+
+ 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._updateRegionIdle) {
+ this._updateRegionIdle = Meta.later_add(Meta.LaterType.BEFORE_REDRAW,
+ this._updateRegions.bind(this));
+ }
+ }
+
+ _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;
+ }
+
+ let rects = [], struts = [], i;
+ let isPopupMenuVisible = global.top_window_group.get_children().some(isPopupMetaWindow);
+ const wantsInputRegion =
+ !this._startingUp &&
+ !isPopupMenuVisible &&
+ Main.modalCount === 0 &&
+ !Meta.is_wayland_compositor();
+
+ 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);
+ }
+ }
+
+ if (wantsInputRegion)
+ 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 extends Signals.EventEmitter {
+ constructor(threshold, timeout, actionMode) {
+ super();
+
+ 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();
+ }
+};
+
+var ScreenTransition = GObject.registerClass(
+class ScreenTransition extends Clutter.Actor {
+ _init() {
+ super._init({ visible: false });
+ }
+
+ vfunc_hide() {
+ this.content = null;
+ super.vfunc_hide();
+ }
+
+ run() {
+ if (this.visible)
+ return;
+
+ Main.uiGroup.set_child_above_sibling(this, null);
+
+ const rect = new imports.gi.cairo.RectangleInt({
+ x: 0,
+ y: 0,
+ width: global.screen_width,
+ height: global.screen_height,
+ });
+ const [, , , scale] = global.stage.get_capture_final_size(rect);
+ this.content = global.stage.paint_to_content(rect, scale, Clutter.PaintFlag.NO_CURSORS);
+
+ this.opacity = 255;
+ this.show();
+
+ this.ease({
+ opacity: 0,
+ duration: SCREEN_TRANSITION_DURATION,
+ delay: SCREEN_TRANSITION_DELAY,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onStopped: () => this.hide(),
+ });
+ }
+});
diff --git a/js/ui/lightbox.js b/js/ui/lightbox.js
new file mode 100644
index 0000000..b0ca77a
--- /dev/null
+++ b/js/ui/lightbox.js
@@ -0,0 +1,289 @@
+// -*- 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\
+float rand(vec2 p) { \n\
+ return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453123); \n\
+} \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 = clamp(length(1.41421 * position), 0.0, 1.0); \n\
+float pixel_brightness = mix(1.0, 1.0 - vignette_sharpness, t); \n\
+cogl_color_out.a *= 1.0 - pixel_brightness * brightness; \n\
+cogl_color_out.a += (rand(position) - 0.5) / 100.0; \n';
+
+
+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 = 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,
+ }));
+ }
+
+ container.connectObject(
+ 'actor-added', this._actorAdded.bind(this),
+ 'actor-removed', this._actorRemoved.bind(this), 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() {
+ 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..6b6b65f
--- /dev/null
+++ b/js/ui/lookingGlass.js
@@ -0,0 +1,1670 @@
+// -*- 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.misc.signals;
+const System = imports.system;
+
+const History = imports.misc.history;
+const ExtensionUtils = imports.misc.extensionUtils;
+const PopupMenu = imports.ui.popupMenu;
+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;
+
+const CLUTTER_DEBUG_FLAG_CATEGORIES = new Map([
+ // Paint debugging can easily result in a non-responsive session
+ ['DebugFlag', { argPos: 0, exclude: ['PAINT'] }],
+ ['DrawDebugFlag', { argPos: 1, exclude: [] }],
+ // Exluded due to the only current option likely to result in shooting ones
+ // foot
+ // ['PickDebugFlag', { argPos: 2, exclude: [] }],
+]);
+
+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 extends Signals.EventEmitter {
+ constructor(entry) {
+ super();
+
+ 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);
+ }
+};
+
+
+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) {
+ const 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);
+
+ const 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 && o.toString === undefined) {
+ // eeks, something unprintable. we'll have to guess, probably a module
+ return typeof o === 'object' && !(o instanceof Object)
+ ? '<module>'
+ : '<unknown>';
+ } else {
+ return `${o}`;
+ }
+}
+
+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(${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: ${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: ${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',
+ 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: `${propName}: ` }));
+ box.add(link);
+ this._container.add_actor(box);
+ }
+ }
+ }
+
+ open(sourceActor) {
+ if (this._open)
+ return;
+
+ const grab = Main.pushModal(this, { actionMode: Shell.ActionMode.LOOKING_GLASS });
+ if (grab.get_seat_state() !== Clutter.GrabState.ALL) {
+ Main.popModal(grab);
+ return;
+ }
+
+ this._grab = grab;
+ 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;
+ Main.popModal(this._grab);
+ this._grab = null;
+ this._open = false;
+ this.hide();
+ this._previousObj = null;
+ this._obj = null;
+ }
+
+ vfunc_key_press_event(keyPressEvent) {
+ const symbol = keyPressEvent.keyval;
+ if (symbol === Clutter.KEY_Escape) {
+ this.close();
+ return Clutter.EVENT_STOP;
+ }
+ return super.vfunc_key_press_event(keyPressEvent);
+ }
+
+ _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_node(node, paintContext) {
+ let actor = this.get_actor();
+
+ const actorNode = new Clutter.ActorNode(actor, -1);
+ node.add_child(actorNode);
+
+ if (!this._pipeline) {
+ const framebuffer = paintContext.get_framebuffer();
+ const coglContext = framebuffer.get_context();
+
+ 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;
+
+ const pipelineNode = new Clutter.PipelineNode(this._pipeline);
+ pipelineNode.set_name('Red Border');
+ node.add_child(pipelineNode);
+
+ const box = new Clutter.ActorBox();
+
+ // clockwise order
+ box.set_origin(0, 0);
+ box.set_size(alloc.get_width(), width);
+ pipelineNode.add_rectangle(box);
+
+ box.set_origin(alloc.get_width() - width, width);
+ box.set_size(width, alloc.get_height() - width);
+ pipelineNode.add_rectangle(box);
+
+ box.set_origin(0, alloc.get_height() - width);
+ box.set_size(alloc.get_width() - width, width);
+ pipelineNode.add_rectangle(box);
+
+ box.set_origin(0, width);
+ box.set_size(width, alloc.get_height() - width * 2);
+ pipelineNode.add_rectangle(box);
+ }
+});
+
+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);
+
+ const 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));
+
+ this._grab = global.stage.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() {
+ if (this._grab) {
+ this._grab.dismiss();
+ this._grab = null;
+ }
+ 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: ${stageX} y: ${stageY}]`;
+ this._displayText.text = '';
+ this._displayText.text = `${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++;
+ const { name } = extension.metadata;
+ const pos = [...this._extensionsList].findIndex(
+ dsp => dsp._extension.metadata.name.localeCompare(name) > 0);
+ this._extensionsList.insert_child_at_index(extensionDisplay, pos);
+ }
+
+ _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 });
+ box._extension = extension;
+ 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);
+ const state = new St.Label({
+ style_class: 'lg-extension-state',
+ text: this._stateToString(extension.state),
+ });
+ metaBox.add(state);
+
+ const 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) {
+ const 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);
+ }
+
+ const 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({
+ icon_name: 'insert-object-symbolic',
+ 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 DebugFlag = GObject.registerClass({
+ GTypeFlags: GObject.TypeFlags.ABSTRACT,
+}, class DebugFlag extends St.Button {
+ _init(label) {
+ const box = new St.BoxLayout();
+
+ const flagLabel = new St.Label({
+ text: label,
+ x_expand: true,
+ x_align: Clutter.ActorAlign.START,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ box.add_child(flagLabel);
+
+ this._flagSwitch = new PopupMenu.Switch(false);
+ this._stateHandler = this._flagSwitch.connect('notify::state', () => {
+ if (this._flagSwitch.state)
+ this._enable();
+ else
+ this._disable();
+ });
+
+ // Update state whenever the switch is mapped, because most debug flags
+ // don't have a way of notifying us of changes.
+ this._flagSwitch.connect('notify::mapped', () => {
+ if (!this._flagSwitch.is_mapped())
+ return;
+
+ const state = this._isEnabled();
+ if (state === this._flagSwitch.state)
+ return;
+
+ this._flagSwitch.block_signal_handler(this._stateHandler);
+ this._flagSwitch.state = state;
+ this._flagSwitch.unblock_signal_handler(this._stateHandler);
+ });
+
+ box.add_child(this._flagSwitch);
+
+ super._init({
+ style_class: 'lg-debug-flag-button',
+ can_focus: true,
+ toggleMode: true,
+ child: box,
+ label_actor: flagLabel,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+
+ this.connect('clicked', () => this._flagSwitch.toggle());
+ }
+
+ _isEnabled() {
+ throw new Error('Method not implemented');
+ }
+
+ _enable() {
+ throw new Error('Method not implemented');
+ }
+
+ _disable() {
+ throw new Error('Method not implemented');
+ }
+});
+
+
+var ClutterDebugFlag = GObject.registerClass(
+class ClutterDebugFlag extends DebugFlag {
+ _init(categoryName, flagName) {
+ super._init(flagName);
+
+ this._argPos = CLUTTER_DEBUG_FLAG_CATEGORIES.get(categoryName).argPos;
+ this._enumValue = Clutter[categoryName][flagName];
+ }
+
+ _isEnabled() {
+ const enabledFlags = Meta.get_clutter_debug_flags();
+ return !!(enabledFlags[this._argPos] & this._enumValue);
+ }
+
+ _getArgs() {
+ const args = [0, 0, 0];
+ args[this._argPos] = this._enumValue;
+ return args;
+ }
+
+ _enable() {
+ Meta.add_clutter_debug_flags(...this._getArgs());
+ }
+
+ _disable() {
+ Meta.remove_clutter_debug_flags(...this._getArgs());
+ }
+});
+
+var MutterPaintDebugFlag = GObject.registerClass(
+class MutterPaintDebugFlag extends DebugFlag {
+ _init(flagName) {
+ super._init(flagName);
+
+ this._enumValue = Meta.DebugPaintFlag[flagName];
+ }
+
+ _isEnabled() {
+ return !!(Meta.get_debug_paint_flags() & this._enumValue);
+ }
+
+ _enable() {
+ Meta.add_debug_paint_flag(this._enumValue);
+ }
+
+ _disable() {
+ Meta.remove_debug_paint_flag(this._enumValue);
+ }
+});
+
+var MutterTopicDebugFlag = GObject.registerClass(
+class MutterTopicDebugFlag extends DebugFlag {
+ _init(flagName) {
+ super._init(flagName);
+
+ this._enumValue = Meta.DebugTopic[flagName];
+ }
+
+ _isEnabled() {
+ return Meta.is_topic_enabled(this._enumValue);
+ }
+
+ _enable() {
+ Meta.add_verbose_topic(this._enumValue);
+ }
+
+ _disable() {
+ Meta.remove_verbose_topic(this._enumValue);
+ }
+});
+
+var UnsafeModeDebugFlag = GObject.registerClass(
+class UnsafeModeDebugFlag extends DebugFlag {
+ _init() {
+ super._init('unsafe-mode');
+ }
+
+ _isEnabled() {
+ return global.context.unsafe_mode;
+ }
+
+ _enable() {
+ global.context.unsafe_mode = true;
+ }
+
+ _disable() {
+ global.context.unsafe_mode = false;
+ }
+});
+
+var DebugFlags = GObject.registerClass(
+class DebugFlags extends St.BoxLayout {
+ _init() {
+ super._init({
+ name: 'lookingGlassDebugFlags',
+ vertical: true,
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+
+ // Clutter debug flags
+ for (const [categoryName, props] of CLUTTER_DEBUG_FLAG_CATEGORIES.entries()) {
+ this._addHeader(`Clutter${categoryName}`);
+ for (const flagName of this._getFlagNames(Clutter[categoryName])) {
+ if (props.exclude.includes(flagName))
+ continue;
+ this.add_child(new ClutterDebugFlag(categoryName, flagName));
+ }
+ }
+
+ // Meta paint flags
+ this._addHeader('MetaDebugPaintFlag');
+ for (const flagName of this._getFlagNames(Meta.DebugPaintFlag))
+ this.add_child(new MutterPaintDebugFlag(flagName));
+
+ // Meta debug topics
+ this._addHeader('MetaDebugTopic');
+ for (const flagName of this._getFlagNames(Meta.DebugTopic))
+ this.add_child(new MutterTopicDebugFlag(flagName));
+
+ // MetaContext::unsafe-mode
+ this._addHeader('MetaContext');
+ this.add_child(new UnsafeModeDebugFlag());
+ }
+
+ _addHeader(title) {
+ const header = new St.Label({
+ text: title,
+ style_class: 'lg-debug-flags-header',
+ x_align: Clutter.ActorAlign.START,
+ });
+ this.add_child(header);
+ }
+
+ *_getFlagNames(enumObject) {
+ for (const flagName of Object.getOwnPropertyNames(enumObject)) {
+ if (typeof enumObject[flagName] !== 'number')
+ continue;
+
+ if (enumObject[flagName] <= 0)
+ continue;
+
+ yield flagName;
+ }
+ }
+});
+
+
+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);
+ const inspectButton = new St.Button({
+ style_class: 'lg-toolbar-button',
+ icon_name: 'find-location-symbolic',
+ });
+ toolbar.add_actor(inspectButton);
+ inspectButton.connect('clicked', () => {
+ let inspector = new Inspector(this);
+ inspector.connect('target', (i, target, stageX, stageY) => {
+ this._pushResult(`inspect(${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;
+ });
+
+ const gcButton = new St.Button({
+ style_class: 'lg-toolbar-button',
+ icon_name: 'user-trash-full-symbolic',
+ });
+ toolbar.add_actor(gcButton);
+ gcButton.connect('clicked', () => {
+ gcButton.child.icon_name = 'user-trash-symbolic';
+ System.gc();
+ this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => {
+ gcButton.child.icon_name = 'user-trash-full-symbolic';
+ this._timeoutId = 0;
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(
+ this._timeoutId,
+ '[gnome-shell] gcButton.child.icon_name = \'user-trash-full-symbolic\''
+ );
+ 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._debugFlags = new DebugFlags();
+ notebook.appendPage('Flags', this._debugFlags);
+
+ 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', ' ');
+ 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();
+ }
+
+ vfunc_captured_event(event) {
+ if (Main.keyboard.maybeHandleEvent(event))
+ return Clutter.EVENT_STOP;
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _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: ${size}${unit};
+ font-family: "${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) {
+ command = this._history.addItem(command); // trims command
+ if (!command)
+ return;
+
+ let lines = command.split(';');
+ lines.push(`return ${lines.pop()}`);
+
+ let fullCmd = commandHeader + lines.join(';');
+
+ let resultObj;
+ try {
+ resultObj = Function(fullCmd)();
+ } catch (e) {
+ resultObj = `<exception ${e}>`;
+ }
+
+ 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 ${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) {
+ 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;
+
+ let grab = Main.pushModal(this, { actionMode: Shell.ActionMode.LOOKING_GLASS });
+ if (grab.get_seat_state() !== Clutter.GrabState.ALL) {
+ Main.popModal(grab);
+ return;
+ }
+
+ this._grab = grab;
+ 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();
+ this._entry.grab_key_focus();
+ }
+
+ close() {
+ if (!this._open)
+ return;
+
+ this._objInspector.hide();
+
+ this._open = false;
+ this.remove_all_transitions();
+
+ this.setBorderPaintTarget(null);
+
+ 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: () => {
+ Main.popModal(this._grab);
+ this._grab = null;
+ 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..bd69047
--- /dev/null
+++ b/js/ui/magnifier.js
@@ -0,0 +1,2093 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Magnifier */
+
+const {
+ Atspi, Clutter, GDesktopEnums, Gio, GLib, GObject, Meta, Shell, St,
+} = imports.gi;
+const Signals = imports.misc.signals;
+
+const Background = imports.ui.background;
+const FocusCaretTracker = imports.ui.focusCaretTracker;
+const Main = imports.ui.main;
+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 extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ // 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();
+
+ this._cursorRoot = new Clutter.Actor();
+ this._cursorRoot.add_actor(this._mouseSprite);
+
+ // 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._cursorRoot);
+ 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);
+ });
+
+ 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();
+
+ if (this._cursorVisibilityChangedId) {
+ this._cursorTracker.disconnect(this._cursorVisibilityChangedId);
+ delete this._cursorVisibilityChangedId;
+
+ 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();
+
+ if (!this._cursorVisibilityChangedId) {
+ this._cursorTracker.set_pointer_visible(false);
+ this._cursorVisibilityChangedId = this._cursorTracker.connect('visibility-changed', () => {
+ if (this._cursorTracker.get_pointer_visible())
+ 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._cursorTracker.connectObject(
+ 'cursor-changed', this._updateMouseSprite.bind(this), this);
+ Meta.disable_unredirect_for_display(global.display);
+ this.startTrackingMouse();
+ } else {
+ this._cursorTracker.disconnectObject(this);
+ 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 / 60;
+ this._pointerWatch = PointerWatcher.getPointerWatcher().addWatch(interval, this.scrollToMousePos.bind(this));
+
+ this.scrollToMousePos();
+ }
+ }
+
+ /**
+ * 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.
+ */
+ scrollToMousePos(...args) {
+ const [xMouse, yMouse] = args.length ? args : global.get_pointer();
+
+ if (xMouse === this.xMouse && yMouse === this.yMouse)
+ return;
+
+ 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();
+ }
+
+ /**
+ * 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._cursorRoot);
+ 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);
+ }
+ }
+};
+
+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 = global.backend.get_core_idle_monitor();
+ 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);
+ }
+ }
+
+ _convertExtentsToScreenSpace(accessible, extents) {
+ const toplevelWindowTypes = new Set([
+ Atspi.Role.FRAME,
+ Atspi.Role.DIALOG,
+ Atspi.Role.WINDOW,
+ ]);
+
+ try {
+ let app = null;
+ let parentWindow = null;
+ let iter = accessible;
+ while (iter) {
+ if (iter.get_role() === Atspi.Role.APPLICATION) {
+ app = iter;
+ /* This is the last Accessible we are interested in */
+ break;
+ } else if (toplevelWindowTypes.has(iter.get_role())) {
+ parentWindow = iter;
+ }
+ iter = iter.get_parent();
+ }
+
+ /* We don't want to translate our own events to the focus window.
+ * They are also already scaled by clutter before being sent, so
+ * we don't need to do that here either. */
+ if (app && app.get_name() === 'gnome-shell')
+ return extents;
+
+ /* Only events from the focused widget of the focused window. Some
+ * widgets seem to claim to have focus when the window does not so
+ * check both. */
+ const windowActive = parentWindow &&
+ parentWindow.get_state_set().contains(Atspi.StateType.ACTIVE);
+ const accessibleFocused =
+ accessible.get_state_set().contains(Atspi.StateType.FOCUSED);
+ if (!windowActive || !accessibleFocused)
+ return null;
+ } catch (e) {
+ throw new Error(`Failed to validate parent window: ${e}`);
+ }
+
+ const { focusWindow } = global.display;
+ if (!focusWindow)
+ return null;
+
+ let windowRect = focusWindow.get_frame_rect();
+ if (!focusWindow.is_client_decorated())
+ windowRect = focusWindow.frame_rect_to_client_rect(windowRect);
+
+ const scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ const screenSpaceExtents = new Atspi.Rect({
+ x: windowRect.x + (scaleFactor * extents.x),
+ y: windowRect.y + (scaleFactor * extents.y),
+ width: scaleFactor * extents.width,
+ height: scaleFactor * extents.height,
+ });
+
+ return screenSpaceExtents;
+ }
+
+ _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.WINDOW);
+ extents = this._convertExtentsToScreenSpace(event.source, extents);
+ if (!extents)
+ return;
+ } catch (e) {
+ log(`Failed to read extents of focused component: ${e.message}`);
+ return;
+ }
+
+ const [xFocus, yFocus] = [
+ extents.x + (extents.width / 2),
+ extents.y + (extents.height / 2),
+ ];
+
+ 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(),
+ Atspi.CoordType.WINDOW);
+ extents = this._convertExtentsToScreenSpace(text, extents);
+ if (!extents)
+ return;
+ } catch (e) {
+ log(`Failed to read extents of text caret: ${e.message}`);
+ return;
+ }
+
+ const [xCaret, yCaret] = [extents.x, extents.y];
+
+ // 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 {
+ Main.uiGroup.set_opacity(255);
+ 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();
+
+ const uiGroupIsOccluded = this.isActive() && this._isFullScreen();
+ Main.uiGroup.set_opacity(uiGroupIsOccluded ? 0 : 255);
+ }
+
+ _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._colorDesaturation.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);
+ this._colorDesaturation.set_enabled(factor !== 1.0);
+ }
+
+ /**
+ * 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/main.js b/js/ui/main.js
new file mode 100644
index 0000000..2d8804a
--- /dev/null
+++ b/js/ui/main.js
@@ -0,0 +1,958 @@
+// -*- 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, moveWindowToMonitorAndWorkspace,
+ createLookingGlass, initializeDeferredWork,
+ getThemeStylesheet, setThemeStylesheet, screenshotUI */
+
+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 WelcomeDialog = imports.ui.welcomeDialog;
+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 Screenshot = imports.ui.screenshot;
+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 Config = imports.misc.config;
+const Util = imports.misc.util;
+
+const WELCOME_DIALOG_LAST_SHOWN_VERSION = 'welcome-dialog-last-shown-version';
+// Make sure to mention the point release, otherwise it will show every time
+// until this version is current
+const WELCOME_DIALOG_LAST_TOUR_CHANGE = '40.beta';
+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 welcomeDialog = 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 screenshotUI = 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 _themeResource = null;
+let _oskResource = null;
+let _iconResource = null;
+
+Gio._promisify(Gio.File.prototype, 'delete_async');
+Gio._promisify(Gio.File.prototype, 'touch_async');
+
+let _remoteAccessInhibited = false;
+
+function _sessionUpdated() {
+ if (sessionMode.isPrimary)
+ _loadDefaultStylesheet();
+
+ 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();
+ if (welcomeDialog)
+ welcomeDialog.close();
+ }
+
+ let remoteAccessController = global.backend.get_remote_access_controller();
+ if (remoteAccessController && !global.backend.is_headless()) {
+ if (sessionMode.allowScreencast && _remoteAccessInhibited) {
+ remoteAccessController.uninhibit_remote_access();
+ _remoteAccessInhibited = false;
+ } else if (!sessionMode.allowScreencast && !_remoteAccessInhibited) {
+ remoteAccessController.inhibit_remote_access();
+ _remoteAccessInhibited = true;
+ }
+ }
+}
+
+/**
+ * @param {any...} args a list of values to log
+ */
+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 start() {
+ globalThis.log = _loggingFunc;
+
+ // 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::high-contrast', _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();
+ _loadIcons();
+ _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);
+
+ screenshotUI = new Screenshot.ScreenshotUI();
+
+ 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();
+
+ 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);
+
+ global.context.connect('notify::unsafe-mode', () => {
+ if (!global.context.unsafe_mode)
+ return; // we're safe
+ if (lookingGlass?.isOpen)
+ return; // assume user action
+
+ const source = new MessageTray.SystemNotificationSource();
+ messageTray.add(source);
+ const notification = new MessageTray.Notification(source,
+ _('System was put in unsafe mode'),
+ _('Applications now have unrestricted access'));
+ notification.addAction(_('Undo'),
+ () => (global.context.unsafe_mode = false));
+ notification.setTransient(true);
+ source.showNotification(notification);
+ });
+
+ // 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();
+ global.context.notify_ready();
+ 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 ${_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.'));
+ } else if (sessionMode.showWelcomeDialog) {
+ _handleShowWelcomeScreen();
+ }
+
+ 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.${perfModuleName};`);
+ Scripting.runPerfScript(module, perfOutput);
+ }
+ });
+}
+
+function _handleShowWelcomeScreen() {
+ const lastShownVersion = global.settings.get_string(WELCOME_DIALOG_LAST_SHOWN_VERSION);
+ if (Util.GNOMEversionCompare(WELCOME_DIALOG_LAST_TOUR_CHANGE, lastShownVersion) > 0) {
+ openWelcomeDialog();
+ global.settings.set_string(WELCOME_DIALOG_LAST_SHOWN_VERSION, Config.PACKAGE_VERSION);
+ }
+}
+
+async function _handleLockScreenWarning() {
+ const path = `${global.userdatadir}/lock-warning-shown`;
+ 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/${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(`${global.datadir}/theme/${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
+ if (St.Settings.get().high_contrast)
+ 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(
+ `${global.datadir}/${sessionMode.themeResourceName}`);
+ _themeResource._register();
+}
+
+/** @private */
+function _loadIcons() {
+ _iconResource = Gio.Resource.load(`${global.datadir}/gnome-shell-icons.gresource`);
+ _iconResource._register();
+}
+
+function _loadOskLayouts() {
+ _oskResource = Gio.Resource.load(`${global.datadir}/gnome-shell-osk-layouts.gresource`);
+ _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 '${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)
+ console.warn(`error: ${msg}: ${details}`);
+ else
+ console.warn(`error: ${msg}`);
+
+ notify(msg, details);
+}
+
+/**
+ * _findModal:
+ *
+ * @param {Clutter.Grab} grab - grab
+ *
+ * Private function.
+ *
+ */
+function _findModal(grab) {
+ for (let i = 0; i < modalActorFocusStack.length; i++) {
+ if (modalActorFocusStack[i].grab === grab)
+ 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 {Clutter.Grab}: the grab handle created
+ */
+function pushModal(actor, params) {
+ params = Params.parse(params, {
+ timestamp: global.get_current_time(),
+ options: 0,
+ actionMode: Shell.ActionMode.NONE,
+ });
+
+ let grab = global.stage.grab(actor);
+
+ if (modalCount === 0)
+ Meta.disable_unredirect_for_display(global.display);
+
+ modalCount += 1;
+ let actorDestroyId = actor.connect('destroy', () => {
+ let index = _findModal(grab);
+ if (index >= 0)
+ popModal(grab);
+ });
+
+ 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,
+ grab,
+ destroyId: actorDestroyId,
+ prevFocus,
+ prevFocusDestroyId,
+ actionMode,
+ });
+
+ actionMode = params.actionMode;
+ global.stage.set_key_focus(actor);
+ return grab;
+}
+
+/**
+ * popModal:
+ * @param {Clutter.Grab} grab - the grab given by 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(grab, timestamp) {
+ if (timestamp == undefined)
+ timestamp = global.get_current_time();
+
+ let focusIndex = _findModal(grab);
+ if (focusIndex < 0) {
+ global.stage.set_key_focus(null);
+ actionMode = Shell.ActionMode.NORMAL;
+
+ throw new Error('incorrect pop');
+ }
+
+ modalCount -= 1;
+
+ let record = modalActorFocusStack[focusIndex];
+ record.actor.disconnect(record.destroyId);
+
+ record.grab.dismiss();
+
+ 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();
+ 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();
+}
+
+function openWelcomeDialog() {
+ if (welcomeDialog === null)
+ welcomeDialog = new WelcomeDialog.WelcomeDialog();
+
+ welcomeDialog.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();
+}
+
+/**
+ * Move @window to the specified monitor and workspace.
+ *
+ * @param {Meta.Window} window - the window to move
+ * @param {number} monitorIndex - the requested monitor
+ * @param {number} workspaceIndex - the requested workspace
+ * @param {bool} append - create workspace if it doesn't exist
+ */
+function moveWindowToMonitorAndWorkspace(window, monitorIndex, workspaceIndex, append = 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() !== monitorIndex) {
+ // Wait for the monitor change to take effect
+ const id = global.display.connect('window-entered-monitor',
+ (dsp, num, w) => {
+ if (w !== window)
+ return;
+ window.change_workspace_by_index(workspaceIndex, append);
+ global.display.disconnect(id);
+ });
+ window.move_to_monitor(monitorIndex);
+ } else {
+ window.change_workspace_by_index(workspaceIndex, append);
+ }
+}
+
+// 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}`;
+ _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 ${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..c910ca7
--- /dev/null
+++ b/js/ui/messageList.js
@@ -0,0 +1,760 @@
+/* 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://${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 += `${str}<span foreground="${this._linkColor}"><u>${url.url}</u></span>`;
+ 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;
+
+ this._container?.disconnectObject(this);
+
+ this._container = container;
+
+ if (this._container) {
+ this._container.connectObject(
+ 'notify::scale-x', () => this.layout_changed(),
+ 'notify::scale-y', () => this.layout_changed(), this);
+ }
+ }
+
+ 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);
+ const [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);
+
+ const 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);
+
+ this._closeButton = new St.Button({
+ style_class: 'message-close-button',
+ icon_name: 'window-close-symbolic',
+ y_align: Clutter.ActorAlign.CENTER,
+ 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) {
+ const button = new St.Button({
+ style_class: 'message-media-control',
+ iconName,
+ });
+ 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 ||
+ keysym === Clutter.KEY_BackSpace) {
+ if (this.canClose()) {
+ 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));
+
+ Main.sessionMode.connectObject(
+ 'updated', () => this._sync(), this);
+
+ 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) {
+ const messages = this._messages;
+
+ if (!messages.includes(message))
+ throw new Error(`Impossible to remove untracked message`);
+
+ let listItem = message.get_parent();
+ listItem._connectionsIds.forEach(id => message.disconnect(id));
+
+ let nextMessage = null;
+
+ if (message.has_key_focus()) {
+ const index = messages.indexOf(message);
+ nextMessage =
+ messages[index + 1] ||
+ messages[index - 1] ||
+ this._list;
+ }
+
+ if (animate) {
+ listItem.ease({
+ scale_x: 0,
+ scale_y: 0,
+ duration: MESSAGE_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ listItem.destroy();
+ nextMessage?.grab_key_focus();
+ },
+ });
+ } else {
+ listItem.destroy();
+ nextMessage?.grab_key_focus();
+ }
+ }
+
+ 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..1edd932
--- /dev/null
+++ b/js/ui/messageTray.js
@@ -0,0 +1,1423 @@
+// -*- 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 SignalTracker = imports.misc.signalTracker;
+
+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._focused = false;
+ }
+
+ grabFocus() {
+ if (this._focused)
+ return;
+
+ this._prevKeyFocusActor = global.stage.get_key_focus();
+
+ global.stage.connectObject('notify::key-focus',
+ this._focusActorChanged.bind(this), 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;
+
+ global.stage.disconnectObject(this);
+
+ 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/${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', `${this.id}.desktop`);
+
+ 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.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 });
+ }
+
+ setUrgency(urgency) {
+ this.urgency = urgency;
+ }
+
+ setResident(resident) {
+ this.resident = resident;
+ }
+
+ 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');
+
+ if (!this.resident)
+ this.destroy();
+ }
+
+ destroy(reason = NotificationDestroyedReason.DISMISSED) {
+ this.emit('destroy', reason);
+ this.run_dispose();
+ }
+});
+SignalTracker.registerDestroyableType(Notification);
+
+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.notification.connectObject('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');
+ }, this);
+ }
+
+ _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) {
+ const 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) {
+ const 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._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._source.connectObject('icon-updated',
+ this._updateIcon.bind(this), 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() {
+ this.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 (notification.urgency === Urgency.LOW)
+ return;
+
+ if (this.policy.showBanners || notification.urgency == Urgency.CRITICAL)
+ this.emit('notification-show', notification);
+ }
+
+ 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();
+ }
+ }
+});
+SignalTracker.registerDestroyableType(Source);
+
+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);
+ });
+
+ 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._userActiveWhileNotificationShown = false;
+
+ this.idleMonitor = global.backend.get_core_idle_monitor();
+
+ 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 Set();
+
+ 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 ${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) {
+ this._sources.add(source);
+
+ source.connectObject(
+ 'notification-show', this._onNotificationShow.bind(this),
+ 'destroy', () => this._removeSource(source), this);
+
+ this.emit('source-added', source);
+ }
+
+ _removeSource(source) {
+ this._sources.delete(source);
+ source.disconnectObject(this);
+ 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);
+ }
+ }
+
+ _onNotificationDestroy(notification) {
+ if (this._notification === notification) {
+ this._notificationRemoved = true;
+ if (this._notificationState === State.SHOWN ||
+ this._notificationState === State.SHOWING) {
+ this._updateNotificationTimeout(0);
+ this._updateState();
+ }
+ } else {
+ const 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 ||= 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.SHOWING ||
+ 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._notificationState === State.SHOWN &&
+ this._pointerInNotification) {
+ if (!this._banner.expanded)
+ this._expandBanner(false);
+ else
+ 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._banner.connectObject(
+ 'done-displaying', this._escapeTray.bind(this),
+ 'unfocused', () => this._updateState(), this);
+
+ 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();
+
+ this._banner.disconnectObject(this);
+
+ 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..0561b8b
--- /dev/null
+++ b/js/ui/modalDialog.js
@@ -0,0 +1,288 @@
+// -*- 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,
+ reactive: true,
+ 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);
+
+ const 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');
+ }
+
+ vfunc_key_press_event() {
+ if (global.focus_manager.navigate_from_event(Clutter.get_current_event()))
+ return Clutter.EVENT_STOP;
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_captured_event(event) {
+ if (Main.keyboard.maybeHandleEvent(event))
+ return Clutter.EVENT_STOP;
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ 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) {
+ this._initialKeyFocus?.disconnectObject(this);
+
+ this._initialKeyFocus = actor;
+
+ actor.connectObject('destroy',
+ () => (this._initialKeyFocus = null), this);
+ }
+
+ 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._grab, timestamp);
+ this._grab = null;
+ 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;
+ let grab = Main.pushModal(this, params);
+ if (grab.get_seat_state() !== Clutter.GrabState.ALL) {
+ Main.popModal(grab);
+ return false;
+ }
+
+ this._grab = grab;
+ 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._setState(State.FADED_OUT),
+ });
+ }
+});
diff --git a/js/ui/mpris.js b/js/ui/mpris.js
new file mode 100644
index 0000000..f44f87e
--- /dev/null
+++ b/js/ui/mpris.js
@@ -0,0 +1,297 @@
+/* exported MediaSection */
+const { Gio, GObject, Shell, St } = imports.gi;
+const Signals = imports.misc.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);
+
+ // reclaim space used by unused elements
+ this._secondaryBin.hide();
+ this._closeButton.hide();
+
+ this._prevButton = this.addMediaControl('media-skip-backward-symbolic',
+ () => {
+ this._player.previous();
+ });
+
+ this._playPauseButton = this.addMediaControl('',
+ () => {
+ this._player.playPause();
+ });
+
+ this._nextButton = this.addMediaControl('media-skip-forward-symbolic',
+ () => {
+ this._player.next();
+ });
+
+ this._player.connectObject(
+ 'changed', this._update.bind(this),
+ 'closed', this.close.bind(this), this);
+ this._update();
+ }
+
+ vfunc_clicked() {
+ this._player.raise();
+ Main.panel.closeCalendar();
+ }
+
+ _updateNavButton(button, sensitive) {
+ button.reactive = sensitive;
+ }
+
+ _update() {
+ this.setTitle(this._player.trackTitle);
+ this.setBody(this._player.trackArtists.join(', '));
+
+ 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 extends Signals.EventEmitter {
+ constructor(busName) {
+ super();
+
+ 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.PlayPauseAsync().catch(logError);
+ }
+
+ get canGoNext() {
+ return this._playerProxy.CanGoNext;
+ }
+
+ next() {
+ this._playerProxy.NextAsync().catch(logError);
+ }
+
+ get canGoPrevious() {
+ return this._playerProxy.CanGoPrevious;
+ }
+
+ previous() {
+ this._playerProxy.PreviousAsync().catch(logError);
+ }
+
+ 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 = `${this._mprisProxy.DesktopEntry}.desktop`;
+ app = Shell.AppSystem.get_default().lookup_app(desktopId);
+ }
+
+ if (app)
+ app.activate();
+ else if (this._mprisProxy.CanRaise)
+ this._mprisProxy.RaiseAsync().catch(logError);
+ }
+
+ _close() {
+ this._mprisProxy.disconnectObject(this);
+ this._mprisProxy = null;
+
+ this._playerProxy.disconnectObject(this);
+ this._playerProxy = null;
+
+ this.emit('closed');
+ }
+
+ _onMprisProxyReady() {
+ this._mprisProxy.connectObject('notify::g-name-owner',
+ () => {
+ if (!this._mprisProxy.g_name_owner)
+ this._close();
+ }, this);
+ // 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._playerProxy.connectObject(
+ 'g-properties-changed', () => this._updateState(), this);
+ this._updateState();
+ }
+
+ _updateState() {
+ let metadata = {};
+ for (let prop in this._playerProxy.Metadata)
+ metadata[prop] = this._playerProxy.Metadata[prop].deepUnpack();
+
+ // 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 ${
+ this._busName}; expected an array of strings, got ${
+ 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 ${
+ this._busName}; expected a string, got ${
+ 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 ${
+ this._busName}; expected a string, got ${
+ 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');
+ }
+ }
+};
+
+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);
+ }
+
+ async _onProxyReady() {
+ const [names] = await this._proxy.ListNamesAsync();
+ 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..b27158e
--- /dev/null
+++ b/js/ui/notificationDaemon.js
@@ -0,0 +1,771 @@
+// -*- 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,
+};
+
+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;
+ }
+
+ _imageForNotificationData(hints) {
+ if (hints['image-data']) {
+ const [
+ 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;
+ }
+
+ const appId = ndata?.hints['desktop-entry'];
+ 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].deepUnpack();
+ }
+
+ 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]));
+ }
+
+ // 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'];
+ }
+
+ const 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) {
+ const { icon, summary, body, actions, hints } = ndata;
+ let { notification } = ndata;
+
+ 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);
+
+ const soundFile = 'sound-file' in hints
+ ? Gio.File.new_for_path(hints['sound-file']) : null;
+
+ notification.update(summary, body, {
+ gicon,
+ bannerMarkup: true,
+ clear: true,
+ soundFile,
+ 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',
+ ];
+ }
+
+ _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(`${appId}.desktop`);
+
+ if (!app)
+ app = appSys.lookup_app(`${this.initialTitle}.desktop`);
+
+ 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);
+
+ const {
+ 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.deepUnpack().forEach(button => {
+ this.addAction(button.label.unpack(), () => {
+ this._onButtonClicked(button);
+ });
+ });
+ }
+
+ this._defaultAction = defaultAction?.unpack();
+ this._defaultActionTarget = defaultActionTarget;
+
+ this.update(title.unpack(), body?.unpack(), {
+ 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${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(`${appId}.desktop`);
+ 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() {
+ return new Promise((resolve, reject) => {
+ new FdoApplicationProxy(Gio.DBus.session,
+ this._appId, this._objectPath, (proxy, err) => {
+ if (err)
+ reject(err);
+ else
+ resolve(proxy);
+ });
+ });
+ }
+
+ _createNotification(params) {
+ return new GtkNotificationDaemonNotification(this, params);
+ }
+
+ async activateAction(actionId, target) {
+ try {
+ const app = await this._createApp();
+ const params = target ? [target] : [];
+ app.ActivateActionAsync(actionId, params, getPlatformData());
+ } catch (error) {
+ logError(error, 'Failed to activate application proxy');
+ }
+ Main.overview.hide();
+ Main.panel.closeCalendar();
+ }
+
+ async open() {
+ try {
+ const app = await this._createApp();
+ app.ActivateAsync(getPlatformData());
+ } catch (error) {
+ 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.deepUnpack();
+ 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.deepUnpack(), 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 "${appId}" could not be found`);
+ 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..07c7d65
--- /dev/null
+++ b/js/ui/osdMonitorLabeler.js
@@ -0,0 +1,117 @@
+// -*- 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({
+ 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].deepUnpack());
+ }
+
+ 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..b183333
--- /dev/null
+++ b/js/ui/osdWindow.js
@@ -0,0 +1,192 @@
+// -*- 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 OsdWindow = GObject.registerClass(
+class OsdWindow extends Clutter.Actor {
+ _init(monitorIndex) {
+ super._init({
+ x_expand: true,
+ y_expand: true,
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.END,
+ });
+
+ this._monitorIndex = monitorIndex;
+ let constraint = new Layout.MonitorConstraint({ index: monitorIndex });
+ this.add_constraint(constraint);
+
+ this._hbox = new St.BoxLayout({
+ style_class: 'osd-window',
+ });
+ this.add_actor(this._hbox);
+
+ this._icon = new St.Icon({ y_expand: true });
+ this._hbox.add_child(this._icon);
+
+ this._vbox = new St.BoxLayout({
+ vertical: true,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ this._hbox.add_child(this._vbox);
+
+ this._label = new St.Label();
+ this._vbox.add_child(this._label);
+
+ this._level = new BarLevel.BarLevel({
+ style_class: 'level',
+ value: 0,
+ });
+ this._vbox.add_child(this._level);
+
+ this._hideTimeoutId = 0;
+ this._reset();
+ Main.uiGroup.add_child(this);
+ }
+
+ _updateBoxVisibility() {
+ this._vbox.visible = [...this._vbox].some(c => c.visible);
+ }
+
+ setIcon(icon) {
+ this._icon.gicon = icon;
+ }
+
+ setLabel(label) {
+ this._label.visible = label != undefined;
+ if (label)
+ this._label.text = label;
+ this._updateBoxVisibility();
+ }
+
+ 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;
+ }
+ }
+ this._updateBoxVisibility();
+ }
+
+ 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);
+ }
+});
+
+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..757a8e4
--- /dev/null
+++ b/js/ui/overview.js
@@ -0,0 +1,715 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Overview, ANIMATION_TIME */
+
+const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi;
+const Signals = imports.misc.signals;
+
+// Time for initial animation going into Overview mode;
+// this is defined here to make it available in imports.
+var ANIMATION_TIME = 250;
+
+const DND = imports.ui.dnd;
+const LayoutManager = imports.ui.layout;
+const Main = imports.ui.main;
+const MessageTray = imports.ui.messageTray;
+const OverviewControls = imports.ui.overviewControls;
+const Params = imports.misc.params;
+const SwipeTracker = imports.ui.swipeTracker;
+const WindowManager = imports.ui.windowManager;
+const WorkspaceThumbnail = imports.ui.workspaceThumbnail;
+
+var DND_WINDOW_SWITCH_TIMEOUT = 750;
+
+var OVERVIEW_ACTIVATION_TIMEOUT = 0.5;
+
+var ShellInfo = class {
+ constructor() {
+ this._source = null;
+ }
+
+ 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 });
+ }
+
+ if (undoCallback)
+ notification.addAction(_('Undo'), () => undoCallback());
+
+ 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 }));
+
+ this._controls = new OverviewControls.ControlsManager();
+ this.add_child(this._controls);
+ }
+
+ prepareToEnterOverview() {
+ this._controls.prepareToEnterOverview();
+ }
+
+ prepareToLeaveOverview() {
+ this._controls.prepareToLeaveOverview();
+ }
+
+ animateToOverview(state, callback) {
+ this._controls.animateToOverview(state, callback);
+ }
+
+ animateFromOverview(callback) {
+ this._controls.animateFromOverview(callback);
+ }
+
+ runStartupAnimation(callback) {
+ this._controls.runStartupAnimation(callback);
+ }
+
+ get dash() {
+ return this._controls.dash;
+ }
+
+ get searchEntry() {
+ return this._controls.searchEntry;
+ }
+
+ get controls() {
+ return this._controls;
+ }
+});
+
+const OverviewShownState = {
+ HIDDEN: 'HIDDEN',
+ HIDING: 'HIDING',
+ SHOWING: 'SHOWING',
+ SHOWN: 'SHOWN',
+};
+
+const OVERVIEW_SHOWN_TRANSITIONS = {
+ [OverviewShownState.HIDDEN]: {
+ signal: 'hidden',
+ allowedTransitions: [OverviewShownState.SHOWING],
+ },
+ [OverviewShownState.HIDING]: {
+ signal: 'hiding',
+ allowedTransitions:
+ [OverviewShownState.HIDDEN, OverviewShownState.SHOWING],
+ },
+ [OverviewShownState.SHOWING]: {
+ signal: 'showing',
+ allowedTransitions:
+ [OverviewShownState.SHOWN, OverviewShownState.HIDING],
+ },
+ [OverviewShownState.SHOWN]: {
+ signal: 'shown',
+ allowedTransitions: [OverviewShownState.HIDING],
+ },
+};
+
+var Overview = class extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ 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 animationInProgress() {
+ return this._animationInProgress;
+ }
+
+ get visible() {
+ return this._visible;
+ }
+
+ get visibleTarget() {
+ return this._visibleTarget;
+ }
+
+ get closing() {
+ return this._animationInProgress && !this._visibleTarget;
+ }
+
+ _createOverview() {
+ if (this._overview)
+ return;
+
+ if (this.isDummy)
+ return;
+
+ 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;
+ this._shownState = OverviewShownState.HIDDEN;
+
+ // 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', (_actor, event) => {
+ return event.type() === Clutter.EventType.ENTER ||
+ event.type() === Clutter.EventType.LEAVE
+ ? Clutter.EVENT_PROPAGATE : 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();
+ }
+
+ _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._shellInfo = new ShellInfo();
+
+ Main.layoutManager.connect('monitors-changed', this._relayout.bind(this));
+ this._relayout();
+
+ Main.wm.addKeybinding(
+ 'toggle-overview',
+ new Gio.Settings({ schema_id: WindowManager.SHELL_KEYBINDINGS_SCHEMA }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW,
+ this.toggle.bind(this));
+
+ const swipeTracker = new SwipeTracker.SwipeTracker(global.stage,
+ Clutter.Orientation.VERTICAL,
+ Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW,
+ { allowDrag: false, allowScroll: false });
+ swipeTracker.orientation = Clutter.Orientation.VERTICAL;
+ swipeTracker.connect('begin', this._gestureBegin.bind(this));
+ swipeTracker.connect('update', this._gestureUpdate.bind(this));
+ swipeTracker.connect('end', this._gestureEnd.bind(this));
+ this._swipeTracker = swipeTracker;
+ }
+
+ //
+ // 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);
+ }
+
+ _changeShownState(state) {
+ const {allowedTransitions} =
+ OVERVIEW_SHOWN_TRANSITIONS[this._shownState];
+
+ if (!allowedTransitions.includes(state)) {
+ throw new Error('Invalid overview shown transition from ' +
+ `${this._shownState} to ${state}`);
+ }
+
+ this._shownState = state;
+ this.emit(OVERVIEW_SHOWN_TRANSITIONS[state].signal);
+ }
+
+ _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() {
+ 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(global.get_current_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;
+ }
+
+ _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);
+ }
+
+ _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);
+ }
+
+ _gestureBegin(tracker) {
+ this._overview.controls.gestureBegin(tracker);
+ }
+
+ _gestureUpdate(tracker, progress) {
+ if (!this._shown) {
+ Meta.disable_unredirect_for_display(global.display);
+
+ this._shown = true;
+ this._visible = true;
+ this._visibleTarget = true;
+ this._animationInProgress = true;
+
+ Main.layoutManager.overviewGroup.set_child_above_sibling(
+ this._coverPane, null);
+ this._coverPane.show();
+ this._changeShownState(OverviewShownState.SHOWING);
+
+ Main.layoutManager.showOverview();
+ this._syncGrab();
+ }
+
+ this._overview.controls.gestureProgress(progress);
+ }
+
+ _gestureEnd(tracker, duration, endProgress) {
+ let onComplete;
+ if (endProgress === 0) {
+ this._shown = false;
+ this._visibleTarget = false;
+ this._changeShownState(OverviewShownState.HIDING);
+ Main.panel.style = `transition-duration: ${duration}ms;`;
+ onComplete = () => this._hideDone();
+ } else {
+ onComplete = () => this._showDone();
+ }
+
+ this._overview.controls.gestureEnd(endProgress, duration, onComplete);
+ }
+
+ 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();
+ }
+
+ // 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) {
+ if (global.display.get_grab_op() !== Meta.GrabOp.NONE &&
+ global.display.get_grab_op() !== Meta.GrabOp.WAYLAND_POPUP) {
+ this.hide();
+ return false;
+ }
+
+ const grab = Main.pushModal(global.stage, {
+ actionMode: Shell.ActionMode.OVERVIEW,
+ });
+ if (grab.get_seat_state() !== Clutter.GrabState.ALL) {
+ Main.popModal(grab);
+ this.hide();
+ return false;
+ }
+
+ this._grab = grab;
+ this._modal = true;
+ }
+ } else {
+ // eslint-disable-next-line no-lonely-if
+ if (this._modal) {
+ Main.popModal(this._grab);
+ this._grab = false;
+ this._modal = false;
+ }
+ }
+ return true;
+ }
+
+ // show:
+ //
+ // Animates the overview visible and grabs mouse and keyboard input
+ show(state = OverviewControls.ControlsState.WINDOW_PICKER) {
+ if (state === OverviewControls.ControlsState.HIDDEN)
+ throw new Error('Invalid state, use hide() to hide');
+
+ if (this.isDummy)
+ return;
+ if (this._shown)
+ return;
+ this._shown = true;
+
+ if (!this._syncGrab())
+ return;
+
+ Main.layoutManager.showOverview();
+ this._animateVisible(state);
+ }
+
+
+ _animateVisible(state) {
+ 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);
+
+ Main.layoutManager.overviewGroup.set_child_above_sibling(
+ this._coverPane, null);
+ this._coverPane.show();
+
+ this._overview.prepareToEnterOverview();
+ this._changeShownState(OverviewShownState.SHOWING);
+ this._overview.animateToOverview(state, () => this._showDone());
+ }
+
+ _showDone() {
+ this._animationInProgress = false;
+ this._coverPane.hide();
+
+ if (this._shownState !== OverviewShownState.SHOWN)
+ this._changeShownState(OverviewShownState.SHOWN);
+
+ // Handle any calls to hide* while we were showing
+ if (!this._shown)
+ this._animateNotVisible();
+
+ this._syncGrab();
+ }
+
+ // 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;
+
+ Main.layoutManager.overviewGroup.set_child_above_sibling(
+ this._coverPane, null);
+ this._coverPane.show();
+
+ this._overview.prepareToLeaveOverview();
+ this._changeShownState(OverviewShownState.HIDING);
+ this._overview.animateFromOverview(() => this._hideDone());
+ }
+
+ _hideDone() {
+ // Re-enable unredirection
+ Meta.enable_unredirect_for_display(global.display);
+
+ this._coverPane.hide();
+
+ this._visible = false;
+ this._animationInProgress = false;
+
+ // Handle any calls to show* while we were hiding
+ if (this._shown) {
+ this._changeShownState(OverviewShownState.HIDDEN);
+ this._animateVisible(OverviewControls.ControlsState.WINDOW_PICKER);
+ } else {
+ Main.layoutManager.hideOverview();
+ this._changeShownState(OverviewShownState.HIDDEN);
+ }
+
+ Main.panel.style = null;
+
+ this._syncGrab();
+ }
+
+ toggle() {
+ if (this.isDummy)
+ return;
+
+ if (this._visible)
+ this.hide();
+ else
+ this.show();
+ }
+
+ showApps() {
+ this.show(OverviewControls.ControlsState.APP_GRID);
+ }
+
+ selectApp(id) {
+ this.showApps();
+ this._overview.controls.appDisplay.selectApp(id);
+ }
+
+ runStartupAnimation(callback) {
+ Main.panel.style = 'transition-duration: 0ms;';
+
+ this._shown = true;
+ this._visible = true;
+ this._visibleTarget = true;
+ Main.layoutManager.showOverview();
+ // We should call this._syncGrab() here, but moved it to happen after
+ // the animation because of a race in the xserver where the grab
+ // fails when requested very early during startup.
+
+ Meta.disable_unredirect_for_display(global.display);
+
+ this._changeShownState(OverviewShownState.SHOWING);
+
+ this._overview.runStartupAnimation(() => {
+ // Overview got hidden during startup animation
+ if (this._shownState !== OverviewShownState.SHOWING) {
+ callback();
+ return;
+ }
+
+ if (!this._syncGrab()) {
+ callback();
+ this.hide();
+ return;
+ }
+
+ Main.panel.style = null;
+ this._changeShownState(OverviewShownState.SHOWN);
+ callback();
+ });
+ }
+
+ 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;
+ }
+};
diff --git a/js/ui/overviewControls.js b/js/ui/overviewControls.js
new file mode 100644
index 0000000..29aac35
--- /dev/null
+++ b/js/ui/overviewControls.js
@@ -0,0 +1,867 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported ControlsManager */
+
+const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi;
+
+const AppDisplay = imports.ui.appDisplay;
+const Dash = imports.ui.dash;
+const Layout = imports.ui.layout;
+const Main = imports.ui.main;
+const Overview = imports.ui.overview;
+const SearchController = imports.ui.searchController;
+const Util = imports.misc.util;
+const WindowManager = imports.ui.windowManager;
+const WorkspaceThumbnail = imports.ui.workspaceThumbnail;
+const WorkspacesView = imports.ui.workspacesView;
+
+const SMALL_WORKSPACE_RATIO = 0.15;
+const DASH_MAX_HEIGHT_RATIO = 0.15;
+
+const A11Y_SCHEMA = 'org.gnome.desktop.a11y.keyboard';
+
+var SIDE_CONTROLS_ANIMATION_TIME = Overview.ANIMATION_TIME;
+
+var ControlsState = {
+ HIDDEN: 0,
+ WINDOW_PICKER: 1,
+ APP_GRID: 2,
+};
+
+var ControlsManagerLayout = GObject.registerClass(
+class ControlsManagerLayout extends Clutter.BoxLayout {
+ _init(searchEntry, appDisplay, workspacesDisplay, workspacesThumbnails,
+ searchController, dash, stateAdjustment) {
+ super._init({ orientation: Clutter.Orientation.VERTICAL });
+
+ this._appDisplay = appDisplay;
+ this._workspacesDisplay = workspacesDisplay;
+ this._workspacesThumbnails = workspacesThumbnails;
+ this._stateAdjustment = stateAdjustment;
+ this._searchEntry = searchEntry;
+ this._searchController = searchController;
+ this._dash = dash;
+
+ this._cachedWorkspaceBoxes = new Map();
+ this._postAllocationCallbacks = [];
+
+ stateAdjustment.connect('notify::value', () => this.layout_changed());
+
+ this._workAreaBox = new Clutter.ActorBox();
+ global.display.connectObject(
+ 'workareas-changed', () => this._updateWorkAreaBox(),
+ this);
+ this._updateWorkAreaBox();
+ }
+
+ _updateWorkAreaBox() {
+ const monitor = Main.layoutManager.primaryMonitor;
+ if (!monitor)
+ return;
+
+ const workArea = Main.layoutManager.getWorkAreaForMonitor(monitor.index);
+ const startX = workArea.x - monitor.x;
+ const startY = workArea.y - monitor.y;
+ this._workAreaBox.set_origin(startX, startY);
+ this._workAreaBox.set_size(workArea.width, workArea.height);
+ }
+
+ _computeWorkspacesBoxForState(state, box, searchHeight, dashHeight, thumbnailsHeight) {
+ const workspaceBox = box.copy();
+ const [width, height] = workspaceBox.get_size();
+ const {y1: startY} = this._workAreaBox;
+ const {spacing} = this;
+ const {expandFraction} = this._workspacesThumbnails;
+
+ switch (state) {
+ case ControlsState.HIDDEN:
+ workspaceBox.set_origin(...this._workAreaBox.get_origin());
+ workspaceBox.set_size(...this._workAreaBox.get_size());
+ break;
+ case ControlsState.WINDOW_PICKER:
+ workspaceBox.set_origin(0,
+ startY + searchHeight + spacing +
+ thumbnailsHeight + spacing * expandFraction);
+ workspaceBox.set_size(width,
+ height -
+ dashHeight - spacing -
+ searchHeight - spacing -
+ thumbnailsHeight - spacing * expandFraction);
+ break;
+ case ControlsState.APP_GRID:
+ workspaceBox.set_origin(0, startY + searchHeight + spacing);
+ workspaceBox.set_size(
+ width,
+ Math.round(height * SMALL_WORKSPACE_RATIO));
+ break;
+ }
+
+ return workspaceBox;
+ }
+
+ _getAppDisplayBoxForState(state, box, searchHeight, dashHeight, appGridBox) {
+ const [width, height] = box.get_size();
+ const {y1: startY} = this._workAreaBox;
+ const appDisplayBox = new Clutter.ActorBox();
+ const {spacing} = this;
+
+ switch (state) {
+ case ControlsState.HIDDEN:
+ case ControlsState.WINDOW_PICKER:
+ appDisplayBox.set_origin(0, box.y2);
+ break;
+ case ControlsState.APP_GRID:
+ appDisplayBox.set_origin(0,
+ startY + searchHeight + spacing + appGridBox.get_height());
+ break;
+ }
+
+ appDisplayBox.set_size(width,
+ height -
+ searchHeight - spacing -
+ appGridBox.get_height() - spacing -
+ dashHeight);
+
+ return appDisplayBox;
+ }
+
+ _runPostAllocation() {
+ if (this._postAllocationCallbacks.length === 0)
+ return;
+
+ this._postAllocationCallbacks.forEach(cb => cb());
+ this._postAllocationCallbacks = [];
+ }
+
+ vfunc_set_container(container) {
+ this._container = container;
+ if (container)
+ this.hookup_style(container);
+ }
+
+ vfunc_get_preferred_width(_container, _forHeight) {
+ // The MonitorConstraint will allocate us a fixed size anyway
+ return [0, 0];
+ }
+
+ vfunc_get_preferred_height(_container, _forWidth) {
+ // The MonitorConstraint will allocate us a fixed size anyway
+ return [0, 0];
+ }
+
+ vfunc_allocate(container, box) {
+ const childBox = new Clutter.ActorBox();
+
+ const { spacing } = this;
+
+ const startY = this._workAreaBox.y1;
+ box.y1 += startY;
+ const [width, height] = box.get_size();
+ let availableHeight = height;
+
+ // Search entry
+ let [searchHeight] = this._searchEntry.get_preferred_height(width);
+ childBox.set_origin(0, startY);
+ childBox.set_size(width, searchHeight);
+ this._searchEntry.allocate(childBox);
+
+ availableHeight -= searchHeight + spacing;
+
+ // Dash
+ const maxDashHeight = Math.round(box.get_height() * DASH_MAX_HEIGHT_RATIO);
+ this._dash.setMaxSize(width, maxDashHeight);
+
+ let [, dashHeight] = this._dash.get_preferred_height(width);
+ dashHeight = Math.min(dashHeight, maxDashHeight);
+ childBox.set_origin(0, startY + height - dashHeight);
+ childBox.set_size(width, dashHeight);
+ this._dash.allocate(childBox);
+
+ availableHeight -= dashHeight + spacing;
+
+ // Workspace Thumbnails
+ let thumbnailsHeight = 0;
+ if (this._workspacesThumbnails.visible) {
+ const { expandFraction } = this._workspacesThumbnails;
+ [thumbnailsHeight] =
+ this._workspacesThumbnails.get_preferred_height(width);
+ thumbnailsHeight = Math.min(
+ thumbnailsHeight * expandFraction,
+ height * WorkspaceThumbnail.MAX_THUMBNAIL_SCALE);
+ childBox.set_origin(0, startY + searchHeight + spacing);
+ childBox.set_size(width, thumbnailsHeight);
+ this._workspacesThumbnails.allocate(childBox);
+ }
+
+ // Workspaces
+ let params = [box, searchHeight, dashHeight, thumbnailsHeight];
+ const transitionParams = this._stateAdjustment.getStateTransitionParams();
+
+ // Update cached boxes
+ for (const state of Object.values(ControlsState)) {
+ this._cachedWorkspaceBoxes.set(
+ state, this._computeWorkspacesBoxForState(state, ...params));
+ }
+
+ let workspacesBox;
+ if (!transitionParams.transitioning) {
+ workspacesBox = this._cachedWorkspaceBoxes.get(transitionParams.currentState);
+ } else {
+ const initialBox = this._cachedWorkspaceBoxes.get(transitionParams.initialState);
+ const finalBox = this._cachedWorkspaceBoxes.get(transitionParams.finalState);
+ workspacesBox = initialBox.interpolate(finalBox, transitionParams.progress);
+ }
+
+ this._workspacesDisplay.allocate(workspacesBox);
+
+ // AppDisplay
+ if (this._appDisplay.visible) {
+ const workspaceAppGridBox =
+ this._cachedWorkspaceBoxes.get(ControlsState.APP_GRID);
+
+ params = [box, searchHeight, dashHeight, workspaceAppGridBox];
+ let appDisplayBox;
+ if (!transitionParams.transitioning) {
+ appDisplayBox =
+ this._getAppDisplayBoxForState(transitionParams.currentState, ...params);
+ } else {
+ const initialBox =
+ this._getAppDisplayBoxForState(transitionParams.initialState, ...params);
+ const finalBox =
+ this._getAppDisplayBoxForState(transitionParams.finalState, ...params);
+
+ appDisplayBox = initialBox.interpolate(finalBox, transitionParams.progress);
+ }
+
+ this._appDisplay.allocate(appDisplayBox);
+ }
+
+ // Search
+ childBox.set_origin(0, startY + searchHeight + spacing);
+ childBox.set_size(width, availableHeight);
+
+ this._searchController.allocate(childBox);
+
+ this._runPostAllocation();
+ }
+
+ ensureAllocation() {
+ this.layout_changed();
+ return new Promise(
+ resolve => this._postAllocationCallbacks.push(resolve));
+ }
+
+ getWorkspacesBoxForState(state) {
+ return this._cachedWorkspaceBoxes.get(state);
+ }
+});
+
+var OverviewAdjustment = GObject.registerClass({
+ Properties: {
+ 'gesture-in-progress': GObject.ParamSpec.boolean(
+ 'gesture-in-progress', 'Gesture in progress', 'Gesture in progress',
+ GObject.ParamFlags.READWRITE,
+ false),
+ },
+}, class OverviewAdjustment extends St.Adjustment {
+ _init(actor) {
+ super._init({
+ actor,
+ value: ControlsState.WINDOW_PICKER,
+ lower: ControlsState.HIDDEN,
+ upper: ControlsState.APP_GRID,
+ });
+ }
+
+ getStateTransitionParams() {
+ const currentState = this.value;
+
+ const transition = this.get_transition('value');
+ let initialState = transition
+ ? transition.get_interval().peek_initial_value()
+ : currentState;
+ let finalState = transition
+ ? transition.get_interval().peek_final_value()
+ : currentState;
+
+ if (initialState > finalState) {
+ initialState = Math.ceil(initialState);
+ finalState = Math.floor(finalState);
+ } else {
+ initialState = Math.floor(initialState);
+ finalState = Math.ceil(finalState);
+ }
+
+ const length = Math.abs(finalState - initialState);
+ const progress = length > 0
+ ? Math.abs((currentState - initialState) / length)
+ : 1;
+
+ return {
+ transitioning: transition !== null || this.gestureInProgress,
+ currentState,
+ initialState,
+ finalState,
+ progress,
+ };
+ }
+});
+
+var ControlsManager = GObject.registerClass(
+class ControlsManager extends St.Widget {
+ _init() {
+ super._init({
+ style_class: 'controls-manager',
+ x_expand: true,
+ y_expand: true,
+ clip_to_allocation: true,
+ });
+
+ this._ignoreShowAppsButtonToggle = false;
+
+ 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);
+ this._searchEntryBin = new St.Bin({
+ child: this._searchEntry,
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+
+ this.dash = new Dash.Dash();
+
+ 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._stateAdjustment = new OverviewAdjustment(this);
+ this._stateAdjustment.connect('notify::value', this._update.bind(this));
+
+ workspaceManager.connectObject(
+ 'notify::n-workspaces', () => this._updateAdjustment(), this);
+
+ this._searchController = new SearchController.SearchController(
+ this._searchEntry,
+ this.dash.showAppsButton);
+ this._searchController.connect('notify::search-active', this._onSearchChanged.bind(this));
+
+ Main.layoutManager.connect('monitors-changed', () => {
+ this._thumbnailsBox.setMonitorIndex(Main.layoutManager.primaryIndex);
+ });
+ this._thumbnailsBox = new WorkspaceThumbnail.ThumbnailsBox(
+ this._workspaceAdjustment, Main.layoutManager.primaryIndex);
+ this._thumbnailsBox.connect('notify::should-show', () => {
+ this._thumbnailsBox.show();
+ this._thumbnailsBox.ease_property('expand-fraction',
+ this._thumbnailsBox.should_show ? 1 : 0, {
+ duration: SIDE_CONTROLS_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this._updateThumbnailsBox(),
+ });
+ });
+
+ this._workspacesDisplay = new WorkspacesView.WorkspacesDisplay(
+ this,
+ this._workspaceAdjustment,
+ this._stateAdjustment);
+ this._appDisplay = new AppDisplay.AppDisplay();
+
+ this.add_child(this._searchEntryBin);
+ this.add_child(this._appDisplay);
+ this.add_child(this.dash);
+ this.add_child(this._searchController);
+ this.add_child(this._thumbnailsBox);
+ this.add_child(this._workspacesDisplay);
+
+ this.layout_manager = new ControlsManagerLayout(
+ this._searchEntryBin,
+ this._appDisplay,
+ this._workspacesDisplay,
+ this._thumbnailsBox,
+ this._searchController,
+ this.dash,
+ this._stateAdjustment);
+
+ this.dash.showAppsButton.connect('notify::checked',
+ this._onShowAppsButtonToggled.bind(this));
+
+ Main.ctrlAltTabManager.addGroup(
+ this.appDisplay,
+ _('Applications'),
+ 'view-app-grid-symbolic', {
+ proxy: this,
+ focusCallback: () => {
+ this.dash.showAppsButton.checked = true;
+ this.appDisplay.navigate_focus(
+ null, St.DirectionType.TAB_FORWARD, false);
+ },
+ });
+
+ Main.ctrlAltTabManager.addGroup(
+ this._workspacesDisplay,
+ _('Windows'),
+ 'focus-windows-symbolic', {
+ proxy: this,
+ focusCallback: () => {
+ this.dash.showAppsButton.checked = false;
+ this._workspacesDisplay.navigate_focus(
+ null, St.DirectionType.TAB_FORWARD, false);
+ },
+ });
+
+ this._a11ySettings = new Gio.Settings({ schema_id: A11Y_SCHEMA });
+
+ this._lastOverlayKeyTime = 0;
+ global.display.connect('overlay-key', () => {
+ if (this._a11ySettings.get_boolean('stickykeys-enable'))
+ return;
+
+ const { initialState, finalState, transitioning } =
+ this._stateAdjustment.getStateTransitionParams();
+
+ const time = GLib.get_monotonic_time() / 1000;
+ const timeDiff = time - this._lastOverlayKeyTime;
+ this._lastOverlayKeyTime = time;
+
+ const shouldShift = St.Settings.get().enable_animations
+ ? transitioning && finalState > initialState
+ : Main.overview.visible && timeDiff < Overview.ANIMATION_TIME;
+
+ if (shouldShift)
+ this._shiftState(Meta.MotionDirection.UP);
+ else
+ Main.overview.toggle();
+ });
+
+ // connect_after to give search controller first dibs on the event
+ global.stage.connect_after('key-press-event', (actor, event) => {
+ if (this._searchController.searchActive)
+ return Clutter.EVENT_PROPAGATE;
+
+ if (global.stage.key_focus &&
+ !this.contains(global.stage.key_focus))
+ return Clutter.EVENT_PROPAGATE;
+
+ const { finalState } =
+ this._stateAdjustment.getStateTransitionParams();
+ let keynavDisplay;
+
+ if (finalState === ControlsState.WINDOW_PICKER)
+ keynavDisplay = this._workspacesDisplay;
+ else if (finalState === ControlsState.APP_GRID)
+ keynavDisplay = this._appDisplay;
+
+ if (!keynavDisplay)
+ return Clutter.EVENT_PROPAGATE;
+
+ const symbol = event.get_key_symbol();
+ if (symbol === Clutter.KEY_Tab || symbol === Clutter.KEY_Down) {
+ keynavDisplay.navigate_focus(
+ null, St.DirectionType.TAB_FORWARD, false);
+ return Clutter.EVENT_STOP;
+ } else if (symbol === Clutter.KEY_ISO_Left_Tab) {
+ keynavDisplay.navigate_focus(
+ null, St.DirectionType.TAB_BACKWARD, false);
+ return Clutter.EVENT_STOP;
+ }
+
+ return Clutter.EVENT_PROPAGATE;
+ });
+
+ Main.wm.addKeybinding(
+ 'toggle-application-view',
+ new Gio.Settings({ schema_id: WindowManager.SHELL_KEYBINDINGS_SCHEMA }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW,
+ this._toggleAppsPage.bind(this));
+
+ Main.wm.addKeybinding('shift-overview-up',
+ new Gio.Settings({ schema_id: WindowManager.SHELL_KEYBINDINGS_SCHEMA }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW,
+ () => this._shiftState(Meta.MotionDirection.UP));
+
+ Main.wm.addKeybinding('shift-overview-down',
+ new Gio.Settings({ schema_id: WindowManager.SHELL_KEYBINDINGS_SCHEMA }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW,
+ () => this._shiftState(Meta.MotionDirection.DOWN));
+
+ this._update();
+ }
+
+ _getFitModeForState(state) {
+ switch (state) {
+ case ControlsState.HIDDEN:
+ case ControlsState.WINDOW_PICKER:
+ return WorkspacesView.FitMode.SINGLE;
+ case ControlsState.APP_GRID:
+ return WorkspacesView.FitMode.ALL;
+ default:
+ return WorkspacesView.FitMode.SINGLE;
+ }
+ }
+
+ _getThumbnailsBoxParams() {
+ const { initialState, finalState, progress } =
+ this._stateAdjustment.getStateTransitionParams();
+
+ const paramsForState = s => {
+ let opacity, scale, translationY;
+ switch (s) {
+ case ControlsState.HIDDEN:
+ case ControlsState.WINDOW_PICKER:
+ opacity = 255;
+ scale = 1;
+ translationY = 0;
+ break;
+ case ControlsState.APP_GRID:
+ opacity = 0;
+ scale = 0.5;
+ translationY = this._thumbnailsBox.height / 2;
+ break;
+ default:
+ opacity = 255;
+ scale = 1;
+ translationY = 0;
+ break;
+ }
+
+ return { opacity, scale, translationY };
+ };
+
+ const initialParams = paramsForState(initialState);
+ const finalParams = paramsForState(finalState);
+
+ return [
+ Util.lerp(initialParams.opacity, finalParams.opacity, progress),
+ Util.lerp(initialParams.scale, finalParams.scale, progress),
+ Util.lerp(initialParams.translationY, finalParams.translationY, progress),
+ ];
+ }
+
+ _updateThumbnailsBox(animate = false) {
+ const { shouldShow } = this._thumbnailsBox;
+ const { searchActive } = this._searchController;
+ const [opacity, scale, translationY] = this._getThumbnailsBoxParams();
+
+ const thumbnailsBoxVisible = shouldShow && !searchActive && opacity !== 0;
+ if (thumbnailsBoxVisible) {
+ this._thumbnailsBox.opacity = 0;
+ this._thumbnailsBox.visible = thumbnailsBoxVisible;
+ }
+
+ const params = {
+ opacity: searchActive ? 0 : opacity,
+ duration: animate ? SIDE_CONTROLS_ANIMATION_TIME : 0,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => (this._thumbnailsBox.visible = thumbnailsBoxVisible),
+ };
+
+ if (!searchActive) {
+ params.scale_x = scale;
+ params.scale_y = scale;
+ params.translation_y = translationY;
+ }
+
+ this._thumbnailsBox.ease(params);
+ }
+
+ _updateAppDisplayVisibility(stateTransitionParams = null) {
+ if (!stateTransitionParams)
+ stateTransitionParams = this._stateAdjustment.getStateTransitionParams();
+
+ const { initialState, finalState } = stateTransitionParams;
+ const state = Math.max(initialState, finalState);
+
+ this._appDisplay.visible =
+ state > ControlsState.WINDOW_PICKER &&
+ !this._searchController.searchActive;
+ }
+
+ _update() {
+ const params = this._stateAdjustment.getStateTransitionParams();
+
+ const fitMode = Util.lerp(
+ this._getFitModeForState(params.initialState),
+ this._getFitModeForState(params.finalState),
+ params.progress);
+
+ const { fitModeAdjustment } = this._workspacesDisplay;
+ fitModeAdjustment.value = fitMode;
+
+ this._updateThumbnailsBox();
+ this._updateAppDisplayVisibility(params);
+ }
+
+ _onSearchChanged() {
+ const { searchActive } = this._searchController;
+
+ if (!searchActive) {
+ this._updateAppDisplayVisibility();
+ this._workspacesDisplay.reactive = true;
+ this._workspacesDisplay.setPrimaryWorkspaceVisible(true);
+ } else {
+ this._searchController.show();
+ }
+
+ this._updateThumbnailsBox(true);
+
+ this._appDisplay.ease({
+ opacity: searchActive ? 0 : 255,
+ duration: SIDE_CONTROLS_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this._updateAppDisplayVisibility(),
+ });
+ this._workspacesDisplay.ease({
+ opacity: searchActive ? 0 : 255,
+ duration: SIDE_CONTROLS_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ this._workspacesDisplay.reactive = !searchActive;
+ this._workspacesDisplay.setPrimaryWorkspaceVisible(!searchActive);
+ },
+ });
+ this._searchController.ease({
+ opacity: searchActive ? 255 : 0,
+ duration: SIDE_CONTROLS_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => (this._searchController.visible = searchActive),
+ });
+ }
+
+ _onShowAppsButtonToggled() {
+ if (this._ignoreShowAppsButtonToggle)
+ return;
+
+ const checked = this.dash.showAppsButton.checked;
+
+ const value = checked
+ ? ControlsState.APP_GRID : ControlsState.WINDOW_PICKER;
+ this._stateAdjustment.remove_transition('value');
+ this._stateAdjustment.ease(value, {
+ duration: SIDE_CONTROLS_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ _toggleAppsPage() {
+ if (Main.overview.visible) {
+ const checked = this.dash.showAppsButton.checked;
+ this.dash.showAppsButton.checked = !checked;
+ } else {
+ Main.overview.show(ControlsState.APP_GRID);
+ }
+ }
+
+ _shiftState(direction) {
+ let { currentState, finalState } = this._stateAdjustment.getStateTransitionParams();
+
+ if (direction === Meta.MotionDirection.DOWN)
+ finalState = Math.max(finalState - 1, ControlsState.HIDDEN);
+ else if (direction === Meta.MotionDirection.UP)
+ finalState = Math.min(finalState + 1, ControlsState.APP_GRID);
+
+ if (finalState === currentState)
+ return;
+
+ if (currentState === ControlsState.HIDDEN &&
+ finalState === ControlsState.WINDOW_PICKER) {
+ Main.overview.show();
+ } else if (finalState === ControlsState.HIDDEN) {
+ Main.overview.hide();
+ } else {
+ this._stateAdjustment.ease(finalState, {
+ duration: SIDE_CONTROLS_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ this.dash.showAppsButton.checked =
+ finalState === ControlsState.APP_GRID;
+ },
+ });
+ }
+ }
+
+ _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;
+ }
+
+ vfunc_unmap() {
+ super.vfunc_unmap();
+ this._workspacesDisplay.hide();
+ }
+
+ prepareToEnterOverview() {
+ this._searchController.prepareToEnterOverview();
+ this._workspacesDisplay.prepareToEnterOverview();
+ }
+
+ prepareToLeaveOverview() {
+ this._workspacesDisplay.prepareToLeaveOverview();
+ }
+
+ animateToOverview(state, callback) {
+ this._ignoreShowAppsButtonToggle = true;
+
+ this._stateAdjustment.value = ControlsState.HIDDEN;
+ this._stateAdjustment.ease(state, {
+ duration: Overview.ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onStopped: () => {
+ if (callback)
+ callback();
+ },
+ });
+
+ this.dash.showAppsButton.checked =
+ state === ControlsState.APP_GRID;
+
+ this._ignoreShowAppsButtonToggle = false;
+ }
+
+ animateFromOverview(callback) {
+ this._ignoreShowAppsButtonToggle = true;
+
+ this._stateAdjustment.ease(ControlsState.HIDDEN, {
+ duration: Overview.ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onStopped: () => {
+ this.dash.showAppsButton.checked = false;
+ this._ignoreShowAppsButtonToggle = false;
+
+ if (callback)
+ callback();
+ },
+ });
+ }
+
+ getWorkspacesBoxForState(state) {
+ return this.layoutManager.getWorkspacesBoxForState(state);
+ }
+
+ gestureBegin(tracker) {
+ const baseDistance = global.screen_height;
+ const progress = this._stateAdjustment.value;
+ const points = [
+ ControlsState.HIDDEN,
+ ControlsState.WINDOW_PICKER,
+ ControlsState.APP_GRID,
+ ];
+
+ const transition = this._stateAdjustment.get_transition('value');
+ const cancelProgress = transition
+ ? transition.get_interval().peek_final_value()
+ : Math.round(progress);
+ this._stateAdjustment.remove_transition('value');
+
+ tracker.confirmSwipe(baseDistance, points, progress, cancelProgress);
+ this.prepareToEnterOverview();
+ this._stateAdjustment.gestureInProgress = true;
+ }
+
+ gestureProgress(progress) {
+ this._stateAdjustment.value = progress;
+ }
+
+ gestureEnd(target, duration, onComplete) {
+ if (target === ControlsState.HIDDEN)
+ this.prepareToLeaveOverview();
+
+ this.dash.showAppsButton.checked =
+ target === ControlsState.APP_GRID;
+
+ this._stateAdjustment.remove_transition('value');
+ this._stateAdjustment.ease(target, {
+ duration,
+ mode: Clutter.AnimationMode.EASE_OUT_CUBIC,
+ onComplete,
+ });
+
+ this._stateAdjustment.gestureInProgress = false;
+ }
+
+ async runStartupAnimation(callback) {
+ this._ignoreShowAppsButtonToggle = true;
+
+ this.prepareToEnterOverview();
+
+ this._stateAdjustment.value = ControlsState.HIDDEN;
+ this._stateAdjustment.ease(ControlsState.WINDOW_PICKER, {
+ duration: Overview.ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+
+ this.dash.showAppsButton.checked = false;
+ this._ignoreShowAppsButtonToggle = false;
+
+ // Set the opacity here to avoid a 1-frame flicker
+ this.opacity = 0;
+
+ // We can't run the animation before the first allocation happens
+ await this.layout_manager.ensureAllocation();
+
+ const { STARTUP_ANIMATION_TIME } = Layout;
+
+ // Opacity
+ this.ease({
+ opacity: 255,
+ duration: STARTUP_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.LINEAR,
+ });
+
+ // Search bar falls from the ceiling
+ const { primaryMonitor } = Main.layoutManager;
+ const [, y] = this._searchEntryBin.get_transformed_position();
+ const yOffset = y - primaryMonitor.y;
+
+ this._searchEntryBin.translation_y = -(yOffset + this._searchEntryBin.height);
+ this._searchEntryBin.ease({
+ translation_y: 0,
+ duration: STARTUP_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+
+ // The Dash rises from the bottom. This is the last animation to finish,
+ // so run the callback there.
+ this.dash.translation_y = this.dash.height + this.dash.margin_bottom;
+ this.dash.ease({
+ translation_y: 0,
+ delay: STARTUP_ANIMATION_TIME,
+ duration: STARTUP_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => callback(),
+ });
+ }
+
+ get searchEntry() {
+ return this._searchEntry;
+ }
+
+ get appDisplay() {
+ return this._appDisplay;
+ }
+});
diff --git a/js/ui/padOsd.js b/js/ui/padOsd.js
new file mode 100644
index 0000000..e1e24f7
--- /dev/null
+++ b/js/ui/padOsd.js
@@ -0,0 +1,991 @@
+// -*- 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 Signals = imports.misc.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);
+
+ this._menuManager = new PopupMenu.PopupMenuManager(this);
+ this._menuManager.addMenu(this._padChooserMenu);
+
+ 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);
+
+ const 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);
+
+ const 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._editMenuManager = new PopupMenu.PopupMenuManager(this);
+ this._editMenuManager.addMenu(this._editMenu);
+
+ 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() {
+ const 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);
+ this._curEdited = null;
+ this._prevEdited = null;
+ this._css = new TextDecoder().decode(css);
+ this._labels = [];
+ this._activeButtons = [];
+ super._init(params);
+ }
+
+ 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();
+ }
+
+ get editorActor() {
+ return this._editorActor;
+ }
+
+ set editorActor(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="${this._imageWidth}" height="${this._imageHeight}"> ` +
+ '<style type="text/css">';
+ }
+
+ _wrappingSvgFooter() {
+ return '%s%s%s'.format(
+ '</style>',
+ '<xi:include href="%s" />'.format(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 += `.${ch}.Leader { stroke: ${ACTIVE_COLOR} !important; }`;
+ css += `.${ch}.Button { stroke: ${ACTIVE_COLOR} !important; fill: ${ACTIVE_COLOR} !important; }`;
+ }
+
+ 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];
+
+ const [labelFound, labelPos] = this._handle.get_position_sub(`#${labelName}`);
+ const [, labelSize] = this._handle.get_dimensions_sub(`#${labelName}`);
+ if (!labelFound)
+ return [false];
+
+ const [leaderFound, leaderPos] = this._handle.get_position_sub(`#${leaderName}`);
+ const [, leaderSize] = this._handle.get_dimensions_sub(`#${leaderName}`);
+ if (!leaderFound)
+ return [false];
+
+ let direction;
+ if (labelPos.x > leaderPos.x + leaderSize.width)
+ direction = LTR;
+ else
+ direction = RTL;
+
+ let pos = {x: labelPos.x, y: labelPos.y + labelSize.height};
+ 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);
+ const labelName = `Label${ch}`;
+ const leaderName = `Leader${ch}`;
+ return [labelName, leaderName];
+ }
+
+ _getRingLabels(number, dir) {
+ let numStr = number > 0 ? (number + 1).toString() : '';
+ let dirStr = dir == CW ? 'CW' : 'CCW';
+ const labelName = `LabelRing${numStr}${dirStr}`;
+ const leaderName = `LeaderRing${numStr}${dirStr}`;
+ return [labelName, leaderName];
+ }
+
+ _getStripLabels(number, dir) {
+ let numStr = number > 0 ? (number + 1).toString() : '';
+ let dirStr = dir == UP ? 'Up' : 'Down';
+ const labelName = `LabelStrip${numStr}${dirStr}`;
+ const leaderName = `LeaderStrip${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._padChooser = null;
+
+ let seat = Clutter.get_default_backend().get_default_seat();
+ seat.connectObject(
+ 'device-added', (_seat, device) => {
+ if (device.get_device_type() === Clutter.InputDeviceType.PAD_DEVICE &&
+ this.padDevice.is_grouped(device)) {
+ this._groupPads.push(device);
+ this._updatePadChooser();
+ }
+ },
+ '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();
+ }
+ }, this);
+
+ 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);
+
+ const 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();
+
+ const 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();
+ this._grab = 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 ?? _('None');
+ }
+
+ _updateActionLabels() {
+ this._padDiagram.updateLabels(this._getActionText.bind(this));
+ }
+
+ vfunc_captured_event(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 ?? _('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 };
+
+ const 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${ch}`;
+ this._startActionEdition(key, Meta.PadActionType.BUTTON, button);
+ }
+
+ _startRingActionEdition(ring, dir, mode) {
+ let ch = String.fromCharCode('A'.charCodeAt() + ring);
+ const key = `ring${ch}-${dir === CCW ? 'ccw' : 'cw'}-mode-${mode}`;
+ this._startActionEdition(key, Meta.PadActionType.RING, ring, dir, mode);
+ }
+
+ _startStripActionEdition(strip, dir, mode) {
+ let ch = String.fromCharCode('A'.charCodeAt() + strip);
+ const key = `strip${ch}-${dir === UP ? 'up' : 'down'}-mode-${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._grab);
+ this._grab = null;
+ this._actionEditor.close();
+
+ this.emit('closed');
+ }
+});
+
+const PadOsdIface = loadInterfaceXML('org.gnome.Shell.Wacom.PadOsd');
+
+var PadOsdService = class extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ 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);
+ }
+};
diff --git a/js/ui/pageIndicators.js b/js/ui/pageIndicators.js
new file mode 100644
index 0000000..18a376c
--- /dev/null
+++ b/js/ui/pageIndicators.js
@@ -0,0 +1,116 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported PageIndicators */
+
+const { Clutter, Graphene, GObject, St } = imports.gi;
+
+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 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;
+ this._orientation = orientation;
+ }
+
+ 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;
+ const 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;
+ }
+});
diff --git a/js/ui/panel.js b/js/ui/panel.js
new file mode 100644
index 0000000..94dffda
--- /dev/null
+++ b/js/ui/panel.js
@@ -0,0 +1,774 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Panel */
+
+const { Atk, Clutter, GLib, GObject, Meta, Shell, St } = imports.gi;
+
+const Animation = imports.ui.animation;
+const { AppMenu } = imports.ui.appMenu;
+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 {QuickSettingsMenu, SystemIndicator} = imports.ui.quickSettings;
+const Main = imports.ui.main;
+
+var PANEL_ICON_SIZE = 16;
+var APP_MENU_ICON_MARGIN = 0;
+
+var BUTTON_DND_ACTIVATION_TIMEOUT = 250;
+
+const N_QUICK_SETTINGS_COLUMNS = 2;
+
+/**
+ * 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;
+
+ 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._visible = !Main.overview.visible;
+ if (!this._visible)
+ this.hide();
+ Main.overview.connectObject(
+ 'hiding', this._sync.bind(this),
+ 'showing', this._sync.bind(this), 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);
+
+ Shell.WindowTracker.get_default().connectObject('notify::focus-app',
+ this._focusAppChanged.bind(this), this);
+ Shell.AppSystem.get_default().connectObject('app-state-changed',
+ this._onAppStateChanged.bind(this), this);
+ global.window_manager.connectObject('switch-workspace',
+ this._sync.bind(this), this);
+
+ this._sync();
+ }
+
+ fadeIn() {
+ if (this._visible)
+ return;
+
+ this._visible = true;
+ this.reactive = true;
+ 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,
+ });
+ }
+
+ _syncIcon(app) {
+ const icon = app.create_icon_texture(PANEL_ICON_SIZE - APP_MENU_ICON_MARGIN);
+ this._iconBox.set_child(icon);
+ }
+
+ _onIconThemeChanged() {
+ if (this._iconBox.child == null)
+ return;
+
+ if (this._targetApp)
+ this._syncIcon(this._targetApp);
+ }
+
+ 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) {
+ this._targetApp?.disconnectObject(this);
+
+ this._targetApp = targetApp;
+
+ if (this._targetApp) {
+ this._targetApp.connectObject('notify::busy', this._sync.bind(this), this);
+ this._label.set_text(this._targetApp.get_name());
+ this.set_accessible_name(this._targetApp.get_name());
+
+ this._syncIcon(this._targetApp);
+ }
+ }
+
+ 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.menu.setApp(this._targetApp);
+ this.emit('changed');
+ }
+});
+
+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;
+ }
+});
+
+const UnsafeModeIndicator = GObject.registerClass(
+class UnsafeModeIndicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this._indicator = this._addIndicator();
+ this._indicator.icon_name = 'channel-insecure-symbolic';
+
+ global.context.bind_property('unsafe-mode',
+ this._indicator, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+ }
+});
+
+var QuickSettings = GObject.registerClass(
+class QuickSettings extends PanelMenu.Button {
+ _init() {
+ super._init(0.0, C_('System menu in the top bar', 'System'), true);
+
+ this._indicators = new St.BoxLayout({
+ style_class: 'panel-status-indicators-box',
+ });
+ this.add_child(this._indicators);
+
+ this.setMenu(new QuickSettingsMenu(this, N_QUICK_SETTINGS_COLUMNS));
+
+ if (Config.HAVE_NETWORKMANAGER)
+ this._network = new imports.ui.status.network.Indicator();
+ else
+ this._network = null;
+
+ if (Config.HAVE_BLUETOOTH)
+ this._bluetooth = new imports.ui.status.bluetooth.Indicator();
+ else
+ this._bluetooth = null;
+
+ this._system = new imports.ui.status.system.Indicator();
+ this._volume = new imports.ui.status.volume.Indicator();
+ this._brightness = new imports.ui.status.brightness.Indicator();
+ this._remoteAccess = new imports.ui.status.remoteAccess.RemoteAccessApplet();
+ this._location = new imports.ui.status.location.Indicator();
+ this._thunderbolt = new imports.ui.status.thunderbolt.Indicator();
+ this._nightLight = new imports.ui.status.nightLight.Indicator();
+ this._darkMode = new imports.ui.status.darkMode.Indicator();
+ this._powerProfiles = new imports.ui.status.powerProfiles.Indicator();
+ this._rfkill = new imports.ui.status.rfkill.Indicator();
+ this._autoRotate = new imports.ui.status.autoRotate.Indicator();
+ this._unsafeMode = new UnsafeModeIndicator();
+
+ this._indicators.add_child(this._brightness);
+ 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);
+ this._indicators.add_child(this._darkMode);
+ this._indicators.add_child(this._powerProfiles);
+ if (this._bluetooth)
+ this._indicators.add_child(this._bluetooth);
+ this._indicators.add_child(this._rfkill);
+ this._indicators.add_child(this._autoRotate);
+ this._indicators.add_child(this._volume);
+ this._indicators.add_child(this._unsafeMode);
+ this._indicators.add_child(this._system);
+
+ this._addItems(this._system.quickSettingsItems, N_QUICK_SETTINGS_COLUMNS);
+ this._addItems(this._volume.quickSettingsItems, N_QUICK_SETTINGS_COLUMNS);
+ this._addItems(this._brightness.quickSettingsItems, N_QUICK_SETTINGS_COLUMNS);
+
+ this._addItems(this._remoteAccess.quickSettingsItems);
+ this._addItems(this._thunderbolt.quickSettingsItems);
+ this._addItems(this._location.quickSettingsItems);
+ if (this._network)
+ this._addItems(this._network.quickSettingsItems);
+ if (this._bluetooth)
+ this._addItems(this._bluetooth.quickSettingsItems);
+ this._addItems(this._powerProfiles.quickSettingsItems);
+ this._addItems(this._nightLight.quickSettingsItems);
+ this._addItems(this._darkMode.quickSettingsItems);
+ this._addItems(this._rfkill.quickSettingsItems);
+ this._addItems(this._autoRotate.quickSettingsItems);
+ this._addItems(this._unsafeMode.quickSettingsItems);
+ }
+
+ _addItems(items, colSpan = 1) {
+ items.forEach(item => this.menu.addItem(item, colSpan));
+ }
+});
+
+const PANEL_ITEM_IMPLEMENTATIONS = {
+ 'activities': ActivitiesButton,
+ 'appMenu': AppMenuButton,
+ 'quickSettings': QuickSettings,
+ 'dateMenu': imports.ui.dateMenu.DateMenuButton,
+ 'a11y': imports.ui.status.accessibility.ATIndicator,
+ 'keyboard': imports.ui.status.keyboard.InputSourceIndicator,
+ 'dwellClick': imports.ui.status.dwellClick.DwellClickIndicator,
+ 'screenRecording': imports.ui.status.remoteAccess.ScreenRecordingIndicator,
+ 'screenSharing': imports.ui.status.remoteAccess.ScreenSharingIndicator,
+};
+
+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.connect('button-press-event', this._onButtonPress.bind(this));
+ this.connect('touch-event', this._onTouchEvent.bind(this));
+
+ 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);
+ }
+
+ _tryDragWindow(event) {
+ if (Main.modalCount > 0)
+ return Clutter.EVENT_PROPAGATE;
+
+ const targetActor = global.stage.get_event_actor(event);
+ if (targetActor !== this)
+ return Clutter.EVENT_PROPAGATE;
+
+ const [x, y] = event.get_coords();
+ let dragWindow = this._getDraggableWindowForPosition(x);
+
+ if (!dragWindow)
+ return Clutter.EVENT_PROPAGATE;
+
+ const button = event.type() === Clutter.EventType.BUTTON_PRESS
+ ? event.get_button() : -1;
+
+ return global.display.begin_grab_op(
+ dragWindow,
+ Meta.GrabOp.MOVING,
+ false, /* pointer grab */
+ true, /* frame action */
+ button,
+ event.get_state(),
+ event.get_time(),
+ x, y) ? Clutter.EVENT_STOP : Clutter.EVENT_PROPAGATE;
+ }
+
+ _onButtonPress(actor, event) {
+ if (event.get_button() !== Clutter.BUTTON_PRIMARY)
+ return Clutter.EVENT_PROPAGATE;
+
+ return this._tryDragWindow(event);
+ }
+
+ _onTouchEvent(actor, event) {
+ if (event.type() !== Clutter.EventType.TOUCH_BEGIN)
+ return Clutter.EVENT_PROPAGATE;
+
+ return this._tryDragWindow(event);
+ }
+
+ 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);
+ }
+
+ _closeMenu(indicator) {
+ if (!indicator || !indicator.mapped)
+ return; // menu not supported by current session mode
+
+ if (!indicator.reactive)
+ return;
+
+ indicator.menu.close();
+ }
+
+ toggleAppMenu() {
+ this._toggleMenu(this.statusArea.appMenu);
+ }
+
+ toggleCalendar() {
+ this._toggleMenu(this.statusArea.dateMenu);
+ }
+
+ closeCalendar() {
+ this._closeMenu(this.statusArea.dateMenu);
+ }
+
+ closeQuickSettings() {
+ this._closeMenu(this.statusArea.quickSettings);
+ }
+
+ 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.remove_style_class_name(this._sessionStyle);
+
+ this._sessionStyle = Main.sessionMode.panelStyle;
+ if (this._sessionStyle)
+ this.add_style_class_name(this._sessionStyle);
+ }
+
+ _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);
+ 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 ${role}`);
+
+ if (!(indicator instanceof PanelMenu.Button))
+ throw new TypeError('Status indicator must be an instance of PanelMenu.Button');
+
+ 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;
+ }
+
+ _onMenuSet(indicator) {
+ if (!indicator.menu || indicator.menu._openChangedId)
+ return;
+
+ this.menuManager.addMenu(indicator.menu);
+
+ 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..a5445ce
--- /dev/null
+++ b/js/ui/panelMenu.js
@@ -0,0 +1,233 @@
+// -*- 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 ?? '',
+ 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));
+
+ this.connect('key-press-event',
+ (o, ev) => global.focus_manager.navigate_from_event(ev));
+ }
+
+ 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: ${maxHeight}px;`;
+ }
+
+ _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..2af35b6
--- /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 } = 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 = global.backend.get_core_idle_monitor();
+ 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..2f57c58
--- /dev/null
+++ b/js/ui/popupMenu.js
@@ -0,0 +1,1415 @@
+// -*- 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.misc.signals;
+
+const BoxPointer = imports.ui.boxpointer;
+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;
+ }
+
+ const 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 (global.focus_manager.navigate_from_event(Clutter.get_current_event()))
+ return Clutter.EVENT_STOP;
+
+ 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() {
+ const parentSensitive = 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,
+ y_expand: true,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ 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,
+ 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,
+ y_expand: true,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ 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',
+ y_expand: true,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ 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,
+ y_expand: true,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ this.add_child(this.label);
+ this.label_actor = this.label;
+
+ this.set_child_above_sibling(this._ornamentLabel, 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 extends Signals.EventEmitter {
+ constructor(sourceActor, styleClass) {
+ super();
+
+ if (this.constructor === PopupMenuBase)
+ throw new TypeError(`Cannot instantiate abstract class ${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;
+
+ this._activeMenuItem = null;
+ this._settingsActions = { };
+
+ this._sensitive = true;
+
+ Main.sessionMode.connectObject('updated', () => this._sessionUpdated(), this);
+ }
+
+ _getTopMenu() {
+ if (this._parent)
+ return this._parent._getTopMenu();
+ else
+ return this;
+ }
+
+ _setParent(parent) {
+ this._parent = parent;
+ }
+
+ getSensitive() {
+ const parentSensitive = 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 ${desktopFile} could not be loaded!`);
+ return;
+ }
+
+ Main.overview.hide();
+ Main.panel.closeQuickSettings();
+ 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.connectObject(
+ 'notify::active', () => {
+ const { active } = menuItem;
+ 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);
+ }
+ },
+ 'notify::sensitive', () => {
+ const { sensitive } = menuItem;
+ 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();
+ }
+ },
+ 'activate', () => {
+ this.emit('activate', menuItem);
+ this.itemActivated(BoxPointer.PopupAnimation.FULL);
+ }, GObject.ConnectFlags.AFTER,
+ 'destroy', () => {
+ if (menuItem === this._activeMenuItem)
+ this._activeMenuItem = null;
+ }, this);
+
+ this.connectObject('notify::sensitive',
+ () => menuItem.syncSensitive(), menuItem);
+ }
+
+ _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) {
+ menuItem.connectObject(
+ 'active-changed', this._subMenuActiveChanged.bind(this),
+ 'destroy', () => this.length--, this);
+
+ this.connectObject(
+ 'open-state-changed', (self, open) => {
+ if (open)
+ menuItem.open();
+ else
+ menuItem.close();
+ },
+ 'menu-closed', () => menuItem.emit('menu-closed'),
+ 'notify::sensitive', () => menuItem.emit('notify::sensitive'),
+ menuItem);
+ } 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);
+ menuItem.menu.connectObject('active-changed',
+ this._subMenuActiveChanged.bind(this), this);
+ this.connectObject('menu-closed', () => {
+ menuItem.menu.close(BoxPointer.PopupAnimation.NONE);
+ }, menuItem);
+ } 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.
+ this.connectObject('open-state-changed', () => {
+ this._updateSeparatorVisibility(menuItem);
+ }, menuItem);
+ } 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.disconnectObject(this);
+ }
+};
+
+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.sourceActor.connectObject(
+ 'key-press-event', this._onKeyPress.bind(this),
+ 'notify::mapped', () => {
+ if (!this.sourceActor.mapped)
+ this.close();
+ }, this);
+ }
+
+ 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 == 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() {
+ this.sourceActor?.disconnectObject(this);
+
+ if (this._systemModalOpenedId)
+ Main.layoutManager.disconnect(this._systemModalOpenedId);
+ this._systemModalOpenedId = 0;
+
+ super.destroy();
+ }
+};
+
+var PopupDummyMenu = class extends Signals.EventEmitter {
+ constructor(sourceActor) {
+ super();
+
+ this.sourceActor = sourceActor;
+ this.actor = sourceActor;
+ this.actor._delegate = this;
+ }
+
+ getSensitive() {
+ return true;
+ }
+
+ get sensitive() {
+ return this.getSensitive();
+ }
+
+ open() {
+ if (this.isOpen)
+ return;
+ this.isOpen = true;
+ this.emit('open-state-changed', true);
+ }
+
+ close() {
+ if (!this.isOpen)
+ return;
+ this.isOpen = false;
+ this.emit('open-state-changed', false);
+ }
+
+ toggle() {}
+
+ destroy() {
+ this.emit('destroy');
+ }
+};
+
+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;
+
+ this.actor.add_style_class_name('popup-menu-section');
+ }
+
+ // 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) {
+ this._grabParams = Params.parse(grabParams,
+ { actionMode: Shell.ActionMode.POPUP });
+ global.stage.connect('notify::key-focus', () => {
+ if (!this.activeMenu)
+ return;
+
+ let actor = global.stage.get_key_focus();
+ let newMenu = this._findMenuForSource(actor);
+
+ if (newMenu)
+ this._changeMenu(newMenu);
+ });
+ this._menus = [];
+ }
+
+ addMenu(menu, position) {
+ if (this._menus.includes(menu))
+ return;
+
+ menu.connectObject(
+ 'open-state-changed', this._onMenuOpenState.bind(this),
+ 'destroy', () => this.removeMenu(menu), this);
+ menu.actor.connectObject('captured-event',
+ this._onCapturedEvent.bind(this), this);
+
+ if (position == undefined)
+ this._menus.push(menu);
+ else
+ this._menus.splice(position, 0, menu);
+ }
+
+ removeMenu(menu) {
+ if (menu === this.activeMenu) {
+ Main.popModal(this._grab);
+ this._grab = null;
+ }
+
+ const position = this._menus.indexOf(menu);
+ if (position == -1) // not a menu we manage
+ return;
+
+ menu.disconnectObject(this);
+ menu.actor.disconnectObject(this);
+
+ this._menus.splice(position, 1);
+ }
+
+ ignoreRelease() {
+ }
+
+ _onMenuOpenState(menu, open) {
+ if (open && this.activeMenu === menu)
+ return;
+
+ if (open) {
+ const oldMenu = this.activeMenu;
+ const oldGrab = this._grab;
+ this._grab = Main.pushModal(menu.actor, this._grabParams);
+ this.activeMenu = menu;
+ oldMenu?.close(BoxPointer.PopupAnimation.FADE);
+ if (oldGrab)
+ Main.popModal(oldGrab);
+ } else if (this.activeMenu === menu) {
+ this.activeMenu = null;
+ Main.popModal(this._grab);
+ this._grab = null;
+ }
+ }
+
+ _changeMenu(newMenu) {
+ newMenu.open(this.activeMenu
+ ? BoxPointer.PopupAnimation.FADE
+ : BoxPointer.PopupAnimation.FULL);
+ }
+
+ _onCapturedEvent(actor, event) {
+ let menu = actor._delegate;
+ const targetActor = global.stage.get_event_actor(event);
+
+ if (event.type() === Clutter.EventType.KEY_PRESS) {
+ let symbol = event.get_key_symbol();
+ if (symbol === Clutter.KEY_Down &&
+ global.stage.get_key_focus() === menu.actor) {
+ actor.navigate_focus(null, St.DirectionType.TAB_FORWARD, false);
+ return Clutter.EVENT_STOP;
+ } else if (symbol === Clutter.KEY_Escape && menu.isOpen) {
+ menu.close(BoxPointer.PopupAnimation.FULL);
+ return Clutter.EVENT_STOP;
+ }
+ } else if (event.type() === Clutter.EventType.ENTER &&
+ (event.get_flags() & Clutter.EventFlags.FLAG_GRAB_NOTIFY) === 0) {
+ let hoveredMenu = this._findMenuForSource(targetActor);
+
+ if (hoveredMenu && hoveredMenu !== menu)
+ this._changeMenu(hoveredMenu);
+ } else if ((event.type() === Clutter.EventType.BUTTON_PRESS ||
+ event.type() === Clutter.EventType.TOUCH_BEGIN) &&
+ !actor.contains(targetActor)) {
+ menu.close(BoxPointer.PopupAnimation.FULL);
+ }
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _findMenuForSource(source) {
+ while (source) {
+ let actor = source;
+ const menu = this._menus.find(m => m.sourceActor === actor);
+ if (menu)
+ return menu;
+ source = source.get_parent();
+ }
+
+ return null;
+ }
+
+ _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/quickSettings.js b/js/ui/quickSettings.js
new file mode 100644
index 0000000..7cfd4f4
--- /dev/null
+++ b/js/ui/quickSettings.js
@@ -0,0 +1,717 @@
+/* exported QuickToggle, QuickMenuToggle, QuickSlider, QuickSettingsMenu, SystemIndicator */
+const {Atk, Clutter, Gio, GLib, GObject, Graphene, Meta, Pango, St} = imports.gi;
+
+const Main = imports.ui.main;
+const PopupMenu = imports.ui.popupMenu;
+const {Slider} = imports.ui.slider;
+
+const {PopupAnimation} = imports.ui.boxpointer;
+
+const DIM_BRIGHTNESS = -0.4;
+const POPUP_ANIMATION_TIME = 400;
+
+var QuickSettingsItem = GObject.registerClass({
+ Properties: {
+ 'has-menu': GObject.ParamSpec.boolean(
+ 'has-menu', 'has-menu', 'has-menu',
+ GObject.ParamFlags.READWRITE |
+ GObject.ParamFlags.CONSTRUCT_ONLY,
+ false),
+ },
+}, class QuickSettingsItem extends St.Button {
+ _init(params) {
+ super._init(params);
+
+ if (this.hasMenu) {
+ this.menu = new QuickToggleMenu(this);
+ this.menu.actor.hide();
+
+ this._menuManager = new PopupMenu.PopupMenuManager(this);
+ this._menuManager.addMenu(this.menu);
+ }
+ }
+});
+
+var QuickToggle = GObject.registerClass({
+ Properties: {
+ 'label': GObject.ParamSpec.override('label', St.Button),
+ 'icon-name': GObject.ParamSpec.override('icon-name', St.Button),
+ 'gicon': GObject.ParamSpec.object('gicon', '', '',
+ GObject.ParamFlags.READWRITE,
+ Gio.Icon),
+ },
+}, class QuickToggle extends QuickSettingsItem {
+ _init(params) {
+ super._init({
+ style_class: 'quick-toggle button',
+ accessible_role: Atk.Role.TOGGLE_BUTTON,
+ can_focus: true,
+ ...params,
+ });
+
+ this._box = new St.BoxLayout();
+ this.set_child(this._box);
+
+ const iconProps = {};
+ if (this.gicon)
+ iconProps['gicon'] = this.gicon;
+ if (this.iconName)
+ iconProps['icon-name'] = this.iconName;
+
+ this._icon = new St.Icon({
+ style_class: 'quick-toggle-icon',
+ x_expand: false,
+ ...iconProps,
+ });
+ this._box.add_child(this._icon);
+
+ // bindings are in the "wrong" direction, so we
+ // pick up StIcon's linking of the two properties
+ this._icon.bind_property('icon-name',
+ this, 'icon-name',
+ GObject.BindingFlags.SYNC_CREATE |
+ GObject.BindingFlags.BIDIRECTIONAL);
+ this._icon.bind_property('gicon',
+ this, 'gicon',
+ GObject.BindingFlags.SYNC_CREATE |
+ GObject.BindingFlags.BIDIRECTIONAL);
+
+ this._label = new St.Label({
+ style_class: 'quick-toggle-label',
+ y_align: Clutter.ActorAlign.CENTER,
+ x_align: Clutter.ActorAlign.START,
+ x_expand: true,
+ });
+ this.label_actor = this._label;
+ this._box.add_child(this._label);
+
+ this._label.clutter_text.ellipsize = Pango.EllipsizeMode.END;
+
+ this.bind_property('label',
+ this._label, 'text',
+ GObject.BindingFlags.SYNC_CREATE);
+ }
+});
+
+var QuickMenuToggle = GObject.registerClass({
+ Properties: {
+ 'menu-enabled': GObject.ParamSpec.boolean(
+ 'menu-enabled', '', '',
+ GObject.ParamFlags.READWRITE,
+ true),
+ },
+}, class QuickMenuToggle extends QuickToggle {
+ _init(params) {
+ super._init({
+ ...params,
+ hasMenu: true,
+ });
+
+ this.add_style_class_name('quick-menu-toggle');
+
+ this._menuButton = new St.Button({
+ child: new St.Icon({
+ style_class: 'quick-toggle-arrow',
+ icon_name: 'go-next-symbolic',
+ }),
+ x_expand: false,
+ y_expand: true,
+ });
+ this._box.add_child(this._menuButton);
+
+ this.bind_property('menu-enabled',
+ this._menuButton, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+ this.bind_property('reactive',
+ this._menuButton, 'reactive',
+ GObject.BindingFlags.SYNC_CREATE);
+ this._menuButton.connect('clicked', () => this.menu.open());
+ this.connect('popup-menu', () => {
+ if (this.menuEnabled)
+ this.menu.open();
+ });
+ }
+});
+
+var QuickSlider = GObject.registerClass({
+ Properties: {
+ 'icon-name': GObject.ParamSpec.override('icon-name', St.Button),
+ 'gicon': GObject.ParamSpec.object('gicon', '', '',
+ GObject.ParamFlags.READWRITE,
+ Gio.Icon),
+ 'menu-enabled': GObject.ParamSpec.boolean(
+ 'menu-enabled', '', '',
+ GObject.ParamFlags.READWRITE,
+ false),
+ },
+}, class QuickSlider extends QuickSettingsItem {
+ _init(params) {
+ super._init({
+ style_class: 'quick-slider',
+ ...params,
+ can_focus: false,
+ reactive: false,
+ hasMenu: true,
+ });
+
+ const box = new St.BoxLayout();
+ this.set_child(box);
+
+ const iconProps = {};
+ if (this.gicon)
+ iconProps['gicon'] = this.gicon;
+ if (this.iconName)
+ iconProps['icon-name'] = this.iconName;
+
+ this._icon = new St.Icon({
+ style_class: 'quick-toggle-icon',
+ ...iconProps,
+ });
+ box.add_child(this._icon);
+
+ // bindings are in the "wrong" direction, so we
+ // pick up StIcon's linking of the two properties
+ this._icon.bind_property('icon-name',
+ this, 'icon-name',
+ GObject.BindingFlags.SYNC_CREATE |
+ GObject.BindingFlags.BIDIRECTIONAL);
+ this._icon.bind_property('gicon',
+ this, 'gicon',
+ GObject.BindingFlags.SYNC_CREATE |
+ GObject.BindingFlags.BIDIRECTIONAL);
+
+ this.slider = new Slider(0);
+
+ // for focus indication
+ const sliderBin = new St.Bin({
+ style_class: 'slider-bin',
+ child: this.slider,
+ reactive: true,
+ can_focus: true,
+ x_expand: true,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ box.add_child(sliderBin);
+
+ // Make the slider bin transparent for a11y
+ const sliderAccessible = this.slider.get_accessible();
+ sliderAccessible.set_parent(sliderBin.get_parent().get_accessible());
+ sliderBin.set_accessible(sliderAccessible);
+ sliderBin.connect('event', (bin, event) => this.slider.event(event, false));
+
+ this._menuButton = new St.Button({
+ child: new St.Icon({icon_name: 'go-next-symbolic'}),
+ style_class: 'icon-button flat',
+ can_focus: true,
+ x_expand: false,
+ y_expand: true,
+ });
+ box.add_child(this._menuButton);
+
+ this.bind_property('menu-enabled',
+ this._menuButton, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+ this._menuButton.connect('clicked', () => this.menu.open());
+ this.slider.connect('popup-menu', () => {
+ if (this.menuEnabled)
+ this.menu.open();
+ });
+ }
+});
+
+class QuickToggleMenu extends PopupMenu.PopupMenuBase {
+ constructor(sourceActor) {
+ super(sourceActor, 'quick-toggle-menu');
+
+ const constraints = new Clutter.BindConstraint({
+ coordinate: Clutter.BindCoordinate.Y,
+ source: sourceActor,
+ });
+ sourceActor.bind_property('height',
+ constraints, 'offset',
+ GObject.BindingFlags.DEFAULT);
+
+ this.actor = new St.Widget({
+ layout_manager: new Clutter.BinLayout(),
+ style_class: 'quick-toggle-menu-container',
+ reactive: true,
+ x_expand: true,
+ y_expand: false,
+ constraints,
+ });
+ this.actor._delegate = this;
+ this.actor.add_child(this.box);
+
+ global.focus_manager.add_group(this.actor);
+
+ const headerLayout = new Clutter.GridLayout();
+ this._header = new St.Widget({
+ style_class: 'header',
+ layout_manager: headerLayout,
+ visible: false,
+ });
+ headerLayout.hookup_style(this._header);
+ this.box.add_child(this._header);
+
+ this._headerIcon = new St.Icon({
+ style_class: 'icon',
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ this._headerTitle = new St.Label({
+ style_class: 'title',
+ y_align: Clutter.ActorAlign.CENTER,
+ y_expand: true,
+ });
+ this._headerSubtitle = new St.Label({
+ style_class: 'subtitle',
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ this._headerSpacer = new Clutter.Actor({x_expand: true});
+
+ const side = this.actor.text_direction === Clutter.TextDirection.RTL
+ ? Clutter.GridPosition.LEFT
+ : Clutter.GridPosition.RIGHT;
+
+ headerLayout.attach(this._headerIcon, 0, 0, 1, 2);
+ headerLayout.attach_next_to(this._headerTitle,
+ this._headerIcon, side, 1, 1);
+ headerLayout.attach_next_to(this._headerSpacer,
+ this._headerTitle, side, 1, 1);
+ headerLayout.attach_next_to(this._headerSubtitle,
+ this._headerTitle, Clutter.GridPosition.BOTTOM, 1, 1);
+
+ sourceActor.connect('notify::checked',
+ () => this._syncChecked());
+ this._syncChecked();
+ }
+
+ setHeader(icon, title, subtitle = '') {
+ if (icon instanceof Gio.Icon)
+ this._headerIcon.gicon = icon;
+ else
+ this._headerIcon.icon_name = icon;
+
+ this._headerTitle.text = title;
+ this._headerSubtitle.set({
+ text: subtitle,
+ visible: !!subtitle,
+ });
+
+ this._header.show();
+ }
+
+ addHeaderSuffix(actor) {
+ const {layoutManager: headerLayout} = this._header;
+ const side = this.actor.text_direction === Clutter.TextDirection.RTL
+ ? Clutter.GridPosition.LEFT
+ : Clutter.GridPosition.RIGHT;
+ this._header.remove_child(this._headerSpacer);
+ headerLayout.attach_next_to(actor, this._headerTitle, side, 1, 1);
+ headerLayout.attach_next_to(this._headerSpacer, actor, side, 1, 1);
+ }
+
+ open(animate) {
+ if (this.isOpen)
+ return;
+
+ this.actor.show();
+ this.isOpen = true;
+
+ this.actor.height = -1;
+ const [targetHeight] = this.actor.get_preferred_height(-1);
+
+ const duration = animate !== PopupAnimation.NONE
+ ? POPUP_ANIMATION_TIME / 2
+ : 0;
+
+ this.actor.height = 0;
+ this.box.opacity = 0;
+ this.actor.ease({
+ duration,
+ height: targetHeight,
+ onComplete: () => {
+ this.box.ease({
+ duration,
+ opacity: 255,
+ });
+ this.actor.height = -1;
+ },
+ });
+ this.emit('open-state-changed', true);
+ }
+
+ close(animate) {
+ if (!this.isOpen)
+ return;
+
+ const duration = animate !== PopupAnimation.NONE
+ ? POPUP_ANIMATION_TIME / 2
+ : 0;
+
+ this.box.ease({
+ duration,
+ opacity: 0,
+ onComplete: () => {
+ this.actor.ease({
+ duration,
+ height: 0,
+ onComplete: () => {
+ this.actor.hide();
+ this.emit('menu-closed');
+ },
+ });
+ },
+ });
+
+ this.isOpen = false;
+ this.emit('open-state-changed', false);
+ }
+
+ _syncChecked() {
+ if (this.sourceActor.checked)
+ this._headerIcon.add_style_class_name('active');
+ else
+ this._headerIcon.remove_style_class_name('active');
+ }
+
+ // expected on toplevel menus
+ _setOpenedSubMenu(submenu) {
+ this._openedSubMenu?.close(true);
+ this._openedSubMenu = submenu;
+ }
+}
+
+const QuickSettingsLayoutMeta = GObject.registerClass({
+ Properties: {
+ 'column-span': GObject.ParamSpec.int(
+ 'column-span', '', '',
+ GObject.ParamFlags.READWRITE,
+ 1, GLib.MAXINT32, 1),
+ },
+}, class QuickSettingsLayoutMeta extends Clutter.LayoutMeta {});
+
+const QuickSettingsLayout = GObject.registerClass({
+ Properties: {
+ 'row-spacing': GObject.ParamSpec.int(
+ 'row-spacing', 'row-spacing', 'row-spacing',
+ GObject.ParamFlags.READWRITE,
+ 0, GLib.MAXINT32, 0),
+ 'column-spacing': GObject.ParamSpec.int(
+ 'column-spacing', 'column-spacing', 'column-spacing',
+ GObject.ParamFlags.READWRITE,
+ 0, GLib.MAXINT32, 0),
+ 'n-columns': GObject.ParamSpec.int(
+ 'n-columns', 'n-columns', 'n-columns',
+ GObject.ParamFlags.READWRITE,
+ 1, GLib.MAXINT32, 1),
+ },
+}, class QuickSettingsLayout extends Clutter.LayoutManager {
+ _init(overlay, params) {
+ super._init(params);
+
+ this._overlay = overlay;
+ }
+
+ _containerStyleChanged() {
+ const node = this._container.get_theme_node();
+
+ let changed = false;
+ let found, length;
+ [found, length] = node.lookup_length('spacing-rows', false);
+ changed ||= found;
+ if (found)
+ this.rowSpacing = length;
+
+ [found, length] = node.lookup_length('spacing-columns', false);
+ changed ||= found;
+ if (found)
+ this.columnSpacing = length;
+
+ if (changed)
+ this.layout_changed();
+ }
+
+ _getColSpan(container, child) {
+ const {columnSpan} = this.get_child_meta(container, child);
+ return Math.clamp(columnSpan, 1, this.nColumns);
+ }
+
+ _getMaxChildWidth(container) {
+ let [minWidth, natWidth] = [0, 0];
+
+ for (const child of container) {
+ if (child === this._overlay)
+ continue;
+
+ const [childMin, childNat] = child.get_preferred_width(-1);
+ const colSpan = this._getColSpan(container, child);
+ minWidth = Math.max(minWidth, childMin / colSpan);
+ natWidth = Math.max(natWidth, childNat / colSpan);
+ }
+
+ return [minWidth, natWidth];
+ }
+
+ _getRows(container) {
+ const rows = [];
+ let lineIndex = 0;
+ let curRow;
+
+ /** private */
+ function appendRow() {
+ curRow = [];
+ rows.push(curRow);
+ lineIndex = 0;
+ }
+
+ for (const child of container) {
+ if (!child.visible)
+ continue;
+
+ if (child === this._overlay)
+ continue;
+
+ if (lineIndex === 0)
+ appendRow();
+
+ const colSpan = this._getColSpan(container, child);
+ const fitsRow = lineIndex + colSpan <= this.nColumns;
+
+ if (!fitsRow)
+ appendRow();
+
+ curRow.push(child);
+ lineIndex = (lineIndex + colSpan) % this.nColumns;
+ }
+
+ return rows;
+ }
+
+ _getRowHeight(children) {
+ let [minHeight, natHeight] = [0, 0];
+
+ children.forEach(child => {
+ const [childMin, childNat] = child.get_preferred_height(-1);
+ minHeight = Math.max(minHeight, childMin);
+ natHeight = Math.max(natHeight, childNat);
+ });
+
+ return [minHeight, natHeight];
+ }
+
+ vfunc_get_child_meta_type() {
+ return QuickSettingsLayoutMeta.$gtype;
+ }
+
+ vfunc_set_container(container) {
+ this._container?.disconnectObject(this);
+
+ this._container = container;
+
+ this._container?.connectObject('style-changed',
+ () => this._containerStyleChanged(), this);
+ }
+
+ vfunc_get_preferred_width(container, _forHeight) {
+ const [childMin, childNat] = this._getMaxChildWidth(container);
+ const spacing = (this.nColumns - 1) * this.column_spacing;
+ return [this.nColumns * childMin + spacing, this.nColumns * childNat + spacing];
+ }
+
+ vfunc_get_preferred_height(container, _forWidth) {
+ const rows = this._getRows(container);
+
+ let [minHeight, natHeight] = this._overlay
+ ? this._overlay.get_preferred_height(-1)
+ : [0, 0];
+
+ const spacing = (rows.length - 1) * this.row_spacing;
+ minHeight += spacing;
+ natHeight += spacing;
+
+ rows.forEach(row => {
+ const [rowMin, rowNat] = this._getRowHeight(row);
+ minHeight += rowMin;
+ natHeight += rowNat;
+ });
+
+ return [minHeight, natHeight];
+ }
+
+ vfunc_allocate(container, box) {
+ const rows = this._getRows(container);
+
+ const [, overlayHeight] = this._overlay
+ ? this._overlay.get_preferred_height(-1)
+ : [0, 0];
+
+ const availWidth = box.get_width() - (this.nColumns - 1) * this.column_spacing;
+ const childWidth = Math.floor(availWidth / this.nColumns);
+
+ this._overlay?.allocate_available_size(0, 0, box.get_width(), box.get_height());
+
+ const isRtl = container.text_direction === Clutter.TextDirection.RTL;
+
+ const childBox = new Clutter.ActorBox();
+ let y = box.y1;
+ rows.forEach(row => {
+ const [, rowNat] = this._getRowHeight(row);
+
+ let lineIndex = 0;
+ row.forEach(child => {
+ const colSpan = this._getColSpan(container, child);
+ const width =
+ childWidth * colSpan + this.column_spacing * (colSpan - 1);
+ let x = box.x1 + lineIndex * (childWidth + this.column_spacing);
+ if (isRtl)
+ x = box.x2 - width - x;
+
+ childBox.set_origin(x, y);
+ childBox.set_size(width, rowNat);
+ child.allocate(childBox);
+
+ lineIndex = (lineIndex + colSpan) % this.nColumns;
+ });
+
+ y += rowNat + this.row_spacing;
+
+ if (row.some(c => c.menu?.actor.visible))
+ y += overlayHeight;
+ });
+ }
+});
+
+var QuickSettingsMenu = class extends PopupMenu.PopupMenu {
+ constructor(sourceActor, nColumns = 1) {
+ super(sourceActor, 0, St.Side.TOP);
+
+ this.actor = new St.Widget({reactive: true, width: 0, height: 0});
+ this.actor.add_child(this._boxPointer);
+ this.actor._delegate = this;
+
+ this.connect('menu-closed', () => this.actor.hide());
+
+ Main.layoutManager.connectObject('system-modal-opened',
+ () => this.close(), this);
+
+ this._dimEffect = new Clutter.BrightnessContrastEffect({
+ enabled: false,
+ });
+ this._boxPointer.add_effect_with_name('dim', this._dimEffect);
+ this.box.add_style_class_name('quick-settings');
+
+ // Overlay layer for menus
+ this._overlay = new Clutter.Actor({
+ layout_manager: new Clutter.BinLayout(),
+ });
+
+ // "clone"
+ const placeholder = new Clutter.Actor({
+ constraints: new Clutter.BindConstraint({
+ coordinate: Clutter.BindCoordinate.HEIGHT,
+ source: this._overlay,
+ }),
+ });
+
+ this._grid = new St.Widget({
+ style_class: 'quick-settings-grid',
+ layout_manager: new QuickSettingsLayout(placeholder, {
+ nColumns,
+ }),
+ });
+ this.box.add_child(this._grid);
+ this._grid.add_child(placeholder);
+
+ const yConstraint = new Clutter.BindConstraint({
+ coordinate: Clutter.BindCoordinate.Y,
+ source: this._boxPointer,
+ });
+
+ // Pick up additional spacing from any intermediate actors
+ const updateOffset = () => {
+ Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
+ const offset = this._grid.apply_relative_transform_to_point(
+ this._boxPointer, new Graphene.Point3D());
+ yConstraint.offset = offset.y;
+ return GLib.SOURCE_REMOVE;
+ });
+ };
+ this._grid.connect('notify::y', updateOffset);
+ this.box.connect('notify::y', updateOffset);
+ this._boxPointer.bin.connect('notify::y', updateOffset);
+
+ this._overlay.add_constraint(yConstraint);
+ this._overlay.add_constraint(new Clutter.BindConstraint({
+ coordinate: Clutter.BindCoordinate.X,
+ source: this._boxPointer,
+ }));
+ this._overlay.add_constraint(new Clutter.BindConstraint({
+ coordinate: Clutter.BindCoordinate.WIDTH,
+ source: this._boxPointer,
+ }));
+
+ this.actor.add_child(this._overlay);
+ }
+
+ addItem(item, colSpan = 1) {
+ this._grid.add_child(item);
+ this._grid.layout_manager.child_set_property(
+ this._grid, item, 'column-span', colSpan);
+
+ if (item.menu) {
+ this._overlay.add_child(item.menu.actor);
+
+ item.menu.connect('open-state-changed', (m, isOpen) => {
+ this._setDimmed(isOpen);
+ this._activeMenu = isOpen ? item.menu : null;
+ });
+ }
+ }
+
+ open(animate) {
+ this.actor.show();
+ super.open(animate);
+ }
+
+ close(animate) {
+ this._activeMenu?.close(animate);
+ super.close(animate);
+ }
+
+ _setDimmed(dim) {
+ const val = 127 * (1 + (dim ? 1 : 0) * DIM_BRIGHTNESS);
+ const color = Clutter.Color.new(val, val, val, 255);
+
+ this._boxPointer.ease_property('@effects.dim.brightness', color, {
+ mode: Clutter.AnimationMode.LINEAR,
+ duration: POPUP_ANIMATION_TIME,
+ onStopped: () => (this._dimEffect.enabled = dim),
+ });
+ this._dimEffect.enabled = true;
+ }
+};
+
+var SystemIndicator = GObject.registerClass(
+class SystemIndicator extends St.BoxLayout {
+ _init() {
+ super._init({
+ style_class: 'panel-status-indicators-box',
+ reactive: true,
+ visible: false,
+ });
+
+ this.quickSettingsItems = [];
+ }
+
+ _syncIndicatorsVisible() {
+ this.visible = this.get_children().some(a => a.visible);
+ }
+
+ _addIndicator() {
+ const icon = new St.Icon({style_class: 'system-status-icon'});
+ this.add_actor(icon);
+ icon.connect('notify::visible', () => this._syncIndicatorsVisible());
+ this._syncIndicatorsVisible();
+ return icon;
+ }
+});
diff --git a/js/ui/remoteSearch.js b/js/ui/remoteSearch.js
new file mode 100644
index 0000000..87ee384
--- /dev/null
+++ b/js/ui/remoteSearch.js
@@ -0,0 +1,332 @@
+// -*- 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);
+
+/**
+ * loadRemoteSearchProviders:
+ *
+ * @param {Gio.Settings} searchSettings - search settings
+ * @returns {RemoteSearchProvider[]} - the list of remote providers
+ */
+function loadRemoteSearchProviders(searchSettings) {
+ 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 ${path}: ${e}`);
+ }
+ }
+
+ if (searchSettings.get_boolean('disable-external'))
+ 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('org.gnome.Settings.desktop');
+
+ const disabled = searchSettings.get_strv('disabled');
+ const enabled = searchSettings.get_strv('enabled');
+
+ loadedProviders = loadedProviders.filter(provider => {
+ let appId = provider.appInfo.get_id();
+
+ if (provider.defaultEnabled)
+ return !disabled.includes(appId);
+ else
+ 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;
+ });
+
+ return 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']) {
+ const [
+ 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));
+ }
+
+ async getInitialResultSet(terms, cancellable) {
+ try {
+ const [results] = await this.proxy.GetInitialResultSetAsync(terms, cancellable);
+ return results;
+ } catch (error) {
+ if (!error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
+ log(`Received error from D-Bus search provider ${this.id}: ${error}`);
+ return [];
+ }
+ }
+
+ async getSubsearchResultSet(previousResults, newTerms, cancellable) {
+ try {
+ const [results] = await this.proxy.GetSubsearchResultSetAsync(previousResults, newTerms, cancellable);
+ return results;
+ } catch (error) {
+ if (!error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
+ log(`Received error from D-Bus search provider ${this.id}: ${error}`);
+ return [];
+ }
+ }
+
+ async getResultMetas(ids, cancellable) {
+ let metas;
+ try {
+ [metas] = await this.proxy.GetResultMetasAsync(ids, cancellable);
+ } catch (error) {
+ if (!error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
+ log(`Received error from D-Bus search provider ${this.id} during GetResultMetas: ${error}`);
+ return [];
+ }
+
+ 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].deepUnpack();
+ }
+
+ resultMetas.push({
+ id: metas[i]['id'],
+ name: metas[i]['name'],
+ description: metas[i]['description'],
+ createIcon: size => this.createIcon(size, metas[i]),
+ clipboardText: metas[i]['clipboardText'],
+ });
+ }
+ return resultMetas;
+ }
+
+ activateResult(id) {
+ this.proxy.ActivateResultAsync(id).catch(logError);
+ }
+
+ 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.ActivateResultAsync(
+ id, terms, global.get_current_time()).catch(logError);
+ }
+
+ launchSearch(terms) {
+ this.proxy.LaunchSearchAsync(
+ terms, global.get_current_time()).catch(logError);
+ }
+};
diff --git a/js/ui/ripples.js b/js/ui/ripples.js
new file mode 100644
index 0000000..20ca9ed
--- /dev/null
+++ b/js/ui/ripples.js
@@ -0,0 +1,110 @@
+// -*- 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..fe9b33e
--- /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': () => global.context.terminate(),
+
+ // 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) {
+ input = this._history.addItem(input); // trims input
+ let command = input;
+
+ this._commandError = false;
+ let f;
+ if (this._enableInternalCommands)
+ f = this._internalCommands[input];
+ else
+ f = null;
+ if (f) {
+ f();
+ } else {
+ try {
+ if (inTerminal) {
+ let exec = this._terminalSettings.get_string(EXEC_KEY);
+ let execArg = this._terminalSettings.get_string(EXEC_ARG_KEY);
+ command = `${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) {
+ if (input.charAt(0) == '~')
+ input = input.slice(1);
+ path = `${GLib.get_home_dir()}/${input}`;
+ }
+
+ if (path && 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…'), global.context);
+ }
+
+ open() {
+ this._history.lastItem();
+ this._entryText.set_text('');
+ this._commandError = false;
+
+ if (this._lockdownSettings.get_boolean(DISABLE_COMMAND_LINE_KEY))
+ return false;
+
+ return super.open();
+ }
+});
diff --git a/js/ui/screenShield.js b/js/ui/screenShield.js
new file mode 100644
index 0000000..325fbff
--- /dev/null
+++ b/js/ui/screenShield.js
@@ -0,0 +1,686 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported ScreenShield */
+
+const {
+ AccountsService, Clutter, Gio,
+ GLib, Graphene, Meta, Shell, St,
+} = imports.gi;
+const Signals = imports.misc.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 extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ 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._getLoginSession();
+
+ this._settings = new Gio.Settings({ schema_id: SCREENSAVER_SCHEMA });
+ this._settings.connect(`changed::${LOCK_ENABLED_KEY}`, this._syncInhibitor.bind(this));
+
+ this._lockSettings = new Gio.Settings({ schema_id: LOCKDOWN_SCHEMA });
+ this._lockSettings.connect(`changed::${DISABLE_LOCK_KEY}`, this._syncInhibitor.bind(this));
+
+ this._isModal = false;
+ this._isGreeter = false;
+ this._isActive = false;
+ this._isLocked = false;
+ this._inUnlockAnimation = false;
+ this._inhibited = 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 = global.backend.get_core_idle_monitor();
+ this._cursorTracker = Meta.CursorTracker.get_for_display(global.display);
+
+ this._syncInhibitor();
+ }
+
+ async _getLoginSession() {
+ this._loginSession = await this._loginManager.getCurrentSessionProxy();
+ this._loginSession.connectSignal('Lock',
+ () => this.lock(false));
+ this._loginSession.connectSignal('Unlock',
+ () => this.deactivate(false));
+ this._loginSession.connect('g-properties-changed',
+ () => this._syncInhibitor());
+ this._syncInhibitor();
+ }
+
+ _setActive(active) {
+ let prevIsActive = this._isActive;
+ this._isActive = active;
+
+ if (prevIsActive != this._isActive)
+ this.emit('active-changed');
+
+ this._syncInhibitor();
+ }
+
+ _setLocked(locked) {
+ let prevIsLocked = this._isLocked;
+ this._isLocked = locked;
+
+ if (prevIsLocked !== this._isLocked)
+ this.emit('locked-changed');
+
+ if (this._loginSession)
+ this._loginSession.SetLockedHintAsync(locked).catch(logError);
+ }
+
+ _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;
+
+ let grab = Main.pushModal(Main.uiGroup, { actionMode: Shell.ActionMode.LOCK_SCREEN });
+
+ // We expect at least a keyboard grab here
+ this._isModal = (grab.get_seat_state() & Clutter.GrabState.KEYBOARD) !== 0;
+ if (this._isModal)
+ this._grab = grab;
+ else
+ Main.popModal(grab);
+
+ return this._isModal;
+ }
+
+ async _syncInhibitor() {
+ const lockEnabled = this._settings.get_boolean(LOCK_ENABLED_KEY);
+ const lockLocked = this._lockSettings.get_boolean(DISABLE_LOCK_KEY);
+ const inhibit = !!this._loginSession && this._loginSession.Active &&
+ !this._isActive && lockEnabled && !lockLocked &&
+ !!Main.sessionMode.unlockDialog;
+
+ if (inhibit === this._inhibited)
+ return;
+
+ this._inhibited = inhibit;
+
+ this._inhibitCancellable?.cancel();
+ this._inhibitCancellable = new Gio.Cancellable();
+
+ if (inhibit) {
+ try {
+ this._inhibitor = await this._loginManager.inhibit(
+ _('GNOME needs to lock the screen'),
+ this._inhibitCancellable);
+ } catch (e) {
+ if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
+ log('Failed to inhibit suspend: %s'.format(e.message));
+ }
+ } else {
+ 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 them 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
+ const error = new GLib.Error(
+ Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED,
+ 'Could not acquire modal grab for the login screen. Aborting login process.');
+ global.context.terminate_with_error(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() {
+ if (!this.active)
+ return; // already woken up, or not yet asleep
+ 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._grab);
+ this._grab = null;
+ 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._setLocked(false);
+ 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());
+
+ this.activate(animate);
+
+ const lock = this._isGreeter
+ ? true
+ : user.password_mode !== AccountsService.UserPasswordMode.NONE;
+ this._setLocked(lock);
+ }
+
+ // 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;
+ });
+ }
+};
diff --git a/js/ui/screenshot.js b/js/ui/screenshot.js
new file mode 100644
index 0000000..5139052
--- /dev/null
+++ b/js/ui/screenshot.js
@@ -0,0 +1,2897 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported ScreenshotService, ScreenshotUI, showScreenshotUI, captureScreenshot */
+
+const { Clutter, Cogl, Gio, GObject, GLib, Graphene, Gtk, Meta, Shell, St } = imports.gi;
+
+const GrabHelper = imports.ui.grabHelper;
+const Layout = imports.ui.layout;
+const Lightbox = imports.ui.lightbox;
+const Main = imports.ui.main;
+const MessageTray = imports.ui.messageTray;
+const Workspace = imports.ui.workspace;
+
+Gio._promisify(Shell.Screenshot.prototype, 'pick_color');
+Gio._promisify(Shell.Screenshot.prototype, 'screenshot');
+Gio._promisify(Shell.Screenshot.prototype, 'screenshot_window');
+Gio._promisify(Shell.Screenshot.prototype, 'screenshot_area');
+Gio._promisify(Shell.Screenshot.prototype, 'screenshot_stage_to_content');
+Gio._promisify(Shell.Screenshot, 'composite_to_stream');
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+const { DBusSenderChecker } = imports.misc.util;
+
+const ScreenshotIface = loadInterfaceXML('org.gnome.Shell.Screenshot');
+
+const ScreencastIface = loadInterfaceXML('org.gnome.Shell.Screencast');
+const ScreencastProxy = Gio.DBusProxy.makeProxyWrapper(ScreencastIface);
+
+var IconLabelButton = GObject.registerClass(
+class IconLabelButton extends St.Button {
+ _init(iconName, label, params) {
+ super._init(params);
+
+ this._container = new St.BoxLayout({
+ vertical: true,
+ style_class: 'icon-label-button-container',
+ });
+ this.set_child(this._container);
+
+ this._container.add_child(new St.Icon({ icon_name: iconName }));
+ this._container.add_child(new St.Label({
+ text: label,
+ x_align: Clutter.ActorAlign.CENTER,
+ }));
+ }
+});
+
+var Tooltip = GObject.registerClass(
+class Tooltip extends St.Label {
+ _init(widget, params) {
+ super._init(params);
+
+ this._widget = widget;
+ this._timeoutId = null;
+
+ this._widget.connect('notify::hover', () => {
+ if (this._widget.hover)
+ this.open();
+ else
+ this.close();
+ });
+ }
+
+ open() {
+ if (this._timeoutId)
+ return;
+
+ this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 300, () => {
+ this.opacity = 0;
+ this.show();
+
+ const extents = this._widget.get_transformed_extents();
+
+ const xOffset = Math.floor((extents.get_width() - this.width) / 2);
+ const x =
+ Math.clamp(extents.get_x() + xOffset, 0, global.stage.width - this.width);
+
+ const node = this.get_theme_node();
+ const yOffset = node.get_length('-y-offset');
+
+ const y = extents.get_y() - this.height - yOffset;
+
+ this.set_position(x, y);
+ this.ease({
+ opacity: 255,
+ duration: 150,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+
+ this._timeoutId = null;
+ return GLib.SOURCE_REMOVE;
+ });
+ GLib.Source.set_name_by_id(this._timeoutId, '[gnome-shell] tooltip.open');
+ }
+
+ close() {
+ if (this._timeoutId) {
+ GLib.source_remove(this._timeoutId);
+ this._timeoutId = null;
+ return;
+ }
+
+ if (!this.visible)
+ return;
+
+ this.remove_all_transitions();
+ this.ease({
+ opacity: 0,
+ duration: 100,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this.hide(),
+ });
+ }
+});
+
+var UIAreaIndicator = GObject.registerClass(
+class UIAreaIndicator extends St.Widget {
+ _init(params) {
+ super._init(params);
+
+ this._topRect = new St.Widget({ style_class: 'screenshot-ui-area-indicator-shade' });
+ this._topRect.add_constraint(new Clutter.BindConstraint({
+ source: this,
+ coordinate: Clutter.BindCoordinate.WIDTH,
+ }));
+ this._topRect.add_constraint(new Clutter.SnapConstraint({
+ source: this,
+ from_edge: Clutter.SnapEdge.TOP,
+ to_edge: Clutter.SnapEdge.TOP,
+ }));
+ this._topRect.add_constraint(new Clutter.SnapConstraint({
+ source: this,
+ from_edge: Clutter.SnapEdge.LEFT,
+ to_edge: Clutter.SnapEdge.LEFT,
+ }));
+ this.add_child(this._topRect);
+
+ this._bottomRect = new St.Widget({ style_class: 'screenshot-ui-area-indicator-shade' });
+ this._bottomRect.add_constraint(new Clutter.BindConstraint({
+ source: this,
+ coordinate: Clutter.BindCoordinate.WIDTH,
+ }));
+ this._bottomRect.add_constraint(new Clutter.SnapConstraint({
+ source: this,
+ from_edge: Clutter.SnapEdge.BOTTOM,
+ to_edge: Clutter.SnapEdge.BOTTOM,
+ }));
+ this._bottomRect.add_constraint(new Clutter.SnapConstraint({
+ source: this,
+ from_edge: Clutter.SnapEdge.LEFT,
+ to_edge: Clutter.SnapEdge.LEFT,
+ }));
+ this.add_child(this._bottomRect);
+
+ this._leftRect = new St.Widget({ style_class: 'screenshot-ui-area-indicator-shade' });
+ this._leftRect.add_constraint(new Clutter.SnapConstraint({
+ source: this,
+ from_edge: Clutter.SnapEdge.LEFT,
+ to_edge: Clutter.SnapEdge.LEFT,
+ }));
+ this._leftRect.add_constraint(new Clutter.SnapConstraint({
+ source: this._topRect,
+ from_edge: Clutter.SnapEdge.TOP,
+ to_edge: Clutter.SnapEdge.BOTTOM,
+ }));
+ this._leftRect.add_constraint(new Clutter.SnapConstraint({
+ source: this._bottomRect,
+ from_edge: Clutter.SnapEdge.BOTTOM,
+ to_edge: Clutter.SnapEdge.TOP,
+ }));
+ this.add_child(this._leftRect);
+
+ this._rightRect = new St.Widget({ style_class: 'screenshot-ui-area-indicator-shade' });
+ this._rightRect.add_constraint(new Clutter.SnapConstraint({
+ source: this,
+ from_edge: Clutter.SnapEdge.RIGHT,
+ to_edge: Clutter.SnapEdge.RIGHT,
+ }));
+ this._rightRect.add_constraint(new Clutter.SnapConstraint({
+ source: this._topRect,
+ from_edge: Clutter.SnapEdge.TOP,
+ to_edge: Clutter.SnapEdge.BOTTOM,
+ }));
+ this._rightRect.add_constraint(new Clutter.SnapConstraint({
+ source: this._bottomRect,
+ from_edge: Clutter.SnapEdge.BOTTOM,
+ to_edge: Clutter.SnapEdge.TOP,
+ }));
+ this.add_child(this._rightRect);
+
+ this._selectionRect = new St.Widget({ style_class: 'screenshot-ui-area-indicator-selection' });
+ this.add_child(this._selectionRect);
+
+ this._topRect.add_constraint(new Clutter.SnapConstraint({
+ source: this._selectionRect,
+ from_edge: Clutter.SnapEdge.BOTTOM,
+ to_edge: Clutter.SnapEdge.TOP,
+ }));
+
+ this._bottomRect.add_constraint(new Clutter.SnapConstraint({
+ source: this._selectionRect,
+ from_edge: Clutter.SnapEdge.TOP,
+ to_edge: Clutter.SnapEdge.BOTTOM,
+ }));
+
+ this._leftRect.add_constraint(new Clutter.SnapConstraint({
+ source: this._selectionRect,
+ from_edge: Clutter.SnapEdge.RIGHT,
+ to_edge: Clutter.SnapEdge.LEFT,
+ }));
+
+ this._rightRect.add_constraint(new Clutter.SnapConstraint({
+ source: this._selectionRect,
+ from_edge: Clutter.SnapEdge.LEFT,
+ to_edge: Clutter.SnapEdge.RIGHT,
+ }));
+ }
+
+ setSelectionRect(x, y, width, height) {
+ this._selectionRect.set_position(x, y);
+ this._selectionRect.set_size(width, height);
+ }
+});
+
+var UIAreaSelector = GObject.registerClass({
+ Signals: { 'drag-started': {}, 'drag-ended': {} },
+}, class UIAreaSelector extends St.Widget {
+ _init(params) {
+ super._init(params);
+
+ // During a drag, this can be Clutter.BUTTON_PRIMARY,
+ // Clutter.BUTTON_SECONDARY or the string "touch" to identify the source
+ // of the drag operation.
+ this._dragButton = 0;
+ this._dragSequence = null;
+
+ this._areaIndicator = new UIAreaIndicator();
+ this._areaIndicator.add_constraint(new Clutter.BindConstraint({
+ source: this,
+ coordinate: Clutter.BindCoordinate.ALL,
+ }));
+ this.add_child(this._areaIndicator);
+
+ this._topLeftHandle = new St.Widget({ style_class: 'screenshot-ui-area-selector-handle' });
+ this.add_child(this._topLeftHandle);
+ this._topRightHandle = new St.Widget({ style_class: 'screenshot-ui-area-selector-handle' });
+ this.add_child(this._topRightHandle);
+ this._bottomLeftHandle = new St.Widget({ style_class: 'screenshot-ui-area-selector-handle' });
+ this.add_child(this._bottomLeftHandle);
+ this._bottomRightHandle = new St.Widget({ style_class: 'screenshot-ui-area-selector-handle' });
+ this.add_child(this._bottomRightHandle);
+
+ // This will be updated before the first drawn frame.
+ this._handleSize = 0;
+ this._topLeftHandle.connect('style-changed', widget => {
+ this._handleSize = widget.get_theme_node().get_width();
+ this._updateSelectionRect();
+ });
+
+ this.connect('notify::mapped', () => {
+ if (this.mapped) {
+ const [x, y] = global.get_pointer();
+ this._updateCursor(x, y);
+ }
+ });
+
+ // Initialize area to out of bounds so reset() below resets it.
+ this._startX = -1;
+ this._startY = 0;
+ this._lastX = 0;
+ this._lastY = 0;
+
+ this.reset();
+ }
+
+ reset() {
+ this.stopDrag();
+ global.display.set_cursor(Meta.Cursor.DEFAULT);
+
+ // Preserve area selection if possible. If the area goes out of bounds,
+ // the monitors might have changed, so reset the area.
+ const [x, y, w, h] = this.getGeometry();
+ if (x < 0 || y < 0 || x + w > this.width || y + h > this.height) {
+ // Initialize area to out of bounds so if there's no monitor,
+ // the area will be reset once a monitor does appear.
+ this._startX = -1;
+ this._startY = 0;
+ this._lastX = 0;
+ this._lastY = 0;
+
+ // This can happen when running headless without any monitors.
+ if (Main.layoutManager.primaryIndex !== -1) {
+ const monitor =
+ Main.layoutManager.monitors[Main.layoutManager.primaryIndex];
+
+ this._startX = monitor.x + Math.floor(monitor.width * 3 / 8);
+ this._startY = monitor.y + Math.floor(monitor.height * 3 / 8);
+ this._lastX = monitor.x + Math.floor(monitor.width * 5 / 8) - 1;
+ this._lastY = monitor.y + Math.floor(monitor.height * 5 / 8) - 1;
+ }
+
+ this._updateSelectionRect();
+ }
+ }
+
+ getGeometry() {
+ const leftX = Math.min(this._startX, this._lastX);
+ const topY = Math.min(this._startY, this._lastY);
+ const rightX = Math.max(this._startX, this._lastX);
+ const bottomY = Math.max(this._startY, this._lastY);
+
+ return [leftX, topY, rightX - leftX + 1, bottomY - topY + 1];
+ }
+
+ _updateSelectionRect() {
+ const [x, y, w, h] = this.getGeometry();
+ this._areaIndicator.setSelectionRect(x, y, w, h);
+
+ const offset = this._handleSize / 2;
+ this._topLeftHandle.set_position(x - offset, y - offset);
+ this._topRightHandle.set_position(x + w - 1 - offset, y - offset);
+ this._bottomLeftHandle.set_position(x - offset, y + h - 1 - offset);
+ this._bottomRightHandle.set_position(x + w - 1 - offset, y + h - 1 - offset);
+ }
+
+ _computeCursorType(cursorX, cursorY) {
+ const [leftX, topY, width, height] = this.getGeometry();
+ const [rightX, bottomY] = [leftX + width - 1, topY + height - 1];
+ const [x, y] = [cursorX, cursorY];
+
+ // Check if the cursor overlaps the handles first.
+ const limit = (this._handleSize / 2) ** 2;
+ if ((leftX - x) ** 2 + (topY - y) ** 2 <= limit)
+ return Meta.Cursor.NW_RESIZE;
+ else if ((rightX - x) ** 2 + (topY - y) ** 2 <= limit)
+ return Meta.Cursor.NE_RESIZE;
+ else if ((leftX - x) ** 2 + (bottomY - y) ** 2 <= limit)
+ return Meta.Cursor.SW_RESIZE;
+ else if ((rightX - x) ** 2 + (bottomY - y) ** 2 <= limit)
+ return Meta.Cursor.SE_RESIZE;
+
+ // Now check the rest of the rectangle.
+ const threshold =
+ 10 * St.ThemeContext.get_for_stage(global.stage).scaleFactor;
+
+ if (leftX - x >= 0 && leftX - x <= threshold) {
+ if (topY - y >= 0 && topY - y <= threshold)
+ return Meta.Cursor.NW_RESIZE;
+ else if (y - bottomY >= 0 && y - bottomY <= threshold)
+ return Meta.Cursor.SW_RESIZE;
+ else if (topY - y < 0 && y - bottomY < 0)
+ return Meta.Cursor.WEST_RESIZE;
+ } else if (x - rightX >= 0 && x - rightX <= threshold) {
+ if (topY - y >= 0 && topY - y <= threshold)
+ return Meta.Cursor.NE_RESIZE;
+ else if (y - bottomY >= 0 && y - bottomY <= threshold)
+ return Meta.Cursor.SE_RESIZE;
+ else if (topY - y < 0 && y - bottomY < 0)
+ return Meta.Cursor.EAST_RESIZE;
+ } else if (leftX - x < 0 && x - rightX < 0) {
+ if (topY - y >= 0 && topY - y <= threshold)
+ return Meta.Cursor.NORTH_RESIZE;
+ else if (y - bottomY >= 0 && y - bottomY <= threshold)
+ return Meta.Cursor.SOUTH_RESIZE;
+ else if (topY - y < 0 && y - bottomY < 0)
+ return Meta.Cursor.MOVE_OR_RESIZE_WINDOW;
+ }
+
+ return Meta.Cursor.CROSSHAIR;
+ }
+
+ stopDrag() {
+ if (!this._dragButton)
+ return;
+
+ if (this._dragGrab) {
+ this._dragGrab.dismiss();
+ this._dragGrab = null;
+ }
+
+ this._dragButton = 0;
+ this._dragSequence = null;
+
+ if (this._dragCursor === Meta.Cursor.CROSSHAIR &&
+ this._lastX === this._startX && this._lastY === this._startY) {
+ // The user clicked without dragging. Make up a larger selection
+ // to reduce confusion.
+ const offset =
+ 20 * St.ThemeContext.get_for_stage(global.stage).scaleFactor;
+ this._startX -= offset;
+ this._startY -= offset;
+ this._lastX += offset;
+ this._lastY += offset;
+
+ // Keep the coordinates inside the stage.
+ if (this._startX < 0) {
+ this._lastX -= this._startX;
+ this._startX = 0;
+ } else if (this._lastX >= this.width) {
+ this._startX -= this._lastX - this.width + 1;
+ this._lastX = this.width - 1;
+ }
+
+ if (this._startY < 0) {
+ this._lastY -= this._startY;
+ this._startY = 0;
+ } else if (this._lastY >= this.height) {
+ this._startY -= this._lastY - this.height + 1;
+ this._lastY = this.height - 1;
+ }
+
+ this._updateSelectionRect();
+ }
+
+ this.emit('drag-ended');
+ }
+
+ _updateCursor(x, y) {
+ const cursor = this._computeCursorType(x, y);
+ global.display.set_cursor(cursor);
+ }
+
+ _onPress(event, button, sequence) {
+ if (this._dragButton)
+ return Clutter.EVENT_PROPAGATE;
+
+ const cursor = this._computeCursorType(event.x, event.y);
+
+ // Clicking outside of the selection, or using the right mouse button,
+ // or with Ctrl results in dragging a new selection from scratch.
+ if (cursor === Meta.Cursor.CROSSHAIR ||
+ button === Clutter.BUTTON_SECONDARY ||
+ (event.modifier_state & Clutter.ModifierType.CONTROL_MASK)) {
+ this._dragButton = button;
+
+ this._dragCursor = Meta.Cursor.CROSSHAIR;
+ global.display.set_cursor(Meta.Cursor.CROSSHAIR);
+
+ [this._startX, this._startY] = [event.x, event.y];
+ this._lastX = this._startX = Math.floor(this._startX);
+ this._lastY = this._startY = Math.floor(this._startY);
+
+ this._updateSelectionRect();
+ } else {
+ // This is a move or resize operation.
+ this._dragButton = button;
+
+ this._dragCursor = cursor;
+ this._dragStartX = event.x;
+ this._dragStartY = event.y;
+
+ const [leftX, topY, width, height] = this.getGeometry();
+ const rightX = leftX + width - 1;
+ const bottomY = topY + height - 1;
+
+ // For moving, start X and Y are the top left corner, while
+ // last X and Y are the bottom right corner.
+ if (cursor === Meta.Cursor.MOVE_OR_RESIZE_WINDOW) {
+ this._startX = leftX;
+ this._startY = topY;
+ this._lastX = rightX;
+ this._lastY = bottomY;
+ }
+
+ // Start X and Y are set to the stationary sides, while last X
+ // and Y are set to the moving sides.
+ if (cursor === Meta.Cursor.NW_RESIZE ||
+ cursor === Meta.Cursor.WEST_RESIZE ||
+ cursor === Meta.Cursor.SW_RESIZE) {
+ this._startX = rightX;
+ this._lastX = leftX;
+ }
+ if (cursor === Meta.Cursor.NE_RESIZE ||
+ cursor === Meta.Cursor.EAST_RESIZE ||
+ cursor === Meta.Cursor.SE_RESIZE) {
+ this._startX = leftX;
+ this._lastX = rightX;
+ }
+ if (cursor === Meta.Cursor.NW_RESIZE ||
+ cursor === Meta.Cursor.NORTH_RESIZE ||
+ cursor === Meta.Cursor.NE_RESIZE) {
+ this._startY = bottomY;
+ this._lastY = topY;
+ }
+ if (cursor === Meta.Cursor.SW_RESIZE ||
+ cursor === Meta.Cursor.SOUTH_RESIZE ||
+ cursor === Meta.Cursor.SE_RESIZE) {
+ this._startY = topY;
+ this._lastY = bottomY;
+ }
+ }
+
+ if (this._dragButton) {
+ this._dragGrab = global.stage.grab(this);
+ this._dragSequence = sequence;
+
+ this.emit('drag-started');
+
+ return Clutter.EVENT_STOP;
+ }
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _onRelease(event, button, sequence) {
+ if (this._dragButton !== button ||
+ this._dragSequence?.get_slot() !== sequence?.get_slot())
+ return Clutter.EVENT_PROPAGATE;
+
+ this.stopDrag();
+
+ // We might have finished creating a new selection, so we need to
+ // update the cursor.
+ this._updateCursor(event.x, event.y);
+
+ return Clutter.EVENT_STOP;
+ }
+
+ _onMotion(event, sequence) {
+ if (!this._dragButton) {
+ this._updateCursor(event.x, event.y);
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ if (sequence?.get_slot() !== this._dragSequence?.get_slot())
+ return Clutter.EVENT_PROPAGATE;
+
+ if (this._dragCursor === Meta.Cursor.CROSSHAIR) {
+ [this._lastX, this._lastY] = [event.x, event.y];
+ this._lastX = Math.floor(this._lastX);
+ this._lastY = Math.floor(this._lastY);
+ } else {
+ let dx = Math.round(event.x - this._dragStartX);
+ let dy = Math.round(event.y - this._dragStartY);
+
+ if (this._dragCursor === Meta.Cursor.MOVE_OR_RESIZE_WINDOW) {
+ const [,, selectionWidth, selectionHeight] = this.getGeometry();
+
+ let newStartX = this._startX + dx;
+ let newStartY = this._startY + dy;
+ let newLastX = this._lastX + dx;
+ let newLastY = this._lastY + dy;
+
+ let overshootX = 0;
+ let overshootY = 0;
+
+ // Keep the size intact if we bumped into the stage edge.
+ if (newStartX < 0) {
+ overshootX = 0 - newStartX;
+ newStartX = 0;
+ newLastX = newStartX + (selectionWidth - 1);
+ } else if (newLastX > this.width - 1) {
+ overshootX = (this.width - 1) - newLastX;
+ newLastX = this.width - 1;
+ newStartX = newLastX - (selectionWidth - 1);
+ }
+
+ if (newStartY < 0) {
+ overshootY = 0 - newStartY;
+ newStartY = 0;
+ newLastY = newStartY + (selectionHeight - 1);
+ } else if (newLastY > this.height - 1) {
+ overshootY = (this.height - 1) - newLastY;
+ newLastY = this.height - 1;
+ newStartY = newLastY - (selectionHeight - 1);
+ }
+
+ // Add the overshoot to the delta to create a "rubberbanding"
+ // behavior of the pointer when dragging.
+ dx += overshootX;
+ dy += overshootY;
+
+ this._startX = newStartX;
+ this._startY = newStartY;
+ this._lastX = newLastX;
+ this._lastY = newLastY;
+ } else {
+ if (this._dragCursor === Meta.Cursor.WEST_RESIZE ||
+ this._dragCursor === Meta.Cursor.EAST_RESIZE)
+ dy = 0;
+ if (this._dragCursor === Meta.Cursor.NORTH_RESIZE ||
+ this._dragCursor === Meta.Cursor.SOUTH_RESIZE)
+ dx = 0;
+
+ // Make sure last X and Y are clamped between 0 and size - 1,
+ // while always preserving the cursor dragging position relative
+ // to the selection rectangle.
+ this._lastX += dx;
+ if (this._lastX >= this.width) {
+ dx -= this._lastX - this.width + 1;
+ this._lastX = this.width - 1;
+ } else if (this._lastX < 0) {
+ dx -= this._lastX;
+ this._lastX = 0;
+ }
+
+ this._lastY += dy;
+ if (this._lastY >= this.height) {
+ dy -= this._lastY - this.height + 1;
+ this._lastY = this.height - 1;
+ } else if (this._lastY < 0) {
+ dy -= this._lastY;
+ this._lastY = 0;
+ }
+
+ // If we drag the handle past a selection side, update which
+ // handles are which.
+ if (this._lastX > this._startX) {
+ if (this._dragCursor === Meta.Cursor.NW_RESIZE)
+ this._dragCursor = Meta.Cursor.NE_RESIZE;
+ else if (this._dragCursor === Meta.Cursor.SW_RESIZE)
+ this._dragCursor = Meta.Cursor.SE_RESIZE;
+ else if (this._dragCursor === Meta.Cursor.WEST_RESIZE)
+ this._dragCursor = Meta.Cursor.EAST_RESIZE;
+ } else {
+ // eslint-disable-next-line no-lonely-if
+ if (this._dragCursor === Meta.Cursor.NE_RESIZE)
+ this._dragCursor = Meta.Cursor.NW_RESIZE;
+ else if (this._dragCursor === Meta.Cursor.SE_RESIZE)
+ this._dragCursor = Meta.Cursor.SW_RESIZE;
+ else if (this._dragCursor === Meta.Cursor.EAST_RESIZE)
+ this._dragCursor = Meta.Cursor.WEST_RESIZE;
+ }
+
+ if (this._lastY > this._startY) {
+ if (this._dragCursor === Meta.Cursor.NW_RESIZE)
+ this._dragCursor = Meta.Cursor.SW_RESIZE;
+ else if (this._dragCursor === Meta.Cursor.NE_RESIZE)
+ this._dragCursor = Meta.Cursor.SE_RESIZE;
+ else if (this._dragCursor === Meta.Cursor.NORTH_RESIZE)
+ this._dragCursor = Meta.Cursor.SOUTH_RESIZE;
+ } else {
+ // eslint-disable-next-line no-lonely-if
+ if (this._dragCursor === Meta.Cursor.SW_RESIZE)
+ this._dragCursor = Meta.Cursor.NW_RESIZE;
+ else if (this._dragCursor === Meta.Cursor.SE_RESIZE)
+ this._dragCursor = Meta.Cursor.NE_RESIZE;
+ else if (this._dragCursor === Meta.Cursor.SOUTH_RESIZE)
+ this._dragCursor = Meta.Cursor.NORTH_RESIZE;
+ }
+
+ global.display.set_cursor(this._dragCursor);
+ }
+
+ this._dragStartX += dx;
+ this._dragStartY += dy;
+ }
+
+ this._updateSelectionRect();
+
+ return Clutter.EVENT_STOP;
+ }
+
+ vfunc_button_press_event(event) {
+ if (event.button === Clutter.BUTTON_PRIMARY ||
+ event.button === Clutter.BUTTON_SECONDARY)
+ return this._onPress(event, event.button, null);
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_button_release_event(event) {
+ if (event.button === Clutter.BUTTON_PRIMARY ||
+ event.button === Clutter.BUTTON_SECONDARY)
+ return this._onRelease(event, event.button, null);
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_motion_event(event) {
+ return this._onMotion(event, null);
+ }
+
+ vfunc_touch_event(event) {
+ if (event.type === Clutter.EventType.TOUCH_BEGIN)
+ return this._onPress(event, 'touch', event.sequence);
+ else if (event.type === Clutter.EventType.TOUCH_END)
+ return this._onRelease(event, 'touch', event.sequence);
+ else if (event.type === Clutter.EventType.TOUCH_UPDATE)
+ return this._onMotion(event, event.sequence);
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ vfunc_leave_event(event) {
+ // If we're dragging and go over the panel we still get a leave event
+ // for some reason, even though we have a grab. We don't want to switch
+ // the cursor when we're dragging.
+ if (!this._dragButton)
+ global.display.set_cursor(Meta.Cursor.DEFAULT);
+
+ return super.vfunc_leave_event(event);
+ }
+});
+
+var UIWindowSelectorLayout = GObject.registerClass(
+class UIWindowSelectorLayout extends Workspace.WorkspaceLayout {
+ _init(monitorIndex) {
+ super._init(null, monitorIndex, null);
+ }
+
+ vfunc_set_container(container) {
+ this._container = container;
+ this._syncWorkareaTracking();
+ }
+
+ vfunc_allocate(container, box) {
+ const containerBox = container.allocation;
+ const containerAllocationChanged =
+ this._lastBox === null || !this._lastBox.equal(containerBox);
+ this._lastBox = containerBox.copy();
+
+ let layoutChanged = false;
+ if (this._layout === null) {
+ this._layout = this._createBestLayout(this._workarea);
+ layoutChanged = true;
+ }
+
+ if (layoutChanged || containerAllocationChanged)
+ this._windowSlots = this._getWindowSlots(box.copy());
+
+ const childBox = new Clutter.ActorBox();
+
+ const nSlots = this._windowSlots.length;
+ for (let i = 0; i < nSlots; i++) {
+ let [x, y, width, height, child] = this._windowSlots[i];
+
+ childBox.set_origin(x, y);
+ childBox.set_size(width, height);
+
+ child.allocate(childBox);
+ }
+ }
+
+ addWindow(window) {
+ if (this._sortedWindows.includes(window))
+ return;
+
+ this._sortedWindows.push(window);
+
+ this._container.add_child(window);
+
+ this._layout = null;
+ this.layout_changed();
+ }
+
+ reset() {
+ for (const window of this._sortedWindows)
+ window.destroy();
+
+ this._sortedWindows = [];
+ this._windowSlots = [];
+ this._layout = null;
+ }
+
+ get windows() {
+ return this._sortedWindows;
+ }
+});
+
+var UIWindowSelectorWindow = GObject.registerClass(
+class UIWindowSelectorWindow extends St.Button {
+ _init(actor, params) {
+ super._init(params);
+
+ const window = actor.metaWindow;
+ this._boundingBox = window.get_frame_rect();
+ this._bufferRect = window.get_buffer_rect();
+ this._bufferScale = actor.get_resource_scale();
+ this._actor = new Clutter.Actor({
+ content: actor.paint_to_content(null),
+ });
+ this.add_child(this._actor);
+
+ this._border = new St.Bin({ style_class: 'screenshot-ui-window-selector-window-border' });
+ this._border.connect('style-changed', () => {
+ this._borderSize =
+ this._border.get_theme_node().get_border_width(St.Side.TOP);
+ });
+ this.add_child(this._border);
+
+ this._border.child = new St.Icon({
+ icon_name: 'object-select-symbolic',
+ style_class: 'screenshot-ui-window-selector-check',
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+
+ this._cursor = null;
+ this._cursorPoint = { x: 0, y: 0 };
+ this._shouldShowCursor = window.has_pointer && window.has_pointer();
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ get boundingBox() {
+ return this._boundingBox;
+ }
+
+ get windowCenter() {
+ const boundingBox = this.boundingBox;
+ return {
+ x: boundingBox.x + boundingBox.width / 2,
+ y: boundingBox.y + boundingBox.height / 2,
+ };
+ }
+
+ chromeHeights() {
+ return [0, 0];
+ }
+
+ chromeWidths() {
+ return [0, 0];
+ }
+
+ overlapHeights() {
+ return [0, 0];
+ }
+
+ get cursorPoint() {
+ return {
+ x: this._cursorPoint.x + this._boundingBox.x - this._bufferRect.x,
+ y: this._cursorPoint.y + this._boundingBox.y - this._bufferRect.y,
+ };
+ }
+
+ get bufferScale() {
+ return this._bufferScale;
+ }
+
+ get windowContent() {
+ return this._actor.content;
+ }
+
+ _onDestroy() {
+ this.remove_child(this._actor);
+ this._actor.destroy();
+ this._actor = null;
+ this.remove_child(this._border);
+ this._border.destroy();
+ this._border = null;
+
+ if (this._cursor) {
+ this.remove_child(this._cursor);
+ this._cursor.destroy();
+ this._cursor = null;
+ }
+ }
+
+ vfunc_allocate(box) {
+ this.set_allocation(box);
+
+ // Border goes around the window.
+ const borderBox = box.copy();
+ borderBox.set_origin(0, 0);
+ borderBox.x1 -= this._borderSize;
+ borderBox.y1 -= this._borderSize;
+ borderBox.x2 += this._borderSize;
+ borderBox.y2 += this._borderSize;
+ this._border.allocate(borderBox);
+
+ // box should contain this._boundingBox worth of window. Compute
+ // origin and size for the actor box to satisfy that.
+ const xScale = box.get_width() / this._boundingBox.width;
+ const yScale = box.get_height() / this._boundingBox.height;
+
+ const [, windowW, windowH] = this._actor.content.get_preferred_size();
+
+ const actorBox = new Clutter.ActorBox();
+ actorBox.set_origin(
+ (this._bufferRect.x - this._boundingBox.x) * xScale,
+ (this._bufferRect.y - this._boundingBox.y) * yScale
+ );
+ actorBox.set_size(
+ windowW * xScale / this._bufferScale,
+ windowH * yScale / this._bufferScale
+ );
+ this._actor.allocate(actorBox);
+
+ // Allocate the cursor if we have one.
+ if (!this._cursor)
+ return;
+
+ let [, , w, h] = this._cursor.get_preferred_size();
+ w *= this._cursorScale;
+ h *= this._cursorScale;
+
+ const cursorBox = new Clutter.ActorBox({
+ x1: this._cursorPoint.x,
+ y1: this._cursorPoint.y,
+ x2: this._cursorPoint.x + w,
+ y2: this._cursorPoint.y + h,
+ });
+ cursorBox.x1 *= xScale;
+ cursorBox.x2 *= xScale;
+ cursorBox.y1 *= yScale;
+ cursorBox.y2 *= yScale;
+
+ this._cursor.allocate(cursorBox);
+ }
+
+ addCursorTexture(content, point, scale) {
+ if (!this._shouldShowCursor)
+ return;
+
+ // Add the cursor.
+ this._cursor = new St.Widget({
+ content,
+ request_mode: Clutter.RequestMode.CONTENT_SIZE,
+ });
+
+ this._cursorPoint = {
+ x: point.x - this._boundingBox.x,
+ y: point.y - this._boundingBox.y,
+ };
+ this._cursorScale = scale;
+
+ this.insert_child_below(this._cursor, this._border);
+ }
+
+ getCursorTexture() {
+ return this._cursor?.content;
+ }
+
+ setCursorVisible(visible) {
+ if (!this._cursor)
+ return;
+
+ this._cursor.visible = visible;
+ }
+});
+
+var UIWindowSelector = GObject.registerClass(
+class UIWindowSelector extends St.Widget {
+ _init(monitorIndex, params) {
+ super._init(params);
+ super.layout_manager = new Clutter.BinLayout();
+
+ this._monitorIndex = monitorIndex;
+
+ this._layoutManager = new UIWindowSelectorLayout(monitorIndex);
+
+ // Window screenshots
+ this._container = new St.Widget({
+ style_class: 'screenshot-ui-window-selector-window-container',
+ x_expand: true,
+ y_expand: true,
+ });
+ this._container.layout_manager = this._layoutManager;
+ this.add_child(this._container);
+ }
+
+ capture() {
+ for (const actor of global.get_window_actors()) {
+ let window = actor.metaWindow;
+ let workspaceManager = global.workspace_manager;
+ let activeWorkspace = workspaceManager.get_active_workspace();
+ if (window.is_override_redirect() ||
+ !window.located_on_workspace(activeWorkspace) ||
+ window.get_monitor() !== this._monitorIndex)
+ continue;
+
+ const widget = new UIWindowSelectorWindow(
+ actor,
+ {
+ style_class: 'screenshot-ui-window-selector-window',
+ reactive: true,
+ can_focus: true,
+ toggle_mode: true,
+ }
+ );
+
+ widget.connect('key-focus-in', win => {
+ Main.screenshotUI.grab_key_focus();
+ win.checked = true;
+ });
+
+ if (window.has_focus()) {
+ widget.checked = true;
+ widget.toggle_mode = false;
+ }
+
+ this._layoutManager.addWindow(widget);
+ }
+ }
+
+ reset() {
+ this._layoutManager.reset();
+ }
+
+ windows() {
+ return this._layoutManager.windows;
+ }
+});
+
+const UIMode = {
+ SCREENSHOT: 0,
+ SCREENCAST: 1,
+};
+
+var ScreenshotUI = GObject.registerClass({
+ Properties: {
+ 'screencast-in-progress': GObject.ParamSpec.boolean(
+ 'screencast-in-progress',
+ 'screencast-in-progress',
+ 'screencast-in-progress',
+ GObject.ParamFlags.READABLE,
+ false),
+ },
+}, class ScreenshotUI extends St.Widget {
+ _init() {
+ super._init({
+ name: 'screenshot-ui',
+ constraints: new Clutter.BindConstraint({
+ source: global.stage,
+ coordinate: Clutter.BindCoordinate.ALL,
+ }),
+ layout_manager: new Clutter.BinLayout(),
+ opacity: 0,
+ visible: false,
+ reactive: true,
+ });
+
+ this._screencastInProgress = false;
+ this._screencastSupported = false;
+
+ this._screencastProxy = new ScreencastProxy(
+ Gio.DBus.session,
+ 'org.gnome.Shell.Screencast',
+ '/org/gnome/Shell/Screencast',
+ (object, error) => {
+ if (error !== null) {
+ log('Error connecting to the screencast service');
+ return;
+ }
+
+ this._screencastSupported = this._screencastProxy.ScreencastSupported;
+ this._castButton.visible = this._screencastSupported;
+ });
+
+ this._lockdownSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.lockdown' });
+
+ // The full-screen screenshot has a separate container so that we can
+ // show it without the screenshot UI fade-in for a nicer animation.
+ this._stageScreenshotContainer = new St.Widget({ visible: false });
+ this._stageScreenshotContainer.add_constraint(new Clutter.BindConstraint({
+ source: global.stage,
+ coordinate: Clutter.BindCoordinate.ALL,
+ }));
+ Main.layoutManager.screenshotUIGroup.add_child(
+ this._stageScreenshotContainer);
+
+ this._screencastAreaIndicator = new UIAreaIndicator({
+ style_class: 'screenshot-ui-screencast-area-indicator',
+ visible: false,
+ });
+ this._screencastAreaIndicator.add_constraint(new Clutter.BindConstraint({
+ source: global.stage,
+ coordinate: Clutter.BindCoordinate.ALL,
+ }));
+ this.bind_property(
+ 'screencast-in-progress',
+ this._screencastAreaIndicator,
+ 'visible',
+ GObject.BindingFlags.DEFAULT);
+ // Add it directly to the stage so that it's above popup menus.
+ global.stage.add_child(this._screencastAreaIndicator);
+ Shell.util_set_hidden_from_pick(this._screencastAreaIndicator, true);
+
+ Main.layoutManager.screenshotUIGroup.add_child(this);
+
+ this._stageScreenshot = new St.Widget({ style_class: 'screenshot-ui-screen-screenshot' });
+ this._stageScreenshot.add_constraint(new Clutter.BindConstraint({
+ source: global.stage,
+ coordinate: Clutter.BindCoordinate.ALL,
+ }));
+ this._stageScreenshotContainer.add_child(this._stageScreenshot);
+
+ this._cursor = new St.Widget();
+ this._stageScreenshotContainer.add_child(this._cursor);
+
+ this._openingCoroutineInProgress = false;
+ this._grabHelper = new GrabHelper.GrabHelper(this, {
+ actionMode: Shell.ActionMode.POPUP,
+ });
+
+ this._areaSelector = new UIAreaSelector({
+ style_class: 'screenshot-ui-area-selector',
+ x_expand: true,
+ y_expand: true,
+ reactive: true,
+ });
+ this.add_child(this._areaSelector);
+
+ this._primaryMonitorBin = new St.Widget({ layout_manager: new Clutter.BinLayout() });
+ this._primaryMonitorBin.add_constraint(
+ new Layout.MonitorConstraint({ 'primary': true }));
+ this.add_child(this._primaryMonitorBin);
+
+ this._panel = new St.BoxLayout({
+ style_class: 'screenshot-ui-panel',
+ y_align: Clutter.ActorAlign.END,
+ y_expand: true,
+ vertical: true,
+ offscreen_redirect: Clutter.OffscreenRedirect.AUTOMATIC_FOR_OPACITY,
+ });
+ this._primaryMonitorBin.add_child(this._panel);
+
+ this._closeButton = new St.Button({
+ style_class: 'screenshot-ui-close-button',
+ icon_name: 'preview-close-symbolic',
+ });
+ this._closeButton.add_constraint(new Clutter.BindConstraint({
+ source: this._panel,
+ coordinate: Clutter.BindCoordinate.POSITION,
+ }));
+ this._closeButton.add_constraint(new Clutter.AlignConstraint({
+ source: this._panel,
+ align_axis: Clutter.AlignAxis.Y_AXIS,
+ pivot_point: new Graphene.Point({ x: -1, y: 0.5 }),
+ factor: 0,
+ }));
+ this._closeButtonXAlignConstraint = new Clutter.AlignConstraint({
+ source: this._panel,
+ align_axis: Clutter.AlignAxis.X_AXIS,
+ pivot_point: new Graphene.Point({ x: 0.5, y: -1 }),
+ });
+ this._closeButton.add_constraint(this._closeButtonXAlignConstraint);
+ this._closeButton.connect('clicked', () => this.close());
+ this._primaryMonitorBin.add_child(this._closeButton);
+
+ this._areaSelector.connect('drag-started', () => {
+ this._panel.ease({
+ opacity: 100,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ this._closeButton.ease({
+ opacity: 100,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ });
+ this._areaSelector.connect('drag-ended', () => {
+ this._panel.ease({
+ opacity: 255,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ this._closeButton.ease({
+ opacity: 255,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ });
+
+ this._typeButtonContainer = new St.Widget({
+ style_class: 'screenshot-ui-type-button-container',
+ layout_manager: new Clutter.BoxLayout({
+ spacing: 12,
+ homogeneous: true,
+ }),
+ });
+ this._panel.add_child(this._typeButtonContainer);
+
+ this._selectionButton = new IconLabelButton('screenshot-ui-area-symbolic', _('Selection'), {
+ style_class: 'screenshot-ui-type-button',
+ checked: true,
+ x_expand: true,
+ });
+ this._selectionButton.connect('notify::checked',
+ this._onSelectionButtonToggled.bind(this));
+ this._typeButtonContainer.add_child(this._selectionButton);
+
+ this.add_child(new Tooltip(this._selectionButton, {
+ text: _('Area Selection'),
+ style_class: 'screenshot-ui-tooltip',
+ visible: false,
+ }));
+
+ this._screenButton = new IconLabelButton('screenshot-ui-display-symbolic', _('Screen'), {
+ style_class: 'screenshot-ui-type-button',
+ toggle_mode: true,
+ x_expand: true,
+ });
+ this._screenButton.connect('notify::checked',
+ this._onScreenButtonToggled.bind(this));
+ this._typeButtonContainer.add_child(this._screenButton);
+
+ this.add_child(new Tooltip(this._screenButton, {
+ text: _('Screen Selection'),
+ style_class: 'screenshot-ui-tooltip',
+ visible: false,
+ }));
+
+ this._windowButton = new IconLabelButton('screenshot-ui-window-symbolic', _('Window'), {
+ style_class: 'screenshot-ui-type-button',
+ toggle_mode: true,
+ x_expand: true,
+ });
+ this._windowButton.connect('notify::checked',
+ this._onWindowButtonToggled.bind(this));
+ this._typeButtonContainer.add_child(this._windowButton);
+
+ this.add_child(new Tooltip(this._windowButton, {
+ text: _('Window Selection'),
+ style_class: 'screenshot-ui-tooltip',
+ visible: false,
+ }));
+
+ this._bottomRowContainer = new St.Widget({ layout_manager: new Clutter.BinLayout() });
+ this._panel.add_child(this._bottomRowContainer);
+
+ this._shotCastContainer = new St.BoxLayout({
+ style_class: 'screenshot-ui-shot-cast-container',
+ x_align: Clutter.ActorAlign.START,
+ x_expand: true,
+ });
+ this._bottomRowContainer.add_child(this._shotCastContainer);
+
+ this._shotButton = new St.Button({
+ style_class: 'screenshot-ui-shot-cast-button',
+ icon_name: 'camera-photo-symbolic',
+ checked: true,
+ });
+ this._shotButton.connect('notify::checked',
+ this._onShotButtonToggled.bind(this));
+ this._shotCastContainer.add_child(this._shotButton);
+
+ this._castButton = new St.Button({
+ style_class: 'screenshot-ui-shot-cast-button',
+ icon_name: 'camera-web-symbolic',
+ toggle_mode: true,
+ visible: false,
+ });
+ this._castButton.connect('notify::checked',
+ this._onCastButtonToggled.bind(this));
+ this._shotCastContainer.add_child(this._castButton);
+
+ this._shotButton.bind_property('checked', this._castButton, 'checked',
+ GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.INVERT_BOOLEAN);
+
+ this._shotCastTooltip = new Tooltip(this._shotCastContainer, {
+ text: _('Screenshot / Screencast'),
+ style_class: 'screenshot-ui-tooltip',
+ visible: false,
+ });
+ const shotCastCallback = () => {
+ if (this._shotButton.hover || this._castButton.hover)
+ this._shotCastTooltip.open();
+ else
+ this._shotCastTooltip.close();
+ };
+ this._shotButton.connect('notify::hover', shotCastCallback);
+ this._castButton.connect('notify::hover', shotCastCallback);
+ this.add_child(this._shotCastTooltip);
+
+ this._captureButton = new St.Button({ style_class: 'screenshot-ui-capture-button' });
+ this._captureButton.set_child(new St.Widget({
+ style_class: 'screenshot-ui-capture-button-circle',
+ }));
+ this._captureButton.connect('clicked',
+ this._onCaptureButtonClicked.bind(this));
+ this._bottomRowContainer.add_child(this._captureButton);
+
+ this._showPointerButtonContainer = new St.BoxLayout({
+ x_align: Clutter.ActorAlign.END,
+ x_expand: true,
+ });
+ this._bottomRowContainer.add_child(this._showPointerButtonContainer);
+
+ this._showPointerButton = new St.Button({
+ style_class: 'screenshot-ui-show-pointer-button',
+ icon_name: 'screenshot-ui-show-pointer-symbolic',
+ toggle_mode: true,
+ });
+ this._showPointerButtonContainer.add_child(this._showPointerButton);
+
+ this.add_child(new Tooltip(this._showPointerButton, {
+ text: _('Show Pointer'),
+ style_class: 'screenshot-ui-tooltip',
+ visible: false,
+ }));
+
+ this._showPointerButton.connect('notify::checked', () => {
+ const state = this._showPointerButton.checked;
+ this._cursor.visible = state;
+
+ const windows =
+ this._windowSelectors.flatMap(selector => selector.windows());
+ for (const window of windows)
+ window.setCursorVisible(state);
+ });
+ this._cursor.visible = false;
+
+ this._monitorBins = [];
+ this._windowSelectors = [];
+ this._rebuildMonitorBins();
+
+ Main.layoutManager.connect('monitors-changed', () => {
+ // Nope, not dealing with monitor changes.
+ this.close(true);
+ this._rebuildMonitorBins();
+ });
+
+ const uiModes =
+ Shell.ActionMode.ALL & ~Shell.ActionMode.LOGIN_SCREEN;
+ const restrictedModes =
+ uiModes &
+ ~(Shell.ActionMode.LOCK_SCREEN | Shell.ActionMode.UNLOCK_SCREEN);
+
+ Main.wm.addKeybinding(
+ 'show-screenshot-ui',
+ new Gio.Settings({ schema_id: 'org.gnome.shell.keybindings' }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ uiModes,
+ showScreenshotUI
+ );
+
+ Main.wm.addKeybinding(
+ 'show-screen-recording-ui',
+ new Gio.Settings({ schema_id: 'org.gnome.shell.keybindings' }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ restrictedModes,
+ showScreenRecordingUI
+ );
+
+ Main.wm.addKeybinding(
+ 'screenshot-window',
+ new Gio.Settings({ schema_id: 'org.gnome.shell.keybindings' }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT | Meta.KeyBindingFlags.PER_WINDOW,
+ restrictedModes,
+ async (_display, window, _binding) => {
+ try {
+ const actor = window.get_compositor_private();
+ const content = actor.paint_to_content(null);
+ const texture = content.get_texture();
+
+ await captureScreenshot(texture, null, 1, null);
+ } catch (e) {
+ logError(e, 'Error capturing screenshot');
+ }
+ }
+ );
+
+ Main.wm.addKeybinding(
+ 'screenshot',
+ new Gio.Settings({ schema_id: 'org.gnome.shell.keybindings' }),
+ Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
+ uiModes,
+ async () => {
+ try {
+ const shooter = new Shell.Screenshot();
+ const [content] = await shooter.screenshot_stage_to_content();
+ const texture = content.get_texture();
+
+ await captureScreenshot(texture, null, 1, null);
+ } catch (e) {
+ logError(e, 'Error capturing screenshot');
+ }
+ }
+ );
+
+ Main.sessionMode.connect('updated',
+ () => this._sessionUpdated());
+ this._sessionUpdated();
+ }
+
+ _sessionUpdated() {
+ this.close(true);
+ this._castButton.reactive = Main.sessionMode.allowScreencast;
+ }
+
+ _syncWindowButtonSensitivity() {
+ const windows =
+ this._windowSelectors.flatMap(selector => selector.windows());
+
+ this._windowButton.reactive =
+ Main.sessionMode.hasWindows &&
+ windows.length > 0 &&
+ !this._castButton.checked;
+ }
+
+ _refreshButtonLayout() {
+ const buttonLayout = Meta.prefs_get_button_layout();
+
+ this._closeButton.remove_style_class_name('left');
+ this._closeButton.remove_style_class_name('right');
+
+ if (buttonLayout.left_buttons.includes(Meta.ButtonFunction.CLOSE)) {
+ this._closeButton.add_style_class_name('left');
+ this._closeButtonXAlignConstraint.factor = 0;
+ } else {
+ this._closeButton.add_style_class_name('right');
+ this._closeButtonXAlignConstraint.factor = 1;
+ }
+ }
+
+ _rebuildMonitorBins() {
+ for (const bin of this._monitorBins)
+ bin.destroy();
+
+ this._monitorBins = [];
+ this._windowSelectors = [];
+ this._screenSelectors = [];
+
+ for (let i = 0; i < Main.layoutManager.monitors.length; i++) {
+ const bin = new St.Widget({
+ layout_manager: new Clutter.BinLayout(),
+ });
+ bin.add_constraint(new Layout.MonitorConstraint({ 'index': i }));
+ this.insert_child_below(bin, this._primaryMonitorBin);
+ this._monitorBins.push(bin);
+
+ const windowSelector = new UIWindowSelector(i, {
+ style_class: 'screenshot-ui-window-selector',
+ x_expand: true,
+ y_expand: true,
+ visible: this._windowButton.checked,
+ });
+ if (i === Main.layoutManager.primaryIndex)
+ windowSelector.add_style_pseudo_class('primary-monitor');
+
+ bin.add_child(windowSelector);
+ this._windowSelectors.push(windowSelector);
+
+ const screenSelector = new St.Button({
+ style_class: 'screenshot-ui-screen-selector',
+ x_expand: true,
+ y_expand: true,
+ visible: this._screenButton.checked,
+ reactive: true,
+ can_focus: true,
+ toggle_mode: true,
+ });
+ screenSelector.connect('key-focus-in', () => {
+ this.grab_key_focus();
+ screenSelector.checked = true;
+ });
+ bin.add_child(screenSelector);
+ this._screenSelectors.push(screenSelector);
+
+ screenSelector.connect('notify::checked', () => {
+ if (!screenSelector.checked)
+ return;
+
+ screenSelector.toggle_mode = false;
+
+ for (const otherSelector of this._screenSelectors) {
+ if (screenSelector === otherSelector)
+ continue;
+
+ otherSelector.toggle_mode = true;
+ otherSelector.checked = false;
+ }
+ });
+ }
+
+ if (Main.layoutManager.primaryIndex !== -1)
+ this._screenSelectors[Main.layoutManager.primaryIndex].checked = true;
+ }
+
+ async open(mode = UIMode.SCREENSHOT) {
+ if (this._openingCoroutineInProgress)
+ return;
+
+ if (this._screencastInProgress)
+ return;
+
+ if (mode === UIMode.SCREENCAST && !this._screencastSupported)
+ return;
+
+ this._castButton.checked = mode === UIMode.SCREENCAST;
+
+ if (!this.visible) {
+ // Screenshot UI is opening from completely closed state
+ // (rather than opening back from in process of closing).
+ for (const selector of this._windowSelectors)
+ selector.capture();
+
+ const windows =
+ this._windowSelectors.flatMap(selector => selector.windows());
+ for (const window of windows) {
+ window.connect('notify::checked', () => {
+ if (!window.checked)
+ return;
+
+ window.toggle_mode = false;
+
+ for (const otherWindow of windows) {
+ if (window === otherWindow)
+ continue;
+
+ otherWindow.toggle_mode = true;
+ otherWindow.checked = false;
+ }
+ });
+ }
+
+ this._syncWindowButtonSensitivity();
+ if (!this._windowButton.reactive)
+ this._selectionButton.checked = true;
+
+ this._shooter = new Shell.Screenshot();
+
+ this._openingCoroutineInProgress = true;
+ try {
+ const [content, scale, cursorContent, cursorPoint, cursorScale] =
+ await this._shooter.screenshot_stage_to_content();
+ this._stageScreenshot.set_content(content);
+ this._scale = scale;
+
+ if (cursorContent !== null) {
+ this._cursor.set_content(cursorContent);
+ this._cursor.set_position(cursorPoint.x, cursorPoint.y);
+
+ let [, w, h] = cursorContent.get_preferred_size();
+ w *= cursorScale;
+ h *= cursorScale;
+ this._cursor.set_size(w, h);
+
+ this._cursorScale = cursorScale;
+
+ for (const window of windows) {
+ window.addCursorTexture(cursorContent, cursorPoint, cursorScale);
+ window.setCursorVisible(this._showPointerButton.checked);
+ }
+ }
+
+ this._stageScreenshotContainer.show();
+ } catch (e) {
+ log(`Error capturing screenshot: ${e.message}`);
+ }
+ this._openingCoroutineInProgress = false;
+ }
+
+ // Get rid of any popup menus.
+ // We already have them captured on the screenshot anyway.
+ //
+ // This needs to happen before the grab below as closing menus will
+ // pop their grabs.
+ Main.layoutManager.emit('system-modal-opened');
+
+ const { screenshotUIGroup } = Main.layoutManager;
+ screenshotUIGroup.get_parent().set_child_above_sibling(
+ screenshotUIGroup, null);
+
+ const grabResult = this._grabHelper.grab({
+ actor: this,
+ onUngrab: () => this.close(),
+ });
+ if (!grabResult) {
+ this.close(true);
+ return;
+ }
+
+ this._refreshButtonLayout();
+
+ this.remove_all_transitions();
+ this.visible = true;
+ this.ease({
+ opacity: 255,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ this._stageScreenshotContainer.get_parent().remove_child(
+ this._stageScreenshotContainer);
+ this.insert_child_at_index(this._stageScreenshotContainer, 0);
+ },
+ });
+ }
+
+ _finishClosing() {
+ this.hide();
+
+ this._shooter = null;
+
+ // Switch back to screenshot mode.
+ this._shotButton.checked = true;
+
+ this._stageScreenshotContainer.get_parent().remove_child(
+ this._stageScreenshotContainer);
+ Main.layoutManager.screenshotUIGroup.insert_child_at_index(
+ this._stageScreenshotContainer, 0);
+ this._stageScreenshotContainer.hide();
+
+ this._stageScreenshot.set_content(null);
+ this._cursor.set_content(null);
+
+ this._areaSelector.reset();
+ for (const selector of this._windowSelectors)
+ selector.reset();
+ }
+
+ close(instantly = false) {
+ this._grabHelper.ungrab();
+
+ if (instantly) {
+ this._finishClosing();
+ return;
+ }
+
+ this.remove_all_transitions();
+ this.ease({
+ opacity: 0,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: this._finishClosing.bind(this),
+ });
+ }
+
+ _onSelectionButtonToggled() {
+ if (this._selectionButton.checked) {
+ this._selectionButton.toggle_mode = false;
+ this._windowButton.checked = false;
+ this._screenButton.checked = false;
+
+ this._areaSelector.show();
+ this._areaSelector.remove_all_transitions();
+ this._areaSelector.reactive = true;
+ this._areaSelector.ease({
+ opacity: 255,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ } else {
+ this._selectionButton.toggle_mode = true;
+
+ this._areaSelector.stopDrag();
+ global.display.set_cursor(Meta.Cursor.DEFAULT);
+
+ this._areaSelector.remove_all_transitions();
+ this._areaSelector.reactive = false;
+ this._areaSelector.ease({
+ opacity: 0,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this._areaSelector.hide(),
+ });
+ }
+ }
+
+ _onScreenButtonToggled() {
+ if (this._screenButton.checked) {
+ this._screenButton.toggle_mode = false;
+ this._selectionButton.checked = false;
+ this._windowButton.checked = false;
+
+ for (const selector of this._screenSelectors) {
+ selector.show();
+ selector.remove_all_transitions();
+ selector.ease({
+ opacity: 255,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+ } else {
+ this._screenButton.toggle_mode = true;
+
+ for (const selector of this._screenSelectors) {
+ selector.remove_all_transitions();
+ selector.ease({
+ opacity: 0,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => selector.hide(),
+ });
+ }
+ }
+ }
+
+ _onWindowButtonToggled() {
+ if (this._windowButton.checked) {
+ this._windowButton.toggle_mode = false;
+ this._selectionButton.checked = false;
+ this._screenButton.checked = false;
+
+ for (const selector of this._windowSelectors) {
+ selector.show();
+ selector.remove_all_transitions();
+ selector.ease({
+ opacity: 255,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+ } else {
+ this._windowButton.toggle_mode = true;
+
+ for (const selector of this._windowSelectors) {
+ selector.remove_all_transitions();
+ selector.ease({
+ opacity: 0,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => selector.hide(),
+ });
+ }
+ }
+ }
+
+ _onShotButtonToggled() {
+ if (this._shotButton.checked) {
+ this._shotButton.toggle_mode = false;
+
+ this._stageScreenshotContainer.show();
+ this._stageScreenshotContainer.remove_all_transitions();
+ this._stageScreenshotContainer.ease({
+ opacity: 255,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ } else {
+ this._shotButton.toggle_mode = true;
+ }
+ }
+
+ _onCastButtonToggled() {
+ if (this._castButton.checked) {
+ this._castButton.toggle_mode = false;
+
+ this._captureButton.add_style_pseudo_class('cast');
+
+ this._stageScreenshotContainer.remove_all_transitions();
+ this._stageScreenshotContainer.ease({
+ opacity: 0,
+ duration: 200,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => this._stageScreenshotContainer.hide(),
+ });
+
+ // Screen recording doesn't support window selection yet.
+ if (this._windowButton.checked)
+ this._selectionButton.checked = true;
+
+ this._windowButton.reactive = false;
+ } else {
+ this._castButton.toggle_mode = true;
+
+ this._captureButton.remove_style_pseudo_class('cast');
+
+ this._syncWindowButtonSensitivity();
+ }
+ }
+
+ _getSelectedGeometry(rescale) {
+ let x, y, w, h;
+
+ if (this._selectionButton.checked) {
+ [x, y, w, h] = this._areaSelector.getGeometry();
+ } else if (this._screenButton.checked) {
+ const index =
+ this._screenSelectors.findIndex(screen => screen.checked);
+ const monitor = Main.layoutManager.monitors[index];
+
+ x = monitor.x;
+ y = monitor.y;
+ w = monitor.width;
+ h = monitor.height;
+ }
+
+ if (rescale) {
+ x *= this._scale;
+ y *= this._scale;
+ w *= this._scale;
+ h *= this._scale;
+ }
+
+ return [x, y, w, h];
+ }
+
+ _onCaptureButtonClicked() {
+ if (this._shotButton.checked) {
+ this._saveScreenshot();
+ this.close();
+ } else {
+ // Screencast closes the UI on its own.
+ this._startScreencast();
+ }
+ }
+
+ _saveScreenshot() {
+ if (this._selectionButton.checked || this._screenButton.checked) {
+ const content = this._stageScreenshot.get_content();
+ if (!content)
+ return; // Failed to capture the screenshot for some reason.
+
+ const texture = content.get_texture();
+ const geometry = this._getSelectedGeometry(true);
+
+ let cursorTexture = this._cursor.content?.get_texture();
+ if (!this._cursor.visible)
+ cursorTexture = null;
+
+ captureScreenshot(
+ texture, geometry, this._scale,
+ {
+ texture: cursorTexture ?? null,
+ x: this._cursor.x * this._scale,
+ y: this._cursor.y * this._scale,
+ scale: this._cursorScale,
+ }
+ ).catch(e => logError(e, 'Error capturing screenshot'));
+ } else if (this._windowButton.checked) {
+ const window =
+ this._windowSelectors.flatMap(selector => selector.windows())
+ .find(win => win.checked);
+ if (!window)
+ return;
+
+ const content = window.windowContent;
+ if (!content)
+ return;
+
+ const texture = content.get_texture();
+
+ let cursorTexture = window.getCursorTexture()?.get_texture();
+ if (!this._cursor.visible)
+ cursorTexture = null;
+
+ captureScreenshot(
+ texture,
+ null,
+ window.bufferScale,
+ {
+ texture: cursorTexture ?? null,
+ x: window.cursorPoint.x * window.bufferScale,
+ y: window.cursorPoint.y * window.bufferScale,
+ scale: this._cursorScale,
+ }
+ ).catch(e => logError(e, 'Error capturing screenshot'));
+ }
+ }
+
+ async _startScreencast() {
+ if (this._windowButton.checked)
+ return; // TODO
+
+ const [x, y, w, h] = this._getSelectedGeometry(false);
+ const drawCursor = this._cursor.visible;
+
+ // Set up the screencast indicator rect.
+ if (this._selectionButton.checked) {
+ this._screencastAreaIndicator.setSelectionRect(
+ ...this._areaSelector.getGeometry());
+ } else if (this._screenButton.checked) {
+ const index =
+ this._screenSelectors.findIndex(screen => screen.checked);
+ const monitor = Main.layoutManager.monitors[index];
+
+ this._screencastAreaIndicator.setSelectionRect(
+ monitor.x, monitor.y, monitor.width, monitor.height);
+ }
+
+ // Close instantly so the fade-out doesn't get recorded.
+ this.close(true);
+
+ // This is a bit awkward because creating a proxy synchronously hangs Shell.
+ let method =
+ this._screencastProxy.ScreencastAsync.bind(this._screencastProxy);
+ if (w !== -1) {
+ method = this._screencastProxy.ScreencastAreaAsync.bind(
+ this._screencastProxy, x, y, w, h);
+ }
+
+ // Set this before calling the method as the screen recording indicator
+ // will check it before the success callback fires.
+ this._setScreencastInProgress(true);
+
+ try {
+ const [success, path] = await method(
+ GLib.build_filenamev([
+ /* Translators: this is the folder where recorded
+ screencasts are stored. */
+ _('Screencasts'),
+ /* Translators: this is a filename used for screencast
+ * recording, where "%d" and "%t" date and time, e.g.
+ * "Screencast from 07-17-2013 10:00:46 PM.webm" */
+ /* xgettext:no-c-format */
+ _('Screencast from %d %t.webm'),
+ ]),
+ {'draw-cursor': new GLib.Variant('b', drawCursor)});
+ if (!success)
+ throw new Error();
+ this._screencastPath = path;
+ } catch (error) {
+ this._setScreencastInProgress(false);
+ const {message} = error;
+ if (message)
+ log(`Error starting screencast: ${message}`);
+ else
+ log('Error starting screencast');
+ }
+ }
+
+ async stopScreencast() {
+ if (!this._screencastInProgress)
+ return;
+
+ // Set this before calling the method as the screen recording indicator
+ // will check it before the success callback fires.
+ this._setScreencastInProgress(false);
+
+ try {
+ const [success] = await this._screencastProxy.StopScreencastAsync();
+ if (!success)
+ throw new Error();
+ } catch (error) {
+ const {message} = error;
+ if (message)
+ log(`Error stopping screencast: ${message}`);
+ else
+ log('Error stopping screencast');
+ return;
+ }
+
+ // Show a notification.
+ const file = Gio.file_new_for_path(this._screencastPath);
+
+ const source = new MessageTray.Source(
+ // Translators: notification source name.
+ _('Screenshot'),
+ 'screencast-recorded-symbolic'
+ );
+ const notification = new MessageTray.Notification(
+ source,
+ // Translators: notification title.
+ _('Screencast recorded'),
+ // Translators: notification body when a screencast was recorded.
+ _('Click here to view the video.')
+ );
+ // Translators: button on the screencast notification.
+ notification.addAction(_('Show in Files'), () => {
+ const app =
+ Gio.app_info_get_default_for_type('inode/directory', false);
+
+ if (app === null) {
+ // It may be null e.g. in a toolbox without nautilus.
+ log('Error showing in files: no default app set for inode/directory');
+ return;
+ }
+
+ app.launch([file], global.create_app_launch_context(0, -1));
+ });
+ notification.connect('activated', () => {
+ try {
+ Gio.app_info_launch_default_for_uri(
+ file.get_uri(), global.create_app_launch_context(0, -1));
+ } catch (err) {
+ logError(err, 'Error opening screencast');
+ }
+ });
+ notification.setTransient(true);
+
+ Main.messageTray.add(source);
+ source.showNotification(notification);
+ }
+
+ get screencast_in_progress() {
+ if (!('_screencastInProgress' in this))
+ return false;
+
+ return this._screencastInProgress;
+ }
+
+ _setScreencastInProgress(inProgress) {
+ if (this._screencastInProgress === inProgress)
+ return;
+
+ this._screencastInProgress = inProgress;
+ this.notify('screencast-in-progress');
+ }
+
+ vfunc_key_press_event(event) {
+ const symbol = event.keyval;
+ if (symbol === Clutter.KEY_Return || symbol === Clutter.KEY_space ||
+ ((event.modifier_state & Clutter.ModifierType.CONTROL_MASK) &&
+ (symbol === Clutter.KEY_c || symbol === Clutter.KEY_C))) {
+ this._onCaptureButtonClicked();
+ return Clutter.EVENT_STOP;
+ }
+
+ if (symbol === Clutter.KEY_s || symbol === Clutter.KEY_S) {
+ this._selectionButton.checked = true;
+ return Clutter.EVENT_STOP;
+ }
+
+ if (symbol === Clutter.KEY_c || symbol === Clutter.KEY_C) {
+ this._screenButton.checked = true;
+ return Clutter.EVENT_STOP;
+ }
+
+ if (this._windowButton.reactive &&
+ (symbol === Clutter.KEY_w || symbol === Clutter.KEY_W)) {
+ this._windowButton.checked = true;
+ return Clutter.EVENT_STOP;
+ }
+
+ if (symbol === Clutter.KEY_p || symbol === Clutter.KEY_P) {
+ this._showPointerButton.checked = !this._showPointerButton.checked;
+ return Clutter.EVENT_STOP;
+ }
+
+ if (this._castButton.reactive &&
+ (symbol === Clutter.KEY_v || symbol === Clutter.KEY_V)) {
+ this._castButton.checked = !this._castButton.checked;
+ return Clutter.EVENT_STOP;
+ }
+
+ if (symbol === Clutter.KEY_Left || symbol === Clutter.KEY_Right ||
+ symbol === Clutter.KEY_Up || symbol === Clutter.KEY_Down) {
+ let direction;
+ if (symbol === Clutter.KEY_Left)
+ direction = St.DirectionType.LEFT;
+ else if (symbol === Clutter.KEY_Right)
+ direction = St.DirectionType.RIGHT;
+ else if (symbol === Clutter.KEY_Up)
+ direction = St.DirectionType.UP;
+ else if (symbol === Clutter.KEY_Down)
+ direction = St.DirectionType.DOWN;
+
+ if (this._windowButton.checked) {
+ const window =
+ this._windowSelectors.flatMap(selector => selector.windows())
+ .find(win => win.checked) ?? null;
+ this.navigate_focus(window, direction, false);
+ } else if (this._screenButton.checked) {
+ const screen =
+ this._screenSelectors.find(selector => selector.checked) ?? null;
+ this.navigate_focus(screen, direction, false);
+ }
+
+ return Clutter.EVENT_STOP;
+ }
+
+ return super.vfunc_key_press_event(event);
+ }
+});
+
+/**
+ * Stores a PNG-encoded screenshot into the clipboard and a file, and shows a
+ * notification.
+ *
+ * @param {GLib.Bytes} bytes - The PNG-encoded screenshot.
+ * @param {GdkPixbuf.Pixbuf} pixbuf - The Pixbuf with the screenshot.
+ */
+function _storeScreenshot(bytes, pixbuf) {
+ // Store to the clipboard first in case storing to file fails.
+ const clipboard = St.Clipboard.get_default();
+ clipboard.set_content(St.ClipboardType.CLIPBOARD, 'image/png', bytes);
+
+ const time = GLib.DateTime.new_now_local();
+
+ // This will be set in the first save to disk branch and then accessed
+ // in the second save to disk branch, so we need to declare it outside.
+ let file;
+
+ // The function is declared here rather than inside the condition to
+ // satisfy eslint.
+
+ /**
+ * Returns a filename suffix with an increasingly large index.
+ *
+ * @returns {Generator<string|*, void, *>} suffix string
+ */
+ function* suffixes() {
+ yield '';
+
+ for (let i = 1; ; i++)
+ yield `-${i}`;
+ }
+
+ const lockdownSettings =
+ new Gio.Settings({ schema_id: 'org.gnome.desktop.lockdown' });
+ const disableSaveToDisk =
+ lockdownSettings.get_boolean('disable-save-to-disk');
+
+ if (!disableSaveToDisk) {
+ const dir = Gio.File.new_for_path(GLib.build_filenamev([
+ GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_PICTURES) || GLib.get_home_dir(),
+ // Translators: name of the folder under ~/Pictures for screenshots.
+ _('Screenshots'),
+ ]));
+
+ try {
+ dir.make_directory_with_parents(null);
+ } catch (e) {
+ if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS))
+ throw e;
+ }
+
+ const timestamp = time.format('%Y-%m-%d %H-%M-%S');
+ // Translators: this is the name of the file that the screenshot is
+ // saved to. The placeholder is a timestamp, e.g. "2017-05-21 12-24-03".
+ const name = _('Screenshot from %s').format(timestamp);
+
+ // If the target file already exists, try appending a suffix with an
+ // increasing number to it.
+ for (const suffix of suffixes()) {
+ file = Gio.File.new_for_path(GLib.build_filenamev([
+ dir.get_path(), `${name}${suffix}.png`,
+ ]));
+
+ try {
+ const stream = file.create(Gio.FileCreateFlags.NONE, null);
+ stream.write_bytes(bytes, null);
+ break;
+ } catch (e) {
+ if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS))
+ throw e;
+ }
+ }
+
+ // Add it to recent files.
+ Gtk.RecentManager.get_default().add_item(file.get_uri());
+ }
+
+ // Create a St.ImageContent icon for the notification. We want
+ // St.ImageContent specifically because it preserves the aspect ratio when
+ // shown in a notification.
+ const pixels = pixbuf.read_pixel_bytes();
+ const content =
+ St.ImageContent.new_with_preferred_size(pixbuf.width, pixbuf.height);
+ content.set_bytes(
+ pixels,
+ Cogl.PixelFormat.RGBA_8888,
+ pixbuf.width,
+ pixbuf.height,
+ pixbuf.rowstride
+ );
+
+ // Show a notification.
+ const source = new MessageTray.Source(
+ // Translators: notification source name.
+ _('Screenshot'),
+ 'screenshot-recorded-symbolic'
+ );
+ const notification = new MessageTray.Notification(
+ source,
+ // Translators: notification title.
+ _('Screenshot captured'),
+ // Translators: notification body when a screenshot was captured.
+ _('You can paste the image from the clipboard.'),
+ { datetime: time, gicon: content }
+ );
+
+ if (!disableSaveToDisk) {
+ // Translators: button on the screenshot notification.
+ notification.addAction(_('Show in Files'), () => {
+ const app =
+ Gio.app_info_get_default_for_type('inode/directory', false);
+
+ if (app === null) {
+ // It may be null e.g. in a toolbox without nautilus.
+ log('Error showing in files: no default app set for inode/directory');
+ return;
+ }
+
+ app.launch([file], global.create_app_launch_context(0, -1));
+ });
+ notification.connect('activated', () => {
+ try {
+ Gio.app_info_launch_default_for_uri(
+ file.get_uri(), global.create_app_launch_context(0, -1));
+ } catch (err) {
+ logError(err, 'Error opening screenshot');
+ }
+ });
+ }
+
+ notification.setTransient(true);
+ Main.messageTray.add(source);
+ source.showNotification(notification);
+}
+
+/**
+ * Captures a screenshot from a texture, given a region, scale and optional
+ * cursor data.
+ *
+ * @param {Cogl.Texture} texture - The texture to take the screenshot from.
+ * @param {number[4]} [geometry] - The region to use: x, y, width and height.
+ * @param {number} scale - The texture scale.
+ * @param {Object} [cursor] - Cursor data to include in the screenshot.
+ * @param {Cogl.Texture} cursor.texture - The cursor texture.
+ * @param {number} cursor.x - The cursor x coordinate.
+ * @param {number} cursor.y - The cursor y coordinate.
+ * @param {number} cursor.scale - The cursor texture scale.
+ */
+async function captureScreenshot(texture, geometry, scale, cursor) {
+ const stream = Gio.MemoryOutputStream.new_resizable();
+ const [x, y, w, h] = geometry ?? [0, 0, -1, -1];
+ if (cursor === null)
+ cursor = { texture: null, x: 0, y: 0, scale: 1 };
+
+ global.display.get_sound_player().play_from_theme(
+ 'screen-capture', _('Screenshot taken'), null);
+
+ const pixbuf = await Shell.Screenshot.composite_to_stream(
+ texture,
+ x, y, w, h,
+ scale,
+ cursor.texture, cursor.x, cursor.y, cursor.scale,
+ stream
+ );
+
+ stream.close(null);
+ _storeScreenshot(stream.steal_as_bytes(), pixbuf);
+}
+
+/**
+ * Shows the screenshot UI.
+ */
+function showScreenshotUI() {
+ Main.screenshotUI.open().catch(err => {
+ logError(err, 'Error opening the screenshot UI');
+ });
+}
+
+/**
+ * Shows the screen recording UI.
+ */
+function showScreenRecordingUI() {
+ Main.screenshotUI.open(UIMode.SCREENCAST).catch(err => {
+ logError(err, 'Error opening the screenshot UI');
+ });
+}
+
+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._senderChecker = new DBusSenderChecker([
+ 'org.gnome.SettingsDaemon.MediaKeys',
+ 'org.freedesktop.impl.portal.desktop.gtk',
+ 'org.freedesktop.impl.portal.desktop.gnome',
+ 'org.gnome.Screenshot',
+ ]);
+
+ 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);
+ }
+
+ async _createScreenshot(invocation, needsDisk = true, restrictCallers = 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)) {
+ invocation.return_error_literal(
+ Gio.IOErrorEnum, Gio.IOErrorEnum.BUSY,
+ 'There is an ongoing operation for this sender');
+ return null;
+ } else if (lockedDown) {
+ invocation.return_error_literal(
+ Gio.IOErrorEnum, Gio.IOErrorEnum.PERMISSION_DENIED,
+ 'Saving to disk is disabled');
+ return null;
+ } else if (restrictCallers) {
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ 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 => 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_gerror(e);
+ this._removeShooterForSender(invocation.get_sender());
+ return [null, null];
+ }
+ }
+
+ let err;
+ for (let file of this._resolveRelativeFilename(filename)) {
+ try {
+ let stream = file.create(Gio.FileCreateFlags.NONE, null);
+ return [stream, file];
+ } catch (e) {
+ err = e;
+ if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS))
+ break;
+ }
+ }
+
+ invocation.return_gerror(err);
+ this._removeShooterForSender(invocation.get_sender());
+ return [null, null];
+ }
+
+ _flashAsync(shooter) {
+ return new Promise((resolve, _reject) => {
+ shooter.connect('screenshot_taken', (s, area) => {
+ const flashspot = new Flashspot(area);
+ flashspot.fire(resolve);
+
+ global.display.get_sound_player().play_from_theme(
+ 'screen-capture', _('Screenshot taken'), null);
+ });
+ });
+ }
+
+ _onScreenshotComplete(stream, file, invocation) {
+ 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 = await this._createScreenshot(invocation);
+ if (!screenshot)
+ return;
+
+ let [stream, file] = this._createStream(filename, invocation);
+ if (!stream)
+ return;
+
+ try {
+ await Promise.all([
+ flash ? this._flashAsync(screenshot) : null,
+ screenshot.screenshot_area(x, y, width, height, stream),
+ ]);
+ this._onScreenshotComplete(stream, file, invocation);
+ } catch (e) {
+ invocation.return_value(new GLib.Variant('(bs)', [false, '']));
+ } finally {
+ this._removeShooterForSender(invocation.get_sender());
+ }
+ }
+
+ async ScreenshotWindowAsync(params, invocation) {
+ let [includeFrame, includeCursor, flash, filename] = params;
+ let screenshot = await this._createScreenshot(invocation);
+ if (!screenshot)
+ return;
+
+ let [stream, file] = this._createStream(filename, invocation);
+ if (!stream)
+ return;
+
+ try {
+ await Promise.all([
+ flash ? this._flashAsync(screenshot) : null,
+ screenshot.screenshot_window(includeFrame, includeCursor, stream),
+ ]);
+ this._onScreenshotComplete(stream, file, invocation);
+ } catch (e) {
+ invocation.return_value(new GLib.Variant('(bs)', [false, '']));
+ } finally {
+ this._removeShooterForSender(invocation.get_sender());
+ }
+ }
+
+ async ScreenshotAsync(params, invocation) {
+ let [includeCursor, flash, filename] = params;
+ let screenshot = await this._createScreenshot(invocation);
+ if (!screenshot)
+ return;
+
+ let [stream, file] = this._createStream(filename, invocation);
+ if (!stream)
+ return;
+
+ try {
+ await Promise.all([
+ flash ? this._flashAsync(screenshot) : null,
+ screenshot.screenshot(includeCursor, stream),
+ ]);
+ this._onScreenshotComplete(stream, file, invocation);
+ } catch (e) {
+ invocation.return_value(new GLib.Variant('(bs)', [false, '']));
+ } finally {
+ this._removeShooterForSender(invocation.get_sender());
+ }
+ }
+
+ async SelectAreaAsync(params, invocation) {
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ return;
+ }
+
+ 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');
+ }
+ }
+
+ async FlashAreaAsync(params, invocation) {
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ return;
+ }
+
+ 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 = await this._createScreenshot(invocation, false, 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);
+
+ const 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) {
+ if (this._result)
+ return Clutter.EVENT_PROPAGATE;
+
+ [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() {
+ if (this._startX === -1 || this._startY === -1 || this._result)
+ return Clutter.EVENT_PROPAGATE;
+
+ 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);
+
+ const 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..a4d80d7
--- /dev/null
+++ b/js/ui/scripting.js
@@ -0,0 +1,340 @@
+// -*- 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);
+}
+
+/**
+ * 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();
+ perfHelper.CreateWindowAsync(
+ params.width, params.height,
+ params.alpha, params.maximized, params.redraws).catch(logError);
+}
+
+/**
+ * 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();
+ perfHelper.WaitWindowsAsync().catch(logError);
+}
+
+/**
+ * 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();
+ perfHelper.DestroyWindowsAsync().catch(logError);
+}
+
+/**
+ * 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, ', ');
+ const prefix = i === primary ? '*' : '';
+ Shell.write_string_to_stream(out,
+ `"${prefix}${monitor.width}x${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..1029f31
--- /dev/null
+++ b/js/ui/search.js
@@ -0,0 +1,945 @@
+// -*- 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 { Highlighter } = 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);
+
+ 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._resultsView.connectObject(
+ 'terms-changed', this._highlightTerms.bind(this), this);
+
+ this._highlightTerms();
+ }
+ }
+
+ get ICON_SIZE() {
+ return 24;
+ }
+
+ _highlightTerms() {
+ let markup = this._resultsView.highlightTerms(this.metaInfo['description'].split('\n')[0]);
+ this._descriptionLabel.clutter_text.set_markup(markup);
+ }
+});
+
+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) {
+ }
+
+ async _ensureResultActors(results) {
+ let metasNeeded = results.filter(
+ resultId => this._resultDisplays[resultId] === undefined);
+
+ if (metasNeeded.length === 0)
+ return;
+
+ this._cancellable.cancel();
+ this._cancellable.reset();
+
+ const metas = await this.provider.getResultMetas(metasNeeded, this._cancellable);
+
+ if (this._cancellable.is_cancelled()) {
+ if (metas.length > 0)
+ throw new Error(`Search provider ${this.provider.id} returned results after the request was canceled`);
+ }
+
+ if (metas.length !== metasNeeded.length) {
+ throw new Error(`Wrong number of result metas returned by search provider ${this.provider.id}: ` +
+ `expected ${metasNeeded.length} but got ${metas.length}`);
+ }
+
+ if (metas.some(meta => !meta.name || !meta.id))
+ throw new Error(`Invalid result meta returned from search provider ${this.provider.id}`);
+
+ 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;
+ });
+ }
+
+ async 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);
+
+ try {
+ await this._ensureResultActors(results);
+
+ // 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();
+ } catch (e) {
+ this._clearResultDisplay();
+ 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);
+ child.can_focus = childBox.get_area() > 0;
+
+ 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,
+ x_expand: true,
+ y_expand: 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._highlighter = new Highlighter();
+
+ 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);
+ });
+
+ const providers = RemoteSearch.loadRemoteSearchProviders(this._searchSettings);
+ 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();
+ }
+
+ _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();
+ }
+
+ async _doProviderSearch(provider, previousResults) {
+ provider.searchInProgress = true;
+
+ let results;
+ if (this._isSubSearch && previousResults) {
+ results = await provider.getSubsearchResultSet(
+ previousResults,
+ this._terms,
+ this._cancellable);
+ } else {
+ results = await provider.getInitialResultSet(
+ this._terms,
+ this._cancellable);
+ }
+
+ this._results[provider.id] = results;
+ this._updateResults(provider, results);
+ }
+
+ _doSearch() {
+ this._startingSearch = false;
+
+ let previousResults = this._results;
+ this._results = {};
+
+ this._providers.forEach(provider => {
+ let previousProviderResults = previousResults[provider.id];
+ this._doProviderSearch(provider, previousProviderResults);
+ });
+
+ 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));
+
+ this._highlighter = new Highlighter(this._terms);
+
+ 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;
+ }
+
+ const from = 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 '';
+
+ return this._highlighter.highlight(description);
+ }
+});
+
+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);
+
+ const icon = new St.Icon({
+ icon_size: this.PROVIDER_ICON_SIZE,
+ gicon: provider.appInfo.get_icon(),
+ });
+
+ const detailsBox = new St.BoxLayout({
+ style_class: 'list-search-provider-details',
+ vertical: true,
+ x_expand: true,
+ });
+
+ const 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/searchController.js b/js/ui/searchController.js
new file mode 100644
index 0000000..ba743a9
--- /dev/null
+++ b/js/ui/searchController.js
@@ -0,0 +1,325 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported SearchController */
+
+const { Clutter, GObject, St } = imports.gi;
+
+const Main = imports.ui.main;
+const Search = imports.ui.search;
+const ShellEntry = imports.ui.shellEntry;
+
+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 [];
+ return searchString.split(/\s+/);
+}
+
+var SearchController = GObject.registerClass({
+ Properties: {
+ 'search-active': GObject.ParamSpec.boolean(
+ 'search-active', 'search-active', 'search-active',
+ GObject.ParamFlags.READABLE,
+ false),
+ },
+}, class SearchController extends St.Widget {
+ _init(searchEntry, showAppsButton) {
+ super._init({
+ name: 'searchController',
+ layout_manager: new Clutter.BinLayout(),
+ x_expand: true,
+ y_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._searchResults = new Search.SearchResultsView();
+ this.add_child(this._searchResults);
+ Main.ctrlAltTabManager.addGroup(this._entry, _('Search'), 'edit-find-symbolic');
+
+ // 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;
+ }
+ });
+ }
+
+ prepareToEnterOverview() {
+ this.reset();
+ this._setSearchActive(false);
+ }
+
+ vfunc_unmap() {
+ this.reset();
+
+ super.vfunc_unmap();
+ }
+
+ _setSearchActive(searchActive) {
+ if (this._searchActive === searchActive)
+ return;
+
+ this._searchActive = searchActive;
+ this.notify('search-active');
+ }
+
+ _onShowAppsButtonToggled() {
+ this._setSearchActive(false);
+ }
+
+ _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);
+ }
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _searchCancelled() {
+ this._setSearchActive(false);
+
+ // 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);
+ this._text.event(event, 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());
+
+ const searchActive = terms.length > 0;
+ this._searchResults.setTerms(terms);
+
+ if (searchActive) {
+ this._setSearchActive(true);
+
+ 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) {
+ const targetActor = global.stage.get_event_actor(event);
+ if (targetActor !== this._text &&
+ this._text.has_key_focus() &&
+ this._text.text === '' &&
+ !this._text.has_preedit() &&
+ !Main.layoutManager.keyboardBox.contains(targetActor)) {
+ // 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;
+ }
+
+ get searchActive() {
+ return this._searchActive;
+ }
+});
diff --git a/js/ui/sessionMode.js b/js/ui/sessionMode.js
new file mode 100644
index 0000000..b38bcdf
--- /dev/null
+++ b/js/ui/sessionMode.js
@@ -0,0 +1,206 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported SessionMode, listModes */
+
+const GLib = imports.gi.GLib;
+const Signals = imports.misc.signals;
+
+const FileUtils = imports.misc.fileUtils;
+const Params = imports.misc.params;
+
+const Config = imports.misc.config;
+
+const DEFAULT_MODE = 'restrictive';
+
+const USER_SESSION_COMPONENTS = [
+ 'polkitAgent', 'telepathyClient', 'keyring',
+ 'autorunManager', 'automountManager',
+];
+
+if (Config.HAVE_NETWORKMANAGER)
+ USER_SESSION_COMPONENTS.push('networkAgent');
+
+const _modes = {
+ 'restrictive': {
+ parentMode: null,
+ stylesheetName: 'gnome-shell.css',
+ themeResourceName: 'gnome-shell-theme.gresource',
+ hasOverview: false,
+ showCalendarEvents: false,
+ showWelcomeDialog: false,
+ allowSettings: 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: Config.HAVE_NETWORKMANAGER
+ ? ['networkAgent', 'polkitAgent']
+ : ['polkitAgent'],
+ panel: {
+ left: [],
+ center: ['dateMenu'],
+ right: ['dwellClick', 'a11y', 'keyboard', 'quickSettings'],
+ },
+ panelStyle: 'login-screen',
+ },
+
+ 'unlock-dialog': {
+ isLocked: true,
+ unlockDialog: undefined,
+ components: ['polkitAgent', 'telepathyClient'],
+ panel: {
+ left: [],
+ center: [],
+ right: ['dwellClick', 'a11y', 'keyboard', 'quickSettings'],
+ },
+ panelStyle: 'unlock-screen',
+ },
+
+ 'user': {
+ hasOverview: true,
+ showCalendarEvents: true,
+ showWelcomeDialog: true,
+ allowSettings: true,
+ allowScreencast: true,
+ hasRunDialog: true,
+ hasWorkspaces: true,
+ hasWindows: true,
+ hasWmMenus: true,
+ hasNotifications: true,
+ isLocked: false,
+ isPrimary: true,
+ unlockDialog: imports.ui.unlockDialog.UnlockDialog,
+ components: USER_SESSION_COMPONENTS,
+ panel: {
+ left: ['activities', 'appMenu'],
+ center: ['dateMenu'],
+ right: ['screenRecording', 'screenSharing', 'dwellClick', 'a11y', 'keyboard', 'quickSettings'],
+ },
+ },
+};
+
+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);
+ const decoder = new TextDecoder();
+ newMode = JSON.parse(decoder.decode(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 extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ _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) {
+ console.debug(`sessionMode: Pushing mode ${mode}`);
+ this._modeStack.push(mode);
+ this._sync();
+ }
+
+ popMode(mode) {
+ if (this.currentMode != mode || this._modeStack.length === 1)
+ throw new Error("Invalid SessionMode.popMode");
+
+ console.debug(`sessionMode: Popping mode ${mode}`);
+ 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');
+ }
+};
diff --git a/js/ui/shellDBus.js b/js/ui/shellDBus.js
new file mode 100644
index 0000000..284d92b
--- /dev/null
+++ b/js/ui/shellDBus.js
@@ -0,0 +1,540 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported GnomeShell, ScreenSaverDBus */
+
+const { Gio, GLib, Meta, Shell } = 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 { DBusSenderChecker } = imports.misc.util;
+const { ControlsState } = imports.ui.overviewControls;
+
+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._senderChecker = new DBusSenderChecker([
+ 'org.gnome.Settings',
+ 'org.gnome.SettingsDaemon.MediaKeys',
+ ]);
+
+ 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.context.unsafe_mode)
+ 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];
+ }
+
+ /**
+ * Focus the overview's search entry
+ *
+ * @async
+ * @param {...any} params - method parameters
+ * @param {Gio.DBusMethodInvocation} invocation - the invocation
+ * @returns {void}
+ */
+ async FocusSearchAsync(params, invocation) {
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ return;
+ }
+
+ Main.overview.focusSearch();
+ invocation.return_value(null);
+ }
+
+ /**
+ * Show OSD with the specified parameters
+ *
+ * @async
+ * @param {...any} params - method parameters
+ * @param {Gio.DBusMethodInvocation} invocation - the invocation
+ * @returns {void}
+ */
+ async ShowOSDAsync([params], invocation) {
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ return;
+ }
+
+ for (let param in params)
+ params[param] = params[param].deepUnpack();
+
+ const {
+ 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);
+ invocation.return_value(null);
+ }
+
+ /**
+ * Focus specified app in the overview's app grid
+ *
+ * @async
+ * @param {string} id - an application ID
+ * @param {Gio.DBusMethodInvocation} invocation - the invocation
+ * @returns {void}
+ */
+ async FocusAppAsync([id], invocation) {
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ return;
+ }
+
+ const appSys = Shell.AppSystem.get_default();
+ if (appSys.lookup_app(id) === null) {
+ invocation.return_error_literal(
+ Gio.DBusError,
+ Gio.DBusError.FILE_NOT_FOUND,
+ `No application with ID ${id}`);
+ return;
+ }
+
+ Main.overview.selectApp(id);
+ invocation.return_value(null);
+ }
+
+ /**
+ * Show the overview's app grid
+ *
+ * @async
+ * @param {...any} params - method parameters
+ * @param {Gio.DBusMethodInvocation} invocation - the invocation
+ * @returns {void}
+ */
+ async ShowApplicationsAsync(params, invocation) {
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ return;
+ }
+
+ Main.overview.show(ControlsState.APP_GRID);
+ invocation.return_value(null);
+ }
+
+ async GrabAcceleratorAsync(params, invocation) {
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ return;
+ }
+
+ 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]));
+ }
+
+ async GrabAcceleratorsAsync(params, invocation) {
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ return;
+ }
+
+ 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]));
+ }
+
+ async UngrabAcceleratorAsync(params, invocation) {
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ return;
+ }
+
+ let [action] = params;
+ let sender = invocation.get_sender();
+ let ungrabSucceeded = this._ungrabAcceleratorForSender(action, sender);
+
+ invocation.return_value(GLib.Variant.new('(b)', [ungrabSucceeded]));
+ }
+
+ async UngrabAcceleratorsAsync(params, invocation) {
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ return;
+ }
+
+ 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]));
+ }
+
+ async ScreenTransitionAsync(params, invocation) {
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ return;
+ }
+
+ Main.layoutManager.screenTransition.run();
+
+ invocation.return_value(null);
+ }
+
+ _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 = {
+ '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?.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);
+ }
+
+ async ShowMonitorLabelsAsync(params, invocation) {
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ return;
+ }
+
+ let sender = invocation.get_sender();
+ let [dict] = params;
+ Main.osdMonitorLabeler.show(sender, dict);
+ invocation.return_value(null);
+ }
+
+ async HideMonitorLabelsAsync(params, invocation) {
+ try {
+ await this._senderChecker.checkInvocation(invocation);
+ } catch (e) {
+ invocation.return_gerror(e);
+ return;
+ }
+
+ 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.Shell.ScreenShield',
+ Gio.BusNameOwnerFlags.NONE, 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..e6c1f37
--- /dev/null
+++ b/js/ui/shellEntry.js
@@ -0,0 +1,206 @@
+// -*- 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;
+
+ this._entry.bind_property('show-peek-icon',
+ this._passwordItem, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+ }
+
+ 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.connect('notify::mapped', () => {
+ if (this.is_mapped()) {
+ this._keymap.connectObject(
+ 'state-changed', () => this._sync(true), this);
+ } else {
+ this._keymap.disconnectObject(this);
+ }
+
+ this._sync(false);
+ });
+ }
+
+ _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..b04156d
--- /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 ||= 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._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._dialog.connectObject('response',
+ (object, choice) => {
+ this.mountOp.set_choice(choice);
+ this.mountOp.reply(Gio.MountOperationResult.HANDLED);
+
+ this.close();
+ }, this);
+
+ 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._dialog.connectObject('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);
+ 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._processesDialog.connectObject('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);
+ 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() {
+ this._dialog?.disconnectObject(this);
+ 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._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 = `${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..849599d
--- /dev/null
+++ b/js/ui/slider.js
@@ -0,0 +1,218 @@
+/* -*- 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();
+ const rtl = this.get_text_direction() === Clutter.TextDirection.RTL;
+
+ 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 handleY = height / 2;
+
+ let handleX = ceiledHandleRadius +
+ (width - 2 * ceiledHandleRadius) * this._value / this._maxValue;
+ if (rtl)
+ handleX = width - handleX;
+
+ 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();
+
+ this._grab = global.stage.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._grab) {
+ this._grab.dismiss();
+ this._grab = null;
+ }
+
+ 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 sequence = event.get_event_sequence();
+
+ if (!this._dragging &&
+ event.type() == Clutter.EventType.TOUCH_BEGIN) {
+ this.startDragging(event);
+ return Clutter.EVENT_STOP;
+ } else if (this._grabbedSequence &&
+ sequence.get_slot() === this._grabbedSequence.get_slot()) {
+ 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();
+ const rtl = this.get_text_direction() === Clutter.TextDirection.RTL;
+ let width = this._barLevelWidth;
+
+ relX = absX - sliderX;
+ if (rtl)
+ relX = width - relX;
+
+ 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..a4bad14
--- /dev/null
+++ b/js/ui/status/accessibility.js
@@ -0,0 +1,153 @@
+// -*- 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_TEXT_SCALING_FACTOR = 'text-scaling-factor';
+
+const A11Y_INTERFACE_SCHEMA = 'org.gnome.desktop.a11y.interface';
+const KEY_HIGH_CONTRAST = 'high-contrast';
+
+var ATIndicator = GObject.registerClass(
+class ATIndicator extends PanelMenu.Button {
+ _init() {
+ super._init(0.5, _("Accessibility"));
+
+ this.add_child(new St.Icon({
+ style_class: 'system-status-icon',
+ icon_name: 'org.gnome.Settings-accessibility-symbolic',
+ }));
+
+ this._a11ySettings = new Gio.Settings({ schema_id: A11Y_SCHEMA });
+ this._a11ySettings.connect(`changed::${KEY_ALWAYS_SHOW}`, this._queueSyncMenuVisibility.bind(this));
+
+ let highContrast = this._buildItem(_('High Contrast'), A11Y_INTERFACE_SCHEMA, KEY_HIGH_CONTRAST);
+ 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::${key}`, () => {
+ widget.setToggleState(settings.get_boolean(key));
+
+ this._queueSyncMenuVisibility();
+ });
+
+ return widget;
+ }
+
+ _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::${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/autoRotate.js b/js/ui/status/autoRotate.js
new file mode 100644
index 0000000..bde3b80
--- /dev/null
+++ b/js/ui/status/autoRotate.js
@@ -0,0 +1,45 @@
+/* exported Indicator */
+const {Gio, GObject} = imports.gi;
+
+const SystemActions = imports.misc.systemActions;
+
+const {QuickToggle, SystemIndicator} = imports.ui.quickSettings;
+
+const RotationToggle = GObject.registerClass(
+class RotationToggle extends QuickToggle {
+ _init() {
+ this._systemActions = new SystemActions.getDefault();
+
+ super._init({
+ label: _('Auto Rotate'),
+ });
+
+ this._systemActions.bind_property('can-lock-orientation',
+ this, 'visible',
+ GObject.BindingFlags.DEFAULT |
+ GObject.BindingFlags.SYNC_CREATE);
+ this._systemActions.bind_property('orientation-lock-icon',
+ this, 'icon-name',
+ GObject.BindingFlags.DEFAULT |
+ GObject.BindingFlags.SYNC_CREATE);
+
+ this._settings = new Gio.Settings({
+ schema_id: 'org.gnome.settings-daemon.peripherals.touchscreen',
+ });
+ this._settings.bind('orientation-lock',
+ this, 'checked',
+ Gio.SettingsBindFlags.INVERT_BOOLEAN);
+
+ this.connect('clicked',
+ () => this._systemActions.activateLockOrientation());
+ }
+});
+
+var Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this.quickSettingsItems.push(new RotationToggle());
+ }
+});
diff --git a/js/ui/status/bluetooth.js b/js/ui/status/bluetooth.js
new file mode 100644
index 0000000..bbff62d
--- /dev/null
+++ b/js/ui/status/bluetooth.js
@@ -0,0 +1,211 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+const {Gio, GLib, GnomeBluetooth, GObject} = imports.gi;
+
+const {QuickToggle, SystemIndicator} = imports.ui.quickSettings;
+
+const {loadInterfaceXML} = imports.misc.fileUtils;
+
+const {AdapterState} = GnomeBluetooth;
+
+const BUS_NAME = 'org.gnome.SettingsDaemon.Rfkill';
+const OBJECT_PATH = '/org/gnome/SettingsDaemon/Rfkill';
+
+const RfkillManagerInterface = loadInterfaceXML('org.gnome.SettingsDaemon.Rfkill');
+const rfkillManagerInfo = Gio.DBusInterfaceInfo.new_for_xml(RfkillManagerInterface);
+
+const BtClient = GObject.registerClass({
+ Properties: {
+ 'available': GObject.ParamSpec.boolean('available', '', '',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'active': GObject.ParamSpec.boolean('active', '', '',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'adapter-state': GObject.ParamSpec.enum('adapter-state', '', '',
+ GObject.ParamFlags.READABLE,
+ AdapterState, AdapterState.ABSENT),
+ },
+ Signals: {
+ 'devices-changed': {},
+ },
+}, class BtClient extends GObject.Object {
+ _init() {
+ super._init();
+
+ this._client = new GnomeBluetooth.Client();
+ this._client.connect('notify::default-adapter-powered', () => {
+ this.notify('active');
+ this.notify('available');
+ });
+ this._client.connect('notify::default-adapter-state',
+ () => this.notify('adapter-state'));
+ this._client.connect('notify::default-adapter', () => {
+ const newAdapter = this._client.default_adapter ?? null;
+
+ this._adapter = newAdapter;
+ this._deviceNotifyConnected.clear();
+ this.emit('devices-changed');
+
+ this.notify('active');
+ this.notify('available');
+ });
+
+ this._proxy = new Gio.DBusProxy({
+ g_connection: Gio.DBus.session,
+ g_name: BUS_NAME,
+ g_object_path: OBJECT_PATH,
+ g_interface_name: rfkillManagerInfo.name,
+ g_interface_info: rfkillManagerInfo,
+ });
+ this._proxy.connect('g-properties-changed', (p, properties) => {
+ const changedProperties = properties.unpack();
+ if ('BluetoothHardwareAirplaneMode' in changedProperties)
+ this.notify('available');
+ else if ('BluetoothHasAirplaneMode' in changedProperties)
+ this.notify('available');
+ });
+ this._proxy.init_async(GLib.PRIORITY_DEFAULT, null)
+ .catch(e => console.error(e.message));
+
+ this._adapter = null;
+
+ this._deviceNotifyConnected = new Set();
+
+ const deviceStore = this._client.get_devices();
+ for (let i = 0; i < deviceStore.get_n_items(); i++)
+ this._connectDeviceNotify(deviceStore.get_item(i));
+
+ this._client.connect('device-removed', (c, path) => {
+ this._deviceNotifyConnected.delete(path);
+ this.emit('devices-changed');
+ });
+ this._client.connect('device-added', (c, device) => {
+ this._connectDeviceNotify(device);
+ this.emit('devices-changed');
+ });
+ }
+
+ get available() {
+ // If we have an rfkill switch, make sure it's not a hardware
+ // one as we can't get out of it in software
+ return this._proxy.BluetoothHasAirplaneMode
+ ? !this._proxy.BluetoothHardwareAirplaneMode
+ : this.active;
+ }
+
+ get active() {
+ return this._client.default_adapter_powered;
+ }
+
+ get adapter_state() {
+ return this._client.default_adapter_state;
+ }
+
+ toggleActive() {
+ this._proxy.BluetoothAirplaneMode = this.active;
+ if (!this._client.default_adapter_powered)
+ this._client.default_adapter_powered = true;
+ }
+
+ *getDevices() {
+ const deviceStore = this._client.get_devices();
+
+ for (let i = 0; i < deviceStore.get_n_items(); i++) {
+ const device = deviceStore.get_item(i);
+
+ if (device.paired || device.trusted)
+ yield device;
+ }
+ }
+
+ _queueDevicesChanged() {
+ if (this._devicesChangedId)
+ return;
+ this._devicesChangedId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
+ delete this._devicesChangedId;
+ this.emit('devices-changed');
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+
+ _connectDeviceNotify(device) {
+ const path = device.get_object_path();
+
+ if (this._deviceNotifyConnected.has(path))
+ return;
+
+ device.connect('notify::alias', () => this._queueDevicesChanged());
+ device.connect('notify::paired', () => this._queueDevicesChanged());
+ device.connect('notify::trusted', () => this._queueDevicesChanged());
+ device.connect('notify::connected', () => this._queueDevicesChanged());
+
+ this._deviceNotifyConnected.add(path);
+ }
+});
+
+const BluetoothToggle = GObject.registerClass(
+class BluetoothToggle extends QuickToggle {
+ _init(client) {
+ super._init({label: _('Bluetooth')});
+
+ this._client = client;
+
+ this._client.bind_property('available',
+ this, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+ this._client.bind_property('active',
+ this, 'checked',
+ GObject.BindingFlags.SYNC_CREATE);
+ this._client.bind_property_full('adapter-state',
+ this, 'icon-name',
+ GObject.BindingFlags.SYNC_CREATE,
+ (bind, source) => [true, this._getIconNameFromState(source)],
+ null);
+
+ this.connect('clicked', () => this._client.toggleActive());
+ }
+
+ _getIconNameFromState(state) {
+ switch (state) {
+ case AdapterState.ON:
+ return 'bluetooth-active-symbolic';
+ case AdapterState.OFF:
+ case AdapterState.ABSENT:
+ return 'bluetooth-disabled-symbolic';
+ case AdapterState.TURNING_ON:
+ case AdapterState.TURNING_OFF:
+ return 'bluetooth-acquiring-symbolic';
+ default:
+ console.warn(`Unexpected state ${
+ GObject.enum_to_string(AdapterState, state)}`);
+ return '';
+ }
+ }
+});
+
+var Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this._client = new BtClient();
+ this._client.connect('devices-changed', () => this._sync());
+
+ this._indicator = this._addIndicator();
+ this._indicator.icon_name = 'bluetooth-active-symbolic';
+
+ this.quickSettingsItems.push(new BluetoothToggle(this._client));
+
+ this._sync();
+ }
+
+ _sync() {
+ const devices = [...this._client.getDevices()];
+ const connectedDevices = devices.filter(dev => dev.connected);
+ const nConnectedDevices = connectedDevices.length;
+
+ this._indicator.visible = nConnectedDevices > 0;
+ }
+});
diff --git a/js/ui/status/brightness.js b/js/ui/status/brightness.js
new file mode 100644
index 0000000..4c0da67
--- /dev/null
+++ b/js/ui/status/brightness.js
@@ -0,0 +1,64 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+const {Gio, GObject} = imports.gi;
+
+const {QuickSlider, SystemIndicator} = imports.ui.quickSettings;
+
+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);
+
+const BrightnessItem = GObject.registerClass(
+class BrightnessItem extends QuickSlider {
+ _init() {
+ super._init({
+ iconName: 'display-brightness-symbolic',
+ });
+
+ this._proxy = new BrightnessProxy(Gio.DBus.session, BUS_NAME, OBJECT_PATH,
+ (proxy, error) => {
+ if (error)
+ console.error(error.message);
+ else
+ this._proxy.connect('g-properties-changed', () => this._sync());
+ this._sync();
+ });
+
+ this._sliderChangedId = this.slider.connect('notify::value',
+ this._sliderChanged.bind(this));
+ this.slider.accessible_name = _('Brightness');
+ }
+
+ _sliderChanged() {
+ const 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() {
+ const brightness = this._proxy.Brightness;
+ const visible = Number.isInteger(brightness) && brightness >= 0;
+ this.visible = visible;
+ if (visible)
+ this._changeSlider(this._proxy.Brightness / 100.0);
+ }
+});
+
+var Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this.quickSettingsItems.push(new BrightnessItem());
+ }
+});
diff --git a/js/ui/status/darkMode.js b/js/ui/status/darkMode.js
new file mode 100644
index 0000000..d1ec2bd
--- /dev/null
+++ b/js/ui/status/darkMode.js
@@ -0,0 +1,49 @@
+/* exported Indicator */
+const {Gio, GObject} = imports.gi;
+
+const Main = imports.ui.main;
+const {QuickToggle, SystemIndicator} = imports.ui.quickSettings;
+
+const DarkModeToggle = GObject.registerClass(
+class DarkModeToggle extends QuickToggle {
+ _init() {
+ super._init({
+ label: _('Dark Mode'),
+ iconName: 'dark-mode-symbolic',
+ });
+
+ this._settings = new Gio.Settings({
+ schema_id: 'org.gnome.desktop.interface',
+ });
+ this._changedId = this._settings.connect('changed::color-scheme',
+ () => this._sync());
+
+ this.connectObject(
+ 'destroy', () => this._settings.run_dispose(),
+ 'clicked', () => this._toggleMode(),
+ this);
+ this._sync();
+ }
+
+ _toggleMode() {
+ Main.layoutManager.screenTransition.run();
+ this._settings.set_string('color-scheme',
+ this.checked ? 'default' : 'prefer-dark');
+ }
+
+ _sync() {
+ const colorScheme = this._settings.get_string('color-scheme');
+ const checked = colorScheme === 'prefer-dark';
+ if (this.checked !== checked)
+ this.set({checked});
+ }
+});
+
+var Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this.quickSettingsItems.push(new DarkModeToggle());
+ }
+});
diff --git a/js/ui/status/dwellClick.js b/js/ui/status/dwellClick.js
new file mode 100644
index 0000000..82726e5
--- /dev/null
+++ b/js/ui/status/dwellClick.js
@@ -0,0 +1,83 @@
+/* exported DwellClickIndicator */
+const { Clutter, Gio, GLib, GObject, St } = imports.gi;
+
+const PanelMenu = imports.ui.panelMenu;
+
+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._icon = new St.Icon({
+ style_class: 'system-status-icon',
+ icon_name: 'pointer-primary-click-symbolic',
+ });
+ this.add_child(this._icon);
+
+ this._a11ySettings = new Gio.Settings({ schema_id: MOUSE_A11Y_SCHEMA });
+ this._a11ySettings.connect(`changed::${KEY_DWELL_CLICK_ENABLED}`, this._syncMenuVisibility.bind(this));
+ this._a11ySettings.connect(`changed::${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..b47375d
--- /dev/null
+++ b/js/ui/status/keyboard.js
@@ -0,0 +1,1095 @@
+// -*- 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.misc.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 extends Signals.EventEmitter {
+ constructor(type, id, displayName, shortName, index) {
+ super();
+
+ 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 `${engineDesc.layout}+${engineDesc.variant}`;
+ else
+ return engineDesc.layout;
+ }
+};
+
+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 extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ if (this.constructor === InputSourceSettings)
+ throw new TypeError(`Cannot instantiate abstract class ${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;
+ }
+};
+
+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.deepUnpack();
+ } catch (e) {
+ log(`Could not get properties from ${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 += `+${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::${this._KEY_INPUT_SOURCES}`, this._emitInputSourcesChanged.bind(this));
+ this._settings.connect(`changed::${this._KEY_KEYBOARD_OPTIONS}`, this._emitKeyboardOptionsChanged.bind(this));
+ this._settings.connect(`changed::${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).deepUnpack();
+ 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 extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ // 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._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;
+ }
+
+ this._switcherPopup = new InputSourcePopup(
+ this._mruSources, this._keybindingAction, this._keybindingActionBackward);
+ this._switcherPopup.connect('destroy', () => {
+ this._switcherPopup = null;
+ });
+ if (!this._switcherPopup.show(
+ binding.is_reversed(), binding.get_name(), binding.get_mask()))
+ this._switcherPopup.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 of Object.keys(this._inputSources).sort((a, b) => a - b))
+ 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 = [];
+ if (this._mruSources.length > 1) {
+ 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 = `${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) {
+ // Avoid purpose changes while the switcher popup is shown, likely due to
+ // the focus change caused by the switcher popup causing this purpose change.
+ if (this._switcherPopup)
+ return;
+ 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));
+ Main.overview.connectObject(
+ 'showing', this._setPerWindowInputSource.bind(this),
+ 'hidden', this._setPerWindowInputSource.bind(this), this);
+ } else if (!this._sourcesPerWindow && this._focusWindowNotifyId != 0) {
+ global.display.disconnect(this._focusWindowNotifyId);
+ this._focusWindowNotifyId = 0;
+ Main.overview.disconnectObject(this);
+
+ 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;
+ }
+
+ get keyboardManager() {
+ return this._keyboardManager;
+ }
+};
+
+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({ style_class: 'system-status-icon' });
+ this.add_child(this._container);
+
+ 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._inputSourceManager.connectObject(
+ 'sources-changed', this._sourcesChanged.bind(this),
+ 'current-source-changed', this._currentSourceChanged.bind(this), this);
+ this._inputSourceManager.reload();
+ }
+
+ _onDestroy() {
+ 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));
+
+ const 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 ${prop.get_key()} has invalid type ${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();
+ }
+
+ // The `default` layout from ibus engine means to
+ // use the current keyboard layout.
+ if (xkbLayout === 'default') {
+ const current = this._inputSourceManager.keyboardManager.currentLayout;
+ xkbLayout = current.layout;
+ xkbVariant = current.variant;
+ }
+ }
+
+ if (!xkbLayout || xkbLayout.length == 0)
+ return;
+
+ let description = xkbLayout;
+ if (xkbVariant.length > 0)
+ description = `${description}\t${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..45f6b7a
--- /dev/null
+++ b/js/ui/status/location.js
@@ -0,0 +1,371 @@
+// -*- 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 ModalDialog = imports.ui.modalDialog;
+const PermissionStore = imports.misc.permissionStore;
+const {SystemIndicator} = imports.ui.quickSettings;
+
+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 = 'location';
+const APP_PERMISSIONS_ID = 'location';
+
+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');
+
+let _geoclueAgent = null;
+function _getGeoclueAgent() {
+ if (_geoclueAgent === null)
+ _geoclueAgent = new GeoclueAgent();
+ return _geoclueAgent;
+}
+
+var GeoclueAgent = GObject.registerClass({
+ Properties: {
+ 'enabled': GObject.ParamSpec.boolean(
+ 'enabled', 'Enabled', 'Enabled',
+ GObject.ParamFlags.READWRITE,
+ false),
+ 'in-use': GObject.ParamSpec.boolean(
+ 'in-use', 'In use', 'In use',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'max-accuracy-level': GObject.ParamSpec.int(
+ 'max-accuracy-level', 'Max accuracy level', 'Max accuracy level',
+ GObject.ParamFlags.READABLE,
+ 0, 8, 0),
+ },
+}, class GeoclueAgent extends GObject.Object {
+ _init() {
+ super._init();
+
+ this._settings = new Gio.Settings({ schema_id: LOCATION_SCHEMA });
+ this._settings.connectObject(
+ `changed::${ENABLED}`, () => this.notify('enabled'),
+ `changed::${MAX_ACCURACY_LEVEL}`, () => this._onMaxAccuracyLevelChanged(),
+ this);
+
+ this._agent = Gio.DBusExportedObject.wrapJSObject(AgentIface, this);
+ this._agent.export(Gio.DBus.system, '/org/freedesktop/GeoClue2/Agent');
+
+ this.connect('notify::enabled', this._onMaxAccuracyLevelChanged.bind(this));
+
+ this._watchId = Gio.bus_watch_name(Gio.BusType.SYSTEM,
+ 'org.freedesktop.GeoClue2',
+ 0,
+ this._connectToGeoclue.bind(this),
+ this._onGeoclueVanished.bind(this));
+ this._onMaxAccuracyLevelChanged();
+ this._connectToGeoclue();
+ this._connectToPermissionStore();
+ }
+
+ get enabled() {
+ return this._settings.get_boolean(ENABLED);
+ }
+
+ set enabled(value) {
+ this._settings.set_boolean(ENABLED, value);
+ }
+
+ get inUse() {
+ return this._managerProxy?.InUse ?? false;
+ }
+
+ get maxAccuracyLevel() {
+ if (this.enabled) {
+ let level = this._settings.get_string(MAX_ACCURACY_LEVEL);
+
+ return GeoclueAccuracyLevel[level.toUpperCase()] ||
+ GeoclueAccuracyLevel.NONE;
+ } else {
+ return GeoclueAccuracyLevel.NONE;
+ }
+ }
+
+ async AuthorizeAppAsync(params, invocation) {
+ let [desktopId, reqAccuracyLevel] = params;
+
+ let authorizer = new AppAuthorizer(desktopId,
+ reqAccuracyLevel, this._permStoreProxy, this.maxAccuracyLevel);
+
+ const accuracyLevel = await authorizer.authorize();
+ const ret = accuracyLevel !== GeoclueAccuracyLevel.NONE;
+ invocation.return_value(GLib.Variant.new('(bu)', [ret, accuracyLevel]));
+ }
+
+ get MaxAccuracyLevel() {
+ return this.maxAccuracyLevel;
+ }
+
+ _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;
+ }
+
+ async _onManagerProxyReady(proxy, error) {
+ if (error != null) {
+ log(error.message);
+ this._connecting = false;
+ return;
+ }
+
+ this._managerProxy = proxy;
+ this._managerProxy.connectObject('g-properties-changed',
+ this._onGeocluePropsChanged.bind(this), this);
+
+ this.notify('in-use');
+
+ try {
+ await this._managerProxy.AddAgentAsync('gnome-shell');
+ this._connecting = false;
+ this._notifyMaxAccuracyLevel();
+ } catch (e) {
+ log(e.message);
+ }
+ }
+
+ _onGeoclueVanished() {
+ this._managerProxy?.disconnectObject(this);
+ this._managerProxy = null;
+
+ this.notify('in-use');
+ }
+
+ _onMaxAccuracyLevelChanged() {
+ this.notify('max-accuracy-level');
+
+ // 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();
+ }
+
+ _notifyMaxAccuracyLevel() {
+ let variant = new GLib.Variant('u', this.maxAccuracyLevel);
+ this._agent.emit_property_changed('MaxAccuracyLevel', variant);
+ }
+
+ _onGeocluePropsChanged(proxy, properties) {
+ const inUseChanged = !!properties.lookup_value('InUse', null);
+ if (inUseChanged)
+ this.notify('in-use');
+ }
+
+ _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 Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this._agent = _getGeoclueAgent();
+
+ this._indicator = this._addIndicator();
+ this._indicator.icon_name = 'location-services-active-symbolic';
+ this._agent.bind_property('in-use',
+ this._indicator,
+ 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+ }
+});
+
+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;
+ }
+
+ async authorize() {
+ let appSystem = Shell.AppSystem.get_default();
+ this._app = appSystem.lookup_app(`${this.desktopId}.desktop`);
+ if (this._app == null || this._permStoreProxy == null)
+ return this._completeAuth();
+
+ try {
+ [this._permissions] = await this._permStoreProxy.LookupAsync(
+ APP_PERMISSIONS_TABLE,
+ APP_PERMISSIONS_ID);
+ } catch (error) {
+ if (error.domain === Gio.DBusError) {
+ // Likely no xdg-app installed, just authorize the app
+ this._accuracyLevel = this.reqAccuracyLevel;
+ this._permStoreProxy = null;
+ return 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 = {};
+ }
+ }
+
+ let permission = this._permissions[this.desktopId];
+
+ if (permission == null) {
+ await this._userAuthorizeApp();
+ } else {
+ let [levelStr] = permission || ['NONE'];
+ this._accuracyLevel = GeoclueAccuracyLevel[levelStr] ||
+ GeoclueAccuracyLevel.NONE;
+ }
+
+ return 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._dialog =
+ new GeolocationDialog(name, reason, this.reqAccuracyLevel);
+
+ return new Promise(resolve => {
+ const responseId = this._dialog.connect('response',
+ (dialog, level) => {
+ this._dialog.disconnect(responseId);
+ this._accuracyLevel = level;
+ resolve();
+ });
+ this._dialog.open();
+ });
+ }
+
+ _completeAuth() {
+ if (this._accuracyLevel != GeoclueAccuracyLevel.NONE) {
+ this._accuracyLevel = Math.clamp(this._accuracyLevel,
+ 0, this._maxAccuracyLevel);
+ }
+ this._saveToPermissionStore();
+
+ return this._accuracyLevel;
+ }
+
+ async _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', {});
+
+ try {
+ await this._permStoreProxy.SetAsync(
+ APP_PERMISSIONS_TABLE,
+ true,
+ APP_PERMISSIONS_ID,
+ this._permissions,
+ data);
+ } catch (error) {
+ 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);
+
+ const 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..d9755a3
--- /dev/null
+++ b/js/ui/status/network.js
@@ -0,0 +1,2095 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+const {Atk, Clutter, Gio, GLib, GObject, NM, Polkit, St} = imports.gi;
+
+const Main = imports.ui.main;
+const PopupMenu = imports.ui.popupMenu;
+const MessageTray = imports.ui.messageTray;
+const ModemManager = imports.misc.modemManager;
+const Util = imports.misc.util;
+
+const {Spinner} = imports.ui.animation;
+const {QuickMenuToggle, SystemIndicator} = imports.ui.quickSettings;
+
+const {loadInterfaceXML} = imports.misc.fileUtils;
+const {registerDestroyableType} = imports.misc.signalTracker;
+
+Gio._promisify(Gio.DBusConnection.prototype, 'call');
+Gio._promisify(NM.Client, 'new_async');
+Gio._promisify(NM.Client.prototype, 'check_connectivity_async');
+Gio._promisify(NM.DeviceWifi.prototype, 'request_scan_async');
+
+const WIFI_SCAN_FREQUENCY = 15;
+const MAX_VISIBLE_NETWORKS = 8;
+
+// small optimization, to avoid using [] all the time
+const NM80211Mode = NM['80211Mode'];
+
+var PortalHelperResult = {
+ CANCELLED: 0,
+ COMPLETED: 1,
+ RECHECK: 2,
+};
+
+const PortalHelperIface = loadInterfaceXML('org.gnome.Shell.PortalHelper');
+const PortalHelperInfo = Gio.DBusInterfaceInfo.new_for_xml(PortalHelperIface);
+
+function signalToIcon(value) {
+ if (value < 20)
+ return 'none';
+ else if (value < 40)
+ return 'weak';
+ else if (value < 50)
+ return 'ok';
+ else if (value < 80)
+ return 'good';
+ else
+ return 'excellent';
+}
+
+function ssidToLabel(ssid) {
+ let label = NM.utils_ssid_to_utf8(ssid.get_data());
+ if (!label)
+ label = _("<unknown>");
+ return label;
+}
+
+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${global.get_current_time()}`),
+ };
+ try {
+ Gio.DBus.session.call(
+ 'org.gnome.Settings',
+ '/org/gnome/Settings',
+ '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: ${e.message}`);
+ }
+}
+
+class ItemSorter {
+ [Symbol.iterator] = this.items;
+
+ /**
+ * Maintains a list of sorted items. By default, items are
+ * assumed to be objects with a name property.
+ *
+ * Optionally items can have a secondary sort order by
+ * recency. If used, items must by objects with a timestamp
+ * property that can be used in substraction, and "bigger"
+ * must mean "more recent". Number and Date both qualify.
+ *
+ * @param {object=} options - property object with options
+ * @param {Function} options.sortFunc - a custom sort function
+ * @param {bool} options.trackMru - whether to track MRU order as well
+ **/
+ constructor(options = {}) {
+ const {sortFunc, trackMru} = {
+ sortFunc: this._sortByName.bind(this),
+ trackMru: false,
+ ...options,
+ };
+
+ this._trackMru = trackMru;
+ this._sortFunc = sortFunc;
+ this._sortFuncMru = this._sortByMru.bind(this);
+
+ this._itemsOrder = [];
+ this._itemsMruOrder = [];
+ }
+
+ *items() {
+ yield* this._itemsOrder;
+ }
+
+ *itemsByMru() {
+ console.assert(this._trackMru, 'itemsByMru: MRU tracking is disabled');
+ yield* this._itemsMruOrder;
+ }
+
+ _sortByName(one, two) {
+ return GLib.utf8_collate(one.name, two.name);
+ }
+
+ _sortByMru(one, two) {
+ return two.timestamp - one.timestamp;
+ }
+
+ _upsert(array, item, sortFunc) {
+ this._delete(array, item);
+ return Util.insertSorted(array, item, sortFunc);
+ }
+
+ _delete(array, item) {
+ const pos = array.indexOf(item);
+ if (pos >= 0)
+ array.splice(pos, 1);
+ }
+
+ /**
+ * Insert or update item.
+ *
+ * @param {any} item - the item to upsert
+ * @returns {number} - the sorted position of item
+ */
+ upsert(item) {
+ if (this._trackMru)
+ this._upsert(this._itemsMruOrder, item, this._sortFuncMru);
+
+ return this._upsert(this._itemsOrder, item, this._sortFunc);
+ }
+
+ /**
+ * @param {any} item - item to remove
+ */
+ delete(item) {
+ if (this._trackMru)
+ this._delete(this._itemsMruOrder, item);
+ this._delete(this._itemsOrder, item);
+ }
+}
+
+const NMMenuItem = GObject.registerClass({
+ Properties: {
+ 'radio-mode': GObject.ParamSpec.boolean('radio-mode', '', '',
+ GObject.ParamFlags.READWRITE,
+ false),
+ 'is-active': GObject.ParamSpec.boolean('is-active', '', '',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'name': GObject.ParamSpec.string('name', '', '',
+ GObject.ParamFlags.READWRITE,
+ ''),
+ 'icon-name': GObject.ParamSpec.string('icon-name', '', '',
+ GObject.ParamFlags.READWRITE,
+ ''),
+ },
+}, class NMMenuItem extends PopupMenu.PopupBaseMenuItem {
+ get state() {
+ return this._activeConnection?.state ??
+ NM.ActiveConnectionState.DEACTIVATED;
+ }
+
+ get is_active() {
+ return this.state <= NM.ActiveConnectionState.ACTIVATED;
+ }
+
+ get timestamp() {
+ return 0;
+ }
+
+ activate() {
+ super.activate(Clutter.get_current_event());
+ }
+
+ _activeConnectionStateChanged() {
+ this.notify('is-active');
+ this.notify('icon-name');
+
+ this._sync();
+ }
+
+ _setActiveConnection(activeConnection) {
+ this._activeConnection?.disconnectObject(this);
+
+ this._activeConnection = activeConnection;
+
+ this._activeConnection?.connectObject(
+ 'notify::state', () => this._activeConnectionStateChanged(),
+ this);
+ this._activeConnectionStateChanged();
+ }
+
+ _sync() {
+ // Overridden by subclasses
+ }
+});
+
+/**
+ * Item that contains a section, and can be collapsed
+ * into a submenu
+ */
+const NMSectionItem = GObject.registerClass({
+ Properties: {
+ 'use-submenu': GObject.ParamSpec.boolean('use-submenu', '', '',
+ GObject.ParamFlags.READWRITE,
+ false),
+ },
+}, class NMSectionItem extends NMMenuItem {
+ constructor() {
+ super({
+ activate: false,
+ can_focus: false,
+ });
+
+ this._useSubmenu = false;
+
+ // Turn into an empty container with no padding
+ this.styleClass = '';
+ this.setOrnament(PopupMenu.Ornament.HIDDEN);
+
+ // Add intermediate section; we need this for submenu support
+ this._mainSection = new PopupMenu.PopupMenuSection();
+ this.add_child(this._mainSection.actor);
+
+ this._submenuItem = new PopupMenu.PopupSubMenuMenuItem('', true);
+ this._mainSection.addMenuItem(this._submenuItem);
+ this._submenuItem.hide();
+
+ this.section = new PopupMenu.PopupMenuSection();
+ this._mainSection.addMenuItem(this.section);
+
+ // Represents the item as a whole when shown
+ this.bind_property('name',
+ this._submenuItem.label, 'text',
+ GObject.BindingFlags.DEFAULT);
+ this.bind_property('icon-name',
+ this._submenuItem.icon, 'icon-name',
+ GObject.BindingFlags.DEFAULT);
+ }
+
+ _setParent(parent) {
+ super._setParent(parent);
+ this._mainSection._setParent(parent);
+
+ parent?.connect('menu-closed',
+ () => this._mainSection.emit('menu-closed'));
+ }
+
+ get use_submenu() {
+ return this._useSubmenu;
+ }
+
+ set use_submenu(useSubmenu) {
+ if (this._useSubmenu === useSubmenu)
+ return;
+
+ this._useSubmenu = useSubmenu;
+ this._submenuItem.visible = useSubmenu;
+
+ if (useSubmenu) {
+ this._mainSection.box.remove_child(this.section.actor);
+ this._submenuItem.menu.box.add_child(this.section.actor);
+ } else {
+ this._submenuItem.menu.box.remove_child(this.section.actor);
+ this._mainSection.box.add_child(this.section.actor);
+ }
+ }
+});
+
+const NMConnectionItem = GObject.registerClass(
+class NMConnectionItem extends NMMenuItem {
+ constructor(section, connection) {
+ super();
+
+ this._section = section;
+ this._connection = connection;
+ this._activeConnection = null;
+
+ this._icon = new St.Icon({
+ style_class: 'popup-menu-icon',
+ x_align: Clutter.ActorAlign.END,
+ visible: !this.radio_mode,
+ });
+ this.add_child(this._icon);
+
+ this._label = new St.Label({
+ y_expand: true,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ this.add_child(this._label);
+ this.label_actor = this._label;
+
+ this.bind_property('icon-name',
+ this._icon, 'icon-name',
+ GObject.BindingFlags.DEFAULT);
+ this.bind_property('radio-mode',
+ this._icon, 'visible',
+ GObject.BindingFlags.INVERT_BOOLEAN);
+
+ this.connectObject(
+ 'notify::radio-mode', () => this._sync(),
+ 'notify::name', () => this._sync(),
+ this);
+ this._sync();
+ }
+
+ get name() {
+ return this._connection.get_id();
+ }
+
+ get timestamp() {
+ return this._connection.get_setting_connection()?.get_timestamp() ?? 0;
+ }
+
+ 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.notify('name');
+ this._sync();
+ }
+
+ _updateOrnament() {
+ this.setOrnament(this.radio_mode && this.is_active
+ ? PopupMenu.Ornament.DOT : PopupMenu.Ornament.NONE);
+ }
+
+ _getRegularLabel() {
+ return this.is_active
+ // Translators: %s is a device name like "MyPhone"
+ ? _('Disconnect %s').format(this.name)
+ // Translators: %s is a device name like "MyPhone"
+ : _('Connect to %s').format(this.name);
+ }
+
+ _sync() {
+ if (this.radioMode) {
+ this._label.text = this.name;
+ this.accessible_role = Atk.Role.CHECK_MENU_ITEM;
+ } else {
+ this._label.text = this._getRegularLabel();
+ this.accessible_role = Atk.Role.MENU_ITEM;
+ }
+ this._updateOrnament();
+ }
+
+ activate() {
+ super.activate();
+
+ if (this.radio_mode && this._activeConnection != null)
+ return; // only activate in radio mode
+
+ if (this._activeConnection == null)
+ this._section.activateConnection(this._connection);
+ else
+ this._section.deactivateConnection(this._activeConnection);
+
+ this._sync();
+ }
+
+ setActiveConnection(connection) {
+ this._setActiveConnection(connection);
+ }
+});
+
+const NMDeviceConnectionItem = GObject.registerClass({
+ Properties: {
+ 'device-name': GObject.ParamSpec.string('device-name', '', '',
+ GObject.ParamFlags.READWRITE,
+ ''),
+ },
+}, class NMDeviceConnectionItem extends NMConnectionItem {
+ constructor(section, connection) {
+ super(section, connection);
+
+ this.connectObject(
+ 'notify::radio-mode', () => this.notify('name'),
+ 'notify::device-name', () => this.notify('name'),
+ this);
+ }
+
+ get name() {
+ return this.radioMode
+ ? this._connection.get_id()
+ : this.deviceName;
+ }
+});
+
+const NMDeviceItem = GObject.registerClass({
+ Properties: {
+ 'single-device-mode': GObject.ParamSpec.boolean('single-device-mode', '', '',
+ GObject.ParamFlags.READWRITE,
+ false),
+ },
+}, class NMDeviceItem extends NMSectionItem {
+ constructor(client, device) {
+ super();
+
+ if (this.constructor === NMDeviceItem)
+ throw new TypeError(`Cannot instantiate abstract type ${this.constructor.name}`);
+
+ this._client = client;
+ this._device = device;
+ this._deviceName = '';
+
+ this._connectionItems = new Map();
+ this._itemSorter = new ItemSorter({trackMru: true});
+
+ // Item shown in the 0-connections case
+ this._autoConnectItem =
+ this.section.addAction(_('Connect'), () => this._autoConnect(), '');
+
+ // Represents the device as a whole when shown
+ this.bind_property('name',
+ this._autoConnectItem.label, 'text',
+ GObject.BindingFlags.SYNC_CREATE);
+ this.bind_property('icon-name',
+ this._autoConnectItem._icon, 'icon-name',
+ GObject.BindingFlags.SYNC_CREATE);
+
+ this._deactivateItem =
+ this.section.addAction(_('Turn Off'), () => this.deactivateConnection());
+
+ this._client.connectObject(
+ 'notify::connectivity', () => this.notify('icon-name'),
+ 'notify::primary-connection', () => this.notify('icon-name'),
+ this);
+
+ this._device.connectObject(
+ 'notify::available-connections', () => this._syncConnections(),
+ 'notify::active-connection', () => this._activeConnectionChanged(),
+ this);
+
+ this.connect('notify::single-device-mode', () => this._sync());
+
+ this._syncConnections();
+ this._activeConnectionChanged();
+ }
+
+ get timestamp() {
+ const [item] = this._itemSorter.itemsByMru();
+ return item?.timestamp ?? 0;
+ }
+
+ _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);
+ }
+
+ _activeConnectionChanged() {
+ const oldItem = this._connectionItems.get(
+ this._activeConnection?.connection);
+ oldItem?.setActiveConnection(null);
+
+ this._setActiveConnection(this._device.active_connection);
+
+ const newItem = this._connectionItems.get(
+ this._activeConnection?.connection);
+ newItem?.setActiveConnection(this._activeConnection);
+ }
+
+ _syncConnections() {
+ const available = this._device.get_available_connections();
+ const removed = [...this._connectionItems.keys()]
+ .filter(conn => !available.includes(conn));
+
+ for (const conn of removed)
+ this._removeConnection(conn);
+
+ for (const conn of available)
+ this._addConnection(conn);
+ }
+
+ _getActivatableItem() {
+ const [lastUsed] = this._itemSorter.itemsByMru();
+ if (lastUsed?.timestamp > 0)
+ return lastUsed;
+
+ const [firstItem] = this._itemSorter;
+ if (firstItem)
+ return firstItem;
+
+ console.assert(this._autoConnectItem.visible,
+ `${this}'s autoConnect item should be visible when otherwise empty`);
+ return this._autoConnectItem;
+ }
+
+ activate() {
+ super.activate();
+
+ if (this._activeConnection)
+ this.deactivateConnection();
+ else
+ this._getActivatableItem()?.activate();
+ }
+
+ activateConnection(connection) {
+ this._client.activate_connection_async(connection, this._device, null, null, null);
+ }
+
+ deactivateConnection(_activeConnection) {
+ this._device.disconnect(null);
+ }
+
+ _onConnectionChanged(connection) {
+ const item = this._connectionItems.get(connection);
+ item.updateForConnection(connection);
+ }
+
+ _resortItem(item) {
+ const pos = this._itemSorter.upsert(item);
+ this.section.moveMenuItem(item, pos);
+ }
+
+ _addConnection(connection) {
+ if (this._connectionItems.has(connection))
+ return;
+
+ connection.connectObject(
+ 'changed', this._onConnectionChanged.bind(this),
+ this);
+
+ const item = new NMDeviceConnectionItem(this, connection);
+
+ this.bind_property('radio-mode',
+ item, 'radio-mode',
+ GObject.BindingFlags.SYNC_CREATE);
+ this.bind_property('name',
+ item, 'device-name',
+ GObject.BindingFlags.SYNC_CREATE);
+ this.bind_property('icon-name',
+ item, 'icon-name',
+ GObject.BindingFlags.SYNC_CREATE);
+ item.connectObject(
+ 'notify::name', () => this._resortItem(item),
+ this);
+
+ const pos = this._itemSorter.upsert(item);
+ this.section.addMenuItem(item, pos);
+ this._connectionItems.set(connection, item);
+ this._sync();
+ }
+
+ _removeConnection(connection) {
+ const item = this._connectionItems.get(connection);
+ if (!item)
+ return;
+
+ this._itemSorter.delete(item);
+ this._connectionItems.delete(connection);
+ item.destroy();
+
+ this._sync();
+ }
+
+ setDeviceName(name) {
+ this._deviceName = name;
+ this.notify('name');
+ }
+
+ _sync() {
+ const nItems = this._connectionItems.size;
+ this.radio_mode = nItems > 1;
+ this.useSubmenu = this.radioMode && !this.singleDeviceMode;
+ this._autoConnectItem.visible = nItems === 0;
+ this._deactivateItem.visible = this.radioMode && this.isActive;
+ }
+});
+
+const NMWiredDeviceItem = GObject.registerClass(
+class NMWiredDeviceItem extends NMDeviceItem {
+ get icon_name() {
+ switch (this.state) {
+ case NM.ActiveConnectionState.ACTIVATING:
+ return 'network-wired-acquiring-symbolic';
+ case NM.ActiveConnectionState.ACTIVATED:
+ return this._canReachInternet()
+ ? 'network-wired-symbolic'
+ : 'network-wired-no-route-symbolic';
+ default:
+ return 'network-wired-disconnected-symbolic';
+ }
+ }
+
+ get name() {
+ return this._deviceName;
+ }
+
+ _hasCarrier() {
+ if (this._device instanceof NM.DeviceEthernet)
+ return this._device.carrier;
+ else
+ return true;
+ }
+
+ _sync() {
+ this.visible = this._hasCarrier();
+ super._sync();
+ }
+});
+
+const NMModemDeviceItem = GObject.registerClass(
+class NMModemDeviceItem extends NMDeviceItem {
+ constructor(client, device) {
+ super(client, device);
+
+ 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);
+
+ this._mobileDevice?.connectObject(
+ 'notify::operator-name', this._sync.bind(this),
+ 'notify::signal-quality', () => this.notify('icon-name'), this);
+
+ Main.sessionMode.connectObject('updated',
+ this._sessionUpdated.bind(this), this);
+ this._sessionUpdated();
+ }
+
+ get icon_name() {
+ switch (this.state) {
+ case NM.ActiveConnectionState.ACTIVATING:
+ return 'network-cellular-acquiring-symbolic';
+ case NM.ActiveConnectionState.ACTIVATED: {
+ const qualityString = signalToIcon(this._mobileDevice.signal_quality);
+ return `network-cellular-signal-${qualityString}-symbolic`;
+ }
+ default:
+ return this._activeConnection
+ ? 'network-cellular-signal-none-symbolic'
+ : 'network-cellular-disabled-symbolic';
+ }
+ }
+
+ get name() {
+ return this._mobileDevice?.operator_name || this._deviceName;
+ }
+
+ get wwanPanelSupported() {
+ // Currently, wwan panel doesn't support CDMA_EVDO modems
+ const supportedCaps =
+ NM.DeviceModemCapabilities.GSM_UMTS |
+ NM.DeviceModemCapabilities.LTE;
+ return this._device.current_capabilities & supportedCaps;
+ }
+
+ _autoConnect() {
+ if (this.wwanPanelSupported)
+ launchSettingsPanel('wwan', 'show-device', this._device.udi);
+ else
+ launchSettingsPanel('network', 'connect-3g', this._device.get_path());
+ }
+
+ _sessionUpdated() {
+ this._autoConnectItem.sensitive = Main.sessionMode.hasWindows;
+ }
+});
+
+const NMBluetoothDeviceItem = GObject.registerClass(
+class NMBluetoothDeviceItem extends NMDeviceItem {
+ constructor(client, device) {
+ super(client, device);
+
+ this._device.bind_property('name',
+ this, 'name',
+ GObject.BindingFlags.SYNC_CREATE);
+ }
+
+ get icon_name() {
+ switch (this.state) {
+ case NM.ActiveConnectionState.ACTIVATING:
+ return 'network-cellular-acquiring-symbolic';
+ case NM.ActiveConnectionState.ACTIVATED:
+ return 'network-cellular-connected-symbolic';
+ default:
+ return this._activeConnection
+ ? 'network-cellular-signal-none-symbolic'
+ : 'network-cellular-disabled-symbolic';
+ }
+ }
+
+ get name() {
+ return this._device.name;
+ }
+});
+
+const WirelessNetwork = GObject.registerClass({
+ Properties: {
+ 'name': GObject.ParamSpec.string(
+ 'name', '', '',
+ GObject.ParamFlags.READABLE,
+ ''),
+ 'icon-name': GObject.ParamSpec.string(
+ 'icon-name', '', '',
+ GObject.ParamFlags.READABLE,
+ ''),
+ 'secure': GObject.ParamSpec.boolean(
+ 'secure', '', '',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'is-active': GObject.ParamSpec.boolean(
+ 'is-active', '', '',
+ GObject.ParamFlags.READABLE,
+ false),
+ },
+ Signals: {
+ 'destroy': {},
+ },
+}, class WirelessNetwork extends GObject.Object {
+ static _securityTypes =
+ Object.values(NM.UtilsSecurityType).sort((a, b) => b - a);
+
+ _init(device) {
+ super._init();
+
+ this._device = device;
+
+ this._device.connectObject(
+ 'notify::active-access-point', () => this.notify('is-active'),
+ this);
+
+ this._accessPoints = new Set();
+ this._connections = [];
+ this._name = '';
+ this._ssid = null;
+ this._bestAp = null;
+ this._mode = 0;
+ this._securityType = NM.UtilsSecurityType.NONE;
+ }
+
+ get _strength() {
+ return this._bestAp?.strength ?? 0;
+ }
+
+ get name() {
+ return this._name;
+ }
+
+ get icon_name() {
+ if (this._mode === NM80211Mode.ADHOC)
+ return 'network-workgroup-symbolic';
+
+ if (!this._bestAp)
+ return '';
+
+ return `network-wireless-signal-${signalToIcon(this._bestAp.strength)}-symbolic`;
+ }
+
+ get secure() {
+ return this._securityType !== NM.UtilsSecurityType.NONE;
+ }
+
+ get is_active() {
+ return this._accessPoints.has(this._device.activeAccessPoint);
+ }
+
+ hasAccessPoint(ap) {
+ return this._accessPoints.has(ap);
+ }
+
+ hasAccessPoints() {
+ return this._accessPoints.size > 0;
+ }
+
+ checkAccessPoint(ap) {
+ if (!ap.get_ssid())
+ return false;
+
+ const secType = this._getApSecurityType(ap);
+ if (secType === NM.UtilsSecurityType.INVALID)
+ return false;
+
+ if (this._accessPoints.size === 0)
+ return true;
+
+ return this._ssid.equal(ap.ssid) &&
+ this._mode === ap.mode &&
+ this._securityType === secType;
+ }
+
+ /**
+ * @param {NM.AccessPoint} ap - an access point
+ * @returns {bool} - whether the access point was added
+ */
+ addAccessPoint(ap) {
+ if (!this.checkAccessPoint(ap))
+ return false;
+
+ if (this._accessPoints.size === 0) {
+ this._ssid = ap.get_ssid();
+ this._mode = ap.mode;
+ this._securityType = this._getApSecurityType(ap);
+ this._name = NM.utils_ssid_to_utf8(this._ssid.get_data()) || '<unknown>';
+
+ this.notify('name');
+ this.notify('secure');
+ }
+
+ const wasActive = this.is_active;
+ this._accessPoints.add(ap);
+
+ ap.connectObject(
+ 'notify::strength', () => {
+ this.notify('icon-name');
+ this._updateBestAp();
+ }, this);
+ this._updateBestAp();
+
+ if (wasActive !== this.is_active)
+ this.notify('is-active');
+
+ return true;
+ }
+
+ /**
+ * @param {NM.AccessPoint} ap - an access point
+ * @returns {bool} - whether the access point was removed
+ */
+ removeAccessPoint(ap) {
+ const wasActive = this.is_active;
+ if (!this._accessPoints.delete(ap))
+ return false;
+
+ ap.disconnectObject(this);
+ this._updateBestAp();
+
+ if (wasActive !== this.is_active)
+ this.notify('is-active');
+
+ return true;
+ }
+
+ /**
+ * @param {WirelessNetwork} other - network to compare with
+ * @returns {number} - the sort order
+ */
+ compare(other) {
+ // place known connections first
+ const cmpConnections = other.hasConnections() - this.hasConnections();
+ if (cmpConnections !== 0)
+ return cmpConnections;
+
+ const cmpAps = other.hasAccessPoints() - this.hasAccessPoints();
+ if (cmpAps !== 0)
+ return cmpAps;
+
+ // place stronger connections first
+ const cmpStrength = other._strength - this._strength;
+ if (cmpStrength !== 0)
+ return cmpStrength;
+
+ // place secure connections first
+ const cmpSec = other.secure - this.secure;
+ if (cmpSec !== 0)
+ return cmpSec;
+
+ // sort alphabetically
+ return GLib.utf8_collate(this._name, other._name);
+ }
+
+ hasConnections() {
+ return this._connections.length > 0;
+ }
+
+ checkConnections(connections) {
+ const aps = [...this._accessPoints];
+ this._connections = connections.filter(
+ c => aps.some(ap => ap.connection_valid(c)));
+ }
+
+ canAutoconnect() {
+ const canAutoconnect =
+ this._securityTypes !== NM.UtilsSecurityType.WPA_ENTERPRISE &&
+ this._securityTypes !== NM.UtilsSecurityType.WPA2_ENTERPRISE;
+ return canAutoconnect;
+ }
+
+ activate() {
+ const [ap] = this._accessPoints;
+ let [conn] = this._connections;
+ if (conn) {
+ this._device.client.activate_connection_async(conn, this._device, null, null, null);
+ } else if (!this.canAutoconnect()) {
+ launchSettingsPanel('wifi', 'connect-8021x-wifi',
+ this._getDeviceDBusPath(), ap.get_path());
+ } else {
+ conn = new NM.SimpleConnection();
+ this._device.client.add_and_activate_connection_async(
+ conn, this._device, ap.get_path(), null, null);
+ }
+ }
+
+ destroy() {
+ this.emit('destroy');
+ }
+
+ _getDeviceDBusPath() {
+ // nm_object_get_path() is shadowed by nm_device_get_path()
+ return NM.Object.prototype.get_path.call(this._device);
+ }
+
+ _getApSecurityType(ap) {
+ const {wirelessCapabilities: caps} = this._device;
+ const {flags, wpaFlags, rsnFlags} = ap;
+ const haveAp = true;
+ const adHoc = ap.mode === NM80211Mode.ADHOC;
+ const bestType = WirelessNetwork._securityTypes
+ .find(t => NM.utils_security_valid(t, caps, haveAp, adHoc, flags, wpaFlags, rsnFlags));
+ return bestType ?? NM.UtilsSecurityType.INVALID;
+ }
+
+ _updateBestAp() {
+ const [bestAp] =
+ [...this._accessPoints].sort((a, b) => b.strength - a.strength);
+
+ if (this._bestAp === bestAp)
+ return;
+
+ this._bestAp = bestAp;
+ this.notify('icon-name');
+ }
+});
+registerDestroyableType(WirelessNetwork);
+
+const NMWirelessNetworkItem = GObject.registerClass(
+class NMWirelessNetworkItem extends PopupMenu.PopupBaseMenuItem {
+ _init(network) {
+ super._init({style_class: 'nm-network-item'});
+
+ this._network = network;
+
+ const icons = new St.BoxLayout();
+ this.add_child(icons);
+
+ this._signalIcon = new St.Icon({style_class: 'popup-menu-icon'});
+ icons.add_child(this._signalIcon);
+
+ this._secureIcon = new St.Icon({
+ style_class: 'wireless-secure-icon',
+ y_align: Clutter.ActorAlign.END,
+ });
+ icons.add_actor(this._secureIcon);
+
+ this._label = new St.Label();
+ this.label_actor = this._label;
+ this.add_child(this._label);
+
+ this._selectedIcon = new St.Icon({
+ style_class: 'popup-menu-icon',
+ icon_name: 'object-select-symbolic',
+ });
+ this.add(this._selectedIcon);
+
+ this._network.bind_property('icon-name',
+ this._signalIcon, 'icon-name',
+ GObject.BindingFlags.SYNC_CREATE);
+ this._network.bind_property('name',
+ this._label, 'text',
+ GObject.BindingFlags.SYNC_CREATE);
+ this._network.bind_property('is-active',
+ this._selectedIcon, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+ this._network.bind_property_full('secure',
+ this._secureIcon, 'icon-name',
+ GObject.BindingFlags.SYNC_CREATE,
+ (bind, source) => [true, source ? 'network-wireless-encrypted-symbolic' : ''],
+ null);
+ }
+
+ get network() {
+ return this._network;
+ }
+});
+
+const NMWirelessDeviceItem = GObject.registerClass({
+ Properties: {
+ 'is-hotspot': GObject.ParamSpec.boolean('is-hotspot', '', '',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'single-device-mode': GObject.ParamSpec.boolean('single-device-mode', '', '',
+ GObject.ParamFlags.READWRITE,
+ false),
+ },
+}, class NMWirelessDeviceItem extends NMSectionItem {
+ constructor(client, device) {
+ super();
+
+ this._client = client;
+ this._device = device;
+
+ this._deviceName = '';
+
+ this._networkItems = new Map();
+ this._itemSorter = new ItemSorter({
+ sortFunc: (one, two) => one.network.compare(two.network),
+ });
+
+ this._client.connectObject(
+ 'notify::wireless-enabled', () => this.notify('icon-name'),
+ 'notify::connectivity', () => this.notify('icon-name'),
+ 'notify::primary-connection', () => this.notify('icon-name'),
+ this);
+
+ this._device.connectObject(
+ 'notify::active-access-point', this._activeApChanged.bind(this),
+ 'notify::active-connection', () => this._activeConnectionChanged(),
+ 'notify::available-connections', () => this._availableConnectionsChanged(),
+ 'state-changed', () => this.notify('is-hotspot'),
+ 'access-point-added', (d, ap) => {
+ this._addAccessPoint(ap);
+ this._updateItemsVisibility();
+ },
+ 'access-point-removed', (d, ap) => {
+ this._removeAccessPoint(ap);
+ this._updateItemsVisibility();
+ }, this);
+
+ this.bind_property('single-device-mode',
+ this, 'use-submenu',
+ GObject.BindingFlags.INVERT_BOOLEAN);
+
+ Main.sessionMode.connectObject('updated',
+ () => this._updateItemsVisibility(),
+ this);
+
+ for (const ap of this._device.get_access_points())
+ this._addAccessPoint(ap);
+
+ this._activeApChanged();
+ this._activeConnectionChanged();
+ this._availableConnectionsChanged();
+ this._updateItemsVisibility();
+
+ this.connect('destroy', () => {
+ for (const net of this._networkItems.keys())
+ net.destroy();
+ });
+ }
+
+ get icon_name() {
+ if (!this._device.client.wireless_enabled)
+ return 'network-wireless-disabled-symbolic';
+
+ switch (this.state) {
+ case NM.ActiveConnectionState.ACTIVATING:
+ return 'network-wireless-acquiring-symbolic';
+
+ case NM.ActiveConnectionState.ACTIVATED: {
+ if (this.is_hotspot)
+ return 'network-wireless-hotspot-symbolic';
+
+ if (!this._canReachInternet())
+ return 'network-wireless-no-route-symbolic';
+
+ if (!this._activeAccessPoint) {
+ if (this._device.mode !== NM80211Mode.ADHOC)
+ console.info('An active wireless connection, in infrastructure mode, involves no access point?');
+
+ return 'network-wireless-connected-symbolic';
+ }
+
+ const {strength} = this._activeAccessPoint;
+ return `network-wireless-signal-${signalToIcon(strength)}-symbolic`;
+ }
+ default:
+ return 'network-wireless-signal-none-symbolic';
+ }
+ }
+
+ get name() {
+ if (this.is_hotspot)
+ /* Translators: %s is a network identifier */
+ return _('%s Hotspot').format(this._deviceName);
+
+ const {ssid} = this._activeAccessPoint ?? {};
+ if (ssid)
+ return ssidToLabel(ssid);
+
+ return this._deviceName;
+ }
+
+ get is_hotspot() {
+ if (!this._device.active_connection)
+ return false;
+
+ const {connection} = this._device.active_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;
+ }
+
+ activate() {
+ if (!this.is_hotspot)
+ return;
+
+ const {activeConnection} = this._device;
+ this._client.deactivate_connection_async(activeConnection, null, null);
+ }
+
+ _activeApChanged() {
+ this._activeAccessPoint?.disconnectObject(this);
+ this._activeAccessPoint = this._device.active_access_point;
+ this._activeAccessPoint?.connectObject(
+ 'notify::strength', () => this.notify('icon-name'),
+ 'notify::ssid', () => this.notify('name'),
+ this);
+
+ this.notify('icon-name');
+ this.notify('name');
+ }
+
+ _activeConnectionChanged() {
+ this._setActiveConnection(this._device.active_connection);
+ }
+
+ _availableConnectionsChanged() {
+ const connections = this._device.get_available_connections();
+ for (const net of this._networkItems.keys())
+ net.checkConnections(connections);
+ }
+
+ _addAccessPoint(ap) {
+ if (ap.get_ssid() == null) {
+ // This access point is not visible yet
+ // Wait for it to get a ssid
+ ap.connectObject('notify::ssid', () => {
+ if (!ap.ssid)
+ return;
+ ap.disconnectObject(this);
+ this._addAccessPoint(ap);
+ }, this);
+ return;
+ }
+
+ let network = [...this._networkItems.keys()]
+ .find(n => n.checkAccessPoint(ap));
+
+ if (!network) {
+ network = new WirelessNetwork(this._device);
+
+ const item = new NMWirelessNetworkItem(network);
+ item.connect('activate', () => network.activate());
+
+ network.connectObject(
+ 'notify::icon-name', () => this._resortItem(item),
+ 'notify::is-active', () => this._resortItem(item),
+ this);
+
+ const pos = this._itemSorter.upsert(item);
+ this.section.addMenuItem(item, pos);
+ this._networkItems.set(network, item);
+ }
+
+ network.addAccessPoint(ap);
+ }
+
+ _removeAccessPoint(ap) {
+ const network = [...this._networkItems.keys()]
+ .find(n => n.removeAccessPoint(ap));
+
+ if (!network || network.hasAccessPoints())
+ return;
+
+ const item = this._networkItems.get(network);
+ this._itemSorter.delete(item);
+ this._networkItems.delete(network);
+
+ item?.destroy();
+ network.destroy();
+ }
+
+ _resortItem(item) {
+ const pos = this._itemSorter.upsert(item);
+ this.section.moveMenuItem(item, pos);
+
+ this._updateItemsVisibility();
+ }
+
+ _updateItemsVisibility() {
+ const {hasWindows} = Main.sessionMode;
+
+ let nVisible = 0;
+ for (const item of this._itemSorter) {
+ const {network: net} = item;
+ item.visible =
+ (hasWindows || net.hasConnections() || net.canAutoconnect()) &&
+ nVisible < MAX_VISIBLE_NETWORKS;
+ if (item.visible)
+ nVisible++;
+ }
+ }
+
+ setDeviceName(name) {
+ this._deviceName = name;
+ this.notify('name');
+ }
+
+ _canReachInternet() {
+ if (this._client.primary_connection !== this._device.active_connection)
+ return true;
+
+ return this._client.connectivity === NM.ConnectivityState.FULL;
+ }
+});
+
+const NMVpnConnectionItem = GObject.registerClass({
+ Signals: {
+ 'activation-failed': {},
+ },
+}, class NMVpnConnectionItem extends NMConnectionItem {
+ constructor(section, connection) {
+ super(section, connection);
+
+ this._label.x_expand = true;
+ this.accessible_role = Atk.Role.CHECK_MENU_ITEM;
+ this._icon.hide();
+
+ this._switch = new PopupMenu.Switch(this.is_active);
+ this.add_child(this._switch);
+
+ this.bind_property('is-active',
+ this._switch, 'state',
+ GObject.BindingFlags.SYNC_CREATE);
+ this.bind_property('name',
+ this._label, 'text',
+ GObject.BindingFlags.SYNC_CREATE);
+ }
+
+ _sync() {
+ if (this.is_active)
+ this.add_accessible_state(Atk.StateType.CHECKED);
+ else
+ this.remove_accessible_state(Atk.StateType.CHECKED);
+ }
+
+ _activeConnectionStateChanged() {
+ const state = this._activeConnection?.get_state();
+ const reason = this._activeConnection?.get_state_reason();
+
+ if (state === NM.ActiveConnectionState.DEACTIVATED &&
+ reason !== NM.ActiveConnectionStateReason.NO_SECRETS &&
+ reason !== NM.ActiveConnectionStateReason.USER_DISCONNECTED)
+ this.emit('activation-failed');
+
+ super._activeConnectionStateChanged();
+ }
+
+ get icon_name() {
+ switch (this.state) {
+ case NM.ActiveConnectionState.ACTIVATING:
+ return 'network-vpn-acquiring-symbolic';
+ case NM.ActiveConnectionState.ACTIVATED:
+ return 'network-vpn-symbolic';
+ default:
+ return 'network-vpn-disabled-symbolic';
+ }
+ }
+
+ set icon_name(_ignored) {
+ }
+});
+
+const NMToggle = GObject.registerClass({
+ Signals: {
+ 'activation-failed': {},
+ },
+}, class NMToggle extends QuickMenuToggle {
+ constructor() {
+ super();
+
+ this._items = new Map();
+ this._itemSorter = new ItemSorter({trackMru: true});
+
+ this._itemsSection = new PopupMenu.PopupMenuSection();
+ this.menu.addMenuItem(this._itemsSection);
+
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ this._itemBinding = new GObject.BindingGroup();
+ this._itemBinding.bind('icon-name',
+ this, 'icon-name', GObject.BindingFlags.DEFAULT);
+ this._itemBinding.bind_full('name',
+ this, 'label', GObject.BindingFlags.DEFAULT,
+ (bind, source) => [true, this._transformLabel(source)],
+ null);
+
+ this.connect('clicked', () => this.activate());
+ }
+
+ setClient(client) {
+ if (this._client === client)
+ return;
+
+ this._client?.disconnectObject(this);
+ this._client = client;
+ this._client?.connectObject(
+ 'notify::networking-enabled', () => this._sync(),
+ this);
+
+ this._items.forEach(item => item.destroy());
+ this._items.clear();
+
+ if (this._client)
+ this._loadInitialItems();
+ this._sync();
+ }
+
+ activate() {
+ const activeItems = [...this._getActiveItems()];
+
+ if (activeItems.length > 0)
+ activeItems.forEach(i => i.activate());
+ else
+ this._itemBinding.source?.activate();
+ }
+
+ _loadInitialItems() {
+ throw new GObject.NotImplementedError();
+ }
+
+ // transform function for property binding:
+ // Ignore the provided label if there are multiple active
+ // items, and replace it with something like "VPN (2)"
+ _transformLabel(source) {
+ const nActive = this.checked
+ ? [...this._getActiveItems()].length
+ : 0;
+ if (nActive > 1)
+ return `${this._getDefaultName()} (${nActive})`;
+ return source;
+ }
+
+ _updateItemsVisibility() {
+ [...this._itemSorter.itemsByMru()].forEach(
+ (item, i) => (item.visible = i < MAX_VISIBLE_NETWORKS));
+ }
+
+ _itemActiveChanged() {
+ // force an update in case we changed
+ // from or to multiple active items
+ this._itemBinding.source?.notify('name');
+ this._sync();
+ }
+
+ _updateChecked() {
+ const [firstActive] = this._getActiveItems();
+ this.checked = !!firstActive;
+ }
+
+ _resortItem(item) {
+ const pos = this._itemSorter.upsert(item);
+ this._itemsSection.moveMenuItem(item, pos);
+ }
+
+ _addItem(key, item) {
+ console.assert(!this._items.has(key),
+ `${this} already has an item for ${key}`);
+
+ item.connectObject(
+ 'notify::is-active', () => this._itemActiveChanged(),
+ 'notify::name', () => this._resortItem(item),
+ 'destroy', () => this._removeItem(key),
+ this);
+
+ this._items.set(key, item);
+ const pos = this._itemSorter.upsert(item);
+ this._itemsSection.addMenuItem(item, pos);
+ this._sync();
+ }
+
+ _removeItem(key) {
+ const item = this._items.get(key);
+ if (!item)
+ return;
+
+ this._itemSorter.delete(item);
+ this._items.delete(key);
+
+ item.destroy();
+ this._sync();
+ }
+
+ *_getActiveItems() {
+ for (const item of this._itemSorter) {
+ if (item.is_active)
+ yield item;
+ }
+ }
+
+ _getPrimaryItem() {
+ // prefer active items
+ const [firstActive] = this._getActiveItems();
+ if (firstActive)
+ return firstActive;
+
+ // otherwise prefer the most-recently used
+ const [lastUsed] = this._itemSorter.itemsByMru();
+ if (lastUsed?.timestamp > 0)
+ return lastUsed;
+
+ // as a last resort, return the top-most visible item
+ for (const item of this._itemSorter) {
+ if (item.visible)
+ return item;
+ }
+
+ console.assert(!this.visible,
+ `${this} should not be visible when empty`);
+
+ return null;
+ }
+
+ _sync() {
+ this.visible =
+ this._client?.networking_enabled && this._items.size > 0;
+ this._updateItemsVisibility();
+ this._updateChecked();
+ this._itemBinding.source = this._getPrimaryItem();
+ }
+});
+
+const NMVpnToggle = GObject.registerClass(
+class NMVpnToggle extends NMToggle {
+ constructor() {
+ super();
+
+ this.menu.setHeader('network-vpn-symbolic', _('VPN'));
+ this.menu.addSettingsAction(_('VPN Settings'),
+ 'gnome-network-panel.desktop');
+ }
+
+ setClient(client) {
+ super.setClient(client);
+
+ this._client?.connectObject(
+ 'connection-added', (c, conn) => this._addConnection(conn),
+ 'connection-removed', (c, conn) => this._removeConnection(conn),
+ 'notify::active-connections', () => this._syncActiveConnections(),
+ this);
+ }
+
+ _getDefaultName() {
+ return _('VPN');
+ }
+
+ _loadInitialItems() {
+ const connections = this._client.get_connections();
+ for (const conn of connections)
+ this._addConnection(conn);
+
+ this._syncActiveConnections();
+ }
+
+ _syncActiveConnections() {
+ const activeConnections =
+ this._client.get_active_connections().filter(
+ c => this._shouldHandleConnection(c.connection));
+
+ for (const item of this._items.values())
+ item.setActiveConnection(null);
+
+ for (const a of activeConnections)
+ this._items.get(a.connection)?.setActiveConnection(a);
+ }
+
+ _shouldHandleConnection(connection) {
+ const setting = connection.get_setting_connection();
+ if (!setting)
+ return false;
+
+ // Ignore slave connection
+ if (setting.get_master())
+ return false;
+
+ const handledTypes = [
+ NM.SETTING_VPN_SETTING_NAME,
+ NM.SETTING_WIREGUARD_SETTING_NAME,
+ ];
+ return handledTypes.includes(setting.type);
+ }
+
+ _onConnectionChanged(connection) {
+ const item = this._items.get(connection);
+ item.updateForConnection(connection);
+ }
+
+ _addConnection(connection) {
+ if (this._items.has(connection))
+ return;
+
+ if (!this._shouldHandleConnection(connection))
+ return;
+
+ connection.connectObject(
+ 'changed', this._onConnectionChanged.bind(this),
+ this);
+
+ const item = new NMVpnConnectionItem(this, connection);
+ item.connectObject(
+ 'activation-failed', () => this.emit('activation-failed'),
+ this);
+ this._addItem(connection, item);
+ }
+
+ _removeConnection(connection) {
+ this._removeItem(connection);
+ }
+
+ activateConnection(connection) {
+ this._client.activate_connection_async(connection, null, null, null, null);
+ }
+
+ deactivateConnection(activeConnection) {
+ this._client.deactivate_connection(activeConnection, null);
+ }
+});
+
+const NMDeviceToggle = GObject.registerClass(
+class NMDeviceToggle extends NMToggle {
+ constructor(deviceType) {
+ super();
+
+ this._deviceType = deviceType;
+ this._nmDevices = new Set();
+ this._deviceNames = new Map();
+ }
+
+ setClient(client) {
+ this._nmDevices.clear();
+
+ super.setClient(client);
+
+ this._client?.connectObject(
+ 'device-added', (c, dev) => {
+ this._addDevice(dev);
+ this._syncDeviceNames();
+ },
+ 'device-removed', (c, dev) => {
+ this._removeDevice(dev);
+ this._syncDeviceNames();
+ }, this);
+ }
+
+ _getDefaultName() {
+ const [dev] = this._nmDevices;
+ const [name] = NM.Device.disambiguate_names([dev]);
+ return name;
+ }
+
+ _loadInitialItems() {
+ const devices = this._client.get_devices();
+ for (const dev of devices)
+ this._addDevice(dev);
+ this._syncDeviceNames();
+ }
+
+ _shouldShowDevice(device) {
+ switch (device.state) {
+ case NM.DeviceState.DISCONNECTED:
+ case NM.DeviceState.ACTIVATED:
+ case NM.DeviceState.DEACTIVATING:
+ case NM.DeviceState.PREPARE:
+ case NM.DeviceState.CONFIG:
+ case NM.DeviceState.IP_CONFIG:
+ case NM.DeviceState.IP_CHECK:
+ case NM.DeviceState.SECONDARIES:
+ case NM.DeviceState.NEED_AUTH:
+ case NM.DeviceState.FAILED:
+ return true;
+ case NM.DeviceState.UNMANAGED:
+ case NM.DeviceState.UNAVAILABLE:
+ default:
+ return false;
+ }
+ }
+
+ _syncDeviceNames() {
+ const devices = [...this._nmDevices];
+ const names = NM.Device.disambiguate_names(devices);
+ this._deviceNames.clear();
+ devices.forEach(
+ (dev, i) => {
+ this._deviceNames.set(dev, names[i]);
+ this._items.get(dev)?.setDeviceName(names[i]);
+ });
+ }
+
+ _syncDeviceItem(device) {
+ if (this._shouldShowDevice(device))
+ this._ensureDeviceItem(device);
+ else
+ this._removeDeviceItem(device);
+ }
+
+ _deviceStateChanged(device, newState, oldState, reason) {
+ if (newState === oldState) {
+ console.info(`${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');
+ }
+
+ _createDeviceMenuItem(_device) {
+ throw new GObject.NotImplementedError();
+ }
+
+ _ensureDeviceItem(device) {
+ if (this._items.has(device))
+ return;
+
+ const item = this._createDeviceMenuItem(device);
+ item.setDeviceName(this._deviceNames.get(device) ?? '');
+ this._addItem(device, item);
+ }
+
+ _removeDeviceItem(device) {
+ this._removeItem(device);
+ }
+
+ _addDevice(device) {
+ if (this._nmDevices.has(device))
+ return;
+
+ if (device.get_device_type() !== this._deviceType)
+ return;
+
+ device.connectObject(
+ 'state-changed', this._deviceStateChanged.bind(this),
+ 'notify::interface', () => this._syncDeviceNames(),
+ 'notify::state', () => this._syncDeviceItem(device),
+ this);
+
+ this._nmDevices.add(device);
+ this._syncDeviceItem(device);
+ }
+
+ _removeDevice(device) {
+ if (!this._nmDevices.delete(device))
+ return;
+
+ device.disconnectObject(this);
+ this._removeDeviceItem(device);
+ }
+
+ _sync() {
+ super._sync();
+
+ const nItems = this._items.size;
+ this._items.forEach(item => (item.singleDeviceMode = nItems === 1));
+ }
+});
+
+const NMWirelessToggle = GObject.registerClass(
+class NMWirelessToggle extends NMDeviceToggle {
+ constructor() {
+ super(NM.DeviceType.WIFI);
+
+ this._itemBinding.bind('is-hotspot',
+ this, 'menu-enabled',
+ GObject.BindingFlags.INVERT_BOOLEAN);
+
+ this._scanningSpinner = new Spinner(16);
+
+ this.menu.connectObject('open-state-changed', (m, isOpen) => {
+ if (isOpen)
+ this._startScanning();
+ else
+ this._stopScanning();
+ });
+
+ this.menu.setHeader('network-wireless-symbolic', _('Wi–Fi'));
+ this.menu.addHeaderSuffix(this._scanningSpinner);
+ this.menu.addSettingsAction(_('All Networks'),
+ 'gnome-wifi-panel.desktop');
+ }
+
+ setClient(client) {
+ super.setClient(client);
+
+ this._client?.bind_property('wireless-enabled',
+ this, 'checked',
+ GObject.BindingFlags.SYNC_CREATE);
+ this._client?.bind_property('wireless-hardware-enabled',
+ this, 'reactive',
+ GObject.BindingFlags.SYNC_CREATE);
+ }
+
+ activate() {
+ const primaryItem = this._itemBinding.source;
+ if (primaryItem?.is_hotspot)
+ primaryItem.activate();
+ else
+ this._client.wireless_enabled = !this._client.wireless_enabled;
+ }
+
+ async _scanDevice(device) {
+ const {lastScan} = device;
+ await device.request_scan_async(null);
+
+ // Wait for the lastScan property to update, which
+ // indicates the end of the scan
+ return new Promise(resolve => {
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1500, () => {
+ if (device.lastScan === lastScan)
+ return GLib.SOURCE_CONTINUE;
+
+ resolve();
+ return GLib.SOURCE_REMOVE;
+ });
+ });
+ }
+
+ async _scanDevices() {
+ if (!this._client.wireless_enabled)
+ return;
+
+ this._scanningSpinner.play();
+
+ const devices = [...this._items.keys()];
+ await Promise.all(
+ devices.map(d => this._scanDevice(d)));
+
+ this._scanningSpinner.stop();
+ }
+
+ _startScanning() {
+ this._scanTimeoutId = GLib.timeout_add_seconds(
+ GLib.PRIORITY_DEFAULT, WIFI_SCAN_FREQUENCY, () => {
+ this._scanDevices().catch(logError);
+ return GLib.SOURCE_CONTINUE;
+ });
+ this._scanDevices().catch(logError);
+ }
+
+ _stopScanning() {
+ if (this._scanTimeoutId)
+ GLib.source_remove(this._scanTimeoutId);
+ delete this._scanTimeoutId;
+ }
+
+ _createDeviceMenuItem(device) {
+ return new NMWirelessDeviceItem(this._client, device);
+ }
+
+ _updateChecked() {
+ // handled via a property binding
+ }
+
+ _getPrimaryItem() {
+ const hotspot = [...this._items.values()].find(i => i.is_hotspot);
+ if (hotspot)
+ return hotspot;
+
+ return super._getPrimaryItem();
+ }
+
+ _shouldShowDevice(device) {
+ // don't disappear if wireless-enabled is false
+ if (device.state === NM.DeviceState.UNAVAILABLE)
+ return true;
+ return super._shouldShowDevice(device);
+ }
+});
+
+const NMWiredToggle = GObject.registerClass(
+class NMWiredToggle extends NMDeviceToggle {
+ constructor() {
+ super(NM.DeviceType.ETHERNET);
+
+ this.menu.setHeader('network-wired-symbolic', _('Wired Connections'));
+ this.menu.addSettingsAction(_('Wired Settings'),
+ 'gnome-network-panel.desktop');
+ }
+
+ _createDeviceMenuItem(device) {
+ return new NMWiredDeviceItem(this._client, device);
+ }
+});
+
+const NMBluetoothToggle = GObject.registerClass(
+class NMBluetoothToggle extends NMDeviceToggle {
+ constructor() {
+ super(NM.DeviceType.BT);
+
+ this.menu.setHeader('network-cellular-symbolic', _('Bluetooth Tethers'));
+ this.menu.addSettingsAction(_('Bluetooth Settings'),
+ 'gnome-network-panel.desktop');
+ }
+
+ _createDeviceMenuItem(device) {
+ return new NMBluetoothDeviceItem(this._client, device);
+ }
+});
+
+const NMModemToggle = GObject.registerClass(
+class NMModemToggle extends NMDeviceToggle {
+ constructor() {
+ super(NM.DeviceType.MODEM);
+
+ this.menu.setHeader('network-cellular-symbolic', _('Mobile Connections'));
+
+ const settingsLabel = _('Mobile Broadband Settings');
+ this._wwanSettings = this.menu.addSettingsAction(settingsLabel,
+ 'gnome-wwan-panel.desktop');
+ this._legacySettings = this.menu.addSettingsAction(settingsLabel,
+ 'gnome-network-panel.desktop');
+ }
+
+ _createDeviceMenuItem(device) {
+ return new NMModemDeviceItem(this._client, device);
+ }
+
+ _sync() {
+ super._sync();
+
+ const useWwanPanel =
+ [...this._items.values()].some(i => i.wwanPanelSupported);
+ this._wwanSettings.visible = useWwanPanel;
+ this._legacySettings.visible = !useWwanPanel;
+ }
+});
+
+var Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this._connectivityQueue = new Set();
+
+ this._mainConnection = null;
+
+ this._notification = null;
+
+ this._wiredToggle = new NMWiredToggle();
+ this._wirelessToggle = new NMWirelessToggle();
+ this._modemToggle = new NMModemToggle();
+ this._btToggle = new NMBluetoothToggle();
+ this._vpnToggle = new NMVpnToggle();
+
+ this._deviceToggles = new Map([
+ [NM.DeviceType.ETHERNET, this._wiredToggle],
+ [NM.DeviceType.WIFI, this._wirelessToggle],
+ [NM.DeviceType.MODEM, this._modemToggle],
+ [NM.DeviceType.BT, this._btToggle],
+ ]);
+ this.quickSettingsItems.push(...this._deviceToggles.values());
+ this.quickSettingsItems.push(this._vpnToggle);
+
+ this.quickSettingsItems.forEach(toggle => {
+ toggle.connectObject(
+ 'activation-failed', () => this._onActivationFailed(),
+ this);
+ });
+
+ this._primaryIndicator = this._addIndicator();
+ this._vpnIndicator = this._addIndicator();
+
+ this._primaryIndicatorBinding = new GObject.BindingGroup();
+ this._primaryIndicatorBinding.bind('icon-name',
+ this._primaryIndicator, 'icon-name',
+ GObject.BindingFlags.DEFAULT);
+
+ this._vpnToggle.bind_property('checked',
+ this._vpnIndicator, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+ this._vpnToggle.bind_property('icon-name',
+ this._vpnIndicator, 'icon-name',
+ GObject.BindingFlags.SYNC_CREATE);
+
+ this._getClient().catch(logError);
+ }
+
+ async _getClient() {
+ this._client = await NM.Client.new_async(null);
+
+ this.quickSettingsItems.forEach(
+ toggle => toggle.setClient(this._client));
+
+ this._client.bind_property('nm-running',
+ this, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+
+ this._client.connectObject(
+ 'notify::primary-connection', () => this._syncMainConnection(),
+ 'notify::activating-connection', () => this._syncMainConnection(),
+ 'notify::connectivity', () => this._syncConnectivity(),
+ this);
+ this._syncMainConnection();
+
+ try {
+ this._configPermission = await Polkit.Permission.new(
+ 'org.freedesktop.NetworkManager.network-control', null, null);
+
+ this.quickSettingsItems.forEach(toggle => {
+ this._configPermission.bind_property('allowed',
+ toggle, 'reactive',
+ GObject.BindingFlags.SYNC_CREATE);
+ });
+ } catch (e) {
+ log(`No permission to control network connections: ${e}`);
+ this._configPermission = null;
+ }
+ }
+
+ _onActivationFailed() {
+ this._notification?.destroy();
+
+ const source = new MessageTray.Source(
+ _('Network Manager'), 'network-error-symbolic');
+ source.policy =
+ new MessageTray.NotificationApplicationPolicy('gnome-network-panel');
+
+ this._notification = new MessageTray.Notification(source,
+ _('Connection failed'),
+ _('Activation of network connection failed'));
+ this._notification.setUrgency(MessageTray.Urgency.HIGH);
+ this._notification.setTransient(true);
+ this._notification.connect('destroy',
+ () => (this._notification = null));
+
+ Main.messageTray.add(source);
+ source.showNotification(this._notification);
+ }
+
+ _syncMainConnection() {
+ this._mainConnection?.disconnectObject(this);
+
+ this._mainConnection =
+ this._client.get_primary_connection() ||
+ this._client.get_activating_connection();
+
+ if (this._mainConnection) {
+ this._mainConnection.connectObject('notify::state',
+ this._mainConnectionStateChanged.bind(this), this);
+ this._mainConnectionStateChanged();
+ }
+
+ this._updateIcon();
+ this._syncConnectivity();
+ }
+
+ _mainConnectionStateChanged() {
+ if (this._mainConnection.state === NM.ActiveConnectionState.ACTIVATED)
+ this._notification?.destroy();
+ }
+
+ _flushConnectivityQueue() {
+ for (let item of this._connectivityQueue)
+ this._portalHelperProxy?.CloseAsync(item);
+ this._connectivityQueue.clear();
+ }
+
+ _closeConnectivityCheck(path) {
+ if (this._connectivityQueue.delete(path))
+ this._portalHelperProxy?.CloseAsync(path);
+ }
+
+ async _portalHelperDone(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 they choose 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: ${result}`);
+ }
+ }
+
+ async _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 ||= this._client.connectivity < NM.ConnectivityState.FULL;
+ if (!isPortal || Main.sessionMode.isGreeter)
+ return;
+
+ let path = this._mainConnection.get_path();
+ if (this._connectivityQueue.has(path))
+ return;
+
+ let timestamp = global.get_current_time();
+ if (!this._portalHelperProxy) {
+ this._portalHelperProxy = new Gio.DBusProxy({
+ g_connection: Gio.DBus.session,
+ g_name: 'org.gnome.Shell.PortalHelper',
+ g_object_path: '/org/gnome/Shell/PortalHelper',
+ g_interface_name: PortalHelperInfo.name,
+ g_interface_info: PortalHelperInfo,
+ });
+ this._portalHelperProxy.connectSignal('Done',
+ (proxy, emitter, params) => {
+ this._portalHelperDone(params).catch(logError);
+ });
+
+ try {
+ await this._portalHelperProxy.init_async(
+ GLib.PRIORITY_DEFAULT, null);
+ } catch (e) {
+ console.error(`Error launching the portal helper: ${e.message}`);
+ }
+ }
+
+ this._portalHelperProxy?.AuthenticateAsync(path, this._client.connectivity_check_uri, timestamp).catch(logError);
+
+ this._connectivityQueue.add(path);
+ }
+
+ _updateIcon() {
+ const [dev] = this._mainConnection?.get_devices() ?? [];
+ const primaryToggle = this._deviceToggles.get(dev?.device_type) ?? null;
+ this._primaryIndicatorBinding.source = primaryToggle;
+
+ if (!primaryToggle) {
+ if (this._client.connectivity === NM.ConnectivityState.FULL)
+ this._primaryIndicator.icon_name = 'network-wired-symbolic';
+ else
+ this._primaryIndicator.icon_name = 'network-wired-no-route-symbolic';
+ }
+
+ const state = this._client.get_state();
+ const connected = state === NM.State.CONNECTED_GLOBAL;
+ this._primaryIndicator.visible = (primaryToggle != null) || connected;
+ }
+});
diff --git a/js/ui/status/nightLight.js b/js/ui/status/nightLight.js
new file mode 100644
index 0000000..0d148e3
--- /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, GLib, GObject} = imports.gi;
+
+const {QuickToggle, SystemIndicator} = imports.ui.quickSettings;
+
+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 colorInfo = Gio.DBusInterfaceInfo.new_for_xml(ColorInterface);
+
+const NightLightToggle = GObject.registerClass(
+class NightLightToggle extends QuickToggle {
+ _init() {
+ super._init({
+ label: _('Night Light'),
+ iconName: 'night-light-symbolic',
+ toggleMode: true,
+ });
+
+ const monitorManager = global.backend.get_monitor_manager();
+ monitorManager.bind_property('night-light-supported',
+ this, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+
+ this._settings = new Gio.Settings({
+ schema_id: 'org.gnome.settings-daemon.plugins.color',
+ });
+ this._settings.bind('night-light-enabled',
+ this, 'checked',
+ Gio.SettingsBindFlags.DEFAULT);
+ }
+});
+
+var Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this._indicator = this._addIndicator();
+ this._indicator.icon_name = 'night-light-symbolic';
+
+ this.quickSettingsItems.push(new NightLightToggle());
+
+ this._proxy = new Gio.DBusProxy({
+ g_connection: Gio.DBus.session,
+ g_name: BUS_NAME,
+ g_object_path: OBJECT_PATH,
+ g_interface_name: colorInfo.name,
+ g_interface_info: colorInfo,
+ });
+ this._proxy.connect('g-properties-changed', (p, properties) => {
+ const nightLightActiveChanged = !!properties.lookup_value('NightLightActive', null);
+ if (nightLightActiveChanged)
+ this._sync();
+ });
+ this._proxy.init_async(GLib.PRIORITY_DEFAULT, null)
+ .catch(e => console.error(e.message));
+
+ this._sync();
+ }
+
+ _sync() {
+ this._indicator.visible = this._proxy.NightLightActive;
+ }
+});
diff --git a/js/ui/status/powerProfiles.js b/js/ui/status/powerProfiles.js
new file mode 100644
index 0000000..e15208d
--- /dev/null
+++ b/js/ui/status/powerProfiles.js
@@ -0,0 +1,126 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+const {Gio, GObject} = imports.gi;
+
+const {QuickMenuToggle, SystemIndicator} = imports.ui.quickSettings;
+
+const PopupMenu = imports.ui.popupMenu;
+
+const {loadInterfaceXML} = imports.misc.fileUtils;
+
+const BUS_NAME = 'net.hadess.PowerProfiles';
+const OBJECT_PATH = '/net/hadess/PowerProfiles';
+
+const PowerProfilesIface = loadInterfaceXML('net.hadess.PowerProfiles');
+const PowerProfilesProxy = Gio.DBusProxy.makeProxyWrapper(PowerProfilesIface);
+
+const PROFILE_PARAMS = {
+ 'performance': {
+ label: C_('Power profile', 'Performance'),
+ iconName: 'power-profile-performance-symbolic',
+ },
+
+ 'balanced': {
+ label: C_('Power profile', 'Balanced'),
+ iconName: 'power-profile-balanced-symbolic',
+ },
+
+ 'power-saver': {
+ label: C_('Power profile', 'Power Saver'),
+ iconName: 'power-profile-power-saver-symbolic',
+ },
+};
+
+const LAST_PROFILE_KEY = 'last-selected-power-profile';
+
+const PowerProfilesToggle = GObject.registerClass(
+class PowerProfilesToggle extends QuickMenuToggle {
+ _init() {
+ super._init();
+
+ this._profileItems = new Map();
+
+ this.connect('clicked', () => {
+ this._proxy.ActiveProfile = this.checked
+ ? 'balanced'
+ : global.settings.get_string(LAST_PROFILE_KEY);
+ });
+
+ this._proxy = new PowerProfilesProxy(Gio.DBus.system, BUS_NAME, OBJECT_PATH,
+ (proxy, error) => {
+ if (error) {
+ log(error.message);
+ } else {
+ this._proxy.connect('g-properties-changed', (p, properties) => {
+ const profilesChanged = !!properties.lookup_value('Profiles', null);
+ if (profilesChanged)
+ this._syncProfiles();
+ this._sync();
+ });
+
+ if (this._proxy.g_name_owner)
+ this._syncProfiles();
+ }
+ this._sync();
+ });
+
+ this._profileSection = new PopupMenu.PopupMenuSection();
+ this.menu.addMenuItem(this._profileSection);
+ this.menu.setHeader('power-profile-balanced-symbolic', _('Power Profiles'));
+
+ this._sync();
+ }
+
+ _syncProfiles() {
+ this._profileSection.removeAll();
+ this._profileItems.clear();
+
+ const profiles = this._proxy.Profiles
+ .map(p => p.Profile.unpack())
+ .reverse();
+ for (const profile of profiles) {
+ const {label, iconName} = PROFILE_PARAMS[profile];
+ if (!label)
+ continue;
+
+ const item = new PopupMenu.PopupImageMenuItem(label, iconName);
+ item.connect('activate',
+ () => (this._proxy.ActiveProfile = profile));
+ this._profileItems.set(profile, item);
+ this._profileSection.addMenuItem(item);
+ }
+
+ this.menuEnabled = this._profileItems.size > 2;
+ }
+
+ _sync() {
+ this.visible = this._proxy.g_name_owner !== null;
+
+ if (!this.visible)
+ return;
+
+ const {ActiveProfile: activeProfile} = this._proxy;
+
+ for (const [profile, item] of this._profileItems) {
+ item.setOrnament(profile === activeProfile
+ ? PopupMenu.Ornament.CHECK
+ : PopupMenu.Ornament.NONE);
+ }
+
+ this.set(PROFILE_PARAMS[activeProfile]);
+ this.checked = activeProfile !== 'balanced';
+
+ if (this.checked)
+ global.settings.set_string(LAST_PROFILE_KEY, activeProfile);
+ }
+});
+
+var Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this.quickSettingsItems.push(new PowerProfilesToggle());
+ }
+});
diff --git a/js/ui/status/remoteAccess.js b/js/ui/status/remoteAccess.js
new file mode 100644
index 0000000..1ed8793
--- /dev/null
+++ b/js/ui/status/remoteAccess.js
@@ -0,0 +1,230 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported RemoteAccessApplet, ScreenRecordingIndicator, ScreenSharingIndicator */
+
+const { Atk, Clutter, GLib, GObject, Meta, St } = imports.gi;
+
+const Main = imports.ui.main;
+const PanelMenu = imports.ui.panelMenu;
+const {SystemIndicator} = imports.ui.quickSettings;
+
+// Minimum amount of time the shared indicator is visible (in micro seconds)
+const MIN_SHARED_INDICATOR_VISIBLE_TIME_US = 5 * GLib.TIME_SPAN_SECOND;
+
+var RemoteAccessApplet = GObject.registerClass(
+class RemoteAccessApplet extends SystemIndicator {
+ _init() {
+ super._init();
+
+ let controller = global.backend.get_remote_access_controller();
+
+ if (!controller)
+ return;
+
+ this._handles = new Set();
+
+ this._indicator = this._addIndicator();
+ this._indicator.icon_name = 'media-record-symbolic';
+ this._indicator.add_style_class_name('screencast-indicator');
+
+ controller.connect('new-handle', (o, handle) => {
+ this._onNewHandle(handle);
+ });
+ this._sync();
+ }
+
+ _isRecording() {
+ // Screenshot UI screencasts have their own panel, so don't show this
+ // indicator if there's only a screenshot UI screencast.
+ if (Main.screenshotUI.screencast_in_progress)
+ return this._handles.size > 1;
+
+ return this._handles.size > 0;
+ }
+
+ _sync() {
+ this._indicator.visible = this._isRecording();
+ }
+
+ _onStopped(handle) {
+ this._handles.delete(handle);
+ this._sync();
+ }
+
+ _onNewHandle(handle) {
+ if (!handle.is_recording)
+ return;
+
+ this._handles.add(handle);
+ handle.connect('stopped', this._onStopped.bind(this));
+
+ this._sync();
+ }
+});
+
+var ScreenRecordingIndicator = GObject.registerClass({
+ Signals: { 'menu-set': {} },
+}, class ScreenRecordingIndicator extends PanelMenu.ButtonBox {
+ _init() {
+ super._init({
+ reactive: true,
+ can_focus: true,
+ track_hover: true,
+ accessible_name: _('Stop Screencast'),
+ accessible_role: Atk.Role.PUSH_BUTTON,
+ });
+ this.add_style_class_name('screen-recording-indicator');
+
+ this._box = new St.BoxLayout();
+ this.add_child(this._box);
+
+ this._label = new St.Label({
+ text: '0:00',
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ this._box.add_child(this._label);
+
+ this._icon = new St.Icon({ icon_name: 'stop-symbolic' });
+ this._box.add_child(this._icon);
+
+ this.hide();
+ Main.screenshotUI.connect(
+ 'notify::screencast-in-progress',
+ this._onScreencastInProgressChanged.bind(this));
+ }
+
+ vfunc_event(event) {
+ if (event.type() === Clutter.EventType.TOUCH_BEGIN ||
+ event.type() === Clutter.EventType.BUTTON_PRESS)
+ Main.screenshotUI.stopScreencast();
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _updateLabel() {
+ const minutes = this._secondsPassed / 60;
+ const seconds = this._secondsPassed % 60;
+ this._label.text = '%d:%02d'.format(minutes, seconds);
+ }
+
+ _onScreencastInProgressChanged() {
+ if (Main.screenshotUI.screencast_in_progress) {
+ this.show();
+
+ this._secondsPassed = 0;
+ this._updateLabel();
+
+ this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1000, () => {
+ this._secondsPassed += 1;
+ this._updateLabel();
+ return GLib.SOURCE_CONTINUE;
+ });
+ GLib.Source.set_name_by_id(
+ this._timeoutId, '[gnome-shell] screen recording indicator tick');
+ } else {
+ this.hide();
+
+ GLib.source_remove(this._timeoutId);
+ delete this._timeoutId;
+
+ delete this._secondsPassed;
+ }
+ }
+});
+
+var ScreenSharingIndicator = GObject.registerClass({
+ Signals: {'menu-set': {}},
+}, class ScreenSharingIndicator extends PanelMenu.ButtonBox {
+ _init() {
+ super._init({
+ reactive: true,
+ can_focus: true,
+ track_hover: true,
+ accessible_name: _('Stop Screen Sharing'),
+ accessible_role: Atk.Role.PUSH_BUTTON,
+ });
+ this.add_style_class_name('screen-sharing-indicator');
+
+ this._box = new St.BoxLayout();
+ this.add_child(this._box);
+
+ let icon = new St.Icon({icon_name: 'screen-shared-symbolic'});
+ this._box.add_child(icon);
+
+ icon = new St.Icon({icon_name: 'window-close-symbolic'});
+ this._box.add_child(icon);
+
+ this._controller = global.backend.get_remote_access_controller();
+
+ this._handles = new Set();
+
+ this._controller?.connect('new-handle',
+ (o, handle) => this._onNewHandle(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.
+ if (!Meta.is_wayland_compositor())
+ return;
+
+ if (handle.isRecording)
+ return;
+
+ this._handles.add(handle);
+ handle.connect('stopped', () => {
+ this._handles.delete(handle);
+ this._sync();
+ });
+ this._sync();
+ }
+
+ vfunc_event(event) {
+ if (event.type() === Clutter.EventType.TOUCH_BEGIN ||
+ event.type() === Clutter.EventType.BUTTON_PRESS)
+ this._stopSharing();
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _stopSharing() {
+ for (const handle of this._handles)
+ handle.stop();
+ }
+
+ _hideIndicator() {
+ this.hide();
+ delete this._hideIndicatorId;
+ return GLib.SOURCE_REMOVE;
+ }
+
+ _sync() {
+ if (this._hideIndicatorId) {
+ GLib.source_remove(this._hideIndicatorId);
+ delete this._hideIndicatorId;
+ }
+
+ if (this._handles.size > 0) {
+ if (!this.visible)
+ this._visibleTimeUs = GLib.get_monotonic_time();
+ this.show();
+ } else if (this.visible) {
+ const currentTimeUs = GLib.get_monotonic_time();
+ const timeSinceVisibleUs = currentTimeUs - this._visibleTimeUs;
+
+ if (timeSinceVisibleUs >= MIN_SHARED_INDICATOR_VISIBLE_TIME_US) {
+ this._hideIndicator();
+ } else {
+ const timeUntilHideUs =
+ MIN_SHARED_INDICATOR_VISIBLE_TIME_US - timeSinceVisibleUs;
+ this._hideIndicatorId =
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT,
+ timeUntilHideUs / GLib.TIME_SPAN_MILLISECOND,
+ () => this._hideIndicator());
+ }
+ }
+ }
+});
diff --git a/js/ui/status/rfkill.js b/js/ui/status/rfkill.js
new file mode 100644
index 0000000..2e1f98f
--- /dev/null
+++ b/js/ui/status/rfkill.js
@@ -0,0 +1,136 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+const {Gio, GLib, GObject} = imports.gi;
+
+const {QuickToggle, SystemIndicator} = imports.ui.quickSettings;
+
+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 rfkillManagerInfo = Gio.DBusInterfaceInfo.new_for_xml(RfkillManagerInterface);
+
+const RfkillManager = GObject.registerClass({
+ Properties: {
+ 'airplane-mode': GObject.ParamSpec.boolean(
+ 'airplane-mode', '', '',
+ GObject.ParamFlags.READWRITE,
+ false),
+ 'hw-airplane-mode': GObject.ParamSpec.boolean(
+ 'hw-airplane-mode', '', '',
+ GObject.ParamFlags.READABLE,
+ false),
+ 'show-airplane-mode': GObject.ParamSpec.boolean(
+ 'show-airplane-mode', '', '',
+ GObject.ParamFlags.READABLE,
+ false),
+ },
+}, class RfkillManager extends GObject.Object {
+ constructor() {
+ super();
+
+ this._proxy = new Gio.DBusProxy({
+ g_connection: Gio.DBus.session,
+ g_name: BUS_NAME,
+ g_object_path: OBJECT_PATH,
+ g_interface_name: rfkillManagerInfo.name,
+ g_interface_info: rfkillManagerInfo,
+ });
+ this._proxy.connect('g-properties-changed', this._changed.bind(this));
+ this._proxy.init_async(GLib.PRIORITY_DEFAULT, null)
+ .catch(e => console.error(e.message));
+ }
+
+ /* eslint-disable camelcase */
+ get airplane_mode() {
+ return this._proxy.AirplaneMode;
+ }
+
+ set airplane_mode(v) {
+ this._proxy.AirplaneMode = v;
+ }
+
+ get hw_airplane_mode() {
+ return this._proxy.HardwareAirplaneMode;
+ }
+
+ get show_airplane_mode() {
+ return this._proxy.HasAirplaneMode && this._proxy.ShouldShowAirplaneMode;
+ }
+ /* eslint-enable camelcase */
+
+ _changed(proxy, properties) {
+ for (const prop in properties.deepUnpack()) {
+ switch (prop) {
+ case 'AirplaneMode':
+ this.notify('airplane-mode');
+ break;
+ case 'HardwareAirplaneMode':
+ this.notify('hw-airplane-mode');
+ break;
+ case 'HasAirplaneMode':
+ case 'ShouldShowAirplaneMode':
+ this.notify('show-airplane-mode');
+ break;
+ }
+ }
+ }
+});
+
+var _manager;
+function getRfkillManager() {
+ if (_manager != null)
+ return _manager;
+
+ _manager = new RfkillManager();
+ return _manager;
+}
+
+const RfkillToggle = GObject.registerClass(
+class RfkillToggle extends QuickToggle {
+ _init() {
+ super._init({
+ label: _('Airplane Mode'),
+ iconName: 'airplane-mode-symbolic',
+ });
+
+ this._manager = getRfkillManager();
+ this._manager.bind_property('show-airplane-mode',
+ this, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+ this._manager.bind_property('airplane-mode',
+ this, 'checked',
+ GObject.BindingFlags.SYNC_CREATE);
+
+ this.connect('clicked',
+ () => (this._manager.airplaneMode = !this._manager.airplaneMode));
+ }
+});
+
+var Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this._indicator = this._addIndicator();
+ this._indicator.icon_name = 'airplane-mode-symbolic';
+
+ this._rfkillToggle = new RfkillToggle();
+ this._rfkillToggle.connectObject(
+ 'notify::visible', () => this._sync(),
+ 'notify::checked', () => this._sync(),
+ this);
+ this.quickSettingsItems.push(this._rfkillToggle);
+
+ this._sync();
+ }
+
+ _sync() {
+ // Only show indicator when airplane mode is on
+ const {visible, checked} = this._rfkillToggle;
+ this._indicator.visible = visible && checked;
+ }
+});
diff --git a/js/ui/status/system.js b/js/ui/status/system.js
new file mode 100644
index 0000000..5a2d92c
--- /dev/null
+++ b/js/ui/status/system.js
@@ -0,0 +1,348 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+const {Atk, Clutter, Gio, GLib, GObject, Meta, Shell, St, UPowerGlib: UPower} = imports.gi;
+
+const SystemActions = imports.misc.systemActions;
+const Main = imports.ui.main;
+const PopupMenu = imports.ui.popupMenu;
+const {PopupAnimation} = imports.ui.boxpointer;
+
+const {QuickSettingsItem, QuickToggle, SystemIndicator} = imports.ui.quickSettings;
+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';
+
+const PowerToggle = GObject.registerClass({
+ Properties: {
+ 'fallback-icon-name': GObject.ParamSpec.string('fallback-icon-name', '', '',
+ GObject.ParamFlags.READWRITE,
+ ''),
+ },
+}, class PowerToggle extends QuickToggle {
+ _init() {
+ super._init({
+ accessible_role: Atk.Role.PUSH_BUTTON,
+ });
+
+ this.add_style_class_name('power-item');
+
+ this._proxy = new PowerManagerProxy(Gio.DBus.system, BUS_NAME, OBJECT_PATH,
+ (proxy, error) => {
+ if (error)
+ console.error(error.message);
+ else
+ this._proxy.connect('g-properties-changed', () => this._sync());
+ this._sync();
+ });
+
+ this.bind_property('fallback-icon-name',
+ this._icon, 'fallback-icon-name',
+ GObject.BindingFlags.SYNC_CREATE);
+
+ this.connect('clicked', () => {
+ const app = Shell.AppSystem.get_default().lookup_app('gnome-power-panel.desktop');
+ Main.overview.hide();
+ Main.panel.closeQuickSettings();
+ app.activate();
+ });
+
+ Main.sessionMode.connect('updated', () => this._sessionUpdated());
+ this._sessionUpdated();
+ this._sync();
+ }
+
+ _sessionUpdated() {
+ this.reactive = Main.sessionMode.allowSettings;
+ }
+
+ _sync() {
+ // Do we have batteries or a UPS?
+ this.visible = this._proxy.IsPresent;
+ if (!this.visible)
+ 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-${fillLevel}${chargingState}-symbolic`;
+
+ // Make sure we fall back to fallback-icon-name and not GThemedIcon's
+ // default fallbacks
+ const gicon = new Gio.ThemedIcon({
+ name: icon,
+ use_default_fallbacks: false,
+ });
+
+ this.set({
+ label: _('%d\u2009%%').format(this._proxy.Percentage),
+ fallback_icon_name: this._proxy.IconName,
+ gicon,
+ });
+ }
+});
+
+const ScreenshotItem = GObject.registerClass(
+class ScreenshotItem extends QuickSettingsItem {
+ _init() {
+ super._init({
+ style_class: 'icon-button',
+ can_focus: true,
+ icon_name: 'camera-photo-symbolic',
+ visible: !Main.sessionMode.isGreeter,
+ accessible_name: _('Take Screenshot'),
+ });
+
+ this.connect('clicked', () => {
+ const topMenu = Main.panel.statusArea.quickSettings.menu;
+ Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
+ Main.screenshotUI.open().catch(logError);
+ return GLib.SOURCE_REMOVE;
+ });
+ topMenu.close(PopupAnimation.NONE);
+ });
+ }
+});
+
+const SettingsItem = GObject.registerClass(
+class SettingsItem extends QuickSettingsItem {
+ _init() {
+ super._init({
+ style_class: 'icon-button',
+ can_focus: true,
+ child: new St.Icon(),
+ });
+
+ this._settingsApp = Shell.AppSystem.get_default().lookup_app(
+ 'org.gnome.Settings.desktop');
+
+ if (!this._settingsApp)
+ console.warn('Missing required core component Settings, expect trouble…');
+
+ this.child.gicon = this._settingsApp?.get_icon() ?? null;
+ this.accessible_name = this._settingsApp?.get_name() ?? null;
+
+ this.connect('clicked', () => {
+ Main.overview.hide();
+ Main.panel.closeQuickSettings();
+ this._settingsApp.activate();
+ });
+
+ Main.sessionMode.connectObject('updated', () => this._sync(), this);
+ this._sync();
+ }
+
+ _sync() {
+ this.visible =
+ this._settingsApp != null && Main.sessionMode.allowSettings;
+ }
+});
+
+const ShutdownItem = GObject.registerClass(
+class ShutdownItem extends QuickSettingsItem {
+ _init() {
+ super._init({
+ style_class: 'icon-button',
+ hasMenu: true,
+ canFocus: true,
+ icon_name: 'system-shutdown-symbolic',
+ accessible_name: _('Power Off Menu'),
+ });
+
+ this._systemActions = new SystemActions.getDefault();
+ this._items = [];
+
+ this.menu.setHeader('system-shutdown-symbolic', C_('title', 'Power Off'));
+
+ this._addSystemAction(_('Suspend'), 'can-suspend', () => {
+ this._systemActions.activateSuspend();
+ Main.panel.closeQuickSettings();
+ });
+
+ this._addSystemAction(_('Restart…'), 'can-restart', () => {
+ this._systemActions.activateRestart();
+ Main.panel.closeQuickSettings();
+ });
+
+ this._addSystemAction(_('Power Off…'), 'can-power-off', () => {
+ this._systemActions.activatePowerOff();
+ Main.panel.closeQuickSettings();
+ });
+
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ this._addSystemAction(_('Log Out…'), 'can-logout', () => {
+ this._systemActions.activateLogout();
+ Main.panel.closeQuickSettings();
+ });
+
+ this._addSystemAction(_('Switch User…'), 'can-switch-user', () => {
+ this._systemActions.activateSwitchUser();
+ Main.panel.closeQuickSettings();
+ });
+
+ // 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 item each time we become visible or
+ // the lockdown setting changes, which should be close enough.
+ this.connect('notify::mapped', () => {
+ if (!this.mapped)
+ return;
+
+ this._systemActions.forceUpdate();
+ });
+
+ this.connect('clicked', () => this.menu.open());
+ this.connect('popup-menu', () => this.menu.open());
+ }
+
+ _addSystemAction(label, propName, callback) {
+ const item = this.menu.addAction(label, callback);
+ this._items.push(item);
+
+ this._systemActions.bind_property(propName,
+ item, 'visible',
+ GObject.BindingFlags.DEFAULT | GObject.BindingFlags.SYNC_CREATE);
+ item.connect('notify::visible', () => this._sync());
+ }
+
+ _sync() {
+ this.visible = this._items.some(i => i.visible);
+ }
+});
+
+const LockItem = GObject.registerClass(
+class LockItem extends QuickSettingsItem {
+ _init() {
+ this._systemActions = new SystemActions.getDefault();
+
+ super._init({
+ style_class: 'icon-button',
+ can_focus: true,
+ icon_name: 'system-lock-screen-symbolic',
+ accessible_name: C_('action', 'Lock Screen'),
+ });
+
+ this._systemActions.bind_property('can-lock-screen',
+ this, 'visible',
+ GObject.BindingFlags.DEFAULT |
+ GObject.BindingFlags.SYNC_CREATE);
+
+ this.connect('clicked',
+ () => this._systemActions.activateLockScreen());
+ }
+});
+
+
+const SystemItem = GObject.registerClass(
+class SystemItem extends QuickSettingsItem {
+ _init() {
+ super._init({
+ style_class: 'quick-settings-system-item',
+ reactive: false,
+ });
+
+ this.child = new St.BoxLayout();
+
+ this._powerToggle = new PowerToggle();
+ this.child.add_child(this._powerToggle);
+
+ this._laptopSpacer = new Clutter.Actor({x_expand: true});
+ this._powerToggle.bind_property('visible',
+ this._laptopSpacer, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+ this.child.add_child(this._laptopSpacer);
+
+ const screenshotItem = new ScreenshotItem();
+ this.child.add_child(screenshotItem);
+
+ const settingsItem = new SettingsItem();
+ this.child.add_child(settingsItem);
+
+ this._desktopSpacer = new Clutter.Actor({x_expand: true});
+ this._powerToggle.bind_property('visible',
+ this._desktopSpacer, 'visible',
+ GObject.BindingFlags.INVERT_BOOLEAN |
+ GObject.BindingFlags.SYNC_CREATE);
+ this.child.add_child(this._desktopSpacer);
+
+ const lockItem = new LockItem();
+ this.child.add_child(lockItem);
+
+ const shutdownItem = new ShutdownItem();
+ this.child.add_child(shutdownItem);
+
+ this.menu = shutdownItem.menu;
+ }
+
+ get powerToggle() {
+ return this._powerToggle;
+ }
+});
+
+var Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this._desktopSettings = new Gio.Settings({
+ schema_id: 'org.gnome.desktop.interface',
+ });
+ this._desktopSettings.connectObject(
+ `changed::${SHOW_BATTERY_PERCENTAGE}`, () => this._sync(), 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._systemItem = new SystemItem();
+
+ const {powerToggle} = this._systemItem;
+
+ powerToggle.bind_property('label',
+ this._percentageLabel, 'text',
+ GObject.BindingFlags.SYNC_CREATE);
+
+ powerToggle.connectObject(
+ 'notify::visible', () => this._sync(),
+ 'notify::gicon', () => this._sync(),
+ 'notify::fallback-icon-name', () => this._sync(),
+ this);
+
+ this.quickSettingsItems.push(this._systemItem);
+
+ this._sync();
+ }
+
+ _sync() {
+ const {powerToggle} = this._systemItem;
+ if (powerToggle.visible) {
+ this._indicator.set({
+ gicon: powerToggle.gicon,
+ fallback_icon_name: powerToggle.fallback_icon_name,
+ });
+ this._percentageLabel.visible =
+ this._desktopSettings.get_boolean(SHOW_BATTERY_PERCENTAGE);
+ } else {
+ // If there's no battery, then we use the power icon.
+ this._indicator.icon_name = 'system-shutdown-symbolic';
+ this._percentageLabel.hide();
+ }
+ }
+});
diff --git a/js/ui/status/thunderbolt.js b/js/ui/status/thunderbolt.js
new file mode 100644
index 0000000..2e1236e
--- /dev/null
+++ b/js/ui/status/thunderbolt.js
@@ -0,0 +1,332 @@
+// -*- 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.misc.signals;
+
+const Main = imports.ui.main;
+const MessageTray = imports.ui.messageTray;
+const {SystemIndicator} = imports.ui.quickSettings;
+
+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 extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ 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: ${e.message}`);
+ return;
+ }
+ this._proxy.connectObject('g-properties-changed',
+ this._onPropertiesChanged.bind(this), 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) {
+ const probingChanged = !!properties.lookup_value('Probing', null);
+ if (probingChanged) {
+ 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.disconnectObject(this);
+ this._proxy = null;
+ }
+
+ async enrollDevice(id, policy) {
+ try {
+ const [path] = await this._proxy.EnrollDeviceAsync(id, policy, AuthCtrl.NONE);
+ const device = new BoltDeviceProxy(Gio.DBus.system, BOLT_DBUS_NAME, path);
+ return device;
+ } catch (error) {
+ Gio.DBusError.strip_remote_error(error);
+ throw error;
+ }
+ }
+
+ get authMode() {
+ return this._proxy.AuthMode;
+ }
+};
+
+/* helper class to automatically authorize new devices */
+var AuthRobot = class extends Signals.EventEmitter {
+ constructor(client) {
+ super();
+
+ 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));
+ }
+
+ async _enrollDevicesIdle() {
+ let devices = this._devicesToEnroll;
+
+ let dev = devices.shift();
+ if (dev === undefined)
+ return GLib.SOURCE_REMOVE;
+
+ try {
+ await this._client.enrollDevice(dev.Uid, Policy.DEFAULT);
+
+ /* 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));
+ }
+ } catch (error) {
+ this.emit('enroll-failed', null, error);
+ }
+ return GLib.SOURCE_REMOVE;
+ }
+};
+
+/* eof client.js */
+
+var Indicator = GObject.registerClass(
+class Indicator extends 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: ${e}`);
+ }
+ }
+
+ _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: [${device.Name}] auto enrollment: ${auth ? 'yes' : 'no'} (allowed: ${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..bd49cc3
--- /dev/null
+++ b/js/ui/status/volume.js
@@ -0,0 +1,458 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Indicator */
+
+const {Clutter, Gio, GLib, GObject, Gvc} = imports.gi;
+
+const Main = imports.ui.main;
+const PopupMenu = imports.ui.popupMenu;
+
+const {QuickSlider, SystemIndicator} = imports.ui.quickSettings;
+
+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;
+/**
+ * @returns {Gvc.MixerControl} - the mixer control singleton
+ */
+function getMixerControl() {
+ if (_mixerControl)
+ return _mixerControl;
+
+ _mixerControl = new Gvc.MixerControl({ name: 'GNOME Shell Volume Control' });
+ _mixerControl.open();
+
+ return _mixerControl;
+}
+
+const StreamSlider = GObject.registerClass({
+ Signals: {
+ 'stream-updated': {},
+ },
+}, class StreamSlider extends QuickSlider {
+ _init(control) {
+ super._init();
+
+ this._control = control;
+
+ this._inDrag = false;
+ this._notifyVolumeChangeId = 0;
+
+ this._soundSettings = new Gio.Settings({
+ schema_id: 'org.gnome.desktop.sound',
+ });
+ this._soundSettings.connect(`changed::${ALLOW_AMPLIFIED_VOLUME_KEY}`,
+ () => this._amplifySettingsChanged());
+ this._amplifySettingsChanged();
+
+ this._sliderChangedId = this.slider.connect('notify::value',
+ () => this._sliderChanged());
+ this.slider.connect('drag-begin', () => (this._inDrag = true));
+ this.slider.connect('drag-end', () => {
+ this._inDrag = false;
+ this._notifyVolumeChange();
+ });
+
+ this._deviceItems = new Map();
+
+ this._deviceSection = new PopupMenu.PopupMenuSection();
+ this.menu.addMenuItem(this._deviceSection);
+
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+ this.menu.addSettingsAction(_('Sound Settings'),
+ 'gnome-sound-panel.desktop');
+
+ this._stream = null;
+ this._volumeCancellable = null;
+ this._icons = [];
+
+ this._sync();
+ }
+
+ get stream() {
+ return this._stream;
+ }
+
+ set stream(stream) {
+ this._stream?.disconnectObject(this);
+
+ this._stream = stream;
+
+ if (this._stream) {
+ this._connectStream(this._stream);
+ this._updateVolume();
+ } else {
+ this.emit('stream-updated');
+ }
+
+ this._sync();
+ }
+
+ _connectStream(stream) {
+ stream.connectObject(
+ 'notify::is-muted', this._updateVolume.bind(this),
+ 'notify::volume', this._updateVolume.bind(this), this);
+ }
+
+ _lookupDevice(_id) {
+ throw new GObject.NotImplementedError(
+ `_lookupDevice in ${this.constructor.name}`);
+ }
+
+ _activateDevice(_device) {
+ throw new GObject.NotImplementedError(
+ `_activateDevice in ${this.constructor.name}`);
+ }
+
+ _addDevice(id) {
+ if (this._deviceItems.has(id))
+ return;
+
+ const device = this._lookupDevice(id);
+ if (!device)
+ return;
+
+ const {description, origin} = device;
+ const name = origin
+ ? `${description} – ${origin}`
+ : description;
+ const item = new PopupMenu.PopupImageMenuItem(name, device.get_gicon());
+ item.connect('activate', () => this._activateDevice(device));
+
+ this._deviceSection.addMenuItem(item);
+ this._deviceItems.set(id, item);
+
+ this._sync();
+ }
+
+ _removeDevice(id) {
+ this._deviceItems.get(id)?.destroy();
+ if (this._deviceItems.delete(id))
+ this._sync();
+ }
+
+ _setActiveDevice(activeId) {
+ for (const [id, item] of this._deviceItems) {
+ item.setOrnament(id === activeId
+ ? PopupMenu.Ornament.CHECK
+ : PopupMenu.Ornament.NONE);
+ }
+ }
+
+ _shouldBeVisible() {
+ return this._stream != null;
+ }
+
+ _sync() {
+ this.visible = this._shouldBeVisible();
+ this.menuEnabled = this._deviceItems.size > 1;
+ }
+
+ _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();
+ }
+});
+
+const OutputStreamSlider = GObject.registerClass(
+class OutputStreamSlider extends StreamSlider {
+ _init(control) {
+ super._init(control);
+
+ this.slider.accessible_name = _('Volume');
+
+ this._control.connectObject(
+ 'output-added', (c, id) => this._addDevice(id),
+ 'output-removed', (c, id) => this._removeDevice(id),
+ 'active-output-update', (c, id) => this._setActiveDevice(id),
+ this);
+
+ this._icons = [
+ 'audio-volume-muted-symbolic',
+ 'audio-volume-low-symbolic',
+ 'audio-volume-medium-symbolic',
+ 'audio-volume-high-symbolic',
+ 'audio-volume-overamplified-symbolic',
+ ];
+
+ this.menu.setHeader('audio-headphones-symbolic', _('Sound Output'));
+ }
+
+ _connectStream(stream) {
+ super._connectStream(stream);
+ stream.connectObject('notify::port',
+ this._portChanged.bind(this), this);
+ this._portChanged();
+ }
+
+ _lookupDevice(id) {
+ return this._control.lookup_output_id(id);
+ }
+
+ _activateDevice(device) {
+ this._control.change_output(device);
+ }
+
+ _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;
+ }
+
+ _portChanged() {
+ const hasHeadphones = this._findHeadphones(this._stream);
+ if (hasHeadphones === this._hasHeadphones)
+ return;
+
+ this._hasHeadphones = hasHeadphones;
+ this.iconName = this._hasHeadphones
+ ? 'audio-headphones-symbolic'
+ : 'audio-speakers-symbolic';
+ }
+});
+
+const InputStreamSlider = GObject.registerClass(
+class InputStreamSlider extends StreamSlider {
+ _init(control) {
+ super._init(control);
+
+ this.slider.accessible_name = _('Microphone');
+
+ this._control.connectObject(
+ 'input-added', (c, id) => this._addDevice(id),
+ 'input-removed', (c, id) => this._removeDevice(id),
+ 'active-input-update', (c, id) => this._setActiveDevice(id),
+ 'stream-added', () => this._maybeShowInput(),
+ 'stream-removed', () => this._maybeShowInput(),
+ this);
+
+ this.iconName = 'audio-input-microphone-symbolic';
+ this._icons = [
+ 'microphone-sensitivity-muted-symbolic',
+ 'microphone-sensitivity-low-symbolic',
+ 'microphone-sensitivity-medium-symbolic',
+ 'microphone-sensitivity-high-symbolic',
+ ];
+
+ this.menu.setHeader('audio-input-microphone-symbolic', _('Sound Input'));
+ }
+
+ _connectStream(stream) {
+ super._connectStream(stream);
+ this._maybeShowInput();
+ }
+
+ _lookupDevice(id) {
+ return this._control.lookup_input_id(id);
+ }
+
+ _activateDevice(device) {
+ this._control.change_input(device);
+ }
+
+ _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 => !skippedApps.includes(output.get_application_id()));
+ }
+
+ this._showInput = showInput;
+ this._sync();
+ }
+
+ _shouldBeVisible() {
+ return super._shouldBeVisible() && this._showInput;
+ }
+});
+
+var Indicator = GObject.registerClass(
+class Indicator extends SystemIndicator {
+ _init() {
+ super._init();
+
+ this._primaryIndicator = this._addIndicator();
+ this._inputIndicator = this._addIndicator();
+
+ this._primaryIndicator.reactive = true;
+ this._inputIndicator.reactive = true;
+
+ this._primaryIndicator.connect('scroll-event',
+ (actor, event) => this._handleScrollEvent(this._output, event));
+ this._inputIndicator.connect('scroll-event',
+ (actor, event) => this._handleScrollEvent(this._input, event));
+
+ this._control = getMixerControl();
+ this._control.connectObject(
+ 'state-changed', () => this._onControlStateChanged(),
+ 'default-sink-changed', () => this._readOutput(),
+ 'default-source-changed', () => this._readInput(),
+ this);
+
+ this._output = new OutputStreamSlider(this._control);
+ this._output.connect('stream-updated', () => {
+ const icon = this._output.getIcon();
+
+ if (icon)
+ this._primaryIndicator.icon_name = icon;
+ this._primaryIndicator.visible = icon !== null;
+ });
+
+ this._input = new InputStreamSlider(this._control);
+ this._input.connect('stream-updated', () => {
+ const icon = this._input.getIcon();
+
+ if (icon)
+ this._inputIndicator.icon_name = icon;
+ });
+
+ this._input.bind_property('visible',
+ this._inputIndicator, 'visible',
+ GObject.BindingFlags.SYNC_CREATE);
+
+ this.quickSettingsItems.push(this._output);
+ this.quickSettingsItems.push(this._input);
+
+ this._onControlStateChanged();
+ }
+
+ _onControlStateChanged() {
+ if (this._control.get_state() === Gvc.MixerControlState.READY) {
+ this._readInput();
+ this._readOutput();
+ } else {
+ this._primaryIndicator.hide();
+ }
+ }
+
+ _readOutput() {
+ this._output.stream = this._control.get_default_sink();
+ }
+
+ _readInput() {
+ this._input.stream = this._control.get_default_source();
+ }
+
+ _handleScrollEvent(item, event) {
+ const result = item.slider.scroll(event);
+ if (result === Clutter.EVENT_PROPAGATE || item.mapped)
+ return result;
+
+ const gicon = new Gio.ThemedIcon({name: item.getIcon()});
+ const level = item.getLevel();
+ const maxLevel = item.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..869f977
--- /dev/null
+++ b/js/ui/swipeTracker.js
@@ -0,0 +1,787 @@
+// -*- 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 EVENT_HISTORY_THRESHOLD_MS = 150;
+
+const SCROLL_MULTIPLIER = 10;
+
+const MIN_ANIMATION_DURATION = 100;
+const MAX_ANIMATION_DURATION = 400;
+const VELOCITY_THRESHOLD_TOUCH = 0.3;
+const VELOCITY_THRESHOLD_TOUCHPAD = 0.6;
+const DECELERATION_TOUCH = 0.998;
+const DECELERATION_TOUCHPAD = 0.997;
+const VELOCITY_CURVE_THRESHOLD = 2;
+const DECELERATION_PARABOLA_MULTIPLIER = 0.35;
+const DRAG_THRESHOLD_DISTANCE = 16;
+
+// Derivative of easeOutCubic at t=0
+const DURATION_MULTIPLIER = 3;
+const ANIMATION_BASE_VELOCITY = 0.002;
+const EPSILON = 0.005;
+
+const GESTURE_FINGER_COUNT = 3;
+
+const State = {
+ NONE: 0,
+ SCROLLING: 1,
+};
+
+const TouchpadState = {
+ NONE: 0,
+ PENDING: 1,
+ HANDLING: 2,
+ IGNORED: 3,
+};
+
+const EventHistory = class {
+ constructor() {
+ this.reset();
+ }
+
+ reset() {
+ this._data = [];
+ }
+
+ trim(time) {
+ const thresholdTime = time - EVENT_HISTORY_THRESHOLD_MS;
+ const index = this._data.findIndex(r => r.time >= thresholdTime);
+
+ this._data.splice(0, index);
+ }
+
+ append(time, delta) {
+ this.trim(time);
+
+ this._data.push({ time, delta });
+ }
+
+ calculateVelocity() {
+ if (this._data.length < 2)
+ return 0;
+
+ const firstTime = this._data[0].time;
+ const lastTime = this._data[this._data.length - 1].time;
+
+ if (firstTime === lastTime)
+ return 0;
+
+ const totalDelta = this._data.slice(1).map(a => a.delta).reduce((a, b) => a + b);
+ const period = lastTime - firstTime;
+
+ return totalDelta / period;
+ }
+};
+
+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.HORIZONTAL),
+ },
+ Signals: {
+ 'begin': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] },
+ 'update': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] },
+ 'end': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE] },
+ },
+}, class TouchpadSwipeGesture extends GObject.Object {
+ _init(allowedModes) {
+ super._init();
+ this._allowedModes = allowedModes;
+ this._state = TouchpadState.NONE;
+ this._cumulativeX = 0;
+ this._cumulativeY = 0;
+ this._touchpadSettings = new Gio.Settings({
+ schema_id: 'org.gnome.desktop.peripherals.touchpad',
+ });
+
+ global.stage.connectObject(
+ 'captured-event::touchpad', this._handleEvent.bind(this), this);
+ }
+
+ _handleEvent(actor, event) {
+ if (event.type() !== Clutter.EventType.TOUCHPAD_SWIPE)
+ return Clutter.EVENT_PROPAGATE;
+
+ if (event.get_gesture_phase() === Clutter.TouchpadGesturePhase.BEGIN)
+ this._state = TouchpadState.NONE;
+
+ if (event.get_touchpad_gesture_finger_count() !== GESTURE_FINGER_COUNT)
+ return Clutter.EVENT_PROPAGATE;
+
+ if ((this._allowedModes & Main.actionMode) === 0)
+ return Clutter.EVENT_PROPAGATE;
+
+ if (!this.enabled)
+ return Clutter.EVENT_PROPAGATE;
+
+ if (this._state === TouchpadState.IGNORED)
+ return Clutter.EVENT_PROPAGATE;
+
+ let time = event.get_time();
+
+ const [x, y] = event.get_coords();
+ const [dx, dy] = event.get_gesture_motion_delta_unaccelerated();
+
+ if (this._state === TouchpadState.NONE) {
+ if (dx === 0 && dy === 0)
+ return Clutter.EVENT_PROPAGATE;
+
+ this._cumulativeX = 0;
+ this._cumulativeY = 0;
+ this._state = TouchpadState.PENDING;
+ }
+
+ if (this._state === TouchpadState.PENDING) {
+ this._cumulativeX += dx;
+ this._cumulativeY += dy;
+
+ const cdx = this._cumulativeX;
+ const cdy = this._cumulativeY;
+ const distance = Math.sqrt(cdx * cdx + cdy * cdy);
+
+ if (distance >= DRAG_THRESHOLD_DISTANCE) {
+ const gestureOrientation = Math.abs(cdx) > Math.abs(cdy)
+ ? Clutter.Orientation.HORIZONTAL
+ : Clutter.Orientation.VERTICAL;
+
+ this._cumulativeX = 0;
+ this._cumulativeY = 0;
+
+ if (gestureOrientation === this.orientation) {
+ this._state = TouchpadState.HANDLING;
+ this.emit('begin', time, x, y);
+ } else {
+ this._state = TouchpadState.IGNORED;
+ return Clutter.EVENT_PROPAGATE;
+ }
+ } else {
+ return Clutter.EVENT_PROPAGATE;
+ }
+ }
+
+ const vertical = this.orientation === Clutter.Orientation.VERTICAL;
+ let delta = vertical ? dy : dx;
+ const distance = vertical ? TOUCHPAD_BASE_HEIGHT : TOUCHPAD_BASE_WIDTH;
+
+ switch (event.get_gesture_phase()) {
+ case Clutter.TouchpadGesturePhase.BEGIN:
+ case Clutter.TouchpadGesturePhase.UPDATE:
+ if (this._touchpadSettings.get_boolean('natural-scroll'))
+ delta = -delta;
+
+ this.emit('update', time, delta, distance);
+ break;
+
+ case Clutter.TouchpadGesturePhase.END:
+ case Clutter.TouchpadGesturePhase.CANCEL:
+ this.emit('end', time, distance);
+ this._state = TouchpadState.NONE;
+ break;
+ }
+
+ return this._state === TouchpadState.HANDLING
+ ? Clutter.EVENT_STOP
+ : Clutter.EVENT_PROPAGATE;
+ }
+
+ destroy() {
+ global.stage.disconnectObject(this);
+ }
+});
+
+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.HORIZONTAL),
+ },
+ Signals: {
+ 'begin': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] },
+ 'update': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] },
+ 'end': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE] },
+ 'cancel': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE] },
+ },
+}, 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;
+
+ 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');
+ }
+
+ 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);
+ const [xDelta, yDelta] = [x - xPress, y - yPress];
+ const swipeOrientation = Math.abs(xDelta) > Math.abs(yDelta)
+ ? Clutter.Orientation.HORIZONTAL : Clutter.Orientation.VERTICAL;
+
+ if (swipeOrientation !== this.orientation)
+ return false;
+
+ 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, this._distance);
+ }
+
+ vfunc_gesture_cancel(_actor) {
+ let time = Clutter.get_current_event_time();
+
+ this.emit('cancel', time, this._distance);
+ }
+});
+
+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.HORIZONTAL),
+ 'scroll-modifiers': GObject.ParamSpec.flags(
+ 'scroll-modifiers', 'scroll-modifiers', 'scroll-modifiers',
+ GObject.ParamFlags.READWRITE,
+ Clutter.ModifierType, 0),
+ },
+ Signals: {
+ 'begin': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] },
+ 'update': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] },
+ 'end': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE] },
+ },
+}, class ScrollGesture extends GObject.Object {
+ _init(actor, allowedModes) {
+ super._init();
+ this._allowedModes = allowedModes;
+ this._began = false;
+ this._enabled = true;
+
+ 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');
+ }
+
+ 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;
+
+ if (!this._began && this.scrollModifiers !== 0 &&
+ (event.get_state() & this.scrollModifiers) === 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;
+
+ const vertical = this.orientation === Clutter.Orientation.VERTICAL;
+ const distance = vertical ? TOUCHPAD_BASE_HEIGHT : TOUCHPAD_BASE_WIDTH;
+
+ let time = event.get_time();
+ let [dx, dy] = event.get_scroll_delta();
+ if (dx === 0 && dy === 0) {
+ this.emit('end', time, distance);
+ 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;
+ }
+
+ const delta = (vertical ? dy : dx) * SCROLL_MULTIPLIER;
+
+ this.emit('update', time, delta, distance);
+
+ 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.HORIZONTAL),
+ 'distance': GObject.ParamSpec.double(
+ 'distance', 'distance', 'distance',
+ GObject.ParamFlags.READWRITE,
+ 0, Infinity, 0),
+ 'allow-long-swipes': GObject.ParamSpec.boolean(
+ 'allow-long-swipes', 'allow-long-swipes', 'allow-long-swipes',
+ GObject.ParamFlags.READWRITE,
+ false),
+ 'scroll-modifiers': GObject.ParamSpec.flags(
+ 'scroll-modifiers', 'scroll-modifiers', 'scroll-modifiers',
+ GObject.ParamFlags.READWRITE,
+ Clutter.ModifierType, 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, orientation, allowedModes, params) {
+ super._init();
+ params = Params.parse(params, { allowDrag: true, allowScroll: true });
+
+ this.orientation = orientation;
+ this._allowedModes = allowedModes;
+ this._enabled = true;
+ this._distance = global.screen_height;
+ this._history = new EventHistory();
+ 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._endTouchpadGesture.bind(this));
+ this.bind_property('enabled', this._touchpadGesture, 'enabled', 0);
+ this.bind_property('orientation', this._touchpadGesture, 'orientation',
+ GObject.BindingFlags.SYNC_CREATE);
+
+ this._touchGesture = new TouchSwipeGesture(allowedModes,
+ GESTURE_FINGER_COUNT,
+ Clutter.GestureTriggerEdge.AFTER);
+ this._touchGesture.connect('begin', this._beginTouchSwipe.bind(this));
+ this._touchGesture.connect('update', this._updateGesture.bind(this));
+ this._touchGesture.connect('end', this._endTouchGesture.bind(this));
+ this._touchGesture.connect('cancel', this._cancelTouchGesture.bind(this));
+ this.bind_property('enabled', this._touchGesture, 'enabled', 0);
+ this.bind_property('orientation', this._touchGesture, 'orientation',
+ GObject.BindingFlags.SYNC_CREATE);
+ this.bind_property('distance', this._touchGesture, 'distance', 0);
+ global.stage.add_action_full('swipe', Clutter.EventPhase.CAPTURE, 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._endTouchGesture.bind(this));
+ this._dragGesture.connect('cancel', this._cancelTouchGesture.bind(this));
+ this.bind_property('enabled', this._dragGesture, 'enabled', 0);
+ this.bind_property('orientation', this._dragGesture, 'orientation',
+ GObject.BindingFlags.SYNC_CREATE);
+ this.bind_property('distance', this._dragGesture, 'distance', 0);
+ actor.add_action_full('drag', Clutter.EventPhase.CAPTURE, 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._endTouchpadGesture.bind(this));
+ this.bind_property('enabled', this._scrollGesture, 'enabled', 0);
+ this.bind_property('orientation', this._scrollGesture, 'orientation',
+ GObject.BindingFlags.SYNC_CREATE);
+ this.bind_property('scroll-modifiers',
+ this._scrollGesture, 'scroll-modifiers', 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 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._cancelled = false;
+
+ this._history.reset();
+ }
+
+ _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._history.append(time, 0);
+
+ 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);
+ }
+
+ _findClosestPoint(pos) {
+ const distances = this._snapPoints.map(x => Math.abs(x - pos));
+ const min = Math.min(...distances);
+ return distances.indexOf(min);
+ }
+
+ _findNextPoint(pos) {
+ return this._snapPoints.findIndex(p => p >= pos);
+ }
+
+ _findPreviousPoint(pos) {
+ const reversedIndex = this._snapPoints.slice().reverse().findIndex(p => p <= pos);
+ return this._snapPoints.length - 1 - reversedIndex;
+ }
+
+ _findPointForProjection(pos, velocity) {
+ const initial = this._findClosestPoint(this._initialProgress);
+ const prev = this._findPreviousPoint(pos);
+ const next = this._findNextPoint(pos);
+
+ if ((velocity > 0 ? prev : next) === initial)
+ return velocity > 0 ? next : prev;
+
+ return this._findClosestPoint(pos);
+ }
+
+ _getBounds(pos) {
+ if (this.allowLongSwipes)
+ return [this._snapPoints[0], this._snapPoints[this._snapPoints.length - 1]];
+
+ const closest = this._findClosestPoint(pos);
+
+ let prev, next;
+ if (Math.abs(this._snapPoints[closest] - pos) < EPSILON) {
+ prev = next = closest;
+ } else {
+ prev = this._findPreviousPoint(pos);
+ next = this._findNextPoint(pos);
+ }
+
+ const lowerIndex = Math.max(prev - 1, 0);
+ const upperIndex = Math.min(next + 1, this._snapPoints.length - 1);
+
+ return [this._snapPoints[lowerIndex], this._snapPoints[upperIndex]];
+ }
+
+ _updateGesture(gesture, time, delta, distance) {
+ 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 / distance;
+ this._history.append(time, delta);
+
+ this._progress = Math.clamp(this._progress, ...this._getBounds(this._initialProgress));
+
+ this.emit('update', this._progress);
+ }
+
+ _getEndProgress(velocity, distance, isTouchpad) {
+ if (this._cancelled)
+ return this._cancelProgress;
+
+ const threshold = isTouchpad ? VELOCITY_THRESHOLD_TOUCHPAD : VELOCITY_THRESHOLD_TOUCH;
+
+ if (Math.abs(velocity) < threshold)
+ return this._snapPoints[this._findClosestPoint(this._progress)];
+
+ const decel = isTouchpad ? DECELERATION_TOUCHPAD : DECELERATION_TOUCH;
+ const slope = decel / (1.0 - decel) / 1000.0;
+
+ let pos;
+ if (Math.abs(velocity) > VELOCITY_CURVE_THRESHOLD) {
+ const c = slope / 2 / DECELERATION_PARABOLA_MULTIPLIER;
+ const x = Math.abs(velocity) - VELOCITY_CURVE_THRESHOLD + c;
+
+ pos = slope * VELOCITY_CURVE_THRESHOLD +
+ DECELERATION_PARABOLA_MULTIPLIER * x * x -
+ DECELERATION_PARABOLA_MULTIPLIER * c * c;
+ } else {
+ pos = Math.abs(velocity) * slope;
+ }
+
+ pos = pos * Math.sign(velocity) + this._progress;
+ pos = Math.clamp(pos, ...this._getBounds(this._initialProgress));
+
+ const index = this._findPointForProjection(pos, velocity);
+
+ return this._snapPoints[index];
+ }
+
+ _endTouchGesture(_gesture, time, distance) {
+ this._endGesture(time, distance, false);
+ }
+
+ _endTouchpadGesture(_gesture, time, distance) {
+ this._endGesture(time, distance, true);
+ }
+
+ _endGesture(time, distance, isTouchpad) {
+ if (this._state !== State.SCROLLING)
+ return;
+
+ if ((this._allowedModes & Main.actionMode) === 0 || !this.enabled) {
+ this._interrupt();
+ return;
+ }
+
+ this._history.trim(time);
+
+ let velocity = this._history.calculateVelocity();
+ const endProgress = this._getEndProgress(velocity, distance, isTouchpad);
+
+ velocity /= distance;
+
+ if ((endProgress - this._progress) * velocity <= 0)
+ velocity = ANIMATION_BASE_VELOCITY;
+
+ const nPoints = Math.max(1, Math.ceil(Math.abs(this._progress - endProgress)));
+ const maxDuration = MAX_ANIMATION_DURATION * Math.log2(1 + nPoints);
+
+ let duration = Math.abs((this._progress - endProgress) / velocity * DURATION_MULTIPLIER);
+ if (duration > 0)
+ duration = Math.clamp(duration, MIN_ANIMATION_DURATION, maxDuration);
+
+ this._reset();
+ this.emit('end', duration, endProgress);
+ }
+
+ _cancelTouchGesture(_gesture, time, distance) {
+ if (this._state !== State.SCROLLING)
+ return;
+
+ this._cancelled = true;
+ this._endGesture(time, distance, false);
+ }
+
+ /**
+ * 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._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..a95c5fa
--- /dev/null
+++ b/js/ui/switchMonitor.js
@@ -0,0 +1,122 @@
+// -*- 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 = [];
+
+ items.push({
+ icon: 'view-mirror-symbolic',
+ /* Translators: this is for display mirroring i.e. cloning.
+ * Try to keep it under around 15 characters.
+ */
+ label: _('Mirror'),
+ configType: Meta.MonitorSwitchConfigType.ALL_MIRROR,
+ });
+
+ items.push({
+ icon: 'video-joined-displays-symbolic',
+ /* Translators: this is for the desktop spanning displays.
+ * Try to keep it under around 15 characters.
+ */
+ label: _('Join Displays'),
+ configType: Meta.MonitorSwitchConfigType.ALL_LINEAR,
+ });
+
+ if (global.backend.get_monitor_manager().has_builtin_panel) {
+ items.push({
+ 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'),
+ configType: Meta.MonitorSwitchConfigType.EXTERNAL,
+ });
+ items.push({
+ icon: 'computer-symbolic',
+ /* Translators: this is for using only the laptop display.
+ * Try to keep it under around 15 characters.
+ */
+ label: _('Built-in Only'),
+ configType: Meta.MonitorSwitchConfigType.BUILTIN,
+ });
+ }
+
+ 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) % this._items.length;
+ 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();
+
+ const monitorManager = global.backend.get_monitor_manager();
+ const item = this._items[this._selectedIndex];
+
+ monitorManager.switch_config(item.configType);
+ }
+});
+
+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) {
+ const box = new St.BoxLayout({
+ style_class: 'alt-tab-app',
+ vertical: true,
+ });
+
+ const 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..4b0479b
--- /dev/null
+++ b/js/ui/switcherPopup.js
@@ -0,0 +1,688 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported SwitcherPopup, SwitcherList */
+
+const { Clutter, GLib, GObject, 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);
+
+ Main.layoutManager.connectObject(
+ 'system-modal-opened', () => this.destroy(), this);
+
+ 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;
+
+ let grab = Main.pushModal(this);
+ // We expect at least a keyboard grab here
+ if ((grab.get_seat_state() & Clutter.GrabState.KEYBOARD) === 0) {
+ Main.popModal(grab);
+ return false;
+ }
+ this._grab = grab;
+ 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._grab);
+ this._grab = null;
+ 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();
+
+ 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._onItemMotion(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));
+ }
+
+ _onItemMotion(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..24c8160
--- /dev/null
+++ b/js/ui/unlockDialog.js
@@ -0,0 +1,899 @@
+// -*- 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._settings = new Gio.Settings({
+ schema_id: 'org.gnome.desktop.notifications',
+ });
+
+ this._sources = new Map();
+ Main.messageTray.getSources().forEach(source => {
+ this._sourceAdded(Main.messageTray, source, true);
+ });
+ this._updateVisibility();
+
+ Main.messageTray.connectObject('source-added',
+ this._sourceAdded.bind(this), this);
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ _onDestroy() {
+ 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}`,
+ 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>${n.title}</b> ${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);
+ }
+
+ _wakeUpScreenForSource(source) {
+ if (!this._settings.get_boolean('show-banners'))
+ return;
+ const obj = this._sources.get(source);
+ if (obj?.sourceBox.visible)
+ this.emit('wake-up-screen');
+ }
+
+ _sourceAdded(tray, source, initial) {
+ let obj = {
+ visible: source.policy.showInLockScreen,
+ detailed: this._shouldShowDetails(source),
+ 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);
+
+ source.connectObject(
+ 'notify::count', () => this._countChanged(source, obj),
+ 'notify::title', () => this._titleChanged(source, obj),
+ 'destroy', () => {
+ this._removeSource(source, obj);
+ this._updateVisibility();
+ }, this);
+ obj.policyChangedId = source.policy.connect('notify', (policy, pspec) => {
+ if (pspec.name === 'show-in-lock-screen')
+ this._visibleChanged(source, obj);
+ else
+ this._detailedChanged(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();
+ this._wakeUpScreenForSource(source);
+ }
+ }
+
+ _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}`;
+ obj.countLabel.visible = count > 1;
+ }
+
+ obj.sourceBox.visible = obj.visible && (source.unseenCount > 0);
+
+ this._updateVisibility();
+ this._wakeUpScreenForSource(source);
+ }
+
+ _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();
+ this._wakeUpScreenForSource(source);
+ }
+
+ _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);
+ }
+
+ _removeSource(source, obj) {
+ obj.sourceBox.destroy();
+ obj.sourceBox = obj.titleLabel = obj.countLabel = null;
+
+ 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._seat.connectObject('notify::touch-mode',
+ this._updateHint.bind(this), this);
+
+ this._monitorManager = Meta.MonitorManager.get();
+ this._monitorManager.connectObject('power-save-mode-changed',
+ () => (this._hint.opacity = 0), this);
+
+ this._idleMonitor = global.backend.get_core_idle_monitor();
+ 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._idleMonitor.remove_watch(this._idleWatchId);
+ }
+});
+
+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: 'unlock-dialog',
+ visible: false,
+ reactive: true,
+ });
+
+ parentActor.add_child(this);
+
+ this._gdmClient = new Gdm.Client();
+
+ try {
+ this._gdmClient.set_enabled_extensions([
+ Gdm.UserVerifierChoiceList.interface_info().name,
+ ]);
+ } catch (e) {
+ }
+
+ 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,
+ Clutter.Orientation.VERTICAL,
+ 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);
+ themeContext.connectObject('notify::scale-factor',
+ () => this._updateBackgroundEffects(), this);
+
+ this._updateBackgrounds();
+ Main.layoutManager.connectObject('monitors-changed',
+ this._updateBackgrounds.bind(this), 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'),
+ button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE,
+ reactive: false,
+ opacity: 0,
+ x_align: Clutter.ActorAlign.END,
+ y_align: Clutter.ActorAlign.END,
+ 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._screenSaverSettings.connectObject('changed::user-switch-enabled',
+ this._updateUserSwitchVisibility.bind(this), this);
+
+ this._lockdownSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.lockdown' });
+ this._lockdownSettings.connect('changed::disable-user-switching',
+ this._updateUserSwitchVisibility.bind(this));
+
+ this._user.connectObject('notify::is-loaded',
+ this._updateUserSwitchVisibility.bind(this), 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 = global.backend.get_core_idle_monitor();
+ 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;
+ }
+
+ vfunc_captured_event(event) {
+ if (Main.keyboard.maybeHandleEvent(event))
+ return Clutter.EVENT_STOP;
+
+ 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) {
+ 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._gdmClient) {
+ this._gdmClient = null;
+ delete this._gdmClient;
+ }
+ }
+
+ _updateUserSwitchVisibility() {
+ this._otherUserButton.visible = this._userManager.can_switch() &&
+ this._screenSaverSettings.get_boolean('user-switch-enabled') &&
+ !this._lockdownSettings.get_boolean('disable-user-switching');
+ }
+
+ cancel() {
+ if (this._authPrompt)
+ this._authPrompt.cancel();
+ }
+
+ finish(onComplete) {
+ if (!this._authPrompt) {
+ onComplete();
+ return;
+ }
+
+ this._authPrompt.finish(onComplete);
+ }
+
+ open(timestamp) {
+ this.show();
+
+ if (this._isModal)
+ return true;
+
+ let modalParams = {
+ timestamp,
+ actionMode: Shell.ActionMode.UNLOCK_SCREEN,
+ };
+ let grab = Main.pushModal(Main.uiGroup, modalParams);
+ if (grab.get_seat_state() !== Clutter.GrabState.ALL) {
+ Main.popModal(grab);
+ return false;
+ }
+
+ this._grab = grab;
+ this._isModal = true;
+
+ return true;
+ }
+
+ activate() {
+ this._showPrompt();
+ }
+
+ popModal(timestamp) {
+ if (this._isModal) {
+ Main.popModal(this._grab, timestamp);
+ this._grab = null;
+ this._isModal = false;
+ }
+ }
+});
diff --git a/js/ui/userWidget.js b/js/ui/userWidget.js
new file mode 100644
index 0000000..76139e1
--- /dev/null
+++ b/js/ui/userWidget.js
@@ -0,0 +1,212 @@
+// -*- 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.
+ themeContext.connectObject('notify::scale-factor', this.update.bind(this), 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();
+ }
+
+ 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.add_style_class_name('user-avatar');
+ 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._user.connectObject(
+ 'notify::is-loaded', this._updateUser.bind(this),
+ 'changed', this._updateUser.bind(this), this);
+ this._updateUser();
+ }
+
+ 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._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._user.connectObject(
+ 'notify::is-loaded', this._updateUser.bind(this),
+ 'changed', this._updateUser.bind(this), this);
+ } else {
+ this._label = new St.Label({
+ style_class: 'user-widget-label',
+ text: 'Empty User',
+ opacity: 0,
+ });
+ this.add_child(this._label);
+ }
+
+ this._updateUser();
+ }
+
+ _updateUser() {
+ this._avatar.update();
+ }
+});
diff --git a/js/ui/welcomeDialog.js b/js/ui/welcomeDialog.js
new file mode 100644
index 0000000..63c6d90
--- /dev/null
+++ b/js/ui/welcomeDialog.js
@@ -0,0 +1,64 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported WelcomeDialog */
+
+const { Clutter, GObject, Shell, St } = imports.gi;
+
+const Config = imports.misc.config;
+const Dialog = imports.ui.dialog;
+const Main = imports.ui.main;
+const ModalDialog = imports.ui.modalDialog;
+
+var DialogResponse = {
+ NO_THANKS: 0,
+ TAKE_TOUR: 1,
+};
+
+var WelcomeDialog = GObject.registerClass(
+class WelcomeDialog extends ModalDialog.ModalDialog {
+ _init() {
+ super._init({ styleClass: 'welcome-dialog' });
+
+ const appSystem = Shell.AppSystem.get_default();
+ this._tourAppInfo = appSystem.lookup_app('org.gnome.Tour.desktop');
+
+ this._buildLayout();
+ }
+
+ open() {
+ if (!this._tourAppInfo)
+ return false;
+
+ return super.open();
+ }
+
+ _buildLayout() {
+ const [majorVersion] = Config.PACKAGE_VERSION.split('.');
+ const title = _('Welcome to GNOME %s').format(majorVersion);
+ const description = _('If you want to learn your way around, check out the tour.');
+ const content = new Dialog.MessageDialogContent({ title, description });
+
+ const icon = new St.Widget({ style_class: 'welcome-dialog-image' });
+ content.insert_child_at_index(icon, 0);
+
+ this.contentLayout.add_child(content);
+
+ this.addButton({
+ label: _('No Thanks'),
+ action: () => this._sendResponse(DialogResponse.NO_THANKS),
+ key: Clutter.KEY_Escape,
+ });
+ this.addButton({
+ label: _('Take Tour'),
+ action: () => this._sendResponse(DialogResponse.TAKE_TOUR),
+ });
+ }
+
+ _sendResponse(response) {
+ if (response === DialogResponse.TAKE_TOUR) {
+ this._tourAppInfo.launch(0, -1, Shell.AppLaunchGpu.APP_PREF);
+ Main.overview.hide();
+ }
+
+ this.close();
+ }
+});
diff --git a/js/ui/windowAttentionHandler.js b/js/ui/windowAttentionHandler.js
new file mode 100644
index 0000000..8da3049
--- /dev/null
+++ b/js/ui/windowAttentionHandler.js
@@ -0,0 +1,100 @@
+// -*- 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();
+ global.display.connectObject(
+ 'window-demands-attention', this._onWindowDemandsAttention.bind(this),
+ 'window-marked-urgent', this._onWindowDemandsAttention.bind(this),
+ 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);
+
+ window.connectObject('notify::title', () => {
+ [title, banner] = this._getTitleAndBanner(app, window);
+ notification.update(title, banner);
+ }, source);
+ }
+};
+
+var WindowAttentionSource = GObject.registerClass(
+class WindowAttentionSource extends MessageTray.Source {
+ _init(app, window) {
+ this._window = window;
+ this._app = app;
+
+ super._init(app.get_name());
+
+ this._window.connectObject(
+ 'notify::demands-attention', this._sync.bind(this),
+ 'notify::urgent', this._sync.bind(this),
+ 'focus', () => this.destroy(),
+ 'unmanaged', () => this.destroy(), this);
+ }
+
+ _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) {
+ this._window.disconnectObject(this);
+
+ 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..d415412
--- /dev/null
+++ b/js/ui/windowManager.js
@@ -0,0 +1,1927 @@
+// -*- 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 SwitchMonitor = imports.ui.switchMonitor;
+const IBusManager = imports.misc.ibusManager;
+const WorkspaceAnimation = imports.ui.workspaceAnimation;
+
+const { loadInterfaceXML } = imports.misc.fileUtils;
+
+var SHELL_KEYBINDINGS_SCHEMA = 'org.gnome.shell.keybindings';
+var MINIMIZE_WINDOW_ANIMATION_TIME = 400;
+var MINIMIZE_WINDOW_ANIMATION_MODE = Clutter.AnimationMode.EASE_OUT_EXPO;
+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 SCROLL_TIMEOUT_TIME = 150;
+var DIM_BRIGHTNESS = -0.3;
+var DIM_TIME = 500;
+var UNDIM_TIME = 250;
+var APP_MOTION_THRESHOLD = 30;
+
+var ONE_SECOND = 1000; // in ms
+
+var MIN_NUM_WORKSPACES = 2;
+
+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');
+Gio._promisify(Shell, 'util_stop_systemd_unit');
+
+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,
+ });
+ }
+
+ _syncEnabled(dimmed) {
+ let animating = this.actor.get_transition(`@effects.${this.name}.brightness`) !== null;
+
+ this.enabled = Meta.prefs_get_attach_modal_dialogs() && (animating || dimmed);
+ }
+
+ setDimmed(dimmed, animate) {
+ let val = 127 * (1 + (dimmed ? 1 : 0) * DIM_BRIGHTNESS);
+ let color = Clutter.Color.new(val, val, val, 255);
+
+ this.actor.ease_property(`@effects.${this.name}.brightness`, color, {
+ mode: Clutter.AnimationMode.LINEAR,
+ duration: (dimmed ? DIM_TIME : UNDIM_TIME) * (animate ? 1 : 0),
+ onStopped: () => this._syncEnabled(dimmed),
+ });
+
+ this._syncEnabled(dimmed);
+ }
+});
+
+function getWindowDimmer(actor) {
+ let effect = actor.get_effect(WINDOW_DIMMER_EFFECT_NAME);
+
+ if (!effect) {
+ 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));
+
+ 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);
+ }
+
+ // Enforce minimum number of workspaces
+ while (emptyWorkspaces.length < MIN_NUM_WORKSPACES) {
+ 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 (workspaceManager.n_workspaces === MIN_NUM_WORKSPACES)
+ break;
+ 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();
+ }
+
+ _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++) {
+ this._workspaces[w].connectObject(
+ 'window-added', this._queueCheckWorkspaces.bind(this),
+ 'window-removed', this._windowRemoved.bind(this), 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.disconnectObject(this));
+ }
+
+ 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) {
+ const 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._dimmedWindows = [];
+
+ this._skippedActors = new Set();
+
+ this._allowedKeybindings = {};
+
+ this._isWorkspacePrepended = false;
+ this._canScroll = true; // limiting scrolling speed
+
+ 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));
+
+ 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.stage.connect('scroll-event', (stage, event) => {
+ const allowedModes = Shell.ActionMode.NORMAL;
+ if ((allowedModes & Main.actionMode) === 0)
+ return Clutter.EVENT_PROPAGATE;
+
+ if (this._workspaceAnimation.canHandleScrollEvent(event))
+ return Clutter.EVENT_PROPAGATE;
+
+ if ((event.get_state() & global.display.compositor_modifiers) === 0)
+ return Clutter.EVENT_PROPAGATE;
+
+ return this.handleWorkspaceScroll(event);
+ });
+
+ 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 ?? '');
+ }
+
+ this._gsdWacomProxy?.SetOLEDLabelsAsync(
+ pad.get_device_node(), labels).catch(logError);
+ });
+
+ global.display.connect('init-xserver', (display, task) => {
+ IBusManager.getIBusManager().restartDaemon(['--xim']);
+
+ this._startX11Services(task);
+
+ 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]);
+ });
+ Main.overview.connect('hiding', () => {
+ for (let i = 0; i < this._dimmedWindows.length; i++)
+ this._dimWindow(this._dimmedWindows[i]);
+ });
+
+ this._windowMenuManager = new WindowMenu.WindowMenuManager();
+
+ if (Main.sessionMode.hasWorkspaces)
+ this._workspaceTracker = new WorkspaceTracker(this);
+
+ let appSwitchAction = new AppSwitchAction();
+ appSwitchAction.connect('activated', this._switchApp.bind(this));
+ global.stage.add_action_full('app-switch', Clutter.EventPhase.CAPTURE, appSwitchAction);
+
+ let mode = Shell.ActionMode.NORMAL;
+ 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);
+ updateUnfullscreenGesture();
+
+ global.stage.add_action_full('unfullscreen', Clutter.EventPhase.CAPTURE, topDragAction);
+
+ this._workspaceAnimation =
+ new WorkspaceAnimation.WorkspaceAnimationController();
+
+ this._shellwm.connect('kill-switch-workspace', () => {
+ this._workspaceAnimation.cancelSwitchAnimation();
+ this._switchWorkspaceDone();
+ });
+ }
+
+ async _startX11Services(task) {
+ let status = true;
+ try {
+ await Shell.util_start_systemd_unit(
+ 'gnome-session-x11-services-ready.target', 'fail', null);
+ } 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: ${e.message}`);
+ status = false;
+ }
+ } finally {
+ task.return_boolean(status);
+ }
+ }
+
+ 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: ${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() {
+ const overviewOpen = Main.overview.visible && !Main.overview.closing;
+ return !(overviewOpen || this._workspaceAnimation.gestureActive);
+ }
+
+ _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) {
+ const 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: MINIMIZE_WINDOW_ANIMATION_MODE,
+ 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: MINIMIZE_WINDOW_ANIMATION_MODE,
+ 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) {
+ const 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: MINIMIZE_WINDOW_ANIMATION_MODE,
+ 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_buffer_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: MINIMIZE_WINDOW_ANIMATION_MODE,
+ 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 = actor.paint_to_content(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 ${actor}`);
+ this._shellwm.completed_size_change(actor);
+ }
+
+ actor.connectObject('destroy',
+ () => this._clearAnimationInfo(actor), actorClone);
+
+ this._resizePending.add(actor);
+ actor.__animationInfo = {
+ clone: actorClone,
+ oldRect: oldFrameRect,
+ frozen: true,
+ };
+ }
+
+ _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();
+ 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);
+ }
+ }
+
+ _checkDimming(window) {
+ const shouldDim = window.has_attached_dialogs();
+
+ 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());
+ }
+
+ _waitForOverviewToHide() {
+ if (!Main.overview.visible)
+ return Promise.resolve();
+
+ return new Promise(resolve => {
+ const id = Main.overview.connect('hidden', () => {
+ Main.overview.disconnect(id);
+ resolve();
+ });
+ });
+ }
+
+ async _mapWindow(shellwm, actor) {
+ actor._windowType = actor.meta_window.get_window_type();
+ actor.meta_window.connectObject('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);
+ 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());
+
+ const 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);
+
+ await this._waitForOverviewToHide();
+ 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);
+
+ await this._waitForOverviewToHide();
+ 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;
+ window.disconnectObject(actor);
+ if (window._dimmed) {
+ this._dimmedWindows =
+ this._dimmedWindows.filter(win => win != window);
+ }
+
+ if (window.is_attached_dialog())
+ this._checkDimming(window.get_transient_for());
+
+ const 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();
+ parent.connectObject('unmanaged', () => {
+ actor.remove_all_transitions();
+ this._destroyWindowDone(shellwm, actor);
+ }, 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();
+ parent?.disconnectObject(actor);
+ 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);
+ }
+
+ _switchWorkspace(shellwm, from, to, direction) {
+ if (!Main.sessionMode.hasWorkspaces || !this._shouldAnimate()) {
+ shellwm.completed_switch_workspace();
+ return;
+ }
+
+ this._switchInProgress = true;
+
+ this._workspaceAnimation.animateSwitch(from, to, direction, () => {
+ this._shellwm.completed_switch_workspace();
+ this._switchInProgress = false;
+ });
+ }
+
+ _switchWorkspaceDone() {
+ if (!this._switchInProgress)
+ return;
+
+ this._shellwm.completed_switch_workspace();
+ this._switchInProgress = false;
+ }
+
+ _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) {
+ Main.overview.hide();
+ 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(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._workspaceAnimation.movingWindow = window;
+ window.change_workspace(workspace);
+
+ global.display.clear_mouse_mode();
+ workspace.activate_with_focus(window, global.get_current_time());
+ }
+ }
+
+ handleWorkspaceScroll(event) {
+ if (!this._canScroll)
+ return Clutter.EVENT_PROPAGATE;
+
+ if (event.type() !== Clutter.EventType.SCROLL)
+ return Clutter.EVENT_PROPAGATE;
+
+ const direction = event.get_scroll_direction();
+ if (direction === Clutter.ScrollDirection.SMOOTH)
+ return Clutter.EVENT_PROPAGATE;
+
+ const workspaceManager = global.workspace_manager;
+ const vertical = workspaceManager.layout_rows === -1;
+ const rtl = Clutter.get_default_text_direction() === Clutter.TextDirection.RTL;
+ const activeWs = workspaceManager.get_active_workspace();
+ let ws;
+ switch (direction) {
+ case Clutter.ScrollDirection.UP:
+ if (vertical)
+ ws = activeWs.get_neighbor(Meta.MotionDirection.UP);
+ else if (rtl)
+ ws = activeWs.get_neighbor(Meta.MotionDirection.RIGHT);
+ else
+ ws = activeWs.get_neighbor(Meta.MotionDirection.LEFT);
+ break;
+ case Clutter.ScrollDirection.DOWN:
+ if (vertical)
+ ws = activeWs.get_neighbor(Meta.MotionDirection.DOWN);
+ else if (rtl)
+ ws = activeWs.get_neighbor(Meta.MotionDirection.LEFT);
+ else
+ ws = activeWs.get_neighbor(Meta.MotionDirection.RIGHT);
+ 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;
+ }
+ this.actionMoveWorkspace(ws);
+
+ this._canScroll = false;
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT,
+ SCROLL_TIMEOUT_TIME, () => {
+ this._canScroll = true;
+ return GLib.SOURCE_REMOVE;
+ });
+
+ return Clutter.EVENT_STOP;
+ }
+
+ _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..081645f
--- /dev/null
+++ b/js/ui/windowMenu.js
@@ -0,0 +1,252 @@
+// -*- 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;
+const Screenshot = imports.ui.screenshot;
+
+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;
+
+ // Translators: entry in the window right click menu.
+ item = this.addAction(_('Take Screenshot'), async () => {
+ try {
+ const actor = window.get_compositor_private();
+ const content = actor.paint_to_content(null);
+ const texture = content.get_texture();
+
+ await Screenshot.captureScreenshot(texture, null, 1, null);
+ } catch (e) {
+ logError(e, 'Error capturing screenshot');
+ }
+ });
+
+ item = this.addAction(_('Hide'), () => {
+ window.minimize();
+ });
+ if (!window.can_minimize())
+ item.setSensitive(false);
+
+ if (window.get_maximized()) {
+ item = this.addAction(_('Restore'), () => {
+ 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..c650414
--- /dev/null
+++ b/js/ui/windowPreview.js
@@ -0,0 +1,681 @@
+// -*- 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;
+const OverviewControls = imports.ui.overviewControls;
+
+var WINDOW_DND_SIZE = 256;
+
+var WINDOW_OVERLAY_IDLE_HIDE_TIMEOUT = 750;
+var WINDOW_OVERLAY_FADE_TIME = 200;
+
+var WINDOW_SCALE_TIME = 200;
+var WINDOW_ACTIVE_SIZE_INC = 5; // in each direction
+
+var DRAGGING_WINDOW_OPACITY = 100;
+
+const ICON_SIZE = 64;
+const ICON_OVERLAP = 0.7;
+
+const ICON_TITLE_SPACING = 6;
+
+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 Shell.WindowPreview {
+ _init(metaWindow, workspace, overviewAdjustment) {
+ this.metaWindow = metaWindow;
+ this.metaWindow._delegate = this;
+ this._windowActor = metaWindow.get_compositor_private();
+ this._workspace = workspace;
+ this._overviewAdjustment = overviewAdjustment;
+
+ super._init({
+ reactive: true,
+ can_focus: true,
+ accessible_role: Atk.Role.PUSH_BUTTON,
+ offscreen_redirect: Clutter.OffscreenRedirect.AUTOMATIC_FOR_OPACITY,
+ });
+
+ const windowContainer = new Clutter.Actor({
+ pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }),
+ });
+ this.window_container = windowContainer;
+
+ windowContainer.connect('notify::scale-x',
+ () => this._adjustOverlayOffsets());
+ // 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
+ windowContainer.layout_manager = new Shell.WindowPreviewLayout();
+ this.add_child(windowContainer);
+
+ this._addWindow(metaWindow);
+
+ this._delegate = this;
+
+ this._stackAbove = null;
+
+ this._cachedBoundingBox = {
+ x: windowContainer.layout_manager.bounding_box.x1,
+ y: windowContainer.layout_manager.bounding_box.y1,
+ width: windowContainer.layout_manager.bounding_box.get_width(),
+ height: windowContainer.layout_manager.bounding_box.get_height(),
+ };
+
+ windowContainer.layout_manager.connect(
+ 'notify::bounding-box', layout => {
+ this._cachedBoundingBox = {
+ x: layout.bounding_box.x1,
+ y: layout.bounding_box.y1,
+ width: layout.bounding_box.get_width(),
+ height: layout.bounding_box.get_height(),
+ };
+
+ // A bounding box of 0x0 means all windows were removed
+ if (layout.bounding_box.get_area() > 0)
+ this.emit('size-changed');
+ });
+
+ this._windowActor.connectObject('destroy', () => this.destroy(), this);
+
+ 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._overlayShown = false;
+ this._closeRequested = false;
+ this._idleHideOverlayId = 0;
+
+ const tracker = Shell.WindowTracker.get_default();
+ const app = tracker.get_window_app(this.metaWindow);
+ this._icon = app.create_icon_texture(ICON_SIZE);
+ this._icon.add_style_class_name('icon-dropshadow');
+ this._icon.set({
+ reactive: true,
+ pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }),
+ });
+ this._icon.add_constraint(new Clutter.BindConstraint({
+ source: windowContainer,
+ coordinate: Clutter.BindCoordinate.POSITION,
+ }));
+ this._icon.add_constraint(new Clutter.AlignConstraint({
+ source: windowContainer,
+ align_axis: Clutter.AlignAxis.X_AXIS,
+ factor: 0.5,
+ }));
+ this._icon.add_constraint(new Clutter.AlignConstraint({
+ source: windowContainer,
+ align_axis: Clutter.AlignAxis.Y_AXIS,
+ pivot_point: new Graphene.Point({ x: -1, y: ICON_OVERLAP }),
+ factor: 1,
+ }));
+
+ const { scaleFactor } = St.ThemeContext.get_for_stage(global.stage);
+ this._title = new St.Label({
+ visible: false,
+ style_class: 'window-caption',
+ text: this._getCaption(),
+ reactive: true,
+ });
+ this._title.clutter_text.single_line_mode = true;
+ this._title.add_constraint(new Clutter.BindConstraint({
+ source: windowContainer,
+ coordinate: Clutter.BindCoordinate.X,
+ }));
+ const iconBottomOverlap = ICON_SIZE * (1 - ICON_OVERLAP);
+ this._title.add_constraint(new Clutter.BindConstraint({
+ source: windowContainer,
+ coordinate: Clutter.BindCoordinate.Y,
+ offset: scaleFactor * (iconBottomOverlap + ICON_TITLE_SPACING),
+ }));
+ this._title.add_constraint(new Clutter.AlignConstraint({
+ source: windowContainer,
+ align_axis: Clutter.AlignAxis.X_AXIS,
+ factor: 0.5,
+ }));
+ this._title.add_constraint(new Clutter.AlignConstraint({
+ source: windowContainer,
+ align_axis: Clutter.AlignAxis.Y_AXIS,
+ pivot_point: new Graphene.Point({ x: -1, y: 0 }),
+ factor: 1,
+ }));
+ this._title.clutter_text.ellipsize = Pango.EllipsizeMode.END;
+ this.label_actor = this._title;
+ this.metaWindow.connectObject(
+ 'notify::title', () => (this._title.text = this._getCaption()),
+ this);
+
+ 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',
+ icon_name: 'preview-close-symbolic',
+ });
+ this._closeButton.add_constraint(new Clutter.BindConstraint({
+ source: windowContainer,
+ coordinate: Clutter.BindCoordinate.POSITION,
+ }));
+ this._closeButton.add_constraint(new Clutter.AlignConstraint({
+ source: windowContainer,
+ 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: windowContainer,
+ 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._title);
+ this.add_child(this._icon);
+ this.add_child(this._closeButton);
+
+ this._overviewAdjustment.connectObject(
+ 'notify::value', () => this._updateIconScale(), this);
+ this._updateIconScale();
+
+ this.connect('notify::realized', () => {
+ if (!this.realized)
+ return;
+
+ this._title.ensure_style();
+ this._icon.ensure_style();
+ });
+ }
+
+ _updateIconScale() {
+ const { ControlsState } = OverviewControls;
+ const { currentState, initialState, finalState } =
+ this._overviewAdjustment.getStateTransitionParams();
+ const visible =
+ initialState === ControlsState.WINDOW_PICKER ||
+ finalState === ControlsState.WINDOW_PICKER;
+ const scale = visible
+ ? 1 - Math.abs(ControlsState.WINDOW_PICKER - currentState) : 0;
+
+ this._icon.set({
+ scale_x: scale,
+ scale_y: scale,
+ });
+ }
+
+ _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();
+ }
+
+ overlapHeights() {
+ const [, titleHeight] = this._title.get_preferred_height(-1);
+
+ const topOverlap = 0;
+ const bottomOverlap = ICON_TITLE_SPACING + titleHeight;
+
+ return [topOverlap, bottomOverlap];
+ }
+
+ chromeHeights() {
+ const [, closeButtonHeight] = this._closeButton.get_preferred_height(-1);
+ const [, iconHeight] = this._icon.get_preferred_height(-1);
+ const { scaleFactor } = St.ThemeContext.get_for_stage(global.stage);
+ const activeExtraSize = WINDOW_ACTIVE_SIZE_INC * scaleFactor;
+
+ const topOversize = closeButtonHeight / 2;
+ const bottomOversize = (1 - ICON_OVERLAP) * iconHeight;
+
+ return [
+ topOversize + activeExtraSize,
+ bottomOversize + activeExtraSize,
+ ];
+ }
+
+ chromeWidths() {
+ const [, closeButtonWidth] = this._closeButton.get_preferred_width(-1);
+ const { scaleFactor } = St.ThemeContext.get_for_stage(global.stage);
+ const activeExtraSize = WINDOW_ACTIVE_SIZE_INC * scaleFactor;
+
+ const leftOversize = this._closeButtonSide === St.Side.LEFT
+ ? closeButtonWidth / 2
+ : 0;
+ const rightOversize = this._closeButtonSide === St.Side.LEFT
+ ? 0
+ : closeButtonWidth / 2;
+
+ return [
+ leftOversize + activeExtraSize,
+ rightOversize + activeExtraSize,
+ ];
+ }
+
+ showOverlay(animate) {
+ if (!this._overlayEnabled)
+ return;
+
+ if (this._overlayShown)
+ return;
+
+ this._overlayShown = true;
+ this._restack();
+
+ // If we're supposed to animate and an animation in our direction
+ // is already happening, let that one continue
+ const ongoingTransition = this._title.get_transition('opacity');
+ if (animate &&
+ ongoingTransition &&
+ ongoingTransition.get_interval().peek_final_value() === 255)
+ return;
+
+ const toShow = this._windowCanClose()
+ ? [this._title, this._closeButton]
+ : [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,
+ });
+ });
+
+ const [width, height] = this.window_container.get_size();
+ const { scaleFactor } = St.ThemeContext.get_for_stage(global.stage);
+ const activeExtraSize = WINDOW_ACTIVE_SIZE_INC * 2 * scaleFactor;
+ const origSize = Math.max(width, height);
+ const scale = (origSize + activeExtraSize) / origSize;
+
+ this.window_container.ease({
+ scale_x: scale,
+ scale_y: scale,
+ duration: animate ? WINDOW_SCALE_TIME : 0,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+
+ this.emit('show-chrome');
+ }
+
+ hideOverlay(animate) {
+ if (!this._overlayShown)
+ return;
+
+ this._overlayShown = false;
+ this._restack();
+
+ // If we're supposed to animate and an animation in our direction
+ // is already happening, let that one continue
+ const ongoingTransition = this._title.get_transition('opacity');
+ if (animate &&
+ ongoingTransition &&
+ ongoingTransition.get_interval().peek_final_value() === 0)
+ return;
+
+ [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(),
+ });
+ });
+
+ this.window_container.ease({
+ scale_x: 1,
+ scale_y: 1,
+ duration: animate ? WINDOW_SCALE_TIME : 0,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ _adjustOverlayOffsets() {
+ // Assume that scale-x and scale-y update always set
+ // in lock-step; that allows us to not use separate
+ // handlers for horizontal and vertical offsets
+ const previewScale = this.window_container.scale_x;
+ const [previewWidth, previewHeight] =
+ this.window_container.allocation.get_size();
+
+ const heightIncrease =
+ Math.floor(previewHeight * (previewScale - 1) / 2);
+ const widthIncrease =
+ Math.floor(previewWidth * (previewScale - 1) / 2);
+
+ const closeAlign = this._closeButtonSide === St.Side.LEFT ? -1 : 1;
+
+ this._icon.translation_y = heightIncrease;
+ this._title.translation_y = heightIncrease;
+ this._closeButton.set({
+ translation_x: closeAlign * widthIncrease,
+ translation_y: -heightIncrease,
+ });
+ }
+
+ _addWindow(metaWindow) {
+ const clone = this.window_container.layout_manager.add_window(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() ||
+ this._icon.visible ||
+ this._closeButton.visible;
+ }
+
+ _deleteAll() {
+ const windows = this.window_container.layout_manager.get_windows();
+
+ // 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.window_container.layout_manager.get_windows().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() {
+ return { ...this._cachedBoundingBox };
+ }
+
+ get windowCenter() {
+ return {
+ x: this._cachedBoundingBox.x + this._cachedBoundingBox.width / 2,
+ y: this._cachedBoundingBox.y + this._cachedBoundingBox.height / 2,
+ };
+ }
+
+ get overlayEnabled() {
+ return this._overlayEnabled;
+ }
+
+ set overlayEnabled(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.metaWindow._delegate = null;
+ this._delegate = null;
+ this._destroyed = true;
+
+ 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._destroyed)
+ return super.vfunc_leave_event(crossingEvent);
+
+ if ((crossingEvent.flags & Clutter.EventFlags.FLAG_GRAB_NOTIFY) !== 0 &&
+ global.stage.get_grab_actor() === this._closeButton)
+ return super.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();
+
+ if (global.stage.get_grab_actor() !== this._closeButton)
+ 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) {
+ this._selected = false;
+ 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;
+ }
+
+ _restack() {
+ // 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.
+ const parent = this.get_parent();
+ if (parent !== null) {
+ if (this._overlayShown)
+ parent.set_child_above_sibling(this, null);
+ else if (this._stackAbove === null)
+ parent.set_child_below_sibling(this, null);
+ else if (!this._stackAbove._overlayShown)
+ parent.set_child_above_sibling(this, this._stackAbove);
+ }
+ }
+
+ _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;
+
+ this._restack();
+
+ 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..bf631a5
--- /dev/null
+++ b/js/ui/workspace.js
@@ -0,0 +1,1457 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported Workspace */
+
+const {Clutter, GLib, GObject, Graphene, Meta, Shell, St} = imports.gi;
+
+const Background = imports.ui.background;
+const DND = imports.ui.dnd;
+const Main = imports.ui.main;
+const OverviewControls = imports.ui.overviewControls;
+const Params = imports.misc.params;
+const Util = imports.misc.util;
+const { WindowPreview } = imports.ui.windowPreview;
+
+var WINDOW_PREVIEW_MAXIMUM_SCALE = 0.95;
+
+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;
+
+const BACKGROUND_CORNER_RADIUS_PIXELS = 30;
+
+// 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(params) {
+ params = Params.parse(params, {
+ monitor: null,
+ rowSpacing: 0,
+ columnSpacing: 0,
+ });
+
+ if (!params.monitor)
+ throw new Error(`No monitor param passed to ${this.constructor.name}`);
+
+ this._monitor = params.monitor;
+ this._rowSpacing = params.rowSpacing;
+ this._columnSpacing = params.columnSpacing;
+ }
+
+ // Compute a strategy-specific overall layout given a list of WindowPreviews
+ // @windows and the strategy-specific @layoutParams.
+ //
+ // Returns a strategy-specific layout object that is opaque to the user.
+ computeLayout(_windows, _layoutParams) {
+ throw new GObject.NotImplementedError(`computeLayout in ${this.constructor.name}`);
+ }
+
+ // Given @layout and @area, compute the overall scale of the layout and
+ // space occupied by the layout.
+ //
+ // This method returns an array where the first element is the scale and
+ // the second element is the space.
+ //
+ // This method must be called before calling computeWindowSlots(), as it
+ // sets the fixed overall scale of the layout.
+ computeScaleAndSpace(_layout, _area) {
+ throw new GObject.NotImplementedError(`computeScaleAndSpace in ${this.constructor.name}`);
+ }
+
+ // Returns an array with final position and size information for each
+ // window of the layout, given a bounding area that it will be inside of.
+ computeWindowSlots(_layout, _area) {
+ throw new GObject.NotImplementedError(`computeWindowSlots in ${this.constructor.name}`);
+ }
+};
+
+var UnalignedLayoutStrategy = class extends LayoutStrategy {
+ _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 Util.lerp(1.5, 1, ratio);
+ }
+
+ _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, layoutParams) {
+ layoutParams = Params.parse(layoutParams, {
+ numRows: 0,
+ });
+
+ if (layoutParams.numRows === 0)
+ throw new Error(`${this.constructor.name}: No numRows given in layout params`);
+
+ const numRows = layoutParams.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;
+ }
+
+ return {
+ numRows,
+ rows,
+ maxColumns: maxRow.windows.length,
+ gridWidth: maxRow.fullWidth,
+ gridHeight,
+ };
+ }
+
+ computeScaleAndSpace(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;
+
+ return [scale, 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++) {
+ const row = rows[i];
+ const rowY = row.y + compensation;
+ const rowHeight = row.height * row.additionalScale;
+
+ 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;
+
+ // If there's only one row, align windows vertically centered inside the row
+ if (rows.length === 1)
+ cloneY = rowY + (rowHeight - cloneHeight) / 2;
+ // If there are multiple rows, align windows to the bottom edge of the row
+ else
+ cloneY = rowY + rowHeight - cellHeight;
+
+ // 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;
+ }
+};
+
+function animateAllocation(actor, box) {
+ 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, overviewAdjustment) {
+ super._init();
+
+ this._spacing = 20;
+ this._layoutFrozen = false;
+
+ this._metaWorkspace = metaWorkspace;
+ this._monitorIndex = monitorIndex;
+ this._overviewAdjustment = overviewAdjustment;
+
+ this._container = null;
+ this._windows = new Map();
+ this._sortedWindows = [];
+ this._lastBox = null;
+ this._windowSlots = [];
+ this._layout = null;
+
+ this._needsLayout = true;
+
+ this._stateAdjustment = new St.Adjustment({
+ value: 0,
+ lower: 0,
+ upper: 1,
+ });
+
+ this._stateAdjustment.connect('notify::value', () => {
+ this._syncOpacities();
+ this.syncOverlays();
+ this.layout_changed();
+ });
+
+ this._workarea = null;
+ this._workareasChangedId = 0;
+ }
+
+ _syncOpacity(actor, metaWindow) {
+ if (!metaWindow.showing_on_its_workspace())
+ actor.opacity = this._stateAdjustment.value * 255;
+ }
+
+ _syncOpacities() {
+ this._windows.forEach(({ metaWindow }, actor) => {
+ this._syncOpacity(actor, metaWindow);
+ });
+ }
+
+ _isBetterScaleAndSpace(oldScale, oldSpace, scale, space) {
+ let spacePower = (space - oldSpace) * LAYOUT_SPACE_WEIGHT;
+ let scalePower = (scale - oldScale) * LAYOUT_SCALE_WEIGHT;
+
+ if (scale > oldScale && space > oldSpace) {
+ // Win win -- better scale and better space
+ return true;
+ } else if (scale > oldScale && space <= oldSpace) {
+ // Keep new layout only if scale gain outweighs aspect space loss
+ return scalePower > spacePower;
+ } else if (scale <= oldScale && space > oldSpace) {
+ // 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 [rowSpacing, colSpacing, 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();
+
+ const oversize =
+ Math.max(topOversize, bottomOversize, leftOversize, rightOversize);
+
+ if (rowSpacing !== null)
+ rowSpacing += oversize;
+ if (colSpacing !== null)
+ colSpacing += oversize;
+
+ if (containerBox) {
+ const monitor = Main.layoutManager.monitors[this._monitorIndex];
+
+ const bottomPoint = new Graphene.Point3D({ y: containerBox.y2 });
+ const transformedBottomPoint =
+ this._container.apply_transform_to_point(bottomPoint);
+ const bottomFreeSpace =
+ (monitor.y + monitor.height) - transformedBottomPoint.y;
+
+ const [, bottomOverlap] = window.overlapHeights();
+
+ if ((bottomOverlap + oversize) > bottomFreeSpace)
+ containerBox.y2 -= (bottomOverlap + oversize) - bottomFreeSpace;
+ }
+
+ return [rowSpacing, colSpacing, containerBox];
+ }
+
+ _createBestLayout(area) {
+ const [rowSpacing, columnSpacing] =
+ 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.
+ this._layoutStrategy = new UnalignedLayoutStrategy({
+ monitor: Main.layoutManager.monitors[this._monitorIndex],
+ rowSpacing,
+ columnSpacing,
+ });
+
+ let lastLayout = null;
+ let lastNumColumns = -1;
+ let lastScale = 0;
+ let lastSpace = 0;
+
+ for (let numRows = 1; ; numRows++) {
+ const 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 === lastNumColumns)
+ break;
+
+ const layout = this._layoutStrategy.computeLayout(this._sortedWindows, {
+ numRows,
+ });
+
+ const [scale, space] = this._layoutStrategy.computeScaleAndSpace(layout, area);
+
+ if (lastLayout && !this._isBetterScaleAndSpace(lastScale, lastSpace, scale, space))
+ break;
+
+ lastLayout = layout;
+ lastNumColumns = numColumns;
+ lastScale = scale;
+ lastSpace = space;
+ }
+
+ 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._layoutStrategy.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;
+ }
+
+ _syncWorkareaTracking() {
+ if (this._container) {
+ if (this._workAreaChangedId)
+ return;
+ this._workarea = Main.layoutManager.getWorkAreaForMonitor(this._monitorIndex);
+ this._workareasChangedId =
+ global.display.connect('workareas-changed', () => {
+ this._workarea = Main.layoutManager.getWorkAreaForMonitor(this._monitorIndex);
+ this.layout_changed();
+ });
+ } else if (this._workareasChangedId) {
+ global.display.disconnect(this._workareasChangedId);
+ this._workareasChangedId = 0;
+ }
+ }
+
+ vfunc_set_container(container) {
+ this._container = container;
+ this._syncWorkareaTracking();
+ 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 [containerWidth, containerHeight] = containerBox.get_size();
+ const containerAllocationChanged =
+ this._lastBox === null || !this._lastBox.equal(containerBox);
+
+ // 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');
+ }
+
+ const { ControlsState } = OverviewControls;
+ const { currentState } =
+ this._overviewAdjustment.getStateTransitionParams();
+ const inSessionTransition = currentState <= ControlsState.WINDOW_PICKER;
+
+ const window = this._sortedWindows[0];
+
+ if (inSessionTransition || !window) {
+ container.remove_clip();
+ } else {
+ const [, bottomOversize] = window.chromeHeights();
+ const [containerX, containerY] = containerBox.get_origin();
+
+ const extraHeightProgress =
+ currentState - OverviewControls.ControlsState.WINDOW_PICKER;
+
+ const extraClipHeight = bottomOversize * (1 - extraHeightProgress);
+
+ container.set_clip(containerX, containerY,
+ containerWidth, containerHeight + extraClipHeight);
+ }
+
+ let layoutChanged = false;
+ if (!this._layoutFrozen || !this._lastBox) {
+ if (this._needsLayout) {
+ this._layout = this._createBestLayout(this._workarea);
+ this._needsLayout = false;
+ layoutChanged = true;
+ }
+
+ if (layoutChanged || containerAllocationChanged) {
+ this._windowSlotsBox = box.copy();
+ this._windowSlots = this._getWindowSlots(this._windowSlotsBox);
+ }
+ }
+
+ const slotsScale = box.get_width() / this._windowSlotsBox.get_width();
+ const workareaX = this._workarea.x;
+ const workareaY = this._workarea.y;
+ const workareaWidth = this._workarea.width;
+ const stateAdjustementValue = this._stateAdjustment.value;
+
+ const allocationScale = containerWidth / workareaWidth;
+
+ const childBox = new Clutter.ActorBox();
+
+ const nSlots = this._windowSlots.length;
+ for (let i = 0; i < nSlots; i++) {
+ let [x, y, width, height, child] = this._windowSlots[i];
+ if (!child.visible)
+ continue;
+
+ x *= slotsScale;
+ y *= slotsScale;
+ width *= slotsScale;
+ height *= slotsScale;
+
+ const windowInfo = this._windows.get(child);
+
+ let workspaceBoxX, workspaceBoxY;
+ let workspaceBoxWidth, workspaceBoxHeight;
+
+ if (windowInfo.metaWindow.showing_on_its_workspace()) {
+ workspaceBoxX = (child.boundingBox.x - workareaX) * allocationScale;
+ workspaceBoxY = (child.boundingBox.y - workareaY) * allocationScale;
+ workspaceBoxWidth = child.boundingBox.width * allocationScale;
+ workspaceBoxHeight = child.boundingBox.height * allocationScale;
+ } else {
+ workspaceBoxX = workareaX * allocationScale;
+ workspaceBoxY = workareaY * allocationScale;
+ workspaceBoxWidth = 0;
+ workspaceBoxHeight = 0;
+ }
+
+ // Don't allow the scaled floating size to drop below
+ // the target layout size.
+ // We only want to apply this when the scaled floating size is
+ // actually larger than the target layout size, that is while
+ // animating between the session and the window picker.
+ if (inSessionTransition) {
+ workspaceBoxWidth = Math.max(workspaceBoxWidth, width);
+ workspaceBoxHeight = Math.max(workspaceBoxHeight, height);
+ }
+
+ x = Util.lerp(workspaceBoxX, x, stateAdjustementValue);
+ y = Util.lerp(workspaceBoxY, y, stateAdjustementValue);
+ width = Util.lerp(workspaceBoxWidth, width, stateAdjustementValue);
+ height = Util.lerp(workspaceBoxHeight, height, stateAdjustementValue);
+
+ childBox.set_origin(x, y);
+ childBox.set_size(width, height);
+
+ 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);
+ }
+ }
+
+ this._lastBox = containerBox.copy();
+ }
+
+ _syncOverlay(preview) {
+ const active = this._metaWorkspace?.active ?? true;
+ preview.overlayEnabled = active && this._stateAdjustment.value === 1;
+ }
+
+ /**
+ * syncOverlays:
+ *
+ * Synchronizes the overlay state of all window previews.
+ */
+ syncOverlays() {
+ [...this._windows.keys()].forEach(preview => this._syncOverlay(preview));
+ }
+
+ /**
+ * 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._needsLayout = true;
+ 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._syncOpacity(window, metaWindow);
+ this._syncOverlay(window);
+ this._container.add_child(window);
+
+ this._needsLayout = true;
+ 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._needsLayout = true;
+ 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._needsLayout = true;
+ 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._needsLayout = true;
+ this.notify('spacing');
+ this.layout_changed();
+ }
+
+ get layoutFrozen() {
+ return this._layoutFrozen;
+ }
+
+ set layoutFrozen(f) {
+ if (this._layoutFrozen === f)
+ return;
+
+ this._layoutFrozen = f;
+
+ this.notify('layout-frozen');
+ if (!this._layoutFrozen)
+ this.layout_changed();
+ }
+});
+
+var WorkspaceBackground = GObject.registerClass(
+class WorkspaceBackground extends Shell.WorkspaceBackground {
+ _init(monitorIndex, stateAdjustment) {
+ super._init({
+ style_class: 'workspace-background',
+ x_expand: true,
+ y_expand: true,
+ monitor_index: monitorIndex,
+ });
+
+ this._monitorIndex = monitorIndex;
+ this._workarea = Main.layoutManager.getWorkAreaForMonitor(monitorIndex);
+
+ this._stateAdjustment = stateAdjustment;
+ this._stateAdjustment.connectObject('notify::value', () => {
+ this._updateBorderRadius();
+ this.queue_relayout();
+ }, this);
+ this._stateAdjustment.bind_property(
+ 'value', this, 'state-adjustment-value',
+ GObject.BindingFlags.SYNC_CREATE
+ );
+
+ this._bin = new Clutter.Actor({
+ layout_manager: new Clutter.BinLayout(),
+ clip_to_allocation: true,
+ });
+
+ this._backgroundGroup = new Meta.BackgroundGroup({
+ layout_manager: new Clutter.BinLayout(),
+ x_expand: true,
+ y_expand: true,
+ });
+ this._bin.add_child(this._backgroundGroup);
+ this.add_child(this._bin);
+
+ this._bgManager = new Background.BackgroundManager({
+ container: this._backgroundGroup,
+ monitorIndex: this._monitorIndex,
+ controlPosition: false,
+ useContentSize: false,
+ });
+
+ this._bgManager.connect('changed', () => {
+ this._updateRoundedClipBounds();
+ this._updateBorderRadius();
+ });
+
+ global.display.connectObject('workareas-changed', () => {
+ this._workarea = Main.layoutManager.getWorkAreaForMonitor(monitorIndex);
+ this._updateRoundedClipBounds();
+ this.queue_relayout();
+ }, this);
+ this._updateRoundedClipBounds();
+
+ this._updateBorderRadius();
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ _updateBorderRadius() {
+ const { scaleFactor } = St.ThemeContext.get_for_stage(global.stage);
+ const cornerRadius = scaleFactor * BACKGROUND_CORNER_RADIUS_PIXELS;
+
+ const backgroundContent = this._bgManager.backgroundActor.content;
+ backgroundContent.rounded_clip_radius =
+ Util.lerp(0, cornerRadius, this._stateAdjustment.value);
+ }
+
+ _updateRoundedClipBounds() {
+ const monitor = Main.layoutManager.monitors[this._monitorIndex];
+
+ const rect = new Graphene.Rect();
+ rect.origin.x = this._workarea.x - monitor.x;
+ rect.origin.y = this._workarea.y - monitor.y;
+ rect.size.width = this._workarea.width;
+ rect.size.height = this._workarea.height;
+
+ this._bgManager.backgroundActor.content.set_rounded_clip_bounds(rect);
+ }
+
+ _onDestroy() {
+ if (this._bgManager) {
+ this._bgManager.destroy();
+ this._bgManager = null;
+ }
+ }
+});
+
+/**
+ * @metaWorkspace: a #Meta.Workspace, or null
+ */
+var Workspace = GObject.registerClass(
+class Workspace extends St.Widget {
+ _init(metaWorkspace, monitorIndex, overviewAdjustment) {
+ super._init({
+ style_class: 'window-picker',
+ pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }),
+ layout_manager: new Clutter.BinLayout(),
+ });
+
+ const layoutManager = new WorkspaceLayout(metaWorkspace, monitorIndex,
+ overviewAdjustment);
+
+ // Background
+ this._background =
+ new WorkspaceBackground(monitorIndex, layoutManager.stateAdjustment);
+ this.add_child(this._background);
+
+ // Window previews
+ this._container = new Clutter.Actor({
+ reactive: true,
+ x_expand: true,
+ y_expand: true,
+ });
+ this._container.layout_manager = layoutManager;
+ this.add_child(this._container);
+
+ this.metaWorkspace = metaWorkspace;
+
+ this._overviewAdjustment = overviewAdjustment;
+
+ this.monitorIndex = monitorIndex;
+ this._monitor = Main.layoutManager.monitors[this.monitorIndex];
+
+ if (monitorIndex != Main.layoutManager.primaryIndex)
+ this.add_style_class_name('external-monitor');
+
+ const clickAction = new Clutter.ClickAction();
+ clickAction.connect('clicked', action => {
+ // Switch to the workspace when not the active one, leave the
+ // overview otherwise.
+ if (action.get_button() === 1 || action.get_button() === 0) {
+ const leaveOverview = this._shouldLeaveOverview();
+
+ this.metaWorkspace?.activate(global.get_current_time());
+ if (leaveOverview)
+ Main.overview.hide();
+ }
+ });
+ this.bind_property('mapped', clickAction, 'enabled', GObject.BindingFlags.SYNC_CREATE);
+ this._container.add_action(clickAction);
+
+ this.connect('style-changed', this._onStyleChanged.bind(this));
+ this.connect('destroy', this._onDestroy.bind(this));
+
+ this._skipTaskbarSignals = new Map();
+ 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, but let the window tracker process them first
+ this.metaWorkspace?.connectObject(
+ 'window-added', this._windowAdded.bind(this), GObject.ConnectFlags.AFTER,
+ 'window-removed', this._windowRemoved.bind(this), GObject.ConnectFlags.AFTER,
+ 'notify::active', () => layoutManager.syncOverlays(), this);
+ global.display.connectObject(
+ 'window-entered-monitor', this._windowEnteredMonitor.bind(this), GObject.ConnectFlags.AFTER,
+ 'window-left-monitor', this._windowLeftMonitor.bind(this), GObject.ConnectFlags.AFTER,
+ this);
+ this._layoutFrozenId = 0;
+
+ // DND requires this to be set
+ this._delegate = this;
+ }
+
+ _shouldLeaveOverview() {
+ if (!this.metaWorkspace || this.metaWorkspace.active)
+ return true;
+
+ const overviewState = this._overviewAdjustment.value;
+ return overviewState > OverviewControls.ControlsState.WINDOW_PICKER;
+ }
+
+ vfunc_get_focus_chain() {
+ return this._container.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._container.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._container.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._container.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;
+
+ this._skipTaskbarSignals.set(metaWin,
+ metaWin.connect('notify::skip-taskbar', () => {
+ if (metaWin.skip_taskbar)
+ this._doRemoveWindow(metaWin);
+ else
+ this._doAddWindow(metaWin);
+ }));
+
+ 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._container.layout_manager.layout_frozen = false;
+
+ GLib.source_remove(this._layoutFrozenId);
+ this._layoutFrozenId = 0;
+ }
+ }
+
+ _windowAdded(metaWorkspace, metaWin) {
+ if (!Main.overview.closing)
+ this._doAddWindow(metaWin);
+ }
+
+ _windowRemoved(metaWorkspace, metaWin) {
+ this._doRemoveWindow(metaWin);
+ }
+
+ _windowEnteredMonitor(metaDisplay, monitorIndex, metaWin) {
+ if (monitorIndex === this.monitorIndex && !Main.overview.closing)
+ 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;
+ }
+
+ _clearSkipTaskbarSignals() {
+ for (const [metaWin, id] of this._skipTaskbarSignals)
+ metaWin.disconnect(id);
+ this._skipTaskbarSignals.clear();
+ }
+
+ prepareToLeaveOverview() {
+ this._clearSkipTaskbarSignals();
+
+ 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._container.layout_manager.layout_frozen = true;
+ Main.overview.connectObject(
+ 'hidden', this._doneLeavingOverview.bind(this), this);
+ }
+
+ _onDestroy() {
+ this._clearSkipTaskbarSignals();
+
+ if (this._layoutFrozenId > 0) {
+ GLib.source_remove(this._layoutFrozenId);
+ this._layoutFrozenId = 0;
+ }
+
+ this._windows = [];
+ }
+
+ _doneLeavingOverview() {
+ this._container.layout_manager.layout_frozen = false;
+ }
+
+ _doneShowingOverview() {
+ this._container.layout_manager.layout_frozen = false;
+ }
+
+ _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, this._overviewAdjustment);
+
+ 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._container.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._container.layout_manager.removeWindow(this._windows[index]);
+
+ return this._windows.splice(index, 1).pop();
+ }
+
+ _onStyleChanged() {
+ const themeNode = this.get_theme_node();
+ this._container.layout_manager.spacing = themeNode.get_length('spacing');
+ }
+
+ _onCloneSelected(clone, time) {
+ const wsIndex = this.metaWorkspace?.index();
+
+ if (this._shouldLeaveOverview())
+ Main.activateWindow(clone.metaWindow, time, wsIndex);
+ else
+ this.metaWorkspace?.activate(time);
+ }
+
+ // 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;
+
+ Main.moveWindowToMonitorAndWorkspace(window,
+ this.monitorIndex, workspaceIndex);
+ 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;
+ }
+
+ get stateAdjustment() {
+ return this._container.layout_manager.stateAdjustment;
+ }
+});
diff --git a/js/ui/workspaceAnimation.js b/js/ui/workspaceAnimation.js
new file mode 100644
index 0000000..432044f
--- /dev/null
+++ b/js/ui/workspaceAnimation.js
@@ -0,0 +1,496 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported WorkspaceAnimationController, WorkspaceGroup */
+
+const { Clutter, GObject, Meta, Shell, St } = imports.gi;
+
+const Background = imports.ui.background;
+const Layout = imports.ui.layout;
+const Main = imports.ui.main;
+const SwipeTracker = imports.ui.swipeTracker;
+
+const WINDOW_ANIMATION_TIME = 250;
+const WORKSPACE_SPACING = 100;
+
+var WorkspaceGroup = GObject.registerClass(
+class WorkspaceGroup extends Clutter.Actor {
+ _init(workspace, monitor, movingWindow) {
+ super._init();
+
+ this._workspace = workspace;
+ this._monitor = monitor;
+ this._movingWindow = movingWindow;
+ this._windowRecords = [];
+
+ if (this._workspace) {
+ this._background = new Meta.BackgroundGroup();
+
+ this.add_actor(this._background);
+
+ this._bgManager = new Background.BackgroundManager({
+ container: this._background,
+ monitorIndex: this._monitor.index,
+ controlPosition: false,
+ });
+ }
+
+ this.width = monitor.width;
+ this.height = monitor.height;
+ this.clip_to_allocation = true;
+
+ this._createWindows();
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ global.display.connectObject('restacked',
+ this._syncStacking.bind(this), this);
+ }
+
+ get workspace() {
+ return this._workspace;
+ }
+
+ _shouldShowWindow(window) {
+ if (!window.showing_on_its_workspace())
+ return false;
+
+ const geometry = global.display.get_monitor_geometry(this._monitor.index);
+ const [intersects] = window.get_frame_rect().intersect(geometry);
+ if (!intersects)
+ return false;
+
+ const isSticky =
+ window.is_on_all_workspaces() || window === this._movingWindow;
+
+ // No workspace means we should show windows that are on all workspaces
+ if (!this._workspace)
+ return isSticky;
+
+ // Otherwise only show windows that are (only) on that workspace
+ return !isSticky && window.located_on_workspace(this._workspace);
+ }
+
+ _syncStacking() {
+ const windowActors = global.get_window_actors().filter(w =>
+ this._shouldShowWindow(w.meta_window));
+
+ let lastRecord;
+ const bottomActor = this._background ?? null;
+
+ for (const windowActor of windowActors) {
+ const record = this._windowRecords.find(r => r.windowActor === windowActor);
+
+ this.set_child_above_sibling(record.clone,
+ lastRecord ? lastRecord.clone : bottomActor);
+ lastRecord = record;
+ }
+ }
+
+ _createWindows() {
+ const windowActors = global.get_window_actors().filter(w =>
+ this._shouldShowWindow(w.meta_window));
+
+ for (const windowActor of windowActors) {
+ const clone = new Clutter.Clone({
+ source: windowActor,
+ x: windowActor.x - this._monitor.x,
+ y: windowActor.y - this._monitor.y,
+ });
+
+ this.add_child(clone);
+
+ const record = { windowActor, clone };
+
+ windowActor.connectObject('destroy', () => {
+ clone.destroy();
+ this._windowRecords.splice(this._windowRecords.indexOf(record), 1);
+ }, this);
+
+ this._windowRecords.push(record);
+ }
+ }
+
+ _removeWindows() {
+ for (const record of this._windowRecords)
+ record.clone.destroy();
+
+ this._windowRecords = [];
+ }
+
+ _onDestroy() {
+ this._removeWindows();
+
+ if (this._workspace)
+ this._bgManager.destroy();
+ }
+});
+
+const MonitorGroup = GObject.registerClass({
+ Properties: {
+ 'progress': GObject.ParamSpec.double(
+ 'progress', 'progress', 'progress',
+ GObject.ParamFlags.READWRITE,
+ -Infinity, Infinity, 0),
+ },
+}, class MonitorGroup extends St.Widget {
+ _init(monitor, workspaceIndices, movingWindow) {
+ super._init({
+ clip_to_allocation: true,
+ style_class: 'workspace-animation',
+ });
+
+ this._monitor = monitor;
+
+ const constraint = new Layout.MonitorConstraint({ index: monitor.index });
+ this.add_constraint(constraint);
+
+ this._container = new Clutter.Actor();
+ this.add_child(this._container);
+
+ const stickyGroup = new WorkspaceGroup(null, monitor, movingWindow);
+ this.add_child(stickyGroup);
+
+ this._workspaceGroups = [];
+
+ const workspaceManager = global.workspace_manager;
+ const vertical = workspaceManager.layout_rows === -1;
+ const activeWorkspace = workspaceManager.get_active_workspace();
+
+ let x = 0;
+ let y = 0;
+
+ for (const i of workspaceIndices) {
+ const ws = workspaceManager.get_workspace_by_index(i);
+ const fullscreen = ws.list_windows().some(w => w.get_monitor() === monitor.index && w.is_fullscreen());
+
+ if (i > 0 && vertical && !fullscreen && monitor.index === Main.layoutManager.primaryIndex) {
+ // 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.
+ y -= Main.panel.height;
+ }
+
+ const group = new WorkspaceGroup(ws, monitor, movingWindow);
+
+ this._workspaceGroups.push(group);
+ this._container.add_child(group);
+ group.set_position(x, y);
+
+ if (vertical)
+ y += this.baseDistance;
+ else if (Clutter.get_default_text_direction() === Clutter.TextDirection.RTL)
+ x -= this.baseDistance;
+ else
+ x += this.baseDistance;
+ }
+
+ this.progress = this.getWorkspaceProgress(activeWorkspace);
+ }
+
+ get baseDistance() {
+ const spacing = WORKSPACE_SPACING * St.ThemeContext.get_for_stage(global.stage).scale_factor;
+
+ if (global.workspace_manager.layout_rows === -1)
+ return this._monitor.height + spacing;
+ else
+ return this._monitor.width + spacing;
+ }
+
+ get progress() {
+ if (global.workspace_manager.layout_rows === -1)
+ return -this._container.y / this.baseDistance;
+ else if (this.get_text_direction() === Clutter.TextDirection.RTL)
+ return this._container.x / this.baseDistance;
+ else
+ return -this._container.x / this.baseDistance;
+ }
+
+ set progress(p) {
+ if (global.workspace_manager.layout_rows === -1)
+ this._container.y = -Math.round(p * this.baseDistance);
+ else if (this.get_text_direction() === Clutter.TextDirection.RTL)
+ this._container.x = Math.round(p * this.baseDistance);
+ else
+ this._container.x = -Math.round(p * this.baseDistance);
+ }
+
+ get index() {
+ return this._monitor.index;
+ }
+
+ getWorkspaceProgress(workspace) {
+ const group = this._workspaceGroups.find(g =>
+ g.workspace.index() === workspace.index());
+ return this._getWorkspaceGroupProgress(group);
+ }
+
+ _getWorkspaceGroupProgress(group) {
+ if (global.workspace_manager.layout_rows === -1)
+ return group.y / this.baseDistance;
+ else if (this.get_text_direction() === Clutter.TextDirection.RTL)
+ return -group.x / this.baseDistance;
+ else
+ return group.x / this.baseDistance;
+ }
+
+ getSnapPoints() {
+ return this._workspaceGroups.map(g =>
+ this._getWorkspaceGroupProgress(g));
+ }
+
+ findClosestWorkspace(progress) {
+ const distances = this.getSnapPoints().map(p =>
+ Math.abs(p - progress));
+ const index = distances.indexOf(Math.min(...distances));
+ return this._workspaceGroups[index].workspace;
+ }
+
+ _interpolateProgress(progress, monitorGroup) {
+ if (this.index === monitorGroup.index)
+ return progress;
+
+ const points1 = monitorGroup.getSnapPoints();
+ const points2 = this.getSnapPoints();
+
+ const upper = points1.indexOf(points1.find(p => p >= progress));
+ const lower = points1.indexOf(points1.slice().reverse().find(p => p <= progress));
+
+ if (points1[upper] === points1[lower])
+ return points2[upper];
+
+ const t = (progress - points1[lower]) / (points1[upper] - points1[lower]);
+
+ return points2[lower] + (points2[upper] - points2[lower]) * t;
+ }
+
+ updateSwipeForMonitor(progress, monitorGroup) {
+ this.progress = this._interpolateProgress(progress, monitorGroup);
+ }
+});
+
+var WorkspaceAnimationController = class {
+ constructor() {
+ this._movingWindow = null;
+ this._switchData = null;
+
+ Main.overview.connect('showing', () => {
+ if (this._switchData) {
+ if (this._switchData.gestureActivated)
+ this._finishWorkspaceSwitch(this._switchData);
+ this._swipeTracker.enabled = false;
+ }
+ });
+ Main.overview.connect('hiding', () => {
+ this._swipeTracker.enabled = true;
+ });
+
+ const swipeTracker = new SwipeTracker.SwipeTracker(global.stage,
+ Clutter.Orientation.HORIZONTAL,
+ Shell.ActionMode.NORMAL,
+ { allowDrag: 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;
+
+ global.display.bind_property('compositor-modifiers',
+ this._swipeTracker, 'scroll-modifiers',
+ GObject.BindingFlags.SYNC_CREATE);
+ }
+
+ _prepareWorkspaceSwitch(workspaceIndices) {
+ if (this._switchData)
+ return;
+
+ const workspaceManager = global.workspace_manager;
+ const nWorkspaces = workspaceManager.get_n_workspaces();
+
+ const switchData = {};
+
+ this._switchData = switchData;
+ switchData.monitors = [];
+
+ switchData.gestureActivated = false;
+ switchData.inProgress = false;
+
+ if (!workspaceIndices)
+ workspaceIndices = [...Array(nWorkspaces).keys()];
+
+ const monitors = Meta.prefs_get_workspaces_only_on_primary()
+ ? [Main.layoutManager.primaryMonitor] : Main.layoutManager.monitors;
+
+ for (const monitor of monitors) {
+ if (Meta.prefs_get_workspaces_only_on_primary() &&
+ monitor.index !== Main.layoutManager.primaryIndex)
+ continue;
+
+ const group = new MonitorGroup(monitor, workspaceIndices, this.movingWindow);
+
+ Main.uiGroup.insert_child_above(group, global.window_group);
+
+ switchData.monitors.push(group);
+ }
+
+ Meta.disable_unredirect_for_display(global.display);
+ }
+
+ _finishWorkspaceSwitch(switchData) {
+ Meta.enable_unredirect_for_display(global.display);
+
+ this._switchData = null;
+
+ switchData.monitors.forEach(m => m.destroy());
+
+ this.movingWindow = null;
+ }
+
+ animateSwitch(from, to, direction, onComplete) {
+ this._swipeTracker.enabled = false;
+
+ let workspaceIndices = [];
+
+ switch (direction) {
+ case Meta.MotionDirection.UP:
+ case Meta.MotionDirection.LEFT:
+ case Meta.MotionDirection.UP_LEFT:
+ case Meta.MotionDirection.UP_RIGHT:
+ workspaceIndices = [to, from];
+ break;
+
+ case Meta.MotionDirection.DOWN:
+ case Meta.MotionDirection.RIGHT:
+ case Meta.MotionDirection.DOWN_LEFT:
+ case Meta.MotionDirection.DOWN_RIGHT:
+ workspaceIndices = [from, to];
+ break;
+ }
+
+ if (Clutter.get_default_text_direction() === Clutter.TextDirection.RTL &&
+ direction !== Meta.MotionDirection.UP &&
+ direction !== Meta.MotionDirection.DOWN)
+ workspaceIndices.reverse();
+
+ this._prepareWorkspaceSwitch(workspaceIndices);
+ this._switchData.inProgress = true;
+
+ const fromWs = global.workspace_manager.get_workspace_by_index(from);
+ const toWs = global.workspace_manager.get_workspace_by_index(to);
+
+ for (const monitorGroup of this._switchData.monitors) {
+ monitorGroup.progress = monitorGroup.getWorkspaceProgress(fromWs);
+ const progress = monitorGroup.getWorkspaceProgress(toWs);
+
+ const params = {
+ duration: WINDOW_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_CUBIC,
+ };
+
+ if (monitorGroup.index === Main.layoutManager.primaryIndex) {
+ params.onComplete = () => {
+ this._finishWorkspaceSwitch(this._switchData);
+ onComplete();
+ this._swipeTracker.enabled = true;
+ };
+ }
+
+ monitorGroup.ease_property('progress', progress, params);
+ }
+ }
+
+ canHandleScrollEvent(event) {
+ return this._swipeTracker.canHandleScrollEvent(event);
+ }
+
+ _findMonitorGroup(monitorIndex) {
+ return this._switchData.monitors.find(m => m.index === monitorIndex);
+ }
+
+ _switchWorkspaceBegin(tracker, monitor) {
+ if (Meta.prefs_get_workspaces_only_on_primary() &&
+ monitor !== Main.layoutManager.primaryIndex)
+ return;
+
+ const workspaceManager = global.workspace_manager;
+ const horiz = workspaceManager.layout_rows !== -1;
+ tracker.orientation = horiz
+ ? Clutter.Orientation.HORIZONTAL
+ : Clutter.Orientation.VERTICAL;
+
+ if (this._switchData && this._switchData.gestureActivated) {
+ for (const group of this._switchData.monitors)
+ group.remove_all_transitions();
+ } else {
+ this._prepareWorkspaceSwitch();
+ }
+
+ const monitorGroup = this._findMonitorGroup(monitor);
+ const baseDistance = monitorGroup.baseDistance;
+ const progress = monitorGroup.progress;
+
+ const closestWs = monitorGroup.findClosestWorkspace(progress);
+ const cancelProgress = monitorGroup.getWorkspaceProgress(closestWs);
+ const points = monitorGroup.getSnapPoints();
+
+ this._switchData.baseMonitorGroup = monitorGroup;
+
+ tracker.confirmSwipe(baseDistance, points, progress, cancelProgress);
+ }
+
+ _switchWorkspaceUpdate(tracker, progress) {
+ if (!this._switchData)
+ return;
+
+ for (const monitorGroup of this._switchData.monitors)
+ monitorGroup.updateSwipeForMonitor(progress, this._switchData.baseMonitorGroup);
+ }
+
+ _switchWorkspaceEnd(tracker, duration, endProgress) {
+ if (!this._switchData)
+ return;
+
+ const switchData = this._switchData;
+ switchData.gestureActivated = true;
+
+ const newWs = switchData.baseMonitorGroup.findClosestWorkspace(endProgress);
+ const endTime = Clutter.get_current_event_time();
+
+ for (const monitorGroup of this._switchData.monitors) {
+ const progress = monitorGroup.getWorkspaceProgress(newWs);
+
+ const params = {
+ duration,
+ mode: Clutter.AnimationMode.EASE_OUT_CUBIC,
+ };
+
+ if (monitorGroup.index === Main.layoutManager.primaryIndex) {
+ params.onComplete = () => {
+ if (!newWs.active)
+ newWs.activate(endTime);
+ this._finishWorkspaceSwitch(switchData);
+ };
+ }
+
+ monitorGroup.ease_property('progress', progress, params);
+ }
+ }
+
+ get gestureActive() {
+ return this._switchData !== null && this._switchData.gestureActivated;
+ }
+
+ cancelSwitchAnimation() {
+ if (!this._switchData)
+ return;
+
+ if (this._switchData.gestureActivated)
+ return;
+
+ this._finishWorkspaceSwitch(this._switchData);
+ }
+
+ set movingWindow(movingWindow) {
+ this._movingWindow = movingWindow;
+ }
+
+ get movingWindow() {
+ return this._movingWindow;
+ }
+};
diff --git a/js/ui/workspaceSwitcherPopup.js b/js/ui/workspaceSwitcherPopup.js
new file mode 100644
index 0000000..8744529
--- /dev/null
+++ b/js/ui/workspaceSwitcherPopup.js
@@ -0,0 +1,101 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported WorkspaceSwitcherPopup */
+
+const { Clutter, GLib, GObject, St } = imports.gi;
+
+const Layout = imports.ui.layout;
+const Main = imports.ui.main;
+
+var ANIMATION_TIME = 100;
+var DISPLAY_TIMEOUT = 600;
+
+
+var WorkspaceSwitcherPopup = GObject.registerClass(
+class WorkspaceSwitcherPopup extends Clutter.Actor {
+ _init() {
+ super._init({
+ offscreen_redirect: Clutter.OffscreenRedirect.ALWAYS,
+ x_expand: true,
+ y_expand: true,
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.END,
+ });
+
+ const constraint = new Layout.MonitorConstraint({ primary: true });
+ this.add_constraint(constraint);
+
+ Main.uiGroup.add_actor(this);
+
+ this._timeoutId = 0;
+
+ this._list = new St.BoxLayout({
+ style_class: 'workspace-switcher',
+ });
+ this.add_child(this._list);
+
+ this._redisplay();
+
+ this.hide();
+
+ let workspaceManager = global.workspace_manager;
+ workspaceManager.connectObject(
+ 'workspace-added', this._redisplay.bind(this),
+ 'workspace-removed', this._redisplay.bind(this), 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++) {
+ const indicator = new St.Bin({
+ style_class: 'ws-switcher-indicator',
+ });
+
+ if (i === this._activeWorkspaceIndex)
+ indicator.add_style_pseudo_class('active');
+
+ this._list.add_actor(indicator);
+ }
+ }
+
+ display(activeWorkspaceIndex) {
+ 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');
+
+ const duration = this.visible ? 0 : ANIMATION_TIME;
+ this.show();
+ this.opacity = 0;
+ this.ease({
+ opacity: 255,
+ duration,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
+
+ _onTimeout() {
+ GLib.source_remove(this._timeoutId);
+ this._timeoutId = 0;
+ this.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;
+ }
+});
diff --git a/js/ui/workspaceThumbnail.js b/js/ui/workspaceThumbnail.js
new file mode 100644
index 0000000..45b938f
--- /dev/null
+++ b/js/ui/workspaceThumbnail.js
@@ -0,0 +1,1436 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported WorkspaceThumbnail, ThumbnailsBox */
+
+const { Clutter, Gio, GLib, GObject, Graphene, Meta, Shell, St } = imports.gi;
+
+const DND = imports.ui.dnd;
+const Main = imports.ui.main;
+const { TransientSignalHolder } = imports.misc.signalTracker;
+const Util = imports.misc.util;
+const Workspace = imports.ui.workspace;
+
+const NUM_WORKSPACES_THRESHOLD = 2;
+
+// The maximum size of a thumbnail is 5% the width and height of the screen
+var MAX_THUMBNAIL_SCALE = 0.05;
+
+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;
+
+ this.realWindow.connectObject(
+ 'notify::position', this._onPositionChanged.bind(this),
+ '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);
+ 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);
+
+ realDialog.connectObject(
+ 'notify::position', dialog => this._updateDialogPosition(dialog, clone),
+ 'destroy', () => clone.destroy(), this);
+ 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);
+ }
+
+ _onDestroy() {
+ 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,
+ EXPANDING: 1,
+ EXPANDED: 2,
+ ANIMATING_IN: 3,
+ NORMAL: 4,
+ REMOVING: 5,
+ ANIMATING_OUT: 6,
+ ANIMATED_OUT: 7,
+ COLLAPSING: 8,
+ DESTROYED: 9,
+};
+
+/**
+ * @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, monitorIndex) {
+ super._init({
+ clip_to_allocation: true,
+ style_class: 'workspace-thumbnail',
+ pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }),
+ });
+ this._delegate = this;
+
+ this.metaWorkspace = metaWorkspace;
+ this.monitorIndex = monitorIndex;
+
+ this._removed = false;
+
+ this._viewport = new Clutter.Actor();
+ this.add_child(this._viewport);
+
+ this._contents = new Clutter.Actor();
+ this._viewport.add_child(this._contents);
+
+ this.connect('destroy', this._onDestroy.bind(this));
+
+ 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 = [];
+ for (let i = 0; i < windows.length; i++) {
+ windows[i].meta_window.connectObject('notify::minimized',
+ this._updateMinimized.bind(this), this);
+ this._allWindows.push(windows[i].meta_window);
+
+ if (this._isMyWindow(windows[i]) && this._isOverviewWindow(windows[i]))
+ this._addWindowClone(windows[i]);
+ }
+
+ // Track window changes
+ this.metaWorkspace.connectObject(
+ 'window-added', this._windowAdded.bind(this),
+ 'window-removed', this._windowRemoved.bind(this), this);
+ global.display.connectObject(
+ 'window-entered-monitor', this._windowEnteredMonitor.bind(this),
+ 'window-left-monitor', this._windowLeftMonitor.bind(this), this);
+
+ this.state = ThumbnailState.NORMAL;
+ this._slidePosition = 0; // Fully slid in
+ this._collapseFraction = 0; // Not collapsed
+ }
+
+ setPorthole(x, y, width, height) {
+ this._viewport.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 = 1; i < this._windows.length; i++) {
+ let clone = this._windows[i];
+ const previousClone = this._windows[i - 1];
+ clone.setStackAbove(previousClone);
+ }
+ }
+
+ set slidePosition(slidePosition) {
+ if (this._slidePosition == slidePosition)
+ return;
+
+ const scale = Util.lerp(1, 0.75, slidePosition);
+ this.set_scale(scale, scale);
+ this.opacity = Util.lerp(255, 0, slidePosition);
+
+ this._slidePosition = slidePosition;
+ this.notify('slide-position');
+ this.queue_relayout();
+ }
+
+ get slidePosition() {
+ return this._slidePosition;
+ }
+
+ set collapseFraction(collapseFraction) {
+ if (this._collapseFraction == collapseFraction)
+ return;
+ this._collapseFraction = collapseFraction;
+ this.notify('collapse-fraction');
+ this.queue_relayout();
+ }
+
+ get collapseFraction() {
+ 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)) {
+ metaWin.connectObject('notify::minimized',
+ this._updateMinimized.bind(this), this);
+ this._allWindows.push(metaWin);
+ }
+
+ // 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.disconnectObject(this);
+ this._allWindows.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.disconnectObject(this);
+ global.display.disconnectObject(this);
+ this._allWindows.forEach(w => w.disconnectObject(this));
+ }
+
+ _onDestroy() {
+ this.workspaceRemoved();
+ 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._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();
+ Main.moveWindowToMonitorAndWorkspace(metaWindow,
+ this.monitorIndex, this.metaWorkspace.index());
+ 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;
+ }
+
+ setScale(scaleX, scaleY) {
+ this._viewport.set_scale(scaleX, scaleY);
+ }
+});
+
+
+var ThumbnailsBox = GObject.registerClass({
+ Properties: {
+ 'expand-fraction': GObject.ParamSpec.double(
+ 'expand-fraction', 'expand-fraction', 'expand-fraction',
+ GObject.ParamFlags.READWRITE,
+ 0, 1, 1),
+ 'scale': GObject.ParamSpec.double(
+ 'scale', 'scale', 'scale',
+ GObject.ParamFlags.READWRITE,
+ 0, Infinity, 0),
+ 'should-show': GObject.ParamSpec.boolean(
+ 'should-show', 'should-show', 'should-show',
+ GObject.ParamFlags.READABLE,
+ true),
+ },
+}, class ThumbnailsBox extends St.Widget {
+ _init(scrollAdjustment, monitorIndex) {
+ super._init({
+ style_class: 'workspace-thumbnails',
+ reactive: true,
+ x_align: Clutter.ActorAlign.CENTER,
+ pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }),
+ });
+
+ 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);
+
+ this._monitorIndex = monitorIndex;
+
+ 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._expandFraction = 1;
+ this._updateStateId = 0;
+ this._pendingScaleUpdate = false;
+ this._animatingIndicator = false;
+
+ this._shouldShow = true;
+
+ this._stateCounts = {};
+ for (let key in ThumbnailState)
+ this._stateCounts[ThumbnailState[key]] = 0;
+
+ this._thumbnails = [];
+
+ Main.overview.connectObject(
+ 'showing', () => this._createThumbnails(),
+ 'hidden', () => this._destroyThumbnails(),
+ 'item-drag-begin', () => this._onDragBegin(),
+ 'item-drag-end', () => this._onDragEnd(),
+ 'item-drag-cancelled', () => this._onDragCancelled(),
+ 'window-drag-begin', () => this._onDragBegin(),
+ 'window-drag-end', () => this._onDragEnd(),
+ 'window-drag-cancelled', () => this._onDragCancelled(), this);
+
+ this._settings = new Gio.Settings({ schema_id: MUTTER_SCHEMA });
+ this._settings.connect('changed::dynamic-workspaces',
+ () => this._updateShouldShow());
+ this._updateShouldShow();
+
+ Main.layoutManager.connectObject('monitors-changed', () => {
+ this._destroyThumbnails();
+ if (Main.overview.visible)
+ this._createThumbnails();
+ }, this);
+
+ // The porthole is the part of the screen we're showing in the thumbnails
+ global.display.connectObject('workareas-changed',
+ () => this._updatePorthole(), this);
+ this._updatePorthole();
+
+ this.connect('notify::visible', () => {
+ if (!this.visible)
+ this._queueUpdateStates();
+ });
+ this.connect('destroy', () => this._onDestroy());
+
+ this._scrollAdjustment = scrollAdjustment;
+ this._scrollAdjustment.connectObject('notify::value',
+ () => this._updateIndicator(), this);
+ }
+
+ setMonitorIndex(monitorIndex) {
+ this._monitorIndex = monitorIndex;
+ }
+
+ _onDestroy() {
+ this._destroyThumbnails();
+ this._unqueueUpdateStates();
+
+ if (this._settings)
+ this._settings.run_dispose();
+ this._settings = null;
+ }
+
+ _updateShouldShow() {
+ const { nWorkspaces } = global.workspace_manager;
+ const shouldShow = this._settings.get_boolean('dynamic-workspaces')
+ ? nWorkspaces > NUM_WORKSPACES_THRESHOLD
+ : nWorkspaces > 1;
+
+ if (this._shouldShow === shouldShow)
+ return;
+
+ this._shouldShow = shouldShow;
+ this.notify('should-show');
+ }
+
+ _updateIndicator() {
+ const { value } = this._scrollAdjustment;
+ const { workspaceManager } = global;
+ const activeIndex = workspaceManager.get_active_workspace_index();
+
+ this._animatingIndicator = value !== activeIndex;
+
+ if (!this._animatingIndicator)
+ this._queueUpdateStates();
+
+ this.queue_relayout();
+ }
+
+ _activateThumbnailAtPoint(stageX, stageY, time) {
+ const [r_, x] = this.transform_stage_point(stageX, stageY);
+
+ const thumbnail = this._thumbnails.find(t => x >= t.x && x <= t.x + t.width);
+ 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();
+ }
+
+ _getPlaceholderTarget(index, spacing, rtl) {
+ const workspace = this._thumbnails[index];
+
+ let targetX1;
+ let targetX2;
+
+ if (rtl) {
+ const baseX = workspace.x + workspace.width;
+ targetX1 = baseX - WORKSPACE_CUT_SIZE;
+ targetX2 = baseX + spacing + WORKSPACE_CUT_SIZE;
+ } else {
+ targetX1 = workspace.x - spacing - WORKSPACE_CUT_SIZE;
+ targetX2 = workspace.x + WORKSPACE_CUT_SIZE;
+ }
+
+ if (index === 0) {
+ if (rtl)
+ targetX2 -= spacing + WORKSPACE_CUT_SIZE;
+ else
+ targetX1 += spacing + WORKSPACE_CUT_SIZE;
+ }
+
+ if (index === this._dropPlaceholderPos) {
+ const placeholderWidth = this._dropPlaceholder.get_width() + spacing;
+ if (rtl)
+ targetX2 += placeholderWidth;
+ else
+ targetX1 -= placeholderWidth;
+ }
+
+ return [targetX1, targetX2];
+ }
+
+ _withinWorkspace(x, index, rtl) {
+ const length = this._thumbnails.length;
+ const workspace = this._thumbnails[index];
+
+ let workspaceX1 = workspace.x + WORKSPACE_CUT_SIZE;
+ let workspaceX2 = workspace.x + workspace.width - WORKSPACE_CUT_SIZE;
+
+ if (index === length - 1) {
+ if (rtl)
+ workspaceX1 -= WORKSPACE_CUT_SIZE;
+ else
+ workspaceX2 += WORKSPACE_CUT_SIZE;
+ }
+
+ return x > workspaceX1 && x <= workspaceX2;
+ }
+
+ // 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;
+
+ const rtl = Clutter.get_default_text_direction() === Clutter.TextDirection.RTL;
+ let canCreateWorkspaces = Meta.prefs_get_dynamic_workspaces();
+ let spacing = this.get_theme_node().get_length('spacing');
+
+ this._dropWorkspace = -1;
+ let placeholderPos = -1;
+ let length = this._thumbnails.length;
+ for (let i = 0; i < length; i++) {
+ const index = rtl ? length - i - 1 : i;
+
+ if (canCreateWorkspaces && source !== Main.xdndHandler) {
+ const [targetStart, targetEnd] =
+ this._getPlaceholderTarget(index, spacing, rtl);
+
+ if (x > targetStart && x <= targetEnd) {
+ placeholderPos = index;
+ break;
+ }
+ }
+
+ if (this._withinWorkspace(x, index, rtl)) {
+ this._dropWorkspace = index;
+ break;
+ }
+ }
+
+ 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;
+ Main.moveWindowToMonitorAndWorkspace(source.metaWindow,
+ thumbMonitor, 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;
+ thumbnail.collapse_fraction = 1;
+
+ this._queueUpdateStates();
+
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ _createThumbnails() {
+ if (this._thumbnails.length > 0)
+ return;
+
+ const { workspaceManager } = global;
+ this._transientSignalHolder = new TransientSignalHolder(this);
+ workspaceManager.connectObject(
+ 'notify::n-workspaces', this._workspacesChanged.bind(this),
+ 'active-workspace-changed', () => this._updateIndicator(),
+ 'workspaces-reordered', () => {
+ this._thumbnails.sort((a, b) => {
+ return a.metaWorkspace.index() - b.metaWorkspace.index();
+ });
+ this.queue_relayout();
+ }, this._transientSignalHolder);
+ Main.overview.connectObject('windows-restacked',
+ this._syncStacking.bind(this), this._transientSignalHolder);
+
+ this._targetScale = 0;
+ this._scale = 0;
+ this._pendingScaleUpdate = false;
+ this._unqueueUpdateStates();
+
+ this._stateCounts = {};
+ for (let key in ThumbnailState)
+ this._stateCounts[ThumbnailState[key]] = 0;
+
+ this.addThumbnails(0, workspaceManager.n_workspaces);
+
+ this._updateShouldShow();
+ }
+
+ _destroyThumbnails() {
+ if (this._thumbnails.length == 0)
+ return;
+
+ this._transientSignalHolder.destroy();
+ delete this._transientSignalHolder;
+
+ 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._updateShouldShow();
+ }
+
+ 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, this._monitorIndex);
+ thumbnail.setPorthole(this._porthole.x, this._porthole.y,
+ this._porthole.width, this._porthole.height);
+ this._thumbnails.push(thumbnail);
+ this.add_actor(thumbnail);
+
+ if (this._shouldShow && 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
+ thumbnail.collapse_fraction = 1; // start fully collapsed
+ 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._updateStateId = 0;
+
+ // If we are animating the indicator, wait
+ if (this._animatingIndicator)
+ return;
+
+ // Likewise if we are in the process of hiding
+ if (!this._shouldShow && this.visible)
+ 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,
+ // collapse any removed thumbnails and expand added ones
+ 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();
+ },
+ });
+ });
+
+ this._iterateStateThumbnails(ThumbnailState.NEW, thumbnail => {
+ this._setThumbnailState(thumbnail, ThumbnailState.EXPANDING);
+ thumbnail.ease_property('collapse-fraction', 0, {
+ duration: SLIDE_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ this._setThumbnailState(thumbnail, ThumbnailState.EXPANDED);
+ 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 ||
+ this._stateCounts[ThumbnailState.EXPANDING] > 0)
+ return;
+
+ // And then slide in any new thumbnails
+ this._iterateStateThumbnails(ThumbnailState.EXPANDED, 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._updateStateId > 0)
+ return;
+
+ this._updateStateId = Meta.later_add(
+ Meta.LaterType.BEFORE_REDRAW, () => this._updateStates());
+ }
+
+ _unqueueUpdateStates() {
+ if (this._updateStateId)
+ Meta.later_remove(this._updateStateId);
+ this._updateStateId = 0;
+ }
+
+ vfunc_get_preferred_height(forWidth) {
+ let themeNode = this.get_theme_node();
+
+ forWidth = themeNode.adjust_for_width(forWidth);
+
+ let spacing = themeNode.get_length('spacing');
+ let nWorkspaces = this._thumbnails.length;
+ let totalSpacing = (nWorkspaces - 1) * spacing;
+
+ const avail = forWidth - totalSpacing;
+
+ let scale = (avail / nWorkspaces) / this._porthole.width;
+ scale = Math.min(scale, MAX_THUMBNAIL_SCALE);
+
+ const height = Math.round(this._porthole.height * scale);
+ return themeNode.adjust_preferred_height(height, height);
+ }
+
+ vfunc_get_preferred_width(_forHeight) {
+ // Note that for getPreferredHeight/Width 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 themeNode = this.get_theme_node();
+
+ let spacing = themeNode.get_length('spacing');
+ let nWorkspaces = this._thumbnails.length;
+ let totalSpacing = (nWorkspaces - 1) * spacing;
+
+ const naturalWidth = this._thumbnails.reduce((accumulator, thumbnail, index) => {
+ let workspaceSpacing = 0;
+
+ if (index > 0)
+ workspaceSpacing += spacing / 2;
+ if (index < this._thumbnails.length - 1)
+ workspaceSpacing += spacing / 2;
+
+ const progress = 1 - thumbnail.collapse_fraction;
+ const width = (this._porthole.width * MAX_THUMBNAIL_SCALE + workspaceSpacing) * progress;
+ return accumulator + width;
+ }, 0);
+
+ return themeNode.adjust_preferred_width(totalSpacing, naturalWidth);
+ }
+
+ _updatePorthole() {
+ if (!Main.layoutManager.monitors[this._monitorIndex]) {
+ const { x, y, width, height } = global.stage;
+ this._porthole = { x, y, width, height };
+ } else {
+ this._porthole =
+ Main.layoutManager.getWorkAreaForMonitor(this._monitorIndex);
+ }
+
+ 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 themeNode = this.get_theme_node();
+ box = themeNode.get_content_box(box);
+
+ const portholeWidth = this._porthole.width;
+ const portholeHeight = this._porthole.height;
+ const spacing = themeNode.get_length('spacing');
+
+ const nWorkspaces = this._thumbnails.length;
+
+ // Compute the scale we'll need once everything is updated,
+ // unless we are currently transitioning
+ if (this._expandFraction === 1) {
+ const totalSpacing = (nWorkspaces - 1) * spacing;
+ const availableWidth = (box.get_width() - totalSpacing) / nWorkspaces;
+
+ const hScale = availableWidth / portholeWidth;
+ const vScale = box.get_height() / portholeHeight;
+ const newScale = Math.min(hScale, vScale);
+
+ if (newScale !== this._targetScale) {
+ if (this._targetScale > 0) {
+ // We don't ease 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();
+ }
+ }
+
+ const ratio = portholeWidth / portholeHeight;
+ const thumbnailFullHeight = Math.round(portholeHeight * this._scale);
+ const thumbnailWidth = Math.round(thumbnailFullHeight * ratio);
+ const thumbnailHeight = thumbnailFullHeight * this._expandFraction;
+ const roundedVScale = thumbnailHeight / portholeHeight;
+
+ // We always request size for MAX_THUMBNAIL_SCALE, distribute
+ // space evently if we use smaller thumbnails
+ const extraWidth =
+ (MAX_THUMBNAIL_SCALE * portholeWidth - thumbnailWidth) * nWorkspaces;
+ box.x1 += Math.round(extraWidth / 2);
+ box.x2 -= Math.round(extraWidth / 2);
+
+ let indicatorValue = this._scrollAdjustment.value;
+ let indicatorUpperWs = Math.ceil(indicatorValue);
+ let indicatorLowerWs = Math.floor(indicatorValue);
+
+ let indicatorLowerX1 = 0;
+ let indicatorLowerX2 = 0;
+ let indicatorUpperX1 = 0;
+ let indicatorUpperX2 = 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 x = box.x1;
+
+ 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++) {
+ const thumbnail = this._thumbnails[i];
+ if (i > 0)
+ x += spacing - Math.round(thumbnail.collapse_fraction * spacing);
+
+ const y1 = box.y1;
+ const y2 = y1 + thumbnailHeight;
+
+ if (i === this._dropPlaceholderPos) {
+ const [, placeholderWidth] = this._dropPlaceholder.get_preferred_width(-1);
+ childBox.y1 = y1;
+ childBox.y2 = y2;
+
+ if (rtl) {
+ childBox.x2 = box.x2 - Math.round(x);
+ childBox.x1 = box.x2 - Math.round(x + placeholderWidth);
+ } else {
+ childBox.x1 = Math.round(x);
+ childBox.x2 = Math.round(x + placeholderWidth);
+ }
+
+ this._dropPlaceholder.allocate(childBox);
+
+ Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
+ this._dropPlaceholder.show();
+ });
+ x += placeholderWidth + spacing;
+ }
+
+ // We might end up with thumbnailWidth being something like 99.33
+ // pixels. To make this work and not end up with a gap at the end,
+ // we need some thumbnails to be 99 pixels and some 100 pixels width;
+ // we compute an actual scale separately for each thumbnail.
+ const x1 = Math.round(x);
+ const x2 = Math.round(x + thumbnailWidth);
+ const roundedHScale = (x2 - x1) / portholeWidth;
+
+ // Allocating a scaled actor is funny - x1/y1 correspond to the origin
+ // of the actor, but x2/y2 are increased by the *unscaled* size.
+ if (rtl) {
+ childBox.x2 = box.x2 - x1;
+ childBox.x1 = box.x2 - (x1 + thumbnailWidth);
+ } else {
+ childBox.x1 = x1;
+ childBox.x2 = x1 + thumbnailWidth;
+ }
+ childBox.y1 = y1;
+ childBox.y2 = y1 + thumbnailHeight;
+
+ thumbnail.setScale(roundedHScale, roundedVScale);
+ thumbnail.allocate(childBox);
+
+ if (i === indicatorUpperWs) {
+ indicatorUpperX1 = childBox.x1;
+ indicatorUpperX2 = childBox.x2;
+ }
+ if (i === indicatorLowerWs) {
+ indicatorLowerX1 = childBox.x1;
+ indicatorLowerX2 = childBox.x2;
+ }
+
+ // 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
+ x += thumbnailWidth - Math.round(thumbnailWidth * thumbnail.collapse_fraction);
+ }
+
+ childBox.y1 = box.y1;
+ childBox.y2 = box.y1 + thumbnailHeight;
+
+ const indicatorX1 = indicatorLowerX1 +
+ (indicatorUpperX1 - indicatorLowerX1) * (indicatorValue % 1);
+ const indicatorX2 = indicatorLowerX2 +
+ (indicatorUpperX2 - indicatorLowerX2) * (indicatorValue % 1);
+
+ childBox.x1 = indicatorX1 - indicatorLeftFullBorder;
+ childBox.x2 = indicatorX2 + indicatorRightFullBorder;
+ childBox.y1 -= indicatorTopFullBorder;
+ childBox.y2 += indicatorBottomFullBorder;
+ this._indicator.allocate(childBox);
+ }
+
+ get shouldShow() {
+ return this._shouldShow;
+ }
+
+ set expandFraction(expandFraction) {
+ if (this._expandFraction === expandFraction)
+ return;
+ this._expandFraction = expandFraction;
+ this.notify('expand-fraction');
+ this.queue_relayout();
+ }
+
+ get expandFraction() {
+ return this._expandFraction;
+ }
+});
diff --git a/js/ui/workspacesView.js b/js/ui/workspacesView.js
new file mode 100644
index 0000000..660fcf6
--- /dev/null
+++ b/js/ui/workspacesView.js
@@ -0,0 +1,1156 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported WorkspacesView, WorkspacesDisplay */
+
+const { Clutter, Gio, GObject, Meta, Shell, St } = imports.gi;
+
+const Layout = imports.ui.layout;
+const Main = imports.ui.main;
+const OverviewControls = imports.ui.overviewControls;
+const SwipeTracker = imports.ui.swipeTracker;
+const Util = imports.misc.util;
+const Workspace = imports.ui.workspace;
+const { ThumbnailsBox, MAX_THUMBNAIL_SCALE } = imports.ui.workspaceThumbnail;
+
+var WORKSPACE_SWITCH_TIME = 250;
+
+const MUTTER_SCHEMA = 'org.gnome.mutter';
+
+const WORKSPACE_MIN_SPACING = 24;
+const WORKSPACE_MAX_SPACING = 80;
+
+const WORKSPACE_INACTIVE_SCALE = 0.94;
+
+const SECONDARY_WORKSPACE_SCALE = 0.80;
+
+var WorkspacesViewBase = GObject.registerClass({
+ GTypeFlags: GObject.TypeFlags.ABSTRACT,
+}, class WorkspacesViewBase extends St.Widget {
+ _init(monitorIndex, overviewAdjustment) {
+ super._init({
+ style_class: 'workspaces-view',
+ x_expand: true,
+ y_expand: true,
+ });
+ this.connect('destroy', this._onDestroy.bind(this));
+ global.focus_manager.add_group(this);
+
+ this._monitorIndex = monitorIndex;
+
+ this._inDrag = false;
+ Main.overview.connectObject(
+ 'window-drag-begin', this._dragBegin.bind(this),
+ 'window-drag-end', this._dragEnd.bind(this), this);
+
+ this._overviewAdjustment = overviewAdjustment;
+ overviewAdjustment.connectObject('notify::value',
+ () => this._updateWorkspaceMode(), this);
+ }
+
+ _onDestroy() {
+ this._dragEnd();
+ }
+
+ _dragBegin() {
+ this._inDrag = true;
+ }
+
+ _dragEnd() {
+ this._inDrag = false;
+ }
+
+ _updateWorkspaceMode() {
+ }
+
+ vfunc_allocate(box) {
+ this.set_allocation(box);
+
+ for (const child of this)
+ child.allocate_available_size(0, 0, box.get_width(), box.get_height());
+ }
+
+ vfunc_get_preferred_width() {
+ return [0, 0];
+ }
+
+ vfunc_get_preferred_height() {
+ return [0, 0];
+ }
+});
+
+var FitMode = {
+ SINGLE: 0,
+ ALL: 1,
+};
+
+var WorkspacesView = GObject.registerClass(
+class WorkspacesView extends WorkspacesViewBase {
+ _init(monitorIndex, controls, scrollAdjustment, fitModeAdjustment, overviewAdjustment) {
+ let workspaceManager = global.workspace_manager;
+
+ super._init(monitorIndex, overviewAdjustment);
+
+ this._controls = controls;
+ this._fitModeAdjustment = fitModeAdjustment;
+ this._fitModeAdjustment.connectObject('notify::value', () => {
+ this._updateVisibility();
+ this._updateWorkspacesState();
+ this.queue_relayout();
+ }, this);
+
+ this._animating = false; // tweening
+ this._gestureActive = false; // touch(pad) gestures
+
+ this._scrollAdjustment = scrollAdjustment;
+ this._scrollAdjustment.connectObject('notify::value',
+ this._onScrollAdjustmentChanged.bind(this), this);
+
+ this._workspaces = [];
+ this._updateWorkspaces();
+ workspaceManager.connectObject(
+ 'notify::n-workspaces', this._updateWorkspaces.bind(this),
+ '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);
+
+ global.window_manager.connectObject('switch-workspace',
+ this._activeWorkspaceChanged.bind(this), this);
+ }
+
+ _getFirstFitAllWorkspaceBox(box, spacing, vertical) {
+ const { nWorkspaces } = global.workspaceManager;
+ const [width, height] = box.get_size();
+ const [workspace] = this._workspaces;
+
+ const fitAllBox = new Clutter.ActorBox();
+
+ let [x1, y1] = box.get_origin();
+
+ // Spacing here is not only the space between workspaces, but also the
+ // space before the first workspace, and after the last one. This prevents
+ // workspaces from touching the edges of the allocation box.
+ if (vertical) {
+ const availableHeight = height - spacing * (nWorkspaces + 1);
+ let workspaceHeight = availableHeight / nWorkspaces;
+ let [, workspaceWidth] =
+ workspace.get_preferred_width(workspaceHeight);
+
+ y1 = spacing;
+ if (workspaceWidth > width) {
+ [, workspaceHeight] = workspace.get_preferred_height(width);
+ y1 += Math.max((availableHeight - workspaceHeight * nWorkspaces) / 2, 0);
+ }
+
+ fitAllBox.set_size(width, workspaceHeight);
+ } else {
+ const availableWidth = width - spacing * (nWorkspaces + 1);
+ let workspaceWidth = availableWidth / nWorkspaces;
+ let [, workspaceHeight] =
+ workspace.get_preferred_height(workspaceWidth);
+
+ x1 = spacing;
+ if (workspaceHeight > height) {
+ [, workspaceWidth] = workspace.get_preferred_width(height);
+ x1 += Math.max((availableWidth - workspaceWidth * nWorkspaces) / 2, 0);
+ }
+
+ fitAllBox.set_size(workspaceWidth, height);
+ }
+
+ fitAllBox.set_origin(x1, y1);
+
+ return fitAllBox;
+ }
+
+ _getFirstFitSingleWorkspaceBox(box, spacing, vertical) {
+ const [width, height] = box.get_size();
+ const [workspace] = this._workspaces;
+
+ const rtl = this.text_direction === Clutter.TextDirection.RTL;
+ const adj = this._scrollAdjustment;
+ const currentWorkspace = vertical || !rtl
+ ? adj.value : adj.upper - adj.value - 1;
+
+ // Single fit mode implies centered too
+ let [x1, y1] = box.get_origin();
+ if (vertical) {
+ const [, workspaceHeight] = workspace.get_preferred_height(width);
+ y1 += (height - workspaceHeight) / 2;
+ y1 -= currentWorkspace * (workspaceHeight + spacing);
+ } else {
+ const [, workspaceWidth] = workspace.get_preferred_width(height);
+ x1 += (width - workspaceWidth) / 2;
+ x1 -= currentWorkspace * (workspaceWidth + spacing);
+ }
+
+ const fitSingleBox = new Clutter.ActorBox({ x1, y1 });
+
+ if (vertical) {
+ const [, workspaceHeight] = workspace.get_preferred_height(width);
+ fitSingleBox.set_size(width, workspaceHeight);
+ } else {
+ const [, workspaceWidth] = workspace.get_preferred_width(height);
+ fitSingleBox.set_size(workspaceWidth, height);
+ }
+
+ return fitSingleBox;
+ }
+
+ _getSpacing(box, fitMode, vertical) {
+ const [width, height] = box.get_size();
+ const [workspace] = this._workspaces;
+
+ let availableSpace;
+ let workspaceSize;
+ if (vertical) {
+ [, workspaceSize] = workspace.get_preferred_height(width);
+ availableSpace = (height - workspaceSize) / 2;
+ } else {
+ [, workspaceSize] = workspace.get_preferred_width(height);
+ availableSpace = (width - workspaceSize) / 2;
+ }
+
+ const spacing = (availableSpace - workspaceSize * 0.4) * (1 - fitMode);
+ const { scaleFactor } = St.ThemeContext.get_for_stage(global.stage);
+
+ return Math.clamp(spacing, WORKSPACE_MIN_SPACING * scaleFactor,
+ WORKSPACE_MAX_SPACING * scaleFactor);
+ }
+
+ _getWorkspaceModeForOverviewState(state) {
+ const { ControlsState } = OverviewControls;
+
+ switch (state) {
+ case ControlsState.HIDDEN:
+ return 0;
+ case ControlsState.WINDOW_PICKER:
+ return 1;
+ case ControlsState.APP_GRID:
+ return 0;
+ }
+
+ return 0;
+ }
+
+ _updateWorkspacesState() {
+ const adj = this._scrollAdjustment;
+ const fitMode = this._fitModeAdjustment.value;
+
+ const { initialState, finalState, progress } =
+ this._overviewAdjustment.getStateTransitionParams();
+
+ const workspaceMode = (1 - fitMode) * Util.lerp(
+ this._getWorkspaceModeForOverviewState(initialState),
+ this._getWorkspaceModeForOverviewState(finalState),
+ progress);
+
+ // Fade and scale inactive workspaces
+ this._workspaces.forEach((w, index) => {
+ w.stateAdjustment.value = workspaceMode;
+
+ const distanceToCurrentWorkspace = Math.abs(adj.value - index);
+
+ const scaleProgress = 1 - Math.clamp(distanceToCurrentWorkspace, 0, 1);
+
+ const scale = Util.lerp(WORKSPACE_INACTIVE_SCALE, 1, scaleProgress);
+ w.set_scale(scale, scale);
+ });
+ }
+
+ _getFitModeForState(state) {
+ const { ControlsState } = OverviewControls;
+
+ switch (state) {
+ case ControlsState.HIDDEN:
+ case ControlsState.WINDOW_PICKER:
+ return FitMode.SINGLE;
+ case ControlsState.APP_GRID:
+ return FitMode.ALL;
+ default:
+ return FitMode.SINGLE;
+ }
+ }
+
+ _getInitialBoxes(box) {
+ const offsetBox = new Clutter.ActorBox();
+ offsetBox.set_size(...box.get_size());
+
+ let fitSingleBox = offsetBox;
+ let fitAllBox = offsetBox;
+
+ const { transitioning, initialState, finalState } =
+ this._overviewAdjustment.getStateTransitionParams();
+
+ const isPrimary = Main.layoutManager.primaryIndex === this._monitorIndex;
+
+ if (isPrimary && transitioning) {
+ const initialFitMode = this._getFitModeForState(initialState);
+ const finalFitMode = this._getFitModeForState(finalState);
+
+ // Only use the relative boxes when the overview is in a state
+ // transition, and the corresponding fit modes are different.
+ if (initialFitMode !== finalFitMode) {
+ const initialBox =
+ this._controls.getWorkspacesBoxForState(initialState).copy();
+ const finalBox =
+ this._controls.getWorkspacesBoxForState(finalState).copy();
+
+ // Boxes are relative to ControlsManager, transform them;
+ // this.apply_relative_transform_to_point(controls,
+ // new Graphene.Point3D());
+ // would be more correct, but also more expensive
+ const [parentOffsetX, parentOffsetY] =
+ this.get_parent().allocation.get_origin();
+ [initialBox, finalBox].forEach(b => {
+ b.set_origin(b.x1 - parentOffsetX, b.y1 - parentOffsetY);
+ });
+
+ if (initialFitMode === FitMode.SINGLE)
+ [fitSingleBox, fitAllBox] = [initialBox, finalBox];
+ else
+ [fitAllBox, fitSingleBox] = [initialBox, finalBox];
+ }
+ }
+
+ return [fitSingleBox, fitAllBox];
+ }
+
+ _updateWorkspaceMode() {
+ this._updateWorkspacesState();
+ }
+
+ vfunc_allocate(box) {
+ this.set_allocation(box);
+
+ if (this.get_n_children() === 0)
+ return;
+
+ const vertical = global.workspaceManager.layout_rows === -1;
+ const rtl = this.text_direction === Clutter.TextDirection.RTL;
+
+ const fitMode = this._fitModeAdjustment.value;
+
+ let [fitSingleBox, fitAllBox] = this._getInitialBoxes(box);
+ const fitSingleSpacing =
+ this._getSpacing(fitSingleBox, FitMode.SINGLE, vertical);
+ fitSingleBox =
+ this._getFirstFitSingleWorkspaceBox(fitSingleBox, fitSingleSpacing, vertical);
+
+ const fitAllSpacing =
+ this._getSpacing(fitAllBox, FitMode.ALL, vertical);
+ fitAllBox =
+ this._getFirstFitAllWorkspaceBox(fitAllBox, fitAllSpacing, vertical);
+
+ // Account for RTL locales by reversing the list
+ const workspaces = this._workspaces.slice();
+ if (rtl)
+ workspaces.reverse();
+
+ const [fitSingleX1, fitSingleY1] = fitSingleBox.get_origin();
+ const [fitSingleWidth, fitSingleHeight] = fitSingleBox.get_size();
+ const [fitAllX1, fitAllY1] = fitAllBox.get_origin();
+ const [fitAllWidth, fitAllHeight] = fitAllBox.get_size();
+
+ workspaces.forEach(child => {
+ if (fitMode === FitMode.SINGLE)
+ box = fitSingleBox;
+ else if (fitMode === FitMode.ALL)
+ box = fitAllBox;
+ else
+ box = fitSingleBox.interpolate(fitAllBox, fitMode);
+
+ child.allocate_align_fill(box, 0.5, 0.5, false, false);
+
+ if (vertical) {
+ fitSingleBox.set_origin(
+ fitSingleX1,
+ fitSingleBox.y1 + fitSingleHeight + fitSingleSpacing);
+ fitAllBox.set_origin(
+ fitAllX1,
+ fitAllBox.y1 + fitAllHeight + fitAllSpacing);
+ } else {
+ fitSingleBox.set_origin(
+ fitSingleBox.x1 + fitSingleWidth + fitSingleSpacing,
+ fitSingleY1);
+ fitAllBox.set_origin(
+ fitAllBox.x1 + fitAllWidth + fitAllSpacing,
+ fitAllY1);
+ }
+ });
+ }
+
+ getActiveWorkspace() {
+ let workspaceManager = global.workspace_manager;
+ let active = workspaceManager.get_active_workspace_index();
+ return this._workspaces[active];
+ }
+
+ prepareToLeaveOverview() {
+ for (let w = 0; w < this._workspaces.length; w++)
+ this._workspaces[w].prepareToLeaveOverview();
+ }
+
+ 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();
+
+ const fitMode = this._fitModeAdjustment.value;
+ const singleFitMode = fitMode === FitMode.SINGLE;
+
+ for (let w = 0; w < this._workspaces.length; w++) {
+ let workspace = this._workspaces[w];
+
+ if (this._animating || this._gestureActive || !singleFitMode)
+ workspace.show();
+ else
+ workspace.visible = Math.abs(w - active) <= 1;
+ }
+ }
+
+ _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._overviewAdjustment);
+ 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 */
+ }
+ }
+
+ for (let j = this._workspaces.length - 1; j >= newNumWorkspaces; j--) {
+ this._workspaces[j].destroy();
+ this._workspaces.splice(j, 1);
+ }
+
+ this._updateWorkspacesState();
+ this._updateVisibility();
+ }
+
+ _activeWorkspaceChanged(_wm, _from, _to, _direction) {
+ this._scrollToActive();
+ }
+
+ _onDestroy() {
+ super._onDestroy();
+
+ this._workspaces = [];
+ }
+
+ 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._updateWorkspacesState();
+ this.queue_relayout();
+ }
+});
+
+var ExtraWorkspaceView = GObject.registerClass(
+class ExtraWorkspaceView extends WorkspacesViewBase {
+ _init(monitorIndex, overviewAdjustment) {
+ super._init(monitorIndex, overviewAdjustment);
+ this._workspace =
+ new Workspace.Workspace(null, monitorIndex, overviewAdjustment);
+ this.add_actor(this._workspace);
+ }
+
+ _updateWorkspaceMode() {
+ const overviewState = this._overviewAdjustment.value;
+
+ const progress = Math.clamp(overviewState,
+ OverviewControls.ControlsState.HIDDEN,
+ OverviewControls.ControlsState.WINDOW_PICKER);
+
+ this._workspace.stateAdjustment.value = progress;
+ }
+
+ vfunc_allocate(box) {
+ this.set_allocation(box);
+
+ const [width, height] = box.get_size();
+ const [, childWidth] = this._workspace.get_preferred_width(height);
+
+ const childBox = new Clutter.ActorBox();
+ childBox.set_origin(Math.round((width - childWidth) / 2), 0);
+ childBox.set_size(childWidth, height);
+ this._workspace.allocate(childBox);
+ }
+
+ getActiveWorkspace() {
+ return this._workspace;
+ }
+
+ prepareToLeaveOverview() {
+ this._workspace.prepareToLeaveOverview();
+ }
+
+ syncStacking(stackIndices) {
+ this._workspace.syncStacking(stackIndices);
+ }
+
+ startTouchGesture() {
+ }
+
+ endTouchGesture() {
+ }
+});
+
+const SecondaryMonitorDisplay = GObject.registerClass(
+class SecondaryMonitorDisplay extends St.Widget {
+ _init(monitorIndex, controls, scrollAdjustment, fitModeAdjustment, overviewAdjustment) {
+ this._monitorIndex = monitorIndex;
+ this._controls = controls;
+ this._scrollAdjustment = scrollAdjustment;
+ this._fitModeAdjustment = fitModeAdjustment;
+ this._overviewAdjustment = overviewAdjustment;
+
+ super._init({
+ style_class: 'secondary-monitor-workspaces',
+ constraints: new Layout.MonitorConstraint({
+ index: this._monitorIndex,
+ work_area: true,
+ }),
+ clip_to_allocation: true,
+ });
+
+ this.connect('destroy', () => this._onDestroy());
+
+ this._thumbnails = new ThumbnailsBox(
+ this._scrollAdjustment, monitorIndex);
+ this.add_child(this._thumbnails);
+
+ this._thumbnails.connect('notify::should-show',
+ () => this._updateThumbnailVisibility());
+
+ this._overviewAdjustment.connectObject('notify::value', () => {
+ this._updateThumbnailParams();
+ this.queue_relayout();
+ }, this);
+
+ this._settings = new Gio.Settings({ schema_id: MUTTER_SCHEMA });
+ this._settings.connect('changed::workspaces-only-on-primary',
+ () => this._workspacesOnPrimaryChanged());
+ this._workspacesOnPrimaryChanged();
+ }
+
+ _getThumbnailParamsForState(state) {
+ const { ControlsState } = OverviewControls;
+
+ let opacity, scale;
+ switch (state) {
+ case ControlsState.HIDDEN:
+ case ControlsState.WINDOW_PICKER:
+ opacity = 255;
+ scale = 1;
+ break;
+ case ControlsState.APP_GRID:
+ opacity = 0;
+ scale = 0.5;
+ break;
+ default:
+ opacity = 255;
+ scale = 1;
+ break;
+ }
+
+ return { opacity, scale };
+ }
+
+ _getThumbnailsHeight(box) {
+ if (!this._thumbnails.visible)
+ return 0;
+
+ const [width, height] = box.get_size();
+ const { expandFraction } = this._thumbnails;
+ const [thumbnailsHeight] = this._thumbnails.get_preferred_height(width);
+ return Math.min(
+ thumbnailsHeight * expandFraction,
+ height * MAX_THUMBNAIL_SCALE);
+ }
+
+ _getWorkspacesBoxForState(state, box, padding, thumbnailsHeight, spacing) {
+ const { ControlsState } = OverviewControls;
+ const workspaceBox = box.copy();
+ const [width, height] = workspaceBox.get_size();
+
+ switch (state) {
+ case ControlsState.HIDDEN:
+ break;
+ case ControlsState.WINDOW_PICKER:
+ workspaceBox.set_origin(0, padding + thumbnailsHeight + spacing);
+ workspaceBox.set_size(
+ width,
+ height - 2 * padding - thumbnailsHeight - spacing);
+ break;
+ case ControlsState.APP_GRID:
+ workspaceBox.set_origin(0, padding);
+ workspaceBox.set_size(
+ width,
+ height - 2 * padding);
+ break;
+ }
+
+ return workspaceBox;
+ }
+
+ vfunc_allocate(box) {
+ this.set_allocation(box);
+
+ const themeNode = this.get_theme_node();
+ const contentBox = themeNode.get_content_box(box);
+ const [width, height] = contentBox.get_size();
+ const { expandFraction } = this._thumbnails;
+ const spacing = themeNode.get_length('spacing') * expandFraction;
+ const padding =
+ Math.round((1 - SECONDARY_WORKSPACE_SCALE) * height / 2);
+
+ const thumbnailsHeight = this._getThumbnailsHeight(contentBox);
+
+ if (this._thumbnails.visible) {
+ const childBox = new Clutter.ActorBox();
+ childBox.set_origin(0, padding);
+ childBox.set_size(width, thumbnailsHeight);
+ this._thumbnails.allocate(childBox);
+ }
+
+ const {
+ currentState, initialState, finalState, transitioning, progress,
+ } = this._overviewAdjustment.getStateTransitionParams();
+
+ let workspacesBox;
+ const workspaceParams = [contentBox, padding, thumbnailsHeight, spacing];
+ if (!transitioning) {
+ workspacesBox =
+ this._getWorkspacesBoxForState(currentState, ...workspaceParams);
+ } else {
+ const initialBox =
+ this._getWorkspacesBoxForState(initialState, ...workspaceParams);
+ const finalBox =
+ this._getWorkspacesBoxForState(finalState, ...workspaceParams);
+ workspacesBox = initialBox.interpolate(finalBox, progress);
+ }
+ this._workspacesView.allocate(workspacesBox);
+ }
+
+ _onDestroy() {
+ if (this._settings)
+ this._settings.run_dispose();
+ this._settings = null;
+ }
+
+ _workspacesOnPrimaryChanged() {
+ this._updateWorkspacesView();
+ this._updateThumbnailVisibility();
+ }
+
+ _updateWorkspacesView() {
+ if (this._workspacesView)
+ this._workspacesView.destroy();
+
+ if (this._settings.get_boolean('workspaces-only-on-primary')) {
+ this._workspacesView = new ExtraWorkspaceView(
+ this._monitorIndex,
+ this._overviewAdjustment);
+ } else {
+ this._workspacesView = new WorkspacesView(
+ this._monitorIndex,
+ this._controls,
+ this._scrollAdjustment,
+ this._fitModeAdjustment,
+ this._overviewAdjustment);
+ }
+ this.add_child(this._workspacesView);
+ }
+
+ _updateThumbnailVisibility() {
+ const visible =
+ this._thumbnails.should_show &&
+ !this._settings.get_boolean('workspaces-only-on-primary');
+
+ if (this._thumbnails.visible === visible)
+ return;
+
+ this._thumbnails.show();
+ this._updateThumbnailParams();
+ this._thumbnails.ease_property('expand-fraction', visible ? 1 : 0, {
+ duration: OverviewControls.SIDE_CONTROLS_ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => (this._thumbnails.visible = visible),
+ });
+ }
+
+ _updateThumbnailParams() {
+ if (!this._thumbnails.visible)
+ return;
+
+ const { initialState, finalState, progress } =
+ this._overviewAdjustment.getStateTransitionParams();
+
+ const initialParams = this._getThumbnailParamsForState(initialState);
+ const finalParams = this._getThumbnailParamsForState(finalState);
+
+ const opacity =
+ Util.lerp(initialParams.opacity, finalParams.opacity, progress);
+ const scale =
+ Util.lerp(initialParams.scale, finalParams.scale, progress);
+
+ this._thumbnails.set({
+ opacity,
+ scale_x: scale,
+ scale_y: scale,
+ });
+ }
+
+ getActiveWorkspace() {
+ return this._workspacesView.getActiveWorkspace();
+ }
+
+ prepareToLeaveOverview() {
+ this._workspacesView.prepareToLeaveOverview();
+ }
+
+ syncStacking(stackIndices) {
+ this._workspacesView.syncStacking(stackIndices);
+ }
+
+ startTouchGesture() {
+ this._workspacesView.startTouchGesture();
+ }
+
+ endTouchGesture() {
+ this._workspacesView.endTouchGesture();
+ }
+});
+
+var WorkspacesDisplay = GObject.registerClass(
+class WorkspacesDisplay extends St.Widget {
+ _init(controls, scrollAdjustment, overviewAdjustment) {
+ super._init({
+ layout_manager: new Clutter.BinLayout(),
+ reactive: true,
+ });
+
+ this._controls = controls;
+ this._overviewAdjustment = overviewAdjustment;
+ this._fitModeAdjustment = new St.Adjustment({
+ actor: this,
+ value: FitMode.SINGLE,
+ lower: FitMode.SINGLE,
+ upper: FitMode.ALL,
+ });
+
+ let workspaceManager = global.workspace_manager;
+ this._scrollAdjustment = scrollAdjustment;
+
+ global.window_manager.connectObject('switch-workspace',
+ this._activeWorkspaceChanged.bind(this), this);
+
+ this._swipeTracker = new SwipeTracker.SwipeTracker(
+ Main.layoutManager.overviewGroup,
+ Clutter.Orientation.HORIZONTAL,
+ Shell.ActionMode.OVERVIEW,
+ { allowDrag: false });
+ this._swipeTracker.allowLongSwipes = true;
+ 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));
+
+ workspaceManager.connectObject(
+ 'workspaces-reordered', this._workspacesReordered.bind(this),
+ 'notify::layout-rows', this._updateTrackerOrientation.bind(this), this);
+ this._updateTrackerOrientation();
+
+ Main.overview.connectObject(
+ 'window-drag-begin', this._windowDragBegin.bind(this),
+ 'window-drag-end', this._windowDragEnd.bind(this), this);
+
+ this._primaryVisible = true;
+ this._primaryIndex = Main.layoutManager.primaryIndex;
+ this._workspacesViews = [];
+
+ this._settings = new Gio.Settings({ schema_id: MUTTER_SCHEMA });
+
+ this._inWindowDrag = false;
+ this._leavingOverview = false;
+
+ this._gestureActive = false; // touch(pad) gestures
+
+ this.connect('destroy', this._onDestroy.bind(this));
+ }
+
+ _onDestroy() {
+ if (this._parentSetLater) {
+ Meta.later_remove(this._parentSetLater);
+ this._parentSetLater = 0;
+ }
+ }
+
+ _windowDragBegin() {
+ this._inWindowDrag = true;
+ this._updateSwipeTracker();
+ }
+
+ _windowDragEnd() {
+ this._inWindowDrag = false;
+ this._updateSwipeTracker();
+ }
+
+ _updateSwipeTracker() {
+ this._swipeTracker.enabled =
+ this.mapped &&
+ !this._inWindowDrag &&
+ !this._leavingOverview;
+ }
+
+ _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,
+ });
+ }
+
+ _updateTrackerOrientation() {
+ const { layoutRows } = global.workspace_manager;
+ this._swipeTracker.orientation = layoutRows !== -1
+ ? Clutter.Orientation.HORIZONTAL
+ : Clutter.Orientation.VERTICAL;
+ }
+
+ _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');
+
+ const distance = global.workspace_manager.layout_rows === -1
+ ? this.height : this.width;
+
+ for (let i = 0; i < this._workspacesViews.length; i++)
+ this._workspacesViews[i].startTouchGesture();
+
+ 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) {
+ 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);
+ }
+
+ setPrimaryWorkspaceVisible(visible) {
+ if (this._primaryVisible === visible)
+ return;
+
+ this._primaryVisible = visible;
+
+ const primaryIndex = Main.layoutManager.primaryIndex;
+ const primaryWorkspace = this._workspacesViews[primaryIndex];
+ if (primaryWorkspace)
+ primaryWorkspace.visible = visible;
+ }
+
+ prepareToEnterOverview() {
+ this.show();
+ this._updateWorkspacesViews();
+
+ Main.overview.connectObject(
+ 'windows-restacked', this._onRestacked.bind(this),
+ 'scroll-event', this._onScrollEvent.bind(this), this);
+
+ global.stage.connectObject(
+ 'key-press-event', this._onKeyPressEvent.bind(this), this);
+ }
+
+ prepareToLeaveOverview() {
+ for (let i = 0; i < this._workspacesViews.length; i++)
+ this._workspacesViews[i].prepareToLeaveOverview();
+
+ this._leavingOverview = true;
+ this._updateSwipeTracker();
+ }
+
+ vfunc_hide() {
+ Main.overview.disconnectObject(this);
+ global.stage.disconnectObject(this);
+
+ for (let i = 0; i < this._workspacesViews.length; i++)
+ this._workspacesViews[i].destroy();
+ this._workspacesViews = [];
+
+ this._leavingOverview = false;
+
+ super.vfunc_hide();
+ }
+
+ _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 (i === this._primaryIndex) {
+ view = new WorkspacesView(i,
+ this._controls,
+ this._scrollAdjustment,
+ this._fitModeAdjustment,
+ this._overviewAdjustment);
+
+ view.visible = this._primaryVisible;
+ this.bind_property('opacity', view, 'opacity', GObject.BindingFlags.SYNC_CREATE);
+ this.add_child(view);
+ } else {
+ view = new SecondaryMonitorDisplay(i,
+ this._controls,
+ this._scrollAdjustment,
+ this._fitModeAdjustment,
+ this._overviewAdjustment);
+ Main.layoutManager.overviewGroup.add_actor(view);
+ }
+
+ this._workspacesViews.push(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() {
+ const primaryView = this._getPrimaryView();
+ return primaryView
+ ? primaryView.getActiveWorkspace().hasMaximizedWindows()
+ : false;
+ }
+
+ _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;
+
+ return Main.wm.handleWorkspaceScroll(event);
+ }
+
+ _onKeyPressEvent(actor, event) {
+ const { ControlsState } = OverviewControls;
+ if (this._overviewAdjustment.value !== ControlsState.WINDOW_PICKER)
+ return Clutter.EVENT_PROPAGATE;
+
+ if (!this.reactive)
+ return Clutter.EVENT_PROPAGATE;
+
+ const { workspaceManager } = global;
+ const vertical = workspaceManager.layout_rows === -1;
+ const rtl = this.get_text_direction() === Clutter.TextDirection.RTL;
+
+ let which;
+ switch (event.get_key_symbol()) {
+ case Clutter.KEY_Page_Up:
+ if (vertical)
+ which = Meta.MotionDirection.UP;
+ else if (rtl)
+ which = Meta.MotionDirection.RIGHT;
+ else
+ which = Meta.MotionDirection.LEFT;
+ break;
+ case Clutter.KEY_Page_Down:
+ if (vertical)
+ which = Meta.MotionDirection.DOWN;
+ else if (rtl)
+ which = Meta.MotionDirection.LEFT;
+ else
+ which = Meta.MotionDirection.RIGHT;
+ break;
+ case Clutter.KEY_Home:
+ which = 0;
+ break;
+ case Clutter.KEY_End:
+ which = workspaceManager.n_workspaces - 1;
+ break;
+ default:
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ let ws;
+ if (which < 0)
+ // Negative workspace numbers are directions
+ // with respect to the current workspace
+ ws = workspaceManager.get_active_workspace().get_neighbor(which);
+ else
+ // Otherwise it is a workspace index
+ ws = workspaceManager.get_workspace_by_index(which);
+
+ if (ws)
+ Main.wm.actionMoveWorkspace(ws);
+
+ return Clutter.EVENT_STOP;
+ }
+
+ get _workspacesOnlyOnPrimary() {
+ return this._settings.get_boolean('workspaces-only-on-primary');
+ }
+
+ get fitModeAdjustment() {
+ return this._fitModeAdjustment;
+ }
+});
diff --git a/js/ui/xdndHandler.js b/js/ui/xdndHandler.js
new file mode 100644
index 0000000..3a4880b
--- /dev/null
+++ b/js/ui/xdndHandler.js
@@ -0,0 +1,116 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported XdndHandler */
+
+const { Clutter } = imports.gi;
+const Signals = imports.misc.signals;
+
+const DND = imports.ui.dnd;
+const Main = imports.ui.main;
+
+var XdndHandler = class extends Signals.EventEmitter {
+ constructor() {
+ super();
+
+ // 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));
+ }
+
+ // Called when the user cancels the drag (i.e release the button)
+ _onLeave() {
+ global.window_group.disconnectObject(this);
+ if (this._cursorWindowClone) {
+ this._cursorWindowClone.destroy();
+ this._cursorWindowClone = null;
+ }
+
+ this.emit('drag-end');
+ }
+
+ _onEnter() {
+ global.window_group.connectObject('notify::visible',
+ this._onWindowGroupVisibilityChanged.bind(this), 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;
+
+ const 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._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();
+ }
+ }
+};