diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-27 15:07:22 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-27 15:07:22 +0000 |
commit | f9d480cfe50ca1d7a0f0b5a2b8bb9932962bfbe7 (patch) | |
tree | ce9e8db2d4e8799780fa72ae8f1953039373e2ee /js/ui | |
parent | Initial commit. (diff) | |
download | gnome-shell-f9d480cfe50ca1d7a0f0b5a2b8bb9932962bfbe7.tar.xz gnome-shell-f9d480cfe50ca1d7a0f0b5a2b8bb9932962bfbe7.zip |
Adding upstream version 3.38.6.upstream/3.38.6upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
101 files changed, 57887 insertions, 0 deletions
diff --git a/js/ui/accessDialog.js b/js/ui/accessDialog.js new file mode 100644 index 0000000..0e417bd --- /dev/null +++ b/js/ui/accessDialog.js @@ -0,0 +1,156 @@ +/* exported AccessDialogDBus */ +const { Clutter, Gio, GLib, GObject, Shell, St } = imports.gi; + +const CheckBox = imports.ui.checkBox; +const Dialog = imports.ui.dialog; +const ModalDialog = imports.ui.modalDialog; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const RequestIface = loadInterfaceXML('org.freedesktop.impl.portal.Request'); +const AccessIface = loadInterfaceXML('org.freedesktop.impl.portal.Access'); + +var DialogResponse = { + OK: 0, + CANCEL: 1, + CLOSED: 2, +}; + +var AccessDialog = GObject.registerClass( +class AccessDialog extends ModalDialog.ModalDialog { + _init(invocation, handle, title, description, body, options) { + super._init({ styleClass: 'access-dialog' }); + + this._invocation = invocation; + this._handle = handle; + + this._requestExported = false; + this._request = Gio.DBusExportedObject.wrapJSObject(RequestIface, this); + + for (let option in options) + options[option] = options[option].deep_unpack(); + + this._buildLayout(title, description, body, options); + } + + _buildLayout(title, description, body, options) { + // No support for non-modal system dialogs, so ignore the option + // let modal = options['modal'] || true; + let denyLabel = options['deny_label'] || _("Deny Access"); + let grantLabel = options['grant_label'] || _("Grant Access"); + let choices = options['choices'] || []; + + let content = new Dialog.MessageDialogContent({ title, description }); + this.contentLayout.add_actor(content); + + this._choices = new Map(); + + for (let i = 0; i < choices.length; i++) { + let [id, name, opts, selected] = choices[i]; + if (opts.length > 0) + continue; // radio buttons, not implemented + + let check = new CheckBox.CheckBox(); + check.getLabelActor().text = name; + check.checked = selected == "true"; + content.add_child(check); + + this._choices.set(id, check); + } + + let bodyLabel = new St.Label({ + text: body, + x_align: Clutter.ActorAlign.CENTER, + }); + content.add_child(bodyLabel); + + this.addButton({ label: denyLabel, + action: () => { + this._sendResponse(DialogResponse.CANCEL); + }, + key: Clutter.KEY_Escape }); + this.addButton({ label: grantLabel, + action: () => { + this._sendResponse(DialogResponse.OK); + } }); + } + + open() { + super.open(); + + let connection = this._invocation.get_connection(); + this._requestExported = this._request.export(connection, this._handle); + } + + CloseAsync(invocation, _params) { + if (this._invocation.get_sender() != invocation.get_sender()) { + invocation.return_error_literal(Gio.DBusError, + Gio.DBusError.ACCESS_DENIED, + ''); + return; + } + + this._sendResponse(DialogResponse.CLOSED); + } + + _sendResponse(response) { + if (this._requestExported) + this._request.unexport(); + this._requestExported = false; + + let results = {}; + if (response == DialogResponse.OK) { + for (let [id, check] of this._choices) { + let checked = check.checked ? 'true' : 'false'; + results[id] = new GLib.Variant('s', checked); + } + } + + // Delay actual response until the end of the close animation (if any) + this.connect('closed', () => { + this._invocation.return_value(new GLib.Variant('(ua{sv})', + [response, results])); + }); + this.close(); + } +}); + +var AccessDialogDBus = class { + constructor() { + this._accessDialog = null; + + this._windowTracker = Shell.WindowTracker.get_default(); + + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(AccessIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/freedesktop/portal/desktop'); + + Gio.DBus.session.own_name('org.freedesktop.impl.portal.desktop.gnome', Gio.BusNameOwnerFlags.REPLACE, null, null); + } + + AccessDialogAsync(params, invocation) { + if (this._accessDialog) { + invocation.return_error_literal(Gio.DBusError, + Gio.DBusError.LIMITS_EXCEEDED, + 'Already showing a system access dialog'); + return; + } + + let [handle, appId, parentWindow_, title, description, body, options] = params; + // We probably want to use parentWindow and global.display.focus_window + // for this check in the future + if (appId && '%s.desktop'.format(appId) != this._windowTracker.focus_app.id) { + invocation.return_error_literal(Gio.DBusError, + Gio.DBusError.ACCESS_DENIED, + 'Only the focused app is allowed to show a system access dialog'); + return; + } + + let dialog = new AccessDialog( + invocation, handle, title, description, body, options); + dialog.open(); + + dialog.connect('closed', () => (this._accessDialog = null)); + + this._accessDialog = dialog; + } +}; diff --git a/js/ui/altTab.js b/js/ui/altTab.js new file mode 100644 index 0000000..25683b5 --- /dev/null +++ b/js/ui/altTab.js @@ -0,0 +1,1116 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported AppSwitcherPopup, GroupCyclerPopup, WindowSwitcherPopup, + WindowCyclerPopup */ + +const { Atk, Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; + +const Main = imports.ui.main; +const SwitcherPopup = imports.ui.switcherPopup; + +var APP_ICON_HOVER_TIMEOUT = 200; // milliseconds + +var THUMBNAIL_DEFAULT_SIZE = 256; +var THUMBNAIL_POPUP_TIME = 500; // milliseconds +var THUMBNAIL_FADE_TIME = 100; // milliseconds + +var WINDOW_PREVIEW_SIZE = 128; +var APP_ICON_SIZE = 96; +var APP_ICON_SIZE_SMALL = 48; + +const baseIconSizes = [96, 64, 48, 32, 22]; + +var AppIconMode = { + THUMBNAIL_ONLY: 1, + APP_ICON_ONLY: 2, + BOTH: 3, +}; + +function _createWindowClone(window, size) { + let [width, height] = window.get_size(); + let scale = Math.min(1.0, size / width, size / height); + return new Clutter.Clone({ source: window, + width: width * scale, + height: height * scale, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + // usual hack for the usual bug in ClutterBinLayout... + x_expand: true, + y_expand: true }); +} + +function getWindows(workspace) { + // We ignore skip-taskbar windows in switchers, but if they are attached + // to their parent, their position in the MRU list may be more appropriate + // than the parent; so start with the complete list ... + let windows = global.display.get_tab_list(Meta.TabList.NORMAL_ALL, + workspace); + // ... map windows to their parent where appropriate ... + return windows.map(w => { + return w.is_attached_dialog() ? w.get_transient_for() : w; + // ... and filter out skip-taskbar windows and duplicates + }).filter((w, i, a) => !w.skip_taskbar && a.indexOf(w) == i); +} + +var AppSwitcherPopup = GObject.registerClass( +class AppSwitcherPopup extends SwitcherPopup.SwitcherPopup { + _init() { + super._init(); + + this._thumbnails = null; + this._thumbnailTimeoutId = 0; + this._currentWindow = -1; + + this.thumbnailsVisible = false; + + let apps = Shell.AppSystem.get_default().get_running(); + + this._switcherList = new AppSwitcher(apps, this); + this._items = this._switcherList.icons; + } + + vfunc_allocate(box) { + super.vfunc_allocate(box); + + // Allocate the thumbnails + // We try to avoid overflowing the screen so we base the resulting size on + // those calculations + if (this._thumbnails) { + let childBox = this._switcherList.get_allocation_box(); + let primary = Main.layoutManager.primaryMonitor; + + let leftPadding = this.get_theme_node().get_padding(St.Side.LEFT); + let rightPadding = this.get_theme_node().get_padding(St.Side.RIGHT); + let bottomPadding = this.get_theme_node().get_padding(St.Side.BOTTOM); + let hPadding = leftPadding + rightPadding; + + let icon = this._items[this._selectedIndex]; + let [posX] = icon.get_transformed_position(); + let thumbnailCenter = posX + icon.width / 2; + let [, childNaturalWidth] = this._thumbnails.get_preferred_width(-1); + childBox.x1 = Math.max(primary.x + leftPadding, Math.floor(thumbnailCenter - childNaturalWidth / 2)); + if (childBox.x1 + childNaturalWidth > primary.x + primary.width - hPadding) { + let offset = childBox.x1 + childNaturalWidth - primary.width + hPadding; + childBox.x1 = Math.max(primary.x + leftPadding, childBox.x1 - offset - hPadding); + } + + let spacing = this.get_theme_node().get_length('spacing'); + + childBox.x2 = childBox.x1 + childNaturalWidth; + if (childBox.x2 > primary.x + primary.width - rightPadding) + childBox.x2 = primary.x + primary.width - rightPadding; + childBox.y1 = this._switcherList.allocation.y2 + spacing; + this._thumbnails.addClones(primary.y + primary.height - bottomPadding - childBox.y1); + let [, childNaturalHeight] = this._thumbnails.get_preferred_height(-1); + childBox.y2 = childBox.y1 + childNaturalHeight; + this._thumbnails.allocate(childBox); + } + } + + _initialSelection(backward, binding) { + if (binding == 'switch-group') { + if (backward) + this._select(0, this._items[0].cachedWindows.length - 1); + else if (this._items[0].cachedWindows.length > 1) + this._select(0, 1); + else + this._select(0, 0); + } else if (binding == 'switch-group-backward') { + this._select(0, this._items[0].cachedWindows.length - 1); + } else if (binding == 'switch-applications-backward') { + this._select(this._items.length - 1); + } else if (this._items.length == 1) { + this._select(0); + } else if (backward) { + this._select(this._items.length - 1); + } else { + this._select(1); + } + } + + _nextWindow() { + // We actually want the second window if we're in the unset state + if (this._currentWindow == -1) + this._currentWindow = 0; + return SwitcherPopup.mod(this._currentWindow + 1, + this._items[this._selectedIndex].cachedWindows.length); + } + + _previousWindow() { + // Also assume second window here + if (this._currentWindow == -1) + this._currentWindow = 1; + return SwitcherPopup.mod(this._currentWindow - 1, + this._items[this._selectedIndex].cachedWindows.length); + } + + _closeAppWindow(appIndex, windowIndex) { + let appIcon = this._items[appIndex]; + if (!appIcon) + return; + + let window = appIcon.cachedWindows[windowIndex]; + if (!window) + return; + + window.delete(global.get_current_time()); + } + + _quitApplication(appIndex) { + let appIcon = this._items[appIndex]; + if (!appIcon) + return; + + appIcon.app.request_quit(); + } + + _keyPressHandler(keysym, action) { + if (action == Meta.KeyBindingAction.SWITCH_GROUP) { + if (!this._thumbnailsFocused) + this._select(this._selectedIndex, 0); + else + this._select(this._selectedIndex, this._nextWindow()); + } else if (action == Meta.KeyBindingAction.SWITCH_GROUP_BACKWARD) { + this._select(this._selectedIndex, this._previousWindow()); + } else if (action == Meta.KeyBindingAction.SWITCH_APPLICATIONS) { + this._select(this._next()); + } else if (action == Meta.KeyBindingAction.SWITCH_APPLICATIONS_BACKWARD) { + this._select(this._previous()); + } else if (keysym == Clutter.KEY_q || keysym === Clutter.KEY_Q) { + this._quitApplication(this._selectedIndex); + } else if (this._thumbnailsFocused) { + if (keysym === Clutter.KEY_Left) + this._select(this._selectedIndex, this._previousWindow()); + else if (keysym === Clutter.KEY_Right) + this._select(this._selectedIndex, this._nextWindow()); + else if (keysym === Clutter.KEY_Up) + this._select(this._selectedIndex, null, true); + else if (keysym === Clutter.KEY_w || keysym === Clutter.KEY_W || keysym === Clutter.KEY_F4) + this._closeAppWindow(this._selectedIndex, this._currentWindow); + else + return Clutter.EVENT_PROPAGATE; + } else if (keysym == Clutter.KEY_Left) { + this._select(this._previous()); + } else if (keysym == Clutter.KEY_Right) { + this._select(this._next()); + } else if (keysym == Clutter.KEY_Down) { + this._select(this._selectedIndex, 0); + } else { + return Clutter.EVENT_PROPAGATE; + } + + return Clutter.EVENT_STOP; + } + + _scrollHandler(direction) { + if (direction == Clutter.ScrollDirection.UP) { + if (this._thumbnailsFocused) { + if (this._currentWindow == 0 || this._currentWindow == -1) + this._select(this._previous()); + else + this._select(this._selectedIndex, this._previousWindow()); + } else { + let nwindows = this._items[this._selectedIndex].cachedWindows.length; + if (nwindows > 1) + this._select(this._selectedIndex, nwindows - 1); + else + this._select(this._previous()); + } + } else if (direction == Clutter.ScrollDirection.DOWN) { + if (this._thumbnailsFocused) { + if (this._currentWindow == this._items[this._selectedIndex].cachedWindows.length - 1) + this._select(this._next()); + else + this._select(this._selectedIndex, this._nextWindow()); + } else { + let nwindows = this._items[this._selectedIndex].cachedWindows.length; + if (nwindows > 1) + this._select(this._selectedIndex, 0); + else + this._select(this._next()); + } + } + } + + _itemActivatedHandler(n) { + // If the user clicks on the selected app, activate the + // selected window; otherwise (eg, they click on an app while + // !mouseActive) activate the clicked-on app. + if (n == this._selectedIndex && this._currentWindow >= 0) + this._select(n, this._currentWindow); + else + this._select(n); + } + + _itemEnteredHandler(n) { + this._select(n); + } + + _windowActivated(thumbnailSwitcher, n) { + let appIcon = this._items[this._selectedIndex]; + Main.activateWindow(appIcon.cachedWindows[n]); + this.fadeAndDestroy(); + } + + _windowEntered(thumbnailSwitcher, n) { + if (!this.mouseActive) + return; + + this._select(this._selectedIndex, n); + } + + _windowRemoved(thumbnailSwitcher, n) { + let appIcon = this._items[this._selectedIndex]; + if (!appIcon) + return; + + if (appIcon.cachedWindows.length > 0) { + let newIndex = Math.min(n, appIcon.cachedWindows.length - 1); + this._select(this._selectedIndex, newIndex); + } + } + + _finish(timestamp) { + let appIcon = this._items[this._selectedIndex]; + if (this._currentWindow < 0) + appIcon.app.activate_window(appIcon.cachedWindows[0], timestamp); + else if (appIcon.cachedWindows[this._currentWindow]) + Main.activateWindow(appIcon.cachedWindows[this._currentWindow], timestamp); + + super._finish(timestamp); + } + + _onDestroy() { + if (this._thumbnailTimeoutId != 0) + GLib.source_remove(this._thumbnailTimeoutId); + + super._onDestroy(); + } + + /** + * _select: + * @param {number} app: index of the app to select + * @param {number=} window: index of which of @app's windows to select + * @param {bool} forceAppFocus: optional flag, see below + * + * Selects the indicated @app, and optional @window, and sets + * this._thumbnailsFocused appropriately to indicate whether the + * arrow keys should act on the app list or the thumbnail list. + * + * If @app is specified and @window is unspecified or %null, then + * the app is highlighted (ie, given a light background), and the + * current thumbnail list, if any, is destroyed. If @app has + * multiple windows, and @forceAppFocus is not %true, then a + * timeout is started to open a thumbnail list. + * + * If @app and @window are specified (and @forceAppFocus is not), + * then @app will be outlined, a thumbnail list will be created + * and focused (if it hasn't been already), and the @window'th + * window in it will be highlighted. + * + * If @app and @window are specified and @forceAppFocus is %true, + * then @app will be highlighted, and @window outlined, and the + * app list will have the keyboard focus. + */ + _select(app, window, forceAppFocus) { + if (app != this._selectedIndex || window == null) { + if (this._thumbnails) + this._destroyThumbnails(); + } + + if (this._thumbnailTimeoutId != 0) { + GLib.source_remove(this._thumbnailTimeoutId); + this._thumbnailTimeoutId = 0; + } + + this._thumbnailsFocused = (window != null) && !forceAppFocus; + + this._selectedIndex = app; + this._currentWindow = window ? window : -1; + this._switcherList.highlight(app, this._thumbnailsFocused); + + if (window != null) { + if (!this._thumbnails) + this._createThumbnails(); + this._currentWindow = window; + this._thumbnails.highlight(window, forceAppFocus); + } else if (this._items[this._selectedIndex].cachedWindows.length > 1 && + !forceAppFocus) { + this._thumbnailTimeoutId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + THUMBNAIL_POPUP_TIME, + this._timeoutPopupThumbnails.bind(this)); + GLib.Source.set_name_by_id(this._thumbnailTimeoutId, '[gnome-shell] this._timeoutPopupThumbnails'); + } + } + + _timeoutPopupThumbnails() { + if (!this._thumbnails) + this._createThumbnails(); + this._thumbnailTimeoutId = 0; + this._thumbnailsFocused = false; + return GLib.SOURCE_REMOVE; + } + + _destroyThumbnails() { + let thumbnailsActor = this._thumbnails; + this._thumbnails.ease({ + opacity: 0, + duration: THUMBNAIL_FADE_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + thumbnailsActor.destroy(); + this.thumbnailsVisible = false; + }, + }); + this._thumbnails = null; + this._switcherList.removeAccessibleState(this._selectedIndex, Atk.StateType.EXPANDED); + } + + _createThumbnails() { + this._thumbnails = new ThumbnailSwitcher(this._items[this._selectedIndex].cachedWindows); + this._thumbnails.connect('item-activated', this._windowActivated.bind(this)); + this._thumbnails.connect('item-entered', this._windowEntered.bind(this)); + this._thumbnails.connect('item-removed', this._windowRemoved.bind(this)); + this._thumbnails.connect('destroy', () => { + this._thumbnails = null; + this._thumbnailsFocused = false; + }); + + this.add_actor(this._thumbnails); + + // Need to force an allocation so we can figure out whether we + // need to scroll when selecting + this._thumbnails.get_allocation_box(); + + this._thumbnails.opacity = 0; + this._thumbnails.ease({ + opacity: 255, + duration: THUMBNAIL_FADE_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this.thumbnailsVisible = true; + }, + }); + + this._switcherList.addAccessibleState(this._selectedIndex, Atk.StateType.EXPANDED); + } +}); + +var CyclerHighlight = GObject.registerClass( +class CyclerHighlight extends St.Widget { + _init() { + super._init({ layout_manager: new Clutter.BinLayout() }); + this._window = null; + + this._clone = new Clutter.Clone(); + this.add_actor(this._clone); + + this._highlight = new St.Widget({ style_class: 'cycler-highlight' }); + this.add_actor(this._highlight); + + let coordinate = Clutter.BindCoordinate.ALL; + let constraint = new Clutter.BindConstraint({ coordinate }); + this._clone.bind_property('source', constraint, 'source', 0); + + this.add_constraint(constraint); + + this.connect('notify::allocation', this._onAllocationChanged.bind(this)); + this.connect('destroy', this._onDestroy.bind(this)); + } + + set window(w) { + if (this._window == w) + return; + + this._window = w; + + if (this._clone.source) + this._clone.source.sync_visibility(); + + let windowActor = this._window + ? this._window.get_compositor_private() : null; + + if (windowActor) + windowActor.hide(); + + this._clone.source = windowActor; + } + + _onAllocationChanged() { + if (!this._window) { + this._highlight.set_size(0, 0); + this._highlight.hide(); + } else { + let [x, y] = this.allocation.get_origin(); + let rect = this._window.get_frame_rect(); + this._highlight.set_size(rect.width, rect.height); + this._highlight.set_position(rect.x - x, rect.y - y); + this._highlight.show(); + } + } + + _onDestroy() { + this.window = null; + } +}); + +// We don't show an actual popup, so just provide what SwitcherPopup +// expects instead of inheriting from SwitcherList +var CyclerList = GObject.registerClass({ + Signals: { 'item-activated': { param_types: [GObject.TYPE_INT] }, + 'item-entered': { param_types: [GObject.TYPE_INT] }, + 'item-removed': { param_types: [GObject.TYPE_INT] }, + 'item-highlighted': { param_types: [GObject.TYPE_INT] } }, +}, class CyclerList extends St.Widget { + highlight(index, _justOutline) { + this.emit('item-highlighted', index); + } +}); + +var CyclerPopup = GObject.registerClass({ + GTypeFlags: GObject.TypeFlags.ABSTRACT, +}, class CyclerPopup extends SwitcherPopup.SwitcherPopup { + _init() { + super._init(); + + this._items = this._getWindows(); + + this._highlight = new CyclerHighlight(); + global.window_group.add_actor(this._highlight); + + this._switcherList = new CyclerList(); + this._switcherList.connect('item-highlighted', (list, index) => { + this._highlightItem(index); + }); + } + + _highlightItem(index, _justOutline) { + this._highlight.window = this._items[index]; + global.window_group.set_child_above_sibling(this._highlight, null); + } + + _finish() { + let window = this._items[this._selectedIndex]; + let ws = window.get_workspace(); + let workspaceManager = global.workspace_manager; + let activeWs = workspaceManager.get_active_workspace(); + + if (window.minimized) { + Main.wm.skipNextEffect(window.get_compositor_private()); + window.unminimize(); + } + + if (activeWs == ws) { + Main.activateWindow(window); + } else { + // If the selected window is on a different workspace, we don't + // want it to disappear, then slide in with the workspace; instead, + // always activate it on the active workspace ... + activeWs.activate_with_focus(window, global.get_current_time()); + + // ... then slide it over to the original workspace if necessary + Main.wm.actionMoveWindow(window, ws); + } + + super._finish(); + } + + _onDestroy() { + this._highlight.destroy(); + + super._onDestroy(); + } +}); + + +var GroupCyclerPopup = GObject.registerClass( +class GroupCyclerPopup extends CyclerPopup { + _init() { + this._settings = new Gio.Settings({ schema_id: 'org.gnome.shell.app-switcher' }); + super._init(); + } + + _getWindows() { + let app = Shell.WindowTracker.get_default().focus_app; + let appWindows = app ? app.get_windows() : []; + + if (this._settings.get_boolean('current-workspace-only')) { + const workspaceManager = global.workspace_manager; + const workspace = workspaceManager.get_active_workspace(); + appWindows = appWindows.filter( + window => window.located_on_workspace(workspace)); + } + + return appWindows; + } + + _keyPressHandler(keysym, action) { + if (action == Meta.KeyBindingAction.CYCLE_GROUP) + this._select(this._next()); + else if (action == Meta.KeyBindingAction.CYCLE_GROUP_BACKWARD) + this._select(this._previous()); + else + return Clutter.EVENT_PROPAGATE; + + return Clutter.EVENT_STOP; + } +}); + +var WindowSwitcherPopup = GObject.registerClass( +class WindowSwitcherPopup extends SwitcherPopup.SwitcherPopup { + _init() { + super._init(); + this._settings = new Gio.Settings({ schema_id: 'org.gnome.shell.window-switcher' }); + + let windows = this._getWindowList(); + + let mode = this._settings.get_enum('app-icon-mode'); + this._switcherList = new WindowSwitcher(windows, mode); + this._items = this._switcherList.icons; + } + + _getWindowList() { + let workspace = null; + + if (this._settings.get_boolean('current-workspace-only')) { + let workspaceManager = global.workspace_manager; + + workspace = workspaceManager.get_active_workspace(); + } + + return getWindows(workspace); + } + + _closeWindow(windowIndex) { + let windowIcon = this._items[windowIndex]; + if (!windowIcon) + return; + + windowIcon.window.delete(global.get_current_time()); + } + + _keyPressHandler(keysym, action) { + if (action == Meta.KeyBindingAction.SWITCH_WINDOWS) + this._select(this._next()); + else if (action == Meta.KeyBindingAction.SWITCH_WINDOWS_BACKWARD) + this._select(this._previous()); + else if (keysym == Clutter.KEY_Left) + this._select(this._previous()); + else if (keysym == Clutter.KEY_Right) + this._select(this._next()); + else if (keysym === Clutter.KEY_w || keysym === Clutter.KEY_W || keysym === Clutter.KEY_F4) + this._closeWindow(this._selectedIndex); + else + return Clutter.EVENT_PROPAGATE; + + return Clutter.EVENT_STOP; + } + + _finish() { + Main.activateWindow(this._items[this._selectedIndex].window); + + super._finish(); + } +}); + +var WindowCyclerPopup = GObject.registerClass( +class WindowCyclerPopup extends CyclerPopup { + _init() { + this._settings = new Gio.Settings({ schema_id: 'org.gnome.shell.window-switcher' }); + super._init(); + } + + _getWindows() { + let workspace = null; + + if (this._settings.get_boolean('current-workspace-only')) { + let workspaceManager = global.workspace_manager; + + workspace = workspaceManager.get_active_workspace(); + } + + return getWindows(workspace); + } + + _keyPressHandler(keysym, action) { + if (action == Meta.KeyBindingAction.CYCLE_WINDOWS) + this._select(this._next()); + else if (action == Meta.KeyBindingAction.CYCLE_WINDOWS_BACKWARD) + this._select(this._previous()); + else + return Clutter.EVENT_PROPAGATE; + + return Clutter.EVENT_STOP; + } +}); + +var AppIcon = GObject.registerClass( +class AppIcon extends St.BoxLayout { + _init(app) { + super._init({ style_class: 'alt-tab-app', + vertical: true }); + + this.app = app; + this.icon = null; + this._iconBin = new St.Bin(); + + this.add_child(this._iconBin); + this.label = new St.Label({ + text: this.app.get_name(), + x_align: Clutter.ActorAlign.CENTER, + }); + this.add_child(this.label); + } + + // eslint-disable-next-line camelcase + set_size(size) { + this.icon = this.app.create_icon_texture(size); + this._iconBin.child = this.icon; + } +}); + +var AppSwitcher = GObject.registerClass( +class AppSwitcher extends SwitcherPopup.SwitcherList { + _init(apps, altTabPopup) { + super._init(true); + + this.icons = []; + this._arrows = []; + + let windowTracker = Shell.WindowTracker.get_default(); + let settings = new Gio.Settings({ schema_id: 'org.gnome.shell.app-switcher' }); + + let workspace = null; + if (settings.get_boolean('current-workspace-only')) { + let workspaceManager = global.workspace_manager; + + workspace = workspaceManager.get_active_workspace(); + } + + let allWindows = global.display.get_tab_list(Meta.TabList.NORMAL, workspace); + + // Construct the AppIcons, add to the popup + for (let i = 0; i < apps.length; i++) { + let appIcon = new AppIcon(apps[i]); + // Cache the window list now; we don't handle dynamic changes here, + // and we don't want to be continually retrieving it + appIcon.cachedWindows = allWindows.filter( + w => windowTracker.get_window_app(w) === appIcon.app); + if (appIcon.cachedWindows.length > 0) + this._addIcon(appIcon); + } + + this._curApp = -1; + this._altTabPopup = altTabPopup; + this._mouseTimeOutId = 0; + + this.connect('destroy', this._onDestroy.bind(this)); + } + + _onDestroy() { + if (this._mouseTimeOutId != 0) + GLib.source_remove(this._mouseTimeOutId); + + this.icons.forEach(icon => { + icon.app.disconnect(icon._stateChangedId); + }); + } + + _setIconSize() { + let j = 0; + while (this._items.length > 1 && this._items[j].style_class != 'item-box') + j++; + + let themeNode = this._items[j].get_theme_node(); + this._list.ensure_style(); + + let iconPadding = themeNode.get_horizontal_padding(); + let iconBorder = themeNode.get_border_width(St.Side.LEFT) + themeNode.get_border_width(St.Side.RIGHT); + let [, labelNaturalHeight] = this.icons[j].label.get_preferred_height(-1); + let iconSpacing = labelNaturalHeight + iconPadding + iconBorder; + let totalSpacing = this._list.spacing * (this._items.length - 1); + + // We just assume the whole screen here due to weirdness happening with the passed width + let primary = Main.layoutManager.primaryMonitor; + let parentPadding = this.get_parent().get_theme_node().get_horizontal_padding(); + let availWidth = primary.width - parentPadding - this.get_theme_node().get_horizontal_padding(); + + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + let iconSizes = baseIconSizes.map(s => s * scaleFactor); + let iconSize = baseIconSizes[0]; + + if (this._items.length > 1) { + for (let i = 0; i < baseIconSizes.length; i++) { + iconSize = baseIconSizes[i]; + let height = iconSizes[i] + iconSpacing; + let w = height * this._items.length + totalSpacing; + if (w <= availWidth) + break; + } + } + + this._iconSize = iconSize; + + for (let i = 0; i < this.icons.length; i++) { + if (this.icons[i].icon != null) + break; + this.icons[i].set_size(iconSize); + } + } + + vfunc_get_preferred_height(forWidth) { + this._setIconSize(); + return super.vfunc_get_preferred_height(forWidth); + } + + vfunc_allocate(box) { + // Allocate the main list items + super.vfunc_allocate(box); + + let contentBox = this.get_theme_node().get_content_box(box); + + let arrowHeight = Math.floor(this.get_theme_node().get_padding(St.Side.BOTTOM) / 3); + let arrowWidth = arrowHeight * 2; + + // Now allocate each arrow underneath its item + let childBox = new Clutter.ActorBox(); + for (let i = 0; i < this._items.length; i++) { + let itemBox = this._items[i].allocation; + childBox.x1 = contentBox.x1 + Math.floor(itemBox.x1 + (itemBox.x2 - itemBox.x1 - arrowWidth) / 2); + childBox.x2 = childBox.x1 + arrowWidth; + childBox.y1 = contentBox.y1 + itemBox.y2 + arrowHeight; + childBox.y2 = childBox.y1 + arrowHeight; + this._arrows[i].allocate(childBox); + } + } + + // We override SwitcherList's _onItemEnter method to delay + // activation when the thumbnail list is open + _onItemEnter(item) { + const index = this._items.indexOf(item); + + if (this._mouseTimeOutId != 0) + GLib.source_remove(this._mouseTimeOutId); + if (this._altTabPopup.thumbnailsVisible) { + this._mouseTimeOutId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + APP_ICON_HOVER_TIMEOUT, + () => { + this._enterItem(index); + this._mouseTimeOutId = 0; + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._mouseTimeOutId, '[gnome-shell] this._enterItem'); + } else { + this._itemEntered(index); + } + } + + _enterItem(index) { + let [x, y] = global.get_pointer(); + let pickedActor = global.stage.get_actor_at_pos(Clutter.PickMode.ALL, x, y); + if (this._items[index].contains(pickedActor)) + this._itemEntered(index); + } + + // We override SwitcherList's highlight() method to also deal with + // the AppSwitcher->ThumbnailSwitcher arrows. Apps with only 1 window + // will hide their arrows by default, but show them when their + // thumbnails are visible (ie, when the app icon is supposed to be + // in justOutline mode). Apps with multiple windows will normally + // show a dim arrow, but show a bright arrow when they are + // highlighted. + highlight(n, justOutline) { + if (this.icons[this._curApp]) { + if (this.icons[this._curApp].cachedWindows.length == 1) + this._arrows[this._curApp].hide(); + else + this._arrows[this._curApp].remove_style_pseudo_class('highlighted'); + } + + super.highlight(n, justOutline); + this._curApp = n; + + if (this._curApp != -1) { + if (justOutline && this.icons[this._curApp].cachedWindows.length == 1) + this._arrows[this._curApp].show(); + else + this._arrows[this._curApp].add_style_pseudo_class('highlighted'); + } + } + + _addIcon(appIcon) { + this.icons.push(appIcon); + let item = this.addItem(appIcon, appIcon.label); + + appIcon._stateChangedId = appIcon.app.connect('notify::state', app => { + if (app.state != Shell.AppState.RUNNING) + this._removeIcon(app); + }); + + let arrow = new St.DrawingArea({ style_class: 'switcher-arrow' }); + arrow.connect('repaint', () => SwitcherPopup.drawArrow(arrow, St.Side.BOTTOM)); + this.add_actor(arrow); + this._arrows.push(arrow); + + if (appIcon.cachedWindows.length == 1) + arrow.hide(); + else + item.add_accessible_state(Atk.StateType.EXPANDABLE); + } + + _removeIcon(app) { + let index = this.icons.findIndex(icon => { + return icon.app == app; + }); + if (index === -1) + return; + + this._arrows[index].destroy(); + this._arrows.splice(index, 1); + + this.icons.splice(index, 1); + this.removeItem(index); + } +}); + +var ThumbnailSwitcher = GObject.registerClass( +class ThumbnailSwitcher extends SwitcherPopup.SwitcherList { + _init(windows) { + super._init(false); + + this._labels = []; + this._thumbnailBins = []; + this._clones = []; + this._windows = windows; + + for (let i = 0; i < windows.length; i++) { + let box = new St.BoxLayout({ style_class: 'thumbnail-box', + vertical: true }); + + let bin = new St.Bin({ style_class: 'thumbnail' }); + + box.add_actor(bin); + this._thumbnailBins.push(bin); + + let title = windows[i].get_title(); + if (title) { + let name = new St.Label({ + text: title, + // St.Label doesn't support text-align + x_align: Clutter.ActorAlign.CENTER, + }); + this._labels.push(name); + box.add_actor(name); + + this.addItem(box, name); + } else { + this.addItem(box, null); + } + + } + + this.connect('destroy', this._onDestroy.bind(this)); + } + + addClones(availHeight) { + if (!this._thumbnailBins.length) + return; + let totalPadding = this._items[0].get_theme_node().get_horizontal_padding() + this._items[0].get_theme_node().get_vertical_padding(); + totalPadding += this.get_theme_node().get_horizontal_padding() + this.get_theme_node().get_vertical_padding(); + let [, labelNaturalHeight] = this._labels[0].get_preferred_height(-1); + let spacing = this._items[0].child.get_theme_node().get_length('spacing'); + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + let thumbnailSize = THUMBNAIL_DEFAULT_SIZE * scaleFactor; + + availHeight = Math.min(availHeight - labelNaturalHeight - totalPadding - spacing, thumbnailSize); + let binHeight = availHeight + this._items[0].get_theme_node().get_vertical_padding() + this.get_theme_node().get_vertical_padding() - spacing; + binHeight = Math.min(thumbnailSize, binHeight); + + for (let i = 0; i < this._thumbnailBins.length; i++) { + let mutterWindow = this._windows[i].get_compositor_private(); + if (!mutterWindow) + continue; + + let clone = _createWindowClone(mutterWindow, thumbnailSize); + this._thumbnailBins[i].set_height(binHeight); + this._thumbnailBins[i].add_actor(clone); + + clone._destroyId = mutterWindow.connect('destroy', source => { + this._removeThumbnail(source, clone); + }); + this._clones.push(clone); + } + + // Make sure we only do this once + this._thumbnailBins = []; + } + + _removeThumbnail(source, clone) { + let index = this._clones.indexOf(clone); + if (index === -1) + return; + + this._clones.splice(index, 1); + this._windows.splice(index, 1); + this._labels.splice(index, 1); + this.removeItem(index); + + if (this._clones.length > 0) + this.highlight(SwitcherPopup.mod(index, this._clones.length)); + else + this.destroy(); + } + + _onDestroy() { + this._clones.forEach(clone => { + if (clone.source) + clone.source.disconnect(clone._destroyId); + }); + } +}); + +var WindowIcon = GObject.registerClass( +class WindowIcon extends St.BoxLayout { + _init(window, mode) { + super._init({ style_class: 'alt-tab-app', + vertical: true }); + + this.window = window; + + this._icon = new St.Widget({ layout_manager: new Clutter.BinLayout() }); + + this.add_child(this._icon); + this.label = new St.Label({ text: window.get_title() }); + + let tracker = Shell.WindowTracker.get_default(); + this.app = tracker.get_window_app(window); + + let mutterWindow = this.window.get_compositor_private(); + let size; + + this._icon.destroy_all_children(); + + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + + switch (mode) { + case AppIconMode.THUMBNAIL_ONLY: + size = WINDOW_PREVIEW_SIZE; + this._icon.add_actor(_createWindowClone(mutterWindow, size * scaleFactor)); + break; + + case AppIconMode.BOTH: + size = WINDOW_PREVIEW_SIZE; + this._icon.add_actor(_createWindowClone(mutterWindow, size * scaleFactor)); + + if (this.app) { + this._icon.add_actor(this._createAppIcon(this.app, + APP_ICON_SIZE_SMALL)); + } + break; + + case AppIconMode.APP_ICON_ONLY: + size = APP_ICON_SIZE; + this._icon.add_actor(this._createAppIcon(this.app, size)); + } + + this._icon.set_size(size * scaleFactor, size * scaleFactor); + } + + _createAppIcon(app, size) { + let appIcon = app + ? app.create_icon_texture(size) + : new St.Icon({ icon_name: 'icon-missing', icon_size: size }); + appIcon.x_expand = appIcon.y_expand = true; + appIcon.x_align = appIcon.y_align = Clutter.ActorAlign.END; + + return appIcon; + } +}); + +var WindowSwitcher = GObject.registerClass( +class WindowSwitcher extends SwitcherPopup.SwitcherList { + _init(windows, mode) { + super._init(true); + + this._label = new St.Label({ x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER }); + this.add_actor(this._label); + + this.windows = windows; + this.icons = []; + + for (let i = 0; i < windows.length; i++) { + let win = windows[i]; + let icon = new WindowIcon(win, mode); + + this.addItem(icon, icon.label); + this.icons.push(icon); + + icon._unmanagedSignalId = icon.window.connect('unmanaged', window => { + this._removeWindow(window); + }); + } + + this.connect('destroy', this._onDestroy.bind(this)); + } + + _onDestroy() { + this.icons.forEach(icon => { + icon.window.disconnect(icon._unmanagedSignalId); + }); + } + + vfunc_get_preferred_height(forWidth) { + let [minHeight, natHeight] = super.vfunc_get_preferred_height(forWidth); + + let spacing = this.get_theme_node().get_padding(St.Side.BOTTOM); + let [labelMin, labelNat] = this._label.get_preferred_height(-1); + + minHeight += labelMin + spacing; + natHeight += labelNat + spacing; + + return [minHeight, natHeight]; + } + + vfunc_allocate(box) { + let themeNode = this.get_theme_node(); + let contentBox = themeNode.get_content_box(box); + const labelHeight = this._label.height; + const totalLabelHeight = + labelHeight + themeNode.get_padding(St.Side.BOTTOM); + + box.y2 -= totalLabelHeight; + super.vfunc_allocate(box); + + // Hooking up the parent vfunc will call this.set_allocation() with + // the height without the label height, so call it again with the + // correct size here. + box.y2 += totalLabelHeight; + this.set_allocation(box); + + const childBox = new Clutter.ActorBox(); + childBox.x1 = contentBox.x1; + childBox.x2 = contentBox.x2; + childBox.y2 = contentBox.y2; + childBox.y1 = childBox.y2 - labelHeight; + this._label.allocate(childBox); + } + + highlight(index, justOutline) { + super.highlight(index, justOutline); + + this._label.set_text(index == -1 ? '' : this.icons[index].label.text); + } + + _removeWindow(window) { + let index = this.icons.findIndex(icon => { + return icon.window == window; + }); + if (index === -1) + return; + + this.icons.splice(index, 1); + this.removeItem(index); + } +}); diff --git a/js/ui/animation.js b/js/ui/animation.js new file mode 100644 index 0000000..5cb3a83 --- /dev/null +++ b/js/ui/animation.js @@ -0,0 +1,201 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Animation, AnimatedIcon, Spinner */ + +const { Clutter, GLib, GObject, Gio, St } = imports.gi; + +const Params = imports.misc.params; + +var ANIMATED_ICON_UPDATE_TIMEOUT = 16; +var SPINNER_ANIMATION_TIME = 300; +var SPINNER_ANIMATION_DELAY = 1000; + +var Animation = GObject.registerClass( +class Animation extends St.Bin { + _init(file, width, height, speed) { + const themeContext = St.ThemeContext.get_for_stage(global.stage); + + super._init({ + style: `width: ${width}px; height: ${height}px;`, + }); + + this.connect('destroy', this._onDestroy.bind(this)); + this.connect('resource-scale-changed', + this._loadFile.bind(this, file, width, height)); + + this._scaleChangedId = themeContext.connect('notify::scale-factor', + () => { + this._loadFile(file, width, height); + this.set_size(width * themeContext.scale_factor, height * themeContext.scale_factor); + }); + + this._speed = speed; + + this._isLoaded = false; + this._isPlaying = false; + this._timeoutId = 0; + this._frame = 0; + + this._loadFile(file, width, height); + } + + play() { + if (this._isLoaded && this._timeoutId == 0) { + if (this._frame == 0) + this._showFrame(0); + + this._timeoutId = GLib.timeout_add(GLib.PRIORITY_LOW, this._speed, this._update.bind(this)); + GLib.Source.set_name_by_id(this._timeoutId, '[gnome-shell] this._update'); + } + + this._isPlaying = true; + } + + stop() { + if (this._timeoutId > 0) { + GLib.source_remove(this._timeoutId); + this._timeoutId = 0; + } + + this._isPlaying = false; + } + + _loadFile(file, width, height) { + const resourceScale = this.get_resource_scale(); + let wasPlaying = this._isPlaying; + + if (this._isPlaying) + this.stop(); + + this._isLoaded = false; + this.destroy_all_children(); + + let textureCache = St.TextureCache.get_default(); + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + this._animations = textureCache.load_sliced_image(file, width, height, + scaleFactor, resourceScale, + this._animationsLoaded.bind(this)); + this._animations.set({ + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + this.set_child(this._animations); + + if (wasPlaying) + this.play(); + } + + _showFrame(frame) { + let oldFrameActor = this._animations.get_child_at_index(this._frame); + if (oldFrameActor) + oldFrameActor.hide(); + + this._frame = frame % this._animations.get_n_children(); + + let newFrameActor = this._animations.get_child_at_index(this._frame); + if (newFrameActor) + newFrameActor.show(); + } + + _update() { + this._showFrame(this._frame + 1); + return GLib.SOURCE_CONTINUE; + } + + _syncAnimationSize() { + if (!this._isLoaded) + return; + + let [width, height] = this.get_size(); + + for (let i = 0; i < this._animations.get_n_children(); ++i) + this._animations.get_child_at_index(i).set_size(width, height); + } + + _animationsLoaded() { + this._isLoaded = this._animations.get_n_children() > 0; + + this._syncAnimationSize(); + + if (this._isPlaying) + this.play(); + } + + _onDestroy() { + this.stop(); + + let themeContext = St.ThemeContext.get_for_stage(global.stage); + if (this._scaleChangedId) + themeContext.disconnect(this._scaleChangedId); + this._scaleChangedId = 0; + } +}); + +var AnimatedIcon = GObject.registerClass( +class AnimatedIcon extends Animation { + _init(file, size) { + super._init(file, size, size, ANIMATED_ICON_UPDATE_TIMEOUT); + } +}); + +var Spinner = GObject.registerClass( +class Spinner extends AnimatedIcon { + _init(size, params) { + params = Params.parse(params, { + animate: false, + hideOnStop: false, + }); + let file = Gio.File.new_for_uri('resource:///org/gnome/shell/theme/process-working.svg'); + super._init(file, size); + + this.opacity = 0; + this._animate = params.animate; + this._hideOnStop = params.hideOnStop; + this.visible = !this._hideOnStop; + } + + _onDestroy() { + this._animate = false; + super._onDestroy(); + } + + play() { + this.remove_all_transitions(); + this.show(); + + if (this._animate) { + super.play(); + this.ease({ + opacity: 255, + delay: SPINNER_ANIMATION_DELAY, + duration: SPINNER_ANIMATION_TIME, + mode: Clutter.AnimationMode.LINEAR, + }); + } else { + this.opacity = 255; + super.play(); + } + } + + stop() { + this.remove_all_transitions(); + + if (this._animate) { + this.ease({ + opacity: 0, + duration: SPINNER_ANIMATION_TIME, + mode: Clutter.AnimationMode.LINEAR, + onComplete: () => { + super.stop(); + if (this._hideOnStop) + this.hide(); + }, + }); + } else { + this.opacity = 0; + super.stop(); + + if (this._hideOnStop) + this.hide(); + } + } +}); diff --git a/js/ui/appDisplay.js b/js/ui/appDisplay.js new file mode 100644 index 0000000..399ab54 --- /dev/null +++ b/js/ui/appDisplay.js @@ -0,0 +1,2998 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported AppDisplay, AppSearchProvider */ + +const { Clutter, Gio, GLib, GObject, Graphene, Meta, Shell, St } = imports.gi; +const Signals = imports.signals; + +const AppFavorites = imports.ui.appFavorites; +const DND = imports.ui.dnd; +const GrabHelper = imports.ui.grabHelper; +const IconGrid = imports.ui.iconGrid; +const Layout = imports.ui.layout; +const Main = imports.ui.main; +const PageIndicators = imports.ui.pageIndicators; +const ParentalControlsManager = imports.misc.parentalControlsManager; +const PopupMenu = imports.ui.popupMenu; +const Search = imports.ui.search; +const SwipeTracker = imports.ui.swipeTracker; +const Params = imports.misc.params; +const SystemActions = imports.misc.systemActions; + +var MENU_POPUP_TIMEOUT = 600; +var POPDOWN_DIALOG_TIMEOUT = 500; + +var FOLDER_SUBICON_FRACTION = .4; + +var VIEWS_SWITCH_TIME = 400; +var VIEWS_SWITCH_ANIMATION_DELAY = 100; + +var SCROLL_TIMEOUT_TIME = 150; + +var APP_ICON_SCALE_IN_TIME = 500; +var APP_ICON_SCALE_IN_DELAY = 700; + +const FOLDER_DIALOG_ANIMATION_TIME = 200; + +const OVERSHOOT_THRESHOLD = 20; +const OVERSHOOT_TIMEOUT = 1000; + +const DELAYED_MOVE_TIMEOUT = 200; + +const DIALOG_SHADE_NORMAL = Clutter.Color.from_pixel(0x000000cc); +const DIALOG_SHADE_HIGHLIGHT = Clutter.Color.from_pixel(0x00000055); + +let discreteGpuAvailable = false; + +function _getCategories(info) { + let categoriesStr = info.get_categories(); + if (!categoriesStr) + return []; + return categoriesStr.split(';'); +} + +function _listsIntersect(a, b) { + for (let itemA of a) { + if (b.includes(itemA)) + return true; + } + return false; +} + +function _getFolderName(folder) { + let name = folder.get_string('name'); + + if (folder.get_boolean('translate')) { + let translated = Shell.util_get_translated_folder_name(name); + if (translated !== null) + return translated; + } + + return name; +} + +function _getViewFromIcon(icon) { + for (let parent = icon.get_parent(); parent; parent = parent.get_parent()) { + if (parent instanceof BaseAppView) + return parent; + } + return null; +} + +function _findBestFolderName(apps) { + let appInfos = apps.map(app => app.get_app_info()); + + let categoryCounter = {}; + let commonCategories = []; + + appInfos.reduce((categories, appInfo) => { + for (let category of _getCategories(appInfo)) { + if (!(category in categoryCounter)) + categoryCounter[category] = 0; + + categoryCounter[category] += 1; + + // If a category is present in all apps, its counter will + // reach appInfos.length + if (category.length > 0 && + categoryCounter[category] == appInfos.length) + categories.push(category); + } + return categories; + }, commonCategories); + + for (let category of commonCategories) { + const directory = '%s.directory'.format(category); + const translated = Shell.util_get_translated_folder_name(directory); + if (translated !== null) + return translated; + } + + return null; +} + +var BaseAppView = GObject.registerClass({ + GTypeFlags: GObject.TypeFlags.ABSTRACT, + Properties: { + 'use-pagination': GObject.ParamSpec.boolean( + 'use-pagination', 'use-pagination', 'use-pagination', + GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, + false), + }, + Signals: { + 'view-loaded': {}, + }, +}, class BaseAppView extends St.Widget { + _init(params = {}, orientation = Clutter.Orientation.VERTICAL) { + super._init(params); + + this._grid = this._createGrid(); + this._grid._delegate = this; + // Standard hack for ClutterBinLayout + this._grid.x_expand = true; + this._grid.connect('pages-changed', () => { + this._adjustment.value = 0; + this.goToPage(this._grid.currentPage); + this._pageIndicators.setNPages(this._grid.nPages); + this._pageIndicators.setCurrentPosition(this._grid.currentPage); + }); + + const vertical = orientation === Clutter.Orientation.VERTICAL; + + // Scroll View + this._scrollView = new St.ScrollView({ + clip_to_allocation: true, + x_expand: true, + y_expand: true, + reactive: true, + }); + this._scrollView.set_policy( + vertical ? St.PolicyType.NEVER : St.PolicyType.EXTERNAL, + vertical ? St.PolicyType.EXTERNAL : St.PolicyType.NEVER); + + this._canScroll = true; // limiting scrolling speed + this._scrollTimeoutId = 0; + this._scrollView.connect('scroll-event', this._onScroll.bind(this)); + + this._scrollView.add_actor(this._grid); + + const scroll = vertical ? this._scrollView.vscroll : this._scrollView.hscroll; + this._adjustment = scroll.adjustment; + this._adjustment.connect('notify::value', adj => { + this._pageIndicators.setCurrentPosition(adj.value / adj.page_size); + }); + + // Page Indicators + if (vertical) + this._pageIndicators = new PageIndicators.AnimatedPageIndicators(); + else + this._pageIndicators = new PageIndicators.PageIndicators(orientation); + + this._pageIndicators.y_expand = vertical; + this._pageIndicators.connect('page-activated', + (indicators, pageIndex) => { + this.goToPage(pageIndex); + }); + this._pageIndicators.connect('scroll-event', (actor, event) => { + this._scrollView.event(event, false); + }); + + // Swipe + this._swipeTracker = new SwipeTracker.SwipeTracker(this._scrollView, + Shell.ActionMode.OVERVIEW | Shell.ActionMode.POPUP); + this._swipeTracker.orientation = orientation; + this._swipeTracker.connect('begin', this._swipeBegin.bind(this)); + this._swipeTracker.connect('update', this._swipeUpdate.bind(this)); + this._swipeTracker.connect('end', this._swipeEnd.bind(this)); + + this._availWidth = 0; + this._availHeight = 0; + this._orientation = orientation; + + this._items = new Map(); + this._orderedItems = []; + + this._animateLaterId = 0; + this._viewLoadedHandlerId = 0; + this._viewIsReady = false; + + // Filter the apps through the user’s parental controls. + this._parentalControlsManager = ParentalControlsManager.getDefault(); + this._appFilterChangedId = + this._parentalControlsManager.connect('app-filter-changed', () => { + this._redisplay(); + }); + + // Drag n' Drop + this._lastOvershoot = -1; + this._lastOvershootTimeoutId = 0; + this._delayedMoveData = null; + + this._dragBeginId = 0; + this._dragEndId = 0; + this._dragCancelledId = 0; + + this.connect('destroy', this._onDestroy.bind(this)); + } + + _onDestroy() { + if (this._appFilterChangedId > 0) { + this._parentalControlsManager.disconnect(this._appFilterChangedId); + this._appFilterChangedId = 0; + } + + if (this._swipeTracker) { + this._swipeTracker.destroy(); + delete this._swipeTracker; + } + + this._removeDelayedMove(); + this._disconnectDnD(); + } + + _createGrid() { + return new IconGrid.IconGrid({ allow_incomplete_pages: true }); + } + + _onScroll(actor, event) { + if (this._swipeTracker.canHandleScrollEvent(event)) + return Clutter.EVENT_PROPAGATE; + + if (!this._canScroll) + return Clutter.EVENT_STOP; + + const rtl = this.get_text_direction() === Clutter.TextDirection.RTL; + const vertical = this._orientation === Clutter.Orientation.VERTICAL; + + let nextPage = this._grid.currentPage; + switch (event.get_scroll_direction()) { + case Clutter.ScrollDirection.UP: + nextPage -= 1; + break; + + case Clutter.ScrollDirection.DOWN: + nextPage += 1; + break; + + case Clutter.ScrollDirection.LEFT: + if (vertical) + return Clutter.EVENT_STOP; + nextPage += rtl ? 1 : -1; + break; + + case Clutter.ScrollDirection.RIGHT: + if (vertical) + return Clutter.EVENT_STOP; + nextPage += rtl ? -1 : 1; + break; + + default: + return Clutter.EVENT_STOP; + } + + this.goToPage(nextPage); + + this._canScroll = false; + this._scrollTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, + SCROLL_TIMEOUT_TIME, () => { + this._canScroll = true; + this._scrollTimeoutId = 0; + return GLib.SOURCE_REMOVE; + }); + + return Clutter.EVENT_STOP; + } + + _swipeBegin(tracker, monitor) { + if (monitor !== Main.layoutManager.primaryIndex) + return; + + const adjustment = this._adjustment; + adjustment.remove_transition('value'); + + const progress = adjustment.value / adjustment.page_size; + const points = Array.from({ length: this._grid.nPages }, (v, i) => i); + const size = tracker.orientation === Clutter.Orientation.VERTICAL + ? this._scrollView.height : this._scrollView.width; + + tracker.confirmSwipe(size, points, progress, Math.round(progress)); + } + + _swipeUpdate(tracker, progress) { + const adjustment = this._adjustment; + adjustment.value = progress * adjustment.page_size; + } + + _swipeEnd(tracker, duration, endProgress) { + const adjustment = this._adjustment; + const value = endProgress * adjustment.page_size; + + adjustment.ease(value, { + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + duration, + onComplete: () => this.goToPage(endProgress, false), + }); + } + + _connectDnD() { + this._dragBeginId = + Main.overview.connect('item-drag-begin', this._onDragBegin.bind(this)); + this._dragEndId = + Main.overview.connect('item-drag-end', this._onDragEnd.bind(this)); + this._dragCancelledId = + Main.overview.connect('item-drag-cancelled', this._onDragCancelled.bind(this)); + } + + _disconnectDnD() { + if (this._dragBeginId > 0) { + Main.overview.disconnect(this._dragBeginId); + this._dragBeginId = 0; + } + + if (this._dragEndId > 0) { + Main.overview.disconnect(this._dragEndId); + this._dragEndId = 0; + } + + if (this._dragCancelledId > 0) { + Main.overview.disconnect(this._dragCancelledId); + this._dragCancelledId = 0; + } + + if (this._dragMonitor) { + DND.removeDragMonitor(this._dragMonitor); + this._dragMonitor = null; + } + } + + _maybeMoveItem(dragEvent) { + const [success, x, y] = + this._grid.transform_stage_point(dragEvent.x, dragEvent.y); + + if (!success) + return; + + const { source } = dragEvent; + const [page, position, dragLocation] = + this._getDropTarget(x, y, source); + const item = position !== -1 + ? this._grid.getItemAt(page, position) : null; + + + // Dragging over invalid parts of the grid cancels the timeout + if (item === source || + dragLocation === IconGrid.DragLocation.INVALID || + dragLocation === IconGrid.DragLocation.ON_ICON) { + this._removeDelayedMove(); + return; + } + + if (!this._delayedMoveData || + this._delayedMoveData.page !== page || + this._delayedMoveData.position !== position) { + // Update the item with a small delay + this._removeDelayedMove(); + this._delayedMoveData = { + page, + position, + source, + destroyId: source.connect('destroy', () => this._removeDelayedMove()), + timeoutId: GLib.timeout_add(GLib.PRIORITY_DEFAULT, + DELAYED_MOVE_TIMEOUT, () => { + this._moveItem(source, page, position); + this._delayedMoveData.timeoutId = 0; + this._removeDelayedMove(); + return GLib.SOURCE_REMOVE; + }), + }; + } + } + + _removeDelayedMove() { + if (!this._delayedMoveData) + return; + + const { source, destroyId, timeoutId } = this._delayedMoveData; + + if (timeoutId > 0) + GLib.source_remove(timeoutId); + + if (destroyId > 0) + source.disconnect(destroyId); + + this._delayedMoveData = null; + } + + _resetOvershoot() { + if (this._lastOvershootTimeoutId) + GLib.source_remove(this._lastOvershootTimeoutId); + this._lastOvershootTimeoutId = 0; + this._lastOvershoot = -1; + } + + _handleDragOvershoot(dragEvent) { + const [gridX, gridY] = this.get_transformed_position(); + const [gridWidth, gridHeight] = this.get_transformed_size(); + + const vertical = this._orientation === Clutter.Orientation.VERTICAL; + const gridStart = vertical ? gridY : gridX; + const gridEnd = vertical + ? gridY + gridHeight - OVERSHOOT_THRESHOLD + : gridX + gridWidth - OVERSHOOT_THRESHOLD; + + // Already animating + if (this._adjustment.get_transition('value') !== null) + return; + + // Within the grid boundaries + const dragPosition = vertical ? dragEvent.y : dragEvent.x; + if (dragPosition > gridStart && dragPosition < gridEnd) { + // Check whether we moved out the area of the last switch + if (Math.abs(this._lastOvershoot - dragPosition) > OVERSHOOT_THRESHOLD) + this._resetOvershoot(); + + return; + } + + // Still in the area of the previous page switch + if (this._lastOvershoot >= 0) + return; + + const currentPosition = this._adjustment.value; + const maxPosition = this._adjustment.upper - this._adjustment.page_size; + + if (dragPosition <= gridStart && currentPosition > 0) + this.goToPage(this._grid.currentPage - 1); + else if (dragPosition >= gridEnd && currentPosition < maxPosition) + this.goToPage(this._grid.currentPage + 1); + else + return; // don't go beyond first/last page + + this._lastOvershoot = dragPosition; + + if (this._lastOvershootTimeoutId > 0) + GLib.source_remove(this._lastOvershootTimeoutId); + + this._lastOvershootTimeoutId = + GLib.timeout_add(GLib.PRIORITY_DEFAULT, OVERSHOOT_TIMEOUT, () => { + this._resetOvershoot(); + this._handleDragOvershoot(dragEvent); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._lastOvershootTimeoutId, + '[gnome-shell] this._lastOvershootTimeoutId'); + } + + _onDragBegin() { + this._dragMonitor = { + dragMotion: this._onDragMotion.bind(this), + }; + DND.addDragMonitor(this._dragMonitor); + } + + _onDragMotion(dragEvent) { + if (!(dragEvent.source instanceof AppViewItem)) + return DND.DragMotionResult.CONTINUE; + + const appIcon = dragEvent.source; + + // Handle the drag overshoot. When dragging to above the + // icon grid, move to the page above; when dragging below, + // move to the page below. + if (appIcon instanceof AppViewItem) + this._handleDragOvershoot(dragEvent); + + this._maybeMoveItem(dragEvent); + + return DND.DragMotionResult.CONTINUE; + } + + _onDragEnd() { + if (this._dragMonitor) { + DND.removeDragMonitor(this._dragMonitor); + this._dragMonitor = null; + } + + this._resetOvershoot(); + } + + _onDragCancelled() { + // At this point, the positions aren't stored yet, thus _redisplay() + // will move all items to their original positions + this._redisplay(); + } + + _canAccept(source) { + return source instanceof AppViewItem; + } + + handleDragOver(source) { + if (!this._canAccept(source)) + return DND.DragMotionResult.NO_DROP; + + return DND.DragMotionResult.MOVE_DROP; + } + + acceptDrop(source) { + if (!this._canAccept(source)) + return false; + + // Dropped before the icon was moved + if (this._delayedMoveData) { + const { page, position } = this._delayedMoveData; + + this._moveItem(source, page, position); + this._removeDelayedMove(); + } + + return true; + } + + _findBestPageToAppend(startPage = 1) { + for (let i = startPage; i < this._grid.nPages; i++) { + const pageItems = + this._grid.getItemsAtPage(i).filter(c => c.visible); + + if (pageItems.length < this._grid.itemsPerPage) + return i; + } + + return -1; + } + + _getLinearPosition(page, position) { + let itemIndex = 0; + + if (this._grid.nPages > 0) { + const realPage = page === -1 ? this._grid.nPages - 1 : page; + + itemIndex = position === -1 + ? this._grid.getItemsAtPage(realPage).filter(c => c.visible).length - 1 + : position; + + for (let i = 0; i < realPage; i++) { + const pageItems = this._grid.getItemsAtPage(i).filter(c => c.visible); + itemIndex += pageItems.length; + } + } + + return itemIndex; + } + + _addItem(item, page, position) { + // Append icons to the first page with empty slot, starting from + // the second page + if (this._grid.nPages > 1 && page === -1 && position === -1) + page = this._findBestPageToAppend(); + + const itemIndex = this._getLinearPosition(page, position); + + this._orderedItems.splice(itemIndex, 0, item); + this._items.set(item.id, item); + this._grid.addItem(item, page, position); + } + + _removeItem(item) { + const iconIndex = this._orderedItems.indexOf(item); + + this._orderedItems.splice(iconIndex, 1); + this._items.delete(item.id); + this._grid.removeItem(item); + } + + _getItemPosition(item) { + const { itemsPerPage } = this._grid; + + let iconIndex = this._orderedItems.indexOf(item); + if (iconIndex === -1) + iconIndex = this._orderedItems.length - 1; + + const page = Math.floor(iconIndex / itemsPerPage); + const position = iconIndex % itemsPerPage; + + return [page, position]; + } + + _redisplay() { + let oldApps = this._orderedItems.slice(); + let oldAppIds = oldApps.map(icon => icon.id); + + let newApps = this._loadApps().sort(this._compareItems.bind(this)); + let newAppIds = newApps.map(icon => icon.id); + + let addedApps = newApps.filter(icon => !oldAppIds.includes(icon.id)); + let removedApps = oldApps.filter(icon => !newAppIds.includes(icon.id)); + + // Remove old app icons + removedApps.forEach(icon => { + this._removeItem(icon); + icon.destroy(); + }); + + // Add new app icons, or move existing ones + newApps.forEach(icon => { + const [page, position] = this._getItemPosition(icon); + if (addedApps.includes(icon)) + this._addItem(icon, page, position); + else if (page !== -1 && position !== -1) + this._moveItem(icon, page, position); + }); + + this._viewIsReady = true; + this.emit('view-loaded'); + } + + getAllItems() { + return this._orderedItems; + } + + _compareItems(a, b) { + return a.name.localeCompare(b.name); + } + + _selectAppInternal(id) { + if (this._items.has(id)) + this._items.get(id).navigate_focus(null, St.DirectionType.TAB_FORWARD, false); + else + log('No such application %s'.format(id)); + } + + selectApp(id) { + if (this._items.has(id)) { + let item = this._items.get(id); + + if (item.mapped) { + this._selectAppInternal(id); + } else { + // Need to wait until the view is mapped + let signalId = item.connect('notify::mapped', actor => { + if (actor.mapped) { + actor.disconnect(signalId); + this._selectAppInternal(id); + } + }); + } + } else { + // Need to wait until the view is built + let signalId = this.connect('view-loaded', () => { + this.disconnect(signalId); + this.selectApp(id); + }); + } + } + + _doSpringAnimation(animationDirection) { + this._grid.opacity = 255; + this._grid.animateSpring( + animationDirection, + Main.overview.dash.showAppsButton); + } + + _clearAnimateLater() { + if (this._animateLaterId) { + Meta.later_remove(this._animateLaterId); + this._animateLaterId = 0; + } + if (this._viewLoadedHandlerId) { + this.disconnect(this._viewLoadedHandlerId); + this._viewLoadedHandlerId = 0; + } + } + + animate(animationDirection, onComplete) { + if (onComplete) { + let animationDoneId = this._grid.connect('animation-done', () => { + this._grid.disconnect(animationDoneId); + onComplete(); + }); + } + + this._clearAnimateLater(); + this._grid.opacity = 255; + + if (animationDirection == IconGrid.AnimationDirection.IN) { + const doSpringAnimationLater = laterType => { + this._animateLaterId = Meta.later_add(laterType, + () => { + this._animateLaterId = 0; + this._doSpringAnimation(animationDirection); + return GLib.SOURCE_REMOVE; + }); + }; + + if (this._viewIsReady) { + this._grid.opacity = 0; + doSpringAnimationLater(Meta.LaterType.IDLE); + } else { + this._viewLoadedHandlerId = this.connect('view-loaded', + () => { + this._clearAnimateLater(); + this._grid.opacity = 255; + doSpringAnimationLater(Meta.LaterType.BEFORE_REDRAW); + }); + } + } else { + this._doSpringAnimation(animationDirection); + } + } + + _getDropTarget(x, y, source) { + const { currentPage } = this._grid; + + let [item, dragLocation] = this._grid.getDropTarget(x, y); + + const [sourcePage, sourcePosition] = this._grid.getItemPosition(source); + const targetPage = currentPage; + let targetPosition = item + ? this._grid.getItemPosition(item)[1] : -1; + + // In case we're hovering over the edge of an item but the + // reflow will happen in the opposite direction (the drag + // can't "naturally push the item away"), we instead set the + // drop target to the adjacent item that can be pushed away + // in the reflow-direction. + // + // We must avoid doing that if we're hovering over the first + // or last column though, in that case there is no adjacent + // icon we could push away. + if (dragLocation === IconGrid.DragLocation.START_EDGE && + targetPosition > sourcePosition && + targetPage === sourcePage) { + const nColumns = this._grid.layout_manager.columns_per_page; + const targetColumn = targetPosition % nColumns; + + if (targetColumn > 0) { + targetPosition -= 1; + dragLocation = IconGrid.DragLocation.END_EDGE; + } + } else if (dragLocation === IconGrid.DragLocation.END_EDGE && + (targetPosition < sourcePosition || + targetPage !== sourcePage)) { + const nColumns = this._grid.layout_manager.columns_per_page; + const targetColumn = targetPosition % nColumns; + + if (targetColumn < nColumns - 1) { + targetPosition += 1; + dragLocation = IconGrid.DragLocation.START_EDGE; + } + } + + // Append to the page if dragging over empty area + if (dragLocation === IconGrid.DragLocation.EMPTY_SPACE) { + const pageItems = + this._grid.getItemsAtPage(currentPage).filter(c => c.visible); + + targetPosition = pageItems.length; + } + + return [targetPage, targetPosition, dragLocation]; + } + + _moveItem(item, newPage, newPosition) { + const [page, position] = this._grid.getItemPosition(item); + if (page === newPage && position === newPosition) + return; + + // Update the _orderedItems array + let index = this._orderedItems.indexOf(item); + this._orderedItems.splice(index, 1); + + index = this._getLinearPosition(newPage, newPosition); + this._orderedItems.splice(index, 0, item); + + this._grid.moveItem(item, newPage, newPosition); + } + + vfunc_allocate(box) { + const width = box.get_width(); + const height = box.get_height(); + + this.adaptToSize(width, height); + + super.vfunc_allocate(box); + } + + vfunc_map() { + this._swipeTracker.enabled = true; + this._connectDnD(); + super.vfunc_map(); + } + + vfunc_unmap() { + this._swipeTracker.enabled = false; + this._clearAnimateLater(); + this._disconnectDnD(); + super.vfunc_unmap(); + } + + animateSwitch(animationDirection) { + this.remove_all_transitions(); + this._grid.remove_all_transitions(); + + let params = { + duration: VIEWS_SWITCH_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }; + if (animationDirection == IconGrid.AnimationDirection.IN) { + this.show(); + params.opacity = 255; + params.delay = VIEWS_SWITCH_ANIMATION_DELAY; + } else { + params.opacity = 0; + params.delay = 0; + params.onComplete = () => this.hide(); + } + + this._grid.ease(params); + } + + goToPage(pageNumber, animate = true) { + pageNumber = Math.clamp(pageNumber, 0, this._grid.nPages - 1); + + if (this._grid.currentPage === pageNumber) + return; + + this._grid.goToPage(pageNumber, animate); + } + + adaptToSize(width, height) { + let box = new Clutter.ActorBox({ + x2: width, + y2: height, + }); + box = this._scrollView.get_theme_node().get_content_box(box); + box = this._grid.get_theme_node().get_content_box(box); + + const availWidth = box.get_width(); + const availHeight = box.get_height(); + + this._grid.adaptToSize(availWidth, availHeight); + + this._availWidth = availWidth; + this._availHeight = availHeight; + } +}); + +var PageManager = GObject.registerClass({ + Signals: { 'layout-changed': {} }, +}, class PageManager extends GObject.Object { + _init() { + super._init(); + + this._updatingPages = false; + this._loadPages(); + + global.settings.connect('changed::app-picker-layout', + this._loadPages.bind(this)); + + } + + _loadPages() { + const layout = global.settings.get_value('app-picker-layout'); + this._pages = layout.recursiveUnpack(); + if (!this._updatingPages) + this.emit('layout-changed'); + } + + getAppPosition(appId) { + let position = -1; + let page = -1; + + for (let pageIndex = 0; pageIndex < this._pages.length; pageIndex++) { + const pageData = this._pages[pageIndex]; + + if (appId in pageData) { + page = pageIndex; + position = pageData[appId].position; + break; + } + } + + return [page, position]; + } + + set pages(p) { + const packedPages = []; + + // Pack the icon properties as a GVariant + for (const page of p) { + const pageData = {}; + for (const [appId, properties] of Object.entries(page)) + pageData[appId] = new GLib.Variant('a{sv}', properties); + packedPages.push(pageData); + } + + this._updatingPages = true; + + const variant = new GLib.Variant('aa{sv}', packedPages); + global.settings.set_value('app-picker-layout', variant); + + this._updatingPages = false; + } + + get pages() { + return this._pages; + } +}); + +var AppDisplay = GObject.registerClass( +class AppDisplay extends BaseAppView { + _init() { + super._init({ + layout_manager: new Clutter.BinLayout(), + x_expand: true, + y_expand: true, + }); + + this._pageManager = new PageManager(); + this._pageManager.connect('layout-changed', () => this._redisplay()); + + this._scrollView.add_style_class_name('all-apps'); + + this._stack = new St.Widget({ + layout_manager: new Clutter.BinLayout(), + x_expand: true, + y_expand: true, + }); + this.add_actor(this._stack); + this._stack.add_actor(this._scrollView); + + this.add_actor(this._pageIndicators); + + this._folderIcons = []; + + this._currentDialog = null; + this._displayingDialog = false; + this._currentDialogDestroyId = 0; + + this._placeholder = null; + + Main.overview.connect('hidden', () => this.goToPage(0)); + + this._redisplayWorkId = Main.initializeDeferredWork(this, this._redisplay.bind(this)); + + Shell.AppSystem.get_default().connect('installed-changed', () => { + this._viewIsReady = false; + Main.queueDeferredWork(this._redisplayWorkId); + }); + this._folderSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.app-folders' }); + this._folderSettings.connect('changed::folder-children', () => { + this._viewIsReady = false; + Main.queueDeferredWork(this._redisplayWorkId); + }); + + this._switcherooNotifyId = global.connect('notify::switcheroo-control', + () => this._updateDiscreteGpuAvailable()); + this._updateDiscreteGpuAvailable(); + } + + _updateDiscreteGpuAvailable() { + this._switcherooProxy = global.get_switcheroo_control(); + if (this._switcherooProxy) { + let prop = this._switcherooProxy.get_cached_property('HasDualGpu'); + discreteGpuAvailable = prop ? prop.unpack() : false; + } else { + discreteGpuAvailable = false; + } + } + + _onDestroy() { + super._onDestroy(); + + if (this._scrollTimeoutId !== 0) { + GLib.source_remove(this._scrollTimeoutId); + this._scrollTimeoutId = 0; + } + } + + vfunc_map() { + this._keyPressEventId = + global.stage.connect('key-press-event', + this._onKeyPressEvent.bind(this)); + super.vfunc_map(); + } + + vfunc_unmap() { + if (this._keyPressEventId) { + global.stage.disconnect(this._keyPressEventId); + this._keyPressEventId = 0; + } + super.vfunc_unmap(); + } + + _redisplay() { + this._folderIcons.forEach(icon => { + icon.view._redisplay(); + }); + + super._redisplay(); + } + + _savePages() { + const pages = []; + + for (let i = 0; i < this._grid.nPages; i++) { + const pageItems = + this._grid.getItemsAtPage(i).filter(c => c.visible); + const pageData = {}; + + pageItems.forEach((item, index) => { + pageData[item.id] = { + position: GLib.Variant.new_int32(index), + }; + }); + pages.push(pageData); + } + + this._pageManager.pages = pages; + } + + _ensurePlaceholder(source) { + if (this._placeholder) + return; + + const appSys = Shell.AppSystem.get_default(); + const app = appSys.lookup_app(source.id); + + const isDraggable = + global.settings.is_writable('favorite-apps') || + global.settings.is_writable('app-picker-layout'); + + this._placeholder = new AppIcon(app, { isDraggable }); + this._placeholder.scaleAndFade(); + this._redisplay(); + } + + _removePlaceholder() { + if (this._placeholder) { + this._placeholder.undoScaleAndFade(); + this._placeholder = null; + this._redisplay(); + } + } + + getAppInfos() { + return this._appInfoList; + } + + _getItemPosition(item) { + if (item === this._placeholder) { + let [page, position] = this._grid.getItemPosition(item); + + if (page === -1) + page = this._findBestPageToAppend(this._grid.currentPage); + + return [page, position]; + } + + return this._pageManager.getAppPosition(item.id); + } + + _compareItems(a, b) { + const [aPage, aPosition] = this._getItemPosition(a); + const [bPage, bPosition] = this._getItemPosition(b); + + if (aPage === -1 && bPage === -1) + return a.name.localeCompare(b.name); + else if (aPage === -1) + return 1; + else if (bPage === -1) + return -1; + + if (aPage !== bPage) + return aPage - bPage; + + return aPosition - bPosition; + } + + _loadApps() { + let appIcons = []; + this._appInfoList = Shell.AppSystem.get_default().get_installed().filter(appInfo => { + try { + appInfo.get_id(); // catch invalid file encodings + } catch (e) { + return false; + } + return this._parentalControlsManager.shouldShowApp(appInfo); + }); + + let apps = this._appInfoList.map(app => app.get_id()); + + let appSys = Shell.AppSystem.get_default(); + + const appsInsideFolders = new Set(); + this._folderIcons = []; + + let folders = this._folderSettings.get_strv('folder-children'); + folders.forEach(id => { + let path = '%sfolders/%s/'.format(this._folderSettings.path, id); + let icon = this._items.get(id); + if (!icon) { + icon = new FolderIcon(id, path, this); + icon.connect('apps-changed', () => { + this._redisplay(); + this._savePages(); + }); + } + + // Don't try to display empty folders + if (!icon.visible) { + icon.destroy(); + return; + } + + appIcons.push(icon); + this._folderIcons.push(icon); + + icon.getAppIds().forEach(appId => appsInsideFolders.add(appId)); + }); + + // Allow dragging of the icon only if the Dash would accept a drop to + // change favorite-apps. There are no other possible drop targets from + // the app picker, so there's no other need for a drag to start, + // at least on single-monitor setups. + // This also disables drag-to-launch on multi-monitor setups, + // but we hope that is not used much. + const isDraggable = + global.settings.is_writable('favorite-apps') || + global.settings.is_writable('app-picker-layout'); + + apps.forEach(appId => { + if (appsInsideFolders.has(appId)) + return; + + let icon = this._items.get(appId); + if (!icon) { + let app = appSys.lookup_app(appId); + + icon = new AppIcon(app, { isDraggable }); + } + + appIcons.push(icon); + }); + + // At last, if there's a placeholder available, add it + if (this._placeholder) + appIcons.push(this._placeholder); + + return appIcons; + } + + // Overridden from BaseAppView + animate(animationDirection, onComplete) { + this._scrollView.reactive = false; + this._swipeTracker.enabled = false; + let completionFunc = () => { + this._scrollView.reactive = true; + this._swipeTracker.enabled = this.mapped; + if (onComplete) + onComplete(); + }; + + if (animationDirection == IconGrid.AnimationDirection.OUT && + this._displayingDialog && this._currentDialog) { + this._currentDialog.popdown(); + } else { + super.animate(animationDirection, completionFunc); + if (animationDirection == IconGrid.AnimationDirection.OUT) + this._pageIndicators.animateIndicators(animationDirection); + } + } + + animateSwitch(animationDirection) { + super.animateSwitch(animationDirection); + + if (this._currentDialog && this._displayingDialog && + animationDirection == IconGrid.AnimationDirection.OUT) { + this._currentDialog.ease({ + opacity: 0, + duration: VIEWS_SWITCH_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => (this.opacity = 255), + }); + } + + if (animationDirection == IconGrid.AnimationDirection.OUT) + this._pageIndicators.animateIndicators(animationDirection); + } + + goToPage(pageNumber, animate = true) { + pageNumber = Math.clamp(pageNumber, 0, this._grid.nPages - 1); + + if (this._grid.currentPage === pageNumber && + this._displayingDialog && + this._currentDialog) + return; + if (this._displayingDialog && this._currentDialog) + this._currentDialog.popdown(); + + super.goToPage(pageNumber, animate); + } + + _onScroll(actor, event) { + if (this._displayingDialog || !this._scrollView.reactive) + return Clutter.EVENT_STOP; + + return super._onScroll(actor, event); + } + + _onKeyPressEvent(actor, event) { + if (this._displayingDialog) + return Clutter.EVENT_STOP; + + if (event.get_key_symbol() === Clutter.KEY_Page_Up) { + this.goToPage(this._grid.currentPage - 1); + return Clutter.EVENT_STOP; + } else if (event.get_key_symbol() === Clutter.KEY_Page_Down) { + this.goToPage(this._grid.currentPage + 1); + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_PROPAGATE; + } + + addFolderDialog(dialog) { + Main.layoutManager.overviewGroup.add_child(dialog); + dialog.connect('open-state-changed', (o, isOpen) => { + if (this._currentDialog) { + this._currentDialog.disconnect(this._currentDialogDestroyId); + this._currentDialogDestroyId = 0; + } + + this._currentDialog = null; + + if (isOpen) { + this._currentDialog = dialog; + this._currentDialogDestroyId = dialog.connect('destroy', () => { + this._currentDialog = null; + this._currentDialogDestroyId = 0; + }); + } + this._displayingDialog = isOpen; + }); + } + + _maybeMoveItem(dragEvent) { + const clonedEvent = { + ...dragEvent, + source: this._placeholder ? this._placeholder : dragEvent.source, + }; + + super._maybeMoveItem(clonedEvent); + } + + _onDragBegin(overview, source) { + super._onDragBegin(overview, source); + + // When dragging from a folder dialog, the dragged app icon doesn't + // exist in AppDisplay. We work around that by adding a placeholder + // icon that is either destroyed on cancel, or becomes the effective + // new icon when dropped. + if (_getViewFromIcon(source) instanceof FolderView) + this._ensurePlaceholder(source); + } + + _onDragMotion(dragEvent) { + if (this._currentDialog) + return DND.DragMotionResult.CONTINUE; + + return super._onDragMotion(dragEvent); + } + + _onDragEnd() { + super._onDragEnd(); + this._removePlaceholder(); + } + + _onDragCancelled(overview, source) { + const view = _getViewFromIcon(source); + + if (view instanceof FolderView) + return; + + super._onDragCancelled(overview, source); + } + + acceptDrop(source) { + if (!super.acceptDrop(source)) + return false; + + this._savePages(); + + let view = _getViewFromIcon(source); + if (view instanceof FolderView) + view.removeApp(source.app); + + if (this._currentDialog) + this._currentDialog.popdown(); + + return true; + } + + createFolder(apps) { + let newFolderId = GLib.uuid_string_random(); + + let folders = this._folderSettings.get_strv('folder-children'); + folders.push(newFolderId); + this._folderSettings.set_strv('folder-children', folders); + + // Create the new folder + let newFolderPath = this._folderSettings.path.concat('folders/', newFolderId, '/'); + let newFolderSettings = new Gio.Settings({ + schema_id: 'org.gnome.desktop.app-folders.folder', + path: newFolderPath, + }); + if (!newFolderSettings) { + log('Error creating new folder'); + return false; + } + + // The hovered AppIcon always passes its own id as the first + // one, and this is where we want the folder to be created + let [folderPage, folderPosition] = + this._grid.getItemPosition(this._items.get(apps[0])); + + // Adjust the final position + folderPosition -= apps.reduce((counter, appId) => { + const [page, position] = + this._grid.getItemPosition(this._items.get(appId)); + if (page === folderPage && position < folderPosition) + counter++; + return counter; + }, 0); + + let appItems = apps.map(id => this._items.get(id).app); + let folderName = _findBestFolderName(appItems); + if (!folderName) + folderName = _("Unnamed Folder"); + + newFolderSettings.delay(); + newFolderSettings.set_string('name', folderName); + newFolderSettings.set_strv('apps', apps); + newFolderSettings.apply(); + + this._redisplay(); + + // Move the folder to where the icon target icon was + const folderItem = this._items.get(newFolderId); + this._moveItem(folderItem, folderPage, folderPosition); + this._savePages(); + + return true; + } +}); + +var AppSearchProvider = class AppSearchProvider { + constructor() { + this._appSys = Shell.AppSystem.get_default(); + this.id = 'applications'; + this.isRemoteProvider = false; + this.canLaunchSearch = false; + + this._systemActions = new SystemActions.getDefault(); + + this._parentalControlsManager = ParentalControlsManager.getDefault(); + } + + getResultMetas(apps, callback) { + let metas = []; + for (let id of apps) { + if (id.endsWith('.desktop')) { + let app = this._appSys.lookup_app(id); + + metas.push({ + id: app.get_id(), + name: app.get_name(), + createIcon: size => app.create_icon_texture(size), + }); + } else { + let name = this._systemActions.getName(id); + let iconName = this._systemActions.getIconName(id); + + let createIcon = size => new St.Icon({ icon_name: iconName, + width: size, + height: size, + style_class: 'system-action-icon' }); + + metas.push({ id, name, createIcon }); + } + } + + callback(metas); + } + + filterResults(results, maxNumber) { + return results.slice(0, maxNumber); + } + + getInitialResultSet(terms, callback, _cancellable) { + // Defer until the parental controls manager is initialised, so the + // results can be filtered correctly. + if (!this._parentalControlsManager.initialized) { + let initializedId = this._parentalControlsManager.connect('app-filter-changed', () => { + if (this._parentalControlsManager.initialized) { + this._parentalControlsManager.disconnect(initializedId); + this.getInitialResultSet(terms, callback, _cancellable); + } + }); + return; + } + + let query = terms.join(' '); + let groups = Shell.AppSystem.search(query); + let usage = Shell.AppUsage.get_default(); + let results = []; + + groups.forEach(group => { + group = group.filter(appID => { + const app = this._appSys.lookup_app(appID); + return app && this._parentalControlsManager.shouldShowApp(app.app_info); + }); + results = results.concat(group.sort( + (a, b) => usage.compare(a, b))); + }); + + results = results.concat(this._systemActions.getMatchingActions(terms)); + + callback(results); + } + + getSubsearchResultSet(previousResults, terms, callback, cancellable) { + this.getInitialResultSet(terms, callback, cancellable); + } + + createResultObject(resultMeta) { + if (resultMeta.id.endsWith('.desktop')) + return new AppIcon(this._appSys.lookup_app(resultMeta['id'])); + else + return new SystemActionIcon(this, resultMeta); + } +}; + +var AppViewItem = GObject.registerClass( +class AppViewItem extends St.Button { + _init(params = {}, isDraggable = true) { + super._init({ + pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }), + reactive: true, + button_mask: St.ButtonMask.ONE | St.ButtonMask.TWO, + can_focus: true, + ...params, + }); + + this._delegate = this; + + if (isDraggable) { + this._draggable = DND.makeDraggable(this); + this._draggable.connect('drag-begin', this._onDragBegin.bind(this)); + this._draggable.connect('drag-cancelled', this._onDragCancelled.bind(this)); + this._draggable.connect('drag-end', this._onDragEnd.bind(this)); + } + + this._otherIconIsHovering = false; + + this.connect('destroy', this._onDestroy.bind(this)); + } + + _onDestroy() { + if (this._dragMonitor) { + DND.removeDragMonitor(this._dragMonitor); + this._dragMonitor = null; + } + + if (this._draggable) { + if (this._dragging) + Main.overview.endItemDrag(this); + this._draggable = null; + } + } + + _onDragBegin() { + this._dragging = true; + this.scaleAndFade(); + Main.overview.beginItemDrag(this); + } + + _onDragCancelled() { + this._dragging = false; + Main.overview.cancelledItemDrag(this); + } + + _onDragEnd() { + this._dragging = false; + this.undoScaleAndFade(); + Main.overview.endItemDrag(this); + } + + scaleIn() { + this.scale_x = 0; + this.scale_y = 0; + + this.ease({ + scale_x: 1, + scale_y: 1, + duration: APP_ICON_SCALE_IN_TIME, + delay: APP_ICON_SCALE_IN_DELAY, + mode: Clutter.AnimationMode.EASE_OUT_QUINT, + }); + } + + scaleAndFade() { + this.reactive = false; + this.ease({ + scale_x: 0.5, + scale_y: 0.5, + opacity: 0, + }); + } + + undoScaleAndFade() { + this.reactive = true; + this.ease({ + scale_x: 1.0, + scale_y: 1.0, + opacity: 255, + }); + } + + _canAccept(source) { + return source !== this; + } + + _setHoveringByDnd(hovering) { + if (this._otherIconIsHovering === hovering) + return; + + this._otherIconIsHovering = hovering; + + if (hovering) { + this._dragMonitor = { + dragMotion: this._onDragMotion.bind(this), + }; + DND.addDragMonitor(this._dragMonitor); + } else { + DND.removeDragMonitor(this._dragMonitor); + } + } + + _onDragMotion(dragEvent) { + if (!this.contains(dragEvent.targetActor)) + this._setHoveringByDnd(false); + + return DND.DragMotionResult.CONTINUE; + } + + _withinLeeways(x) { + return x < IconGrid.LEFT_DIVIDER_LEEWAY || + x > this.width - IconGrid.RIGHT_DIVIDER_LEEWAY; + } + + handleDragOver(source, _actor, x) { + if (source === this) + return DND.DragMotionResult.NO_DROP; + + if (!this._canAccept(source)) + return DND.DragMotionResult.CONTINUE; + + if (this._withinLeeways(x)) { + this._setHoveringByDnd(false); + return DND.DragMotionResult.CONTINUE; + } + + this._setHoveringByDnd(true); + + return DND.DragMotionResult.MOVE_DROP; + } + + acceptDrop(source, _actor, x) { + this._setHoveringByDnd(false); + + if (!this._canAccept(source)) + return false; + + if (this._withinLeeways(x)) + return false; + + return true; + } + + get id() { + return this._id; + } + + get name() { + return this._name; + } +}); + +var FolderGrid = GObject.registerClass( +class FolderGrid extends IconGrid.IconGrid { + _init() { + super._init({ + allow_incomplete_pages: false, + orientation: Clutter.Orientation.HORIZONTAL, + columns_per_page: 3, + rows_per_page: 3, + page_halign: Clutter.ActorAlign.CENTER, + page_valign: Clutter.ActorAlign.CENTER, + }); + } + + adaptToSize(width, height) { + this.layout_manager.adaptToSize(width, height); + } +}); + +var FolderView = GObject.registerClass( +class FolderView extends BaseAppView { + _init(folder, id, parentView) { + super._init({ + layout_manager: new Clutter.BinLayout(), + x_expand: true, + y_expand: true, + }, Clutter.Orientation.HORIZONTAL); + + // If it not expand, the parent doesn't take into account its preferred_width when allocating + // the second time it allocates, so we apply the "Standard hack for ClutterBinLayout" + this._grid.x_expand = true; + this._id = id; + this._folder = folder; + this._parentView = parentView; + this._grid._delegate = this; + + const box = new St.BoxLayout({ + vertical: true, + reactive: true, + x_expand: true, + y_expand: true, + }); + box.add_child(this._scrollView); + box.add_child(this._pageIndicators); + this.add_child(box); + + let action = new Clutter.PanAction({ interpolate: true }); + action.connect('pan', this._onPan.bind(this)); + this._scrollView.add_action(action); + + this._deletingFolder = false; + this._appIds = []; + this._redisplay(); + } + + _createGrid() { + return new FolderGrid(); + } + + _getFolderApps() { + const appIds = []; + const excludedApps = this._folder.get_strv('excluded-apps'); + const appSys = Shell.AppSystem.get_default(); + const addAppId = appId => { + if (excludedApps.includes(appId)) + return; + + const app = appSys.lookup_app(appId); + if (!app) + return; + + if (!this._parentalControlsManager.shouldShowApp(app.get_app_info())) + return; + + if (appIds.indexOf(appId) !== -1) + return; + + appIds.push(appId); + }; + + const folderApps = this._folder.get_strv('apps'); + folderApps.forEach(addAppId); + + const folderCategories = this._folder.get_strv('categories'); + const appInfos = this._parentView.getAppInfos(); + appInfos.forEach(appInfo => { + let appCategories = _getCategories(appInfo); + if (!_listsIntersect(folderCategories, appCategories)) + return; + + addAppId(appInfo.get_id()); + }); + + return appIds; + } + + _getItemPosition(item) { + const appIndex = this._appIds.indexOf(item.id); + + if (appIndex === -1) + return [-1, -1]; + + const { itemsPerPage } = this._grid; + return [Math.floor(appIndex / itemsPerPage), appIndex % itemsPerPage]; + } + + _compareItems(a, b) { + const aPosition = this._appIds.indexOf(a.id); + const bPosition = this._appIds.indexOf(b.id); + + if (aPosition === -1 && bPosition === -1) + return a.name.localeCompare(b.name); + else if (aPosition === -1) + return 1; + else if (bPosition === -1) + return -1; + + return aPosition - bPosition; + } + + // Overridden from BaseAppView + animate(animationDirection) { + this._grid.animatePulse(animationDirection); + } + + createFolderIcon(size) { + let layout = new Clutter.GridLayout(); + let icon = new St.Widget({ + layout_manager: layout, + style_class: 'app-folder-icon', + x_align: Clutter.ActorAlign.CENTER, + }); + layout.hookup_style(icon); + let subSize = Math.floor(FOLDER_SUBICON_FRACTION * size); + + let numItems = this._orderedItems.length; + let rtl = icon.get_text_direction() == Clutter.TextDirection.RTL; + for (let i = 0; i < 4; i++) { + const style = 'width: %dpx; height: %dpx;'.format(subSize, subSize); + let bin = new St.Bin({ style }); + if (i < numItems) + bin.child = this._orderedItems[i].app.create_icon_texture(subSize); + layout.attach(bin, rtl ? (i + 1) % 2 : i % 2, Math.floor(i / 2), 1, 1); + } + + return icon; + } + + _onPan(action) { + let [dist_, dx_, dy] = action.get_motion_delta(0); + let adjustment = this._scrollView.vscroll.adjustment; + adjustment.value -= (dy / this._scrollView.height) * adjustment.page_size; + return false; + } + + adaptToSize(width, height) { + const [, indicatorHeight] = this._pageIndicators.get_preferred_height(-1); + height -= indicatorHeight; + + super.adaptToSize(width, height); + } + + _loadApps() { + let apps = []; + let appSys = Shell.AppSystem.get_default(); + + this._appIds.forEach(appId => { + const app = appSys.lookup_app(appId); + + let icon = this._items.get(appId); + if (!icon) + icon = new AppIcon(app); + + apps.push(icon); + }); + + return apps; + } + + _redisplay() { + // Keep the app ids list cached + this._appIds = this._getFolderApps(); + + super._redisplay(); + } + + acceptDrop(source) { + if (!super.acceptDrop(source)) + return false; + + const folderApps = this._orderedItems.map(item => item.id); + this._folder.set_strv('apps', folderApps); + + return true; + } + + addApp(app) { + let folderApps = this._folder.get_strv('apps'); + folderApps.push(app.id); + + this._folder.set_strv('apps', folderApps); + + // Also remove from 'excluded-apps' if the app id is listed + // there. This is only possible on categories-based folders. + let excludedApps = this._folder.get_strv('excluded-apps'); + let index = excludedApps.indexOf(app.id); + if (index >= 0) { + excludedApps.splice(index, 1); + this._folder.set_strv('excluded-apps', excludedApps); + } + } + + removeApp(app) { + let folderApps = this._folder.get_strv('apps'); + let index = folderApps.indexOf(app.id); + if (index >= 0) + folderApps.splice(index, 1); + + // Remove the folder if this is the last app icon; otherwise, + // just remove the icon + if (folderApps.length == 0) { + this._deletingFolder = true; + + // Resetting all keys deletes the relocatable schema + let keys = this._folder.settings_schema.list_keys(); + for (const key of keys) + this._folder.reset(key); + + let settings = new Gio.Settings({ schema_id: 'org.gnome.desktop.app-folders' }); + let folders = settings.get_strv('folder-children'); + folders.splice(folders.indexOf(this._id), 1); + settings.set_strv('folder-children', folders); + + this._deletingFolder = false; + } else { + // If this is a categories-based folder, also add it to + // the list of excluded apps + const categories = this._folder.get_strv('categories'); + if (categories.length > 0) { + const excludedApps = this._folder.get_strv('excluded-apps'); + excludedApps.push(app.id); + this._folder.set_strv('excluded-apps', excludedApps); + } + + this._folder.set_strv('apps', folderApps); + } + } + + get deletingFolder() { + return this._deletingFolder; + } +}); + +var FolderIcon = GObject.registerClass({ + Signals: { + 'apps-changed': {}, + }, +}, class FolderIcon extends AppViewItem { + _init(id, path, parentView) { + super._init({ + style_class: 'app-well-app app-folder', + button_mask: St.ButtonMask.ONE, + toggle_mode: true, + can_focus: true, + }, global.settings.is_writable('app-picker-layout')); + this._id = id; + this._name = ''; + this._parentView = parentView; + + this._folder = new Gio.Settings({ schema_id: 'org.gnome.desktop.app-folders.folder', + path }); + + this.icon = new IconGrid.BaseIcon('', { + createIcon: this._createIcon.bind(this), + setSizeManually: true, + }); + this.set_child(this.icon); + this.label_actor = this.icon.label; + + this.view = new FolderView(this._folder, id, parentView); + + this._folderChangedId = this._folder.connect( + 'changed', this._sync.bind(this)); + this._sync(); + } + + _onDestroy() { + super._onDestroy(); + + if (this._dialog) + this._dialog.destroy(); + else + this.view.destroy(); + + if (this._folderChangedId) { + this._folder.disconnect(this._folderChangedId); + delete this._folderChangedId; + } + } + + vfunc_clicked() { + this.open(); + } + + vfunc_unmap() { + if (this._dialog) + this._dialog.popdown(); + + super.vfunc_unmap(); + } + + open() { + this._ensureFolderDialog(); + this.view._scrollView.vscroll.adjustment.value = 0; + this._dialog.popup(); + } + + getAppIds() { + return this.view.getAllItems().map(item => item.id); + } + + _setHoveringByDnd(hovering) { + if (this._otherIconIsHovering == hovering) + return; + + super._setHoveringByDnd(hovering); + + if (hovering) + this.add_style_pseudo_class('drop'); + else + this.remove_style_pseudo_class('drop'); + } + + _onDragMotion(dragEvent) { + if (!this._canAccept(dragEvent.source)) + this._setHoveringByDnd(false); + + return super._onDragMotion(dragEvent); + } + + getDragActor() { + const iconParams = { + createIcon: this._createIcon.bind(this), + showLabel: this.icon.label !== null, + setSizeManually: false, + }; + + const icon = new IconGrid.BaseIcon(this.name, iconParams); + icon.style_class = this.style_class; + + return icon; + } + + getDragActorSource() { + return this; + } + + _canAccept(source) { + if (!(source instanceof AppIcon)) + return false; + + let view = _getViewFromIcon(source); + if (!view || !(view instanceof AppDisplay)) + return false; + + if (this._folder.get_strv('apps').includes(source.id)) + return false; + + return true; + } + + acceptDrop(source) { + const accepted = super.acceptDrop(source); + + if (!accepted) + return false; + + this.view.addApp(source.app); + + return true; + } + + _updateName() { + let name = _getFolderName(this._folder); + if (this.name == name) + return; + + this._name = name; + this.icon.label.text = this.name; + } + + _sync() { + if (this.view.deletingFolder) + return; + + this.emit('apps-changed'); + this._updateName(); + this.visible = this.view.getAllItems().length > 0; + this.icon.update(); + } + + _createIcon(iconSize) { + return this.view.createFolderIcon(iconSize, this); + } + + _ensureFolderDialog() { + if (this._dialog) + return; + if (!this._dialog) { + this._dialog = new AppFolderDialog(this, this._folder, + this._parentView); + this._parentView.addFolderDialog(this._dialog); + this._dialog.connect('open-state-changed', (popup, isOpen) => { + const duration = FOLDER_DIALOG_ANIMATION_TIME / 2; + const mode = isOpen + ? Clutter.AnimationMode.EASE_OUT_QUAD + : Clutter.AnimationMode.EASE_IN_QUAD; + + this.ease({ + opacity: isOpen ? 0 : 255, + duration, + mode, + delay: isOpen ? 0 : FOLDER_DIALOG_ANIMATION_TIME - duration, + }); + + if (!isOpen) + this.checked = false; + }); + } + } +}); + +var AppFolderDialog = GObject.registerClass({ + Signals: { + 'open-state-changed': { param_types: [GObject.TYPE_BOOLEAN] }, + }, +}, class AppFolderDialog extends St.Bin { + _init(source, folder, appDisplay) { + super._init({ + visible: false, + x_expand: true, + y_expand: true, + reactive: true, + }); + + this.add_constraint(new Layout.MonitorConstraint({ + primary: true, + work_area: true, + })); + + const clickAction = new Clutter.ClickAction(); + clickAction.connect('clicked', () => { + const [x, y] = clickAction.get_coords(); + const actor = + global.stage.get_actor_at_pos(Clutter.PickMode.ALL, x, y); + + if (actor === this) + this.popdown(); + }); + this.add_action(clickAction); + + this._source = source; + this._folder = folder; + this._view = source.view; + this._appDisplay = appDisplay; + this._delegate = this; + + this._isOpen = false; + + this._viewBox = new St.BoxLayout({ + style_class: 'app-folder-dialog', + x_expand: true, + y_expand: true, + x_align: Clutter.ActorAlign.FILL, + y_align: Clutter.ActorAlign.FILL, + vertical: true, + }); + + this.child = new St.Bin({ + style_class: 'app-folder-dialog-container', + child: this._viewBox, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + + this._addFolderNameEntry(); + this._viewBox.add_child(this._view); + + global.focus_manager.add_group(this); + + this._grabHelper = new GrabHelper.GrabHelper(this, { + actionMode: Shell.ActionMode.POPUP, + }); + this._grabHelper.addActor(Main.layoutManager.overviewGroup); + this.connect('destroy', this._onDestroy.bind(this)); + + this._dragMonitor = null; + this._sourceMappedId = 0; + this._popdownTimeoutId = 0; + this._needsZoomAndFade = false; + + this._popdownCallbacks = []; + } + + _addFolderNameEntry() { + this._entryBox = new St.BoxLayout({ + style_class: 'folder-name-container', + }); + this._viewBox.add_child(this._entryBox); + + // Empty actor to center the title + let ghostButton = new Clutter.Actor(); + this._entryBox.add_child(ghostButton); + + let stack = new Shell.Stack({ + x_expand: true, + x_align: Clutter.ActorAlign.CENTER, + }); + this._entryBox.add_child(stack); + + // Folder name label + this._folderNameLabel = new St.Label({ + style_class: 'folder-name-label', + x_expand: true, + y_expand: true, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + + stack.add_child(this._folderNameLabel); + + // Folder name entry + this._entry = new St.Entry({ + style_class: 'folder-name-entry', + opacity: 0, + reactive: false, + }); + this._entry.clutter_text.set({ + x_expand: true, + x_align: Clutter.ActorAlign.CENTER, + }); + + this._entry.clutter_text.connect('activate', () => { + this._showFolderLabel(); + }); + + stack.add_child(this._entry); + + // Edit button + this._editButton = new St.Button({ + style_class: 'edit-folder-button', + button_mask: St.ButtonMask.ONE, + toggle_mode: true, + reactive: true, + can_focus: true, + x_align: Clutter.ActorAlign.END, + y_align: Clutter.ActorAlign.CENTER, + child: new St.Icon({ + icon_name: 'document-edit-symbolic', + icon_size: 16, + }), + }); + + this._editButton.connect('notify::checked', () => { + if (this._editButton.checked) + this._showFolderEntry(); + else + this._showFolderLabel(); + }); + + this._entryBox.add_child(this._editButton); + + ghostButton.add_constraint(new Clutter.BindConstraint({ + source: this._editButton, + coordinate: Clutter.BindCoordinate.SIZE, + })); + + this._folder.connect('changed::name', () => this._syncFolderName()); + this._syncFolderName(); + } + + _syncFolderName() { + let newName = _getFolderName(this._folder); + + this._folderNameLabel.text = newName; + this._entry.text = newName; + } + + _switchActor(from, to) { + to.reactive = true; + to.ease({ + opacity: 255, + duration: 300, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + + from.ease({ + opacity: 0, + duration: 300, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + from.reactive = false; + }, + }); + } + + _showFolderLabel() { + if (this._editButton.checked) + this._editButton.checked = false; + + this._maybeUpdateFolderName(); + this._switchActor(this._entry, this._folderNameLabel); + } + + _showFolderEntry() { + this._switchActor(this._folderNameLabel, this._entry); + + this._entry.clutter_text.set_selection(0, -1); + this._entry.clutter_text.grab_key_focus(); + } + + _maybeUpdateFolderName() { + let folderName = _getFolderName(this._folder); + let newFolderName = this._entry.text.trim(); + + if (newFolderName.length === 0 || newFolderName === folderName) + return; + + this._folder.set_string('name', newFolderName); + this._folder.set_boolean('translate', false); + } + + _zoomAndFadeIn() { + let [sourceX, sourceY] = + this._source.get_transformed_position(); + let [dialogX, dialogY] = + this.child.get_transformed_position(); + + this.child.set({ + translation_x: sourceX - dialogX, + translation_y: sourceY - dialogY, + scale_x: this._source.width / this.child.width, + scale_y: this._source.height / this.child.height, + opacity: 0, + }); + + this.ease({ + background_color: DIALOG_SHADE_NORMAL, + duration: FOLDER_DIALOG_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + this.child.ease({ + translation_x: 0, + translation_y: 0, + scale_x: 1, + scale_y: 1, + opacity: 255, + duration: FOLDER_DIALOG_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + + this._needsZoomAndFade = false; + + if (this._sourceMappedId === 0) { + this._sourceMappedId = this._source.connect( + 'notify::mapped', this._zoomAndFadeOut.bind(this)); + } + } + + _zoomAndFadeOut() { + if (!this._isOpen) + return; + + if (!this._source.mapped) { + this.hide(); + return; + } + + let [sourceX, sourceY] = + this._source.get_transformed_position(); + let [dialogX, dialogY] = + this.child.get_transformed_position(); + + this.ease({ + background_color: Clutter.Color.from_pixel(0x00000000), + duration: FOLDER_DIALOG_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + + this.child.ease({ + translation_x: sourceX - dialogX, + translation_y: sourceY - dialogY, + scale_x: this._source.width / this.child.width, + scale_y: this._source.height / this.child.height, + opacity: 0, + duration: FOLDER_DIALOG_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this.child.set({ + translation_x: 0, + translation_y: 0, + scale_x: 1, + scale_y: 1, + opacity: 255, + }); + this.hide(); + + this._popdownCallbacks.forEach(func => func()); + this._popdownCallbacks = []; + }, + }); + + this._needsZoomAndFade = false; + } + + _removeDragMonitor() { + if (!this._dragMonitor) + return; + + DND.removeDragMonitor(this._dragMonitor); + this._dragMonitor = null; + } + + _removePopdownTimeout() { + if (this._popdownTimeoutId === 0) + return; + + GLib.source_remove(this._popdownTimeoutId); + this._popdownTimeoutId = 0; + } + + _onDestroy() { + if (this._isOpen) { + this._isOpen = false; + this._grabHelper.ungrab({ actor: this }); + this._grabHelper = null; + } + + if (this._sourceMappedId) { + this._source.disconnect(this._sourceMappedId); + this._sourceMappedId = 0; + } + + this._removePopdownTimeout(); + this._removeDragMonitor(); + } + + vfunc_allocate(box) { + super.vfunc_allocate(box); + + // We can only start zooming after receiving an allocation + if (this._needsZoomAndFade) + this._zoomAndFadeIn(); + } + + vfunc_key_press_event(keyEvent) { + if (global.stage.get_key_focus() != this) + return Clutter.EVENT_PROPAGATE; + + // Since we need to only grab focus on one item child when the user + // actually press a key we don't use navigate_focus when opening + // the popup. + // Instead of that, grab the focus on the AppFolderPopup actor + // and actually moves the focus to a child only when the user + // actually press a key. + // It should work with just grab_key_focus on the AppFolderPopup + // actor, but since the arrow keys are not wrapping_around the focus + // is not grabbed by a child when the widget that has the current focus + // is the same that is requesting focus, so to make it works with arrow + // keys we need to connect to the key-press-event and navigate_focus + // when that happens using TAB_FORWARD or TAB_BACKWARD instead of arrow + // keys + + // Use TAB_FORWARD for down key and right key + // and TAB_BACKWARD for up key and left key on ltr + // languages + let direction; + let isLtr = Clutter.get_default_text_direction() == Clutter.TextDirection.LTR; + switch (keyEvent.keyval) { + case Clutter.KEY_Down: + direction = St.DirectionType.TAB_FORWARD; + break; + case Clutter.KEY_Right: + direction = isLtr + ? St.DirectionType.TAB_FORWARD + : St.DirectionType.TAB_BACKWARD; + break; + case Clutter.KEY_Up: + direction = St.DirectionType.TAB_BACKWARD; + break; + case Clutter.KEY_Left: + direction = isLtr + ? St.DirectionType.TAB_BACKWARD + : St.DirectionType.TAB_FORWARD; + break; + default: + return Clutter.EVENT_PROPAGATE; + } + return this.navigate_focus(null, direction, false); + } + + _setLighterBackground(lighter) { + const backgroundColor = lighter + ? DIALOG_SHADE_HIGHLIGHT + : DIALOG_SHADE_NORMAL; + + this.ease({ + backgroundColor, + duration: FOLDER_DIALOG_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + _withinDialog(x, y) { + const childExtents = this.child.get_transformed_extents(); + return childExtents.contains_point(new Graphene.Point({ x, y })); + } + + _setupDragMonitor() { + if (this._dragMonitor) + return; + + this._dragMonitor = { + dragMotion: dragEvent => { + const withinDialog = + this._withinDialog(dragEvent.x, dragEvent.y); + + this._setLighterBackground(!withinDialog); + + if (withinDialog) { + this._removePopdownTimeout(); + this._removeDragMonitor(); + } + return DND.DragMotionResult.CONTINUE; + }, + }; + DND.addDragMonitor(this._dragMonitor); + } + + _setupPopdownTimeout() { + if (this._popdownTimeoutId > 0) + return; + + this._popdownTimeoutId = + GLib.timeout_add(GLib.PRIORITY_DEFAULT, POPDOWN_DIALOG_TIMEOUT, () => { + this._popdownTimeoutId = 0; + this.popdown(); + return GLib.SOURCE_REMOVE; + }); + } + + handleDragOver(source, actor, x, y) { + if (this._withinDialog(x, y)) { + this._setLighterBackground(false); + this._removePopdownTimeout(); + this._removeDragMonitor(); + } else { + this._setupPopdownTimeout(); + this._setupDragMonitor(); + } + + return DND.DragMotionResult.MOVE_DROP; + } + + acceptDrop(source) { + const appId = source.id; + + this.popdown(() => { + this._view.removeApp(source); + this._appDisplay.selectApp(appId); + }); + + return true; + } + + toggle() { + if (this._isOpen) + this.popdown(); + else + this.popup(); + } + + popup() { + if (this._isOpen) + return; + + this._isOpen = this._grabHelper.grab({ + actor: this, + onUngrab: () => this.popdown(), + }); + + if (!this._isOpen) + return; + + this.get_parent().set_child_above_sibling(this, null); + + this._needsZoomAndFade = true; + this.show(); + + this.emit('open-state-changed', true); + } + + popdown(callback) { + // Either call the callback right away, or wait for the zoom out + // animation to finish + if (callback) { + if (this.visible) + this._popdownCallbacks.push(callback); + else + callback(); + } + + if (!this._isOpen) + return; + + this._zoomAndFadeOut(); + this._showFolderLabel(); + + this._isOpen = false; + this._grabHelper.ungrab({ actor: this }); + this.emit('open-state-changed', false); + } +}); + +var AppIcon = GObject.registerClass({ + Signals: { + 'menu-state-changed': { param_types: [GObject.TYPE_BOOLEAN] }, + 'sync-tooltip': {}, + }, +}, class AppIcon extends AppViewItem { + _init(app, iconParams = {}) { + // Get the isDraggable property without passing it on to the BaseIcon: + const appIconParams = Params.parse(iconParams, { isDraggable: true }, true); + const isDraggable = appIconParams['isDraggable']; + delete iconParams['isDraggable']; + + super._init({ style_class: 'app-well-app' }, isDraggable); + + this.app = app; + this._id = app.get_id(); + this._name = app.get_name(); + + this._iconContainer = new St.Widget({ layout_manager: new Clutter.BinLayout(), + x_expand: true, y_expand: true }); + + this.set_child(this._iconContainer); + + this._folderPreviewId = 0; + + iconParams['createIcon'] = this._createIcon.bind(this); + iconParams['setSizeManually'] = true; + this.icon = new IconGrid.BaseIcon(app.get_name(), iconParams); + this._iconContainer.add_child(this.icon); + + this._dot = new St.Widget({ + style_class: 'app-well-app-running-dot', + layout_manager: new Clutter.BinLayout(), + x_expand: true, + y_expand: true, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.END, + }); + this._iconContainer.add_child(this._dot); + + this.label_actor = this.icon.label; + + this.connect('popup-menu', this._onKeyboardPopupMenu.bind(this)); + + this._menu = null; + this._menuManager = new PopupMenu.PopupMenuManager(this); + + this._menuTimeoutId = 0; + this._stateChangedId = this.app.connect('notify::state', () => { + this._updateRunningStyle(); + }); + this._updateRunningStyle(); + } + + _onDestroy() { + super._onDestroy(); + + if (this._folderPreviewId > 0) { + GLib.source_remove(this._folderPreviewId); + this._folderPreviewId = 0; + } + if (this._stateChangedId > 0) + this.app.disconnect(this._stateChangedId); + + this._stateChangedId = 0; + this._removeMenuTimeout(); + } + + _onDragBegin() { + this._removeMenuTimeout(); + super._onDragBegin(); + } + + _createIcon(iconSize) { + return this.app.create_icon_texture(iconSize); + } + + _removeMenuTimeout() { + if (this._menuTimeoutId > 0) { + GLib.source_remove(this._menuTimeoutId); + this._menuTimeoutId = 0; + } + } + + _updateRunningStyle() { + if (this.app.state != Shell.AppState.STOPPED) + this._dot.show(); + else + this._dot.hide(); + } + + _setPopupTimeout() { + this._removeMenuTimeout(); + this._menuTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, MENU_POPUP_TIMEOUT, () => { + this._menuTimeoutId = 0; + this.popupMenu(); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._menuTimeoutId, '[gnome-shell] this.popupMenu'); + } + + vfunc_leave_event(crossingEvent) { + const ret = super.vfunc_leave_event(crossingEvent); + + this.fake_release(); + this._removeMenuTimeout(); + return ret; + } + + vfunc_button_press_event(buttonEvent) { + const ret = super.vfunc_button_press_event(buttonEvent); + if (buttonEvent.button == 1) { + this._setPopupTimeout(); + } else if (buttonEvent.button == 3) { + this.popupMenu(); + return Clutter.EVENT_STOP; + } + return ret; + } + + vfunc_touch_event(touchEvent) { + const ret = super.vfunc_touch_event(touchEvent); + if (touchEvent.type == Clutter.EventType.TOUCH_BEGIN) + this._setPopupTimeout(); + + return ret; + } + + vfunc_clicked(button) { + this._removeMenuTimeout(); + this.activate(button); + } + + _onKeyboardPopupMenu() { + this.popupMenu(); + this._menu.actor.navigate_focus(null, St.DirectionType.TAB_FORWARD, false); + } + + getId() { + return this.app.get_id(); + } + + popupMenu() { + this._removeMenuTimeout(); + this.fake_release(); + + if (this._draggable) + this._draggable.fakeRelease(); + + if (!this._menu) { + this._menu = new AppIconMenu(this); + this._menu.connect('activate-window', (menu, window) => { + this.activateWindow(window); + }); + this._menu.connect('open-state-changed', (menu, isPoppedUp) => { + if (!isPoppedUp) + this._onMenuPoppedDown(); + }); + let id = Main.overview.connect('hiding', () => { + this._menu.close(); + }); + this.connect('destroy', () => { + Main.overview.disconnect(id); + }); + + this._menuManager.addMenu(this._menu); + } + + this.emit('menu-state-changed', true); + + this.set_hover(true); + this._menu.popup(); + this._menuManager.ignoreRelease(); + this.emit('sync-tooltip'); + + return false; + } + + activateWindow(metaWindow) { + if (metaWindow) + Main.activateWindow(metaWindow); + else + Main.overview.hide(); + } + + _onMenuPoppedDown() { + this.sync_hover(); + this.emit('menu-state-changed', false); + } + + activate(button) { + let event = Clutter.get_current_event(); + let modifiers = event ? event.get_state() : 0; + let isMiddleButton = button && button == Clutter.BUTTON_MIDDLE; + let isCtrlPressed = (modifiers & Clutter.ModifierType.CONTROL_MASK) != 0; + let openNewWindow = this.app.can_open_new_window() && + this.app.state == Shell.AppState.RUNNING && + (isCtrlPressed || isMiddleButton); + + if (this.app.state == Shell.AppState.STOPPED || openNewWindow) + this.animateLaunch(); + + if (openNewWindow) + this.app.open_new_window(-1); + else + this.app.activate(); + + Main.overview.hide(); + } + + animateLaunch() { + this.icon.animateZoomOut(); + } + + animateLaunchAtPos(x, y) { + this.icon.animateZoomOutAtPos(x, y); + } + + shellWorkspaceLaunch(params) { + let { stack } = new Error(); + log('shellWorkspaceLaunch is deprecated, use app.open_new_window() instead\n%s'.format(stack)); + + params = Params.parse(params, { workspace: -1, + timestamp: 0 }); + + this.app.open_new_window(params.workspace); + } + + getDragActor() { + return this.app.create_icon_texture(Main.overview.dash.iconSize); + } + + // Returns the original actor that should align with the actor + // we show as the item is being dragged. + getDragActorSource() { + return this.icon.icon; + } + + shouldShowTooltip() { + return this.hover && (!this._menu || !this._menu.isOpen); + } + + _showFolderPreview() { + this.icon.label.opacity = 0; + this.icon.icon.ease({ + scale_x: FOLDER_SUBICON_FRACTION, + scale_y: FOLDER_SUBICON_FRACTION, + }); + } + + _hideFolderPreview() { + this.icon.label.opacity = 255; + this.icon.icon.ease({ + scale_x: 1.0, + scale_y: 1.0, + }); + } + + _canAccept(source) { + let view = _getViewFromIcon(source); + + return source != this && + (source instanceof this.constructor) && + (view instanceof AppDisplay); + } + + _setHoveringByDnd(hovering) { + if (this._otherIconIsHovering == hovering) + return; + + super._setHoveringByDnd(hovering); + + if (hovering) { + if (this._folderPreviewId > 0) + return; + + this._folderPreviewId = + GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => { + this.add_style_pseudo_class('drop'); + this._showFolderPreview(); + this._folderPreviewId = 0; + return GLib.SOURCE_REMOVE; + }); + } else { + if (this._folderPreviewId > 0) { + GLib.source_remove(this._folderPreviewId); + this._folderPreviewId = 0; + } + this._hideFolderPreview(); + this.remove_style_pseudo_class('drop'); + } + } + + acceptDrop(source, actor, x) { + const accepted = super.acceptDrop(source, actor, x); + if (!accepted) + return false; + + let view = _getViewFromIcon(this); + let apps = [this.id, source.id]; + + return view.createFolder(apps); + } +}); + +var AppIconMenu = class AppIconMenu extends PopupMenu.PopupMenu { + constructor(source) { + let side = St.Side.LEFT; + if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) + side = St.Side.RIGHT; + + super(source, 0.5, side); + + // We want to keep the item hovered while the menu is up + this.blockSourceEvents = true; + + this._source = source; + + this._parentalControlsManager = ParentalControlsManager.getDefault(); + + this.actor.add_style_class_name('app-well-menu'); + + // Chain our visibility and lifecycle to that of the source + this._sourceMappedId = source.connect('notify::mapped', () => { + if (!source.mapped) + this.close(); + }); + source.connect('destroy', () => { + source.disconnect(this._sourceMappedId); + this.destroy(); + }); + + Main.uiGroup.add_actor(this.actor); + } + + _rebuildMenu() { + this.removeAll(); + + let windows = this._source.app.get_windows().filter( + w => !w.skip_taskbar); + + if (windows.length > 0) { + this.addMenuItem( + /* Translators: This is the heading of a list of open windows */ + new PopupMenu.PopupSeparatorMenuItem(_('Open Windows'))); + } + + windows.forEach(window => { + let title = window.title + ? window.title : this._source.app.get_name(); + let item = this._appendMenuItem(title); + item.connect('activate', () => { + this.emit('activate-window', window); + }); + }); + + if (!this._source.app.is_window_backed()) { + this._appendSeparator(); + + let appInfo = this._source.app.get_app_info(); + let actions = appInfo.list_actions(); + if (this._source.app.can_open_new_window() && + !actions.includes('new-window')) { + this._newWindowMenuItem = this._appendMenuItem(_("New Window")); + this._newWindowMenuItem.connect('activate', () => { + this._source.animateLaunch(); + this._source.app.open_new_window(-1); + this.emit('activate-window', null); + }); + this._appendSeparator(); + } + + if (discreteGpuAvailable && + this._source.app.state == Shell.AppState.STOPPED) { + const appPrefersNonDefaultGPU = appInfo.get_boolean('PrefersNonDefaultGPU'); + const gpuPref = appPrefersNonDefaultGPU + ? Shell.AppLaunchGpu.DEFAULT + : Shell.AppLaunchGpu.DISCRETE; + this._onGpuMenuItem = this._appendMenuItem(appPrefersNonDefaultGPU + ? _('Launch using Integrated Graphics Card') + : _('Launch using Discrete Graphics Card')); + this._onGpuMenuItem.connect('activate', () => { + this._source.animateLaunch(); + this._source.app.launch(0, -1, gpuPref); + this.emit('activate-window', null); + }); + } + + for (let i = 0; i < actions.length; i++) { + let action = actions[i]; + let item = this._appendMenuItem(appInfo.get_action_name(action)); + item.connect('activate', (emitter, event) => { + if (action == 'new-window') + this._source.animateLaunch(); + + this._source.app.launch_action(action, event.get_time(), -1); + this.emit('activate-window', null); + }); + } + + let canFavorite = global.settings.is_writable('favorite-apps') && + this._parentalControlsManager.shouldShowApp(this._source.app.app_info); + + if (canFavorite) { + this._appendSeparator(); + + let isFavorite = AppFavorites.getAppFavorites().isFavorite(this._source.app.get_id()); + + if (isFavorite) { + let item = this._appendMenuItem(_("Remove from Favorites")); + item.connect('activate', () => { + let favs = AppFavorites.getAppFavorites(); + favs.removeFavorite(this._source.app.get_id()); + }); + } else { + let item = this._appendMenuItem(_("Add to Favorites")); + item.connect('activate', () => { + let favs = AppFavorites.getAppFavorites(); + favs.addFavorite(this._source.app.get_id()); + }); + } + } + + if (Shell.AppSystem.get_default().lookup_app('org.gnome.Software.desktop')) { + this._appendSeparator(); + let item = this._appendMenuItem(_("Show Details")); + item.connect('activate', async () => { + let id = this._source.app.get_id(); + let args = GLib.Variant.new('(ss)', [id, '']); + const bus = await Gio.DBus.get(Gio.BusType.SESSION, null); + bus.call( + 'org.gnome.Software', + '/org/gnome/Software', + 'org.gtk.Actions', 'Activate', + new GLib.Variant.new( + '(sava{sv})', ['details', [args], null]), + null, 0, -1, null); + Main.overview.hide(); + }); + } + } + } + + _appendSeparator() { + let separator = new PopupMenu.PopupSeparatorMenuItem(); + this.addMenuItem(separator); + } + + _appendMenuItem(labelText) { + // FIXME: app-well-menu-item style + let item = new PopupMenu.PopupMenuItem(labelText); + this.addMenuItem(item); + return item; + } + + popup(_activatingButton) { + this._rebuildMenu(); + this.open(); + } +}; +Signals.addSignalMethods(AppIconMenu.prototype); + +var SystemActionIcon = GObject.registerClass( +class SystemActionIcon extends Search.GridSearchResult { + activate() { + SystemActions.getDefault().activateAction(this.metaInfo['id']); + Main.overview.hide(); + } +}); diff --git a/js/ui/appFavorites.js b/js/ui/appFavorites.js new file mode 100644 index 0000000..a876727 --- /dev/null +++ b/js/ui/appFavorites.js @@ -0,0 +1,211 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported getAppFavorites */ + +const Shell = imports.gi.Shell; +const ParentalControlsManager = imports.misc.parentalControlsManager; +const Signals = imports.signals; + +const Main = imports.ui.main; + +// In alphabetical order +const RENAMED_DESKTOP_IDS = { + 'baobab.desktop': 'org.gnome.baobab.desktop', + 'cheese.desktop': 'org.gnome.Cheese.desktop', + 'dconf-editor.desktop': 'ca.desrt.dconf-editor.desktop', + 'empathy.desktop': 'org.gnome.Empathy.desktop', + 'eog.desktop': 'org.gnome.eog.desktop', + 'epiphany.desktop': 'org.gnome.Epiphany.desktop', + 'evolution.desktop': 'org.gnome.Evolution.desktop', + 'file-roller.desktop': 'org.gnome.FileRoller.desktop', + 'five-or-more.desktop': 'org.gnome.five-or-more.desktop', + 'four-in-a-row.desktop': 'org.gnome.Four-in-a-row.desktop', + 'gcalctool.desktop': 'org.gnome.Calculator.desktop', + 'geary.desktop': 'org.gnome.Geary.desktop', + 'gedit.desktop': 'org.gnome.gedit.desktop', + 'glchess.desktop': 'org.gnome.Chess.desktop', + 'glines.desktop': 'org.gnome.five-or-more.desktop', + 'gnect.desktop': 'org.gnome.Four-in-a-row.desktop', + 'gnibbles.desktop': 'org.gnome.Nibbles.desktop', + 'gnobots2.desktop': 'org.gnome.Robots.desktop', + 'gnome-boxes.desktop': 'org.gnome.Boxes.desktop', + 'gnome-calculator.desktop': 'org.gnome.Calculator.desktop', + 'gnome-chess.desktop': 'org.gnome.Chess.desktop', + 'gnome-clocks.desktop': 'org.gnome.clocks.desktop', + 'gnome-contacts.desktop': 'org.gnome.Contacts.desktop', + 'gnome-documents.desktop': 'org.gnome.Documents.desktop', + 'gnome-font-viewer.desktop': 'org.gnome.font-viewer.desktop', + 'gnome-klotski.desktop': 'org.gnome.Klotski.desktop', + 'gnome-nibbles.desktop': 'org.gnome.Nibbles.desktop', + 'gnome-mahjongg.desktop': 'org.gnome.Mahjongg.desktop', + 'gnome-mines.desktop': 'org.gnome.Mines.desktop', + 'gnome-music.desktop': 'org.gnome.Music.desktop', + 'gnome-photos.desktop': 'org.gnome.Photos.desktop', + 'gnome-robots.desktop': 'org.gnome.Robots.desktop', + 'gnome-screenshot.desktop': 'org.gnome.Screenshot.desktop', + 'gnome-software.desktop': 'org.gnome.Software.desktop', + 'gnome-terminal.desktop': 'org.gnome.Terminal.desktop', + 'gnome-tetravex.desktop': 'org.gnome.Tetravex.desktop', + 'gnome-tweaks.desktop': 'org.gnome.tweaks.desktop', + 'gnome-weather.desktop': 'org.gnome.Weather.desktop', + 'gnomine.desktop': 'org.gnome.Mines.desktop', + 'gnotravex.desktop': 'org.gnome.Tetravex.desktop', + 'gnotski.desktop': 'org.gnome.Klotski.desktop', + 'gtali.desktop': 'org.gnome.Tali.desktop', + 'iagno.desktop': 'org.gnome.Reversi.desktop', + 'nautilus.desktop': 'org.gnome.Nautilus.desktop', + 'org.gnome.gnome-2048.desktop': 'org.gnome.TwentyFortyEight.desktop', + 'org.gnome.taquin.desktop': 'org.gnome.Taquin.desktop', + 'org.gnome.Weather.Application.desktop': 'org.gnome.Weather.desktop', + 'polari.desktop': 'org.gnome.Polari.desktop', + 'seahorse.desktop': 'org.gnome.seahorse.Application.desktop', + 'shotwell.desktop': 'org.gnome.Shotwell.desktop', + 'tali.desktop': 'org.gnome.Tali.desktop', + 'totem.desktop': 'org.gnome.Totem.desktop', + 'evince.desktop': 'org.gnome.Evince.desktop', +}; + +class AppFavorites { + constructor() { + // Filter the apps through the user’s parental controls. + this._parentalControlsManager = ParentalControlsManager.getDefault(); + this._parentalControlsManager.connect('app-filter-changed', () => { + this.reload(); + this.emit('changed'); + }); + + this.FAVORITE_APPS_KEY = 'favorite-apps'; + this._favorites = {}; + global.settings.connect('changed::%s'.format(this.FAVORITE_APPS_KEY), this._onFavsChanged.bind(this)); + this.reload(); + } + + _onFavsChanged() { + this.reload(); + this.emit('changed'); + } + + reload() { + let ids = global.settings.get_strv(this.FAVORITE_APPS_KEY); + let appSys = Shell.AppSystem.get_default(); + + // Map old desktop file names to the current ones + let updated = false; + ids = ids.map(id => { + let newId = RENAMED_DESKTOP_IDS[id]; + if (newId !== undefined && + appSys.lookup_app(newId) != null) { + updated = true; + return newId; + } + return id; + }); + // ... and write back the updated desktop file names + if (updated) + global.settings.set_strv(this.FAVORITE_APPS_KEY, ids); + + let apps = ids.map(id => appSys.lookup_app(id)) + .filter(app => app !== null && this._parentalControlsManager.shouldShowApp(app.app_info)); + this._favorites = {}; + for (let i = 0; i < apps.length; i++) { + let app = apps[i]; + this._favorites[app.get_id()] = app; + } + } + + _getIds() { + let ret = []; + for (let id in this._favorites) + ret.push(id); + return ret; + } + + getFavoriteMap() { + return this._favorites; + } + + getFavorites() { + let ret = []; + for (let id in this._favorites) + ret.push(this._favorites[id]); + return ret; + } + + isFavorite(appId) { + return appId in this._favorites; + } + + _addFavorite(appId, pos) { + if (appId in this._favorites) + return false; + + let app = Shell.AppSystem.get_default().lookup_app(appId); + + if (!app) + return false; + + if (!this._parentalControlsManager.shouldShowApp(app.app_info)) + return false; + + let ids = this._getIds(); + if (pos == -1) + ids.push(appId); + else + ids.splice(pos, 0, appId); + global.settings.set_strv(this.FAVORITE_APPS_KEY, ids); + return true; + } + + addFavoriteAtPos(appId, pos) { + if (!this._addFavorite(appId, pos)) + return; + + let app = Shell.AppSystem.get_default().lookup_app(appId); + + let msg = _("%s has been added to your favorites.").format(app.get_name()); + Main.overview.setMessage(msg, { + forFeedback: true, + undoCallback: () => this._removeFavorite(appId), + }); + } + + addFavorite(appId) { + this.addFavoriteAtPos(appId, -1); + } + + moveFavoriteToPos(appId, pos) { + this._removeFavorite(appId); + this._addFavorite(appId, pos); + } + + _removeFavorite(appId) { + if (!(appId in this._favorites)) + return false; + + let ids = this._getIds().filter(id => id != appId); + global.settings.set_strv(this.FAVORITE_APPS_KEY, ids); + return true; + } + + removeFavorite(appId) { + let ids = this._getIds(); + let pos = ids.indexOf(appId); + + let app = this._favorites[appId]; + if (!this._removeFavorite(appId)) + return; + + let msg = _("%s has been removed from your favorites.").format(app.get_name()); + Main.overview.setMessage(msg, { + forFeedback: true, + undoCallback: () => this._addFavorite(appId, pos), + }); + } +} +Signals.addSignalMethods(AppFavorites.prototype); + +var appFavoritesInstance = null; +function getAppFavorites() { + if (appFavoritesInstance == null) + appFavoritesInstance = new AppFavorites(); + return appFavoritesInstance; +} diff --git a/js/ui/audioDeviceSelection.js b/js/ui/audioDeviceSelection.js new file mode 100644 index 0000000..8660663 --- /dev/null +++ b/js/ui/audioDeviceSelection.js @@ -0,0 +1,199 @@ +/* exported AudioDeviceSelectionDBus */ +const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; + +const Dialog = imports.ui.dialog; +const Main = imports.ui.main; +const ModalDialog = imports.ui.modalDialog; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +var AudioDevice = { + HEADPHONES: 1 << 0, + HEADSET: 1 << 1, + MICROPHONE: 1 << 2, +}; + +const AudioDeviceSelectionIface = loadInterfaceXML('org.gnome.Shell.AudioDeviceSelection'); + +var AudioDeviceSelectionDialog = GObject.registerClass({ + Signals: { 'device-selected': { param_types: [GObject.TYPE_UINT] } }, +}, class AudioDeviceSelectionDialog extends ModalDialog.ModalDialog { + _init(devices) { + super._init({ styleClass: 'audio-device-selection-dialog' }); + + this._deviceItems = {}; + + this._buildLayout(); + + if (devices & AudioDevice.HEADPHONES) + this._addDevice(AudioDevice.HEADPHONES); + if (devices & AudioDevice.HEADSET) + this._addDevice(AudioDevice.HEADSET); + if (devices & AudioDevice.MICROPHONE) + this._addDevice(AudioDevice.MICROPHONE); + + if (this._selectionBox.get_n_children() < 2) + throw new Error('Too few devices for a selection'); + } + + _buildLayout() { + let content = new Dialog.MessageDialogContent({ + title: _('Select Audio Device'), + }); + + this._selectionBox = new St.BoxLayout({ + style_class: 'audio-selection-box', + x_align: Clutter.ActorAlign.CENTER, + x_expand: true, + }); + content.add_child(this._selectionBox); + + this.contentLayout.add_child(content); + + if (Main.sessionMode.allowSettings) { + this.addButton({ + action: this._openSettings.bind(this), + label: _('Sound Settings'), + }); + } + this.addButton({ + action: () => this.close(), + label: _('Cancel'), + key: Clutter.KEY_Escape, + }); + } + + _getDeviceLabel(device) { + switch (device) { + case AudioDevice.HEADPHONES: + return _("Headphones"); + case AudioDevice.HEADSET: + return _("Headset"); + case AudioDevice.MICROPHONE: + return _("Microphone"); + default: + return null; + } + } + + _getDeviceIcon(device) { + switch (device) { + case AudioDevice.HEADPHONES: + return 'audio-headphones-symbolic'; + case AudioDevice.HEADSET: + return 'audio-headset-symbolic'; + case AudioDevice.MICROPHONE: + return 'audio-input-microphone-symbolic'; + default: + return null; + } + } + + _addDevice(device) { + let box = new St.BoxLayout({ style_class: 'audio-selection-device-box', + vertical: true }); + box.connect('notify::height', () => { + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + box.width = box.height; + return GLib.SOURCE_REMOVE; + }); + }); + + let icon = new St.Icon({ style_class: 'audio-selection-device-icon', + icon_name: this._getDeviceIcon(device) }); + box.add(icon); + + let label = new St.Label({ style_class: 'audio-selection-device-label', + text: this._getDeviceLabel(device), + x_align: Clutter.ActorAlign.CENTER }); + box.add(label); + + let button = new St.Button({ style_class: 'audio-selection-device', + can_focus: true, + child: box }); + this._selectionBox.add(button); + + button.connect('clicked', () => { + this.emit('device-selected', device); + this.close(); + Main.overview.hide(); + }); + } + + _openSettings() { + let desktopFile = 'gnome-sound-panel.desktop'; + let app = Shell.AppSystem.get_default().lookup_app(desktopFile); + + if (!app) { + log('Settings panel for desktop file %s could not be loaded!'.format(desktopFile)); + return; + } + + this.close(); + Main.overview.hide(); + app.activate(); + } +}); + +var AudioDeviceSelectionDBus = class AudioDeviceSelectionDBus { + constructor() { + this._audioSelectionDialog = null; + + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(AudioDeviceSelectionIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell/AudioDeviceSelection'); + + Gio.DBus.session.own_name('org.gnome.Shell.AudioDeviceSelection', Gio.BusNameOwnerFlags.REPLACE, null, null); + } + + _onDialogClosed() { + this._audioSelectionDialog = null; + } + + _onDeviceSelected(dialog, device) { + let connection = this._dbusImpl.get_connection(); + let info = this._dbusImpl.get_info(); + const deviceName = Object.keys(AudioDevice) + .filter(dev => AudioDevice[dev] === device)[0].toLowerCase(); + connection.emit_signal(this._audioSelectionDialog._sender, + this._dbusImpl.get_object_path(), + info ? info.name : null, + 'DeviceSelected', + GLib.Variant.new('(s)', [deviceName])); + } + + OpenAsync(params, invocation) { + if (this._audioSelectionDialog) { + invocation.return_value(null); + return; + } + + let [deviceNames] = params; + let devices = 0; + deviceNames.forEach(n => (devices |= AudioDevice[n.toUpperCase()])); + + let dialog; + try { + dialog = new AudioDeviceSelectionDialog(devices); + } catch (e) { + invocation.return_value(null); + return; + } + dialog._sender = invocation.get_sender(); + + dialog.connect('closed', this._onDialogClosed.bind(this)); + dialog.connect('device-selected', + this._onDeviceSelected.bind(this)); + dialog.open(); + + this._audioSelectionDialog = dialog; + invocation.return_value(null); + } + + CloseAsync(params, invocation) { + if (this._audioSelectionDialog && + this._audioSelectionDialog._sender == invocation.get_sender()) + this._audioSelectionDialog.close(); + + invocation.return_value(null); + } +}; diff --git a/js/ui/background.js b/js/ui/background.js new file mode 100644 index 0000000..ca34451 --- /dev/null +++ b/js/ui/background.js @@ -0,0 +1,798 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported SystemBackground */ + +// READ THIS FIRST +// Background handling is a maze of objects, both objects in this file, and +// also objects inside Mutter. They all have a role. +// +// BackgroundManager +// The only object that other parts of GNOME Shell deal with; a +// BackgroundManager creates background actors and adds them to +// the specified container. When the background is changed by the +// user it will fade out the old actor and fade in the new actor. +// (This is separate from the fading for an animated background, +// since using two actors is quite inefficient.) +// +// MetaBackgroundImage +// An object represented an image file that will be used for drawing +// the background. MetaBackgroundImage objects asynchronously load, +// so they are first created in an unloaded state, then later emit +// a ::loaded signal when the Cogl object becomes available. +// +// MetaBackgroundImageCache +// A cache from filename to MetaBackgroundImage. +// +// BackgroundSource +// An object that is created for each GSettings schema (separate +// settings schemas are used for the lock screen and main background), +// and holds a reference to shared Background objects. +// +// MetaBackground +// Holds the specification of a background - a background color +// or gradient and one or two images blended together. +// +// Background +// JS delegate object that Connects a MetaBackground to the GSettings +// schema for the background. +// +// Animation +// A helper object that handles loading a XML-based animation; it is a +// wrapper for GnomeDesktop.BGSlideShow +// +// MetaBackgroundActor +// An actor that draws the background for a single monitor +// +// BackgroundCache +// A cache of Settings schema => BackgroundSource and of a single Animation. +// Also used to share file monitors. +// +// A static image, background color or gradient is relatively straightforward. The +// calling code creates a separate BackgroundManager for each monitor. Since they +// are created for the same GSettings schema, they will use the same BackgroundSource +// object, which provides a single Background and correspondingly a single +// MetaBackground object. +// +// BackgroundManager BackgroundManager +// | \ / | +// | BackgroundSource | looked up in BackgroundCache +// | | | +// | Background | +// | | | +// MetaBackgroundActor | MetaBackgroundActor +// \ | / +// `------- MetaBackground ------' +// | +// MetaBackgroundImage looked up in MetaBackgroundImageCache +// +// The animated case is tricker because the animation XML file can specify different +// files for different monitor resolutions and aspect ratios. For this reason, +// the BackgroundSource provides different Background share a single Animation object, +// which tracks the animation, but use different MetaBackground objects. In the +// common case, the different MetaBackground objects will be created for the +// same filename and look up the *same* MetaBackgroundImage object, so there is +// little wasted memory: +// +// BackgroundManager BackgroundManager +// | \ / | +// | BackgroundSource | looked up in BackgroundCache +// | / \ | +// | Background Background | +// | | \ / | | +// | | Animation | | looked up in BackgroundCache +// MetaBackgroundA|tor Me|aBackgroundActor +// \ | | / +// MetaBackground MetaBackground +// \ / +// MetaBackgroundImage looked up in MetaBackgroundImageCache +// MetaBackgroundImage +// +// But the case of different filenames and different background images +// is possible as well: +// .... +// MetaBackground MetaBackground +// | | +// MetaBackgroundImage MetaBackgroundImage +// MetaBackgroundImage MetaBackgroundImage + +const { Clutter, GDesktopEnums, Gio, GLib, GObject, GnomeDesktop, Meta } = imports.gi; +const Signals = imports.signals; + +const LoginManager = imports.misc.loginManager; +const Main = imports.ui.main; +const Params = imports.misc.params; + +var DEFAULT_BACKGROUND_COLOR = Clutter.Color.from_pixel(0x2e3436ff); + +const BACKGROUND_SCHEMA = 'org.gnome.desktop.background'; +const PRIMARY_COLOR_KEY = 'primary-color'; +const SECONDARY_COLOR_KEY = 'secondary-color'; +const COLOR_SHADING_TYPE_KEY = 'color-shading-type'; +const BACKGROUND_STYLE_KEY = 'picture-options'; +const PICTURE_URI_KEY = 'picture-uri'; + +var FADE_ANIMATION_TIME = 1000; + +// These parameters affect how often we redraw. +// The first is how different (percent crossfaded) the slide show +// has to look before redrawing and the second is the minimum +// frequency (in seconds) we're willing to wake up +var ANIMATION_OPACITY_STEP_INCREMENT = 4.0; +var ANIMATION_MIN_WAKEUP_INTERVAL = 1.0; + +let _backgroundCache = null; + +function _fileEqual0(file1, file2) { + if (file1 == file2) + return true; + + if (!file1 || !file2) + return false; + + return file1.equal(file2); +} + +var BackgroundCache = class BackgroundCache { + constructor() { + this._fileMonitors = {}; + this._backgroundSources = {}; + this._animations = {}; + } + + monitorFile(file) { + let key = file.hash(); + if (this._fileMonitors[key]) + return; + + let monitor = file.monitor(Gio.FileMonitorFlags.NONE, null); + monitor.connect('changed', + (obj, theFile, otherFile, eventType) => { + // Ignore CHANGED and CREATED events, since in both cases + // we'll get a CHANGES_DONE_HINT event when done. + if (eventType != Gio.FileMonitorEvent.CHANGED && + eventType != Gio.FileMonitorEvent.CREATED) + this.emit('file-changed', file); + }); + + this._fileMonitors[key] = monitor; + } + + getAnimation(params) { + params = Params.parse(params, { file: null, + settingsSchema: null, + onLoaded: null }); + + let animation = this._animations[params.settingsSchema]; + if (animation && _fileEqual0(animation.file, params.file)) { + if (params.onLoaded) { + let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + params.onLoaded(this._animations[params.settingsSchema]); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] params.onLoaded'); + } + return; + } + + animation = new Animation({ file: params.file }); + + animation.load(() => { + this._animations[params.settingsSchema] = animation; + + if (params.onLoaded) { + let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + params.onLoaded(this._animations[params.settingsSchema]); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] params.onLoaded'); + } + }); + } + + getBackgroundSource(layoutManager, settingsSchema) { + // The layoutManager is always the same one; we pass in it since + // Main.layoutManager may not be set yet + + if (!(settingsSchema in this._backgroundSources)) { + this._backgroundSources[settingsSchema] = new BackgroundSource(layoutManager, settingsSchema); + this._backgroundSources[settingsSchema]._useCount = 1; + } else { + this._backgroundSources[settingsSchema]._useCount++; + } + + return this._backgroundSources[settingsSchema]; + } + + releaseBackgroundSource(settingsSchema) { + if (settingsSchema in this._backgroundSources) { + let source = this._backgroundSources[settingsSchema]; + source._useCount--; + if (source._useCount == 0) { + delete this._backgroundSources[settingsSchema]; + source.destroy(); + } + } + } +}; +Signals.addSignalMethods(BackgroundCache.prototype); + +function getBackgroundCache() { + if (!_backgroundCache) + _backgroundCache = new BackgroundCache(); + return _backgroundCache; +} + +var Background = GObject.registerClass({ + Signals: { 'loaded': {}, 'bg-changed': {} }, +}, class Background extends Meta.Background { + _init(params) { + params = Params.parse(params, { monitorIndex: 0, + layoutManager: Main.layoutManager, + settings: null, + file: null, + style: null }); + + super._init({ meta_display: global.display }); + + this._settings = params.settings; + this._file = params.file; + this._style = params.style; + this._monitorIndex = params.monitorIndex; + this._layoutManager = params.layoutManager; + this._fileWatches = {}; + this._cancellable = new Gio.Cancellable(); + this.isLoaded = false; + + this._clock = new GnomeDesktop.WallClock(); + this._timezoneChangedId = this._clock.connect('notify::timezone', + () => { + if (this._animation) + this._loadAnimation(this._animation.file); + }); + + let loginManager = LoginManager.getLoginManager(); + this._prepareForSleepId = loginManager.connect('prepare-for-sleep', + (lm, aboutToSuspend) => { + if (aboutToSuspend) + return; + this._refreshAnimation(); + }); + + this._settingsChangedSignalId = + this._settings.connect('changed', this._emitChangedSignal.bind(this)); + + this._load(); + } + + destroy() { + this._cancellable.cancel(); + this._removeAnimationTimeout(); + + let i; + let keys = Object.keys(this._fileWatches); + for (i = 0; i < keys.length; i++) + this._cache.disconnect(this._fileWatches[keys[i]]); + + this._fileWatches = null; + + if (this._timezoneChangedId != 0) + this._clock.disconnect(this._timezoneChangedId); + this._timezoneChangedId = 0; + + this._clock = null; + + if (this._prepareForSleepId != 0) + LoginManager.getLoginManager().disconnect(this._prepareForSleepId); + this._prepareForSleepId = 0; + + if (this._settingsChangedSignalId != 0) + this._settings.disconnect(this._settingsChangedSignalId); + this._settingsChangedSignalId = 0; + + if (this._changedIdleId) { + GLib.source_remove(this._changedIdleId); + this._changedIdleId = 0; + } + } + + _emitChangedSignal() { + if (this._changedIdleId) + return; + + this._changedIdleId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this._changedIdleId = 0; + this.emit('bg-changed'); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._changedIdleId, + '[gnome-shell] Background._emitChangedSignal'); + } + + updateResolution() { + if (this._animation) + this._refreshAnimation(); + } + + _refreshAnimation() { + if (!this._animation) + return; + + this._removeAnimationTimeout(); + this._updateAnimation(); + } + + _setLoaded() { + if (this.isLoaded) + return; + + this.isLoaded = true; + + let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this.emit('loaded'); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] Background._setLoaded Idle'); + } + + _loadPattern() { + let colorString, res_, color, secondColor; + + colorString = this._settings.get_string(PRIMARY_COLOR_KEY); + [res_, color] = Clutter.Color.from_string(colorString); + colorString = this._settings.get_string(SECONDARY_COLOR_KEY); + [res_, secondColor] = Clutter.Color.from_string(colorString); + + let shadingType = this._settings.get_enum(COLOR_SHADING_TYPE_KEY); + + if (shadingType == GDesktopEnums.BackgroundShading.SOLID) + this.set_color(color); + else + this.set_gradient(shadingType, color, secondColor); + + this._setLoaded(); + } + + _watchFile(file) { + let key = file.hash(); + if (this._fileWatches[key]) + return; + + this._cache.monitorFile(file); + let signalId = this._cache.connect('file-changed', + (cache, changedFile) => { + if (changedFile.equal(file)) { + let imageCache = Meta.BackgroundImageCache.get_default(); + imageCache.purge(changedFile); + this._emitChangedSignal(); + } + }); + this._fileWatches[key] = signalId; + } + + _removeAnimationTimeout() { + if (this._updateAnimationTimeoutId) { + GLib.source_remove(this._updateAnimationTimeoutId); + this._updateAnimationTimeoutId = 0; + } + } + + _updateAnimation() { + this._updateAnimationTimeoutId = 0; + + this._animation.update(this._layoutManager.monitors[this._monitorIndex]); + let files = this._animation.keyFrameFiles; + + let finish = () => { + this._setLoaded(); + if (files.length > 1) { + this.set_blend(files[0], files[1], + this._animation.transitionProgress, + this._style); + } else if (files.length > 0) { + this.set_file(files[0], this._style); + } else { + this.set_file(null, this._style); + } + this._queueUpdateAnimation(); + }; + + let cache = Meta.BackgroundImageCache.get_default(); + let numPendingImages = files.length; + for (let i = 0; i < files.length; i++) { + this._watchFile(files[i]); + let image = cache.load(files[i]); + if (image.is_loaded()) { + numPendingImages--; + if (numPendingImages == 0) + finish(); + } else { + // eslint-disable-next-line no-loop-func + let id = image.connect('loaded', () => { + image.disconnect(id); + numPendingImages--; + if (numPendingImages == 0) + finish(); + }); + } + } + } + + _queueUpdateAnimation() { + if (this._updateAnimationTimeoutId != 0) + return; + + if (!this._cancellable || this._cancellable.is_cancelled()) + return; + + if (!this._animation.transitionDuration) + return; + + let nSteps = 255 / ANIMATION_OPACITY_STEP_INCREMENT; + let timePerStep = (this._animation.transitionDuration * 1000) / nSteps; + + let interval = Math.max(ANIMATION_MIN_WAKEUP_INTERVAL * 1000, + timePerStep); + + if (interval > GLib.MAXUINT32) + return; + + this._updateAnimationTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, + interval, + () => { + this._updateAnimationTimeoutId = 0; + this._updateAnimation(); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._updateAnimationTimeoutId, '[gnome-shell] this._updateAnimation'); + } + + _loadAnimation(file) { + this._cache.getAnimation({ + file, + settingsSchema: this._settings.schema_id, + onLoaded: animation => { + this._animation = animation; + + if (!this._animation || this._cancellable.is_cancelled()) { + this._setLoaded(); + return; + } + + this._updateAnimation(); + this._watchFile(file); + }, + }); + } + + _loadImage(file) { + this.set_file(file, this._style); + this._watchFile(file); + + let cache = Meta.BackgroundImageCache.get_default(); + let image = cache.load(file); + if (image.is_loaded()) { + this._setLoaded(); + } else { + let id = image.connect('loaded', () => { + this._setLoaded(); + image.disconnect(id); + }); + } + } + + _loadFile(file) { + if (file.get_basename().endsWith('.xml')) + this._loadAnimation(file); + else + this._loadImage(file); + } + + _load() { + this._cache = getBackgroundCache(); + + this._loadPattern(); + + if (!this._file) { + this._setLoaded(); + return; + } + + this._loadFile(this._file); + } +}); + +let _systemBackground; + +var SystemBackground = GObject.registerClass({ + Signals: { 'loaded': {} }, +}, class SystemBackground extends Meta.BackgroundActor { + _init() { + if (_systemBackground == null) { + _systemBackground = new Meta.Background({ meta_display: global.display }); + _systemBackground.set_color(DEFAULT_BACKGROUND_COLOR); + } + + super._init({ + meta_display: global.display, + monitor: 0, + }); + this.content.background = _systemBackground; + + let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this.emit('loaded'); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] SystemBackground.loaded'); + } +}); + +var BackgroundSource = class BackgroundSource { + constructor(layoutManager, settingsSchema) { + // Allow override the background image setting for performance testing + this._layoutManager = layoutManager; + this._overrideImage = GLib.getenv('SHELL_BACKGROUND_IMAGE'); + this._settings = new Gio.Settings({ schema_id: settingsSchema }); + this._backgrounds = []; + + let monitorManager = Meta.MonitorManager.get(); + this._monitorsChangedId = + monitorManager.connect('monitors-changed', + this._onMonitorsChanged.bind(this)); + } + + _onMonitorsChanged() { + for (let monitorIndex in this._backgrounds) { + let background = this._backgrounds[monitorIndex]; + + if (monitorIndex < this._layoutManager.monitors.length) { + background.updateResolution(); + } else { + background.disconnect(background._changedId); + background.destroy(); + delete this._backgrounds[monitorIndex]; + } + } + } + + getBackground(monitorIndex) { + let file = null; + let style; + + // We don't watch changes to settings here, + // instead we rely on Background to watch those + // and emit 'bg-changed' at the right time + + if (this._overrideImage != null) { + file = Gio.File.new_for_path(this._overrideImage); + style = GDesktopEnums.BackgroundStyle.ZOOM; // Hardcode + } else { + style = this._settings.get_enum(BACKGROUND_STYLE_KEY); + if (style != GDesktopEnums.BackgroundStyle.NONE) { + let uri = this._settings.get_string(PICTURE_URI_KEY); + file = Gio.File.new_for_commandline_arg(uri); + } + } + + // Animated backgrounds are (potentially) per-monitor, since + // they can have variants that depend on the aspect ratio and + // size of the monitor; for other backgrounds we can use the + // same background object for all monitors. + if (file == null || !file.get_basename().endsWith('.xml')) + monitorIndex = 0; + + if (!(monitorIndex in this._backgrounds)) { + let background = new Background({ + monitorIndex, + layoutManager: this._layoutManager, + settings: this._settings, + file, + style, + }); + + background._changedId = background.connect('bg-changed', () => { + background.disconnect(background._changedId); + background.destroy(); + delete this._backgrounds[monitorIndex]; + }); + + this._backgrounds[monitorIndex] = background; + } + + return this._backgrounds[monitorIndex]; + } + + destroy() { + let monitorManager = Meta.MonitorManager.get(); + monitorManager.disconnect(this._monitorsChangedId); + + for (let monitorIndex in this._backgrounds) { + let background = this._backgrounds[monitorIndex]; + background.disconnect(background._changedId); + background.destroy(); + } + + this._backgrounds = null; + } +}; + +var Animation = GObject.registerClass( +class Animation extends GnomeDesktop.BGSlideShow { + _init(params) { + super._init(params); + + this.keyFrameFiles = []; + this.transitionProgress = 0.0; + this.transitionDuration = 0.0; + this.loaded = false; + } + + load(callback) { + this.load_async(null, () => { + this.loaded = true; + if (callback) + callback(); + }); + } + + update(monitor) { + this.keyFrameFiles = []; + + if (this.get_num_slides() < 1) + return; + + let [progress, duration, isFixed_, filename1, filename2] = + this.get_current_slide(monitor.width, monitor.height); + + this.transitionDuration = duration; + this.transitionProgress = progress; + + if (filename1) + this.keyFrameFiles.push(Gio.File.new_for_path(filename1)); + + if (filename2) + this.keyFrameFiles.push(Gio.File.new_for_path(filename2)); + } +}); + +var BackgroundManager = class BackgroundManager { + constructor(params) { + params = Params.parse(params, { container: null, + layoutManager: Main.layoutManager, + monitorIndex: null, + vignette: false, + controlPosition: true, + settingsSchema: BACKGROUND_SCHEMA }); + + let cache = getBackgroundCache(); + this._settingsSchema = params.settingsSchema; + this._backgroundSource = cache.getBackgroundSource(params.layoutManager, params.settingsSchema); + + this._container = params.container; + this._layoutManager = params.layoutManager; + this._vignette = params.vignette; + this._monitorIndex = params.monitorIndex; + this._controlPosition = params.controlPosition; + + this.backgroundActor = this._createBackgroundActor(); + this._newBackgroundActor = null; + } + + destroy() { + let cache = getBackgroundCache(); + cache.releaseBackgroundSource(this._settingsSchema); + this._backgroundSource = null; + + if (this._newBackgroundActor) { + this._newBackgroundActor.destroy(); + this._newBackgroundActor = null; + } + + if (this.backgroundActor) { + this.backgroundActor.destroy(); + this.backgroundActor = null; + } + } + + _swapBackgroundActor() { + let oldBackgroundActor = this.backgroundActor; + this.backgroundActor = this._newBackgroundActor; + this._newBackgroundActor = null; + this.emit('changed'); + + oldBackgroundActor.ease({ + opacity: 0, + duration: FADE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => oldBackgroundActor.destroy(), + }); + } + + _updateBackgroundActor() { + if (this._newBackgroundActor) { + /* Skip displaying existing background queued for load */ + this._newBackgroundActor.destroy(); + this._newBackgroundActor = null; + } + + let newBackgroundActor = this._createBackgroundActor(); + + const oldContent = this.backgroundActor.content; + const newContent = newBackgroundActor.content; + + newContent.vignette_sharpness = oldContent.vignette_sharpness; + newContent.brightness = oldContent.brightness; + + newBackgroundActor.visible = this.backgroundActor.visible; + + this._newBackgroundActor = newBackgroundActor; + + const { background } = newBackgroundActor.content; + + if (background.isLoaded) { + this._swapBackgroundActor(); + } else { + newBackgroundActor.loadedSignalId = background.connect('loaded', + () => { + background.disconnect(newBackgroundActor.loadedSignalId); + newBackgroundActor.loadedSignalId = 0; + + this._swapBackgroundActor(); + }); + } + } + + _createBackgroundActor() { + let background = this._backgroundSource.getBackground(this._monitorIndex); + let backgroundActor = new Meta.BackgroundActor({ + meta_display: global.display, + monitor: this._monitorIndex, + }); + backgroundActor.content.set({ + background, + vignette: this._vignette, + vignette_sharpness: 0.5, + brightness: 0.5, + }); + + this._container.add_child(backgroundActor); + + if (this._controlPosition) { + let monitor = this._layoutManager.monitors[this._monitorIndex]; + backgroundActor.set_position(monitor.x, monitor.y); + this._container.set_child_below_sibling(backgroundActor, null); + } + + let changeSignalId = background.connect('bg-changed', () => { + background.disconnect(changeSignalId); + changeSignalId = null; + this._updateBackgroundActor(); + }); + + let loadedSignalId; + if (background.isLoaded) { + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this.emit('loaded'); + return GLib.SOURCE_REMOVE; + }); + } else { + loadedSignalId = background.connect('loaded', () => { + background.disconnect(loadedSignalId); + loadedSignalId = null; + this.emit('loaded'); + }); + } + + backgroundActor.connect('destroy', () => { + if (changeSignalId) + background.disconnect(changeSignalId); + + if (loadedSignalId) + background.disconnect(loadedSignalId); + + if (backgroundActor.loadedSignalId) + background.disconnect(backgroundActor.loadedSignalId); + }); + + return backgroundActor; + } +}; +Signals.addSignalMethods(BackgroundManager.prototype); diff --git a/js/ui/backgroundMenu.js b/js/ui/backgroundMenu.js new file mode 100644 index 0000000..e31e4c1 --- /dev/null +++ b/js/ui/backgroundMenu.js @@ -0,0 +1,69 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported addBackgroundMenu */ + +const { Clutter, St } = imports.gi; + +const BoxPointer = imports.ui.boxpointer; +const Main = imports.ui.main; +const PopupMenu = imports.ui.popupMenu; + +var BackgroundMenu = class BackgroundMenu extends PopupMenu.PopupMenu { + constructor(layoutManager) { + super(layoutManager.dummyCursor, 0, St.Side.TOP); + + this.addSettingsAction(_("Change Background…"), 'gnome-background-panel.desktop'); + this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + this.addSettingsAction(_("Display Settings"), 'gnome-display-panel.desktop'); + this.addSettingsAction(_("Settings"), 'gnome-control-center.desktop'); + + this.actor.add_style_class_name('background-menu'); + + layoutManager.uiGroup.add_actor(this.actor); + this.actor.hide(); + } +}; + +function addBackgroundMenu(actor, layoutManager) { + actor.reactive = true; + actor._backgroundMenu = new BackgroundMenu(layoutManager); + actor._backgroundManager = new PopupMenu.PopupMenuManager(actor); + actor._backgroundManager.addMenu(actor._backgroundMenu); + + function openMenu(x, y) { + Main.layoutManager.setDummyCursorGeometry(x, y, 0, 0); + actor._backgroundMenu.open(BoxPointer.PopupAnimation.FULL); + } + + let clickAction = new Clutter.ClickAction(); + clickAction.connect('long-press', (action, theActor, state) => { + if (state == Clutter.LongPressState.QUERY) { + return (action.get_button() == 0 || + action.get_button() == 1) && + !actor._backgroundMenu.isOpen; + } + if (state == Clutter.LongPressState.ACTIVATE) { + let [x, y] = action.get_coords(); + openMenu(x, y); + actor._backgroundManager.ignoreRelease(); + } + return true; + }); + clickAction.connect('clicked', action => { + if (action.get_button() == 3) { + let [x, y] = action.get_coords(); + openMenu(x, y); + } + }); + actor.add_action(clickAction); + + let grabOpBeginId = global.display.connect('grab-op-begin', () => { + clickAction.release(); + }); + + actor.connect('destroy', () => { + actor._backgroundMenu.destroy(); + actor._backgroundMenu = null; + actor._backgroundManager = null; + global.display.disconnect(grabOpBeginId); + }); +} diff --git a/js/ui/barLevel.js b/js/ui/barLevel.js new file mode 100644 index 0000000..f4fdb6e --- /dev/null +++ b/js/ui/barLevel.js @@ -0,0 +1,234 @@ +/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */ +/* exported BarLevel */ + +const { Atk, Clutter, GObject, St } = imports.gi; + +var BarLevel = GObject.registerClass({ + Properties: { + 'value': GObject.ParamSpec.double( + 'value', 'value', 'value', + GObject.ParamFlags.READWRITE, + 0, 2, 0), + 'maximum-value': GObject.ParamSpec.double( + 'maximum-value', 'maximum-value', 'maximum-value', + GObject.ParamFlags.READWRITE, + 1, 2, 1), + 'overdrive-start': GObject.ParamSpec.double( + 'overdrive-start', 'overdrive-start', 'overdrive-start', + GObject.ParamFlags.READWRITE, + 1, 2, 1), + }, +}, class BarLevel extends St.DrawingArea { + _init(params) { + this._maxValue = 1; + this._value = 0; + this._overdriveStart = 1; + this._barLevelWidth = 0; + + let defaultParams = { + style_class: 'barlevel', + accessible_role: Atk.Role.LEVEL_BAR, + }; + super._init(Object.assign(defaultParams, params)); + this.connect('notify::allocation', () => { + this._barLevelWidth = this.allocation.get_width(); + }); + + this._customAccessible = St.GenericAccessible.new_for_actor(this); + this.set_accessible(this._customAccessible); + + this._customAccessible.connect('get-current-value', this._getCurrentValue.bind(this)); + this._customAccessible.connect('get-minimum-value', this._getMinimumValue.bind(this)); + this._customAccessible.connect('get-maximum-value', this._getMaximumValue.bind(this)); + this._customAccessible.connect('set-current-value', this._setCurrentValue.bind(this)); + + this.connect('notify::value', this._valueChanged.bind(this)); + } + + get value() { + return this._value; + } + + set value(value) { + value = Math.max(Math.min(value, this._maxValue), 0); + + if (this._value == value) + return; + + this._value = value; + this.notify('value'); + this.queue_repaint(); + } + + // eslint-disable-next-line camelcase + get maximum_value() { + return this._maxValue; + } + + // eslint-disable-next-line camelcase + set maximum_value(value) { + value = Math.max(value, 1); + + if (this._maxValue == value) + return; + + this._maxValue = value; + this._overdriveStart = Math.min(this._overdriveStart, this._maxValue); + this.notify('maximum-value'); + this.queue_repaint(); + } + + // eslint-disable-next-line camelcase + get overdrive_start() { + return this._overdriveStart; + } + + // eslint-disable-next-line camelcase + set overdrive_start(value) { + if (this._overdriveStart == value) + return; + + if (value > this._maxValue) { + throw new Error(`Tried to set overdrive value to ${value}, ` + + `which is a number greater than the maximum allowed value ${this._maxValue}`); + } + + this._overdriveStart = value; + this.notify('overdrive-start'); + this.queue_repaint(); + } + + vfunc_repaint() { + let cr = this.get_context(); + let themeNode = this.get_theme_node(); + let [width, height] = this.get_surface_size(); + + let barLevelHeight = themeNode.get_length('-barlevel-height'); + let barLevelBorderRadius = Math.min(width, barLevelHeight) / 2; + let fgColor = themeNode.get_foreground_color(); + + let barLevelColor = themeNode.get_color('-barlevel-background-color'); + let barLevelActiveColor = themeNode.get_color('-barlevel-active-background-color'); + let barLevelOverdriveColor = themeNode.get_color('-barlevel-overdrive-color'); + + let barLevelBorderWidth = Math.min(themeNode.get_length('-barlevel-border-width'), 1); + let [hasBorderColor, barLevelBorderColor] = + themeNode.lookup_color('-barlevel-border-color', false); + if (!hasBorderColor) + barLevelBorderColor = barLevelColor; + let [hasActiveBorderColor, barLevelActiveBorderColor] = + themeNode.lookup_color('-barlevel-active-border-color', false); + if (!hasActiveBorderColor) + barLevelActiveBorderColor = barLevelActiveColor; + let [hasOverdriveBorderColor, barLevelOverdriveBorderColor] = + themeNode.lookup_color('-barlevel-overdrive-border-color', false); + if (!hasOverdriveBorderColor) + barLevelOverdriveBorderColor = barLevelOverdriveColor; + + const TAU = Math.PI * 2; + + let endX = 0; + if (this._maxValue > 0) + endX = barLevelBorderRadius + (width - 2 * barLevelBorderRadius) * this._value / this._maxValue; + + let overdriveSeparatorX = barLevelBorderRadius + (width - 2 * barLevelBorderRadius) * this._overdriveStart / this._maxValue; + let overdriveActive = this._overdriveStart !== this._maxValue; + let overdriveSeparatorWidth = 0; + if (overdriveActive) + overdriveSeparatorWidth = themeNode.get_length('-barlevel-overdrive-separator-width'); + + /* background bar */ + cr.arc(width - barLevelBorderRadius - barLevelBorderWidth, height / 2, barLevelBorderRadius, TAU * (3 / 4), TAU * (1 / 4)); + cr.lineTo(endX, (height + barLevelHeight) / 2); + cr.lineTo(endX, (height - barLevelHeight) / 2); + cr.lineTo(width - barLevelBorderRadius - barLevelBorderWidth, (height - barLevelHeight) / 2); + Clutter.cairo_set_source_color(cr, barLevelColor); + cr.fillPreserve(); + Clutter.cairo_set_source_color(cr, barLevelBorderColor); + cr.setLineWidth(barLevelBorderWidth); + cr.stroke(); + + /* normal progress bar */ + let x = Math.min(endX, overdriveSeparatorX - overdriveSeparatorWidth / 2); + cr.arc(barLevelBorderRadius + barLevelBorderWidth, height / 2, barLevelBorderRadius, TAU * (1 / 4), TAU * (3 / 4)); + cr.lineTo(x, (height - barLevelHeight) / 2); + cr.lineTo(x, (height + barLevelHeight) / 2); + cr.lineTo(barLevelBorderRadius + barLevelBorderWidth, (height + barLevelHeight) / 2); + if (this._value > 0) + Clutter.cairo_set_source_color(cr, barLevelActiveColor); + cr.fillPreserve(); + Clutter.cairo_set_source_color(cr, barLevelActiveBorderColor); + cr.setLineWidth(barLevelBorderWidth); + cr.stroke(); + + /* overdrive progress barLevel */ + x = Math.min(endX, overdriveSeparatorX) + overdriveSeparatorWidth / 2; + if (this._value > this._overdriveStart) { + cr.moveTo(x, (height - barLevelHeight) / 2); + cr.lineTo(endX, (height - barLevelHeight) / 2); + cr.lineTo(endX, (height + barLevelHeight) / 2); + cr.lineTo(x, (height + barLevelHeight) / 2); + cr.lineTo(x, (height - barLevelHeight) / 2); + Clutter.cairo_set_source_color(cr, barLevelOverdriveColor); + cr.fillPreserve(); + Clutter.cairo_set_source_color(cr, barLevelOverdriveBorderColor); + cr.setLineWidth(barLevelBorderWidth); + cr.stroke(); + } + + /* end progress bar arc */ + if (this._value > 0) { + if (this._value <= this._overdriveStart) + Clutter.cairo_set_source_color(cr, barLevelActiveColor); + else + Clutter.cairo_set_source_color(cr, barLevelOverdriveColor); + cr.arc(endX, height / 2, barLevelBorderRadius, TAU * (3 / 4), TAU * (1 / 4)); + cr.lineTo(Math.floor(endX), (height + barLevelHeight) / 2); + cr.lineTo(Math.floor(endX), (height - barLevelHeight) / 2); + cr.lineTo(endX, (height - barLevelHeight) / 2); + cr.fillPreserve(); + cr.setLineWidth(barLevelBorderWidth); + cr.stroke(); + } + + /* draw overdrive separator */ + if (overdriveActive) { + cr.moveTo(overdriveSeparatorX - overdriveSeparatorWidth / 2, (height - barLevelHeight) / 2); + cr.lineTo(overdriveSeparatorX + overdriveSeparatorWidth / 2, (height - barLevelHeight) / 2); + cr.lineTo(overdriveSeparatorX + overdriveSeparatorWidth / 2, (height + barLevelHeight) / 2); + cr.lineTo(overdriveSeparatorX - overdriveSeparatorWidth / 2, (height + barLevelHeight) / 2); + cr.lineTo(overdriveSeparatorX - overdriveSeparatorWidth / 2, (height - barLevelHeight) / 2); + if (this._value <= this._overdriveStart) + Clutter.cairo_set_source_color(cr, fgColor); + else + Clutter.cairo_set_source_color(cr, barLevelColor); + cr.fill(); + } + + cr.$dispose(); + } + + _getCurrentValue() { + return this._value; + } + + _getOverdriveStart() { + return this._overdriveStart; + } + + _getMinimumValue() { + return 0; + } + + _getMaximumValue() { + return this._maxValue; + } + + _setCurrentValue(_actor, value) { + this._value = value; + } + + _valueChanged() { + this._customAccessible.notify("accessible-value"); + } +}); diff --git a/js/ui/boxpointer.js b/js/ui/boxpointer.js new file mode 100644 index 0000000..c664ae7 --- /dev/null +++ b/js/ui/boxpointer.js @@ -0,0 +1,652 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported BoxPointer */ + +const { Clutter, GObject, Meta, St } = imports.gi; + +const Main = imports.ui.main; + +var PopupAnimation = { + NONE: 0, + SLIDE: 1 << 0, + FADE: 1 << 1, + FULL: ~0, +}; + +var POPUP_ANIMATION_TIME = 150; + +/** + * BoxPointer: + * @side: side to draw the arrow on + * @binProperties: Properties to set on contained bin + * + * An actor which displays a triangle "arrow" pointing to a given + * side. The .bin property is a container in which content can be + * placed. The arrow position may be controlled via + * setArrowOrigin(). The arrow side might be temporarily flipped + * depending on the box size and source position to keep the box + * totally inside the monitor workarea if possible. + * + */ +var BoxPointer = GObject.registerClass({ + Signals: { 'arrow-side-changed': {} }, +}, class BoxPointer extends St.Widget { + _init(arrowSide, binProperties) { + super._init(); + + this.set_offscreen_redirect(Clutter.OffscreenRedirect.ALWAYS); + + this._arrowSide = arrowSide; + this._userArrowSide = arrowSide; + this._arrowOrigin = 0; + this._arrowActor = null; + this.bin = new St.Bin(binProperties); + this.add_actor(this.bin); + this._border = new St.DrawingArea(); + this._border.connect('repaint', this._drawBorder.bind(this)); + this.add_actor(this._border); + this.set_child_above_sibling(this.bin, this._border); + this._sourceAlignment = 0.5; + this._muteInput = true; + + this.connect('notify::visible', () => { + if (this.visible) + Meta.disable_unredirect_for_display(global.display); + else + Meta.enable_unredirect_for_display(global.display); + }); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + vfunc_captured_event() { + if (this._muteInput) + return Clutter.EVENT_STOP; + + return Clutter.EVENT_PROPAGATE; + } + + _onDestroy() { + if (this._sourceActorDestroyId) { + this._sourceActor.disconnect(this._sourceActorDestroyId); + delete this._sourceActorDestroyId; + } + } + + get arrowSide() { + return this._arrowSide; + } + + open(animate, onComplete) { + let themeNode = this.get_theme_node(); + let rise = themeNode.get_length('-arrow-rise'); + let animationTime = animate & PopupAnimation.FULL ? POPUP_ANIMATION_TIME : 0; + + if (animate & PopupAnimation.FADE) + this.opacity = 0; + else + this.opacity = 255; + + this.show(); + + if (animate & PopupAnimation.SLIDE) { + switch (this._arrowSide) { + case St.Side.TOP: + this.translation_y = -rise; + break; + case St.Side.BOTTOM: + this.translation_y = rise; + break; + case St.Side.LEFT: + this.translation_x = -rise; + break; + case St.Side.RIGHT: + this.translation_x = rise; + break; + } + } + + this.ease({ + opacity: 255, + translation_x: 0, + translation_y: 0, + duration: animationTime, + mode: Clutter.AnimationMode.LINEAR, + onComplete: () => { + this._muteInput = false; + if (onComplete) + onComplete(); + }, + }); + } + + close(animate, onComplete) { + if (!this.visible) + return; + + let translationX = 0; + let translationY = 0; + let themeNode = this.get_theme_node(); + let rise = themeNode.get_length('-arrow-rise'); + let fade = animate & PopupAnimation.FADE; + let animationTime = animate & PopupAnimation.FULL ? POPUP_ANIMATION_TIME : 0; + + if (animate & PopupAnimation.SLIDE) { + switch (this._arrowSide) { + case St.Side.TOP: + translationY = rise; + break; + case St.Side.BOTTOM: + translationY = -rise; + break; + case St.Side.LEFT: + translationX = rise; + break; + case St.Side.RIGHT: + translationX = -rise; + break; + } + } + + this._muteInput = true; + + this.remove_all_transitions(); + this.ease({ + opacity: fade ? 0 : 255, + translation_x: translationX, + translation_y: translationY, + duration: animationTime, + mode: Clutter.AnimationMode.LINEAR, + onComplete: () => { + this.hide(); + this.opacity = 0; + this.translation_x = 0; + this.translation_y = 0; + if (onComplete) + onComplete(); + }, + }); + } + + _adjustAllocationForArrow(isWidth, minSize, natSize) { + let themeNode = this.get_theme_node(); + let borderWidth = themeNode.get_length('-arrow-border-width'); + minSize += borderWidth * 2; + natSize += borderWidth * 2; + if ((!isWidth && (this._arrowSide == St.Side.TOP || this._arrowSide == St.Side.BOTTOM)) || + (isWidth && (this._arrowSide == St.Side.LEFT || this._arrowSide == St.Side.RIGHT))) { + let rise = themeNode.get_length('-arrow-rise'); + minSize += rise; + natSize += rise; + } + + return [minSize, natSize]; + } + + vfunc_get_preferred_width(forHeight) { + let themeNode = this.get_theme_node(); + forHeight = themeNode.adjust_for_height(forHeight); + + let width = this.bin.get_preferred_width(forHeight); + width = this._adjustAllocationForArrow(true, ...width); + + return themeNode.adjust_preferred_width(...width); + } + + vfunc_get_preferred_height(forWidth) { + let themeNode = this.get_theme_node(); + let borderWidth = themeNode.get_length('-arrow-border-width'); + forWidth = themeNode.adjust_for_width(forWidth); + + let height = this.bin.get_preferred_height(forWidth - 2 * borderWidth); + height = this._adjustAllocationForArrow(false, ...height); + + return themeNode.adjust_preferred_height(...height); + } + + vfunc_allocate(box) { + if (this._sourceActor && this._sourceActor.mapped) { + this._reposition(box); + this._updateFlip(box); + } + + this.set_allocation(box); + + let themeNode = this.get_theme_node(); + let borderWidth = themeNode.get_length('-arrow-border-width'); + let rise = themeNode.get_length('-arrow-rise'); + let childBox = new Clutter.ActorBox(); + let [availWidth, availHeight] = themeNode.get_content_box(box).get_size(); + + childBox.x1 = 0; + childBox.y1 = 0; + childBox.x2 = availWidth; + childBox.y2 = availHeight; + this._border.allocate(childBox); + + childBox.x1 = borderWidth; + childBox.y1 = borderWidth; + childBox.x2 = availWidth - borderWidth; + childBox.y2 = availHeight - borderWidth; + switch (this._arrowSide) { + case St.Side.TOP: + childBox.y1 += rise; + break; + case St.Side.BOTTOM: + childBox.y2 -= rise; + break; + case St.Side.LEFT: + childBox.x1 += rise; + break; + case St.Side.RIGHT: + childBox.x2 -= rise; + break; + } + this.bin.allocate(childBox); + } + + _drawBorder(area) { + let themeNode = this.get_theme_node(); + + if (this._arrowActor) { + let [sourceX, sourceY] = this._arrowActor.get_transformed_position(); + let [sourceWidth, sourceHeight] = this._arrowActor.get_transformed_size(); + let [absX, absY] = this.get_transformed_position(); + + if (this._arrowSide == St.Side.TOP || + this._arrowSide == St.Side.BOTTOM) + this._arrowOrigin = sourceX - absX + sourceWidth / 2; + else + this._arrowOrigin = sourceY - absY + sourceHeight / 2; + } + + let borderWidth = themeNode.get_length('-arrow-border-width'); + let base = themeNode.get_length('-arrow-base'); + let rise = themeNode.get_length('-arrow-rise'); + let borderRadius = themeNode.get_length('-arrow-border-radius'); + + let halfBorder = borderWidth / 2; + let halfBase = Math.floor(base / 2); + + let backgroundColor = themeNode.get_color('-arrow-background-color'); + + let [width, height] = area.get_surface_size(); + let [boxWidth, boxHeight] = [width, height]; + if (this._arrowSide == St.Side.TOP || this._arrowSide == St.Side.BOTTOM) + boxHeight -= rise; + else + boxWidth -= rise; + + let cr = area.get_context(); + + // Translate so that box goes from 0,0 to boxWidth,boxHeight, + // with the arrow poking out of that + if (this._arrowSide == St.Side.TOP) + cr.translate(0, rise); + else if (this._arrowSide == St.Side.LEFT) + cr.translate(rise, 0); + + let [x1, y1] = [halfBorder, halfBorder]; + let [x2, y2] = [boxWidth - halfBorder, boxHeight - halfBorder]; + + let skipTopLeft = false; + let skipTopRight = false; + let skipBottomLeft = false; + let skipBottomRight = false; + + if (rise) { + switch (this._arrowSide) { + case St.Side.TOP: + if (this._arrowOrigin == x1) + skipTopLeft = true; + else if (this._arrowOrigin == x2) + skipTopRight = true; + break; + + case St.Side.RIGHT: + if (this._arrowOrigin == y1) + skipTopRight = true; + else if (this._arrowOrigin == y2) + skipBottomRight = true; + break; + + case St.Side.BOTTOM: + if (this._arrowOrigin == x1) + skipBottomLeft = true; + else if (this._arrowOrigin == x2) + skipBottomRight = true; + break; + + case St.Side.LEFT: + if (this._arrowOrigin == y1) + skipTopLeft = true; + else if (this._arrowOrigin == y2) + skipBottomLeft = true; + break; + } + } + + cr.moveTo(x1 + borderRadius, y1); + if (this._arrowSide == St.Side.TOP && rise) { + if (skipTopLeft) { + cr.moveTo(x1, y2 - borderRadius); + cr.lineTo(x1, y1 - rise); + cr.lineTo(x1 + halfBase, y1); + } else if (skipTopRight) { + cr.lineTo(x2 - halfBase, y1); + cr.lineTo(x2, y1 - rise); + cr.lineTo(x2, y1 + borderRadius); + } else { + cr.lineTo(this._arrowOrigin - halfBase, y1); + cr.lineTo(this._arrowOrigin, y1 - rise); + cr.lineTo(this._arrowOrigin + halfBase, y1); + } + } + + if (!skipTopRight) { + cr.lineTo(x2 - borderRadius, y1); + cr.arc(x2 - borderRadius, y1 + borderRadius, borderRadius, + 3 * Math.PI / 2, Math.PI * 2); + } + + if (this._arrowSide == St.Side.RIGHT && rise) { + if (skipTopRight) { + cr.lineTo(x2 + rise, y1); + cr.lineTo(x2 + rise, y1 + halfBase); + } else if (skipBottomRight) { + cr.lineTo(x2, y2 - halfBase); + cr.lineTo(x2 + rise, y2); + cr.lineTo(x2 - borderRadius, y2); + } else { + cr.lineTo(x2, this._arrowOrigin - halfBase); + cr.lineTo(x2 + rise, this._arrowOrigin); + cr.lineTo(x2, this._arrowOrigin + halfBase); + } + } + + if (!skipBottomRight) { + cr.lineTo(x2, y2 - borderRadius); + cr.arc(x2 - borderRadius, y2 - borderRadius, borderRadius, + 0, Math.PI / 2); + } + + if (this._arrowSide == St.Side.BOTTOM && rise) { + if (skipBottomLeft) { + cr.lineTo(x1 + halfBase, y2); + cr.lineTo(x1, y2 + rise); + cr.lineTo(x1, y2 - borderRadius); + } else if (skipBottomRight) { + cr.lineTo(x2, y2 + rise); + cr.lineTo(x2 - halfBase, y2); + } else { + cr.lineTo(this._arrowOrigin + halfBase, y2); + cr.lineTo(this._arrowOrigin, y2 + rise); + cr.lineTo(this._arrowOrigin - halfBase, y2); + } + } + + if (!skipBottomLeft) { + cr.lineTo(x1 + borderRadius, y2); + cr.arc(x1 + borderRadius, y2 - borderRadius, borderRadius, + Math.PI / 2, Math.PI); + } + + if (this._arrowSide == St.Side.LEFT && rise) { + if (skipTopLeft) { + cr.lineTo(x1, y1 + halfBase); + cr.lineTo(x1 - rise, y1); + cr.lineTo(x1 + borderRadius, y1); + } else if (skipBottomLeft) { + cr.lineTo(x1 - rise, y2); + cr.lineTo(x1 - rise, y2 - halfBase); + } else { + cr.lineTo(x1, this._arrowOrigin + halfBase); + cr.lineTo(x1 - rise, this._arrowOrigin); + cr.lineTo(x1, this._arrowOrigin - halfBase); + } + } + + if (!skipTopLeft) { + cr.lineTo(x1, y1 + borderRadius); + cr.arc(x1 + borderRadius, y1 + borderRadius, borderRadius, + Math.PI, 3 * Math.PI / 2); + } + + Clutter.cairo_set_source_color(cr, backgroundColor); + cr.fillPreserve(); + + if (borderWidth > 0) { + let borderColor = themeNode.get_color('-arrow-border-color'); + Clutter.cairo_set_source_color(cr, borderColor); + cr.setLineWidth(borderWidth); + cr.stroke(); + } + + cr.$dispose(); + } + + setPosition(sourceActor, alignment) { + if (!this._sourceActor || sourceActor != this._sourceActor) { + if (this._sourceActorDestroyId) { + this._sourceActor.disconnect(this._sourceActorDestroyId); + delete this._sourceActorDestroyId; + } + + this._sourceActor = sourceActor; + + if (this._sourceActor) { + this._sourceActorDestroyId = this._sourceActor.connect('destroy', () => { + this._sourceActor = null; + delete this._sourceActorDestroyId; + }); + } + } + + this._arrowAlignment = alignment; + + this.queue_relayout(); + } + + setSourceAlignment(alignment) { + this._sourceAlignment = alignment; + + if (!this._sourceActor) + return; + + this.setPosition(this._sourceActor, this._arrowAlignment); + } + + _reposition(allocationBox) { + let sourceActor = this._sourceActor; + let alignment = this._arrowAlignment; + let monitorIndex = Main.layoutManager.findIndexForActor(sourceActor); + + this._sourceExtents = sourceActor.get_transformed_extents(); + this._workArea = Main.layoutManager.getWorkAreaForMonitor(monitorIndex); + + // Position correctly relative to the sourceActor + let sourceNode = sourceActor.get_theme_node(); + let sourceContentBox = sourceNode.get_content_box(sourceActor.get_allocation_box()); + let sourceTopLeft = this._sourceExtents.get_top_left(); + let sourceBottomRight = this._sourceExtents.get_bottom_right(); + let sourceCenterX = sourceTopLeft.x + sourceContentBox.x1 + (sourceContentBox.x2 - sourceContentBox.x1) * this._sourceAlignment; + let sourceCenterY = sourceTopLeft.y + sourceContentBox.y1 + (sourceContentBox.y2 - sourceContentBox.y1) * this._sourceAlignment; + let [, , natWidth, natHeight] = this.get_preferred_size(); + + // We also want to keep it onscreen, and separated from the + // edge by the same distance as the main part of the box is + // separated from its sourceActor + let workarea = this._workArea; + let themeNode = this.get_theme_node(); + let borderWidth = themeNode.get_length('-arrow-border-width'); + let arrowBase = themeNode.get_length('-arrow-base'); + let borderRadius = themeNode.get_length('-arrow-border-radius'); + let margin = 4 * borderRadius + borderWidth + arrowBase; + + let gap = themeNode.get_length('-boxpointer-gap'); + let padding = themeNode.get_length('-arrow-rise'); + + let resX, resY; + + switch (this._arrowSide) { + case St.Side.TOP: + resY = sourceBottomRight.y + gap; + break; + case St.Side.BOTTOM: + resY = sourceTopLeft.y - natHeight - gap; + break; + case St.Side.LEFT: + resX = sourceBottomRight.x + gap; + break; + case St.Side.RIGHT: + resX = sourceTopLeft.x - natWidth - gap; + break; + } + + // Now align and position the pointing axis, making sure it fits on + // screen. If the arrowOrigin is so close to the edge that the arrow + // will not be isosceles, we try to compensate as follows: + // - We skip the rounded corner and settle for a right angled arrow + // as shown below. See _drawBorder for further details. + // |\_____ + // | + // | + // - If the arrow was going to be acute angled, we move the position + // of the box to maintain the arrow's accuracy. + + let arrowOrigin; + let halfBase = Math.floor(arrowBase / 2); + let halfBorder = borderWidth / 2; + let halfMargin = margin / 2; + let [x1, y1] = [halfBorder, halfBorder]; + let [x2, y2] = [natWidth - halfBorder, natHeight - halfBorder]; + + switch (this._arrowSide) { + case St.Side.TOP: + case St.Side.BOTTOM: + resX = sourceCenterX - (halfMargin + (natWidth - margin) * alignment); + + resX = Math.max(resX, workarea.x + padding); + resX = Math.min(resX, workarea.x + workarea.width - (padding + natWidth)); + + arrowOrigin = sourceCenterX - resX; + if (arrowOrigin <= (x1 + (borderRadius + halfBase))) { + if (arrowOrigin > x1) + resX += arrowOrigin - x1; + arrowOrigin = x1; + } else if (arrowOrigin >= (x2 - (borderRadius + halfBase))) { + if (arrowOrigin < x2) + resX -= x2 - arrowOrigin; + arrowOrigin = x2; + } + break; + + case St.Side.LEFT: + case St.Side.RIGHT: + resY = sourceCenterY - (halfMargin + (natHeight - margin) * alignment); + + resY = Math.max(resY, workarea.y + padding); + resY = Math.min(resY, workarea.y + workarea.height - (padding + natHeight)); + + arrowOrigin = sourceCenterY - resY; + if (arrowOrigin <= (y1 + (borderRadius + halfBase))) { + if (arrowOrigin > y1) + resY += arrowOrigin - y1; + arrowOrigin = y1; + } else if (arrowOrigin >= (y2 - (borderRadius + halfBase))) { + if (arrowOrigin < y2) + resX -= y2 - arrowOrigin; + arrowOrigin = y2; + } + break; + } + + this.setArrowOrigin(arrowOrigin); + + let parent = this.get_parent(); + let success, x, y; + while (!success) { + [success, x, y] = parent.transform_stage_point(resX, resY); + parent = parent.get_parent(); + } + + // Actually set the position + allocationBox.set_origin(Math.floor(x), Math.floor(y)); + } + + // @origin: Coordinate specifying middle of the arrow, along + // the Y axis for St.Side.LEFT, St.Side.RIGHT from the top and X axis from + // the left for St.Side.TOP and St.Side.BOTTOM. + setArrowOrigin(origin) { + if (this._arrowOrigin != origin) { + this._arrowOrigin = origin; + this._border.queue_repaint(); + } + } + + // @actor: an actor relative to which the arrow is positioned. + // Differently from setPosition, this will not move the boxpointer itself, + // on the arrow + setArrowActor(actor) { + if (this._arrowActor != actor) { + this._arrowActor = actor; + this._border.queue_repaint(); + } + } + + _calculateArrowSide(arrowSide) { + let sourceTopLeft = this._sourceExtents.get_top_left(); + let sourceBottomRight = this._sourceExtents.get_bottom_right(); + let [, , boxWidth, boxHeight] = this.get_preferred_size(); + let workarea = this._workArea; + + switch (arrowSide) { + case St.Side.TOP: + if (sourceBottomRight.y + boxHeight > workarea.y + workarea.height && + boxHeight < sourceTopLeft.y - workarea.y) + return St.Side.BOTTOM; + break; + case St.Side.BOTTOM: + if (sourceTopLeft.y - boxHeight < workarea.y && + boxHeight < workarea.y + workarea.height - sourceBottomRight.y) + return St.Side.TOP; + break; + case St.Side.LEFT: + if (sourceBottomRight.x + boxWidth > workarea.x + workarea.width && + boxWidth < sourceTopLeft.x - workarea.x) + return St.Side.RIGHT; + break; + case St.Side.RIGHT: + if (sourceTopLeft.x - boxWidth < workarea.x && + boxWidth < workarea.x + workarea.width - sourceBottomRight.x) + return St.Side.LEFT; + break; + } + + return arrowSide; + } + + _updateFlip(allocationBox) { + let arrowSide = this._calculateArrowSide(this._userArrowSide); + if (this._arrowSide != arrowSide) { + this._arrowSide = arrowSide; + this._reposition(allocationBox); + + this.emit('arrow-side-changed'); + } + } + + updateArrowSide(side) { + this._arrowSide = side; + this._border.queue_repaint(); + + this.emit('arrow-side-changed'); + } + + getPadding(side) { + return this.bin.get_theme_node().get_padding(side); + } + + getArrowHeight() { + return this.get_theme_node().get_length('-arrow-rise'); + } +}); diff --git a/js/ui/calendar.js b/js/ui/calendar.js new file mode 100644 index 0000000..710efba --- /dev/null +++ b/js/ui/calendar.js @@ -0,0 +1,1033 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Calendar, CalendarMessageList, DBusEventSource */ + +const { Clutter, Gio, GLib, GObject, Shell, St } = imports.gi; + +const Main = imports.ui.main; +const MessageList = imports.ui.messageList; +const MessageTray = imports.ui.messageTray; +const Mpris = imports.ui.mpris; +const PopupMenu = imports.ui.popupMenu; +const Util = imports.misc.util; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +var MSECS_IN_DAY = 24 * 60 * 60 * 1000; +var SHOW_WEEKDATE_KEY = 'show-weekdate'; + +var MESSAGE_ICON_SIZE = -1; // pick up from CSS + +var NC_ = (context, str) => '%s\u0004%s'.format(context, str); + +function sameYear(dateA, dateB) { + return dateA.getYear() == dateB.getYear(); +} + +function sameMonth(dateA, dateB) { + return sameYear(dateA, dateB) && (dateA.getMonth() == dateB.getMonth()); +} + +function sameDay(dateA, dateB) { + return sameMonth(dateA, dateB) && (dateA.getDate() == dateB.getDate()); +} + +function _isWorkDay(date) { + /* Translators: Enter 0-6 (Sunday-Saturday) for non-work days. Examples: "0" (Sunday) "6" (Saturday) "06" (Sunday and Saturday). */ + let days = C_('calendar-no-work', "06"); + return !days.includes(date.getDay().toString()); +} + +function _getBeginningOfDay(date) { + let ret = new Date(date.getTime()); + ret.setHours(0); + ret.setMinutes(0); + ret.setSeconds(0); + ret.setMilliseconds(0); + return ret; +} + +function _getEndOfDay(date) { + let ret = new Date(date.getTime()); + ret.setHours(23); + ret.setMinutes(59); + ret.setSeconds(59); + ret.setMilliseconds(999); + return ret; +} + +function _getCalendarDayAbbreviation(dayNumber) { + let abbreviations = [ + /* Translators: Calendar grid abbreviation for Sunday. + * + * NOTE: These grid abbreviations are always shown together + * and in order, e.g. "S M T W T F S". + */ + NC_("grid sunday", "S"), + /* Translators: Calendar grid abbreviation for Monday */ + NC_("grid monday", "M"), + /* Translators: Calendar grid abbreviation for Tuesday */ + NC_("grid tuesday", "T"), + /* Translators: Calendar grid abbreviation for Wednesday */ + NC_("grid wednesday", "W"), + /* Translators: Calendar grid abbreviation for Thursday */ + NC_("grid thursday", "T"), + /* Translators: Calendar grid abbreviation for Friday */ + NC_("grid friday", "F"), + /* Translators: Calendar grid abbreviation for Saturday */ + NC_("grid saturday", "S"), + ]; + return Shell.util_translate_time_string(abbreviations[dayNumber]); +} + +// Abstraction for an appointment/event in a calendar + +var CalendarEvent = class CalendarEvent { + constructor(id, date, end, summary, allDay) { + this.id = id; + this.date = date; + this.end = end; + this.summary = summary; + this.allDay = allDay; + } +}; + +// Interface for appointments/events - e.g. the contents of a calendar +// + +var EventSourceBase = GObject.registerClass({ + GTypeFlags: GObject.TypeFlags.ABSTRACT, + Properties: { + 'has-calendars': GObject.ParamSpec.boolean( + 'has-calendars', 'has-calendars', 'has-calendars', + GObject.ParamFlags.READABLE, + false), + 'is-loading': GObject.ParamSpec.boolean( + 'is-loading', 'is-loading', 'is-loading', + GObject.ParamFlags.READABLE, + false), + }, + Signals: { 'changed': {} }, +}, class EventSourceBase extends GObject.Object { + get isLoading() { + throw new GObject.NotImplementedError('isLoading in %s'.format(this.constructor.name)); + } + + get hasCalendars() { + throw new GObject.NotImplementedError('hasCalendars in %s'.format(this.constructor.name)); + } + + destroy() { + } + + requestRange(_begin, _end) { + throw new GObject.NotImplementedError('requestRange in %s'.format(this.constructor.name)); + } + + getEvents(_begin, _end) { + throw new GObject.NotImplementedError('getEvents in %s'.format(this.constructor.name)); + } + + hasEvents(_day) { + throw new GObject.NotImplementedError('hasEvents in %s'.format(this.constructor.name)); + } +}); + +var EmptyEventSource = GObject.registerClass( +class EmptyEventSource extends EventSourceBase { + get isLoading() { + return false; + } + + get hasCalendars() { + return false; + } + + requestRange(_begin, _end) { + } + + getEvents(_begin, _end) { + let result = []; + return result; + } + + hasEvents(_day) { + return false; + } +}); + +const CalendarServerIface = loadInterfaceXML('org.gnome.Shell.CalendarServer'); + +const CalendarServerInfo = Gio.DBusInterfaceInfo.new_for_xml(CalendarServerIface); + +function CalendarServer() { + return new Gio.DBusProxy({ g_connection: Gio.DBus.session, + g_interface_name: CalendarServerInfo.name, + g_interface_info: CalendarServerInfo, + g_name: 'org.gnome.Shell.CalendarServer', + g_object_path: '/org/gnome/Shell/CalendarServer' }); +} + +function _datesEqual(a, b) { + if (a < b) + return false; + else if (a > b) + return false; + return true; +} + +function _dateIntervalsOverlap(a0, a1, b0, b1) { + if (a1 <= b0) + return false; + else if (b1 <= a0) + return false; + else + return true; +} + +// an implementation that reads data from a session bus service +var DBusEventSource = GObject.registerClass( +class DBusEventSource extends EventSourceBase { + _init() { + super._init(); + this._resetCache(); + this._isLoading = false; + + this._initialized = false; + this._dbusProxy = new CalendarServer(); + this._initProxy(); + } + + async _initProxy() { + let loaded = false; + + try { + await this._dbusProxy.init_async(GLib.PRIORITY_DEFAULT, null); + loaded = true; + } catch (e) { + // Ignore timeouts and install signals as normal, because with high + // probability the service will appear later on, and we will get a + // NameOwnerChanged which will finish loading + // + // (But still _initialized to false, because the proxy does not know + // about the HasCalendars property and would cause an exception trying + // to read it) + if (!e.matches(Gio.DBusError, Gio.DBusError.TIMED_OUT)) { + log('Error loading calendars: %s'.format(e.message)); + return; + } + } + + this._dbusProxy.connectSignal('EventsAddedOrUpdated', + this._onEventsAddedOrUpdated.bind(this)); + this._dbusProxy.connectSignal('EventsRemoved', + this._onEventsRemoved.bind(this)); + this._dbusProxy.connectSignal('ClientDisappeared', + this._onClientDisappeared.bind(this)); + + this._dbusProxy.connect('notify::g-name-owner', () => { + if (this._dbusProxy.g_name_owner) + this._onNameAppeared(); + else + this._onNameVanished(); + }); + + this._dbusProxy.connect('g-properties-changed', () => { + this.notify('has-calendars'); + }); + + this._initialized = loaded; + if (loaded) { + this.notify('has-calendars'); + this._onNameAppeared(); + } + } + + destroy() { + this._dbusProxy.run_dispose(); + } + + get hasCalendars() { + if (this._initialized) + return this._dbusProxy.HasCalendars; + else + return false; + } + + get isLoading() { + return this._isLoading; + } + + _resetCache() { + this._events = new Map(); + this._lastRequestBegin = null; + this._lastRequestEnd = null; + } + + _onNameAppeared() { + this._initialized = true; + this._resetCache(); + this._loadEvents(true); + } + + _onNameVanished() { + this._resetCache(); + this.emit('changed'); + } + + _onEventsAddedOrUpdated(dbusProxy, nameOwner, argArray) { + const [appointments = []] = argArray; + let changed = false; + + for (let n = 0; n < appointments.length; n++) { + const [id, summary, allDay, startTime, endTime] = appointments[n]; + const date = new Date(startTime * 1000); + const end = new Date(endTime * 1000); + let event = new CalendarEvent(id, date, end, summary, allDay); + this._events.set(event.id, event); + + changed = true; + } + + if (changed) + this.emit('changed'); + } + + _onEventsRemoved(dbusProxy, nameOwner, argArray) { + const [ids = []] = argArray; + + let changed = false; + for (const id of ids) + changed |= this._events.delete(id); + + if (changed) + this.emit('changed'); + } + + _onClientDisappeared(dbusProxy, nameOwner, argArray) { + let [sourceUid = ''] = argArray; + sourceUid += '\n'; + + let changed = false; + for (const id of this._events.keys()) { + if (id.startsWith(sourceUid)) + changed |= this._events.delete(id); + } + + if (changed) + this.emit('changed'); + } + + _loadEvents(forceReload) { + // Ignore while loading + if (!this._initialized) + return; + + if (this._curRequestBegin && this._curRequestEnd) { + if (forceReload) { + this._events.clear(); + this.emit('changed'); + } + this._dbusProxy.SetTimeRangeRemote( + this._curRequestBegin.getTime() / 1000, + this._curRequestEnd.getTime() / 1000, + forceReload, + Gio.DBusCallFlags.NONE); + } + } + + requestRange(begin, end) { + if (!(_datesEqual(begin, this._lastRequestBegin) && _datesEqual(end, this._lastRequestEnd))) { + this._lastRequestBegin = begin; + this._lastRequestEnd = end; + this._curRequestBegin = begin; + this._curRequestEnd = end; + this._loadEvents(true); + } + } + + *_getFilteredEvents(begin, end) { + for (const event of this._events.values()) { + if (_dateIntervalsOverlap(event.date, event.end, begin, end)) + yield event; + } + } + + getEvents(begin, end) { + let result = [...this._getFilteredEvents(begin, end)]; + + result.sort((event1, event2) => { + // sort events by end time on ending day + let d1 = event1.date < begin && event1.end <= end ? event1.end : event1.date; + let d2 = event2.date < begin && event2.end <= end ? event2.end : event2.date; + return d1.getTime() - d2.getTime(); + }); + return result; + } + + hasEvents(day) { + let dayBegin = _getBeginningOfDay(day); + let dayEnd = _getEndOfDay(day); + + const { done } = this._getFilteredEvents(dayBegin, dayEnd).next(); + return !done; + } +}); + +var Calendar = GObject.registerClass({ + Signals: { 'selected-date-changed': { param_types: [GLib.DateTime.$gtype] } }, +}, class Calendar extends St.Widget { + _init() { + this._weekStart = Shell.util_get_week_start(); + this._settings = new Gio.Settings({ schema_id: 'org.gnome.desktop.calendar' }); + + this._settings.connect('changed::%s'.format(SHOW_WEEKDATE_KEY), this._onSettingsChange.bind(this)); + this._useWeekdate = this._settings.get_boolean(SHOW_WEEKDATE_KEY); + + /** + * Translators: The header displaying just the month name + * standalone, when this is a month of the current year. + * "%OB" is the new format specifier introduced in glibc 2.27, + * in most cases you should not change it. + */ + this._headerFormatWithoutYear = _('%OB'); + /** + * Translators: The header displaying the month name and the year + * number, when this is a month of a different year. You can + * reorder the format specifiers or add other modifications + * according to the requirements of your language. + * "%OB" is the new format specifier introduced in glibc 2.27, + * in most cases you should not use the old "%B" here unless you + * absolutely know what you are doing. + */ + this._headerFormat = _('%OB %Y'); + + // Start off with the current date + this._selectedDate = new Date(); + + this._shouldDateGrabFocus = false; + + super._init({ + style_class: 'calendar', + layout_manager: new Clutter.GridLayout(), + reactive: true, + }); + + this._buildHeader(); + } + + setEventSource(eventSource) { + if (!(eventSource instanceof EventSourceBase)) + throw new Error('Event source is not valid type'); + + this._eventSource = eventSource; + this._eventSource.connect('changed', () => { + this._rebuildCalendar(); + this._update(); + }); + this._rebuildCalendar(); + this._update(); + } + + // Sets the calendar to show a specific date + setDate(date) { + if (sameDay(date, this._selectedDate)) + return; + + this._selectedDate = date; + this._update(); + + let datetime = GLib.DateTime.new_from_unix_local( + this._selectedDate.getTime() / 1000); + this.emit('selected-date-changed', datetime); + } + + updateTimeZone() { + // The calendar need to be rebuilt after a time zone update because + // the date might have changed. + this._rebuildCalendar(); + this._update(); + } + + _buildHeader() { + let layout = this.layout_manager; + let offsetCols = this._useWeekdate ? 1 : 0; + this.destroy_all_children(); + + // Top line of the calendar '<| September 2009 |>' + this._topBox = new St.BoxLayout(); + layout.attach(this._topBox, 0, 0, offsetCols + 7, 1); + + this._backButton = new St.Button({ style_class: 'calendar-change-month-back pager-button', + accessible_name: _("Previous month"), + can_focus: true }); + this._backButton.add_actor(new St.Icon({ icon_name: 'pan-start-symbolic' })); + this._topBox.add(this._backButton); + this._backButton.connect('clicked', this._onPrevMonthButtonClicked.bind(this)); + + this._monthLabel = new St.Label({ + style_class: 'calendar-month-label', + can_focus: true, + x_align: Clutter.ActorAlign.CENTER, + x_expand: true, + }); + this._topBox.add_child(this._monthLabel); + + this._forwardButton = new St.Button({ style_class: 'calendar-change-month-forward pager-button', + accessible_name: _("Next month"), + can_focus: true }); + this._forwardButton.add_actor(new St.Icon({ icon_name: 'pan-end-symbolic' })); + this._topBox.add(this._forwardButton); + this._forwardButton.connect('clicked', this._onNextMonthButtonClicked.bind(this)); + + // Add weekday labels... + // + // We need to figure out the abbreviated localized names for the days of the week; + // we do this by just getting the next 7 days starting from right now and then putting + // them in the right cell in the table. It doesn't matter if we add them in order + let iter = new Date(this._selectedDate); + iter.setSeconds(0); // Leap second protection. Hah! + iter.setHours(12); + for (let i = 0; i < 7; i++) { + // Could use iter.toLocaleFormat('%a') but that normally gives three characters + // and we want, ideally, a single character for e.g. S M T W T F S + let customDayAbbrev = _getCalendarDayAbbreviation(iter.getDay()); + let label = new St.Label({ style_class: 'calendar-day-base calendar-day-heading', + text: customDayAbbrev, + can_focus: true }); + label.accessible_name = iter.toLocaleFormat('%A'); + let col; + if (this.get_text_direction() == Clutter.TextDirection.RTL) + col = 6 - (7 + iter.getDay() - this._weekStart) % 7; + else + col = offsetCols + (7 + iter.getDay() - this._weekStart) % 7; + layout.attach(label, col, 1, 1, 1); + iter.setTime(iter.getTime() + MSECS_IN_DAY); + } + + // All the children after this are days, and get removed when we update the calendar + this._firstDayIndex = this.get_n_children(); + } + + vfunc_scroll_event(scrollEvent) { + switch (scrollEvent.direction) { + case Clutter.ScrollDirection.UP: + case Clutter.ScrollDirection.LEFT: + this._onPrevMonthButtonClicked(); + break; + case Clutter.ScrollDirection.DOWN: + case Clutter.ScrollDirection.RIGHT: + this._onNextMonthButtonClicked(); + break; + } + return Clutter.EVENT_PROPAGATE; + } + + _onPrevMonthButtonClicked() { + let newDate = new Date(this._selectedDate); + let oldMonth = newDate.getMonth(); + if (oldMonth == 0) { + newDate.setMonth(11); + newDate.setFullYear(newDate.getFullYear() - 1); + if (newDate.getMonth() != 11) { + let day = 32 - new Date(newDate.getFullYear() - 1, 11, 32).getDate(); + newDate = new Date(newDate.getFullYear() - 1, 11, day); + } + } else { + newDate.setMonth(oldMonth - 1); + if (newDate.getMonth() != oldMonth - 1) { + let day = 32 - new Date(newDate.getFullYear(), oldMonth - 1, 32).getDate(); + newDate = new Date(newDate.getFullYear(), oldMonth - 1, day); + } + } + + this._backButton.grab_key_focus(); + + this.setDate(newDate); + } + + _onNextMonthButtonClicked() { + let newDate = new Date(this._selectedDate); + let oldMonth = newDate.getMonth(); + if (oldMonth == 11) { + newDate.setMonth(0); + newDate.setFullYear(newDate.getFullYear() + 1); + if (newDate.getMonth() != 0) { + let day = 32 - new Date(newDate.getFullYear() + 1, 0, 32).getDate(); + newDate = new Date(newDate.getFullYear() + 1, 0, day); + } + } else { + newDate.setMonth(oldMonth + 1); + if (newDate.getMonth() != oldMonth + 1) { + let day = 32 - new Date(newDate.getFullYear(), oldMonth + 1, 32).getDate(); + newDate = new Date(newDate.getFullYear(), oldMonth + 1, day); + } + } + + this._forwardButton.grab_key_focus(); + + this.setDate(newDate); + } + + _onSettingsChange() { + this._useWeekdate = this._settings.get_boolean(SHOW_WEEKDATE_KEY); + this._buildHeader(); + this._rebuildCalendar(); + this._update(); + } + + _rebuildCalendar() { + let now = new Date(); + + // Remove everything but the topBox and the weekday labels + let children = this.get_children(); + for (let i = this._firstDayIndex; i < children.length; i++) + children[i].destroy(); + + this._buttons = []; + + // Start at the beginning of the week before the start of the month + // + // We want to show always 6 weeks (to keep the calendar menu at the same + // height if there are no events), so we pad it according to the following + // policy: + // + // 1 - If a month has 6 weeks, we place no padding (example: Dec 2012) + // 2 - If a month has 5 weeks and it starts on week start, we pad one week + // before it (example: Apr 2012) + // 3 - If a month has 5 weeks and it starts on any other day, we pad one week + // after it (example: Nov 2012) + // 4 - If a month has 4 weeks, we pad one week before and one after it + // (example: Feb 2010) + // + // Actually computing the number of weeks is complex, but we know that the + // problematic categories (2 and 4) always start on week start, and that + // all months at the end have 6 weeks. + let beginDate = new Date(this._selectedDate); + beginDate.setDate(1); + beginDate.setSeconds(0); + beginDate.setHours(12); + + this._calendarBegin = new Date(beginDate); + this._markedAsToday = now; + + let daysToWeekStart = (7 + beginDate.getDay() - this._weekStart) % 7; + let startsOnWeekStart = daysToWeekStart == 0; + let weekPadding = startsOnWeekStart ? 7 : 0; + + beginDate.setTime(beginDate.getTime() - (weekPadding + daysToWeekStart) * MSECS_IN_DAY); + + let layout = this.layout_manager; + let iter = new Date(beginDate); + let row = 2; + // nRows here means 6 weeks + one header + one navbar + let nRows = 8; + while (row < nRows) { + // xgettext:no-javascript-format + let button = new St.Button({ label: iter.toLocaleFormat(C_("date day number format", "%d")), + can_focus: true }); + let rtl = button.get_text_direction() == Clutter.TextDirection.RTL; + + if (this._eventSource instanceof EmptyEventSource) + button.reactive = false; + + button._date = new Date(iter); + button.connect('clicked', () => { + this._shouldDateGrabFocus = true; + this.setDate(button._date); + this._shouldDateGrabFocus = false; + }); + + let hasEvents = this._eventSource.hasEvents(iter); + let styleClass = 'calendar-day-base calendar-day'; + + if (_isWorkDay(iter)) + styleClass += ' calendar-work-day'; + else + styleClass += ' calendar-nonwork-day'; + + // Hack used in lieu of border-collapse - see gnome-shell.css + if (row == 2) + styleClass = 'calendar-day-top %s'.format(styleClass); + + let leftMost = rtl + ? iter.getDay() == (this._weekStart + 6) % 7 + : iter.getDay() == this._weekStart; + if (leftMost) + styleClass = 'calendar-day-left %s'.format(styleClass); + + if (sameDay(now, iter)) + styleClass += ' calendar-today'; + else if (iter.getMonth() != this._selectedDate.getMonth()) + styleClass += ' calendar-other-month-day'; + + if (hasEvents) + styleClass += ' calendar-day-with-events'; + + button.style_class = styleClass; + + let offsetCols = this._useWeekdate ? 1 : 0; + let col; + if (rtl) + col = 6 - (7 + iter.getDay() - this._weekStart) % 7; + else + col = offsetCols + (7 + iter.getDay() - this._weekStart) % 7; + layout.attach(button, col, row, 1, 1); + + this._buttons.push(button); + + if (this._useWeekdate && iter.getDay() == 4) { + let label = new St.Label({ text: iter.toLocaleFormat('%V'), + style_class: 'calendar-day-base calendar-week-number', + can_focus: true }); + let weekFormat = Shell.util_translate_time_string(N_("Week %V")); + label.clutter_text.y_align = Clutter.ActorAlign.CENTER; + label.accessible_name = iter.toLocaleFormat(weekFormat); + layout.attach(label, rtl ? 7 : 0, row, 1, 1); + } + + iter.setTime(iter.getTime() + MSECS_IN_DAY); + + if (iter.getDay() == this._weekStart) + row++; + } + + // Signal to the event source that we are interested in events + // only from this date range + this._eventSource.requestRange(beginDate, iter); + } + + _update() { + let now = new Date(); + + if (sameYear(this._selectedDate, now)) + this._monthLabel.text = this._selectedDate.toLocaleFormat(this._headerFormatWithoutYear); + else + this._monthLabel.text = this._selectedDate.toLocaleFormat(this._headerFormat); + + if (!this._calendarBegin || !sameMonth(this._selectedDate, this._calendarBegin) || !sameDay(now, this._markedAsToday)) + this._rebuildCalendar(); + + this._buttons.forEach(button => { + if (sameDay(button._date, this._selectedDate)) { + button.add_style_pseudo_class('selected'); + if (this._shouldDateGrabFocus) + button.grab_key_focus(); + } else { + button.remove_style_pseudo_class('selected'); + } + }); + } +}); + +var NotificationMessage = GObject.registerClass( +class NotificationMessage extends MessageList.Message { + _init(notification) { + super._init(notification.title, notification.bannerBodyText); + this.setUseBodyMarkup(notification.bannerBodyMarkup); + + this.notification = notification; + + this.setIcon(this._getIcon()); + + this.connect('close', () => { + this._closed = true; + if (this.notification) + this.notification.destroy(MessageTray.NotificationDestroyedReason.DISMISSED); + }); + this._destroyId = notification.connect('destroy', () => { + this._disconnectNotificationSignals(); + this.notification = null; + if (!this._closed) + this.close(); + }); + this._updatedId = notification.connect('updated', + this._onUpdated.bind(this)); + } + + _getIcon() { + if (this.notification.gicon) { + return new St.Icon({ gicon: this.notification.gicon, + icon_size: MESSAGE_ICON_SIZE }); + } else { + return this.notification.source.createIcon(MESSAGE_ICON_SIZE); + } + } + + _onUpdated(n, _clear) { + this.setIcon(this._getIcon()); + this.setTitle(n.title); + this.setBody(n.bannerBodyText); + this.setUseBodyMarkup(n.bannerBodyMarkup); + } + + vfunc_clicked() { + this.notification.activate(); + } + + _onDestroy() { + super._onDestroy(); + this._disconnectNotificationSignals(); + } + + _disconnectNotificationSignals() { + if (this._updatedId) + this.notification.disconnect(this._updatedId); + this._updatedId = 0; + + if (this._destroyId) + this.notification.disconnect(this._destroyId); + this._destroyId = 0; + } + + canClose() { + return true; + } +}); + +var TimeLabel = GObject.registerClass( +class NotificationTimeLabel extends St.Label { + _init(datetime) { + super._init({ + style_class: 'event-time', + x_align: Clutter.ActorAlign.START, + y_align: Clutter.ActorAlign.END, + }); + this._datetime = datetime; + } + + vfunc_map() { + this.text = Util.formatTimeSpan(this._datetime); + super.vfunc_map(); + } +}); + +var NotificationSection = GObject.registerClass( +class NotificationSection extends MessageList.MessageListSection { + _init() { + super._init(); + + this._sources = new Map(); + this._nUrgent = 0; + + Main.messageTray.connect('source-added', this._sourceAdded.bind(this)); + Main.messageTray.getSources().forEach(source => { + this._sourceAdded(Main.messageTray, source); + }); + } + + get allowed() { + return Main.sessionMode.hasNotifications && + !Main.sessionMode.isGreeter; + } + + _sourceAdded(tray, source) { + let obj = { + destroyId: 0, + notificationAddedId: 0, + }; + + obj.destroyId = source.connect('destroy', () => { + this._onSourceDestroy(source, obj); + }); + obj.notificationAddedId = source.connect('notification-added', + this._onNotificationAdded.bind(this)); + + this._sources.set(source, obj); + } + + _onNotificationAdded(source, notification) { + let message = new NotificationMessage(notification); + message.setSecondaryActor(new TimeLabel(notification.datetime)); + + let isUrgent = notification.urgency == MessageTray.Urgency.CRITICAL; + + let updatedId = notification.connect('updated', () => { + message.setSecondaryActor(new TimeLabel(notification.datetime)); + this.moveMessage(message, isUrgent ? 0 : this._nUrgent, this.mapped); + }); + let destroyId = notification.connect('destroy', () => { + notification.disconnect(destroyId); + notification.disconnect(updatedId); + if (isUrgent) + this._nUrgent--; + }); + + if (isUrgent) { + // Keep track of urgent notifications to keep them on top + this._nUrgent++; + } else if (this.mapped) { + // Only acknowledge non-urgent notifications in case it + // has important actions that are inaccessible when not + // shown as banner + notification.acknowledged = true; + } + + let index = isUrgent ? 0 : this._nUrgent; + this.addMessageAtIndex(message, index, this.mapped); + } + + _onSourceDestroy(source, obj) { + source.disconnect(obj.destroyId); + source.disconnect(obj.notificationAddedId); + + this._sources.delete(source); + } + + vfunc_map() { + this._messages.forEach(message => { + if (message.notification.urgency != MessageTray.Urgency.CRITICAL) + message.notification.acknowledged = true; + }); + super.vfunc_map(); + } +}); + +var Placeholder = GObject.registerClass( +class Placeholder extends St.BoxLayout { + _init() { + super._init({ style_class: 'message-list-placeholder', vertical: true }); + this._date = new Date(); + + const file = Gio.File.new_for_uri( + 'resource:///org/gnome/shell/theme/no-notifications.svg'); + this._icon = new St.Icon({ gicon: new Gio.FileIcon({ file }) }); + this.add_actor(this._icon); + + this._label = new St.Label({ text: _('No Notifications') }); + this.add_actor(this._label); + } +}); + +const DoNotDisturbSwitch = GObject.registerClass( +class DoNotDisturbSwitch extends PopupMenu.Switch { + _init() { + this._settings = new Gio.Settings({ + schema_id: 'org.gnome.desktop.notifications', + }); + + super._init(this._settings.get_boolean('show-banners')); + + this._settings.bind('show-banners', + this, 'state', + Gio.SettingsBindFlags.INVERT_BOOLEAN); + + this.connect('destroy', () => { + this._settings.run_dispose(); + this._settings = null; + }); + } +}); + +var CalendarMessageList = GObject.registerClass( +class CalendarMessageList extends St.Widget { + _init() { + super._init({ + style_class: 'message-list', + layout_manager: new Clutter.BinLayout(), + x_expand: true, + y_expand: true, + }); + + this._placeholder = new Placeholder(); + this.add_actor(this._placeholder); + + let box = new St.BoxLayout({ vertical: true, + x_expand: true, y_expand: true }); + this.add_actor(box); + + this._scrollView = new St.ScrollView({ + style_class: 'vfade', + overlay_scrollbars: true, + x_expand: true, y_expand: true, + }); + this._scrollView.set_policy(St.PolicyType.NEVER, St.PolicyType.AUTOMATIC); + box.add_actor(this._scrollView); + + let hbox = new St.BoxLayout({ style_class: 'message-list-controls' }); + box.add_child(hbox); + + const dndLabel = new St.Label({ + text: _('Do Not Disturb'), + y_align: Clutter.ActorAlign.CENTER, + }); + hbox.add_child(dndLabel); + + this._dndSwitch = new DoNotDisturbSwitch(); + this._dndButton = new St.Button({ + can_focus: true, + toggle_mode: true, + child: this._dndSwitch, + label_actor: dndLabel, + }); + this._dndSwitch.bind_property('state', + this._dndButton, 'checked', + GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE); + hbox.add_child(this._dndButton); + + this._clearButton = new St.Button({ + style_class: 'message-list-clear-button button', + label: _('Clear'), + can_focus: true, + x_expand: true, + x_align: Clutter.ActorAlign.END, + }); + this._clearButton.connect('clicked', () => { + this._sectionList.get_children().forEach(s => s.clear()); + }); + hbox.add_actor(this._clearButton); + + this._placeholder.bind_property('visible', + this._clearButton, 'visible', + GObject.BindingFlags.INVERT_BOOLEAN); + + this._sectionList = new St.BoxLayout({ style_class: 'message-list-sections', + vertical: true, + x_expand: true, + y_expand: true, + y_align: Clutter.ActorAlign.START }); + this._sectionList.connect('actor-added', this._sync.bind(this)); + this._sectionList.connect('actor-removed', this._sync.bind(this)); + this._scrollView.add_actor(this._sectionList); + + this._mediaSection = new Mpris.MediaSection(); + this._addSection(this._mediaSection); + + this._notificationSection = new NotificationSection(); + this._addSection(this._notificationSection); + + Main.sessionMode.connect('updated', this._sync.bind(this)); + } + + _addSection(section) { + let connectionsIds = []; + + for (let prop of ['visible', 'empty', 'can-clear']) { + connectionsIds.push( + section.connect('notify::%s'.format(prop), this._sync.bind(this))); + } + connectionsIds.push(section.connect('message-focused', (_s, messageActor) => { + Util.ensureActorVisibleInScrollView(this._scrollView, messageActor); + })); + + connectionsIds.push(section.connect('destroy', () => { + connectionsIds.forEach(id => section.disconnect(id)); + this._sectionList.remove_actor(section); + })); + + this._sectionList.add_actor(section); + } + + _sync() { + let sections = this._sectionList.get_children(); + let visible = sections.some(s => s.allowed); + this.visible = visible; + if (!visible) + return; + + let empty = sections.every(s => s.empty || !s.visible); + this._placeholder.visible = empty; + + let canClear = sections.some(s => s.canClear && s.visible); + this._clearButton.reactive = canClear; + } +}); diff --git a/js/ui/checkBox.js b/js/ui/checkBox.js new file mode 100644 index 0000000..d64bd0d --- /dev/null +++ b/js/ui/checkBox.js @@ -0,0 +1,40 @@ +/* exported CheckBox */ +const { Atk, Clutter, GObject, Pango, St } = imports.gi; + +var CheckBox = GObject.registerClass( +class CheckBox extends St.Button { + _init(label) { + let container = new St.BoxLayout({ + x_expand: true, + y_expand: true, + }); + super._init({ + style_class: 'check-box', + child: container, + button_mask: St.ButtonMask.ONE, + toggle_mode: true, + can_focus: true, + }); + this.set_accessible_role(Atk.Role.CHECK_BOX); + + this._box = new St.Bin({ y_align: Clutter.ActorAlign.START }); + container.add_actor(this._box); + + this._label = new St.Label({ y_align: Clutter.ActorAlign.CENTER }); + this._label.clutter_text.set_line_wrap(true); + this._label.clutter_text.set_ellipsize(Pango.EllipsizeMode.NONE); + this.set_label_actor(this._label); + container.add_actor(this._label); + + if (label) + this.setLabel(label); + } + + setLabel(label) { + this._label.set_text(label); + } + + getLabelActor() { + return this._label; + } +}); diff --git a/js/ui/closeDialog.js b/js/ui/closeDialog.js new file mode 100644 index 0000000..63a0bcf --- /dev/null +++ b/js/ui/closeDialog.js @@ -0,0 +1,210 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported CloseDialog */ + +const { Clutter, GLib, GObject, Meta, Shell, St } = imports.gi; + +const Dialog = imports.ui.dialog; +const Main = imports.ui.main; + +var FROZEN_WINDOW_BRIGHTNESS = -0.3; +var DIALOG_TRANSITION_TIME = 150; +var ALIVE_TIMEOUT = 5000; + +var CloseDialog = GObject.registerClass({ + Implements: [Meta.CloseDialog], + Properties: { + 'window': GObject.ParamSpec.override('window', Meta.CloseDialog), + }, +}, class CloseDialog extends GObject.Object { + _init(window) { + super._init(); + this._window = window; + this._dialog = null; + this._tracked = undefined; + this._timeoutId = 0; + this._windowFocusChangedId = 0; + this._keyFocusChangedId = 0; + } + + get window() { + return this._window; + } + + set window(window) { + this._window = window; + } + + _createDialogContent() { + let tracker = Shell.WindowTracker.get_default(); + let windowApp = tracker.get_window_app(this._window); + + /* Translators: %s is an application name */ + let title = _("“%s” is not responding.").format(windowApp.get_name()); + let description = _('You may choose to wait a short while for it to ' + + 'continue or force the application to quit entirely.'); + return new Dialog.MessageDialogContent({ title, description }); + } + + _updateScale() { + // Since this is a child of MetaWindowActor (which, for Wayland clients, + // applies the geometry scale factor to its children itself, see + // meta_window_actor_set_geometry_scale()), make sure we don't apply + // the factor twice in the end. + if (this._window.get_client_type() !== Meta.WindowClientType.WAYLAND) + return; + + let { scaleFactor } = St.ThemeContext.get_for_stage(global.stage); + this._dialog.set_scale(1 / scaleFactor, 1 / scaleFactor); + } + + _initDialog() { + if (this._dialog) + return; + + let windowActor = this._window.get_compositor_private(); + this._dialog = new Dialog.Dialog(windowActor, 'close-dialog'); + this._dialog.width = windowActor.width; + this._dialog.height = windowActor.height; + + this._dialog.contentLayout.add_child(this._createDialogContent()); + this._dialog.addButton({ label: _('Force Quit'), + action: this._onClose.bind(this), + default: true }); + this._dialog.addButton({ label: _('Wait'), + action: this._onWait.bind(this), + key: Clutter.KEY_Escape }); + + global.focus_manager.add_group(this._dialog); + + let themeContext = St.ThemeContext.get_for_stage(global.stage); + themeContext.connect('notify::scale-factor', this._updateScale.bind(this)); + + this._updateScale(); + } + + _addWindowEffect() { + // We set the effect on the surface actor, so the dialog itself + // (which is a child of the MetaWindowActor) does not get the + // effect applied itself. + let windowActor = this._window.get_compositor_private(); + let surfaceActor = windowActor.get_first_child(); + let effect = new Clutter.BrightnessContrastEffect(); + effect.set_brightness(FROZEN_WINDOW_BRIGHTNESS); + surfaceActor.add_effect_with_name("gnome-shell-frozen-window", effect); + } + + _removeWindowEffect() { + let windowActor = this._window.get_compositor_private(); + let surfaceActor = windowActor.get_first_child(); + surfaceActor.remove_effect_by_name("gnome-shell-frozen-window"); + } + + _onWait() { + this.response(Meta.CloseDialogResponse.WAIT); + } + + _onClose() { + this.response(Meta.CloseDialogResponse.FORCE_CLOSE); + } + + _onFocusChanged() { + if (Meta.is_wayland_compositor()) + return; + + let focusWindow = global.display.focus_window; + let keyFocus = global.stage.key_focus; + + let shouldTrack; + if (focusWindow != null) + shouldTrack = focusWindow == this._window; + else + shouldTrack = keyFocus && this._dialog.contains(keyFocus); + + if (this._tracked === shouldTrack) + return; + + if (shouldTrack) { + Main.layoutManager.trackChrome(this._dialog, + { affectsInputRegion: true }); + } else { + Main.layoutManager.untrackChrome(this._dialog); + } + + // The buttons are broken when they aren't added to the input region, + // so disable them properly in that case + this._dialog.buttonLayout.get_children().forEach(b => { + b.reactive = shouldTrack; + }); + + this._tracked = shouldTrack; + } + + vfunc_show() { + if (this._dialog != null) + return; + + Meta.disable_unredirect_for_display(global.display); + + this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, ALIVE_TIMEOUT, + () => { + this._window.check_alive(global.display.get_current_time_roundtrip()); + return GLib.SOURCE_CONTINUE; + }); + + this._windowFocusChangedId = + global.display.connect('notify::focus-window', + this._onFocusChanged.bind(this)); + + this._keyFocusChangedId = + global.stage.connect('notify::key-focus', + this._onFocusChanged.bind(this)); + + this._addWindowEffect(); + this._initDialog(); + + this._dialog._dialog.scale_y = 0; + this._dialog._dialog.set_pivot_point(0.5, 0.5); + + this._dialog._dialog.ease({ + scale_y: 1, + mode: Clutter.AnimationMode.LINEAR, + duration: DIALOG_TRANSITION_TIME, + onComplete: this._onFocusChanged.bind(this), + }); + } + + vfunc_hide() { + if (this._dialog == null) + return; + + Meta.enable_unredirect_for_display(global.display); + + GLib.source_remove(this._timeoutId); + this._timeoutId = 0; + + global.display.disconnect(this._windowFocusChangedId); + this._windowFocusChangedId = 0; + + global.stage.disconnect(this._keyFocusChangedId); + this._keyFocusChangedId = 0; + + this._dialog._dialog.remove_all_transitions(); + + let dialog = this._dialog; + this._dialog = null; + this._removeWindowEffect(); + + dialog.makeInactive(); + dialog._dialog.ease({ + scale_y: 0, + mode: Clutter.AnimationMode.LINEAR, + duration: DIALOG_TRANSITION_TIME, + onComplete: () => dialog.destroy(), + }); + } + + vfunc_focus() { + if (this._dialog) + this._dialog.grab_key_focus(); + } +}); diff --git a/js/ui/components/__init__.js b/js/ui/components/__init__.js new file mode 100644 index 0000000..7430013 --- /dev/null +++ b/js/ui/components/__init__.js @@ -0,0 +1,58 @@ +/* exported ComponentManager */ +const Main = imports.ui.main; + +var ComponentManager = class { + constructor() { + this._allComponents = {}; + this._enabledComponents = []; + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + this._sessionUpdated(); + } + + _sessionUpdated() { + let newEnabledComponents = Main.sessionMode.components; + + newEnabledComponents + .filter(name => !this._enabledComponents.includes(name)) + .forEach(name => this._enableComponent(name)); + + this._enabledComponents + .filter(name => !newEnabledComponents.includes(name)) + .forEach(name => this._disableComponent(name)); + + this._enabledComponents = newEnabledComponents; + } + + _importComponent(name) { + let module = imports.ui.components[name]; + return module.Component; + } + + _ensureComponent(name) { + let component = this._allComponents[name]; + if (component) + return component; + + if (Main.sessionMode.isLocked) + return null; + + let constructor = this._importComponent(name); + component = new constructor(); + this._allComponents[name] = component; + return component; + } + + _enableComponent(name) { + let component = this._ensureComponent(name); + if (component) + component.enable(); + } + + _disableComponent(name) { + let component = this._allComponents[name]; + if (component == null) + return; + component.disable(); + } +}; diff --git a/js/ui/components/automountManager.js b/js/ui/components/automountManager.js new file mode 100644 index 0000000..1cb84a7 --- /dev/null +++ b/js/ui/components/automountManager.js @@ -0,0 +1,259 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Component */ + +const { Gio, GLib } = imports.gi; +const Params = imports.misc.params; + +const GnomeSession = imports.misc.gnomeSession; +const Main = imports.ui.main; +const ShellMountOperation = imports.ui.shellMountOperation; + +var GNOME_SESSION_AUTOMOUNT_INHIBIT = 16; + +// GSettings keys +const SETTINGS_SCHEMA = 'org.gnome.desktop.media-handling'; +const SETTING_ENABLE_AUTOMOUNT = 'automount'; + +var AUTORUN_EXPIRE_TIMEOUT_SECS = 10; + +var AutomountManager = class { + constructor() { + this._settings = new Gio.Settings({ schema_id: SETTINGS_SCHEMA }); + this._volumeQueue = []; + this._activeOperations = new Map(); + this._session = new GnomeSession.SessionManager(); + this._session.connectSignal('InhibitorAdded', + this._InhibitorsChanged.bind(this)); + this._session.connectSignal('InhibitorRemoved', + this._InhibitorsChanged.bind(this)); + this._inhibited = false; + + this._volumeMonitor = Gio.VolumeMonitor.get(); + } + + enable() { + this._volumeAddedId = this._volumeMonitor.connect('volume-added', this._onVolumeAdded.bind(this)); + this._volumeRemovedId = this._volumeMonitor.connect('volume-removed', this._onVolumeRemoved.bind(this)); + this._driveConnectedId = this._volumeMonitor.connect('drive-connected', this._onDriveConnected.bind(this)); + this._driveDisconnectedId = this._volumeMonitor.connect('drive-disconnected', this._onDriveDisconnected.bind(this)); + this._driveEjectButtonId = this._volumeMonitor.connect('drive-eject-button', this._onDriveEjectButton.bind(this)); + + this._mountAllId = GLib.idle_add(GLib.PRIORITY_DEFAULT, this._startupMountAll.bind(this)); + GLib.Source.set_name_by_id(this._mountAllId, '[gnome-shell] this._startupMountAll'); + } + + disable() { + this._volumeMonitor.disconnect(this._volumeAddedId); + this._volumeMonitor.disconnect(this._volumeRemovedId); + this._volumeMonitor.disconnect(this._driveConnectedId); + this._volumeMonitor.disconnect(this._driveDisconnectedId); + this._volumeMonitor.disconnect(this._driveEjectButtonId); + + if (this._mountAllId > 0) { + GLib.source_remove(this._mountAllId); + this._mountAllId = 0; + } + } + + _InhibitorsChanged(_object, _senderName, [_inhibitor]) { + this._session.IsInhibitedRemote(GNOME_SESSION_AUTOMOUNT_INHIBIT, + (result, error) => { + if (!error) + this._inhibited = result[0]; + }); + } + + _startupMountAll() { + let volumes = this._volumeMonitor.get_volumes(); + volumes.forEach(volume => { + this._checkAndMountVolume(volume, { checkSession: false, + useMountOp: false, + allowAutorun: false }); + }); + + this._mountAllId = 0; + return GLib.SOURCE_REMOVE; + } + + _onDriveConnected() { + // if we're not in the current ConsoleKit session, + // or screensaver is active, don't play sounds + if (!this._session.SessionIsActive) + return; + + let player = global.display.get_sound_player(); + player.play_from_theme('device-added-media', + _("External drive connected"), + null); + } + + _onDriveDisconnected() { + // if we're not in the current ConsoleKit session, + // or screensaver is active, don't play sounds + if (!this._session.SessionIsActive) + return; + + let player = global.display.get_sound_player(); + player.play_from_theme('device-removed-media', + _("External drive disconnected"), + null); + } + + _onDriveEjectButton(monitor, drive) { + // TODO: this code path is not tested, as the GVfs volume monitor + // doesn't emit this signal just yet. + if (!this._session.SessionIsActive) + return; + + // we force stop/eject in this case, so we don't have to pass a + // mount operation object + if (drive.can_stop()) { + drive.stop(Gio.MountUnmountFlags.FORCE, null, null, + (o, res) => { + try { + drive.stop_finish(res); + } catch (e) { + log('Unable to stop the drive after drive-eject-button %s'.format(e.toString())); + } + }); + } else if (drive.can_eject()) { + drive.eject_with_operation(Gio.MountUnmountFlags.FORCE, null, null, + (o, res) => { + try { + drive.eject_with_operation_finish(res); + } catch (e) { + log('Unable to eject the drive after drive-eject-button %s'.format(e.toString())); + } + }); + } + } + + _onVolumeAdded(monitor, volume) { + this._checkAndMountVolume(volume); + } + + _checkAndMountVolume(volume, params) { + params = Params.parse(params, { checkSession: true, + useMountOp: true, + allowAutorun: true }); + + if (params.checkSession) { + // if we're not in the current ConsoleKit session, + // don't attempt automount + if (!this._session.SessionIsActive) + return; + } + + if (this._inhibited) + return; + + // Volume is already mounted, don't bother. + if (volume.get_mount()) + return; + + if (!this._settings.get_boolean(SETTING_ENABLE_AUTOMOUNT) || + !volume.should_automount() || + !volume.can_mount()) { + // allow the autorun to run anyway; this can happen if the + // mount gets added programmatically later, even if + // should_automount() or can_mount() are false, like for + // blank optical media. + this._allowAutorun(volume); + this._allowAutorunExpire(volume); + + return; + } + + if (params.useMountOp) { + let operation = new ShellMountOperation.ShellMountOperation(volume); + this._mountVolume(volume, operation, params.allowAutorun); + } else { + this._mountVolume(volume, null, params.allowAutorun); + } + } + + _mountVolume(volume, operation, allowAutorun) { + if (allowAutorun) + this._allowAutorun(volume); + + let mountOp = operation ? operation.mountOp : null; + this._activeOperations.set(volume, operation); + + volume.mount(0, mountOp, null, + this._onVolumeMounted.bind(this)); + } + + _onVolumeMounted(volume, res) { + this._allowAutorunExpire(volume); + + try { + volume.mount_finish(res); + this._closeOperation(volume); + } catch (e) { + // FIXME: we will always get G_IO_ERROR_FAILED from the gvfs udisks + // backend, see https://bugs.freedesktop.org/show_bug.cgi?id=51271 + // To reask the password if the user input was empty or wrong, we + // will check for corresponding error messages. However, these + // error strings are not unique for the cases in the comments below. + if (e.message.includes('No key available with this passphrase') || // cryptsetup + e.message.includes('No key available to unlock device') || // udisks (no password) + // libblockdev wrong password opening LUKS device + e.message.includes('Failed to activate device: Incorrect passphrase') || + // cryptsetup returns EINVAL in many cases, including wrong TCRYPT password/parameters + e.message.includes('Failed to load device\'s parameters: Invalid argument')) { + + this._reaskPassword(volume); + } else { + if (e.message.includes('Compiled against a version of libcryptsetup that does not support the VeraCrypt PIM setting')) { + Main.notifyError(_("Unable to unlock volume"), + _("The installed udisks version does not support the PIM setting")); + } + + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED_HANDLED)) + log('Unable to mount volume %s: %s'.format(volume.get_name(), e.toString())); + this._closeOperation(volume); + } + } + } + + _onVolumeRemoved(monitor, volume) { + if (volume._allowAutorunExpireId && volume._allowAutorunExpireId > 0) { + GLib.source_remove(volume._allowAutorunExpireId); + delete volume._allowAutorunExpireId; + } + this._volumeQueue = + this._volumeQueue.filter(element => element != volume); + } + + _reaskPassword(volume) { + let prevOperation = this._activeOperations.get(volume); + let existingDialog = prevOperation ? prevOperation.borrowDialog() : null; + let operation = + new ShellMountOperation.ShellMountOperation(volume, + { existingDialog }); + this._mountVolume(volume, operation); + } + + _closeOperation(volume) { + let operation = this._activeOperations.get(volume); + if (!operation) + return; + operation.close(); + this._activeOperations.delete(volume); + } + + _allowAutorun(volume) { + volume.allowAutorun = true; + } + + _allowAutorunExpire(volume) { + let id = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, AUTORUN_EXPIRE_TIMEOUT_SECS, () => { + volume.allowAutorun = false; + delete volume._allowAutorunExpireId; + return GLib.SOURCE_REMOVE; + }); + volume._allowAutorunExpireId = id; + GLib.Source.set_name_by_id(id, '[gnome-shell] volume.allowAutorun'); + } +}; +var Component = AutomountManager; diff --git a/js/ui/components/autorunManager.js b/js/ui/components/autorunManager.js new file mode 100644 index 0000000..cc7b412 --- /dev/null +++ b/js/ui/components/autorunManager.js @@ -0,0 +1,359 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Component */ + +const { Clutter, Gio, GObject, St } = imports.gi; + +const GnomeSession = imports.misc.gnomeSession; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +// GSettings keys +const SETTINGS_SCHEMA = 'org.gnome.desktop.media-handling'; +const SETTING_DISABLE_AUTORUN = 'autorun-never'; +const SETTING_START_APP = 'autorun-x-content-start-app'; +const SETTING_IGNORE = 'autorun-x-content-ignore'; +const SETTING_OPEN_FOLDER = 'autorun-x-content-open-folder'; + +var AutorunSetting = { + RUN: 0, + IGNORE: 1, + FILES: 2, + ASK: 3, +}; + +// misc utils +function shouldAutorunMount(mount) { + let root = mount.get_root(); + let volume = mount.get_volume(); + + if (!volume || !volume.allowAutorun) + return false; + + if (root.is_native() && isMountRootHidden(root)) + return false; + + return true; +} + +function isMountRootHidden(root) { + let path = root.get_path(); + + // skip any mounts in hidden directory hierarchies + return path.includes('/.'); +} + +function isMountNonLocal(mount) { + // If the mount doesn't have an associated volume, that means it's + // an uninteresting filesystem. Most devices that we care about will + // have a mount, like media players and USB sticks. + let volume = mount.get_volume(); + if (volume == null) + return true; + + return volume.get_identifier("class") == "network"; +} + +function startAppForMount(app, mount) { + let files = []; + let root = mount.get_root(); + let retval = false; + + files.push(root); + + try { + retval = app.launch(files, + global.create_app_launch_context(0, -1)); + } catch (e) { + log('Unable to launch the application %s: %s'.format(app.get_name(), e.toString())); + } + + return retval; +} + +const HotplugSnifferIface = loadInterfaceXML('org.gnome.Shell.HotplugSniffer'); +const HotplugSnifferProxy = Gio.DBusProxy.makeProxyWrapper(HotplugSnifferIface); +function HotplugSniffer() { + return new HotplugSnifferProxy(Gio.DBus.session, + 'org.gnome.Shell.HotplugSniffer', + '/org/gnome/Shell/HotplugSniffer'); +} + +var ContentTypeDiscoverer = class { + constructor(callback) { + this._callback = callback; + this._settings = new Gio.Settings({ schema_id: SETTINGS_SCHEMA }); + } + + guessContentTypes(mount) { + let autorunEnabled = !this._settings.get_boolean(SETTING_DISABLE_AUTORUN); + let shouldScan = autorunEnabled && !isMountNonLocal(mount); + + if (shouldScan) { + // guess mount's content types using GIO + mount.guess_content_type(false, null, + this._onContentTypeGuessed.bind(this)); + } else { + this._emitCallback(mount, []); + } + } + + _onContentTypeGuessed(mount, res) { + let contentTypes = []; + + try { + contentTypes = mount.guess_content_type_finish(res); + } catch (e) { + log('Unable to guess content types on added mount %s: %s'.format(mount.get_name(), e.toString())); + } + + if (contentTypes.length) { + this._emitCallback(mount, contentTypes); + } else { + let root = mount.get_root(); + + let hotplugSniffer = new HotplugSniffer(); + hotplugSniffer.SniffURIRemote(root.get_uri(), + result => { + [contentTypes] = result; + this._emitCallback(mount, contentTypes); + }); + } + } + + _emitCallback(mount, contentTypes = []) { + // we're not interested in win32 software content types here + contentTypes = contentTypes.filter( + type => type !== 'x-content/win32-software'); + + let apps = []; + contentTypes.forEach(type => { + let app = Gio.app_info_get_default_for_type(type, false); + + if (app) + apps.push(app); + }); + + if (apps.length == 0) + apps.push(Gio.app_info_get_default_for_type('inode/directory', false)); + + this._callback(mount, apps, contentTypes); + } +}; + +var AutorunManager = class { + constructor() { + this._session = new GnomeSession.SessionManager(); + this._volumeMonitor = Gio.VolumeMonitor.get(); + + this._dispatcher = new AutorunDispatcher(this); + } + + enable() { + this._mountAddedId = this._volumeMonitor.connect('mount-added', this._onMountAdded.bind(this)); + this._mountRemovedId = this._volumeMonitor.connect('mount-removed', this._onMountRemoved.bind(this)); + } + + disable() { + this._volumeMonitor.disconnect(this._mountAddedId); + this._volumeMonitor.disconnect(this._mountRemovedId); + } + + _onMountAdded(monitor, mount) { + // don't do anything if our session is not the currently + // active one + if (!this._session.SessionIsActive) + return; + + let discoverer = new ContentTypeDiscoverer((m, apps, contentTypes) => { + this._dispatcher.addMount(mount, apps, contentTypes); + }); + discoverer.guessContentTypes(mount); + } + + _onMountRemoved(monitor, mount) { + this._dispatcher.removeMount(mount); + } +}; + +var AutorunDispatcher = class { + constructor(manager) { + this._manager = manager; + this._sources = []; + this._settings = new Gio.Settings({ schema_id: SETTINGS_SCHEMA }); + } + + _getAutorunSettingForType(contentType) { + let runApp = this._settings.get_strv(SETTING_START_APP); + if (runApp.includes(contentType)) + return AutorunSetting.RUN; + + let ignore = this._settings.get_strv(SETTING_IGNORE); + if (ignore.includes(contentType)) + return AutorunSetting.IGNORE; + + let openFiles = this._settings.get_strv(SETTING_OPEN_FOLDER); + if (openFiles.includes(contentType)) + return AutorunSetting.FILES; + + return AutorunSetting.ASK; + } + + _getSourceForMount(mount) { + let filtered = this._sources.filter(source => source.mount == mount); + + // we always make sure not to add two sources for the same + // mount in addMount(), so it's safe to assume filtered.length + // is always either 1 or 0. + if (filtered.length == 1) + return filtered[0]; + + return null; + } + + _addSource(mount, apps) { + // if we already have a source showing for this + // mount, return + if (this._getSourceForMount(mount)) + return; + + // add a new source + this._sources.push(new AutorunSource(this._manager, mount, apps)); + } + + addMount(mount, apps, contentTypes) { + // if autorun is disabled globally, return + if (this._settings.get_boolean(SETTING_DISABLE_AUTORUN)) + return; + + // if the mount doesn't want to be autorun, return + if (!shouldAutorunMount(mount)) + return; + + let setting; + if (contentTypes.length > 0) + setting = this._getAutorunSettingForType(contentTypes[0]); + else + setting = AutorunSetting.ASK; + + // check at the settings for the first content type + // to see whether we should ask + if (setting == AutorunSetting.IGNORE) + return; // return right away + + let success = false; + let app = null; + + if (setting == AutorunSetting.RUN) + app = Gio.app_info_get_default_for_type(contentTypes[0], false); + else if (setting == AutorunSetting.FILES) + app = Gio.app_info_get_default_for_type('inode/directory', false); + + if (app) + success = startAppForMount(app, mount); + + // we fallback here also in case the settings did not specify 'ask', + // but we failed launching the default app or the default file manager + if (!success) + this._addSource(mount, apps); + } + + removeMount(mount) { + let source = this._getSourceForMount(mount); + + // if we aren't tracking this mount, don't do anything + if (!source) + return; + + // destroy the notification source + source.destroy(); + } +}; + +var AutorunSource = GObject.registerClass( +class AutorunSource extends MessageTray.Source { + _init(manager, mount, apps) { + super._init(mount.get_name()); + + this._manager = manager; + this.mount = mount; + this.apps = apps; + + this._notification = new AutorunNotification(this._manager, this); + + // add ourselves as a source, and popup the notification + Main.messageTray.add(this); + this.showNotification(this._notification); + } + + getIcon() { + return this.mount.get_icon(); + } + + _createPolicy() { + return new MessageTray.NotificationApplicationPolicy('org.gnome.Nautilus'); + } +}); + +var AutorunNotification = GObject.registerClass( +class AutorunNotification extends MessageTray.Notification { + _init(manager, source) { + super._init(source, source.title); + + this._manager = manager; + this._mount = source.mount; + } + + createBanner() { + let banner = new MessageTray.NotificationBanner(this); + + this.source.apps.forEach(app => { + let actor = this._buttonForApp(app); + + if (actor) + banner.addButton(actor); + }); + + return banner; + } + + _buttonForApp(app) { + let box = new St.BoxLayout({ + x_expand: true, + x_align: Clutter.ActorAlign.START, + }); + let icon = new St.Icon({ gicon: app.get_icon(), + style_class: 'hotplug-notification-item-icon' }); + box.add(icon); + + let label = new St.Bin({ + child: new St.Label({ + text: _("Open with %s").format(app.get_name()), + y_align: Clutter.ActorAlign.CENTER, + }), + }); + box.add(label); + + let button = new St.Button({ child: box, + x_expand: true, + button_mask: St.ButtonMask.ONE, + style_class: 'hotplug-notification-item button' }); + + button.connect('clicked', () => { + startAppForMount(app, this._mount); + this.destroy(); + }); + + return button; + } + + activate() { + super.activate(); + + let app = Gio.app_info_get_default_for_type('inode/directory', false); + startAppForMount(app, this._mount); + } +}); + +var Component = AutorunManager; diff --git a/js/ui/components/keyring.js b/js/ui/components/keyring.js new file mode 100644 index 0000000..8623f2f --- /dev/null +++ b/js/ui/components/keyring.js @@ -0,0 +1,229 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Component */ + +const { Clutter, Gcr, Gio, GObject, Pango, Shell, St } = imports.gi; + +const Dialog = imports.ui.dialog; +const ModalDialog = imports.ui.modalDialog; +const ShellEntry = imports.ui.shellEntry; +const CheckBox = imports.ui.checkBox; +const Util = imports.misc.util; + +var KeyringDialog = GObject.registerClass( +class KeyringDialog extends ModalDialog.ModalDialog { + _init() { + super._init({ styleClass: 'prompt-dialog' }); + + this.prompt = new Shell.KeyringPrompt(); + this.prompt.connect('show-password', this._onShowPassword.bind(this)); + this.prompt.connect('show-confirm', this._onShowConfirm.bind(this)); + this.prompt.connect('prompt-close', this._onHidePrompt.bind(this)); + + let content = new Dialog.MessageDialogContent(); + + this.prompt.bind_property('message', + content, 'title', GObject.BindingFlags.SYNC_CREATE); + this.prompt.bind_property('description', + content, 'description', GObject.BindingFlags.SYNC_CREATE); + + let passwordBox = new St.BoxLayout({ + style_class: 'prompt-dialog-password-layout', + vertical: true, + }); + + this._passwordEntry = new St.PasswordEntry({ + style_class: 'prompt-dialog-password-entry', + can_focus: true, + x_align: Clutter.ActorAlign.CENTER, + }); + ShellEntry.addContextMenu(this._passwordEntry); + this._passwordEntry.clutter_text.connect('activate', this._onPasswordActivate.bind(this)); + this.prompt.bind_property('password-visible', + this._passwordEntry, 'visible', GObject.BindingFlags.SYNC_CREATE); + passwordBox.add_child(this._passwordEntry); + + this._confirmEntry = new St.PasswordEntry({ + style_class: 'prompt-dialog-password-entry', + can_focus: true, + x_align: Clutter.ActorAlign.CENTER, + }); + ShellEntry.addContextMenu(this._confirmEntry); + this._confirmEntry.clutter_text.connect('activate', this._onConfirmActivate.bind(this)); + this.prompt.bind_property('confirm-visible', + this._confirmEntry, 'visible', GObject.BindingFlags.SYNC_CREATE); + passwordBox.add_child(this._confirmEntry); + + this.prompt.set_password_actor(this._passwordEntry.clutter_text); + this.prompt.set_confirm_actor(this._confirmEntry.clutter_text); + + let warningBox = new St.BoxLayout({ vertical: true }); + + let capsLockWarning = new ShellEntry.CapsLockWarning(); + let syncCapsLockWarningVisibility = () => { + capsLockWarning.visible = + this.prompt.password_visible || this.prompt.confirm_visible; + }; + this.prompt.connect('notify::password-visible', syncCapsLockWarningVisibility); + this.prompt.connect('notify::confirm-visible', syncCapsLockWarningVisibility); + warningBox.add_child(capsLockWarning); + + let warning = new St.Label({ style_class: 'prompt-dialog-error-label' }); + warning.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + warning.clutter_text.line_wrap = true; + this.prompt.bind_property('warning', + warning, 'text', GObject.BindingFlags.SYNC_CREATE); + this.prompt.connect('notify::warning-visible', () => { + warning.opacity = this.prompt.warning_visible ? 255 : 0; + }); + this.prompt.connect('notify::warning', () => { + if (this._passwordEntry && warning !== '') + Util.wiggle(this._passwordEntry); + }); + warningBox.add_child(warning); + + passwordBox.add_child(warningBox); + content.add_child(passwordBox); + + this._choice = new CheckBox.CheckBox(); + this.prompt.bind_property('choice-label', this._choice.getLabelActor(), + 'text', GObject.BindingFlags.SYNC_CREATE); + this.prompt.bind_property('choice-chosen', this._choice, + 'checked', GObject.BindingFlags.SYNC_CREATE | GObject.BindingFlags.BIDIRECTIONAL); + this.prompt.bind_property('choice-visible', this._choice, + 'visible', GObject.BindingFlags.SYNC_CREATE); + content.add_child(this._choice); + + this.contentLayout.add_child(content); + + this._cancelButton = this.addButton({ + label: '', + action: this._onCancelButton.bind(this), + key: Clutter.KEY_Escape, + }); + this._continueButton = this.addButton({ + label: '', + action: this._onContinueButton.bind(this), + default: true, + }); + + this.prompt.bind_property('cancel-label', this._cancelButton, 'label', GObject.BindingFlags.SYNC_CREATE); + this.prompt.bind_property('continue-label', this._continueButton, 'label', GObject.BindingFlags.SYNC_CREATE); + } + + _updateSensitivity(sensitive) { + if (this._passwordEntry) + this._passwordEntry.reactive = sensitive; + + if (this._confirmEntry) + this._confirmEntry.reactive = sensitive; + + this._continueButton.can_focus = sensitive; + this._continueButton.reactive = sensitive; + } + + _ensureOpen() { + // NOTE: ModalDialog.open() is safe to call if the dialog is + // already open - it just returns true without side-effects + if (this.open()) + return true; + + // The above fail if e.g. unable to get input grab + // + // In an ideal world this wouldn't happen (because the + // Shell is in complete control of the session) but that's + // just not how things work right now. + + log('keyringPrompt: Failed to show modal dialog.' + + ' Dismissing prompt request'); + this.prompt.cancel(); + return false; + } + + _onShowPassword() { + this._ensureOpen(); + this._updateSensitivity(true); + this._passwordEntry.text = ''; + this._passwordEntry.grab_key_focus(); + } + + _onShowConfirm() { + this._ensureOpen(); + this._updateSensitivity(true); + this._confirmEntry.text = ''; + this._continueButton.grab_key_focus(); + } + + _onHidePrompt() { + this.close(); + } + + _onPasswordActivate() { + if (this.prompt.confirm_visible) + this._confirmEntry.grab_key_focus(); + else + this._onContinueButton(); + } + + _onConfirmActivate() { + this._onContinueButton(); + } + + _onContinueButton() { + this._updateSensitivity(false); + this.prompt.complete(); + } + + _onCancelButton() { + this.prompt.cancel(); + } +}); + +var KeyringDummyDialog = class { + constructor() { + this.prompt = new Shell.KeyringPrompt(); + this.prompt.connect('show-password', this._cancelPrompt.bind(this)); + this.prompt.connect('show-confirm', this._cancelPrompt.bind(this)); + } + + _cancelPrompt() { + this.prompt.cancel(); + } +}; + +var KeyringPrompter = GObject.registerClass( +class KeyringPrompter extends Gcr.SystemPrompter { + _init() { + super._init(); + this.connect('new-prompt', () => { + let dialog = this._enabled + ? new KeyringDialog() + : new KeyringDummyDialog(); + this._currentPrompt = dialog.prompt; + return this._currentPrompt; + }); + this._dbusId = null; + this._registered = false; + this._enabled = false; + this._currentPrompt = null; + } + + enable() { + if (!this._registered) { + this.register(Gio.DBus.session); + this._dbusId = Gio.DBus.session.own_name('org.gnome.keyring.SystemPrompter', + Gio.BusNameOwnerFlags.ALLOW_REPLACEMENT, null, null); + this._registered = true; + } + this._enabled = true; + } + + disable() { + this._enabled = false; + + if (this.prompting) + this._currentPrompt.cancel(); + this._currentPrompt = null; + } +}); + +var Component = KeyringPrompter; diff --git a/js/ui/components/networkAgent.js b/js/ui/components/networkAgent.js new file mode 100644 index 0000000..38b55da --- /dev/null +++ b/js/ui/components/networkAgent.js @@ -0,0 +1,809 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Component */ + +const { Clutter, Gio, GLib, GObject, NM, Pango, Shell, St } = imports.gi; +const ByteArray = imports.byteArray; +const Signals = imports.signals; + +const Dialog = imports.ui.dialog; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; +const ModalDialog = imports.ui.modalDialog; +const ShellEntry = imports.ui.shellEntry; + +Gio._promisify(Shell.NetworkAgent.prototype, 'init_async', 'init_finish'); +Gio._promisify(Shell.NetworkAgent.prototype, + 'search_vpn_plugin', 'search_vpn_plugin_finish'); + +const VPN_UI_GROUP = 'VPN Plugin UI'; + +var NetworkSecretDialog = GObject.registerClass( +class NetworkSecretDialog extends ModalDialog.ModalDialog { + _init(agent, requestId, connection, settingName, hints, flags, contentOverride) { + super._init({ styleClass: 'prompt-dialog' }); + + this._agent = agent; + this._requestId = requestId; + this._connection = connection; + this._settingName = settingName; + this._hints = hints; + + if (contentOverride) + this._content = contentOverride; + else + this._content = this._getContent(); + + let contentBox = new Dialog.MessageDialogContent({ + title: this._content.title, + description: this._content.message, + }); + + let initialFocusSet = false; + for (let i = 0; i < this._content.secrets.length; i++) { + let secret = this._content.secrets[i]; + let reactive = secret.key != null; + + let entryParams = { + style_class: 'prompt-dialog-password-entry', + hint_text: secret.label, + text: secret.value, + can_focus: reactive, + reactive, + x_align: Clutter.ActorAlign.CENTER, + }; + if (secret.password) + secret.entry = new St.PasswordEntry(entryParams); + else + secret.entry = new St.Entry(entryParams); + ShellEntry.addContextMenu(secret.entry); + contentBox.add_child(secret.entry); + + if (secret.validate) + secret.valid = secret.validate(secret); + else // no special validation, just ensure it's not empty + secret.valid = secret.value.length > 0; + + if (reactive) { + if (!initialFocusSet) { + this.setInitialKeyFocus(secret.entry); + initialFocusSet = true; + } + + secret.entry.clutter_text.connect('activate', this._onOk.bind(this)); + secret.entry.clutter_text.connect('text-changed', () => { + secret.value = secret.entry.get_text(); + if (secret.validate) + secret.valid = secret.validate(secret); + else + secret.valid = secret.value.length > 0; + this._updateOkButton(); + }); + } else { + secret.valid = true; + } + } + + if (this._content.secrets.some(s => s.password)) { + let capsLockWarning = new ShellEntry.CapsLockWarning(); + contentBox.add_child(capsLockWarning); + } + + if (flags & NM.SecretAgentGetSecretsFlags.WPS_PBC_ACTIVE) { + let descriptionLabel = new St.Label({ + text: _('Alternatively you can connect by pushing the “WPS” button on your router.'), + style_class: 'message-dialog-description', + }); + descriptionLabel.clutter_text.line_wrap = true; + descriptionLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + + contentBox.add_child(descriptionLabel); + } + + this.contentLayout.add_child(contentBox); + + this._okButton = { + label: _("Connect"), + action: this._onOk.bind(this), + default: true, + }; + + this.setButtons([{ + label: _("Cancel"), + action: this.cancel.bind(this), + key: Clutter.KEY_Escape, + }, this._okButton]); + + this._updateOkButton(); + } + + _updateOkButton() { + let valid = true; + for (let i = 0; i < this._content.secrets.length; i++) { + let secret = this._content.secrets[i]; + valid = valid && secret.valid; + } + + this._okButton.button.reactive = valid; + this._okButton.button.can_focus = valid; + } + + _onOk() { + let valid = true; + for (let i = 0; i < this._content.secrets.length; i++) { + let secret = this._content.secrets[i]; + valid = valid && secret.valid; + if (secret.key !== null) { + if (this._settingName === 'vpn') + this._agent.add_vpn_secret(this._requestId, secret.key, secret.value); + else + this._agent.set_password(this._requestId, secret.key, secret.value); + } + } + + if (valid) { + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.CONFIRMED); + this.close(global.get_current_time()); + } + // do nothing if not valid + } + + cancel() { + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.USER_CANCELED); + this.close(global.get_current_time()); + } + + _validateWpaPsk(secret) { + let value = secret.value; + if (value.length == 64) { + // must be composed of hexadecimal digits only + for (let i = 0; i < 64; i++) { + if (!((value[i] >= 'a' && value[i] <= 'f') || + (value[i] >= 'A' && value[i] <= 'F') || + (value[i] >= '0' && value[i] <= '9'))) + return false; + } + return true; + } + + return value.length >= 8 && value.length <= 63; + } + + _validateStaticWep(secret) { + let value = secret.value; + if (secret.wep_key_type == NM.WepKeyType.KEY) { + if (value.length == 10 || value.length == 26) { + for (let i = 0; i < value.length; i++) { + if (!((value[i] >= 'a' && value[i] <= 'f') || + (value[i] >= 'A' && value[i] <= 'F') || + (value[i] >= '0' && value[i] <= '9'))) + return false; + } + } else if (value.length == 5 || value.length == 13) { + for (let i = 0; i < value.length; i++) { + if (!((value[i] >= 'a' && value[i] <= 'z') || + (value[i] >= 'A' && value[i] <= 'Z'))) + return false; + } + } else { + return false; + } + } else if (secret.wep_key_type == NM.WepKeyType.PASSPHRASE) { + if (value.length < 0 || value.length > 64) + return false; + } + return true; + } + + _getWirelessSecrets(secrets, _wirelessSetting) { + let wirelessSecuritySetting = this._connection.get_setting_wireless_security(); + + if (this._settingName == '802-1x') { + this._get8021xSecrets(secrets); + return; + } + + switch (wirelessSecuritySetting.key_mgmt) { + // First the easy ones + case 'wpa-none': + case 'wpa-psk': + case 'sae': + secrets.push({ label: _('Password'), key: 'psk', + value: wirelessSecuritySetting.psk || '', + validate: this._validateWpaPsk, password: true }); + break; + case 'none': // static WEP + secrets.push({ + label: _('Key'), + key: 'wep-key%s'.format(wirelessSecuritySetting.wep_tx_keyidx), + value: wirelessSecuritySetting.get_wep_key(wirelessSecuritySetting.wep_tx_keyidx) || '', + wep_key_type: wirelessSecuritySetting.wep_key_type, + validate: this._validateStaticWep, + password: true, + }); + break; + case 'ieee8021x': + if (wirelessSecuritySetting.auth_alg == 'leap') { // Cisco LEAP + secrets.push({ label: _('Password'), key: 'leap-password', + value: wirelessSecuritySetting.leap_password || '', password: true }); + } else { // Dynamic (IEEE 802.1x) WEP + this._get8021xSecrets(secrets); + } + break; + case 'wpa-eap': + this._get8021xSecrets(secrets); + break; + default: + log('Invalid wireless key management: %s'.format(wirelessSecuritySetting.key_mgmt)); + } + } + + _get8021xSecrets(secrets) { + let ieee8021xSetting = this._connection.get_setting_802_1x(); + + /* If hints were given we know exactly what we need to ask */ + if (this._settingName == "802-1x" && this._hints.length) { + if (this._hints.includes('identity')) { + secrets.push({ label: _('Username'), key: 'identity', + value: ieee8021xSetting.identity || '', password: false }); + } + if (this._hints.includes('password')) { + secrets.push({ label: _('Password'), key: 'password', + value: ieee8021xSetting.password || '', password: true }); + } + if (this._hints.includes('private-key-password')) { + secrets.push({ label: _('Private key password'), key: 'private-key-password', + value: ieee8021xSetting.private_key_password || '', password: true }); + } + return; + } + + switch (ieee8021xSetting.get_eap_method(0)) { + case 'md5': + case 'leap': + case 'ttls': + case 'peap': + case 'fast': + // TTLS and PEAP are actually much more complicated, but this complication + // is not visible here since we only care about phase2 authentication + // (and don't even care of which one) + secrets.push({ label: _('Username'), key: null, + value: ieee8021xSetting.identity || '', password: false }); + secrets.push({ label: _('Password'), key: 'password', + value: ieee8021xSetting.password || '', password: true }); + break; + case 'tls': + secrets.push({ label: _('Identity'), key: null, + value: ieee8021xSetting.identity || '', password: false }); + secrets.push({ label: _('Private key password'), key: 'private-key-password', + value: ieee8021xSetting.private_key_password || '', password: true }); + break; + default: + log('Invalid EAP/IEEE802.1x method: %s'.format(ieee8021xSetting.get_eap_method(0))); + } + } + + _getPPPoESecrets(secrets) { + let pppoeSetting = this._connection.get_setting_pppoe(); + secrets.push({ label: _('Username'), key: 'username', + value: pppoeSetting.username || '', password: false }); + secrets.push({ label: _('Service'), key: 'service', + value: pppoeSetting.service || '', password: false }); + secrets.push({ label: _('Password'), key: 'password', + value: pppoeSetting.password || '', password: true }); + } + + _getMobileSecrets(secrets, connectionType) { + let setting; + if (connectionType == 'bluetooth') + setting = this._connection.get_setting_cdma() || this._connection.get_setting_gsm(); + else + setting = this._connection.get_setting_by_name(connectionType); + secrets.push({ label: _('Password'), key: 'password', + value: setting.value || '', password: true }); + } + + _getContent() { + let connectionSetting = this._connection.get_setting_connection(); + let connectionType = connectionSetting.get_connection_type(); + let wirelessSetting; + let ssid; + + let content = { }; + content.secrets = []; + + switch (connectionType) { + case '802-11-wireless': + wirelessSetting = this._connection.get_setting_wireless(); + ssid = NM.utils_ssid_to_utf8(wirelessSetting.get_ssid().get_data()); + content.title = _('Authentication required'); + content.message = _("Passwords or encryption keys are required to access the wireless network “%s”.").format(ssid); + this._getWirelessSecrets(content.secrets, wirelessSetting); + break; + case '802-3-ethernet': + content.title = _("Wired 802.1X authentication"); + content.message = null; + content.secrets.push({ label: _('Network name'), key: null, + value: connectionSetting.get_id(), password: false }); + this._get8021xSecrets(content.secrets); + break; + case 'pppoe': + content.title = _("DSL authentication"); + content.message = null; + this._getPPPoESecrets(content.secrets); + break; + case 'gsm': + if (this._hints.includes('pin')) { + let gsmSetting = this._connection.get_setting_gsm(); + content.title = _("PIN code required"); + content.message = _("PIN code is needed for the mobile broadband device"); + content.secrets.push({ label: _('PIN'), key: 'pin', + value: gsmSetting.pin || '', password: true }); + break; + } + // fall through + case 'cdma': + case 'bluetooth': + content.title = _('Authentication required'); + content.message = _("A password is required to connect to “%s”.").format(connectionSetting.get_id()); + this._getMobileSecrets(content.secrets, connectionType); + break; + default: + log('Invalid connection type: %s'.format(connectionType)); + } + + return content; + } +}); + +var VPNRequestHandler = class { + constructor(agent, requestId, authHelper, serviceType, connection, hints, flags) { + this._agent = agent; + this._requestId = requestId; + this._connection = connection; + this._flags = flags; + this._pluginOutBuffer = []; + this._title = null; + this._description = null; + this._content = []; + this._shellDialog = null; + + let connectionSetting = connection.get_setting_connection(); + + let argv = [authHelper.fileName, + '-u', connectionSetting.uuid, + '-n', connectionSetting.id, + '-s', serviceType]; + if (authHelper.externalUIMode) + argv.push('--external-ui-mode'); + if (flags & NM.SecretAgentGetSecretsFlags.ALLOW_INTERACTION) + argv.push('-i'); + if (flags & NM.SecretAgentGetSecretsFlags.REQUEST_NEW) + argv.push('-r'); + if (authHelper.supportsHints) { + for (let i = 0; i < hints.length; i++) { + argv.push('-t'); + argv.push(hints[i]); + } + } + + this._newStylePlugin = authHelper.externalUIMode; + + try { + let [success_, pid, stdin, stdout, stderr] = + GLib.spawn_async_with_pipes(null, /* pwd */ + argv, + null, /* envp */ + GLib.SpawnFlags.DO_NOT_REAP_CHILD, + null /* child_setup */); + + this._childPid = pid; + this._stdin = new Gio.UnixOutputStream({ fd: stdin, close_fd: true }); + this._stdout = new Gio.UnixInputStream({ fd: stdout, close_fd: true }); + GLib.close(stderr); + this._dataStdout = new Gio.DataInputStream({ base_stream: this._stdout }); + + if (this._newStylePlugin) + this._readStdoutNewStyle(); + else + this._readStdoutOldStyle(); + + this._childWatch = GLib.child_watch_add(GLib.PRIORITY_DEFAULT, pid, + this._vpnChildFinished.bind(this)); + + this._writeConnection(); + } catch (e) { + logError(e, 'error while spawning VPN auth helper'); + + this._agent.respond(requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + } + } + + cancel(respond) { + if (respond) + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.USER_CANCELED); + + if (this._newStylePlugin && this._shellDialog) { + this._shellDialog.close(global.get_current_time()); + this._shellDialog.destroy(); + } else { + try { + this._stdin.write('QUIT\n\n', null); + } catch (e) { /* ignore broken pipe errors */ } + } + + this.destroy(); + } + + destroy() { + if (this._destroyed) + return; + + this.emit('destroy'); + if (this._childWatch) + GLib.source_remove(this._childWatch); + + this._stdin.close(null); + // Stdout is closed when we finish reading from it + + this._destroyed = true; + } + + _vpnChildFinished(pid, status, _requestObj) { + this._childWatch = 0; + if (this._newStylePlugin) { + // For new style plugin, all work is done in the async reading functions + // Just reap the process here + return; + } + + let [exited, exitStatus] = Shell.util_wifexited(status); + + if (exited) { + if (exitStatus != 0) + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.USER_CANCELED); + else + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.CONFIRMED); + } else { + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + } + + this.destroy(); + } + + _vpnChildProcessLineOldStyle(line) { + if (this._previousLine != undefined) { + // Two consecutive newlines mean that the child should be closed + // (the actual newlines are eaten by Gio.DataInputStream) + // Send a termination message + if (line == '' && this._previousLine == '') { + try { + this._stdin.write('QUIT\n\n', null); + } catch (e) { /* ignore broken pipe errors */ } + } else { + this._agent.add_vpn_secret(this._requestId, this._previousLine, line); + this._previousLine = undefined; + } + } else { + this._previousLine = line; + } + } + + async _readStdoutOldStyle() { + const [line, len_] = + await this._dataStdout.read_line_async(GLib.PRIORITY_DEFAULT, null); + + if (line === null) { + // end of file + this._stdout.close(null); + return; + } + + this._vpnChildProcessLineOldStyle(ByteArray.toString(line)); + + // try to read more! + this._readStdoutOldStyle(); + } + + async _readStdoutNewStyle() { + const cnt = + await this._dataStdout.fill_async(-1, GLib.PRIORITY_DEFAULT, null); + + if (cnt === 0) { + // end of file + this._showNewStyleDialog(); + + this._stdout.close(null); + return; + } + + // Try to read more + this._dataStdout.set_buffer_size(2 * this._dataStdout.get_buffer_size()); + this._readStdoutNewStyle(); + } + + _showNewStyleDialog() { + let keyfile = new GLib.KeyFile(); + let data; + let contentOverride; + + try { + data = ByteArray.toGBytes(this._dataStdout.peek_buffer()); + keyfile.load_from_bytes(data, GLib.KeyFileFlags.NONE); + + if (keyfile.get_integer(VPN_UI_GROUP, 'Version') != 2) + throw new Error('Invalid plugin keyfile version, is %d'); + + contentOverride = { title: keyfile.get_string(VPN_UI_GROUP, 'Title'), + message: keyfile.get_string(VPN_UI_GROUP, 'Description'), + secrets: [] }; + + let [groups, len_] = keyfile.get_groups(); + for (let i = 0; i < groups.length; i++) { + if (groups[i] == VPN_UI_GROUP) + continue; + + let value = keyfile.get_string(groups[i], 'Value'); + let shouldAsk = keyfile.get_boolean(groups[i], 'ShouldAsk'); + + if (shouldAsk) { + contentOverride.secrets.push({ + label: keyfile.get_string(groups[i], 'Label'), + key: groups[i], + value, + password: keyfile.get_boolean(groups[i], 'IsSecret'), + }); + } else { + if (!value.length) // Ignore empty secrets + continue; + + this._agent.add_vpn_secret(this._requestId, groups[i], value); + } + } + } catch (e) { + // No output is a valid case it means "both secrets are stored" + if (data.length > 0) { + logError(e, 'error while reading VPN plugin output keyfile'); + + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + this.destroy(); + return; + } + } + + if (contentOverride && contentOverride.secrets.length) { + // Only show the dialog if we actually have something to ask + this._shellDialog = new NetworkSecretDialog(this._agent, this._requestId, this._connection, 'vpn', [], this._flags, contentOverride); + this._shellDialog.open(global.get_current_time()); + } else { + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.CONFIRMED); + this.destroy(); + } + } + + _writeConnection() { + let vpnSetting = this._connection.get_setting_vpn(); + + try { + vpnSetting.foreach_data_item((key, value) => { + this._stdin.write('DATA_KEY=%s\n'.format(key), null); + this._stdin.write('DATA_VAL=%s\n\n'.format(value || ''), null); + }); + vpnSetting.foreach_secret((key, value) => { + this._stdin.write('SECRET_KEY=%s\n'.format(key), null); + this._stdin.write('SECRET_VAL=%s\n\n'.format(value || ''), null); + }); + this._stdin.write('DONE\n\n', null); + } catch (e) { + logError(e, 'internal error while writing connection to helper'); + + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + this.destroy(); + } + } +}; +Signals.addSignalMethods(VPNRequestHandler.prototype); + +var NetworkAgent = class { + constructor() { + this._native = new Shell.NetworkAgent({ + identifier: 'org.gnome.Shell.NetworkAgent', + capabilities: NM.SecretAgentCapabilities.VPN_HINTS, + auto_register: false, + }); + + this._dialogs = { }; + this._vpnRequests = { }; + this._notifications = { }; + + this._native.connect('new-request', this._newRequest.bind(this)); + this._native.connect('cancel-request', this._cancelRequest.bind(this)); + + this._initialized = false; + this._initNative(); + } + + async _initNative() { + try { + await this._native.init_async(GLib.PRIORITY_DEFAULT, null); + this._initialized = true; + } catch (e) { + this._native = null; + logError(e, 'error initializing the NetworkManager Agent'); + } + } + + enable() { + if (!this._native) + return; + + this._native.auto_register = true; + if (this._initialized && !this._native.registered) + this._native.register_async(null, null); + } + + disable() { + let requestId; + + for (requestId in this._dialogs) + this._dialogs[requestId].cancel(); + this._dialogs = { }; + + for (requestId in this._vpnRequests) + this._vpnRequests[requestId].cancel(true); + this._vpnRequests = { }; + + for (requestId in this._notifications) + this._notifications[requestId].destroy(); + this._notifications = { }; + + if (!this._native) + return; + + this._native.auto_register = false; + if (this._initialized && this._native.registered) + this._native.unregister_async(null, null); + } + + _showNotification(requestId, connection, settingName, hints, flags) { + let source = new MessageTray.Source(_("Network Manager"), 'network-transmit-receive'); + source.policy = new MessageTray.NotificationApplicationPolicy('gnome-network-panel'); + + let title, body; + + let connectionSetting = connection.get_setting_connection(); + let connectionType = connectionSetting.get_connection_type(); + switch (connectionType) { + case '802-11-wireless': { + let wirelessSetting = connection.get_setting_wireless(); + let ssid = NM.utils_ssid_to_utf8(wirelessSetting.get_ssid().get_data()); + title = _('Authentication required'); + body = _("Passwords or encryption keys are required to access the wireless network “%s”.").format(ssid); + break; + } + case '802-3-ethernet': + title = _("Wired 802.1X authentication"); + body = _("A password is required to connect to “%s”.".format(connection.get_id())); + break; + case 'pppoe': + title = _("DSL authentication"); + body = _("A password is required to connect to “%s”.".format(connection.get_id())); + break; + case 'gsm': + if (hints.includes('pin')) { + title = _("PIN code required"); + body = _("PIN code is needed for the mobile broadband device"); + break; + } + // fall through + case 'cdma': + case 'bluetooth': + title = _('Authentication required'); + body = _("A password is required to connect to “%s”.").format(connectionSetting.get_id()); + break; + case 'vpn': + title = _("VPN password"); + body = _("A password is required to connect to “%s”.").format(connectionSetting.get_id()); + break; + default: + log('Invalid connection type: %s'.format(connectionType)); + this._native.respond(requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + return; + } + + let notification = new MessageTray.Notification(source, title, body); + + notification.connect('activated', () => { + notification.answered = true; + this._handleRequest(requestId, connection, settingName, hints, flags); + }); + + this._notifications[requestId] = notification; + notification.connect('destroy', () => { + if (!notification.answered) + this._native.respond(requestId, Shell.NetworkAgentResponse.USER_CANCELED); + delete this._notifications[requestId]; + }); + + Main.messageTray.add(source); + source.showNotification(notification); + } + + _newRequest(agent, requestId, connection, settingName, hints, flags) { + if (!(flags & NM.SecretAgentGetSecretsFlags.USER_REQUESTED)) + this._showNotification(requestId, connection, settingName, hints, flags); + else + this._handleRequest(requestId, connection, settingName, hints, flags); + } + + _handleRequest(requestId, connection, settingName, hints, flags) { + if (settingName == 'vpn') { + this._vpnRequest(requestId, connection, hints, flags); + return; + } + + let dialog = new NetworkSecretDialog(this._native, requestId, connection, settingName, hints, flags); + dialog.connect('destroy', () => { + delete this._dialogs[requestId]; + }); + this._dialogs[requestId] = dialog; + dialog.open(global.get_current_time()); + } + + _cancelRequest(agent, requestId) { + if (this._dialogs[requestId]) { + this._dialogs[requestId].close(global.get_current_time()); + this._dialogs[requestId].destroy(); + delete this._dialogs[requestId]; + } else if (this._vpnRequests[requestId]) { + this._vpnRequests[requestId].cancel(false); + delete this._vpnRequests[requestId]; + } + } + + async _vpnRequest(requestId, connection, hints, flags) { + let vpnSetting = connection.get_setting_vpn(); + let serviceType = vpnSetting.service_type; + + let binary = await this._findAuthBinary(serviceType); + if (!binary) { + log('Invalid VPN service type (cannot find authentication binary)'); + + /* cancel the auth process */ + this._native.respond(requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + return; + } + + let vpnRequest = new VPNRequestHandler(this._native, requestId, binary, serviceType, connection, hints, flags); + vpnRequest.connect('destroy', () => { + delete this._vpnRequests[requestId]; + }); + this._vpnRequests[requestId] = vpnRequest; + } + + async _findAuthBinary(serviceType) { + let plugin; + + try { + plugin = await this._native.search_vpn_plugin(serviceType); + } catch (e) { + logError(e); + return null; + } + + const fileName = plugin.get_auth_dialog(); + if (!GLib.file_test(fileName, GLib.FileTest.IS_EXECUTABLE)) { + log('VPN plugin at %s is not executable'.format(fileName)); + return null; + } + + const prop = plugin.lookup_property('GNOME', 'supports-external-ui-mode'); + const trimmedProp = prop ? prop.trim().toLowerCase() : ''; + + return { + fileName, + supportsHints: plugin.supports_hints(), + externalUIMode: ['true', 'yes', 'on', '1'].includes(trimmedProp), + }; + } +}; +var Component = NetworkAgent; diff --git a/js/ui/components/polkitAgent.js b/js/ui/components/polkitAgent.js new file mode 100644 index 0000000..27b705e --- /dev/null +++ b/js/ui/components/polkitAgent.js @@ -0,0 +1,477 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Component */ + +const { AccountsService, Clutter, GLib, + GObject, Pango, PolkitAgent, Polkit, Shell, St } = imports.gi; + +const Dialog = imports.ui.dialog; +const Main = imports.ui.main; +const ModalDialog = imports.ui.modalDialog; +const ShellEntry = imports.ui.shellEntry; +const UserWidget = imports.ui.userWidget; +const Util = imports.misc.util; + +const DialogMode = { + AUTH: 0, + CONFIRM: 1, +}; + +const DIALOG_ICON_SIZE = 64; + +const DELAYED_RESET_TIMEOUT = 200; + +var AuthenticationDialog = GObject.registerClass({ + Signals: { 'done': { param_types: [GObject.TYPE_BOOLEAN] } }, +}, class AuthenticationDialog extends ModalDialog.ModalDialog { + _init(actionId, description, cookie, userNames) { + super._init({ styleClass: 'prompt-dialog' }); + + this.actionId = actionId; + this.message = description; + this.userNames = userNames; + + this._sessionUpdatedId = Main.sessionMode.connect('updated', () => { + this.visible = !Main.sessionMode.isLocked; + }); + + this.connect('closed', this._onDialogClosed.bind(this)); + + let title = _("Authentication Required"); + + let headerContent = new Dialog.MessageDialogContent({ title, description }); + this.contentLayout.add_child(headerContent); + + let bodyContent = new Dialog.MessageDialogContent(); + + if (userNames.length > 1) { + log('polkitAuthenticationAgent: Received %d '.format(userNames.length) + + 'identities that can be used for authentication. Only ' + + 'considering one.'); + } + + let userName = GLib.get_user_name(); + if (!userNames.includes(userName)) + userName = 'root'; + if (!userNames.includes(userName)) + userName = userNames[0]; + + this._user = AccountsService.UserManager.get_default().get_user(userName); + + let userBox = new St.BoxLayout({ + style_class: 'polkit-dialog-user-layout', + vertical: true, + }); + bodyContent.add_child(userBox); + + this._userAvatar = new UserWidget.Avatar(this._user, { + iconSize: DIALOG_ICON_SIZE, + }); + this._userAvatar.x_align = Clutter.ActorAlign.CENTER; + userBox.add_child(this._userAvatar); + + this._userLabel = new St.Label({ + style_class: userName === 'root' + ? 'polkit-dialog-user-root-label' + : 'polkit-dialog-user-label', + }); + + if (userName === 'root') + this._userLabel.text = _('Administrator'); + + userBox.add_child(this._userLabel); + + let passwordBox = new St.BoxLayout({ + style_class: 'prompt-dialog-password-layout', + vertical: true, + }); + + this._passwordEntry = new St.PasswordEntry({ + style_class: 'prompt-dialog-password-entry', + text: "", + can_focus: true, + visible: false, + x_align: Clutter.ActorAlign.CENTER, + }); + ShellEntry.addContextMenu(this._passwordEntry); + this._passwordEntry.clutter_text.connect('activate', this._onEntryActivate.bind(this)); + this._passwordEntry.bind_property('reactive', + this._passwordEntry.clutter_text, 'editable', + GObject.BindingFlags.SYNC_CREATE); + passwordBox.add_child(this._passwordEntry); + + let warningBox = new St.BoxLayout({ vertical: true }); + + let capsLockWarning = new ShellEntry.CapsLockWarning(); + this._passwordEntry.bind_property('visible', + capsLockWarning, 'visible', + GObject.BindingFlags.SYNC_CREATE); + warningBox.add_child(capsLockWarning); + + this._errorMessageLabel = new St.Label({ + style_class: 'prompt-dialog-error-label', + visible: false, + }); + this._errorMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._errorMessageLabel.clutter_text.line_wrap = true; + warningBox.add_child(this._errorMessageLabel); + + this._infoMessageLabel = new St.Label({ + style_class: 'prompt-dialog-info-label', + visible: false, + }); + this._infoMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._infoMessageLabel.clutter_text.line_wrap = true; + warningBox.add_child(this._infoMessageLabel); + + /* text is intentionally non-blank otherwise the height is not the same as for + * infoMessage and errorMessageLabel - but it is still invisible because + * gnome-shell.css sets the color to be transparent + */ + this._nullMessageLabel = new St.Label({ style_class: 'prompt-dialog-null-label' }); + this._nullMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._nullMessageLabel.clutter_text.line_wrap = true; + warningBox.add_child(this._nullMessageLabel); + + passwordBox.add_child(warningBox); + bodyContent.add_child(passwordBox); + + this._cancelButton = this.addButton({ label: _("Cancel"), + action: this.cancel.bind(this), + key: Clutter.KEY_Escape }); + this._okButton = this.addButton({ label: _("Authenticate"), + action: this._onAuthenticateButtonPressed.bind(this), + reactive: false }); + this._okButton.bind_property('reactive', + this._okButton, 'can-focus', + GObject.BindingFlags.SYNC_CREATE); + + this._passwordEntry.clutter_text.connect('text-changed', text => { + this._okButton.reactive = text.get_text().length > 0; + }); + + this.contentLayout.add_child(bodyContent); + + this._doneEmitted = false; + + this._mode = -1; + + this._identityToAuth = Polkit.UnixUser.new_for_name(userName); + this._cookie = cookie; + + this._userLoadedId = this._user.connect('notify::is-loaded', + this._onUserChanged.bind(this)); + this._userChangedId = this._user.connect('changed', + this._onUserChanged.bind(this)); + this._onUserChanged(); + } + + _initiateSession() { + this._destroySession(DELAYED_RESET_TIMEOUT); + + this._session = new PolkitAgent.Session({ identity: this._identityToAuth, + cookie: this._cookie }); + this._sessionCompletedId = this._session.connect('completed', this._onSessionCompleted.bind(this)); + this._sessionRequestId = this._session.connect('request', this._onSessionRequest.bind(this)); + this._sessionShowErrorId = this._session.connect('show-error', this._onSessionShowError.bind(this)); + this._sessionShowInfoId = this._session.connect('show-info', this._onSessionShowInfo.bind(this)); + this._session.initiate(); + } + + _ensureOpen() { + // NOTE: ModalDialog.open() is safe to call if the dialog is + // already open - it just returns true without side-effects + if (!this.open(global.get_current_time())) { + // This can fail if e.g. unable to get input grab + // + // In an ideal world this wouldn't happen (because the + // Shell is in complete control of the session) but that's + // just not how things work right now. + // + // One way to make this happen is by running 'sleep 3; + // pkexec bash' and then opening a popup menu. + // + // We could add retrying if this turns out to be a problem + + log('polkitAuthenticationAgent: Failed to show modal dialog. ' + + 'Dismissing authentication request for action-id %s '.format(this.actionId) + + 'cookie %s'.format(this._cookie)); + this._emitDone(true); + } + } + + _emitDone(dismissed) { + if (!this._doneEmitted) { + this._doneEmitted = true; + this.emit('done', dismissed); + } + } + + _onEntryActivate() { + let response = this._passwordEntry.get_text(); + if (response.length === 0) + return; + + this._passwordEntry.reactive = false; + this._okButton.reactive = false; + + this._session.response(response); + // When the user responds, dismiss already shown info and + // error texts (if any) + this._errorMessageLabel.hide(); + this._infoMessageLabel.hide(); + this._nullMessageLabel.show(); + } + + _onAuthenticateButtonPressed() { + if (this._mode === DialogMode.CONFIRM) + this._initiateSession(); + else + this._onEntryActivate(); + } + + _onSessionCompleted(session, gainedAuthorization) { + if (this._completed || this._doneEmitted) + return; + + this._completed = true; + + /* Yay, all done */ + if (gainedAuthorization) { + this._emitDone(false); + + } else { + /* Unless we are showing an existing error message from the PAM + * module (the PAM module could be reporting the authentication + * error providing authentication-method specific information), + * show "Sorry, that didn't work. Please try again." + */ + if (!this._errorMessageLabel.visible) { + /* Translators: "that didn't work" refers to the fact that the + * requested authentication was not gained; this can happen + * because of an authentication error (like invalid password), + * for instance. */ + this._errorMessageLabel.set_text(_("Sorry, that didn’t work. Please try again.")); + this._errorMessageLabel.show(); + this._infoMessageLabel.hide(); + this._nullMessageLabel.hide(); + + Util.wiggle(this._passwordEntry); + } + + /* Try and authenticate again */ + this._initiateSession(); + } + } + + _onSessionRequest(session, request, echoOn) { + if (this._sessionRequestTimeoutId) { + GLib.source_remove(this._sessionRequestTimeoutId); + this._sessionRequestTimeoutId = 0; + } + + // Hack: The request string comes directly from PAM, if it's "Password:" + // we replace it with our own to allow localization, if it's something + // else we remove the last colon and any trailing or leading spaces. + if (request === 'Password:' || request === 'Password: ') + this._passwordEntry.hint_text = _('Password'); + else + this._passwordEntry.hint_text = request.replace(/: *$/, '').trim(); + + this._passwordEntry.password_visible = echoOn; + + this._passwordEntry.show(); + this._passwordEntry.set_text(''); + this._passwordEntry.reactive = true; + this._okButton.reactive = false; + + this._ensureOpen(); + this._passwordEntry.grab_key_focus(); + } + + _onSessionShowError(session, text) { + this._passwordEntry.set_text(''); + this._errorMessageLabel.set_text(text); + this._errorMessageLabel.show(); + this._infoMessageLabel.hide(); + this._nullMessageLabel.hide(); + this._ensureOpen(); + } + + _onSessionShowInfo(session, text) { + this._passwordEntry.set_text(''); + this._infoMessageLabel.set_text(text); + this._infoMessageLabel.show(); + this._errorMessageLabel.hide(); + this._nullMessageLabel.hide(); + this._ensureOpen(); + } + + _destroySession(delay = 0) { + if (this._session) { + this._session.disconnect(this._sessionCompletedId); + this._session.disconnect(this._sessionRequestId); + this._session.disconnect(this._sessionShowErrorId); + this._session.disconnect(this._sessionShowInfoId); + + if (!this._completed) + this._session.cancel(); + + this._completed = false; + this._session = null; + } + + if (this._sessionRequestTimeoutId) { + GLib.source_remove(this._sessionRequestTimeoutId); + this._sessionRequestTimeoutId = 0; + } + + let resetDialog = () => { + this._sessionRequestTimeoutId = 0; + + if (this.state != ModalDialog.State.OPENED) + return GLib.SOURCE_REMOVE; + + this._passwordEntry.hide(); + this._cancelButton.grab_key_focus(); + this._okButton.reactive = false; + + return GLib.SOURCE_REMOVE; + }; + + if (delay) { + this._sessionRequestTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, resetDialog); + GLib.Source.set_name_by_id(this._sessionRequestTimeoutId, '[gnome-shell] this._sessionRequestTimeoutId'); + } else { + resetDialog(); + } + } + + _onUserChanged() { + if (!this._user.is_loaded) + return; + + let userName = this._user.get_user_name(); + let realName = this._user.get_real_name(); + + if (userName !== 'root') + this._userLabel.set_text(realName); + + this._userAvatar.update(); + + if (this._user.get_password_mode() === AccountsService.UserPasswordMode.NONE) { + if (this._mode === DialogMode.CONFIRM) + return; + + this._mode = DialogMode.CONFIRM; + this._destroySession(); + + this._okButton.reactive = true; + + /* We normally open the dialog when we get a "request" signal, but + * since in this case initiating a session would perform the + * authentication, only open the dialog and initiate the session + * when the user confirmed. */ + this._ensureOpen(); + } else { + if (this._mode === DialogMode.AUTH) + return; + + this._mode = DialogMode.AUTH; + this._initiateSession(); + } + } + + close(timestamp) { + // Ensure cleanup if the dialog was never shown + if (this.state === ModalDialog.State.CLOSED) + this._onDialogClosed(); + super.close(timestamp); + } + + cancel() { + this.close(global.get_current_time()); + this._emitDone(true); + } + + _onDialogClosed() { + if (this._sessionUpdatedId) + Main.sessionMode.disconnect(this._sessionUpdatedId); + + if (this._sessionRequestTimeoutId) + GLib.source_remove(this._sessionRequestTimeoutId); + this._sessionRequestTimeoutId = 0; + + if (this._user) { + this._user.disconnect(this._userLoadedId); + this._user.disconnect(this._userChangedId); + this._user = null; + } + + this._destroySession(); + } +}); + +var AuthenticationAgent = GObject.registerClass( +class AuthenticationAgent extends Shell.PolkitAuthenticationAgent { + _init() { + super._init(); + + this._currentDialog = null; + this.connect('initiate', this._onInitiate.bind(this)); + this.connect('cancel', this._onCancel.bind(this)); + this._sessionUpdatedId = 0; + } + + enable() { + try { + this.register(); + } catch (e) { + log('Failed to register AuthenticationAgent'); + } + } + + disable() { + try { + this.unregister(); + } catch (e) { + log('Failed to unregister AuthenticationAgent'); + } + } + + _onInitiate(nativeAgent, actionId, message, iconName, cookie, userNames) { + // Don't pop up a dialog while locked + if (Main.sessionMode.isLocked) { + this._sessionUpdatedId = Main.sessionMode.connect('updated', () => { + Main.sessionMode.disconnect(this._sessionUpdatedId); + this._sessionUpdatedId = 0; + + this._onInitiate(nativeAgent, actionId, message, iconName, cookie, userNames); + }); + return; + } + + this._currentDialog = new AuthenticationDialog(actionId, message, cookie, userNames); + this._currentDialog.connect('done', this._onDialogDone.bind(this)); + } + + _onCancel(_nativeAgent) { + this._completeRequest(false); + } + + _onDialogDone(_dialog, dismissed) { + this._completeRequest(dismissed); + } + + _completeRequest(dismissed) { + this._currentDialog.close(); + this._currentDialog = null; + + if (this._sessionUpdatedId) + Main.sessionMode.disconnect(this._sessionUpdatedId); + this._sessionUpdatedId = 0; + + this.complete(dismissed); + } +}); + +var Component = AuthenticationAgent; diff --git a/js/ui/components/telepathyClient.js b/js/ui/components/telepathyClient.js new file mode 100644 index 0000000..0c9514e --- /dev/null +++ b/js/ui/components/telepathyClient.js @@ -0,0 +1,1018 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Component */ + +const { Clutter, Gio, GLib, GObject, St } = imports.gi; + +var Tpl = null; +var Tp = null; +try { + ({ TelepathyGLib: Tp, TelepathyLogger: Tpl } = imports.gi); + + Gio._promisify(Tp.Channel.prototype, 'close_async', 'close_finish'); + Gio._promisify(Tp.TextChannel.prototype, + 'send_message_async', 'send_message_finish'); + Gio._promisify(Tp.ChannelDispatchOperation.prototype, + 'claim_with_async', 'claim_with_finish'); + Gio._promisify(Tpl.LogManager.prototype, + 'get_filtered_events_async', 'get_filtered_events_finish'); +} catch (e) { + log('Telepathy is not available, chat integration will be disabled.'); +} + +const History = imports.misc.history; +const Main = imports.ui.main; +const MessageList = imports.ui.messageList; +const MessageTray = imports.ui.messageTray; +const Params = imports.misc.params; +const Util = imports.misc.util; + +const HAVE_TP = Tp != null && Tpl != null; + +// See Notification.appendMessage +var SCROLLBACK_IMMEDIATE_TIME = 3 * 60; // 3 minutes +var SCROLLBACK_RECENT_TIME = 15 * 60; // 15 minutes +var SCROLLBACK_RECENT_LENGTH = 20; +var SCROLLBACK_IDLE_LENGTH = 5; + +// See Source._displayPendingMessages +var SCROLLBACK_HISTORY_LINES = 10; + +// See Notification._onEntryChanged +var COMPOSING_STOP_TIMEOUT = 5; + +var CHAT_EXPAND_LINES = 12; + +var NotificationDirection = { + SENT: 'chat-sent', + RECEIVED: 'chat-received', +}; + +const ChatMessage = HAVE_TP ? GObject.registerClass({ + Properties: { + 'message-type': GObject.ParamSpec.int( + 'message-type', 'message-type', 'message-type', + GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, + Math.min(...Object.values(Tp.ChannelTextMessageType)), + Math.max(...Object.values(Tp.ChannelTextMessageType)), + Tp.ChannelTextMessageType.NORMAL), + 'text': GObject.ParamSpec.string( + 'text', 'text', 'text', + GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, + null), + 'sender': GObject.ParamSpec.string( + 'sender', 'sender', 'sender', + GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, + null), + 'timestamp': GObject.ParamSpec.int64( + 'timestamp', 'timestamp', 'timestamp', + GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, + 0, Number.MAX_SAFE_INTEGER, 0), + 'direction': GObject.ParamSpec.string( + 'direction', 'direction', 'direction', + GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, + null), + }, +}, class ChatMessageClass extends GObject.Object { + static newFromTpMessage(tpMessage, direction) { + return new ChatMessage({ + 'message-type': tpMessage.get_message_type(), + 'text': tpMessage.to_text()[0], + 'sender': tpMessage.sender.alias, + 'timestamp': direction === NotificationDirection.RECEIVED + ? tpMessage.get_received_timestamp() : tpMessage.get_sent_timestamp(), + direction, + }); + } + + static newFromTplTextEvent(tplTextEvent) { + let direction = + tplTextEvent.get_sender().get_entity_type() === Tpl.EntityType.SELF + ? NotificationDirection.SENT : NotificationDirection.RECEIVED; + + return new ChatMessage({ + 'message-type': tplTextEvent.get_message_type(), + 'text': tplTextEvent.get_message(), + 'sender': tplTextEvent.get_sender().get_alias(), + 'timestamp': tplTextEvent.get_timestamp(), + direction, + }); + } +}) : null; + + +var TelepathyComponent = class { + constructor() { + this._client = null; + + if (!HAVE_TP) + return; // Telepathy isn't available + + this._client = new TelepathyClient(); + } + + enable() { + if (!this._client) + return; + + try { + this._client.register(); + } catch (e) { + throw new Error('Could not register Telepathy client. Error: %s'.format(e.toString())); + } + + if (!this._client.account_manager.is_prepared(Tp.AccountManager.get_feature_quark_core())) + this._client.account_manager.prepare_async(null, null); + } + + disable() { + if (!this._client) + return; + + this._client.unregister(); + } +}; + +var TelepathyClient = HAVE_TP ? GObject.registerClass( +class TelepathyClient extends Tp.BaseClient { + _init() { + // channel path -> ChatSource + this._chatSources = {}; + this._chatState = Tp.ChannelChatState.ACTIVE; + + // account path -> AccountNotification + this._accountNotifications = {}; + + // Define features we want + this._accountManager = Tp.AccountManager.dup(); + let factory = this._accountManager.get_factory(); + factory.add_account_features([Tp.Account.get_feature_quark_connection()]); + factory.add_connection_features([Tp.Connection.get_feature_quark_contact_list()]); + factory.add_channel_features([Tp.Channel.get_feature_quark_contacts()]); + factory.add_contact_features([Tp.ContactFeature.ALIAS, + Tp.ContactFeature.AVATAR_DATA, + Tp.ContactFeature.PRESENCE, + Tp.ContactFeature.SUBSCRIPTION_STATES]); + + // Set up a SimpleObserver, which will call _observeChannels whenever a + // channel matching its filters is detected. + // The second argument, recover, means _observeChannels will be run + // for any existing channel as well. + super._init({ name: 'GnomeShell', + account_manager: this._accountManager, + uniquify_name: true }); + + // We only care about single-user text-based chats + let filter = {}; + filter[Tp.PROP_CHANNEL_CHANNEL_TYPE] = Tp.IFACE_CHANNEL_TYPE_TEXT; + filter[Tp.PROP_CHANNEL_TARGET_HANDLE_TYPE] = Tp.HandleType.CONTACT; + + this.set_observer_recover(true); + this.add_observer_filter(filter); + this.add_approver_filter(filter); + this.add_handler_filter(filter); + + // Allow other clients (such as Empathy) to preempt our channels if + // needed + this.set_delegated_channels_callback( + this._delegatedChannelsCb.bind(this)); + } + + vfunc_observe_channels(...args) { + let [account, conn, channels, dispatchOp_, requests_, context] = args; + let len = channels.length; + for (let i = 0; i < len; i++) { + let channel = channels[i]; + let [targetHandle_, targetHandleType] = channel.get_handle(); + + if (channel.get_invalidated()) + continue; + + /* Only observe contact text channels */ + if (!(channel instanceof Tp.TextChannel) || + targetHandleType != Tp.HandleType.CONTACT) + continue; + + this._createChatSource(account, conn, channel, channel.get_target_contact()); + } + + context.accept(); + } + + _createChatSource(account, conn, channel, contact) { + if (this._chatSources[channel.get_object_path()]) + return; + + let source = new ChatSource(account, conn, channel, contact, this); + + this._chatSources[channel.get_object_path()] = source; + source.connect('destroy', () => { + delete this._chatSources[channel.get_object_path()]; + }); + } + + vfunc_handle_channels(...args) { + let [account, conn, channels, requests_, userActionTime_, context] = args; + this._handlingChannels(account, conn, channels, true); + context.accept(); + } + + _handlingChannels(account, conn, channels, notify) { + let len = channels.length; + for (let i = 0; i < len; i++) { + let channel = channels[i]; + + // We can only handle text channel, so close any other channel + if (!(channel instanceof Tp.TextChannel)) { + channel.close_async(); + continue; + } + + if (channel.get_invalidated()) + continue; + + // 'notify' will be true when coming from an actual HandleChannels + // call, and not when from a successful Claim call. The point is + // we don't want to notify for a channel we just claimed which + // has no new messages (for example, a new channel which only has + // a delivery notification). We rely on _displayPendingMessages() + // and _messageReceived() to notify for new messages. + + // But we should still notify from HandleChannels because the + // Telepathy spec states that handlers must foreground channels + // in HandleChannels calls which are already being handled. + + if (notify && this.is_handling_channel(channel)) { + // We are already handling the channel, display the source + let source = this._chatSources[channel.get_object_path()]; + if (source) + source.showNotification(); + } + } + } + + vfunc_add_dispatch_operation(...args) { + let [account, conn, channels, dispatchOp, context] = args; + let channel = channels[0]; + let chanType = channel.get_channel_type(); + + if (channel.get_invalidated()) { + context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT, + message: 'Channel is invalidated' })); + return; + } + + if (chanType == Tp.IFACE_CHANNEL_TYPE_TEXT) { + this._approveTextChannel(account, conn, channel, dispatchOp, context); + } else { + context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT, + message: 'Unsupported channel type' })); + } + } + + async _approveTextChannel(account, conn, channel, dispatchOp, context) { + let [targetHandle_, targetHandleType] = channel.get_handle(); + + if (targetHandleType != Tp.HandleType.CONTACT) { + context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT, + message: 'Unsupported handle type' })); + return; + } + + context.accept(); + + // Approve private text channels right away as we are going to handle it + try { + await dispatchOp.claim_with_async(this); + this._handlingChannels(account, conn, [channel], false); + } catch (err) { + log('Failed to Claim channel: %s'.format(err.toString())); + } + } + + _delegatedChannelsCb(_client, _channels) { + // Nothing to do as we don't make a distinction between observed and + // handled channels. + } +}) : null; + +var ChatSource = HAVE_TP ? GObject.registerClass( +class ChatSource extends MessageTray.Source { + _init(account, conn, channel, contact, client) { + this._account = account; + this._contact = contact; + this._client = client; + + super._init(contact.get_alias()); + + this.isChat = true; + this._pendingMessages = []; + + this._conn = conn; + this._channel = channel; + this._closedId = this._channel.connect('invalidated', this._channelClosed.bind(this)); + + this._notifyTimeoutId = 0; + + this._presence = contact.get_presence_type(); + + this._sentId = this._channel.connect('message-sent', this._messageSent.bind(this)); + this._receivedId = this._channel.connect('message-received', this._messageReceived.bind(this)); + this._pendingId = this._channel.connect('pending-message-removed', this._pendingRemoved.bind(this)); + + this._notifyAliasId = this._contact.connect('notify::alias', this._updateAlias.bind(this)); + this._notifyAvatarId = this._contact.connect('notify::avatar-file', this._updateAvatarIcon.bind(this)); + this._presenceChangedId = this._contact.connect('presence-changed', this._presenceChanged.bind(this)); + + // Add ourselves as a source. + Main.messageTray.add(this); + + this._getLogMessages(); + } + + _ensureNotification() { + if (this._notification) + return; + + this._notification = new ChatNotification(this); + this._notification.connect('activated', this.open.bind(this)); + this._notification.connect('updated', () => { + if (this._banner && this._banner.expanded) + this._ackMessages(); + }); + this._notification.connect('destroy', () => { + this._notification = null; + }); + this.pushNotification(this._notification); + } + + _createPolicy() { + if (this._account.protocol_name == 'irc') + return new MessageTray.NotificationApplicationPolicy('org.gnome.Polari'); + return new MessageTray.NotificationApplicationPolicy('empathy'); + } + + createBanner() { + this._banner = new ChatNotificationBanner(this._notification); + + // We ack messages when the user expands the new notification + let id = this._banner.connect('expanded', this._ackMessages.bind(this)); + this._banner.connect('destroy', () => { + this._banner.disconnect(id); + this._banner = null; + }); + + return this._banner; + } + + _updateAlias() { + let oldAlias = this.title; + let newAlias = this._contact.get_alias(); + + if (oldAlias == newAlias) + return; + + this.setTitle(newAlias); + if (this._notification) + this._notification.appendAliasChange(oldAlias, newAlias); + } + + getIcon() { + let file = this._contact.get_avatar_file(); + if (file) + return new Gio.FileIcon({ file }); + else + return new Gio.ThemedIcon({ name: 'avatar-default' }); + } + + getSecondaryIcon() { + let iconName; + let presenceType = this._contact.get_presence_type(); + + switch (presenceType) { + case Tp.ConnectionPresenceType.AVAILABLE: + iconName = 'user-available'; + break; + case Tp.ConnectionPresenceType.BUSY: + iconName = 'user-busy'; + break; + case Tp.ConnectionPresenceType.OFFLINE: + iconName = 'user-offline'; + break; + case Tp.ConnectionPresenceType.HIDDEN: + iconName = 'user-invisible'; + break; + case Tp.ConnectionPresenceType.AWAY: + iconName = 'user-away'; + break; + case Tp.ConnectionPresenceType.EXTENDED_AWAY: + iconName = 'user-idle'; + break; + default: + iconName = 'user-offline'; + } + return new Gio.ThemedIcon({ name: iconName }); + } + + _updateAvatarIcon() { + this.iconUpdated(); + if (this._notifiction) { + this._notification.update(this._notification.title, + this._notification.bannerBodyText, + { gicon: this.getIcon() }); + } + } + + open() { + Main.overview.hide(); + Main.panel.closeCalendar(); + + if (this._client.is_handling_channel(this._channel)) { + // We are handling the channel, try to pass it to Empathy or Polari + // (depending on the channel type) + // We don't check if either app is available - mission control will + // fallback to something else if activation fails + + let target; + if (this._channel.connection.protocol_name == 'irc') + target = 'org.freedesktop.Telepathy.Client.Polari'; + else + target = 'org.freedesktop.Telepathy.Client.Empathy.Chat'; + this._client.delegate_channels_async([this._channel], global.get_current_time(), target, null); + } else { + // We are not the handler, just ask to present the channel + let dbus = Tp.DBusDaemon.dup(); + let cd = Tp.ChannelDispatcher.new(dbus); + + cd.present_channel_async(this._channel, global.get_current_time(), null); + } + } + + async _getLogMessages() { + let logManager = Tpl.LogManager.dup_singleton(); + let entity = Tpl.Entity.new_from_tp_contact(this._contact, Tpl.EntityType.CONTACT); + + const [events] = await logManager.get_filtered_events_async( + this._account, entity, + Tpl.EventTypeMask.TEXT, SCROLLBACK_HISTORY_LINES, + null); + + let logMessages = events.map(e => ChatMessage.newFromTplTextEvent(e)); + this._ensureNotification(); + + let pendingTpMessages = this._channel.get_pending_messages(); + let pendingMessages = []; + + for (let i = 0; i < pendingTpMessages.length; i++) { + let message = pendingTpMessages[i]; + + if (message.get_message_type() == Tp.ChannelTextMessageType.DELIVERY_REPORT) + continue; + + pendingMessages.push(ChatMessage.newFromTpMessage(message, + NotificationDirection.RECEIVED)); + + this._pendingMessages.push(message); + } + + this.countUpdated(); + + let showTimestamp = false; + + for (let i = 0; i < logMessages.length; i++) { + let logMessage = logMessages[i]; + let isPending = false; + + // Skip any log messages that are also in pendingMessages + for (let j = 0; j < pendingMessages.length; j++) { + let pending = pendingMessages[j]; + if (logMessage.timestamp == pending.timestamp && logMessage.text == pending.text) { + isPending = true; + break; + } + } + + if (!isPending) { + showTimestamp = true; + this._notification.appendMessage(logMessage, true, ['chat-log-message']); + } + } + + if (showTimestamp) + this._notification.appendTimestamp(); + + for (let i = 0; i < pendingMessages.length; i++) + this._notification.appendMessage(pendingMessages[i], true); + + if (pendingMessages.length > 0) + this.showNotification(); + } + + destroy(reason) { + if (this._client.is_handling_channel(this._channel)) { + this._ackMessages(); + // The chat box has been destroyed so it can't + // handle the channel any more. + this._channel.close_async(); + } else { + // Don't indicate any unread messages when the notification + // that represents them has been destroyed. + this._pendingMessages = []; + this.countUpdated(); + } + + // Keep source alive while the channel is open + if (reason != MessageTray.NotificationDestroyedReason.SOURCE_CLOSED) + return; + + if (this._destroyed) + return; + + this._destroyed = true; + this._channel.disconnect(this._closedId); + this._channel.disconnect(this._receivedId); + this._channel.disconnect(this._pendingId); + this._channel.disconnect(this._sentId); + + this._contact.disconnect(this._notifyAliasId); + this._contact.disconnect(this._notifyAvatarId); + this._contact.disconnect(this._presenceChangedId); + + super.destroy(reason); + } + + _channelClosed() { + this.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); + } + + /* All messages are new messages for Telepathy sources */ + get count() { + return this._pendingMessages.length; + } + + get unseenCount() { + return this.count; + } + + get countVisible() { + return this.count > 0; + } + + _messageReceived(channel, message) { + if (message.get_message_type() == Tp.ChannelTextMessageType.DELIVERY_REPORT) + return; + + this._ensureNotification(); + this._pendingMessages.push(message); + this.countUpdated(); + + message = ChatMessage.newFromTpMessage(message, + NotificationDirection.RECEIVED); + this._notification.appendMessage(message); + + // Wait a bit before notifying for the received message, a handler + // could ack it in the meantime. + if (this._notifyTimeoutId != 0) + GLib.source_remove(this._notifyTimeoutId); + this._notifyTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, + this._notifyTimeout.bind(this)); + GLib.Source.set_name_by_id(this._notifyTimeoutId, '[gnome-shell] this._notifyTimeout'); + } + + _notifyTimeout() { + if (this._pendingMessages.length != 0) + this.showNotification(); + + this._notifyTimeoutId = 0; + + return GLib.SOURCE_REMOVE; + } + + // This is called for both messages we send from + // our client and other clients as well. + _messageSent(channel, message, _flags, _token) { + this._ensureNotification(); + message = ChatMessage.newFromTpMessage(message, + NotificationDirection.SENT); + this._notification.appendMessage(message); + } + + showNotification() { + super.showNotification(this._notification); + } + + respond(text) { + let type; + if (text.slice(0, 4) == '/me ') { + type = Tp.ChannelTextMessageType.ACTION; + text = text.slice(4); + } else { + type = Tp.ChannelTextMessageType.NORMAL; + } + + let msg = Tp.ClientMessage.new_text(type, text); + this._channel.send_message_async(msg, 0); + } + + setChatState(state) { + // We don't want to send COMPOSING every time a letter is typed into + // the entry. We send the state only when it changes. Telepathy/Empathy + // might change it behind our back if the user is using both + // gnome-shell's entry and the Empathy conversation window. We could + // keep track of it with the ChatStateChanged signal but it is good + // enough right now. + if (state != this._chatState) { + this._chatState = state; + this._channel.set_chat_state_async(state, null); + } + } + + _presenceChanged(_contact, _presence, _status, _message) { + if (this._notification) { + this._notification.update(this._notification.title, + this._notification.bannerBodyText, + { secondaryGIcon: this.getSecondaryIcon() }); + } + } + + _pendingRemoved(channel, message) { + let idx = this._pendingMessages.indexOf(message); + + if (idx >= 0) { + this._pendingMessages.splice(idx, 1); + this.countUpdated(); + } + + if (this._pendingMessages.length == 0 && + this._banner && !this._banner.expanded) + this._banner.hide(); + } + + _ackMessages() { + // Don't clear our messages here, tp-glib will send a + // 'pending-message-removed' for each one. + this._channel.ack_all_pending_messages_async(null); + } +}) : null; + +const ChatNotificationMessage = HAVE_TP ? GObject.registerClass( +class ChatNotificationMessage extends GObject.Object { + _init(props = {}) { + super._init(); + this.set(props); + } +}) : null; + +var ChatNotification = HAVE_TP ? GObject.registerClass({ + Signals: { + 'message-removed': { param_types: [ChatNotificationMessage.$gtype] }, + 'message-added': { param_types: [ChatNotificationMessage.$gtype] }, + 'timestamp-changed': { param_types: [ChatNotificationMessage.$gtype] }, + }, +}, class ChatNotification extends MessageTray.Notification { + _init(source) { + super._init(source, source.title, null, + { secondaryGIcon: source.getSecondaryIcon() }); + this.setUrgency(MessageTray.Urgency.HIGH); + this.setResident(true); + + this.messages = []; + this._timestampTimeoutId = 0; + } + + destroy(reason) { + if (this._timestampTimeoutId) + GLib.source_remove(this._timestampTimeoutId); + this._timestampTimeoutId = 0; + super.destroy(reason); + } + + /** + * appendMessage: + * @param {Object} message: An object with the properties + * {string} message.text: the body of the message, + * {Tp.ChannelTextMessageType} message.messageType: the type + * {string} message.sender: the name of the sender, + * {number} message.timestamp: the time the message was sent + * {NotificationDirection} message.direction: a #NotificationDirection + * + * @param {bool} noTimestamp: Whether to add a timestamp. If %true, + * no timestamp will be added, regardless of the difference since + * the last timestamp + */ + appendMessage(message, noTimestamp) { + let messageBody = GLib.markup_escape_text(message.text, -1); + let styles = [message.direction]; + + if (message.messageType == Tp.ChannelTextMessageType.ACTION) { + let senderAlias = GLib.markup_escape_text(message.sender, -1); + messageBody = '<i>%s</i> %s'.format(senderAlias, messageBody); + styles.push('chat-action'); + } + + if (message.direction == NotificationDirection.RECEIVED) { + this.update(this.source.title, messageBody, + { datetime: GLib.DateTime.new_from_unix_local(message.timestamp), + bannerMarkup: true }); + } + + let group = message.direction == NotificationDirection.RECEIVED + ? 'received' : 'sent'; + + this._append({ body: messageBody, + group, + styles, + timestamp: message.timestamp, + noTimestamp }); + } + + _filterMessages() { + if (this.messages.length < 1) + return; + + let lastMessageTime = this.messages[0].timestamp; + let currentTime = Date.now() / 1000; + + // Keep the scrollback from growing too long. If the most + // recent message (before the one we just added) is within + // SCROLLBACK_RECENT_TIME, we will keep + // SCROLLBACK_RECENT_LENGTH previous messages. Otherwise + // we'll keep SCROLLBACK_IDLE_LENGTH messages. + + let maxLength = lastMessageTime < currentTime - SCROLLBACK_RECENT_TIME + ? SCROLLBACK_IDLE_LENGTH : SCROLLBACK_RECENT_LENGTH; + + let filteredHistory = this.messages.filter(item => item.realMessage); + if (filteredHistory.length > maxLength) { + let lastMessageToKeep = filteredHistory[maxLength]; + let expired = this.messages.splice(this.messages.indexOf(lastMessageToKeep)); + for (let i = 0; i < expired.length; i++) + this.emit('message-removed', expired[i]); + } + } + + /** + * _append: + * @param {Object} props: An object with the properties: + * {string} props.body: The text of the message. + * {string} props.group: The group of the message, one of: + * 'received', 'sent', 'meta'. + * {string[]} props.styles: Style class names for the message to have. + * {number} props.timestamp: The timestamp of the message. + * {bool} props.noTimestamp: suppress timestamp signal? + */ + _append(props) { + let currentTime = Date.now() / 1000; + props = Params.parse(props, { body: null, + group: null, + styles: [], + timestamp: currentTime, + noTimestamp: false }); + const { noTimestamp } = props; + delete props.noTimestamp; + + // Reset the old message timeout + if (this._timestampTimeoutId) + GLib.source_remove(this._timestampTimeoutId); + this._timestampTimeoutId = 0; + + let message = new ChatNotificationMessage({ + realMessage: props.group !== 'meta', + showTimestamp: false, + ...props, + }); + + this.messages.unshift(message); + this.emit('message-added', message); + + if (!noTimestamp) { + let timestamp = props.timestamp; + if (timestamp < currentTime - SCROLLBACK_IMMEDIATE_TIME) { + this.appendTimestamp(); + } else { + // Schedule a new timestamp in SCROLLBACK_IMMEDIATE_TIME + // from the timestamp of the message. + this._timestampTimeoutId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, + SCROLLBACK_IMMEDIATE_TIME - (currentTime - timestamp), + this.appendTimestamp.bind(this)); + GLib.Source.set_name_by_id(this._timestampTimeoutId, '[gnome-shell] this.appendTimestamp'); + } + } + + this._filterMessages(); + } + + appendTimestamp() { + this._timestampTimeoutId = 0; + + this.messages[0].showTimestamp = true; + this.emit('timestamp-changed', this.messages[0]); + + this._filterMessages(); + + return GLib.SOURCE_REMOVE; + } + + appendAliasChange(oldAlias, newAlias) { + oldAlias = GLib.markup_escape_text(oldAlias, -1); + newAlias = GLib.markup_escape_text(newAlias, -1); + + /* Translators: this is the other person changing their old IM name to their new + IM name. */ + let message = '<i>' + _("%s is now known as %s").format(oldAlias, newAlias) + '</i>'; + + this._append({ body: message, + group: 'meta', + styles: ['chat-meta-message'] }); + + this._filterMessages(); + } +}) : null; + +var ChatLineBox = GObject.registerClass( +class ChatLineBox extends St.BoxLayout { + vfunc_get_preferred_height(forWidth) { + let [, natHeight] = super.vfunc_get_preferred_height(forWidth); + return [natHeight, natHeight]; + } +}); + +var ChatNotificationBanner = GObject.registerClass( +class ChatNotificationBanner extends MessageTray.NotificationBanner { + _init(notification) { + super._init(notification); + + this._responseEntry = new St.Entry({ style_class: 'chat-response', + x_expand: true, + can_focus: true }); + this._responseEntry.clutter_text.connect('activate', this._onEntryActivated.bind(this)); + this._responseEntry.clutter_text.connect('text-changed', this._onEntryChanged.bind(this)); + this.setActionArea(this._responseEntry); + + this._responseEntry.clutter_text.connect('key-focus-in', () => { + this.focused = true; + }); + this._responseEntry.clutter_text.connect('key-focus-out', () => { + this.focused = false; + this.emit('unfocused'); + }); + + this._scrollArea = new St.ScrollView({ style_class: 'chat-scrollview vfade', + vscrollbar_policy: St.PolicyType.AUTOMATIC, + hscrollbar_policy: St.PolicyType.NEVER, + visible: this.expanded }); + this._contentArea = new St.BoxLayout({ style_class: 'chat-body', + vertical: true }); + this._scrollArea.add_actor(this._contentArea); + + this.setExpandedBody(this._scrollArea); + this.setExpandedLines(CHAT_EXPAND_LINES); + + this._lastGroup = null; + + // Keep track of the bottom position for the current adjustment and + // force a scroll to the bottom if things change while we were at the + // bottom + this._oldMaxScrollValue = this._scrollArea.vscroll.adjustment.value; + this._scrollArea.vscroll.adjustment.connect('changed', adjustment => { + if (adjustment.value == this._oldMaxScrollValue) + this.scrollTo(St.Side.BOTTOM); + this._oldMaxScrollValue = Math.max(adjustment.lower, adjustment.upper - adjustment.page_size); + }); + + this._inputHistory = new History.HistoryManager({ entry: this._responseEntry.clutter_text }); + + this._composingTimeoutId = 0; + + this._messageActors = new Map(); + + this._messageAddedId = this.notification.connect('message-added', + (n, message) => { + this._addMessage(message); + }); + this._messageRemovedId = this.notification.connect('message-removed', + (n, message) => { + let actor = this._messageActors.get(message); + if (this._messageActors.delete(message)) + actor.destroy(); + }); + this._timestampChangedId = this.notification.connect('timestamp-changed', + (n, message) => { + this._updateTimestamp(message); + }); + + for (let i = this.notification.messages.length - 1; i >= 0; i--) + this._addMessage(this.notification.messages[i]); + } + + _onDestroy() { + super._onDestroy(); + this.notification.disconnect(this._messageAddedId); + this.notification.disconnect(this._messageRemovedId); + this.notification.disconnect(this._timestampChangedId); + } + + scrollTo(side) { + let adjustment = this._scrollArea.vscroll.adjustment; + if (side == St.Side.TOP) + adjustment.value = adjustment.lower; + else if (side == St.Side.BOTTOM) + adjustment.value = adjustment.upper; + } + + hide() { + this.emit('done-displaying'); + } + + _addMessage(message) { + let body = new MessageList.URLHighlighter(message.body, true, true); + + let styles = message.styles; + for (let i = 0; i < styles.length; i++) + body.add_style_class_name(styles[i]); + + let group = message.group; + if (group != this._lastGroup) { + this._lastGroup = group; + body.add_style_class_name('chat-new-group'); + } + + let lineBox = new ChatLineBox(); + lineBox.add(body); + this._contentArea.add_actor(lineBox); + this._messageActors.set(message, lineBox); + + this._updateTimestamp(message); + } + + _updateTimestamp(message) { + let actor = this._messageActors.get(message); + if (!actor) + return; + + while (actor.get_n_children() > 1) + actor.get_child_at_index(1).destroy(); + + if (message.showTimestamp) { + let lastMessageTime = message.timestamp; + let lastMessageDate = new Date(lastMessageTime * 1000); + + let timeLabel = Util.createTimeLabel(lastMessageDate); + timeLabel.style_class = 'chat-meta-message'; + timeLabel.x_expand = timeLabel.y_expand = true; + timeLabel.x_align = timeLabel.y_align = Clutter.ActorAlign.END; + + actor.add_actor(timeLabel); + } + } + + _onEntryActivated() { + let text = this._responseEntry.get_text(); + if (text == '') + return; + + this._inputHistory.addItem(text); + + // Telepathy sends out the Sent signal for us. + // see Source._messageSent + this._responseEntry.set_text(''); + this.notification.source.respond(text); + } + + _composingStopTimeout() { + this._composingTimeoutId = 0; + + this.notification.source.setChatState(Tp.ChannelChatState.PAUSED); + + return GLib.SOURCE_REMOVE; + } + + _onEntryChanged() { + let text = this._responseEntry.get_text(); + + // If we're typing, we want to send COMPOSING. + // If we empty the entry, we want to send ACTIVE. + // If we've stopped typing for COMPOSING_STOP_TIMEOUT + // seconds, we want to send PAUSED. + + // Remove composing timeout. + if (this._composingTimeoutId > 0) { + GLib.source_remove(this._composingTimeoutId); + this._composingTimeoutId = 0; + } + + if (text != '') { + this.notification.source.setChatState(Tp.ChannelChatState.COMPOSING); + + this._composingTimeoutId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, + COMPOSING_STOP_TIMEOUT, + this._composingStopTimeout.bind(this)); + GLib.Source.set_name_by_id(this._composingTimeoutId, '[gnome-shell] this._composingStopTimeout'); + } else { + this.notification.source.setChatState(Tp.ChannelChatState.ACTIVE); + } + } +}); + +var Component = TelepathyComponent; diff --git a/js/ui/ctrlAltTab.js b/js/ui/ctrlAltTab.js new file mode 100644 index 0000000..fbc7a0f --- /dev/null +++ b/js/ui/ctrlAltTab.js @@ -0,0 +1,192 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported CtrlAltTabManager */ + +const { Clutter, GObject, Meta, Shell, St } = imports.gi; + +const Main = imports.ui.main; +const SwitcherPopup = imports.ui.switcherPopup; +const Params = imports.misc.params; + +var POPUP_APPICON_SIZE = 96; + +var SortGroup = { + TOP: 0, + MIDDLE: 1, + BOTTOM: 2, +}; + +var CtrlAltTabManager = class CtrlAltTabManager { + constructor() { + this._items = []; + this.addGroup(global.window_group, _("Windows"), + 'focus-windows-symbolic', { sortGroup: SortGroup.TOP, + focusCallback: this._focusWindows.bind(this) }); + } + + addGroup(root, name, icon, params) { + let item = Params.parse(params, { sortGroup: SortGroup.MIDDLE, + proxy: root, + focusCallback: null }); + + item.root = root; + item.name = name; + item.iconName = icon; + + this._items.push(item); + root.connect('destroy', () => this.removeGroup(root)); + if (root instanceof St.Widget) + global.focus_manager.add_group(root); + } + + removeGroup(root) { + if (root instanceof St.Widget) + global.focus_manager.remove_group(root); + for (let i = 0; i < this._items.length; i++) { + if (this._items[i].root == root) { + this._items.splice(i, 1); + return; + } + } + } + + focusGroup(item, timestamp) { + if (item.focusCallback) + item.focusCallback(timestamp); + else + item.root.navigate_focus(null, St.DirectionType.TAB_FORWARD, false); + } + + // Sort the items into a consistent order; panel first, tray last, + // and everything else in between, sorted by X coordinate, so that + // they will have the same left-to-right ordering in the + // Ctrl-Alt-Tab dialog as they do onscreen. + _sortItems(a, b) { + if (a.sortGroup != b.sortGroup) + return a.sortGroup - b.sortGroup; + + let [ax] = a.proxy.get_transformed_position(); + let [bx] = b.proxy.get_transformed_position(); + + return ax - bx; + } + + popup(backward, binding, mask) { + // Start with the set of focus groups that are currently mapped + let items = this._items.filter(item => item.proxy.mapped); + + // And add the windows metacity would show in its Ctrl-Alt-Tab list + if (Main.sessionMode.hasWindows && !Main.overview.visible) { + let display = global.display; + let workspaceManager = global.workspace_manager; + let activeWorkspace = workspaceManager.get_active_workspace(); + let windows = display.get_tab_list(Meta.TabList.DOCKS, + activeWorkspace); + let windowTracker = Shell.WindowTracker.get_default(); + let textureCache = St.TextureCache.get_default(); + for (let i = 0; i < windows.length; i++) { + let icon = null; + let iconName = null; + if (windows[i].get_window_type() == Meta.WindowType.DESKTOP) { + iconName = 'video-display-symbolic'; + } else { + let app = windowTracker.get_window_app(windows[i]); + if (app) { + icon = app.create_icon_texture(POPUP_APPICON_SIZE); + } else { + icon = new St.Icon({ + gicon: textureCache.bind_cairo_surface_property(windows[i], 'icon'), + icon_size: POPUP_APPICON_SIZE, + }); + } + } + + items.push({ name: windows[i].title, + proxy: windows[i].get_compositor_private(), + focusCallback: timestamp => { + Main.activateWindow(windows[i], timestamp); + }, + iconActor: icon, + iconName, + sortGroup: SortGroup.MIDDLE }); + } + } + + if (!items.length) + return; + + items.sort(this._sortItems.bind(this)); + + if (!this._popup) { + this._popup = new CtrlAltTabPopup(items); + this._popup.show(backward, binding, mask); + + this._popup.connect('destroy', + () => { + this._popup = null; + }); + } + } + + _focusWindows(timestamp) { + global.display.focus_default_window(timestamp); + } +}; + +var CtrlAltTabPopup = GObject.registerClass( +class CtrlAltTabPopup extends SwitcherPopup.SwitcherPopup { + _init(items) { + super._init(items); + + this._switcherList = new CtrlAltTabSwitcher(this._items); + } + + _keyPressHandler(keysym, action) { + if (action == Meta.KeyBindingAction.SWITCH_PANELS) + this._select(this._next()); + else if (action == Meta.KeyBindingAction.SWITCH_PANELS_BACKWARD) + this._select(this._previous()); + else if (keysym == Clutter.KEY_Left) + this._select(this._previous()); + else if (keysym == Clutter.KEY_Right) + this._select(this._next()); + else + return Clutter.EVENT_PROPAGATE; + + return Clutter.EVENT_STOP; + } + + _finish(time) { + super._finish(time); + Main.ctrlAltTabManager.focusGroup(this._items[this._selectedIndex], time); + } +}); + +var CtrlAltTabSwitcher = GObject.registerClass( +class CtrlAltTabSwitcher extends SwitcherPopup.SwitcherList { + _init(items) { + super._init(true); + + for (let i = 0; i < items.length; i++) + this._addIcon(items[i]); + } + + _addIcon(item) { + let box = new St.BoxLayout({ style_class: 'alt-tab-app', + vertical: true }); + + let icon = item.iconActor; + if (!icon) { + icon = new St.Icon({ icon_name: item.iconName, + icon_size: POPUP_APPICON_SIZE }); + } + box.add_child(icon); + + let text = new St.Label({ + text: item.name, + x_align: Clutter.ActorAlign.CENTER, + }); + box.add_child(text); + + this.addItem(box, text); + } +}); diff --git a/js/ui/dash.js b/js/ui/dash.js new file mode 100644 index 0000000..7e4457e --- /dev/null +++ b/js/ui/dash.js @@ -0,0 +1,914 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Dash */ + +const { Clutter, GLib, GObject, + Graphene, Meta, Shell, St } = imports.gi; + +const AppDisplay = imports.ui.appDisplay; +const AppFavorites = imports.ui.appFavorites; +const DND = imports.ui.dnd; +const IconGrid = imports.ui.iconGrid; +const Main = imports.ui.main; + +var DASH_ANIMATION_TIME = 200; +var DASH_ITEM_LABEL_SHOW_TIME = 150; +var DASH_ITEM_LABEL_HIDE_TIME = 100; +var DASH_ITEM_HOVER_TIMEOUT = 300; + +function getAppFromSource(source) { + if (source instanceof AppDisplay.AppIcon) + return source.app; + else + return null; +} + +var DashIcon = GObject.registerClass( +class DashIcon extends AppDisplay.AppIcon { + _init(app) { + super._init(app, { + setSizeManually: true, + showLabel: false, + }); + } + + // Disable scale-n-fade methods used during DND by parent + scaleAndFade() { + } + + undoScaleAndFade() { + } + + handleDragOver() { + return DND.DragMotionResult.CONTINUE; + } + + acceptDrop() { + return false; + } +}); + +// A container like StBin, but taking the child's scale into account +// when requesting a size +var DashItemContainer = GObject.registerClass( +class DashItemContainer extends St.Widget { + _init() { + super._init({ style_class: 'dash-item-container', + pivot_point: new Graphene.Point({ x: .5, y: .5 }), + scale_x: 0, + scale_y: 0, + opacity: 0, + x_expand: true, + x_align: Clutter.ActorAlign.CENTER }); + + this._labelText = ""; + this.label = new St.Label({ style_class: 'dash-label' }); + this.label.hide(); + Main.layoutManager.addChrome(this.label); + this.label_actor = this.label; + + this.child = null; + this.animatingOut = false; + + this.connect('notify::scale-x', () => this.queue_relayout()); + this.connect('notify::scale-y', () => this.queue_relayout()); + + this.connect('destroy', () => { + if (this.child != null) + this.child.destroy(); + this.label.destroy(); + }); + } + + vfunc_get_preferred_height(forWidth) { + let themeNode = this.get_theme_node(); + forWidth = themeNode.adjust_for_width(forWidth); + let [minHeight, natHeight] = super.vfunc_get_preferred_height(forWidth); + return themeNode.adjust_preferred_height(minHeight * this.scale_y, + natHeight * this.scale_y); + } + + vfunc_get_preferred_width(forHeight) { + let themeNode = this.get_theme_node(); + forHeight = themeNode.adjust_for_height(forHeight); + let [minWidth, natWidth] = super.vfunc_get_preferred_width(forHeight); + return themeNode.adjust_preferred_width(minWidth * this.scale_x, + natWidth * this.scale_x); + } + + showLabel() { + if (!this._labelText) + return; + + this.label.set_text(this._labelText); + this.label.opacity = 0; + this.label.show(); + + let [stageX, stageY] = this.get_transformed_position(); + + let itemHeight = this.allocation.y2 - this.allocation.y1; + + let labelHeight = this.label.get_height(); + let yOffset = Math.floor((itemHeight - labelHeight) / 2); + + let y = stageY + yOffset; + + let node = this.label.get_theme_node(); + let xOffset = node.get_length('-x-offset'); + + let x; + if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) + x = stageX - this.label.get_width() - xOffset; + else + x = stageX + this.get_width() + xOffset; + + this.label.set_position(x, y); + this.label.ease({ + opacity: 255, + duration: DASH_ITEM_LABEL_SHOW_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + setLabelText(text) { + this._labelText = text; + this.child.accessible_name = text; + } + + hideLabel() { + this.label.ease({ + opacity: 0, + duration: DASH_ITEM_LABEL_HIDE_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this.label.hide(), + }); + } + + setChild(actor) { + if (this.child == actor) + return; + + this.destroy_all_children(); + + this.child = actor; + this.add_actor(this.child); + } + + show(animate) { + if (this.child == null) + return; + + let time = animate ? DASH_ANIMATION_TIME : 0; + this.ease({ + scale_x: 1, + scale_y: 1, + opacity: 255, + duration: time, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + animateOutAndDestroy() { + this.label.hide(); + + if (this.child == null) { + this.destroy(); + return; + } + + this.animatingOut = true; + this.ease({ + scale_x: 0, + scale_y: 0, + opacity: 0, + duration: DASH_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this.destroy(), + }); + } +}); + +var ShowAppsIcon = GObject.registerClass( +class ShowAppsIcon extends DashItemContainer { + _init() { + super._init(); + + this.toggleButton = new St.Button({ style_class: 'show-apps', + track_hover: true, + can_focus: true, + toggle_mode: true }); + this._iconActor = null; + this.icon = new IconGrid.BaseIcon(_("Show Applications"), + { setSizeManually: true, + showLabel: false, + createIcon: this._createIcon.bind(this) }); + this.toggleButton.add_actor(this.icon); + this.toggleButton._delegate = this; + + this.setChild(this.toggleButton); + this.setDragApp(null); + } + + _createIcon(size) { + this._iconActor = new St.Icon({ icon_name: 'view-app-grid-symbolic', + icon_size: size, + style_class: 'show-apps-icon', + track_hover: true }); + return this._iconActor; + } + + _canRemoveApp(app) { + if (app == null) + return false; + + if (!global.settings.is_writable('favorite-apps')) + return false; + + let id = app.get_id(); + let isFavorite = AppFavorites.getAppFavorites().isFavorite(id); + return isFavorite; + } + + setDragApp(app) { + let canRemove = this._canRemoveApp(app); + + this.toggleButton.set_hover(canRemove); + if (this._iconActor) + this._iconActor.set_hover(canRemove); + + if (canRemove) + this.setLabelText(_("Remove from Favorites")); + else + this.setLabelText(_("Show Applications")); + } + + handleDragOver(source, _actor, _x, _y, _time) { + if (!this._canRemoveApp(getAppFromSource(source))) + return DND.DragMotionResult.NO_DROP; + + return DND.DragMotionResult.MOVE_DROP; + } + + acceptDrop(source, _actor, _x, _y, _time) { + let app = getAppFromSource(source); + if (!this._canRemoveApp(app)) + return false; + + let id = app.get_id(); + + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + AppFavorites.getAppFavorites().removeFavorite(id); + return false; + }); + + return true; + } +}); + +var DragPlaceholderItem = GObject.registerClass( +class DragPlaceholderItem extends DashItemContainer { + _init() { + super._init(); + this.setChild(new St.Bin({ style_class: 'placeholder' })); + } +}); + +var EmptyDropTargetItem = GObject.registerClass( +class EmptyDropTargetItem extends DashItemContainer { + _init() { + super._init(); + this.setChild(new St.Bin({ style_class: 'empty-dash-drop-target' })); + } +}); + +var DashActor = GObject.registerClass( +class DashActor extends St.Widget { + _init() { + let layout = new Clutter.BoxLayout({ orientation: Clutter.Orientation.VERTICAL }); + super._init({ + name: 'dash', + layout_manager: layout, + clip_to_allocation: true, + y_align: Clutter.ActorAlign.CENTER, + }); + } + + vfunc_allocate(box) { + let contentBox = this.get_theme_node().get_content_box(box); + let availWidth = contentBox.x2 - contentBox.x1; + + this.set_allocation(box); + + let [appIcons, showAppsButton] = this.get_children(); + let [, showAppsNatHeight] = showAppsButton.get_preferred_height(availWidth); + + let childBox = new Clutter.ActorBox(); + childBox.x1 = contentBox.x1; + childBox.y1 = contentBox.y1; + childBox.x2 = contentBox.x2; + childBox.y2 = contentBox.y2 - showAppsNatHeight; + appIcons.allocate(childBox); + + childBox.y1 = contentBox.y2 - showAppsNatHeight; + childBox.y2 = contentBox.y2; + showAppsButton.allocate(childBox); + } + + vfunc_get_preferred_height(forWidth) { + // We want to request the natural height of all our children + // as our natural height, so we chain up to StWidget (which + // then calls BoxLayout), but we only request the showApps + // button as the minimum size + + let [, natHeight] = super.vfunc_get_preferred_height(forWidth); + + let themeNode = this.get_theme_node(); + let adjustedForWidth = themeNode.adjust_for_width(forWidth); + let [, showAppsButton] = this.get_children(); + let [minHeight] = showAppsButton.get_preferred_height(adjustedForWidth); + [minHeight] = themeNode.adjust_preferred_height(minHeight, natHeight); + + return [minHeight, natHeight]; + } +}); + +const baseIconSizes = [16, 22, 24, 32, 48, 64]; + +var Dash = GObject.registerClass({ + Signals: { 'icon-size-changed': {} }, +}, class Dash extends St.Bin { + _init() { + this._maxHeight = -1; + this.iconSize = 64; + this._shownInitially = false; + + this._dragPlaceholder = null; + this._dragPlaceholderPos = -1; + this._animatingPlaceholdersCount = 0; + this._showLabelTimeoutId = 0; + this._resetHoverTimeoutId = 0; + this._labelShowing = false; + + this._container = new DashActor(); + this._box = new St.BoxLayout({ vertical: true, + clip_to_allocation: true }); + this._box._delegate = this; + this._container.add_actor(this._box); + this._container.set_offscreen_redirect(Clutter.OffscreenRedirect.ALWAYS); + + this._showAppsIcon = new ShowAppsIcon(); + this._showAppsIcon.show(false); + this._showAppsIcon.icon.setIconSize(this.iconSize); + this._hookUpLabel(this._showAppsIcon); + + this.showAppsButton = this._showAppsIcon.toggleButton; + + this._container.add_actor(this._showAppsIcon); + + super._init({ child: this._container }); + this.connect('notify::height', () => { + if (this._maxHeight != this.height) + this._queueRedisplay(); + this._maxHeight = this.height; + }); + + this._workId = Main.initializeDeferredWork(this._box, this._redisplay.bind(this)); + + this._appSystem = Shell.AppSystem.get_default(); + + this._appSystem.connect('installed-changed', () => { + AppFavorites.getAppFavorites().reload(); + this._queueRedisplay(); + }); + AppFavorites.getAppFavorites().connect('changed', this._queueRedisplay.bind(this)); + this._appSystem.connect('app-state-changed', this._queueRedisplay.bind(this)); + + Main.overview.connect('item-drag-begin', + this._onDragBegin.bind(this)); + Main.overview.connect('item-drag-end', + this._onDragEnd.bind(this)); + Main.overview.connect('item-drag-cancelled', + this._onDragCancelled.bind(this)); + + // Translators: this is the name of the dock/favorites area on + // the left of the overview + Main.ctrlAltTabManager.addGroup(this, _("Dash"), 'user-bookmarks-symbolic'); + } + + _onDragBegin() { + this._dragCancelled = false; + this._dragMonitor = { + dragMotion: this._onDragMotion.bind(this), + }; + DND.addDragMonitor(this._dragMonitor); + + if (this._box.get_n_children() == 0) { + this._emptyDropTarget = new EmptyDropTargetItem(); + this._box.insert_child_at_index(this._emptyDropTarget, 0); + this._emptyDropTarget.show(true); + } + } + + _onDragCancelled() { + this._dragCancelled = true; + this._endDrag(); + } + + _onDragEnd() { + if (this._dragCancelled) + return; + + this._endDrag(); + } + + _endDrag() { + this._clearDragPlaceholder(); + this._clearEmptyDropTarget(); + this._showAppsIcon.setDragApp(null); + DND.removeDragMonitor(this._dragMonitor); + } + + _onDragMotion(dragEvent) { + let app = getAppFromSource(dragEvent.source); + if (app == null) + return DND.DragMotionResult.CONTINUE; + + let showAppsHovered = + this._showAppsIcon.contains(dragEvent.targetActor); + + if (!this._box.contains(dragEvent.targetActor) || showAppsHovered) + this._clearDragPlaceholder(); + + if (showAppsHovered) + this._showAppsIcon.setDragApp(app); + else + this._showAppsIcon.setDragApp(null); + + return DND.DragMotionResult.CONTINUE; + } + + _appIdListToHash(apps) { + let ids = {}; + for (let i = 0; i < apps.length; i++) + ids[apps[i].get_id()] = apps[i]; + return ids; + } + + _queueRedisplay() { + Main.queueDeferredWork(this._workId); + } + + _hookUpLabel(item, appIcon) { + item.child.connect('notify::hover', () => { + this._syncLabel(item, appIcon); + }); + + item.child.connect('clicked', () => { + this._labelShowing = false; + item.hideLabel(); + }); + + let id = Main.overview.connect('hiding', () => { + this._labelShowing = false; + item.hideLabel(); + }); + item.child.connect('destroy', () => { + Main.overview.disconnect(id); + }); + + if (appIcon) { + appIcon.connect('sync-tooltip', () => { + this._syncLabel(item, appIcon); + }); + } + } + + _createAppItem(app) { + let appIcon = new DashIcon(app); + + appIcon.connect('menu-state-changed', + (o, opened) => { + this._itemMenuStateChanged(item, opened); + }); + + let item = new DashItemContainer(); + item.setChild(appIcon); + + // Override default AppIcon label_actor, now the + // accessible_name is set at DashItemContainer.setLabelText + appIcon.label_actor = null; + item.setLabelText(app.get_name()); + + appIcon.icon.setIconSize(this.iconSize); + this._hookUpLabel(item, appIcon); + + return item; + } + + _itemMenuStateChanged(item, opened) { + // When the menu closes, it calls sync_hover, which means + // that the notify::hover handler does everything we need to. + if (opened) { + if (this._showLabelTimeoutId > 0) { + GLib.source_remove(this._showLabelTimeoutId); + this._showLabelTimeoutId = 0; + } + + item.hideLabel(); + } + } + + _syncLabel(item, appIcon) { + let shouldShow = appIcon ? appIcon.shouldShowTooltip() : item.child.get_hover(); + + if (shouldShow) { + if (this._showLabelTimeoutId == 0) { + let timeout = this._labelShowing ? 0 : DASH_ITEM_HOVER_TIMEOUT; + this._showLabelTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, timeout, + () => { + this._labelShowing = true; + item.showLabel(); + this._showLabelTimeoutId = 0; + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._showLabelTimeoutId, '[gnome-shell] item.showLabel'); + if (this._resetHoverTimeoutId > 0) { + GLib.source_remove(this._resetHoverTimeoutId); + this._resetHoverTimeoutId = 0; + } + } + } else { + if (this._showLabelTimeoutId > 0) + GLib.source_remove(this._showLabelTimeoutId); + this._showLabelTimeoutId = 0; + item.hideLabel(); + if (this._labelShowing) { + this._resetHoverTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, DASH_ITEM_HOVER_TIMEOUT, + () => { + this._labelShowing = false; + this._resetHoverTimeoutId = 0; + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._resetHoverTimeoutId, '[gnome-shell] this._labelShowing'); + } + } + } + + _adjustIconSize() { + // For the icon size, we only consider children which are "proper" + // icons (i.e. ignoring drag placeholders) and which are not + // animating out (which means they will be destroyed at the end of + // the animation) + let iconChildren = this._box.get_children().filter(actor => { + return actor.child && + actor.child._delegate && + actor.child._delegate.icon && + !actor.animatingOut; + }); + + iconChildren.push(this._showAppsIcon); + + if (this._maxHeight == -1) + return; + + let themeNode = this._container.get_theme_node(); + let maxAllocation = new Clutter.ActorBox({ x1: 0, y1: 0, + x2: 42 /* whatever */, + y2: this._maxHeight }); + let maxContent = themeNode.get_content_box(maxAllocation); + let availHeight = maxContent.y2 - maxContent.y1; + let spacing = themeNode.get_length('spacing'); + + let firstButton = iconChildren[0].child; + let firstIcon = firstButton._delegate.icon; + + // Enforce valid spacings during the size request + firstIcon.icon.ensure_style(); + let [, iconHeight] = firstIcon.icon.get_preferred_height(-1); + let [, buttonHeight] = firstButton.get_preferred_height(-1); + + // Subtract icon padding and box spacing from the available height + availHeight -= iconChildren.length * (buttonHeight - iconHeight) + + (iconChildren.length - 1) * spacing; + + let availSize = availHeight / iconChildren.length; + + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + let iconSizes = baseIconSizes.map(s => s * scaleFactor); + + let newIconSize = baseIconSizes[0]; + for (let i = 0; i < iconSizes.length; i++) { + if (iconSizes[i] < availSize) + newIconSize = baseIconSizes[i]; + } + + if (newIconSize == this.iconSize) + return; + + let oldIconSize = this.iconSize; + this.iconSize = newIconSize; + this.emit('icon-size-changed'); + + let scale = oldIconSize / newIconSize; + for (let i = 0; i < iconChildren.length; i++) { + let icon = iconChildren[i].child._delegate.icon; + + // Set the new size immediately, to keep the icons' sizes + // in sync with this.iconSize + icon.setIconSize(this.iconSize); + + // Don't animate the icon size change when the overview + // is transitioning, not visible or when initially filling + // the dash + if (!Main.overview.visible || Main.overview.animationInProgress || + !this._shownInitially) + continue; + + let [targetWidth, targetHeight] = icon.icon.get_size(); + + // Scale the icon's texture to the previous size and + // tween to the new size + icon.icon.set_size(icon.icon.width * scale, + icon.icon.height * scale); + + icon.icon.ease({ + width: targetWidth, + height: targetHeight, + duration: DASH_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + } + + _redisplay() { + let favorites = AppFavorites.getAppFavorites().getFavoriteMap(); + + let running = this._appSystem.get_running(); + + let children = this._box.get_children().filter(actor => { + return actor.child && + actor.child._delegate && + actor.child._delegate.app; + }); + // Apps currently in the dash + let oldApps = children.map(actor => actor.child._delegate.app); + // Apps supposed to be in the dash + let newApps = []; + + for (let id in favorites) + newApps.push(favorites[id]); + + for (let i = 0; i < running.length; i++) { + let app = running[i]; + if (app.get_id() in favorites) + continue; + newApps.push(app); + } + + // Figure out the actual changes to the list of items; we iterate + // over both the list of items currently in the dash and the list + // of items expected there, and collect additions and removals. + // Moves are both an addition and a removal, where the order of + // the operations depends on whether we encounter the position + // where the item has been added first or the one from where it + // was removed. + // There is an assumption that only one item is moved at a given + // time; when moving several items at once, everything will still + // end up at the right position, but there might be additional + // additions/removals (e.g. it might remove all the launchers + // and add them back in the new order even if a smaller set of + // additions and removals is possible). + // If above assumptions turns out to be a problem, we might need + // to use a more sophisticated algorithm, e.g. Longest Common + // Subsequence as used by diff. + let addedItems = []; + let removedActors = []; + + let newIndex = 0; + let oldIndex = 0; + while (newIndex < newApps.length || oldIndex < oldApps.length) { + let oldApp = oldApps.length > oldIndex ? oldApps[oldIndex] : null; + let newApp = newApps.length > newIndex ? newApps[newIndex] : null; + + // No change at oldIndex/newIndex + if (oldApp == newApp) { + oldIndex++; + newIndex++; + continue; + } + + // App removed at oldIndex + if (oldApp && !newApps.includes(oldApp)) { + removedActors.push(children[oldIndex]); + oldIndex++; + continue; + } + + // App added at newIndex + if (newApp && !oldApps.includes(newApp)) { + addedItems.push({ app: newApp, + item: this._createAppItem(newApp), + pos: newIndex }); + newIndex++; + continue; + } + + // App moved + let nextApp = newApps.length > newIndex + 1 + ? newApps[newIndex + 1] : null; + let insertHere = nextApp && nextApp == oldApp; + let alreadyRemoved = removedActors.reduce((result, actor) => { + let removedApp = actor.child._delegate.app; + return result || removedApp == newApp; + }, false); + + if (insertHere || alreadyRemoved) { + let newItem = this._createAppItem(newApp); + addedItems.push({ app: newApp, + item: newItem, + pos: newIndex + removedActors.length }); + newIndex++; + } else { + removedActors.push(children[oldIndex]); + oldIndex++; + } + } + + for (let i = 0; i < addedItems.length; i++) { + this._box.insert_child_at_index(addedItems[i].item, + addedItems[i].pos); + } + + for (let i = 0; i < removedActors.length; i++) { + let item = removedActors[i]; + + // Don't animate item removal when the overview is transitioning + // or hidden + if (Main.overview.visible && !Main.overview.animationInProgress) + item.animateOutAndDestroy(); + else + item.destroy(); + } + + this._adjustIconSize(); + + // Skip animations on first run when adding the initial set + // of items, to avoid all items zooming in at once + + let animate = this._shownInitially && Main.overview.visible && + !Main.overview.animationInProgress; + + if (!this._shownInitially) + this._shownInitially = true; + + for (let i = 0; i < addedItems.length; i++) + addedItems[i].item.show(animate); + + // Workaround for https://bugzilla.gnome.org/show_bug.cgi?id=692744 + // Without it, StBoxLayout may use a stale size cache + this._box.queue_relayout(); + } + + _clearDragPlaceholder() { + if (this._dragPlaceholder) { + this._animatingPlaceholdersCount++; + this._dragPlaceholder.animateOutAndDestroy(); + this._dragPlaceholder.connect('destroy', () => { + this._animatingPlaceholdersCount--; + }); + this._dragPlaceholder = null; + } + this._dragPlaceholderPos = -1; + } + + _clearEmptyDropTarget() { + if (this._emptyDropTarget) { + this._emptyDropTarget.animateOutAndDestroy(); + this._emptyDropTarget = null; + } + } + + handleDragOver(source, actor, x, y, _time) { + let app = getAppFromSource(source); + + // Don't allow favoriting of transient apps + if (app == null || app.is_window_backed()) + return DND.DragMotionResult.NO_DROP; + + if (!global.settings.is_writable('favorite-apps')) + return DND.DragMotionResult.NO_DROP; + + let favorites = AppFavorites.getAppFavorites().getFavorites(); + let numFavorites = favorites.length; + + let favPos = favorites.indexOf(app); + + let children = this._box.get_children(); + let numChildren = children.length; + let boxHeight = this._box.height; + + // Keep the placeholder out of the index calculation; assuming that + // the remove target has the same size as "normal" items, we don't + // need to do the same adjustment there. + if (this._dragPlaceholder) { + boxHeight -= this._dragPlaceholder.height; + numChildren--; + } + + let pos; + if (!this._emptyDropTarget) + pos = Math.floor(y * numChildren / boxHeight); + else + pos = 0; // always insert at the top when dash is empty + + // Put the placeholder after the last favorite if we are not + // in the favorites zone + if (pos > numFavorites) + pos = numFavorites; + + if (pos !== this._dragPlaceholderPos && this._animatingPlaceholdersCount === 0) { + this._dragPlaceholderPos = pos; + + // Don't allow positioning before or after self + if (favPos != -1 && (pos == favPos || pos == favPos + 1)) { + this._clearDragPlaceholder(); + return DND.DragMotionResult.CONTINUE; + } + + // If the placeholder already exists, we just move + // it, but if we are adding it, expand its size in + // an animation + let fadeIn; + if (this._dragPlaceholder) { + this._dragPlaceholder.destroy(); + fadeIn = false; + } else { + fadeIn = true; + } + + this._dragPlaceholder = new DragPlaceholderItem(); + this._dragPlaceholder.child.set_width(this.iconSize); + this._dragPlaceholder.child.set_height(this.iconSize / 2); + this._box.insert_child_at_index(this._dragPlaceholder, + this._dragPlaceholderPos); + this._dragPlaceholder.show(fadeIn); + } + + if (!this._dragPlaceholder) + return DND.DragMotionResult.NO_DROP; + + let srcIsFavorite = favPos != -1; + + if (srcIsFavorite) + return DND.DragMotionResult.MOVE_DROP; + + return DND.DragMotionResult.COPY_DROP; + } + + // Draggable target interface + acceptDrop(source, _actor, _x, _y, _time) { + let app = getAppFromSource(source); + + // Don't allow favoriting of transient apps + if (app == null || app.is_window_backed()) + return false; + + if (!global.settings.is_writable('favorite-apps')) + return false; + + let id = app.get_id(); + + let favorites = AppFavorites.getAppFavorites().getFavoriteMap(); + + let srcIsFavorite = id in favorites; + + let favPos = 0; + let children = this._box.get_children(); + for (let i = 0; i < this._dragPlaceholderPos; i++) { + if (this._dragPlaceholder && + children[i] == this._dragPlaceholder) + continue; + + let childId = children[i].child._delegate.app.get_id(); + if (childId == id) + continue; + if (childId in favorites) + favPos++; + } + + // No drag placeholder means we don't want to favorite the app + // and we are dragging it to its original position + if (!this._dragPlaceholder) + return true; + + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + let appFavorites = AppFavorites.getAppFavorites(); + if (srcIsFavorite) + appFavorites.moveFavoriteToPos(id, favPos); + else + appFavorites.addFavoriteAtPos(id, favPos); + return false; + }); + + return true; + } +}); diff --git a/js/ui/dateMenu.js b/js/ui/dateMenu.js new file mode 100644 index 0000000..33c054c --- /dev/null +++ b/js/ui/dateMenu.js @@ -0,0 +1,923 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported DateMenuButton */ + +const { Clutter, Gio, GLib, GnomeDesktop, + GObject, GWeather, Pango, Shell, St } = imports.gi; + +const Util = imports.misc.util; +const Main = imports.ui.main; +const PanelMenu = imports.ui.panelMenu; +const Calendar = imports.ui.calendar; +const Weather = imports.misc.weather; +const System = imports.system; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const NC_ = (context, str) => '%s\u0004%s'.format(context, str); +const T_ = Shell.util_translate_time_string; + +const MAX_FORECASTS = 5; +const ELLIPSIS_CHAR = '\u2026'; + +const ClocksIntegrationIface = loadInterfaceXML('org.gnome.Shell.ClocksIntegration'); +const ClocksProxy = Gio.DBusProxy.makeProxyWrapper(ClocksIntegrationIface); + +function _isToday(date) { + let now = new Date(); + return now.getYear() == date.getYear() && + now.getMonth() == date.getMonth() && + now.getDate() == date.getDate(); +} + +function _gDateTimeToDate(datetime) { + return new Date(datetime.to_unix() * 1000 + datetime.get_microsecond() / 1000); +} + +var TodayButton = GObject.registerClass( +class TodayButton extends St.Button { + _init(calendar) { + // Having the ability to go to the current date if the user is already + // on the current date can be confusing. So don't make the button reactive + // until the selected date changes. + super._init({ + style_class: 'datemenu-today-button', + x_expand: true, + can_focus: true, + reactive: false, + }); + + let hbox = new St.BoxLayout({ vertical: true }); + this.add_actor(hbox); + + this._dayLabel = new St.Label({ style_class: 'day-label', + x_align: Clutter.ActorAlign.START }); + hbox.add_actor(this._dayLabel); + + this._dateLabel = new St.Label({ style_class: 'date-label' }); + hbox.add_actor(this._dateLabel); + + this._calendar = calendar; + this._calendar.connect('selected-date-changed', (_calendar, datetime) => { + // Make the button reactive only if the selected date is not the + // current date. + this.reactive = !_isToday(_gDateTimeToDate(datetime)); + }); + } + + vfunc_clicked() { + this._calendar.setDate(new Date(), false); + } + + setDate(date) { + this._dayLabel.set_text(date.toLocaleFormat('%A')); + + /* Translators: This is the date format to use when the calendar popup is + * shown - it is shown just below the time in the top bar (e.g., + * "Tue 9:29 AM"). The string itself should become a full date, e.g., + * "February 17 2015". + */ + let dateFormat = Shell.util_translate_time_string(N_("%B %-d %Y")); + this._dateLabel.set_text(date.toLocaleFormat(dateFormat)); + + /* Translators: This is the accessible name of the date button shown + * below the time in the shell; it should combine the weekday and the + * date, e.g. "Tuesday February 17 2015". + */ + dateFormat = Shell.util_translate_time_string(N_("%A %B %e %Y")); + this.accessible_name = date.toLocaleFormat(dateFormat); + } +}); + +var EventsSection = GObject.registerClass( +class EventsSection extends St.Button { + _init() { + super._init({ + style_class: 'events-button', + can_focus: true, + x_expand: true, + child: new St.BoxLayout({ + style_class: 'events-box', + vertical: true, + x_expand: true, + }), + }); + + this._startDate = null; + this._endDate = null; + + this._eventSource = null; + this._calendarApp = null; + + this._title = new St.Label({ + style_class: 'events-title', + }); + this.child.add_child(this._title); + + this._eventsList = new St.BoxLayout({ + style_class: 'events-list', + vertical: true, + x_expand: true, + }); + this.child.add_child(this._eventsList); + + this._appSys = Shell.AppSystem.get_default(); + this._appSys.connect('installed-changed', + this._appInstalledChanged.bind(this)); + this._appInstalledChanged(); + } + + setDate(date) { + const day = [date.getFullYear(), date.getMonth(), date.getDate()]; + this._startDate = new Date(...day); + this._endDate = new Date(...day, 23, 59, 59, 999); + + this._updateTitle(); + this._reloadEvents(); + } + + setEventSource(eventSource) { + if (!(eventSource instanceof Calendar.EventSourceBase)) + throw new Error('Event source is not valid type'); + + this._eventSource = eventSource; + this._eventSource.connect('changed', this._reloadEvents.bind(this)); + this._eventSource.connect('notify::has-calendars', + this._sync.bind(this)); + this._sync(); + } + + _updateTitle() { + /* Translators: Shown on calendar heading when selected day occurs on current year */ + const sameYearFormat = T_(NC_('calendar heading', '%B %-d')); + + /* Translators: Shown on calendar heading when selected day occurs on different year */ + const otherYearFormat = T_(NC_('calendar heading', '%B %-d %Y')); + + const timeSpanDay = GLib.TIME_SPAN_DAY / 1000; + const now = new Date(); + + if (this._startDate <= now && now <= this._endDate) + this._title.text = _('Today'); + else if (this._endDate < now && now - this._endDate < timeSpanDay) + this._title.text = _('Yesterday'); + else if (this._startDate > now && this._startDate - now < timeSpanDay) + this._title.text = _('Tomorrow'); + else if (this._startDate.getFullYear() === now.getFullYear()) + this._title.text = this._startDate.toLocaleFormat(sameYearFormat); + else + this._title.text = this._startDate.toLocaleFormat(otherYearFormat); + } + + _formatEventTime(event) { + const allDay = event.allDay || + (event.date <= this._startDate && event.end >= this._endDate); + + let title; + if (allDay) { + /* Translators: Shown in calendar event list for all day events + * Keep it short, best if you can use less then 10 characters + */ + title = C_('event list time', 'All Day'); + } else { + let date = event.date >= this._startDate ? event.date : event.end; + title = Util.formatTime(date, { timeOnly: true }); + } + + const rtl = Clutter.get_default_text_direction() === Clutter.TextDirection.RTL; + if (event.date < this._startDate && !event.allDay) { + if (rtl) + title = '%s%s'.format(title, ELLIPSIS_CHAR); + else + title = '%s%s'.format(ELLIPSIS_CHAR, title); + } + if (event.end > this._endDate && !event.allDay) { + if (rtl) + title = '%s%s'.format(ELLIPSIS_CHAR, title); + else + title = '%s%s'.format(title, ELLIPSIS_CHAR); + } + return title; + } + + _reloadEvents() { + if (this._eventSource.isLoading || this._reloading) + return; + + this._reloading = true; + + [...this._eventsList].forEach(c => c.destroy()); + + const events = + this._eventSource.getEvents(this._startDate, this._endDate); + + for (let event of events) { + const box = new St.BoxLayout({ + style_class: 'event-box', + vertical: true, + }); + box.add(new St.Label({ + text: event.summary, + style_class: 'event-summary', + })); + box.add(new St.Label({ + text: this._formatEventTime(event), + style_class: 'event-time', + })); + this._eventsList.add_child(box); + } + + if (this._eventsList.get_n_children() === 0) { + const placeholder = new St.Label({ + text: _('No Events'), + style_class: 'event-placeholder', + }); + this._eventsList.add_child(placeholder); + } + + this._reloading = false; + this._sync(); + } + + vfunc_clicked() { + Main.overview.hide(); + Main.panel.closeCalendar(); + + let appInfo = this._calendarApp; + if (appInfo.get_id() === 'org.gnome.Evolution.desktop') { + const app = this._appSys.lookup_app('evolution-calendar.desktop'); + if (app) + appInfo = app.app_info; + } + appInfo.launch([], global.create_app_launch_context(0, -1)); + } + + _appInstalledChanged() { + const apps = Gio.AppInfo.get_recommended_for_type('text/calendar'); + if (apps && (apps.length > 0)) { + const app = Gio.AppInfo.get_default_for_type('text/calendar', false); + const defaultInRecommended = apps.some(a => a.equal(app)); + this._calendarApp = defaultInRecommended ? app : apps[0]; + } else { + this._calendarApp = null; + } + + return this._sync(); + } + + _sync() { + this.visible = this._eventSource && this._eventSource.hasCalendars; + this.reactive = this._calendarApp !== null; + } +}); + +var WorldClocksSection = GObject.registerClass( +class WorldClocksSection extends St.Button { + _init() { + super._init({ + style_class: 'world-clocks-button', + can_focus: true, + x_expand: true, + }); + this._clock = new GnomeDesktop.WallClock(); + this._clockNotifyId = 0; + this._tzNotifyId = 0; + + this._locations = []; + + let layout = new Clutter.GridLayout({ orientation: Clutter.Orientation.VERTICAL }); + this._grid = new St.Widget({ style_class: 'world-clocks-grid', + x_expand: true, + layout_manager: layout }); + layout.hookup_style(this._grid); + + this.child = this._grid; + + this._clocksApp = null; + this._clocksProxy = new ClocksProxy( + Gio.DBus.session, + 'org.gnome.clocks', + '/org/gnome/clocks', + this._onProxyReady.bind(this), + null /* cancellable */, + Gio.DBusProxyFlags.DO_NOT_AUTO_START | Gio.DBusProxyFlags.GET_INVALIDATED_PROPERTIES); + + this._settings = new Gio.Settings({ + schema_id: 'org.gnome.shell.world-clocks', + }); + this._settings.connect('changed', this._clocksChanged.bind(this)); + this._clocksChanged(); + + this._appSystem = Shell.AppSystem.get_default(); + this._appSystem.connect('installed-changed', + this._sync.bind(this)); + this._sync(); + } + + vfunc_clicked() { + if (this._clocksApp) + this._clocksApp.activate(); + + Main.overview.hide(); + Main.panel.closeCalendar(); + } + + _sync() { + this._clocksApp = this._appSystem.lookup_app('org.gnome.clocks.desktop'); + this.visible = this._clocksApp != null; + } + + _clocksChanged() { + this._grid.destroy_all_children(); + this._locations = []; + + let world = GWeather.Location.get_world(); + let clocks = this._settings.get_value('locations').deep_unpack(); + for (let i = 0; i < clocks.length; i++) { + let l = world.deserialize(clocks[i]); + if (l && l.get_timezone() != null) + this._locations.push({ location: l }); + } + + this._locations.sort((a, b) => { + return a.location.get_timezone().get_offset() - + b.location.get_timezone().get_offset(); + }); + + let layout = this._grid.layout_manager; + let title = this._locations.length == 0 + ? _("Add world clocks…") + : _("World Clocks"); + let header = new St.Label({ style_class: 'world-clocks-header', + x_align: Clutter.ActorAlign.START, + text: title }); + layout.attach(header, 0, 0, 2, 1); + this.label_actor = header; + + for (let i = 0; i < this._locations.length; i++) { + let l = this._locations[i].location; + + let name = l.get_city_name() || l.get_name(); + let label = new St.Label({ style_class: 'world-clocks-city', + text: name, + x_align: Clutter.ActorAlign.START, + y_align: Clutter.ActorAlign.CENTER, + x_expand: true }); + + let time = new St.Label({ style_class: 'world-clocks-time' }); + + const tz = new St.Label({ + style_class: 'world-clocks-timezone', + x_align: Clutter.ActorAlign.END, + y_align: Clutter.ActorAlign.CENTER, + }); + + time.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + tz.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + + if (this._grid.text_direction == Clutter.TextDirection.RTL) { + layout.attach(tz, 0, i + 1, 1, 1); + layout.attach(time, 1, i + 1, 1, 1); + layout.attach(label, 2, i + 1, 1, 1); + } else { + layout.attach(label, 0, i + 1, 1, 1); + layout.attach(time, 1, i + 1, 1, 1); + layout.attach(tz, 2, i + 1, 1, 1); + } + + this._locations[i].timeLabel = time; + this._locations[i].tzLabel = tz; + } + + if (this._grid.get_n_children() > 1) { + if (!this._clockNotifyId) { + this._clockNotifyId = + this._clock.connect('notify::clock', this._updateTimeLabels.bind(this)); + } + if (!this._tzNotifyId) { + this._tzNotifyId = + this._clock.connect('notify::timezone', this._updateTimezoneLabels.bind(this)); + } + this._updateTimeLabels(); + this._updateTimezoneLabels(); + } else { + if (this._clockNotifyId) + this._clock.disconnect(this._clockNotifyId); + this._clockNotifyId = 0; + + if (this._tzNotifyId) + this._clock.disconnect(this._tzNotifyId); + this._tzNotifyId = 0; + } + } + + _getTimezoneOffsetAtLocation(location) { + const localOffset = GLib.DateTime.new_now_local().get_utc_offset(); + const utcOffset = this._getTimeAtLocation(location).get_utc_offset(); + const offsetCurrentTz = utcOffset - localOffset; + const offsetHours = Math.abs(offsetCurrentTz) / GLib.TIME_SPAN_HOUR; + const offsetMinutes = + (Math.abs(offsetCurrentTz) % GLib.TIME_SPAN_HOUR) / + GLib.TIME_SPAN_MINUTE; + + const prefix = offsetCurrentTz >= 0 ? '+' : '-'; + const text = offsetMinutes === 0 + ? '%s%d'.format(prefix, offsetHours) + : '%s%d\u2236%d'.format(prefix, offsetHours, offsetMinutes); + return text; + } + + _getTimeAtLocation(location) { + let tz = GLib.TimeZone.new(location.get_timezone().get_tzid()); + return GLib.DateTime.new_now(tz); + } + + _updateTimeLabels() { + for (let i = 0; i < this._locations.length; i++) { + let l = this._locations[i]; + let now = this._getTimeAtLocation(l.location); + l.timeLabel.text = Util.formatTime(now, { timeOnly: true }); + } + } + + _updateTimezoneLabels() { + for (let i = 0; i < this._locations.length; i++) { + let l = this._locations[i]; + l.tzLabel.text = this._getTimezoneOffsetAtLocation(l.location); + } + } + + _onProxyReady(proxy, error) { + if (error) { + log('Failed to create GNOME Clocks proxy: %s'.format(error)); + return; + } + + this._clocksProxy.connect('g-properties-changed', + this._onClocksPropertiesChanged.bind(this)); + this._onClocksPropertiesChanged(); + } + + _onClocksPropertiesChanged() { + if (this._clocksProxy.g_name_owner == null) + return; + + this._settings.set_value('locations', + new GLib.Variant('av', this._clocksProxy.Locations)); + } +}); + +var WeatherSection = GObject.registerClass( +class WeatherSection extends St.Button { + _init() { + super._init({ + style_class: 'weather-button', + can_focus: true, + x_expand: true, + }); + + this._weatherClient = new Weather.WeatherClient(); + + let box = new St.BoxLayout({ + style_class: 'weather-box', + vertical: true, + x_expand: true, + }); + + this.child = box; + + let titleBox = new St.BoxLayout({ style_class: 'weather-header-box' }); + this._titleLabel = new St.Label({ + style_class: 'weather-header', + x_align: Clutter.ActorAlign.START, + x_expand: true, + y_align: Clutter.ActorAlign.END, + }); + titleBox.add_child(this._titleLabel); + box.add_child(titleBox); + + this._titleLocation = new St.Label({ + style_class: 'weather-header location', + x_align: Clutter.ActorAlign.END, + y_align: Clutter.ActorAlign.END, + }); + titleBox.add_child(this._titleLocation); + + let layout = new Clutter.GridLayout({ orientation: Clutter.Orientation.VERTICAL }); + this._forecastGrid = new St.Widget({ + style_class: 'weather-grid', + layout_manager: layout, + }); + layout.hookup_style(this._forecastGrid); + box.add_child(this._forecastGrid); + + this._weatherClient.connect('changed', this._sync.bind(this)); + this._sync(); + } + + vfunc_map() { + this._weatherClient.update(); + super.vfunc_map(); + } + + vfunc_clicked() { + this._weatherClient.activateApp(); + + Main.overview.hide(); + Main.panel.closeCalendar(); + } + + _getInfos() { + let forecasts = this._weatherClient.info.get_forecast_list(); + + let now = GLib.DateTime.new_now_local(); + let current = GLib.DateTime.new_from_unix_local(0); + let infos = []; + for (let i = 0; i < forecasts.length; i++) { + const [valid, timestamp] = forecasts[i].get_value_update(); + if (!valid || timestamp === 0) + continue; // 0 means 'never updated' + + const datetime = GLib.DateTime.new_from_unix_local(timestamp); + if (now.difference(datetime) > 0) + continue; // Ignore earlier forecasts + + if (datetime.difference(current) < GLib.TIME_SPAN_HOUR) + continue; // Enforce a minimum interval of 1h + + if (infos.push(forecasts[i]) == MAX_FORECASTS) + break; // Use a maximum of five forecasts + + current = datetime; + } + return infos; + } + + _addForecasts() { + let layout = this._forecastGrid.layout_manager; + + let infos = this._getInfos(); + if (this._forecastGrid.text_direction == Clutter.TextDirection.RTL) + infos.reverse(); + + let col = 0; + infos.forEach(fc => { + const [valid_, timestamp] = fc.get_value_update(); + let timeStr = Util.formatTime(new Date(timestamp * 1000), { + timeOnly: true, + ampm: false, + }); + const [, tempValue] = fc.get_value_temp(GWeather.TemperatureUnit.DEFAULT); + const tempPrefix = Math.round(tempValue) >= 0 ? ' ' : ''; + + let time = new St.Label({ + style_class: 'weather-forecast-time', + text: timeStr, + x_align: Clutter.ActorAlign.CENTER, + }); + let icon = new St.Icon({ + style_class: 'weather-forecast-icon', + icon_name: fc.get_symbolic_icon_name(), + x_align: Clutter.ActorAlign.CENTER, + x_expand: true, + }); + let temp = new St.Label({ + style_class: 'weather-forecast-temp', + text: '%s%d°'.format(tempPrefix, Math.round(tempValue)), + x_align: Clutter.ActorAlign.CENTER, + }); + + temp.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + time.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + + layout.attach(time, col, 0, 1, 1); + layout.attach(icon, col, 1, 1, 1); + layout.attach(temp, col, 2, 1, 1); + col++; + }); + } + + _setStatusLabel(text) { + let layout = this._forecastGrid.layout_manager; + let label = new St.Label({ text }); + layout.attach(label, 0, 0, 1, 1); + } + + _findBestLocationName(loc) { + const locName = loc.get_name(); + + if (loc.get_level() === GWeather.LocationLevel.CITY || + !loc.has_coords()) + return locName; + + const world = GWeather.Location.get_world(); + const city = world.find_nearest_city(...loc.get_coords()); + const cityName = city.get_name(); + + return locName.includes(cityName) ? cityName : locName; + } + + _updateForecasts() { + this._forecastGrid.destroy_all_children(); + + if (!this._weatherClient.hasLocation) + return; + + const { info } = this._weatherClient; + this._titleLocation.text = this._findBestLocationName(info.location); + + if (this._weatherClient.loading) { + this._setStatusLabel(_("Loading…")); + return; + } + + if (info.is_valid()) { + this._addForecasts(); + return; + } + + if (info.network_error()) + this._setStatusLabel(_("Go online for weather information")); + else + this._setStatusLabel(_("Weather information is currently unavailable")); + } + + _sync() { + this.visible = this._weatherClient.available; + + if (!this.visible) + return; + + if (this._weatherClient.hasLocation) + this._titleLabel.text = _('Weather'); + else + this._titleLabel.text = _('Select weather location…'); + + this._forecastGrid.visible = this._weatherClient.hasLocation; + this._titleLocation.visible = this._weatherClient.hasLocation; + + this._updateForecasts(); + } +}); + +var MessagesIndicator = GObject.registerClass( +class MessagesIndicator extends St.Icon { + _init() { + super._init({ + icon_size: 16, + visible: false, + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + + this._sources = []; + this._count = 0; + + this._settings = new Gio.Settings({ + schema_id: 'org.gnome.desktop.notifications', + }); + this._settings.connect('changed::show-banners', this._sync.bind(this)); + + Main.messageTray.connect('source-added', this._onSourceAdded.bind(this)); + Main.messageTray.connect('source-removed', this._onSourceRemoved.bind(this)); + Main.messageTray.connect('queue-changed', this._updateCount.bind(this)); + + let sources = Main.messageTray.getSources(); + sources.forEach(source => this._onSourceAdded(null, source)); + + this._sync(); + + this.connect('destroy', () => { + this._settings.run_dispose(); + this._settings = null; + }); + } + + _onSourceAdded(tray, source) { + source.connect('notify::count', this._updateCount.bind(this)); + this._sources.push(source); + this._updateCount(); + } + + _onSourceRemoved(tray, source) { + this._sources.splice(this._sources.indexOf(source), 1); + this._updateCount(); + } + + _updateCount() { + let count = 0; + this._sources.forEach(source => (count += source.unseenCount)); + this._count = count - Main.messageTray.queueCount; + + this._sync(); + } + + _sync() { + let doNotDisturb = !this._settings.get_boolean('show-banners'); + this.icon_name = doNotDisturb + ? 'notifications-disabled-symbolic' + : 'message-indicator-symbolic'; + this.visible = doNotDisturb || this._count > 0; + } +}); + +var FreezableBinLayout = GObject.registerClass( +class FreezableBinLayout extends Clutter.BinLayout { + _init() { + super._init(); + + this._frozen = false; + this._savedWidth = [NaN, NaN]; + this._savedHeight = [NaN, NaN]; + } + + set frozen(v) { + if (this._frozen == v) + return; + + this._frozen = v; + if (!this._frozen) + this.layout_changed(); + } + + vfunc_get_preferred_width(container, forHeight) { + if (!this._frozen || this._savedWidth.some(isNaN)) + return super.vfunc_get_preferred_width(container, forHeight); + return this._savedWidth; + } + + vfunc_get_preferred_height(container, forWidth) { + if (!this._frozen || this._savedHeight.some(isNaN)) + return super.vfunc_get_preferred_height(container, forWidth); + return this._savedHeight; + } + + vfunc_allocate(container, allocation) { + super.vfunc_allocate(container, allocation); + + let [width, height] = allocation.get_size(); + this._savedWidth = [width, width]; + this._savedHeight = [height, height]; + } +}); + +var CalendarColumnLayout = GObject.registerClass( +class CalendarColumnLayout extends Clutter.BoxLayout { + _init(actors) { + super._init({ orientation: Clutter.Orientation.VERTICAL }); + this._colActors = actors; + } + + vfunc_get_preferred_width(container, forHeight) { + const actors = + this._colActors.filter(a => a.get_parent() === container); + if (actors.length === 0) + return super.vfunc_get_preferred_width(container, forHeight); + return actors.reduce(([minAcc, natAcc], child) => { + const [min, nat] = child.get_preferred_width(forHeight); + return [Math.max(minAcc, min), Math.max(natAcc, nat)]; + }, [0, 0]); + } +}); + +var DateMenuButton = GObject.registerClass( +class DateMenuButton extends PanelMenu.Button { + _init() { + let hbox; + let vbox; + + super._init(0.5); + + this._clockDisplay = new St.Label({ style_class: 'clock' }); + this._clockDisplay.clutter_text.y_align = Clutter.ActorAlign.CENTER; + this._clockDisplay.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + + this._indicator = new MessagesIndicator(); + + const indicatorPad = new St.Widget(); + this._indicator.bind_property('visible', + indicatorPad, 'visible', + GObject.BindingFlags.SYNC_CREATE); + indicatorPad.add_constraint(new Clutter.BindConstraint({ + source: this._indicator, + coordinate: Clutter.BindCoordinate.SIZE, + })); + + let box = new St.BoxLayout({ style_class: 'clock-display-box' }); + box.add_actor(indicatorPad); + box.add_actor(this._clockDisplay); + box.add_actor(this._indicator); + + this.label_actor = this._clockDisplay; + this.add_actor(box); + this.add_style_class_name('clock-display'); + + let layout = new FreezableBinLayout(); + let bin = new St.Widget({ layout_manager: layout }); + // For some minimal compatibility with PopupMenuItem + bin._delegate = this; + this.menu.box.add_child(bin); + + hbox = new St.BoxLayout({ name: 'calendarArea' }); + bin.add_actor(hbox); + + this._calendar = new Calendar.Calendar(); + this._calendar.connect('selected-date-changed', (_calendar, datetime) => { + let date = _gDateTimeToDate(datetime); + layout.frozen = !_isToday(date); + this._eventsItem.setDate(date); + }); + this._date = new TodayButton(this._calendar); + + this.menu.connect('open-state-changed', (menu, isOpen) => { + // Whenever the menu is opened, select today + if (isOpen) { + let now = new Date(); + this._calendar.setDate(now); + this._date.setDate(now); + this._eventsItem.setDate(now); + } + }); + + // Fill up the first column + this._messageList = new Calendar.CalendarMessageList(); + hbox.add_child(this._messageList); + + // Fill up the second column + const boxLayout = new CalendarColumnLayout([this._calendar, this._date]); + vbox = new St.Widget({ style_class: 'datemenu-calendar-column', + layout_manager: boxLayout }); + boxLayout.hookup_style(vbox); + hbox.add(vbox); + + vbox.add_actor(this._date); + vbox.add_actor(this._calendar); + + this._displaysSection = new St.ScrollView({ style_class: 'datemenu-displays-section vfade', + x_expand: true, + overlay_scrollbars: true }); + this._displaysSection.set_policy(St.PolicyType.NEVER, St.PolicyType.EXTERNAL); + vbox.add_actor(this._displaysSection); + + let displaysBox = new St.BoxLayout({ vertical: true, + x_expand: true, + style_class: 'datemenu-displays-box' }); + this._displaysSection.add_actor(displaysBox); + + this._eventsItem = new EventsSection(); + displaysBox.add_child(this._eventsItem); + + this._clocksItem = new WorldClocksSection(); + displaysBox.add_child(this._clocksItem); + + this._weatherItem = new WeatherSection(); + displaysBox.add_child(this._weatherItem); + + // Done with hbox for calendar and event list + + this._clock = new GnomeDesktop.WallClock(); + this._clock.bind_property('clock', this._clockDisplay, 'text', GObject.BindingFlags.SYNC_CREATE); + this._clock.connect('notify::timezone', this._updateTimeZone.bind(this)); + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + this._sessionUpdated(); + } + + _getEventSource() { + return new Calendar.DBusEventSource(); + } + + _setEventSource(eventSource) { + if (this._eventSource) + this._eventSource.destroy(); + + this._calendar.setEventSource(eventSource); + this._eventsItem.setEventSource(eventSource); + + this._eventSource = eventSource; + } + + _updateTimeZone() { + // SpiderMonkey caches the time zone so we must explicitly clear it + // before we can update the calendar, see + // https://bugzilla.gnome.org/show_bug.cgi?id=678507 + System.clearDateCaches(); + + this._calendar.updateTimeZone(); + } + + _sessionUpdated() { + let eventSource; + let showEvents = Main.sessionMode.showCalendarEvents; + if (showEvents) + eventSource = this._getEventSource(); + else + eventSource = new Calendar.EmptyEventSource(); + + this._setEventSource(eventSource); + + // Displays are not actually expected to launch Settings when activated + // but the corresponding app (clocks, weather); however we can consider + // that display-specific settings, so re-use "allowSettings" here ... + this._displaysSection.visible = Main.sessionMode.allowSettings; + } +}); diff --git a/js/ui/dialog.js b/js/ui/dialog.js new file mode 100644 index 0000000..f2b6fee --- /dev/null +++ b/js/ui/dialog.js @@ -0,0 +1,368 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Dialog, MessageDialogContent, ListSection, ListSectionItem */ + +const { Clutter, GLib, GObject, Meta, Pango, St } = imports.gi; + +function _setLabel(label, value) { + label.set({ + text: value || '', + visible: value !== null, + }); +} + +var Dialog = GObject.registerClass( +class Dialog extends St.Widget { + _init(parentActor, styleClass) { + super._init({ layout_manager: new Clutter.BinLayout() }); + this.connect('destroy', this._onDestroy.bind(this)); + + this._initialKeyFocus = null; + this._initialKeyFocusDestroyId = 0; + this._pressedKey = null; + this._buttonKeys = {}; + this._createDialog(); + this.add_child(this._dialog); + + if (styleClass != null) + this._dialog.add_style_class_name(styleClass); + + this._parentActor = parentActor; + this._eventId = this._parentActor.connect('event', this._modalEventHandler.bind(this)); + this._parentActor.add_child(this); + } + + _createDialog() { + this._dialog = new St.BoxLayout({ + style_class: 'modal-dialog', + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + vertical: true, + }); + + // modal dialogs are fixed width and grow vertically; set the request + // mode accordingly so wrapped labels are handled correctly during + // size requests. + this._dialog.request_mode = Clutter.RequestMode.HEIGHT_FOR_WIDTH; + this._dialog.set_offscreen_redirect(Clutter.OffscreenRedirect.ALWAYS); + + this.contentLayout = new St.BoxLayout({ + vertical: true, + style_class: 'modal-dialog-content-box', + y_expand: true, + }); + this._dialog.add_child(this.contentLayout); + + this.buttonLayout = new St.Widget({ + layout_manager: new Clutter.BoxLayout({ homogeneous: true }), + }); + this._dialog.add_child(this.buttonLayout); + } + + makeInactive() { + if (this._eventId != 0) + this._parentActor.disconnect(this._eventId); + this._eventId = 0; + + this.buttonLayout.get_children().forEach(c => c.set_reactive(false)); + } + + _onDestroy() { + this.makeInactive(); + } + + _modalEventHandler(actor, event) { + if (event.type() == Clutter.EventType.KEY_PRESS) { + this._pressedKey = event.get_key_symbol(); + } else if (event.type() == Clutter.EventType.KEY_RELEASE) { + let pressedKey = this._pressedKey; + this._pressedKey = null; + + let symbol = event.get_key_symbol(); + if (symbol != pressedKey) + return Clutter.EVENT_PROPAGATE; + + let buttonInfo = this._buttonKeys[symbol]; + if (!buttonInfo) + return Clutter.EVENT_PROPAGATE; + + let { button, action } = buttonInfo; + + if (action && button.reactive) { + action(); + return Clutter.EVENT_STOP; + } + } + + return Clutter.EVENT_PROPAGATE; + } + + _setInitialKeyFocus(actor) { + if (this._initialKeyFocus) + this._initialKeyFocus.disconnect(this._initialKeyFocusDestroyId); + + this._initialKeyFocus = actor; + + this._initialKeyFocusDestroyId = actor.connect('destroy', () => { + this._initialKeyFocus = null; + this._initialKeyFocusDestroyId = 0; + }); + } + + get initialKeyFocus() { + return this._initialKeyFocus || this; + } + + addButton(buttonInfo) { + let { label, action, key } = buttonInfo; + let isDefault = buttonInfo['default']; + let keys; + + if (key) + keys = [key]; + else if (isDefault) + keys = [Clutter.KEY_Return, Clutter.KEY_KP_Enter, Clutter.KEY_ISO_Enter]; + else + keys = []; + + let button = new St.Button({ + style_class: 'modal-dialog-linked-button', + button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE, + reactive: true, + can_focus: true, + x_expand: true, + y_expand: true, + label, + }); + button.connect('clicked', () => action()); + + buttonInfo['button'] = button; + + if (isDefault) + button.add_style_pseudo_class('default'); + + if (this._initialKeyFocus == null || isDefault) + this._setInitialKeyFocus(button); + + for (let i in keys) + this._buttonKeys[keys[i]] = buttonInfo; + + this.buttonLayout.add_actor(button); + + return button; + } + + clearButtons() { + this.buttonLayout.destroy_all_children(); + this._buttonKeys = {}; + } +}); + +var MessageDialogContent = GObject.registerClass({ + Properties: { + 'title': GObject.ParamSpec.string( + 'title', 'title', 'title', + GObject.ParamFlags.READWRITE | + GObject.ParamFlags.CONSTRUCT, + null), + 'description': GObject.ParamSpec.string( + 'description', 'description', 'description', + GObject.ParamFlags.READWRITE | + GObject.ParamFlags.CONSTRUCT, + null), + }, +}, class MessageDialogContent extends St.BoxLayout { + _init(params) { + this._title = new St.Label({ style_class: 'message-dialog-title' }); + this._description = new St.Label({ style_class: 'message-dialog-description' }); + + this._description.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._description.clutter_text.line_wrap = true; + + let defaultParams = { + style_class: 'message-dialog-content', + x_expand: true, + vertical: true, + }; + super._init(Object.assign(defaultParams, params)); + + this.connect('notify::size', this._updateTitleStyle.bind(this)); + this.connect('destroy', this._onDestroy.bind(this)); + + this.add_child(this._title); + this.add_child(this._description); + } + + _onDestroy() { + if (this._updateTitleStyleLater) { + Meta.later_remove(this._updateTitleStyleLater); + delete this._updateTitleStyleLater; + } + } + + get title() { + return this._title.text; + } + + get description() { + return this._description.text; + } + + _updateTitleStyle() { + if (!this._title.mapped) + return; + + this._title.ensure_style(); + const [, titleNatWidth] = this._title.get_preferred_width(-1); + + if (titleNatWidth > this.width) { + if (this._updateTitleStyleLater) + return; + + this._updateTitleStyleLater = Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + this._updateTitleStyleLater = 0; + this._title.add_style_class_name('lightweight'); + return GLib.SOURCE_REMOVE; + }); + } + + } + + set title(title) { + if (this._title.text === title) + return; + + _setLabel(this._title, title); + + this._title.remove_style_class_name('lightweight'); + this._updateTitleStyle(); + + this.notify('title'); + } + + set description(description) { + if (this._description.text === description) + return; + + _setLabel(this._description, description); + this.notify('description'); + } +}); + +var ListSection = GObject.registerClass({ + Properties: { + 'title': GObject.ParamSpec.string( + 'title', 'title', 'title', + GObject.ParamFlags.READWRITE | + GObject.ParamFlags.CONSTRUCT, + null), + }, +}, class ListSection extends St.BoxLayout { + _init(params) { + this._title = new St.Label({ style_class: 'dialog-list-title' }); + + this._listScrollView = new St.ScrollView({ + style_class: 'dialog-list-scrollview', + hscrollbar_policy: St.PolicyType.NEVER, + }); + + this.list = new St.BoxLayout({ + style_class: 'dialog-list-box', + vertical: true, + }); + this._listScrollView.add_actor(this.list); + + let defaultParams = { + style_class: 'dialog-list', + x_expand: true, + vertical: true, + }; + super._init(Object.assign(defaultParams, params)); + + this.label_actor = this._title; + this.add_child(this._title); + this.add_child(this._listScrollView); + } + + get title() { + return this._title.text; + } + + set title(title) { + _setLabel(this._title, title); + this.notify('title'); + } +}); + +var ListSectionItem = GObject.registerClass({ + Properties: { + 'icon-actor': GObject.ParamSpec.object( + 'icon-actor', 'icon-actor', 'Icon actor', + GObject.ParamFlags.READWRITE, + Clutter.Actor.$gtype), + 'title': GObject.ParamSpec.string( + 'title', 'title', 'title', + GObject.ParamFlags.READWRITE | + GObject.ParamFlags.CONSTRUCT, + null), + 'description': GObject.ParamSpec.string( + 'description', 'description', 'description', + GObject.ParamFlags.READWRITE | + GObject.ParamFlags.CONSTRUCT, + null), + }, +}, class ListSectionItem extends St.BoxLayout { + _init(params) { + this._iconActorBin = new St.Bin(); + + let textLayout = new St.BoxLayout({ + vertical: true, + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + + this._title = new St.Label({ style_class: 'dialog-list-item-title' }); + + this._description = new St.Label({ + style_class: 'dialog-list-item-title-description', + }); + + textLayout.add_child(this._title); + textLayout.add_child(this._description); + + let defaultParams = { style_class: 'dialog-list-item' }; + super._init(Object.assign(defaultParams, params)); + + this.label_actor = this._title; + this.add_child(this._iconActorBin); + this.add_child(textLayout); + } + + // eslint-disable-next-line camelcase + get icon_actor() { + return this._iconActorBin.get_child(); + } + + // eslint-disable-next-line camelcase + set icon_actor(actor) { + this._iconActorBin.set_child(actor); + this.notify('icon-actor'); + } + + get title() { + return this._title.text; + } + + set title(title) { + _setLabel(this._title, title); + this.notify('title'); + } + + get description() { + return this._description.text; + } + + set description(description) { + _setLabel(this._description, description); + this.notify('description'); + } +}); diff --git a/js/ui/dnd.js b/js/ui/dnd.js new file mode 100644 index 0000000..b1e1680 --- /dev/null +++ b/js/ui/dnd.js @@ -0,0 +1,800 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported addDragMonitor, removeDragMonitor, makeDraggable */ + +const { Clutter, GLib, Meta, Shell, St } = imports.gi; +const Signals = imports.signals; + +const Main = imports.ui.main; +const Params = imports.misc.params; + +// Time to scale down to maxDragActorSize +var SCALE_ANIMATION_TIME = 250; +// Time to animate to original position on cancel +var SNAP_BACK_ANIMATION_TIME = 250; +// Time to animate to original position on success +var REVERT_ANIMATION_TIME = 750; + +var DragMotionResult = { + NO_DROP: 0, + COPY_DROP: 1, + MOVE_DROP: 2, + CONTINUE: 3, +}; + +var DragState = { + INIT: 0, + DRAGGING: 1, + CANCELLED: 2, +}; + +var DRAG_CURSOR_MAP = { + 0: Meta.Cursor.DND_UNSUPPORTED_TARGET, + 1: Meta.Cursor.DND_COPY, + 2: Meta.Cursor.DND_MOVE, +}; + +var DragDropResult = { + FAILURE: 0, + SUCCESS: 1, + CONTINUE: 2, +}; +var dragMonitors = []; + +let eventHandlerActor = null; +let currentDraggable = null; + +function _getEventHandlerActor() { + if (!eventHandlerActor) { + eventHandlerActor = new Clutter.Actor({ width: 0, height: 0 }); + Main.uiGroup.add_actor(eventHandlerActor); + // We connect to 'event' rather than 'captured-event' because the capturing phase doesn't happen + // when you've grabbed the pointer. + eventHandlerActor.connect('event', (actor, event) => { + return currentDraggable._onEvent(actor, event); + }); + } + return eventHandlerActor; +} + +function addDragMonitor(monitor) { + dragMonitors.push(monitor); +} + +function removeDragMonitor(monitor) { + for (let i = 0; i < dragMonitors.length; i++) { + if (dragMonitors[i] == monitor) { + dragMonitors.splice(i, 1); + return; + } + } +} + +var _Draggable = class _Draggable { + constructor(actor, params) { + params = Params.parse(params, { manualMode: false, + restoreOnSuccess: false, + dragActorMaxSize: undefined, + dragActorOpacity: undefined }); + + this.actor = actor; + this._dragState = DragState.INIT; + + if (!params.manualMode) { + this.actor.connect('button-press-event', + this._onButtonPress.bind(this)); + this.actor.connect('touch-event', + this._onTouchEvent.bind(this)); + } + + this.actor.connect('destroy', () => { + this._actorDestroyed = true; + + if (this._dragState == DragState.DRAGGING && this._dragCancellable) + this._cancelDrag(global.get_current_time()); + this.disconnectAll(); + }); + this._onEventId = null; + this._touchSequence = null; + + this._restoreOnSuccess = params.restoreOnSuccess; + this._dragActorMaxSize = params.dragActorMaxSize; + this._dragActorOpacity = params.dragActorOpacity; + + this._buttonDown = false; // The mouse button has been pressed and has not yet been released. + this._animationInProgress = false; // The drag is over and the item is in the process of animating to its original position (snapping back or reverting). + this._dragCancellable = true; + + this._eventsGrabbed = false; + this._capturedEventId = 0; + } + + _onButtonPress(actor, event) { + if (event.get_button() != 1) + return Clutter.EVENT_PROPAGATE; + + this._buttonDown = true; + this._grabActor(event.get_device()); + + let [stageX, stageY] = event.get_coords(); + this._dragStartX = stageX; + this._dragStartY = stageY; + + return Clutter.EVENT_PROPAGATE; + } + + _onTouchEvent(actor, event) { + // We only handle touch events here on wayland. On X11 + // we do get emulated pointer events, which already works + // for single-touch cases. Besides, the X11 passive touch grab + // set up by Mutter will make us see first the touch events + // and later the pointer events, so it will look like two + // unrelated series of events, we want to avoid double handling + // in these cases. + if (!Meta.is_wayland_compositor()) + return Clutter.EVENT_PROPAGATE; + + if (event.type() != Clutter.EventType.TOUCH_BEGIN || + !global.display.is_pointer_emulating_sequence(event.get_event_sequence())) + return Clutter.EVENT_PROPAGATE; + + this._buttonDown = true; + this._grabActor(event.get_device(), event.get_event_sequence()); + + let [stageX, stageY] = event.get_coords(); + this._dragStartX = stageX; + this._dragStartY = stageY; + + return Clutter.EVENT_PROPAGATE; + } + + _grabDevice(actor, pointer, touchSequence) { + if (touchSequence) + pointer.sequence_grab(touchSequence, actor); + else if (pointer) + pointer.grab(actor); + + this._grabbedDevice = pointer; + this._touchSequence = touchSequence; + + this._capturedEventId = global.stage.connect('captured-event', (o, event) => { + let device = event.get_device(); + if (device != this._grabbedDevice && + device.get_device_type() != Clutter.InputDeviceType.KEYBOARD_DEVICE) + return Clutter.EVENT_STOP; + return Clutter.EVENT_PROPAGATE; + }); + } + + _ungrabDevice() { + if (this._capturedEventId != 0) { + global.stage.disconnect(this._capturedEventId); + this._capturedEventId = 0; + } + + if (this._touchSequence) + this._grabbedDevice.sequence_ungrab(this._touchSequence); + else + this._grabbedDevice.ungrab(); + + this._touchSequence = null; + this._grabbedDevice = null; + } + + _grabActor(device, touchSequence) { + this._grabDevice(this.actor, device, touchSequence); + this._onEventId = this.actor.connect('event', + this._onEvent.bind(this)); + } + + _ungrabActor() { + if (!this._onEventId) + return; + + this._ungrabDevice(); + this.actor.disconnect(this._onEventId); + this._onEventId = null; + } + + _grabEvents(device, touchSequence) { + if (!this._eventsGrabbed) { + this._eventsGrabbed = Main.pushModal(_getEventHandlerActor()); + if (this._eventsGrabbed) + this._grabDevice(_getEventHandlerActor(), device, touchSequence); + } + } + + _ungrabEvents() { + if (this._eventsGrabbed) { + this._ungrabDevice(); + Main.popModal(_getEventHandlerActor()); + this._eventsGrabbed = false; + } + } + + _eventIsRelease(event) { + if (event.type() == Clutter.EventType.BUTTON_RELEASE) { + let buttonMask = Clutter.ModifierType.BUTTON1_MASK | + Clutter.ModifierType.BUTTON2_MASK | + Clutter.ModifierType.BUTTON3_MASK; + /* We only obey the last button release from the device, + * other buttons may get pressed/released during the DnD op. + */ + return (event.get_state() & buttonMask) == 0; + } else if (event.type() == Clutter.EventType.TOUCH_END) { + /* For touch, we only obey the pointer emulating sequence */ + return global.display.is_pointer_emulating_sequence(event.get_event_sequence()); + } + + return false; + } + + _onEvent(actor, event) { + let device = event.get_device(); + + if (this._grabbedDevice && + device != this._grabbedDevice && + device.get_device_type() != Clutter.InputDeviceType.KEYBOARD_DEVICE) + return Clutter.EVENT_PROPAGATE; + + // We intercept BUTTON_RELEASE event to know that the button was released in case we + // didn't start the drag, to drop the draggable in case the drag was in progress, and + // to complete the drag and ensure that whatever happens to be under the pointer does + // not get triggered if the drag was cancelled with Esc. + if (this._eventIsRelease(event)) { + this._buttonDown = false; + if (this._dragState == DragState.DRAGGING) { + return this._dragActorDropped(event); + } else if ((this._dragActor != null || this._dragState == DragState.CANCELLED) && + !this._animationInProgress) { + // Drag must have been cancelled with Esc. + this._dragComplete(); + return Clutter.EVENT_STOP; + } else { + // Drag has never started. + this._ungrabActor(); + return Clutter.EVENT_PROPAGATE; + } + // We intercept MOTION event to figure out if the drag has started and to draw + // this._dragActor under the pointer when dragging is in progress + } else if (event.type() == Clutter.EventType.MOTION || + (event.type() == Clutter.EventType.TOUCH_UPDATE && + global.display.is_pointer_emulating_sequence(event.get_event_sequence()))) { + if (this._dragActor && this._dragState == DragState.DRAGGING) + return this._updateDragPosition(event); + else if (this._dragActor == null && this._dragState != DragState.CANCELLED) + return this._maybeStartDrag(event); + + // We intercept KEY_PRESS event so that we can process Esc key press to cancel + // dragging and ignore all other key presses. + } else if (event.type() == Clutter.EventType.KEY_PRESS && this._dragState == DragState.DRAGGING) { + let symbol = event.get_key_symbol(); + if (symbol == Clutter.KEY_Escape) { + this._cancelDrag(event.get_time()); + return Clutter.EVENT_STOP; + } + } + + return Clutter.EVENT_PROPAGATE; + } + + /** + * fakeRelease: + * + * Fake a release event. + * Must be called if you want to intercept release events on draggable + * actors for other purposes (for example if you're using + * PopupMenu.ignoreRelease()) + */ + fakeRelease() { + this._buttonDown = false; + this._ungrabActor(); + } + + /** + * startDrag: + * @param {number} stageX: X coordinate of event + * @param {number} stageY: Y coordinate of event + * @param {number} time: Event timestamp + * @param {Clutter.EventSequence=} sequence: Event sequence + * @param {Clutter.InputDevice=} device: device that originated the event + * + * Directly initiate a drag and drop operation from the given actor. + * This function is useful to call if you've specified manualMode + * for the draggable. + */ + startDrag(stageX, stageY, time, sequence, device) { + if (currentDraggable) + return; + + if (device == undefined) { + let event = Clutter.get_current_event(); + + if (event) + device = event.get_device(); + + if (device == undefined) { + let seat = Clutter.get_default_backend().get_default_seat(); + device = seat.get_pointer(); + } + } + + currentDraggable = this; + this._dragState = DragState.DRAGGING; + + // Special-case St.Button: the pointer grab messes with the internal + // state, so force a reset to a reasonable state here + if (this.actor instanceof St.Button) { + this.actor.fake_release(); + this.actor.hover = false; + } + + this.emit('drag-begin', time); + if (this._onEventId) + this._ungrabActor(); + + this._grabEvents(device, sequence); + global.display.set_cursor(Meta.Cursor.DND_IN_DRAG); + + this._dragX = this._dragStartX = stageX; + this._dragY = this._dragStartY = stageY; + + if (this.actor._delegate && this.actor._delegate.getDragActor) { + this._dragActor = this.actor._delegate.getDragActor(); + Main.uiGroup.add_child(this._dragActor); + Main.uiGroup.set_child_above_sibling(this._dragActor, null); + Shell.util_set_hidden_from_pick(this._dragActor, true); + + // Drag actor does not always have to be the same as actor. For example drag actor + // can be an image that's part of the actor. So to perform "snap back" correctly we need + // to know what was the drag actor source. + if (this.actor._delegate.getDragActorSource) { + this._dragActorSource = this.actor._delegate.getDragActorSource(); + // If the user dragged from the source, then position + // the dragActor over it. Otherwise, center it + // around the pointer + let [sourceX, sourceY] = this._dragActorSource.get_transformed_position(); + let x, y; + if (stageX > sourceX && stageX <= sourceX + this._dragActor.width && + stageY > sourceY && stageY <= sourceY + this._dragActor.height) { + x = sourceX; + y = sourceY; + } else { + x = stageX - this._dragActor.width / 2; + y = stageY - this._dragActor.height / 2; + } + this._dragActor.set_position(x, y); + } else { + this._dragActorSource = this.actor; + } + this._dragOrigParent = undefined; + + this._dragOffsetX = this._dragActor.x - this._dragStartX; + this._dragOffsetY = this._dragActor.y - this._dragStartY; + } else { + this._dragActor = this.actor; + + this._dragActorSource = undefined; + this._dragOrigParent = this.actor.get_parent(); + this._dragActorHadFixedPos = this._dragActor.fixed_position_set; + this._dragOrigX = this._dragActor.allocation.x1; + this._dragOrigY = this._dragActor.allocation.y1; + this._dragOrigWidth = this._dragActor.allocation.get_width(); + this._dragOrigHeight = this._dragActor.allocation.get_height(); + this._dragOrigScale = this._dragActor.scale_x; + + // When the actor gets reparented to the uiGroup, it will be + // allocated its preferred size, so use that size instead of the + // current allocation size. + const [, newAllocatedWidth] = this._dragActor.get_preferred_width(-1); + const [, newAllocatedHeight] = this._dragActor.get_preferred_height(-1); + + const transformedExtents = this._dragActor.get_transformed_extents(); + + // Set the actor's scale such that it will keep the same + // transformed size when it's reparented to the uiGroup + this._dragActor.set_scale( + transformedExtents.get_width() / newAllocatedWidth, + transformedExtents.get_height() / newAllocatedHeight); + + this._dragOffsetX = transformedExtents.origin.x - this._dragStartX; + this._dragOffsetY = transformedExtents.origin.y - this._dragStartY; + + this._dragOrigParent.remove_actor(this._dragActor); + Main.uiGroup.add_child(this._dragActor); + Main.uiGroup.set_child_above_sibling(this._dragActor, null); + Shell.util_set_hidden_from_pick(this._dragActor, true); + } + + this._dragActorDestroyId = this._dragActor.connect('destroy', () => { + // Cancel ongoing animation (if any) + this._finishAnimation(); + + this._dragActor = null; + if (this._dragState == DragState.DRAGGING) + this._dragState = DragState.CANCELLED; + }); + this._dragOrigOpacity = this._dragActor.opacity; + if (this._dragActorOpacity != undefined) + this._dragActor.opacity = this._dragActorOpacity; + + this._snapBackX = this._dragStartX + this._dragOffsetX; + this._snapBackY = this._dragStartY + this._dragOffsetY; + this._snapBackScale = this._dragActor.scale_x; + + let origDragOffsetX = this._dragOffsetX; + let origDragOffsetY = this._dragOffsetY; + let [transX, transY] = this._dragActor.get_translation(); + this._dragOffsetX -= transX; + this._dragOffsetY -= transY; + + this._dragActor.set_position( + this._dragX + this._dragOffsetX, + this._dragY + this._dragOffsetY); + + if (this._dragActorMaxSize != undefined) { + let [scaledWidth, scaledHeight] = this._dragActor.get_transformed_size(); + let currentSize = Math.max(scaledWidth, scaledHeight); + if (currentSize > this._dragActorMaxSize) { + let scale = this._dragActorMaxSize / currentSize; + let origScale = this._dragActor.scale_x; + + // The position of the actor changes as we scale + // around the drag position, but we can't just tween + // to the final position because that tween would + // fight with updates as the user continues dragging + // the mouse; instead we do the position computations in + // a ::new-frame handler. + this._dragActor.ease({ + scale_x: scale * origScale, + scale_y: scale * origScale, + duration: SCALE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + + this._dragActor.get_transition('scale-x').connect('new-frame', () => { + let currentScale = this._dragActor.scale_x / origScale; + this._dragOffsetX = currentScale * origDragOffsetX - transX; + this._dragOffsetY = currentScale * origDragOffsetY - transY; + this._dragActor.set_position( + this._dragX + this._dragOffsetX, + this._dragY + this._dragOffsetY); + }); + } + } + } + + _maybeStartDrag(event) { + let [stageX, stageY] = event.get_coords(); + + // See if the user has moved the mouse enough to trigger a drag + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + let threshold = St.Settings.get().drag_threshold * scaleFactor; + if (!currentDraggable && + (Math.abs(stageX - this._dragStartX) > threshold || + Math.abs(stageY - this._dragStartY) > threshold)) { + this.startDrag(stageX, stageY, event.get_time(), this._touchSequence, event.get_device()); + this._updateDragPosition(event); + } + + return true; + } + + _pickTargetActor() { + return this._dragActor.get_stage().get_actor_at_pos(Clutter.PickMode.ALL, + this._dragX, this._dragY); + } + + _updateDragHover() { + this._updateHoverId = 0; + let target = this._pickTargetActor(); + + let dragEvent = { + x: this._dragX, + y: this._dragY, + dragActor: this._dragActor, + source: this.actor._delegate, + targetActor: target, + }; + + let targetActorDestroyHandlerId; + let handleTargetActorDestroyClosure; + handleTargetActorDestroyClosure = () => { + target = this._pickTargetActor(); + dragEvent.targetActor = target; + targetActorDestroyHandlerId = + target.connect('destroy', handleTargetActorDestroyClosure); + }; + targetActorDestroyHandlerId = + target.connect('destroy', handleTargetActorDestroyClosure); + + for (let i = 0; i < dragMonitors.length; i++) { + let motionFunc = dragMonitors[i].dragMotion; + if (motionFunc) { + let result = motionFunc(dragEvent); + if (result != DragMotionResult.CONTINUE) { + global.display.set_cursor(DRAG_CURSOR_MAP[result]); + return GLib.SOURCE_REMOVE; + } + } + } + dragEvent.targetActor.disconnect(targetActorDestroyHandlerId); + + while (target) { + if (target._delegate && target._delegate.handleDragOver) { + let [r_, targX, targY] = target.transform_stage_point(this._dragX, this._dragY); + // We currently loop through all parents on drag-over even if one of the children has handled it. + // We can check the return value of the function and break the loop if it's true if we don't want + // to continue checking the parents. + let result = target._delegate.handleDragOver(this.actor._delegate, + this._dragActor, + targX, + targY, + 0); + if (result != DragMotionResult.CONTINUE) { + global.display.set_cursor(DRAG_CURSOR_MAP[result]); + return GLib.SOURCE_REMOVE; + } + } + target = target.get_parent(); + } + global.display.set_cursor(Meta.Cursor.DND_IN_DRAG); + return GLib.SOURCE_REMOVE; + } + + _queueUpdateDragHover() { + if (this._updateHoverId) + return; + + this._updateHoverId = GLib.idle_add(GLib.PRIORITY_DEFAULT, + this._updateDragHover.bind(this)); + GLib.Source.set_name_by_id(this._updateHoverId, '[gnome-shell] this._updateDragHover'); + } + + _updateDragPosition(event) { + let [stageX, stageY] = event.get_coords(); + this._dragX = stageX; + this._dragY = stageY; + this._dragActor.set_position(stageX + this._dragOffsetX, + stageY + this._dragOffsetY); + + this._queueUpdateDragHover(); + return true; + } + + _dragActorDropped(event) { + let [dropX, dropY] = event.get_coords(); + let target = this._dragActor.get_stage().get_actor_at_pos(Clutter.PickMode.ALL, + dropX, dropY); + + // We call observers only once per motion with the innermost + // target actor. If necessary, the observer can walk the + // parent itself. + let dropEvent = { + dropActor: this._dragActor, + targetActor: target, + clutterEvent: event, + }; + for (let i = 0; i < dragMonitors.length; i++) { + let dropFunc = dragMonitors[i].dragDrop; + if (dropFunc) { + switch (dropFunc(dropEvent)) { + case DragDropResult.FAILURE: + case DragDropResult.SUCCESS: + return true; + case DragDropResult.CONTINUE: + continue; + } + } + } + + // At this point it is too late to cancel a drag by destroying + // the actor, the fate of which is decided by acceptDrop and its + // side-effects + this._dragCancellable = false; + + while (target) { + if (target._delegate && target._delegate.acceptDrop) { + let [r_, targX, targY] = target.transform_stage_point(dropX, dropY); + let accepted = false; + try { + accepted = target._delegate.acceptDrop(this.actor._delegate, + this._dragActor, targX, targY, event.get_time()); + } catch (e) { + // On error, skip this target + logError(e, "Skipping drag target"); + } + if (accepted) { + // If it accepted the drop without taking the actor, + // handle it ourselves. + if (this._dragActor && this._dragActor.get_parent() == Main.uiGroup) { + if (this._restoreOnSuccess) { + this._restoreDragActor(event.get_time()); + return true; + } else { + this._dragActor.destroy(); + } + } + + this._dragState = DragState.INIT; + global.display.set_cursor(Meta.Cursor.DEFAULT); + this.emit('drag-end', event.get_time(), true); + this._dragComplete(); + return true; + } + } + target = target.get_parent(); + } + + this._cancelDrag(event.get_time()); + + return true; + } + + _getRestoreLocation() { + let x, y, scale; + + if (this._dragActorSource && this._dragActorSource.visible) { + // Snap the clone back to its source + [x, y] = this._dragActorSource.get_transformed_position(); + let [sourceScaledWidth] = this._dragActorSource.get_transformed_size(); + scale = sourceScaledWidth ? sourceScaledWidth / this._dragActor.width : 0; + } else if (this._dragOrigParent) { + // Snap the actor back to its original position within + // its parent, adjusting for the fact that the parent + // may have been moved or scaled + let [parentX, parentY] = this._dragOrigParent.get_transformed_position(); + let [parentWidth] = this._dragOrigParent.get_size(); + let [parentScaledWidth] = this._dragOrigParent.get_transformed_size(); + let parentScale = 1.0; + if (parentWidth != 0) + parentScale = parentScaledWidth / parentWidth; + + // Also adjust for the difference in the original actor width + // and the width it is now (children of uiGroup always get + // allocated their preferred size) + const childScaleX = + this._dragOrigWidth / this._dragActor.allocation.get_width(); + + x = parentX + parentScale * this._dragOrigX; + y = parentY + parentScale * this._dragOrigY; + scale = this._dragOrigScale * parentScale * childScaleX; + } else { + // Snap back actor to its original stage position + x = this._snapBackX; + y = this._snapBackY; + scale = this._snapBackScale; + } + + return [x, y, scale]; + } + + _cancelDrag(eventTime) { + this.emit('drag-cancelled', eventTime); + let wasCancelled = this._dragState == DragState.CANCELLED; + this._dragState = DragState.CANCELLED; + + if (this._actorDestroyed || wasCancelled) { + global.display.set_cursor(Meta.Cursor.DEFAULT); + if (!this._buttonDown) + this._dragComplete(); + this.emit('drag-end', eventTime, false); + if (!this._dragOrigParent && this._dragActor) + this._dragActor.destroy(); + + return; + } + + let [snapBackX, snapBackY, snapBackScale] = this._getRestoreLocation(); + + this._animateDragEnd(eventTime, { + x: snapBackX, + y: snapBackY, + scale_x: snapBackScale, + scale_y: snapBackScale, + duration: SNAP_BACK_ANIMATION_TIME, + }); + } + + _restoreDragActor(eventTime) { + this._dragState = DragState.INIT; + let [restoreX, restoreY, restoreScale] = this._getRestoreLocation(); + + // fade the actor back in at its original location + this._dragActor.set_position(restoreX, restoreY); + this._dragActor.set_scale(restoreScale, restoreScale); + this._dragActor.opacity = 0; + + this._animateDragEnd(eventTime, { + duration: REVERT_ANIMATION_TIME, + }); + } + + _animateDragEnd(eventTime, params) { + this._animationInProgress = true; + + // start the animation + this._dragActor.ease(Object.assign(params, { + opacity: this._dragOrigOpacity, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._onAnimationComplete(this._dragActor, eventTime); + }, + })); + } + + _finishAnimation() { + if (!this._animationInProgress) + return; + + this._animationInProgress = false; + if (!this._buttonDown) + this._dragComplete(); + + global.display.set_cursor(Meta.Cursor.DEFAULT); + } + + _onAnimationComplete(dragActor, eventTime) { + if (this._dragOrigParent) { + Main.uiGroup.remove_child(this._dragActor); + this._dragOrigParent.add_actor(this._dragActor); + dragActor.set_scale(this._dragOrigScale, this._dragOrigScale); + if (this._dragActorHadFixedPos) + dragActor.set_position(this._dragOrigX, this._dragOrigY); + else + dragActor.fixed_position_set = false; + } else { + dragActor.destroy(); + } + + this.emit('drag-end', eventTime, false); + this._finishAnimation(); + } + + _dragComplete() { + if (!this._actorDestroyed && this._dragActor) + Shell.util_set_hidden_from_pick(this._dragActor, false); + + this._ungrabEvents(); + global.sync_pointer(); + + if (this._updateHoverId) { + GLib.source_remove(this._updateHoverId); + this._updateHoverId = 0; + } + + if (this._dragActor) { + this._dragActor.disconnect(this._dragActorDestroyId); + this._dragActor = null; + } + + this._dragState = DragState.INIT; + currentDraggable = null; + } +}; +Signals.addSignalMethods(_Draggable.prototype); + +/** + * makeDraggable: + * @param {Clutter.Actor} actor: Source actor + * @param {Object=} params: Additional parameters + * @returns {Object} a new Draggable + * + * Create an object which controls drag and drop for the given actor. + * + * If %manualMode is %true in @params, do not automatically start + * drag and drop on click + * + * If %dragActorMaxSize is present in @params, the drag actor will + * be scaled down to be no larger than that size in pixels. + * + * If %dragActorOpacity is present in @params, the drag actor will + * will be set to have that opacity during the drag. + * + * Note that when the drag actor is the source actor and the drop + * succeeds, the actor scale and opacity aren't reset; if the drop + * target wants to reuse the actor, it's up to the drop target to + * reset these values. + */ +function makeDraggable(actor, params) { + return new _Draggable(actor, params); +} diff --git a/js/ui/edgeDragAction.js b/js/ui/edgeDragAction.js new file mode 100644 index 0000000..8502170 --- /dev/null +++ b/js/ui/edgeDragAction.js @@ -0,0 +1,77 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported EdgeDragAction */ + +const { Clutter, GObject, Meta, St } = imports.gi; + +const Main = imports.ui.main; + +var EDGE_THRESHOLD = 20; +var DRAG_DISTANCE = 80; + +var EdgeDragAction = GObject.registerClass({ + Signals: { 'activated': {} }, +}, class EdgeDragAction extends Clutter.GestureAction { + _init(side, allowedModes) { + super._init(); + this._side = side; + this._allowedModes = allowedModes; + this.set_n_touch_points(1); + + global.display.connect('grab-op-begin', () => this.cancel()); + } + + _getMonitorRect(x, y) { + let rect = new Meta.Rectangle({ x: x - 1, y: y - 1, width: 1, height: 1 }); + let monitorIndex = global.display.get_monitor_index_for_rect(rect); + + return global.display.get_monitor_geometry(monitorIndex); + } + + vfunc_gesture_prepare(_actor) { + if (this.get_n_current_points() == 0) + return false; + + if (!(this._allowedModes & Main.actionMode)) + return false; + + let [x, y] = this.get_press_coords(0); + let monitorRect = this._getMonitorRect(x, y); + + return (this._side == St.Side.LEFT && x < monitorRect.x + EDGE_THRESHOLD) || + (this._side == St.Side.RIGHT && x > monitorRect.x + monitorRect.width - EDGE_THRESHOLD) || + (this._side == St.Side.TOP && y < monitorRect.y + EDGE_THRESHOLD) || + (this._side == St.Side.BOTTOM && y > monitorRect.y + monitorRect.height - EDGE_THRESHOLD); + } + + vfunc_gesture_progress(_actor) { + let [startX, startY] = this.get_press_coords(0); + let [x, y] = this.get_motion_coords(0); + let offsetX = Math.abs(x - startX); + let offsetY = Math.abs(y - startY); + + if (offsetX < EDGE_THRESHOLD && offsetY < EDGE_THRESHOLD) + return true; + + if ((offsetX > offsetY && + (this._side == St.Side.TOP || this._side == St.Side.BOTTOM)) || + (offsetY > offsetX && + (this._side == St.Side.LEFT || this._side == St.Side.RIGHT))) { + this.cancel(); + return false; + } + + return true; + } + + vfunc_gesture_end(_actor) { + let [startX, startY] = this.get_press_coords(0); + let [x, y] = this.get_motion_coords(0); + let monitorRect = this._getMonitorRect(startX, startY); + + if ((this._side == St.Side.TOP && y > monitorRect.y + DRAG_DISTANCE) || + (this._side == St.Side.BOTTOM && y < monitorRect.y + monitorRect.height - DRAG_DISTANCE) || + (this._side == St.Side.LEFT && x > monitorRect.x + DRAG_DISTANCE) || + (this._side == St.Side.RIGHT && x < monitorRect.x + monitorRect.width - DRAG_DISTANCE)) + this.emit('activated'); + } +}); diff --git a/js/ui/endSessionDialog.js b/js/ui/endSessionDialog.js new file mode 100644 index 0000000..ebf4f4d --- /dev/null +++ b/js/ui/endSessionDialog.js @@ -0,0 +1,810 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported init, EndSessionDialog */ +/* + * Copyright 2010-2016 Red Hat, Inc + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see <http://www.gnu.org/licenses/>. + */ + +const { AccountsService, Clutter, Gio, + GLib, GObject, Pango, Polkit, Shell, St, UPowerGlib: UPower } = imports.gi; + +const CheckBox = imports.ui.checkBox; +const Dialog = imports.ui.dialog; +const GnomeSession = imports.misc.gnomeSession; +const LoginManager = imports.misc.loginManager; +const ModalDialog = imports.ui.modalDialog; +const UserWidget = imports.ui.userWidget; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const _ITEM_ICON_SIZE = 64; + +const LOW_BATTERY_THRESHOLD = 30; + +const EndSessionDialogIface = loadInterfaceXML('org.gnome.SessionManager.EndSessionDialog'); + +const logoutDialogContent = { + subjectWithUser: C_("title", "Log Out %s"), + subject: C_("title", "Log Out"), + descriptionWithUser(user, seconds) { + return ngettext( + '%s will be logged out automatically in %d second.', + '%s will be logged out automatically in %d seconds.', + seconds).format(user, seconds); + }, + description(seconds) { + return ngettext( + 'You will be logged out automatically in %d second.', + 'You will be logged out automatically in %d seconds.', + seconds).format(seconds); + }, + showBatteryWarning: false, + confirmButtons: [{ + signal: 'ConfirmedLogout', + label: C_('button', 'Log Out'), + }], + showOtherSessions: false, +}; + +const shutdownDialogContent = { + subject: C_("title", "Power Off"), + subjectWithUpdates: C_("title", "Install Updates & Power Off"), + description(seconds) { + return ngettext( + 'The system will power off automatically in %d second.', + 'The system will power off automatically in %d seconds.', + seconds).format(seconds); + }, + checkBoxText: C_("checkbox", "Install pending software updates"), + showBatteryWarning: true, + confirmButtons: [{ + signal: 'ConfirmedShutdown', + label: C_('button', 'Power Off'), + }], + iconName: 'system-shutdown-symbolic', + showOtherSessions: true, +}; + +const restartDialogContent = { + subject: C_("title", "Restart"), + subjectWithUpdates: C_('title', 'Install Updates & Restart'), + description(seconds) { + return ngettext( + 'The system will restart automatically in %d second.', + 'The system will restart automatically in %d seconds.', + seconds).format(seconds); + }, + checkBoxText: C_('checkbox', 'Install pending software updates'), + showBatteryWarning: true, + confirmButtons: [{ + signal: 'ConfirmedReboot', + label: C_('button', 'Restart'), + }], + iconName: 'view-refresh-symbolic', + showOtherSessions: true, +}; + +const restartUpdateDialogContent = { + + subject: C_("title", "Restart & Install Updates"), + description(seconds) { + return ngettext( + 'The system will automatically restart and install updates in %d second.', + 'The system will automatically restart and install updates in %d seconds.', + seconds).format(seconds); + }, + showBatteryWarning: true, + confirmButtons: [{ + signal: 'ConfirmedReboot', + label: C_('button', 'Restart & Install'), + }], + unusedFutureButtonForTranslation: C_("button", "Install & 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 & Install'), + }], + iconName: 'view-refresh-symbolic', + showOtherSessions: true, +}; + +const DialogType = { + LOGOUT: 0 /* GSM_SHELL_END_SESSION_DIALOG_TYPE_LOGOUT */, + SHUTDOWN: 1 /* GSM_SHELL_END_SESSION_DIALOG_TYPE_SHUTDOWN */, + RESTART: 2 /* GSM_SHELL_END_SESSION_DIALOG_TYPE_RESTART */, + UPDATE_RESTART: 3, + UPGRADE_RESTART: 4, +}; + +const DialogContent = { + 0 /* DialogType.LOGOUT */: logoutDialogContent, + 1 /* DialogType.SHUTDOWN */: shutdownDialogContent, + 2 /* DialogType.RESTART */: restartDialogContent, + 3 /* DialogType.UPDATE_RESTART */: restartUpdateDialogContent, + 4 /* DialogType.UPGRADE_RESTART */: restartUpgradeDialogContent, +}; + +var MAX_USERS_IN_SESSION_DIALOG = 5; + +const LogindSessionIface = loadInterfaceXML('org.freedesktop.login1.Session'); +const LogindSession = Gio.DBusProxy.makeProxyWrapper(LogindSessionIface); + +const PkOfflineIface = loadInterfaceXML('org.freedesktop.PackageKit.Offline'); +const PkOfflineProxy = Gio.DBusProxy.makeProxyWrapper(PkOfflineIface); + +const UPowerIface = loadInterfaceXML('org.freedesktop.UPower.Device'); +const UPowerProxy = Gio.DBusProxy.makeProxyWrapper(UPowerIface); + +function findAppFromInhibitor(inhibitor) { + let desktopFile; + try { + [desktopFile] = inhibitor.GetAppIdSync(); + } catch (e) { + // XXX -- sometimes JIT inhibitors generated by gnome-session + // get removed too soon. Don't fail in this case. + log('gnome-session gave us a dead inhibitor: %s'.format(inhibitor.get_object_path())); + return null; + } + + if (!GLib.str_has_suffix(desktopFile, '.desktop')) + desktopFile += '.desktop'; + + return Shell.AppSystem.get_default().lookup_heuristic_basename(desktopFile); +} + +// The logout timer only shows updates every 10 seconds +// until the last 10 seconds, then it shows updates every +// second. This function takes a given time and returns +// what we should show to the user for that time. +function _roundSecondsToInterval(totalSeconds, secondsLeft, interval) { + let time; + + time = Math.ceil(secondsLeft); + + // Final count down is in decrements of 1 + if (time <= interval) + return time; + + // Round up higher than last displayable time interval + time += interval - 1; + + // Then round down to that time interval + if (time > totalSeconds) + time = Math.ceil(totalSeconds); + else + time -= time % interval; + + return time; +} + +function _setCheckBoxLabel(checkBox, text) { + let label = checkBox.getLabelActor(); + + if (text) { + label.set_text(text); + checkBox.show(); + } else { + label.set_text(''); + checkBox.hide(); + } +} + +function init() { + // This always returns the same singleton object + // By instantiating it initially, we register the + // bus object, etc. + new EndSessionDialog(); +} + +var EndSessionDialog = GObject.registerClass( +class EndSessionDialog extends ModalDialog.ModalDialog { + _init() { + super._init({ styleClass: 'end-session-dialog', + destroyOnClose: false }); + + this._loginManager = LoginManager.getLoginManager(); + this._loginManager.canRebootToBootLoaderMenu( + (canRebootToBootLoaderMenu, unusedNeedsAuth) => { + this._canRebootToBootLoaderMenu = canRebootToBootLoaderMenu; + }); + + this._userManager = AccountsService.UserManager.get_default(); + this._user = this._userManager.get_user(GLib.get_user_name()); + this._updatesPermission = null; + + this._pkOfflineProxy = new PkOfflineProxy(Gio.DBus.system, + 'org.freedesktop.PackageKit', + '/org/freedesktop/PackageKit', + this._onPkOfflineProxyCreated.bind(this)); + + this._powerProxy = new UPowerProxy(Gio.DBus.system, + 'org.freedesktop.UPower', + '/org/freedesktop/UPower/devices/DisplayDevice', + (proxy, error) => { + if (error) { + log(error.message); + return; + } + this._powerProxy.connect('g-properties-changed', + this._sync.bind(this)); + this._sync(); + }); + + this._secondsLeft = 0; + this._totalSecondsToStayOpen = 0; + this._applications = []; + this._sessions = []; + this._capturedEventId = 0; + this._rebootButton = null; + this._rebootButtonAlt = null; + + this.connect('destroy', + this._onDestroy.bind(this)); + this.connect('opened', + this._onOpened.bind(this)); + + this._userLoadedId = this._user.connect('notify::is-loaded', this._sync.bind(this)); + this._userChangedId = this._user.connect('changed', this._sync.bind(this)); + + this._messageDialogContent = new Dialog.MessageDialogContent(); + + this._checkBox = new CheckBox.CheckBox(); + this._checkBox.connect('clicked', this._sync.bind(this)); + this._messageDialogContent.add_child(this._checkBox); + + this._batteryWarning = new St.Label({ + style_class: 'end-session-dialog-battery-warning', + text: _('Low battery power: please plug in before installing updates.'), + }); + this._batteryWarning.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._batteryWarning.clutter_text.line_wrap = true; + this._messageDialogContent.add_child(this._batteryWarning); + + this.contentLayout.add_child(this._messageDialogContent); + + this._applicationSection = new Dialog.ListSection({ + title: _('Some applications are busy or have unsaved work'), + }); + this.contentLayout.add_child(this._applicationSection); + + this._sessionSection = new Dialog.ListSection({ + title: _('Other users are logged in'), + }); + this.contentLayout.add_child(this._sessionSection); + + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(EndSessionDialogIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/gnome/SessionManager/EndSessionDialog'); + } + + async _onPkOfflineProxyCreated(proxy, error) { + if (error) { + log(error.message); + return; + } + + // Creating a D-Bus proxy won't propagate SERVICE_UNKNOWN or NAME_HAS_NO_OWNER + // errors if PackageKit is not available, but the GIO implementation will make + // sure in that case that the proxy's g-name-owner is set to null, so check that. + if (this._pkOfflineProxy.g_name_owner === null) { + this._pkOfflineProxy = null; + return; + } + + // It only makes sense to check for this permission if PackageKit is available. + try { + this._updatesPermission = await Polkit.Permission.new( + 'org.freedesktop.packagekit.trigger-offline-update', null, null); + } catch (e) { + log('No permission to trigger offline updates: %s'.format(e.toString())); + } + } + + _onDestroy() { + this._user.disconnect(this._userLoadedId); + this._user.disconnect(this._userChangedId); + } + + _isDischargingBattery() { + return this._powerProxy.IsPresent && + this._powerProxy.State !== UPower.DeviceState.CHARGING && + this._powerProxy.State !== UPower.DeviceState.FULLY_CHARGED; + } + + _isBatteryLow() { + return this._isDischargingBattery() && this._powerProxy.Percentage < LOW_BATTERY_THRESHOLD; + } + + _shouldShowLowBatteryWarning(dialogContent) { + if (!dialogContent.showBatteryWarning) + return false; + + if (!this._isBatteryLow()) + return false; + + if (this._checkBox.checked) + return true; + + // Show the warning if updates have already been triggered, but + // the user doesn't have enough permissions to cancel them. + let updatesAllowed = this._updatesPermission && this._updatesPermission.allowed; + return this._updateInfo.UpdatePrepared && this._updateInfo.UpdateTriggered && !updatesAllowed; + } + + _sync() { + let open = this.state == ModalDialog.State.OPENING || this.state == ModalDialog.State.OPENED; + if (!open) + return; + + let dialogContent = DialogContent[this._type]; + + let subject = dialogContent.subject; + + // Use different title when we are installing updates + if (dialogContent.subjectWithUpdates && this._checkBox.checked) + subject = dialogContent.subjectWithUpdates; + + this._batteryWarning.visible = this._shouldShowLowBatteryWarning(dialogContent); + + let description; + let displayTime = _roundSecondsToInterval(this._totalSecondsToStayOpen, + this._secondsLeft, + 10); + + if (this._user.is_loaded) { + let realName = this._user.get_real_name(); + + if (realName != null) { + if (dialogContent.subjectWithUser) + subject = dialogContent.subjectWithUser.format(realName); + + if (dialogContent.descriptionWithUser) + description = dialogContent.descriptionWithUser(realName, displayTime); + } + } + + // Use a different description when we are installing a system upgrade + // if the PackageKit proxy is available (i.e. PackageKit is available). + if (dialogContent.upgradeDescription) { + const { name, version } = this._updateInfo.PreparedUpgrade; + if (name != null && version != null) + description = dialogContent.upgradeDescription(name, version); + } + + // Fall back to regular description + if (!description) + description = dialogContent.description(displayTime); + + this._messageDialogContent.title = subject; + this._messageDialogContent.description = description; + + let hasApplications = this._applications.length > 0; + let hasSessions = this._sessions.length > 0; + + this._applicationSection.visible = hasApplications; + this._sessionSection.visible = hasSessions; + } + + _onCapturedEvent(actor, event) { + let altEnabled = false; + + let type = event.type(); + if (type !== Clutter.EventType.KEY_PRESS && type !== Clutter.EventType.KEY_RELEASE) + return Clutter.EVENT_PROPAGATE; + + let key = event.get_key_symbol(); + if (key !== Clutter.KEY_Alt_L && key !== Clutter.KEY_Alt_R) + return Clutter.EVENT_PROPAGATE; + + if (type === Clutter.EventType.KEY_PRESS) + altEnabled = true; + + this._rebootButton.visible = !altEnabled; + this._rebootButtonAlt.visible = altEnabled; + + return Clutter.EVENT_PROPAGATE; + } + + _updateButtons() { + this.clearButtons(); + + this.addButton({ action: this.cancel.bind(this), + label: _("Cancel"), + key: Clutter.KEY_Escape }); + + let dialogContent = DialogContent[this._type]; + for (let i = 0; i < dialogContent.confirmButtons.length; i++) { + let signal = dialogContent.confirmButtons[i].signal; + let label = dialogContent.confirmButtons[i].label; + let button = this.addButton({ + action: () => { + this.close(true); + let signalId = this.connect('closed', () => { + this.disconnect(signalId); + this._confirm(signal); + }); + }, + label, + }); + + // Add Alt "Boot Options" option to the Reboot button + if (this._canRebootToBootLoaderMenu && signal === 'ConfirmedReboot') { + this._rebootButton = button; + this._rebootButtonAlt = this.addButton({ + action: () => { + this.close(true); + let signalId = this.connect('closed', () => { + this.disconnect(signalId); + this._confirmRebootToBootLoaderMenu(); + }); + }, + label: C_('button', 'Boot Options'), + }); + this._rebootButtonAlt.visible = false; + this._capturedEventId = global.stage.connect('captured-event', + this._onCapturedEvent.bind(this)); + } + } + } + + _stopAltCapture() { + if (this._capturedEventId > 0) { + global.stage.disconnect(this._capturedEventId); + this._capturedEventId = 0; + } + this._rebootButton = null; + this._rebootButtonAlt = null; + } + + close(skipSignal) { + super.close(); + + if (!skipSignal) + this._dbusImpl.emit_signal('Closed', null); + } + + cancel() { + this._stopTimer(); + this._stopAltCapture(); + this._dbusImpl.emit_signal('Canceled', null); + this.close(); + } + + _confirmRebootToBootLoaderMenu() { + this._loginManager.setRebootToBootLoaderMenu(); + this._confirm('ConfirmedReboot'); + } + + _confirm(signal) { + let callback = () => { + this._fadeOutDialog(); + this._stopTimer(); + this._stopAltCapture(); + this._dbusImpl.emit_signal(signal, null); + }; + + // Offline update not available; just emit the signal + if (!this._checkBox.visible) { + callback(); + return; + } + + // Trigger the offline update as requested + if (this._checkBox.checked) { + switch (signal) { + case "ConfirmedReboot": + this._triggerOfflineUpdateReboot(callback); + break; + case "ConfirmedShutdown": + // To actually trigger the offline update, we need to + // reboot to do the upgrade. When the upgrade is complete, + // the computer will shut down automatically. + signal = "ConfirmedReboot"; + this._triggerOfflineUpdateShutdown(callback); + break; + default: + callback(); + break; + } + } else { + this._triggerOfflineUpdateCancel(callback); + } + } + + _onOpened() { + this._sync(); + } + + _triggerOfflineUpdateReboot(callback) { + // Handle this gracefully if PackageKit is not available. + if (!this._pkOfflineProxy) { + callback(); + return; + } + + this._pkOfflineProxy.TriggerRemote('reboot', (result, error) => { + if (error) + log(error.message); + + callback(); + }); + } + + _triggerOfflineUpdateShutdown(callback) { + // Handle this gracefully if PackageKit is not available. + if (!this._pkOfflineProxy) { + callback(); + return; + } + + this._pkOfflineProxy.TriggerRemote('power-off', (result, error) => { + if (error) + log(error.message); + + callback(); + }); + } + + _triggerOfflineUpdateCancel(callback) { + // Handle this gracefully if PackageKit is not available. + if (!this._pkOfflineProxy) { + callback(); + return; + } + + this._pkOfflineProxy.CancelRemote((result, error) => { + if (error) + log(error.message); + + callback(); + }); + } + + _startTimer() { + let startTime = GLib.get_monotonic_time(); + this._secondsLeft = this._totalSecondsToStayOpen; + + this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 1, () => { + let currentTime = GLib.get_monotonic_time(); + let secondsElapsed = (currentTime - startTime) / 1000000; + + this._secondsLeft = this._totalSecondsToStayOpen - secondsElapsed; + if (this._secondsLeft > 0) { + this._sync(); + return GLib.SOURCE_CONTINUE; + } + + let dialogContent = DialogContent[this._type]; + let button = dialogContent.confirmButtons[dialogContent.confirmButtons.length - 1]; + this._confirm(button.signal); + this._timerId = 0; + + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._timerId, '[gnome-shell] this._confirm'); + } + + _stopTimer() { + if (this._timerId > 0) { + GLib.source_remove(this._timerId); + this._timerId = 0; + } + + this._secondsLeft = 0; + } + + _onInhibitorLoaded(inhibitor) { + if (!this._applications.includes(inhibitor)) { + // Stale inhibitor + return; + } + + let app = findAppFromInhibitor(inhibitor); + + if (app) { + let [description] = inhibitor.GetReasonSync(); + let listItem = new Dialog.ListSectionItem({ + icon_actor: app.create_icon_texture(_ITEM_ICON_SIZE), + title: app.get_name(), + description, + }); + this._applicationSection.list.add_child(listItem); + } else { + // inhibiting app is a service, not an application + this._applications.splice(this._applications.indexOf(inhibitor), 1); + } + + this._sync(); + } + + _loadSessions() { + this._loginManager.listSessions(result => { + let n = 0; + for (let i = 0; i < result.length; i++) { + let [id_, uid_, userName, seat_, sessionPath] = result[i]; + let proxy = new LogindSession(Gio.DBus.system, 'org.freedesktop.login1', sessionPath); + + if (proxy.Class != 'user') + continue; + + if (proxy.State == 'closing') + continue; + + let sessionId = GLib.getenv('XDG_SESSION_ID'); + if (!sessionId) { + this._loginManager.getCurrentSessionProxy(currentSessionProxy => { + sessionId = currentSessionProxy.Id; + log('endSessionDialog: No XDG_SESSION_ID, fetched from logind: %d'.format(sessionId)); + }); + } + + if (proxy.Id == sessionId) + continue; + + let session = { user: this._userManager.get_user(userName), + username: userName, + type: proxy.Type, + remote: proxy.Remote }; + this._sessions.push(session); + + let userAvatar = new UserWidget.Avatar(session.user, { iconSize: _ITEM_ICON_SIZE }); + userAvatar.update(); + + userName = session.user.get_real_name() + ? session.user.get_real_name() : session.username; + + let userLabelText; + if (session.remote) + /* Translators: Remote here refers to a remote session, like a ssh login */ + userLabelText = _('%s (remote)').format(userName); + else if (session.type === 'tty') + /* Translators: Console here refers to a tty like a VT console */ + userLabelText = _('%s (console)').format(userName); + else + userLabelText = userName; + + let listItem = new Dialog.ListSectionItem({ + icon_actor: userAvatar, + title: userLabelText, + }); + this._sessionSection.list.add_child(listItem); + + // limit the number of entries + n++; + if (n == MAX_USERS_IN_SESSION_DIALOG) + break; + } + + this._sync(); + }); + } + + async _getUpdateInfo() { + const connection = this._pkOfflineProxy.get_connection(); + const reply = await connection.call( + this._pkOfflineProxy.g_name, + this._pkOfflineProxy.g_object_path, + 'org.freedesktop.DBus.Properties', + 'GetAll', + new GLib.Variant('(s)', [this._pkOfflineProxy.g_interface_name]), + null, + Gio.DBusCallFlags.NONE, + -1, + null); + const [info] = reply.recursiveUnpack(); + return info; + } + + async OpenAsync(parameters, invocation) { + let [type, timestamp, totalSecondsToStayOpen, inhibitorObjectPaths] = parameters; + this._totalSecondsToStayOpen = totalSecondsToStayOpen; + this._type = type; + + try { + this._updateInfo = await this._getUpdateInfo(); + } catch (e) { + if (this._pkOfflineProxy !== null) + log('Failed to get update info from PackageKit: %s'.format(e.message)); + + this._updateInfo = { + UpdateTriggered: false, + UpdatePrepared: false, + UpgradeTriggered: false, + PreparedUpgrade: {}, + }; + } + + // Only consider updates and upgrades if PackageKit is available. + if (this._pkOfflineProxy && this._type == DialogType.RESTART) { + if (this._updateInfo.UpdateTriggered) + this._type = DialogType.UPDATE_RESTART; + else if (this._updateInfo.UpgradeTriggered) + this._type = DialogType.UPGRADE_RESTART; + } + + this._applications = []; + this._applicationSection.list.destroy_all_children(); + + this._sessions = []; + this._sessionSection.list.destroy_all_children(); + + if (!(this._type in DialogContent)) { + invocation.return_dbus_error('org.gnome.Shell.ModalDialog.TypeError', + "Unknown dialog type requested"); + return; + } + + let dialogContent = DialogContent[this._type]; + + for (let i = 0; i < inhibitorObjectPaths.length; i++) { + let inhibitor = new GnomeSession.Inhibitor(inhibitorObjectPaths[i], proxy => { + this._onInhibitorLoaded(proxy); + }); + + this._applications.push(inhibitor); + } + + if (dialogContent.showOtherSessions) + this._loadSessions(); + + let updatesAllowed = this._updatesPermission && this._updatesPermission.allowed; + + _setCheckBoxLabel(this._checkBox, dialogContent.checkBoxText || ''); + this._checkBox.visible = dialogContent.checkBoxText && this._updateInfo.UpdatePrepared && updatesAllowed; + + if (this._type === DialogType.UPGRADE_RESTART) + this._checkBox.checked = this._checkBox.visible && this._updateInfo.UpdateTriggered && !this._isDischargingBattery(); + else + this._checkBox.checked = this._checkBox.visible && !this._isBatteryLow(); + + this._batteryWarning.visible = this._shouldShowLowBatteryWarning(dialogContent); + + this._updateButtons(); + + if (!this.open(timestamp)) { + invocation.return_dbus_error('org.gnome.Shell.ModalDialog.GrabError', + "Cannot grab pointer and keyboard"); + return; + } + + if (!dialogContent.disableTimer) + this._startTimer(); + + this._sync(); + + let signalId = this.connect('opened', () => { + invocation.return_value(null); + this.disconnect(signalId); + }); + } + + Close(_parameters, _invocation) { + this.close(); + } +}); diff --git a/js/ui/environment.js b/js/ui/environment.js new file mode 100644 index 0000000..5790aa0 --- /dev/null +++ b/js/ui/environment.js @@ -0,0 +1,400 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported init */ + +const Config = imports.misc.config; + +imports.gi.versions.Clutter = Config.LIBMUTTER_API_VERSION; +imports.gi.versions.Gio = '2.0'; +imports.gi.versions.GdkPixbuf = '2.0'; +imports.gi.versions.Gtk = '3.0'; +imports.gi.versions.Soup = '2.4'; +imports.gi.versions.TelepathyGLib = '0.12'; +imports.gi.versions.TelepathyLogger = '0.2'; + +const { Clutter, Gio, GLib, GObject, Meta, Polkit, Shell, St } = imports.gi; +const Gettext = imports.gettext; +const System = imports.system; + +Gio._promisify(Gio.DataInputStream.prototype, 'fill_async', 'fill_finish'); +Gio._promisify(Gio.DataInputStream.prototype, + 'read_line_async', 'read_line_finish'); +Gio._promisify(Gio.DBus, 'get', 'get_finish'); +Gio._promisify(Gio.DBusConnection.prototype, 'call', 'call_finish'); +Gio._promisify(Gio.DBusProxy, 'new', 'new_finish'); +Gio._promisify(Gio.DBusProxy.prototype, 'init_async', 'init_finish'); +Gio._promisify(Gio.DBusProxy.prototype, + 'call_with_unix_fd_list', 'call_with_unix_fd_list_finish'); +Gio._promisify(Polkit.Permission, 'new', 'new_finish'); + +let _localTimeZone = null; + +// We can't import shell JS modules yet, because they may have +// variable initializations, etc, that depend on init() already having +// been run. + + +// "monkey patch" in some varargs ClutterContainer methods; we need +// to do this per-container class since there is no representation +// of interfaces in Javascript +function _patchContainerClass(containerClass) { + // This one is a straightforward mapping of the C method + containerClass.prototype.child_set = function (actor, props) { + let meta = this.get_child_meta(actor); + for (let prop in props) + meta[prop] = props[prop]; + }; + + // clutter_container_add() actually is a an add-many-actors + // method. We conveniently, but somewhat dubiously, take the + // this opportunity to make it do something more useful. + containerClass.prototype.add = function (actor, props) { + this.add_actor(actor); + if (props) + this.child_set(actor, props); + }; +} + +function _patchLayoutClass(layoutClass, styleProps) { + if (styleProps) { + layoutClass.prototype.hookup_style = function (container) { + container.connect('style-changed', () => { + let node = container.get_theme_node(); + for (let prop in styleProps) { + let [found, length] = node.lookup_length(styleProps[prop], false); + if (found) + this[prop] = length; + } + }); + }; + } +} + +function _makeEaseCallback(params, cleanup) { + let onComplete = params.onComplete; + delete params.onComplete; + + let onStopped = params.onStopped; + delete params.onStopped; + + return isFinished => { + cleanup(); + + if (onStopped) + onStopped(isFinished); + if (onComplete && isFinished) + onComplete(); + }; +} + +function _getPropertyTarget(actor, propName) { + if (!propName.startsWith('@')) + return [actor, propName]; + + let [type, name, prop] = propName.split('.'); + switch (type) { + case '@layout': + return [actor.layout_manager, name]; + case '@actions': + return [actor.get_action(name), prop]; + case '@constraints': + return [actor.get_constraint(name), prop]; + case '@content': + return [actor.content, name]; + case '@effects': + return [actor.get_effect(name), prop]; + } + + throw new Error(`Invalid property name ${propName}`); +} + +function _easeActor(actor, params) { + actor.save_easing_state(); + + if (params.duration != undefined) + actor.set_easing_duration(params.duration); + delete params.duration; + + if (params.delay != undefined) + actor.set_easing_delay(params.delay); + delete params.delay; + + let repeatCount = 0; + if (params.repeatCount != undefined) + repeatCount = params.repeatCount; + delete params.repeatCount; + + let autoReverse = false; + if (params.autoReverse != undefined) + autoReverse = params.autoReverse; + delete params.autoReverse; + + // repeatCount doesn't include the initial iteration + const numIterations = repeatCount + 1; + // whether the transition should finish where it started + const isReversed = autoReverse && numIterations % 2 === 0; + + if (params.mode != undefined) + actor.set_easing_mode(params.mode); + delete params.mode; + + const prepare = () => { + Meta.disable_unredirect_for_display(global.display); + global.begin_work(); + }; + const cleanup = () => { + Meta.enable_unredirect_for_display(global.display); + global.end_work(); + }; + let callback = _makeEaseCallback(params, cleanup); + + // cancel overwritten transitions + let animatedProps = Object.keys(params).map(p => p.replace('_', '-', 'g')); + animatedProps.forEach(p => actor.remove_transition(p)); + + if (actor.get_easing_duration() > 0 || !isReversed) + actor.set(params); + actor.restore_easing_state(); + + let transition = animatedProps.map(p => actor.get_transition(p)) + .find(t => t !== null); + + if (transition && transition.delay) + transition.connect('started', () => prepare()); + else + prepare(); + + if (transition) { + transition.set({ repeatCount, autoReverse }); + transition.connect('stopped', (t, finished) => callback(finished)); + } else { + callback(true); + } +} + +function _easeActorProperty(actor, propName, target, params) { + // Avoid pointless difference with ease() + if (params.mode) + params.progress_mode = params.mode; + delete params.mode; + + if (params.duration) + params.duration = adjustAnimationTime(params.duration); + let duration = Math.floor(params.duration || 0); + + let repeatCount = 0; + if (params.repeatCount != undefined) + repeatCount = params.repeatCount; + delete params.repeatCount; + + let autoReverse = false; + if (params.autoReverse != undefined) + autoReverse = params.autoReverse; + delete params.autoReverse; + + // repeatCount doesn't include the initial iteration + const numIterations = repeatCount + 1; + // whether the transition should finish where it started + const isReversed = autoReverse && numIterations % 2 === 0; + + // Copy Clutter's behavior for implicit animations, see + // should_skip_implicit_transition() + if (actor instanceof Clutter.Actor && !actor.mapped) + duration = 0; + + const prepare = () => { + Meta.disable_unredirect_for_display(global.display); + global.begin_work(); + }; + const cleanup = () => { + Meta.enable_unredirect_for_display(global.display); + global.end_work(); + }; + let callback = _makeEaseCallback(params, cleanup); + + // cancel overwritten transition + actor.remove_transition(propName); + + if (duration == 0) { + let [obj, prop] = _getPropertyTarget(actor, propName); + + if (!isReversed) + obj[prop] = target; + + prepare(); + callback(true); + + return; + } + + let pspec = actor.find_property(propName); + let transition = new Clutter.PropertyTransition(Object.assign({ + property_name: propName, + interval: new Clutter.Interval({ value_type: pspec.value_type }), + remove_on_complete: true, + repeat_count: repeatCount, + auto_reverse: autoReverse, + }, params)); + actor.add_transition(propName, transition); + + transition.set_to(target); + + if (transition.delay) + transition.connect('started', () => prepare()); + else + prepare(); + + transition.connect('stopped', (t, finished) => callback(finished)); +} + +function _loggingFunc(...args) { + let fields = { 'MESSAGE': args.join(', ') }; + let domain = "GNOME Shell"; + + // If the caller is an extension, add it as metadata + let extension = imports.misc.extensionUtils.getCurrentExtension(); + if (extension != null) { + domain = extension.metadata.name; + fields['GNOME_SHELL_EXTENSION_UUID'] = extension.uuid; + fields['GNOME_SHELL_EXTENSION_NAME'] = extension.metadata.name; + } + + GLib.log_structured(domain, GLib.LogLevelFlags.LEVEL_MESSAGE, fields); +} + +function init() { + // Add some bindings to the global JS namespace + globalThis.global = Shell.Global.get(); + + globalThis.log = _loggingFunc; + + globalThis._ = Gettext.gettext; + globalThis.C_ = Gettext.pgettext; + globalThis.ngettext = Gettext.ngettext; + globalThis.N_ = s => s; + + GObject.gtypeNameBasedOnJSPath = true; + + // Miscellaneous monkeypatching + _patchContainerClass(St.BoxLayout); + + _patchLayoutClass(Clutter.GridLayout, { row_spacing: 'spacing-rows', + column_spacing: 'spacing-columns' }); + _patchLayoutClass(Clutter.BoxLayout, { spacing: 'spacing' }); + + let origSetEasingDuration = Clutter.Actor.prototype.set_easing_duration; + Clutter.Actor.prototype.set_easing_duration = function (msecs) { + origSetEasingDuration.call(this, adjustAnimationTime(msecs)); + }; + let origSetEasingDelay = Clutter.Actor.prototype.set_easing_delay; + Clutter.Actor.prototype.set_easing_delay = function (msecs) { + origSetEasingDelay.call(this, adjustAnimationTime(msecs)); + }; + + Clutter.Actor.prototype.ease = function (props) { + _easeActor(this, props); + }; + Clutter.Actor.prototype.ease_property = function (propName, target, params) { + _easeActorProperty(this, propName, target, params); + }; + St.Adjustment.prototype.ease = function (target, params) { + // we're not an actor of course, but we implement the same + // transition API as Clutter.Actor, so this works anyway + _easeActorProperty(this, 'value', target, params); + }; + + Clutter.Actor.prototype[Symbol.iterator] = function* () { + for (let c = this.get_first_child(); c; c = c.get_next_sibling()) + yield c; + }; + + Clutter.Actor.prototype.toString = function () { + return St.describe_actor(this); + }; + // Deprecation warning for former JS classes turned into an actor subclass + Object.defineProperty(Clutter.Actor.prototype, 'actor', { + get() { + let klass = this.constructor.name; + let { stack } = new Error(); + log(`Usage of object.actor is deprecated for ${klass}\n${stack}`); + return this; + }, + }); + + Gio._LocalFilePrototype.touch_async = function (callback) { + Shell.util_touch_file_async(this, callback); + }; + Gio._LocalFilePrototype.touch_finish = function (result) { + return Shell.util_touch_file_finish(this, result); + }; + + St.set_slow_down_factor = function (factor) { + let { stack } = new Error(); + log(`St.set_slow_down_factor() is deprecated, use St.Settings.slow_down_factor\n${stack}`); + St.Settings.get().slow_down_factor = factor; + }; + + let origToString = Object.prototype.toString; + Object.prototype.toString = function () { + let base = origToString.call(this); + try { + if ('actor' in this && this.actor instanceof Clutter.Actor) + return base.replace(/\]$/, ` delegate for ${this.actor.toString().substring(1)}`); + else + return base; + } catch (e) { + return base; + } + }; + + // Override to clear our own timezone cache as well + const origClearDateCaches = System.clearDateCaches; + System.clearDateCaches = function () { + _localTimeZone = null; + origClearDateCaches(); + }; + + // Work around https://bugzilla.mozilla.org/show_bug.cgi?id=508783 + Date.prototype.toLocaleFormat = function (format) { + if (_localTimeZone === null) + _localTimeZone = GLib.TimeZone.new_local(); + + let dt = GLib.DateTime.new(_localTimeZone, + this.getFullYear(), + this.getMonth() + 1, + this.getDate(), + this.getHours(), + this.getMinutes(), + this.getSeconds()); + return dt ? dt.format(format) : ''; + }; + + let slowdownEnv = GLib.getenv('GNOME_SHELL_SLOWDOWN_FACTOR'); + if (slowdownEnv) { + let factor = parseFloat(slowdownEnv); + if (!isNaN(factor) && factor > 0.0) + St.Settings.get().slow_down_factor = factor; + } + + // OK, now things are initialized enough that we can import shell JS + const Format = imports.format; + + String.prototype.format = Format.format; + + Math.clamp = function (x, lower, upper) { + return Math.min(Math.max(x, lower), upper); + }; +} + +// adjustAnimationTime: +// @msecs: time in milliseconds +// +// Adjust @msecs to account for St's enable-animations +// and slow-down-factor settings +function adjustAnimationTime(msecs) { + let settings = St.Settings.get(); + + if (!settings.enable_animations) + return 1; + return settings.slow_down_factor * msecs; +} + diff --git a/js/ui/extensionDownloader.js b/js/ui/extensionDownloader.js new file mode 100644 index 0000000..a7b40f9 --- /dev/null +++ b/js/ui/extensionDownloader.js @@ -0,0 +1,248 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported init, installExtension, uninstallExtension, checkForUpdates */ + +const { Clutter, Gio, GLib, GObject, Soup } = imports.gi; + +const Config = imports.misc.config; +const Dialog = imports.ui.dialog; +const ExtensionUtils = imports.misc.extensionUtils; +const FileUtils = imports.misc.fileUtils; +const Main = imports.ui.main; +const ModalDialog = imports.ui.modalDialog; + +var REPOSITORY_URL_DOWNLOAD = 'https://extensions.gnome.org/download-extension/%s.shell-extension.zip'; +var REPOSITORY_URL_INFO = 'https://extensions.gnome.org/extension-info/'; +var REPOSITORY_URL_UPDATE = 'https://extensions.gnome.org/update-info/'; + +let _httpSession; + +function installExtension(uuid, invocation) { + let params = { uuid, + shell_version: Config.PACKAGE_VERSION }; + + let message = Soup.form_request_new_from_hash('GET', REPOSITORY_URL_INFO, params); + + _httpSession.queue_message(message, () => { + if (message.status_code != Soup.KnownStatusCode.OK) { + Main.extensionManager.logExtensionError(uuid, 'downloading info: %d'.format(message.status_code)); + invocation.return_dbus_error('org.gnome.Shell.DownloadInfoError', message.status_code.toString()); + return; + } + + let info; + try { + info = JSON.parse(message.response_body.data); + } catch (e) { + Main.extensionManager.logExtensionError(uuid, 'parsing info: %s'.format(e.toString())); + invocation.return_dbus_error('org.gnome.Shell.ParseInfoError', e.toString()); + return; + } + + let dialog = new InstallExtensionDialog(uuid, info, invocation); + dialog.open(global.get_current_time()); + }); +} + +function uninstallExtension(uuid) { + let extension = Main.extensionManager.lookup(uuid); + if (!extension) + return false; + + // Don't try to uninstall system extensions + if (extension.type != ExtensionUtils.ExtensionType.PER_USER) + return false; + + if (!Main.extensionManager.unloadExtension(extension)) + return false; + + FileUtils.recursivelyDeleteDir(extension.dir, true); + + try { + const updatesDir = Gio.File.new_for_path(GLib.build_filenamev( + [global.userdatadir, 'extension-updates', extension.uuid])); + FileUtils.recursivelyDeleteDir(updatesDir, true); + } catch (e) { + // not an error + } + + return true; +} + +function gotExtensionZipFile(session, message, uuid, dir, callback, errback) { + if (message.status_code != Soup.KnownStatusCode.OK) { + errback('DownloadExtensionError', message.status_code); + return; + } + + try { + if (!dir.query_exists(null)) + dir.make_directory_with_parents(null); + } catch (e) { + errback('CreateExtensionDirectoryError', e); + return; + } + + let [file, stream] = Gio.File.new_tmp('XXXXXX.shell-extension.zip'); + let contents = message.response_body.flatten().get_as_bytes(); + stream.output_stream.write_bytes(contents, null); + stream.close(null); + let [success, pid] = GLib.spawn_async(null, + ['unzip', '-uod', dir.get_path(), '--', file.get_path()], + null, + GLib.SpawnFlags.SEARCH_PATH | GLib.SpawnFlags.DO_NOT_REAP_CHILD, + null); + + if (!success) { + errback('ExtractExtensionError'); + return; + } + + GLib.child_watch_add(GLib.PRIORITY_DEFAULT, pid, (o, status) => { + GLib.spawn_close_pid(pid); + + if (status != 0) + errback('ExtractExtensionError'); + else + callback(); + }); +} + +function downloadExtensionUpdate(uuid) { + if (!Main.extensionManager.updatesSupported) + return; + + let dir = Gio.File.new_for_path( + GLib.build_filenamev([global.userdatadir, 'extension-updates', uuid])); + + let params = { shell_version: Config.PACKAGE_VERSION }; + + let url = REPOSITORY_URL_DOWNLOAD.format(uuid); + let message = Soup.form_request_new_from_hash('GET', url, params); + + _httpSession.queue_message(message, session => { + gotExtensionZipFile(session, message, uuid, dir, () => { + Main.extensionManager.notifyExtensionUpdate(uuid); + }, (code, msg) => { + log('Error while downloading update for extension %s: %s (%s)'.format(uuid, code, msg)); + }); + }); +} + +function checkForUpdates() { + if (!Main.extensionManager.updatesSupported) + return; + + let metadatas = {}; + Main.extensionManager.getUuids().forEach(uuid => { + let extension = Main.extensionManager.lookup(uuid); + if (extension.type !== ExtensionUtils.ExtensionType.PER_USER) + return; + if (extension.hasUpdate) + return; + metadatas[uuid] = { + version: extension.metadata.version, + }; + }); + + if (Object.keys(metadatas).length === 0) + return; // nothing to update + + let versionCheck = global.settings.get_boolean( + 'disable-extension-version-validation'); + let params = { + shell_version: Config.PACKAGE_VERSION, + installed: JSON.stringify(metadatas), + disable_version_validation: versionCheck.toString(), + }; + + let url = REPOSITORY_URL_UPDATE; + let message = Soup.form_request_new_from_hash('GET', url, params); + _httpSession.queue_message(message, () => { + if (message.status_code != Soup.KnownStatusCode.OK) + return; + + let operations = JSON.parse(message.response_body.data); + for (let uuid in operations) { + let operation = operations[uuid]; + if (operation === 'upgrade' || operation === 'downgrade') + downloadExtensionUpdate(uuid); + } + }); +} + +var InstallExtensionDialog = GObject.registerClass( +class InstallExtensionDialog extends ModalDialog.ModalDialog { + _init(uuid, info, invocation) { + super._init({ styleClass: 'extension-dialog' }); + + this._uuid = uuid; + this._info = info; + this._invocation = invocation; + + this.setButtons([{ + label: _("Cancel"), + action: this._onCancelButtonPressed.bind(this), + key: Clutter.KEY_Escape, + }, { + label: _("Install"), + action: this._onInstallButtonPressed.bind(this), + default: true, + }]); + + let content = new Dialog.MessageDialogContent({ + title: _('Install Extension'), + description: _('Download and install “%s” from extensions.gnome.org?').format(info.name), + }); + + this.contentLayout.add(content); + } + + _onCancelButtonPressed() { + this.close(); + this._invocation.return_value(GLib.Variant.new('(s)', ['cancelled'])); + } + + _onInstallButtonPressed() { + let params = { shell_version: Config.PACKAGE_VERSION }; + + let url = REPOSITORY_URL_DOWNLOAD.format(this._uuid); + let message = Soup.form_request_new_from_hash('GET', url, params); + + let uuid = this._uuid; + let dir = Gio.File.new_for_path(GLib.build_filenamev([global.userdatadir, 'extensions', uuid])); + let invocation = this._invocation; + function errback(code, msg) { + log('Error while installing %s: %s (%s)'.format(uuid, code, msg)); + invocation.return_dbus_error('org.gnome.Shell.%s'.format(code), msg || ''); + } + + function callback() { + try { + let extension = Main.extensionManager.createExtensionObject(uuid, dir, ExtensionUtils.ExtensionType.PER_USER); + Main.extensionManager.loadExtension(extension); + if (!Main.extensionManager.enableExtension(uuid)) + throw new Error('Cannot add %s to enabled extensions gsettings key'.format(uuid)); + } catch (e) { + uninstallExtension(uuid); + errback('LoadExtensionError', e); + return; + } + + invocation.return_value(GLib.Variant.new('(s)', ['successful'])); + } + + _httpSession.queue_message(message, session => { + gotExtensionZipFile(session, message, uuid, dir, callback, errback); + }); + + this.close(); + } +}); + +function init() { + _httpSession = new Soup.Session({ ssl_use_system_ca_file: true }); + + // See: https://bugzilla.gnome.org/show_bug.cgi?id=655189 for context. + // _httpSession.add_feature(new Soup.ProxyResolverDefault()); + Soup.Session.prototype.add_feature.call(_httpSession, new Soup.ProxyResolverDefault()); +} diff --git a/js/ui/extensionSystem.js b/js/ui/extensionSystem.js new file mode 100644 index 0000000..6b624fc --- /dev/null +++ b/js/ui/extensionSystem.js @@ -0,0 +1,658 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported init connect disconnect */ + +const { GLib, Gio, GObject, Shell, St } = imports.gi; +const ByteArray = imports.byteArray; +const Signals = imports.signals; + +const ExtensionDownloader = imports.ui.extensionDownloader; +const ExtensionUtils = imports.misc.extensionUtils; +const FileUtils = imports.misc.fileUtils; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; + +const { ExtensionState, ExtensionType } = ExtensionUtils; + +const ENABLED_EXTENSIONS_KEY = 'enabled-extensions'; +const DISABLED_EXTENSIONS_KEY = 'disabled-extensions'; +const DISABLE_USER_EXTENSIONS_KEY = 'disable-user-extensions'; +const EXTENSION_DISABLE_VERSION_CHECK_KEY = 'disable-extension-version-validation'; + +const UPDATE_CHECK_TIMEOUT = 24 * 60 * 60; // 1 day in seconds + +var ExtensionManager = class { + constructor() { + this._initialized = false; + this._enabled = false; + this._updateNotified = false; + + this._extensions = new Map(); + this._unloadedExtensions = new Map(); + this._enabledExtensions = []; + this._extensionOrder = []; + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + } + + init() { + // The following file should exist for a period of time when extensions + // are enabled after start. If it exists, then the systemd unit will + // disable extensions should gnome-shell crash. + // Should the file already exist from a previous login, then this is OK. + let disableFilename = GLib.build_filenamev([GLib.get_user_runtime_dir(), 'gnome-shell-disable-extensions']); + let disableFile = Gio.File.new_for_path(disableFilename); + try { + disableFile.create(Gio.FileCreateFlags.REPLACE_DESTINATION, null); + } catch (e) { + log('Failed to create file %s: %s'.format(disableFilename, e.message)); + } + + GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 60, () => { + disableFile.delete(null); + return GLib.SOURCE_REMOVE; + }); + + this._installExtensionUpdates(); + this._sessionUpdated(); + + GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, UPDATE_CHECK_TIMEOUT, () => { + ExtensionDownloader.checkForUpdates(); + return GLib.SOURCE_CONTINUE; + }); + ExtensionDownloader.checkForUpdates(); + } + + get updatesSupported() { + const appSys = Shell.AppSystem.get_default(); + return appSys.lookup_app('org.gnome.Extensions.desktop') !== null; + } + + lookup(uuid) { + return this._extensions.get(uuid); + } + + getUuids() { + return [...this._extensions.keys()]; + } + + _callExtensionDisable(uuid) { + let extension = this.lookup(uuid); + if (!extension) + return; + + if (extension.state != ExtensionState.ENABLED) + return; + + // "Rebase" the extension order by disabling and then enabling extensions + // in order to help prevent conflicts. + + // Example: + // order = [A, B, C, D, E] + // user disables C + // this should: disable E, disable D, disable C, enable D, enable E + + let orderIdx = this._extensionOrder.indexOf(uuid); + let order = this._extensionOrder.slice(orderIdx + 1); + let orderReversed = order.slice().reverse(); + + for (let i = 0; i < orderReversed.length; i++) { + let otherUuid = orderReversed[i]; + try { + this.lookup(otherUuid).stateObj.disable(); + } catch (e) { + this.logExtensionError(otherUuid, e); + } + } + + try { + extension.stateObj.disable(); + } catch (e) { + this.logExtensionError(uuid, e); + } + + if (extension.stylesheet) { + let theme = St.ThemeContext.get_for_stage(global.stage).get_theme(); + theme.unload_stylesheet(extension.stylesheet); + delete extension.stylesheet; + } + + for (let i = 0; i < order.length; i++) { + let otherUuid = order[i]; + try { + this.lookup(otherUuid).stateObj.enable(); + } catch (e) { + this.logExtensionError(otherUuid, e); + } + } + + this._extensionOrder.splice(orderIdx, 1); + + if (extension.state != ExtensionState.ERROR) { + extension.state = ExtensionState.DISABLED; + this.emit('extension-state-changed', extension); + } + } + + _callExtensionEnable(uuid) { + if (!Main.sessionMode.allowExtensions) + return; + + let extension = this.lookup(uuid); + if (!extension) + return; + + if (extension.state == ExtensionState.INITIALIZED) + this._callExtensionInit(uuid); + + if (extension.state != ExtensionState.DISABLED) + return; + + let stylesheetNames = ['%s.css'.format(global.session_mode), 'stylesheet.css']; + let theme = St.ThemeContext.get_for_stage(global.stage).get_theme(); + for (let i = 0; i < stylesheetNames.length; i++) { + try { + let stylesheetFile = extension.dir.get_child(stylesheetNames[i]); + theme.load_stylesheet(stylesheetFile); + extension.stylesheet = stylesheetFile; + break; + } catch (e) { + if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND)) + continue; // not an error + this.logExtensionError(uuid, e); + return; + } + } + + try { + extension.stateObj.enable(); + extension.state = ExtensionState.ENABLED; + this._extensionOrder.push(uuid); + this.emit('extension-state-changed', extension); + } catch (e) { + if (extension.stylesheet) { + theme.unload_stylesheet(extension.stylesheet); + delete extension.stylesheet; + } + this.logExtensionError(uuid, e); + } + } + + enableExtension(uuid) { + if (!this._extensions.has(uuid)) + return false; + + let enabledExtensions = global.settings.get_strv(ENABLED_EXTENSIONS_KEY); + let disabledExtensions = global.settings.get_strv(DISABLED_EXTENSIONS_KEY); + + if (disabledExtensions.includes(uuid)) { + disabledExtensions = disabledExtensions.filter(item => item !== uuid); + global.settings.set_strv(DISABLED_EXTENSIONS_KEY, disabledExtensions); + } + + if (!enabledExtensions.includes(uuid)) { + enabledExtensions.push(uuid); + global.settings.set_strv(ENABLED_EXTENSIONS_KEY, enabledExtensions); + } + + return true; + } + + disableExtension(uuid) { + if (!this._extensions.has(uuid)) + return false; + + let enabledExtensions = global.settings.get_strv(ENABLED_EXTENSIONS_KEY); + let disabledExtensions = global.settings.get_strv(DISABLED_EXTENSIONS_KEY); + + if (enabledExtensions.includes(uuid)) { + enabledExtensions = enabledExtensions.filter(item => item !== uuid); + global.settings.set_strv(ENABLED_EXTENSIONS_KEY, enabledExtensions); + } + + if (!disabledExtensions.includes(uuid)) { + disabledExtensions.push(uuid); + global.settings.set_strv(DISABLED_EXTENSIONS_KEY, disabledExtensions); + } + + return true; + } + + openExtensionPrefs(uuid, parentWindow, options) { + const extension = this.lookup(uuid); + if (!extension || !extension.hasPrefs) + return false; + + Gio.DBus.session.call( + 'org.gnome.Shell.Extensions', + '/org/gnome/Shell/Extensions', + 'org.gnome.Shell.Extensions', + 'OpenExtensionPrefs', + new GLib.Variant('(ssa{sv})', [uuid, parentWindow, options]), + null, + Gio.DBusCallFlags.NONE, + -1, + null); + return true; + } + + notifyExtensionUpdate(uuid) { + let extension = this.lookup(uuid); + if (!extension) + return; + + extension.hasUpdate = true; + this.emit('extension-state-changed', extension); + + if (!this._updateNotified) { + this._updateNotified = true; + + let source = new ExtensionUpdateSource(); + Main.messageTray.add(source); + + let notification = new MessageTray.Notification(source, + _('Extension Updates Available'), + _('Extension updates are ready to be installed.')); + notification.connect('activated', + () => source.open()); + source.showNotification(notification); + } + } + + logExtensionError(uuid, error) { + let extension = this.lookup(uuid); + if (!extension) + return; + + const message = error instanceof Error + ? error.message : error.toString(); + + extension.error = message; + extension.state = ExtensionState.ERROR; + if (!extension.errors) + extension.errors = []; + extension.errors.push(message); + + logError(error, 'Extension %s'.format(uuid)); + this._updateCanChange(extension); + this.emit('extension-state-changed', extension); + } + + createExtensionObject(uuid, dir, type) { + let metadataFile = dir.get_child('metadata.json'); + if (!metadataFile.query_exists(null)) + throw new Error('Missing metadata.json'); + + let metadataContents, success_; + try { + [success_, metadataContents] = metadataFile.load_contents(null); + metadataContents = ByteArray.toString(metadataContents); + } catch (e) { + throw new Error('Failed to load metadata.json: %s'.format(e.toString())); + } + let meta; + try { + meta = JSON.parse(metadataContents); + } catch (e) { + throw new Error('Failed to parse metadata.json: %s'.format(e.toString())); + } + + let requiredProperties = ['uuid', 'name', 'description', 'shell-version']; + for (let i = 0; i < requiredProperties.length; i++) { + let prop = requiredProperties[i]; + if (!meta[prop]) + throw new Error('missing "%s" property in metadata.json'.format(prop)); + } + + if (uuid != meta.uuid) + throw new Error('uuid "%s" from metadata.json does not match directory name "%s"'.format(meta.uuid, uuid)); + + let extension = { + metadata: meta, + uuid: meta.uuid, + type, + dir, + path: dir.get_path(), + error: '', + hasPrefs: dir.get_child('prefs.js').query_exists(null), + hasUpdate: false, + canChange: false, + }; + this._extensions.set(uuid, extension); + + return extension; + } + + _canLoad(extension) { + if (!this._unloadedExtensions.has(extension.uuid)) + return true; + + const version = this._unloadedExtensions.get(extension.uuid); + return extension.metadata.version === version; + } + + loadExtension(extension) { + // Default to error, we set success as the last step + extension.state = ExtensionState.ERROR; + + let checkVersion = !global.settings.get_boolean(EXTENSION_DISABLE_VERSION_CHECK_KEY); + + if (checkVersion && ExtensionUtils.isOutOfDate(extension)) { + extension.state = ExtensionState.OUT_OF_DATE; + } else if (!this._canLoad(extension)) { + this.logExtensionError(extension.uuid, new Error( + 'A different version was loaded previously. You need to log out for changes to take effect.')); + } else { + let enabled = this._enabledExtensions.includes(extension.uuid); + if (enabled) { + if (!this._callExtensionInit(extension.uuid)) + return; + if (extension.state == ExtensionState.DISABLED) + this._callExtensionEnable(extension.uuid); + } else { + extension.state = ExtensionState.INITIALIZED; + } + + this._unloadedExtensions.delete(extension.uuid); + } + + this._updateCanChange(extension); + this.emit('extension-state-changed', extension); + } + + unloadExtension(extension) { + const { uuid, type } = extension; + + // Try to disable it -- if it's ERROR'd, we can't guarantee that, + // but it will be removed on next reboot, and hopefully nothing + // broke too much. + this._callExtensionDisable(uuid); + + extension.state = ExtensionState.UNINSTALLED; + this.emit('extension-state-changed', extension); + + // If we did install an importer, it is now cached and it's + // impossible to load a different version + if (type === ExtensionType.PER_USER && extension.imports) + this._unloadedExtensions.set(uuid, extension.metadata.version); + + this._extensions.delete(uuid); + return true; + } + + reloadExtension(oldExtension) { + // Grab the things we'll need to pass to createExtensionObject + // to reload it. + let { uuid, dir, type } = oldExtension; + + // Then unload the old extension. + this.unloadExtension(oldExtension); + + // Now, recreate the extension and load it. + let newExtension; + try { + newExtension = this.createExtensionObject(uuid, dir, type); + } catch (e) { + this.logExtensionError(uuid, e); + return; + } + + this.loadExtension(newExtension); + } + + _callExtensionInit(uuid) { + if (!Main.sessionMode.allowExtensions) + return false; + + let extension = this.lookup(uuid); + if (!extension) + throw new Error("Extension was not properly created. Call createExtensionObject first"); + + let dir = extension.dir; + let extensionJs = dir.get_child('extension.js'); + if (!extensionJs.query_exists(null)) { + this.logExtensionError(uuid, new Error('Missing extension.js')); + return false; + } + + let extensionModule; + let extensionState = null; + + ExtensionUtils.installImporter(extension); + try { + extensionModule = extension.imports.extension; + } catch (e) { + this.logExtensionError(uuid, e); + return false; + } + + if (extensionModule.init) { + try { + extensionState = extensionModule.init(extension); + } catch (e) { + this.logExtensionError(uuid, e); + return false; + } + } + + if (!extensionState) + extensionState = extensionModule; + extension.stateObj = extensionState; + + extension.state = ExtensionState.DISABLED; + this.emit('extension-loaded', uuid); + return true; + } + + _getModeExtensions() { + if (Array.isArray(Main.sessionMode.enabledExtensions)) + return Main.sessionMode.enabledExtensions; + return []; + } + + _updateCanChange(extension) { + let hasError = + extension.state == ExtensionState.ERROR || + extension.state == ExtensionState.OUT_OF_DATE; + + let isMode = this._getModeExtensions().includes(extension.uuid); + let modeOnly = global.settings.get_boolean(DISABLE_USER_EXTENSIONS_KEY); + + let changeKey = isMode + ? DISABLE_USER_EXTENSIONS_KEY + : ENABLED_EXTENSIONS_KEY; + + extension.canChange = + !hasError && + global.settings.is_writable(changeKey) && + (isMode || !modeOnly); + } + + _getEnabledExtensions() { + let extensions = this._getModeExtensions(); + + if (!global.settings.get_boolean(DISABLE_USER_EXTENSIONS_KEY)) + extensions = extensions.concat(global.settings.get_strv(ENABLED_EXTENSIONS_KEY)); + + // filter out 'disabled-extensions' which takes precedence + let disabledExtensions = global.settings.get_strv(DISABLED_EXTENSIONS_KEY); + return extensions.filter(item => !disabledExtensions.includes(item)); + } + + _onUserExtensionsEnabledChanged() { + this._onEnabledExtensionsChanged(); + this._onSettingsWritableChanged(); + } + + _onEnabledExtensionsChanged() { + let newEnabledExtensions = this._getEnabledExtensions(); + + // Find and enable all the newly enabled extensions: UUIDs found in the + // new setting, but not in the old one. + newEnabledExtensions + .filter(uuid => !this._enabledExtensions.includes(uuid)) + .forEach(uuid => this._callExtensionEnable(uuid)); + + // Find and disable all the newly disabled extensions: UUIDs found in the + // old setting, but not in the new one. + this._extensionOrder + .filter(uuid => !newEnabledExtensions.includes(uuid)) + .reverse().forEach(uuid => this._callExtensionDisable(uuid)); + + this._enabledExtensions = newEnabledExtensions; + } + + _onSettingsWritableChanged() { + for (let extension of this._extensions.values()) { + this._updateCanChange(extension); + this.emit('extension-state-changed', extension); + } + } + + _onVersionValidationChanged() { + // Disabling extensions modifies the order array, so use a copy + let extensionOrder = this._extensionOrder.slice(); + + // Disable enabled extensions in the reverse order first to avoid + // the "rebasing" done in _callExtensionDisable... + extensionOrder.slice().reverse().forEach(uuid => { + this._callExtensionDisable(uuid); + }); + + // ...and then reload and enable extensions in the correct order again. + [...this._extensions.values()].sort((a, b) => { + return extensionOrder.indexOf(a.uuid) - extensionOrder.indexOf(b.uuid); + }).forEach(extension => this.reloadExtension(extension)); + } + + _installExtensionUpdates() { + if (!this.updatesSupported) + return; + + FileUtils.collectFromDatadirs('extension-updates', true, (dir, info) => { + let fileType = info.get_file_type(); + if (fileType !== Gio.FileType.DIRECTORY) + return; + let uuid = info.get_name(); + let extensionDir = Gio.File.new_for_path( + GLib.build_filenamev([global.userdatadir, 'extensions', uuid])); + + try { + FileUtils.recursivelyDeleteDir(extensionDir, false); + FileUtils.recursivelyMoveDir(dir, extensionDir); + } catch (e) { + log('Failed to install extension updates for %s'.format(uuid)); + } finally { + FileUtils.recursivelyDeleteDir(dir, true); + } + }); + } + + _loadExtensions() { + global.settings.connect('changed::%s'.format(ENABLED_EXTENSIONS_KEY), + this._onEnabledExtensionsChanged.bind(this)); + global.settings.connect('changed::%s'.format(DISABLED_EXTENSIONS_KEY), + this._onEnabledExtensionsChanged.bind(this)); + global.settings.connect('changed::%s'.format(DISABLE_USER_EXTENSIONS_KEY), + this._onUserExtensionsEnabledChanged.bind(this)); + global.settings.connect('changed::%s'.format(EXTENSION_DISABLE_VERSION_CHECK_KEY), + this._onVersionValidationChanged.bind(this)); + global.settings.connect('writable-changed::%s'.format(ENABLED_EXTENSIONS_KEY), + this._onSettingsWritableChanged.bind(this)); + global.settings.connect('writable-changed::%s'.format(DISABLED_EXTENSIONS_KEY), + this._onSettingsWritableChanged.bind(this)); + + this._enabledExtensions = this._getEnabledExtensions(); + + let perUserDir = Gio.File.new_for_path(global.userdatadir); + FileUtils.collectFromDatadirs('extensions', true, (dir, info) => { + let fileType = info.get_file_type(); + if (fileType != Gio.FileType.DIRECTORY) + return; + let uuid = info.get_name(); + let existing = this.lookup(uuid); + if (existing) { + log('Extension %s already installed in %s. %s will not be loaded'.format(uuid, existing.path, dir.get_path())); + return; + } + + let extension; + let type = dir.has_prefix(perUserDir) + ? ExtensionType.PER_USER + : ExtensionType.SYSTEM; + try { + extension = this.createExtensionObject(uuid, dir, type); + } catch (e) { + logError(e, 'Could not load extension %s'.format(uuid)); + return; + } + this.loadExtension(extension); + }); + } + + _enableAllExtensions() { + if (this._enabled) + return; + + if (!this._initialized) { + this._loadExtensions(); + this._initialized = true; + } else { + this._enabledExtensions.forEach(uuid => { + this._callExtensionEnable(uuid); + }); + } + this._enabled = true; + } + + _disableAllExtensions() { + if (!this._enabled) + return; + + if (this._initialized) { + this._extensionOrder.slice().reverse().forEach(uuid => { + this._callExtensionDisable(uuid); + }); + } + + this._enabled = false; + } + + _sessionUpdated() { + // For now sessionMode.allowExtensions controls extensions from both the + // 'enabled-extensions' preference and the sessionMode.enabledExtensions + // property; it might make sense to make enabledExtensions independent + // from allowExtensions in the future + if (Main.sessionMode.allowExtensions) { + // Take care of added or removed sessionMode extensions + this._onEnabledExtensionsChanged(); + this._enableAllExtensions(); + } else { + this._disableAllExtensions(); + } + } +}; +Signals.addSignalMethods(ExtensionManager.prototype); + +const ExtensionUpdateSource = GObject.registerClass( +class ExtensionUpdateSource extends MessageTray.Source { + _init() { + let appSys = Shell.AppSystem.get_default(); + this._app = appSys.lookup_app('org.gnome.Extensions.desktop'); + + super._init(this._app.get_name()); + } + + getIcon() { + return this._app.app_info.get_icon(); + } + + _createPolicy() { + return new MessageTray.NotificationApplicationPolicy(this._app.id); + } + + open() { + this._app.activate(); + Main.overview.hide(); + Main.panel.closeCalendar(); + } +}); diff --git a/js/ui/focusCaretTracker.js b/js/ui/focusCaretTracker.js new file mode 100644 index 0000000..cc70ae5 --- /dev/null +++ b/js/ui/focusCaretTracker.js @@ -0,0 +1,89 @@ +/** -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */ +/* + * Copyright 2012 Inclusive Design Research Centre, OCAD University. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see <http://www.gnu.org/licenses/>. + * + * Author: + * Joseph Scheuhammer <clown@alum.mit.edu> + * Contributor: + * Magdalen Berns <m.berns@sms.ed.ac.uk> + */ + +const Atspi = imports.gi.Atspi; +const Signals = imports.signals; + +const CARETMOVED = 'object:text-caret-moved'; +const STATECHANGED = 'object:state-changed'; + +var FocusCaretTracker = class FocusCaretTracker { + constructor() { + this._atspiListener = Atspi.EventListener.new(this._onChanged.bind(this)); + + this._atspiInited = false; + this._focusListenerRegistered = false; + this._caretListenerRegistered = false; + } + + _onChanged(event) { + if (event.type.indexOf(STATECHANGED) == 0) + this.emit('focus-changed', event); + else if (event.type == CARETMOVED) + this.emit('caret-moved', event); + } + + _initAtspi() { + if (!this._atspiInited && Atspi.init() == 0) { + Atspi.set_timeout(250, 250); + this._atspiInited = true; + } + + return this._atspiInited; + } + + registerFocusListener() { + if (!this._initAtspi() || this._focusListenerRegistered) + return; + + this._atspiListener.register(`${STATECHANGED}:focused`); + this._atspiListener.register(`${STATECHANGED}:selected`); + this._focusListenerRegistered = true; + } + + registerCaretListener() { + if (!this._initAtspi() || this._caretListenerRegistered) + return; + + this._atspiListener.register(CARETMOVED); + this._caretListenerRegistered = true; + } + + deregisterFocusListener() { + if (!this._focusListenerRegistered) + return; + + this._atspiListener.deregister(`${STATECHANGED}:focused`); + this._atspiListener.deregister(`${STATECHANGED}:selected`); + this._focusListenerRegistered = false; + } + + deregisterCaretListener() { + if (!this._caretListenerRegistered) + return; + + this._atspiListener.deregister(CARETMOVED); + this._caretListenerRegistered = false; + } +}; +Signals.addSignalMethods(FocusCaretTracker.prototype); diff --git a/js/ui/grabHelper.js b/js/ui/grabHelper.js new file mode 100644 index 0000000..2ba2aad --- /dev/null +++ b/js/ui/grabHelper.js @@ -0,0 +1,332 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported GrabHelper */ + +const { Clutter, St } = imports.gi; + +const Main = imports.ui.main; +const Params = imports.misc.params; + +let _capturedEventId = 0; +let _grabHelperStack = []; +function _onCapturedEvent(actor, event) { + let grabHelper = _grabHelperStack[_grabHelperStack.length - 1]; + return grabHelper.onCapturedEvent(event); +} + +function _pushGrabHelper(grabHelper) { + _grabHelperStack.push(grabHelper); + + if (_capturedEventId == 0) + _capturedEventId = global.stage.connect('captured-event', _onCapturedEvent); +} + +function _popGrabHelper(grabHelper) { + let poppedHelper = _grabHelperStack.pop(); + if (poppedHelper != grabHelper) + throw new Error("incorrect grab helper pop"); + + if (_grabHelperStack.length == 0) { + global.stage.disconnect(_capturedEventId); + _capturedEventId = 0; + } +} + +// GrabHelper: +// @owner: the actor that owns the GrabHelper +// @params: optional parameters to pass to Main.pushModal() +// +// Creates a new GrabHelper object, for dealing with keyboard and pointer grabs +// associated with a set of actors. +// +// Note that the grab can be automatically dropped at any time by the user, and +// your code just needs to deal with it; you shouldn't adjust behavior directly +// after you call ungrab(), but instead pass an 'onUngrab' callback when you +// call grab(). +var GrabHelper = class GrabHelper { + constructor(owner, params) { + if (!(owner instanceof Clutter.Actor)) + throw new Error('GrabHelper owner must be a Clutter.Actor'); + + this._owner = owner; + this._modalParams = params; + + this._grabStack = []; + + this._actors = []; + this._ignoreUntilRelease = false; + + this._modalCount = 0; + } + + // addActor: + // @actor: an actor + // + // Adds @actor to the set of actors that are allowed to process events + // during a grab. + addActor(actor) { + actor.__grabHelperDestroyId = actor.connect('destroy', () => { + this.removeActor(actor); + }); + this._actors.push(actor); + } + + // removeActor: + // @actor: an actor + // + // Removes @actor from the set of actors that are allowed to + // process events during a grab. + removeActor(actor) { + let index = this._actors.indexOf(actor); + if (index != -1) + this._actors.splice(index, 1); + if (actor.__grabHelperDestroyId) { + actor.disconnect(actor.__grabHelperDestroyId); + delete actor.__grabHelperDestroyId; + } + } + + _isWithinGrabbedActor(actor) { + let currentActor = this.currentGrab.actor; + while (actor) { + if (this._actors.includes(actor)) + return true; + if (actor == currentActor) + return true; + actor = actor.get_parent(); + } + return false; + } + + get currentGrab() { + return this._grabStack[this._grabStack.length - 1] || {}; + } + + get grabbed() { + return this._grabStack.length > 0; + } + + get grabStack() { + return this._grabStack; + } + + _findStackIndex(actor) { + if (!actor) + return -1; + + for (let i = 0; i < this._grabStack.length; i++) { + if (this._grabStack[i].actor === actor) + return i; + } + return -1; + } + + _actorInGrabStack(actor) { + while (actor) { + let idx = this._findStackIndex(actor); + if (idx >= 0) + return idx; + actor = actor.get_parent(); + } + return -1; + } + + isActorGrabbed(actor) { + return this._findStackIndex(actor) >= 0; + } + + // grab: + // @params: A bunch of parameters, see below + // + // The general effect of a "grab" is to ensure that the passed in actor + // and all actors inside the grab get exclusive control of the mouse and + // keyboard, with the grab automatically being dropped if the user tries + // to dismiss it. The actor is passed in through @params.actor. + // + // grab() can be called multiple times, with the scope of the grab being + // changed to a different actor every time. A nested grab does not have + // to have its grabbed actor inside the parent grab actors. + // + // Grabs can be automatically dropped if the user tries to dismiss it + // in one of two ways: the user clicking outside the currently grabbed + // actor, or the user typing the Escape key. + // + // If the user clicks outside the grabbed actors, and the clicked on + // actor is part of a previous grab in the stack, grabs will be popped + // until that grab is active. However, the click event will not be + // replayed to the actor. + // + // If the user types the Escape key, one grab from the grab stack will + // be popped. + // + // When a grab is popped by user interacting as described above, if you + // pass a callback as @params.onUngrab, it will be called with %true. + // + // If @params.focus is not null, we'll set the key focus directly + // to that actor instead of navigating in @params.actor. This is for + // use cases like menus, where we want to grab the menu actor, but keep + // focus on the clicked on menu item. + grab(params) { + params = Params.parse(params, { actor: null, + focus: null, + onUngrab: null }); + + let focus = global.stage.key_focus; + let hadFocus = focus && this._isWithinGrabbedActor(focus); + let newFocus = params.actor; + + if (this.isActorGrabbed(params.actor)) + return true; + + params.savedFocus = focus; + + if (!this._takeModalGrab()) + return false; + + this._grabStack.push(params); + + if (params.focus) { + params.focus.grab_key_focus(); + } else if (newFocus && hadFocus) { + if (!newFocus.navigate_focus(null, St.DirectionType.TAB_FORWARD, false)) + newFocus.grab_key_focus(); + } + + return true; + } + + grabAsync(params) { + return new Promise((resolve, reject) => { + params.onUngrab = resolve; + + if (!this.grab(params)) + reject(new Error('Grab failed')); + }); + } + + _takeModalGrab() { + let firstGrab = this._modalCount == 0; + if (firstGrab) { + if (!Main.pushModal(this._owner, this._modalParams)) + return false; + + _pushGrabHelper(this); + } + + this._modalCount++; + return true; + } + + _releaseModalGrab() { + this._modalCount--; + if (this._modalCount > 0) + return; + + _popGrabHelper(this); + + this._ignoreUntilRelease = false; + + Main.popModal(this._owner); + global.sync_pointer(); + } + + // ignoreRelease: + // + // Make sure that the next button release event evaluated by the + // capture event handler returns false. This is designed for things + // like the ComboBoxMenu that go away on press, but need to eat + // the next release event. + ignoreRelease() { + this._ignoreUntilRelease = true; + } + + // ungrab: + // @params: The parameters for the grab; see below. + // + // Pops @params.actor from the grab stack, potentially dropping + // the grab. If the actor is not on the grab stack, this call is + // ignored with no ill effects. + // + // If the actor is not at the top of the grab stack, grabs are + // popped until the grabbed actor is at the top of the grab stack. + // The onUngrab callback for every grab is called for every popped + // grab with the parameter %false. + ungrab(params) { + params = Params.parse(params, { actor: this.currentGrab.actor, + isUser: false }); + + let grabStackIndex = this._findStackIndex(params.actor); + if (grabStackIndex < 0) + return; + + let focus = global.stage.key_focus; + let hadFocus = focus && this._isWithinGrabbedActor(focus); + + let poppedGrabs = this._grabStack.slice(grabStackIndex); + // "Pop" all newly ungrabbed actors off the grab stack + // by truncating the array. + this._grabStack.length = grabStackIndex; + + for (let i = poppedGrabs.length - 1; i >= 0; i--) { + let poppedGrab = poppedGrabs[i]; + + if (poppedGrab.onUngrab) + poppedGrab.onUngrab(params.isUser); + + this._releaseModalGrab(); + } + + if (hadFocus) { + let poppedGrab = poppedGrabs[0]; + if (poppedGrab.savedFocus) + poppedGrab.savedFocus.grab_key_focus(); + } + } + + onCapturedEvent(event) { + let type = event.type(); + + if (type == Clutter.EventType.KEY_PRESS && + event.get_key_symbol() == Clutter.KEY_Escape) { + this.ungrab({ isUser: true }); + return Clutter.EVENT_STOP; + } + + let motion = type == Clutter.EventType.MOTION; + let press = type == Clutter.EventType.BUTTON_PRESS; + let release = type == Clutter.EventType.BUTTON_RELEASE; + let button = press || release; + + let touchUpdate = type == Clutter.EventType.TOUCH_UPDATE; + let touchBegin = type == Clutter.EventType.TOUCH_BEGIN; + let touchEnd = type == Clutter.EventType.TOUCH_END; + let touch = touchUpdate || touchBegin || touchEnd; + + if (touch && !global.display.is_pointer_emulating_sequence(event.get_event_sequence())) + return Clutter.EVENT_PROPAGATE; + + if (this._ignoreUntilRelease && (motion || release || touch)) { + if (release || touchEnd) + this._ignoreUntilRelease = false; + return Clutter.EVENT_STOP; + } + + if (this._isWithinGrabbedActor(event.get_source())) + return Clutter.EVENT_PROPAGATE; + + if (Main.keyboard.shouldTakeEvent(event)) + return Clutter.EVENT_PROPAGATE; + + if (button || touchBegin) { + // If we have a press event, ignore the next + // motion/release events. + if (press || touchBegin) + this._ignoreUntilRelease = true; + + let i = this._actorInGrabStack(event.get_source()) + 1; + this.ungrab({ actor: this._grabStack[i].actor, isUser: true }); + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_STOP; + } +}; diff --git a/js/ui/ibusCandidatePopup.js b/js/ui/ibusCandidatePopup.js new file mode 100644 index 0000000..6c497b2 --- /dev/null +++ b/js/ui/ibusCandidatePopup.js @@ -0,0 +1,325 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported CandidatePopup */ + +const { Clutter, GObject, IBus, St } = imports.gi; + +const BoxPointer = imports.ui.boxpointer; +const Main = imports.ui.main; + +var MAX_CANDIDATES_PER_PAGE = 16; + +var DEFAULT_INDEX_LABELS = ['1', '2', '3', '4', '5', '6', '7', '8', + '9', '0', 'a', 'b', 'c', 'd', 'e', 'f']; + +var CandidateArea = GObject.registerClass({ + Signals: { + 'candidate-clicked': { param_types: [GObject.TYPE_UINT, + GObject.TYPE_UINT, + Clutter.ModifierType.$gtype] }, + 'cursor-down': {}, + 'cursor-up': {}, + 'next-page': {}, + 'previous-page': {}, + }, +}, class CandidateArea extends St.BoxLayout { + _init() { + super._init({ + vertical: true, + reactive: true, + visible: false, + }); + this._candidateBoxes = []; + for (let i = 0; i < MAX_CANDIDATES_PER_PAGE; ++i) { + let box = new St.BoxLayout({ style_class: 'candidate-box', + reactive: true, + track_hover: true }); + box._indexLabel = new St.Label({ style_class: 'candidate-index' }); + box._candidateLabel = new St.Label({ style_class: 'candidate-label' }); + box.add_child(box._indexLabel); + box.add_child(box._candidateLabel); + this._candidateBoxes.push(box); + this.add(box); + + let j = i; + box.connect('button-release-event', (actor, event) => { + this.emit('candidate-clicked', j, event.get_button(), event.get_state()); + return Clutter.EVENT_PROPAGATE; + }); + } + + this._buttonBox = new St.BoxLayout({ style_class: 'candidate-page-button-box' }); + + this._previousButton = new St.Button({ + style_class: 'candidate-page-button candidate-page-button-previous button', + x_expand: true, + }); + this._previousButton.child = new St.Icon({ style_class: 'candidate-page-button-icon' }); + this._buttonBox.add_child(this._previousButton); + + this._nextButton = new St.Button({ + style_class: 'candidate-page-button candidate-page-button-next button', + x_expand: true, + }); + this._nextButton.child = new St.Icon({ style_class: 'candidate-page-button-icon' }); + this._buttonBox.add_child(this._nextButton); + + this.add(this._buttonBox); + + this._previousButton.connect('clicked', () => { + this.emit('previous-page'); + }); + this._nextButton.connect('clicked', () => { + this.emit('next-page'); + }); + + this._orientation = -1; + this._cursorPosition = 0; + } + + vfunc_scroll_event(scrollEvent) { + switch (scrollEvent.direction) { + case Clutter.ScrollDirection.UP: + this.emit('cursor-up'); + break; + case Clutter.ScrollDirection.DOWN: + this.emit('cursor-down'); + break; + } + return Clutter.EVENT_PROPAGATE; + } + + setOrientation(orientation) { + if (this._orientation == orientation) + return; + + this._orientation = orientation; + + if (this._orientation == IBus.Orientation.HORIZONTAL) { + this.vertical = false; + this.remove_style_class_name('vertical'); + this.add_style_class_name('horizontal'); + this._previousButton.child.icon_name = 'go-previous-symbolic'; + this._nextButton.child.icon_name = 'go-next-symbolic'; + } else { // VERTICAL || SYSTEM + this.vertical = true; + this.add_style_class_name('vertical'); + this.remove_style_class_name('horizontal'); + this._previousButton.child.icon_name = 'go-up-symbolic'; + this._nextButton.child.icon_name = 'go-down-symbolic'; + } + } + + setCandidates(indexes, candidates, cursorPosition, cursorVisible) { + for (let i = 0; i < MAX_CANDIDATES_PER_PAGE; ++i) { + let visible = i < candidates.length; + let box = this._candidateBoxes[i]; + box.visible = visible; + + if (!visible) + continue; + + box._indexLabel.text = indexes && indexes[i] ? indexes[i] : DEFAULT_INDEX_LABELS[i]; + box._candidateLabel.text = candidates[i]; + } + + this._candidateBoxes[this._cursorPosition].remove_style_pseudo_class('selected'); + this._cursorPosition = cursorPosition; + if (cursorVisible) + this._candidateBoxes[cursorPosition].add_style_pseudo_class('selected'); + } + + updateButtons(wrapsAround, page, nPages) { + if (nPages < 2) { + this._buttonBox.hide(); + return; + } + this._buttonBox.show(); + this._previousButton.reactive = wrapsAround || page > 0; + this._nextButton.reactive = wrapsAround || page < nPages - 1; + } +}); + +var CandidatePopup = GObject.registerClass( +class IbusCandidatePopup extends BoxPointer.BoxPointer { + _init() { + super._init(St.Side.TOP); + this.visible = false; + this.style_class = 'candidate-popup-boxpointer'; + + this._dummyCursor = new St.Widget({ opacity: 0 }); + Main.layoutManager.uiGroup.add_actor(this._dummyCursor); + + Main.layoutManager.addChrome(this); + + let box = new St.BoxLayout({ style_class: 'candidate-popup-content', + vertical: true }); + this.bin.set_child(box); + + this._preeditText = new St.Label({ style_class: 'candidate-popup-text', + visible: false }); + box.add(this._preeditText); + + this._auxText = new St.Label({ style_class: 'candidate-popup-text', + visible: false }); + box.add(this._auxText); + + this._candidateArea = new CandidateArea(); + box.add(this._candidateArea); + + this._candidateArea.connect('previous-page', () => { + this._panelService.page_up(); + }); + this._candidateArea.connect('next-page', () => { + this._panelService.page_down(); + }); + + this._candidateArea.connect('cursor-up', () => { + this._panelService.cursor_up(); + }); + this._candidateArea.connect('cursor-down', () => { + this._panelService.cursor_down(); + }); + + this._candidateArea.connect('candidate-clicked', (area, index, button, state) => { + this._panelService.candidate_clicked(index, button, state); + }); + + this._panelService = null; + } + + setPanelService(panelService) { + this._panelService = panelService; + if (!panelService) + return; + + panelService.connect('set-cursor-location', (ps, x, y, w, h) => { + this._setDummyCursorGeometry(x, y, w, h); + }); + try { + panelService.connect('set-cursor-location-relative', (ps, x, y, w, h) => { + if (!global.display.focus_window) + return; + let window = global.display.focus_window.get_compositor_private(); + this._setDummyCursorGeometry(window.x + x, window.y + y, w, h); + }); + } catch (e) { + // Only recent IBus versions have support for this signal + // which is used for wayland clients. In order to work + // with older IBus versions we can silently ignore the + // signal's absence. + } + panelService.connect('update-preedit-text', (ps, text, cursorPosition, visible) => { + this._preeditText.visible = visible; + this._updateVisibility(); + + this._preeditText.text = text.get_text(); + + let attrs = text.get_attributes(); + if (attrs) { + this._setTextAttributes(this._preeditText.clutter_text, + attrs); + } + }); + panelService.connect('show-preedit-text', () => { + this._preeditText.show(); + this._updateVisibility(); + }); + panelService.connect('hide-preedit-text', () => { + this._preeditText.hide(); + this._updateVisibility(); + }); + panelService.connect('update-auxiliary-text', (_ps, text, visible) => { + this._auxText.visible = visible; + this._updateVisibility(); + + this._auxText.text = text.get_text(); + }); + panelService.connect('show-auxiliary-text', () => { + this._auxText.show(); + this._updateVisibility(); + }); + panelService.connect('hide-auxiliary-text', () => { + this._auxText.hide(); + this._updateVisibility(); + }); + panelService.connect('update-lookup-table', (_ps, lookupTable, visible) => { + this._candidateArea.visible = visible; + this._updateVisibility(); + + let nCandidates = lookupTable.get_number_of_candidates(); + let cursorPos = lookupTable.get_cursor_pos(); + let pageSize = lookupTable.get_page_size(); + let nPages = Math.ceil(nCandidates / pageSize); + let page = cursorPos == 0 ? 0 : Math.floor(cursorPos / pageSize); + let startIndex = page * pageSize; + let endIndex = Math.min((page + 1) * pageSize, nCandidates); + + let indexes = []; + let indexLabel; + for (let i = 0; (indexLabel = lookupTable.get_label(i)); ++i) + indexes.push(indexLabel.get_text()); + + Main.keyboard.resetSuggestions(); + + let candidates = []; + for (let i = startIndex; i < endIndex; ++i) { + candidates.push(lookupTable.get_candidate(i).get_text()); + + Main.keyboard.addSuggestion(lookupTable.get_candidate(i).get_text(), () => { + let index = i; + this._panelService.candidate_clicked(index, 1, 0); + }); + } + + this._candidateArea.setCandidates(indexes, + candidates, + cursorPos % pageSize, + lookupTable.is_cursor_visible()); + this._candidateArea.setOrientation(lookupTable.get_orientation()); + this._candidateArea.updateButtons(lookupTable.is_round(), page, nPages); + }); + panelService.connect('show-lookup-table', () => { + this._candidateArea.show(); + this._updateVisibility(); + }); + panelService.connect('hide-lookup-table', () => { + this._candidateArea.hide(); + this._updateVisibility(); + }); + panelService.connect('focus-out', () => { + this.close(BoxPointer.PopupAnimation.NONE); + Main.keyboard.resetSuggestions(); + }); + } + + _setDummyCursorGeometry(x, y, w, h) { + this._dummyCursor.set_position(Math.round(x), Math.round(y)); + this._dummyCursor.set_size(Math.round(w), Math.round(h)); + + if (this.visible) + this.setPosition(this._dummyCursor, 0); + } + + _updateVisibility() { + let isVisible = !Main.keyboard.visible && + (this._preeditText.visible || + this._auxText.visible || + this._candidateArea.visible); + + if (isVisible) { + this.setPosition(this._dummyCursor, 0); + this.open(BoxPointer.PopupAnimation.NONE); + this.get_parent().set_child_above_sibling(this, null); + } else { + this.close(BoxPointer.PopupAnimation.NONE); + } + } + + _setTextAttributes(clutterText, ibusAttrList) { + let attr; + for (let i = 0; (attr = ibusAttrList.get(i)); ++i) { + if (attr.get_attr_type() == IBus.AttrType.BACKGROUND) + clutterText.set_selection(attr.get_start_index(), attr.get_end_index()); + } + } +}); diff --git a/js/ui/iconGrid.js b/js/ui/iconGrid.js new file mode 100644 index 0000000..e41d4f6 --- /dev/null +++ b/js/ui/iconGrid.js @@ -0,0 +1,1741 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported BaseIcon, IconGrid, IconGridLayout */ + +const { Clutter, GLib, GObject, Meta, St } = imports.gi; + +const Params = imports.misc.params; +const Main = imports.ui.main; + +var ICON_SIZE = 96; + +var ANIMATION_TIME_IN = 350; +var ANIMATION_TIME_OUT = 1 / 2 * ANIMATION_TIME_IN; +var ANIMATION_MAX_DELAY_FOR_ITEM = 2 / 3 * ANIMATION_TIME_IN; +var ANIMATION_MAX_DELAY_OUT_FOR_ITEM = 2 / 3 * ANIMATION_TIME_OUT; +var ANIMATION_FADE_IN_TIME_FOR_ITEM = 1 / 4 * ANIMATION_TIME_IN; + +var PAGE_SWITCH_TIME = 300; + +var AnimationDirection = { + IN: 0, + OUT: 1, +}; + +var IconSize = { + LARGE: 96, + MEDIUM: 64, + SMALL: 32, + TINY: 16, +}; + +var APPICON_ANIMATION_OUT_SCALE = 3; +var APPICON_ANIMATION_OUT_TIME = 250; + +const ICON_POSITION_DELAY = 10; + +const defaultGridModes = [ + { + rows: 8, + columns: 3, + }, + { + rows: 6, + columns: 4, + }, + { + rows: 4, + columns: 6, + }, + { + rows: 3, + columns: 8, + }, +]; + +var LEFT_DIVIDER_LEEWAY = 20; +var RIGHT_DIVIDER_LEEWAY = 20; + +var DragLocation = { + INVALID: 0, + START_EDGE: 1, + ON_ICON: 2, + END_EDGE: 3, + EMPTY_SPACE: 4, +}; + +var BaseIcon = GObject.registerClass( +class BaseIcon extends St.Bin { + _init(label, params) { + params = Params.parse(params, { createIcon: null, + setSizeManually: false, + showLabel: true }); + + let styleClass = 'overview-icon'; + if (params.showLabel) + styleClass += ' overview-icon-with-label'; + + super._init({ style_class: styleClass }); + + this.connect('destroy', this._onDestroy.bind(this)); + + this._box = new St.BoxLayout({ + vertical: true, + x_expand: true, + y_expand: true, + }); + this.set_child(this._box); + + this.iconSize = ICON_SIZE; + this._iconBin = new St.Bin({ x_align: Clutter.ActorAlign.CENTER }); + + this._box.add_actor(this._iconBin); + + if (params.showLabel) { + this.label = new St.Label({ text: label }); + this.label.clutter_text.set({ + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + this._box.add_actor(this.label); + } else { + this.label = null; + } + + if (params.createIcon) + this.createIcon = params.createIcon; + this._setSizeManually = params.setSizeManually; + + this.icon = null; + + let cache = St.TextureCache.get_default(); + this._iconThemeChangedId = cache.connect('icon-theme-changed', this._onIconThemeChanged.bind(this)); + } + + vfunc_get_preferred_width(_forHeight) { + // Return the actual height to keep the squared aspect + return this.get_preferred_height(-1); + } + + // This can be overridden by a subclass, or by the createIcon + // parameter to _init() + createIcon(_size) { + throw new GObject.NotImplementedError(`createIcon in ${this.constructor.name}`); + } + + setIconSize(size) { + if (!this._setSizeManually) + throw new Error('setSizeManually has to be set to use setIconsize'); + + if (size == this.iconSize) + return; + + this._createIconTexture(size); + } + + _createIconTexture(size) { + if (this.icon) + this.icon.destroy(); + this.iconSize = size; + this.icon = this.createIcon(this.iconSize); + + this._iconBin.child = this.icon; + } + + vfunc_style_changed() { + super.vfunc_style_changed(); + let node = this.get_theme_node(); + + let size; + if (this._setSizeManually) { + size = this.iconSize; + } else { + const { scaleFactor } = + St.ThemeContext.get_for_stage(global.stage); + + let [found, len] = node.lookup_length('icon-size', false); + size = found ? len / scaleFactor : ICON_SIZE; + } + + if (this.iconSize == size && this._iconBin.child) + return; + + this._createIconTexture(size); + } + + _onDestroy() { + if (this._iconThemeChangedId > 0) { + let cache = St.TextureCache.get_default(); + cache.disconnect(this._iconThemeChangedId); + this._iconThemeChangedId = 0; + } + } + + _onIconThemeChanged() { + this._createIconTexture(this.iconSize); + } + + animateZoomOut() { + // Animate only the child instead of the entire actor, so the + // styles like hover and running are not applied while + // animating. + zoomOutActor(this.child); + } + + animateZoomOutAtPos(x, y) { + zoomOutActorAtPos(this.child, x, y); + } + + update() { + this._createIconTexture(this.iconSize); + } +}); + +function zoomOutActor(actor) { + let [x, y] = actor.get_transformed_position(); + zoomOutActorAtPos(actor, x, y); +} + +function zoomOutActorAtPos(actor, x, y) { + let actorClone = new Clutter.Clone({ source: actor, + reactive: false }); + let [width, height] = actor.get_transformed_size(); + + actorClone.set_size(width, height); + actorClone.set_position(x, y); + actorClone.opacity = 255; + actorClone.set_pivot_point(0.5, 0.5); + + Main.uiGroup.add_actor(actorClone); + + // Avoid monitor edges to not zoom outside the current monitor + let monitor = Main.layoutManager.findMonitorForActor(actor); + let scaledWidth = width * APPICON_ANIMATION_OUT_SCALE; + let scaledHeight = height * APPICON_ANIMATION_OUT_SCALE; + let scaledX = x - (scaledWidth - width) / 2; + let scaledY = y - (scaledHeight - height) / 2; + let containedX = Math.clamp(scaledX, monitor.x, monitor.x + monitor.width - scaledWidth); + let containedY = Math.clamp(scaledY, monitor.y, monitor.y + monitor.height - scaledHeight); + + actorClone.ease({ + scale_x: APPICON_ANIMATION_OUT_SCALE, + scale_y: APPICON_ANIMATION_OUT_SCALE, + translation_x: containedX - scaledX, + translation_y: containedY - scaledY, + opacity: 0, + duration: APPICON_ANIMATION_OUT_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => actorClone.destroy(), + }); +} + +function animateIconPosition(icon, box, nChangedIcons) { + if (!icon.has_allocation() || icon.allocation.equal(box) || icon.opacity === 0) { + icon.allocate(box); + return false; + } + + icon.save_easing_state(); + icon.set_easing_mode(Clutter.AnimationMode.EASE_OUT_QUAD); + icon.set_easing_delay(nChangedIcons * ICON_POSITION_DELAY); + + icon.allocate(box); + + icon.restore_easing_state(); + + return true; +} + +function swap(value, length) { + return length - value - 1; +} + +var IconGridLayout = GObject.registerClass({ + Properties: { + 'allow-incomplete-pages': GObject.ParamSpec.boolean('allow-incomplete-pages', + 'Allow incomplete pages', 'Allow incomplete pages', + GObject.ParamFlags.READWRITE, + true), + 'column-spacing': GObject.ParamSpec.int('column-spacing', + 'Column spacing', 'Column spacing', + GObject.ParamFlags.READWRITE, + 0, GLib.MAXINT32, 0), + 'columns-per-page': GObject.ParamSpec.int('columns-per-page', + 'Columns per page', 'Columns per page', + GObject.ParamFlags.READWRITE, + 1, GLib.MAXINT32, 1), + 'fixed-icon-size': GObject.ParamSpec.int('fixed-icon-size', + 'Fixed icon size', 'Fixed icon size', + GObject.ParamFlags.READWRITE, + -1, GLib.MAXINT32, -1), + 'icon-size': GObject.ParamSpec.int('icon-size', + 'Icon size', 'Icon size', + GObject.ParamFlags.READABLE, + 0, GLib.MAXINT32, 0), + 'last-row-align': GObject.ParamSpec.enum('last-row-align', + 'Last row align', 'Last row align', + GObject.ParamFlags.READWRITE, + Clutter.ActorAlign.$gtype, + Clutter.ActorAlign.FILL), + 'max-column-spacing': GObject.ParamSpec.int('max-column-spacing', + 'Maximum column spacing', 'Maximum column spacing', + GObject.ParamFlags.READWRITE, + -1, GLib.MAXINT32, -1), + 'max-row-spacing': GObject.ParamSpec.int('max-row-spacing', + 'Maximum row spacing', 'Maximum row spacing', + GObject.ParamFlags.READWRITE, + -1, GLib.MAXINT32, -1), + 'orientation': GObject.ParamSpec.enum('orientation', + 'Orientation', 'Orientation', + GObject.ParamFlags.READWRITE, + Clutter.Orientation.$gtype, + Clutter.Orientation.VERTICAL), + 'page-halign': GObject.ParamSpec.enum('page-halign', + 'Horizontal page align', + 'Horizontal page align', + GObject.ParamFlags.READWRITE, + Clutter.ActorAlign.$gtype, + Clutter.ActorAlign.FILL), + 'page-valign': GObject.ParamSpec.enum('page-valign', + 'Vertical page align', + 'Vertical page align', + GObject.ParamFlags.READWRITE, + Clutter.ActorAlign.$gtype, + Clutter.ActorAlign.FILL), + 'row-spacing': GObject.ParamSpec.int('row-spacing', + 'Row spacing', 'Row spacing', + GObject.ParamFlags.READWRITE, + 0, GLib.MAXINT32, 0), + 'rows-per-page': GObject.ParamSpec.int('rows-per-page', + 'Rows per page', 'Rows per page', + GObject.ParamFlags.READWRITE, + 1, GLib.MAXINT32, 1), + }, + Signals: { + 'pages-changed': {}, + }, +}, class IconGridLayout extends Clutter.LayoutManager { + _init(params = {}) { + params = Params.parse(params, { + allow_incomplete_pages: true, + column_spacing: 0, + columns_per_page: 6, + fixed_icon_size: -1, + last_row_align: Clutter.ActorAlign.FILL, + max_column_spacing: -1, + max_row_spacing: -1, + orientation: Clutter.Orientation.VERTICAL, + page_halign: Clutter.ActorAlign.FILL, + page_valign: Clutter.ActorAlign.FILL, + row_spacing: 0, + rows_per_page: 4, + }); + + this._allowIncompletePages = params.allow_incomplete_pages; + this._columnSpacing = params.column_spacing; + this._columnsPerPage = params.columns_per_page; + this._fixedIconSize = params.fixed_icon_size; + this._lastRowAlign = params.last_row_align; + this._maxColumnSpacing = params.max_column_spacing; + this._maxRowSpacing = params.max_row_spacing; + this._orientation = params.orientation; + this._pageHAlign = params.page_halign; + this._pageVAlign = params.page_valign; + this._rowSpacing = params.row_spacing; + this._rowsPerPage = params.rows_per_page; + + super._init(params); + + this._iconSize = this._fixedIconSize !== -1 + ? this._fixedIconSize + : IconSize.LARGE; + + this._pageSizeChanged = false; + this._pageHeight = 0; + this._pageWidth = 0; + this._nPages = -1; + + // [ + // { + // children: [ itemData, itemData, itemData, ... ], + // }, + // { + // children: [ itemData, itemData, itemData, ... ], + // }, + // { + // children: [ itemData, itemData, itemData, ... ], + // }, + // ] + this._pages = []; + + // { + // item: { + // actor: Clutter.Actor, + // pageIndex: <index>, + // }, + // item: { + // actor: Clutter.Actor, + // pageIndex: <index>, + // }, + // } + this._items = new Map(); + + this._containerDestroyedId = 0; + this._updateIconSizesLaterId = 0; + + this._resolveOnIdleId = 0; + this._iconSizeUpdateResolveCbs = []; + } + + _findBestIconSize() { + const nColumns = this._columnsPerPage; + const nRows = this._rowsPerPage; + const columnSpacingPerPage = this._columnSpacing * (nColumns - 1); + const rowSpacingPerPage = this._rowSpacing * (nRows - 1); + const [firstItem] = this._container; + + if (this._fixedIconSize !== -1) + return this._fixedIconSize; + + const iconSizes = Object.values(IconSize).sort((a, b) => b - a); + for (const size of iconSizes) { + let usedWidth, usedHeight; + + if (firstItem) { + firstItem.icon.setIconSize(size); + const [firstItemWidth, firstItemHeight] = + firstItem.get_preferred_size(); + + const itemSize = Math.max(firstItemWidth, firstItemHeight); + + usedWidth = itemSize * nColumns; + usedHeight = itemSize * nRows; + } else { + usedWidth = size * nColumns; + usedHeight = size * nRows; + } + + const emptyHSpace = + this._pageWidth - usedWidth - columnSpacingPerPage; + const emptyVSpace = + this._pageHeight - usedHeight - rowSpacingPerPage; + + if (emptyHSpace >= 0 && emptyVSpace > 0) + return size; + } + + return IconSize.TINY; + } + + _getChildrenMaxSize() { + let minWidth = 0; + let minHeight = 0; + + for (const child of this._container) { + if (!child.visible) + continue; + + const [childMinHeight] = child.get_preferred_height(-1); + const [childMinWidth] = child.get_preferred_width(-1); + + minWidth = Math.max(minWidth, childMinWidth); + minHeight = Math.max(minHeight, childMinHeight); + } + + return Math.max(minWidth, minHeight); + } + + _getVisibleChildrenForPage(pageIndex) { + return this._pages[pageIndex].children.filter(actor => actor.visible); + } + + _updatePages() { + for (let i = 0; i < this._pages.length; i++) + this._relocateSurplusItems(i); + } + + _unlinkItem(item) { + const itemData = this._items.get(item); + + item.disconnect(itemData.destroyId); + item.disconnect(itemData.visibleId); + + this._items.delete(item); + } + + _removePage(pageIndex) { + // Make sure to not leave any icon left here + this._pages[pageIndex].children.forEach(item => { + this._unlinkItem(item); + }); + + // Adjust the page indexes of items after this page + for (const itemData of this._items.values()) { + if (itemData.pageIndex > pageIndex) + itemData.pageIndex--; + } + + this._pages.splice(pageIndex, 1); + this.emit('pages-changed'); + } + + _fillItemVacancies(pageIndex) { + if (pageIndex >= this._pages.length - 1) + return; + + const visiblePageItems = this._getVisibleChildrenForPage(pageIndex); + const itemsPerPage = this._columnsPerPage * this._rowsPerPage; + + // No reduce needed + if (visiblePageItems.length === itemsPerPage) + return; + + const visibleNextPageItems = this._getVisibleChildrenForPage(pageIndex + 1); + const nMissingItems = Math.min(itemsPerPage - visiblePageItems.length, visibleNextPageItems.length); + + // Append to the current page the first items of the next page + for (let i = 0; i < nMissingItems; i++) { + const reducedItem = visibleNextPageItems[i]; + + this._removeItemData(reducedItem); + this._addItemToPage(reducedItem, pageIndex, -1); + } + } + + _removeItemData(item) { + const itemData = this._items.get(item); + const pageIndex = itemData.pageIndex; + const page = this._pages[pageIndex]; + const itemIndex = page.children.indexOf(item); + + this._unlinkItem(item); + + page.children.splice(itemIndex, 1); + + // Delete the page if this is the last icon in it + const visibleItems = this._getVisibleChildrenForPage(pageIndex); + if (visibleItems.length === 0) + this._removePage(pageIndex); + + if (!this._allowIncompletePages) + this._fillItemVacancies(pageIndex); + } + + _relocateSurplusItems(pageIndex) { + const visiblePageItems = this._getVisibleChildrenForPage(pageIndex); + const itemsPerPage = this._columnsPerPage * this._rowsPerPage; + + // No overflow needed + if (visiblePageItems.length <= itemsPerPage) + return; + + const nExtraItems = visiblePageItems.length - itemsPerPage; + for (let i = 0; i < nExtraItems; i++) { + const overflowIndex = visiblePageItems.length - i - 1; + const overflowItem = visiblePageItems[overflowIndex]; + + this._removeItemData(overflowItem); + this._addItemToPage(overflowItem, pageIndex + 1, 0); + } + } + + _appendPage() { + this._pages.push({ children: [] }); + this.emit('pages-changed'); + } + + _addItemToPage(item, pageIndex, index) { + // Ensure we have at least one page + if (this._pages.length === 0) + this._appendPage(); + + // Append a new page if necessary + if (pageIndex === this._pages.length) + this._appendPage(); + + if (pageIndex === -1) + pageIndex = this._pages.length - 1; + + if (index === -1) + index = this._pages[pageIndex].children.length; + + this._items.set(item, { + actor: item, + pageIndex, + destroyId: item.connect('destroy', () => this._removeItemData(item)), + visibleId: item.connect('notify::visible', () => { + const itemData = this._items.get(item); + + if (item.visible) + this._relocateSurplusItems(itemData.pageIndex); + else if (!this._allowIncompletePages) + this._fillItemVacancies(itemData.pageIndex); + }), + }); + + item.icon.setIconSize(this._iconSize); + + this._pages[pageIndex].children.splice(index, 0, item); + this._relocateSurplusItems(pageIndex); + } + + _calculateSpacing(childSize) { + const nColumns = this._columnsPerPage; + const nRows = this._rowsPerPage; + const usedWidth = childSize * nColumns; + const usedHeight = childSize * nRows; + const columnSpacingPerPage = this._columnSpacing * (nColumns - 1); + const rowSpacingPerPage = this._rowSpacing * (nRows - 1); + + let emptyHSpace = this._pageWidth - usedWidth - columnSpacingPerPage; + let emptyVSpace = this._pageHeight - usedHeight - rowSpacingPerPage; + let leftEmptySpace; + let topEmptySpace; + let hSpacing; + let vSpacing; + + switch (this._pageHAlign) { + case Clutter.ActorAlign.START: + leftEmptySpace = 0; + hSpacing = this._columnSpacing; + break; + case Clutter.ActorAlign.CENTER: + leftEmptySpace = Math.floor(emptyHSpace / 2); + hSpacing = this._columnSpacing; + break; + case Clutter.ActorAlign.END: + leftEmptySpace = emptyHSpace; + hSpacing = this._columnSpacing; + break; + case Clutter.ActorAlign.FILL: + leftEmptySpace = 0; + hSpacing = this._columnSpacing + emptyHSpace / (nColumns - 1); + + // Maybe constraint horizontal spacing + if (this._maxColumnSpacing !== -1 && hSpacing > this._maxColumnSpacing) { + const extraHSpacing = + (this._maxColumnSpacing - this._columnSpacing) * (nColumns - 1); + + hSpacing = this._maxColumnSpacing; + leftEmptySpace = + Math.max((emptyHSpace - extraHSpacing) / 2, 0); + } + break; + } + + switch (this._pageVAlign) { + case Clutter.ActorAlign.START: + topEmptySpace = 0; + vSpacing = this._rowSpacing; + break; + case Clutter.ActorAlign.CENTER: + topEmptySpace = Math.floor(emptyVSpace / 2); + vSpacing = this._rowSpacing; + break; + case Clutter.ActorAlign.END: + topEmptySpace = emptyVSpace; + vSpacing = this._rowSpacing; + break; + case Clutter.ActorAlign.FILL: + topEmptySpace = 0; + vSpacing = this._rowSpacing + emptyVSpace / (nRows - 1); + + // Maybe constraint vertical spacing + if (this._maxRowSpacing !== -1 && vSpacing > this._maxRowSpacing) { + const extraVSpacing = + (this._maxRowSpacing - this._rowSpacing) * (nRows - 1); + + vSpacing = this._maxRowSpacing; + topEmptySpace = + Math.max((emptyVSpace - extraVSpacing) / 2, 0); + } + + break; + } + + return [leftEmptySpace, topEmptySpace, hSpacing, vSpacing]; + } + + _getRowPadding(items, itemIndex, childSize, spacing) { + const nRows = Math.ceil(items.length / this._columnsPerPage); + + let rowAlign = 0; + const row = Math.floor(itemIndex / this._columnsPerPage); + + // Only apply to the last row + if (row < nRows - 1) + return 0; + + const rowStart = row * this._columnsPerPage; + const rowEnd = Math.min((row + 1) * this._columnsPerPage - 1, items.length - 1); + const itemsInThisRow = rowEnd - rowStart + 1; + const nEmpty = this._columnsPerPage - itemsInThisRow; + const availableWidth = nEmpty * (spacing + childSize); + + const isRtl = + Clutter.get_default_text_direction() === Clutter.TextDirection.RTL; + + switch (this._lastRowAlign) { + case Clutter.ActorAlign.START: + rowAlign = 0; + break; + case Clutter.ActorAlign.CENTER: + rowAlign = availableWidth / 2; + break; + case Clutter.ActorAlign.END: + rowAlign = availableWidth; + break; + case Clutter.ActorAlign.FILL: + rowAlign = 0; + break; + } + + return isRtl ? rowAlign * -1 : rowAlign; + } + + _runPostAllocation() { + if (this._iconSizeUpdateResolveCbs.length > 0 && + this._resolveOnIdleId === 0) { + this._resolveOnIdleId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this._iconSizeUpdateResolveCbs.forEach(cb => cb()); + this._iconSizeUpdateResolveCbs = []; + this._resolveOnIdleId = 0; + return GLib.SOURCE_REMOVE; + }); + } + } + + _onDestroy() { + if (this._updateIconSizesLaterId >= 0) { + Meta.later_remove(this._updateIconSizesLaterId); + this._updateIconSizesLaterId = 0; + } + + if (this._resolveOnIdleId > 0) { + GLib.source_remove(this._resolveOnIdleId); + delete this._resolveOnIdleId; + } + } + + vfunc_set_container(container) { + if (this._container) + this._container.disconnect(this._containerDestroyedId); + + this._container = container; + + if (this._container) + this._containerDestroyedId = this._container.connect('destroy', this._onDestroy.bind(this)); + } + + vfunc_get_preferred_width(_container, _forHeight) { + let minWidth = -1; + let natWidth = -1; + + switch (this._orientation) { + case Clutter.Orientation.VERTICAL: + minWidth = IconSize.TINY; + natWidth = this._pageWidth; + break; + + case Clutter.Orientation.HORIZONTAL: + minWidth = this._pageWidth * this._pages.length; + natWidth = minWidth; + break; + } + + return [minWidth, natWidth]; + } + + vfunc_get_preferred_height(_container, _forWidth) { + let minHeight = -1; + let natHeight = -1; + + switch (this._orientation) { + case Clutter.Orientation.VERTICAL: + minHeight = this._pageHeight * this._pages.length; + natHeight = minHeight; + break; + + case Clutter.Orientation.HORIZONTAL: + minHeight = IconSize.TINY; + natHeight = this._pageHeight; + break; + } + + return [minHeight, natHeight]; + } + + vfunc_allocate() { + if (this._pageWidth === 0 || this._pageHeight === 0) + throw new Error('IconGridLayout.adaptToSize wasn\'t called before allocation'); + + this._updatePages(); + + const isRtl = + Clutter.get_default_text_direction() === Clutter.TextDirection.RTL; + const childSize = this._getChildrenMaxSize(); + + const [leftEmptySpace, topEmptySpace, hSpacing, vSpacing] = + this._calculateSpacing(childSize); + + const childBox = new Clutter.ActorBox(); + childBox.set_size(childSize, childSize); + + let nChangedIcons = 0; + + this._pages.forEach((page, pageIndex) => { + const visibleItems = + page.children.filter(actor => actor.visible); + + if (isRtl && this._orientation === Clutter.Orientation.HORIZONTAL) + pageIndex = swap(pageIndex, this._pages.length); + + visibleItems.forEach((item, itemIndex) => { + const row = Math.floor(itemIndex / this._columnsPerPage); + let column = itemIndex % this._columnsPerPage; + + if (isRtl) + column = swap(column, this._columnsPerPage); + + const rowPadding = this._getRowPadding(visibleItems, itemIndex, + childSize, hSpacing); + + // Icon position + let x = leftEmptySpace + rowPadding + column * (childSize + hSpacing); + let y = topEmptySpace + row * (childSize + vSpacing); + + // Page start + switch (this._orientation) { + case Clutter.Orientation.HORIZONTAL: + x += pageIndex * this._pageWidth; + break; + case Clutter.Orientation.VERTICAL: + y += pageIndex * this._pageHeight; + break; + } + + childBox.set_origin(Math.floor(x), Math.floor(y)); + + // Only ease icons when the page size didn't change + if (this._pageSizeChanged) + item.allocate(childBox); + else if (animateIconPosition(item, childBox, nChangedIcons)) + nChangedIcons++; + }); + }); + + this._pageSizeChanged = false; + + this._runPostAllocation(); + } + + /** + * addItem: + * @param {Clutter.Actor} item: item to append to the grid + * @param {int} page: page number + * @param {int} index: position in the page + * + * Adds @item to the grid. @item must not be part of the grid. + * + * If @index exceeds the number of items per page, @item will + * be added to the next page. + * + * @page must be a number between 0 and the number of pages. + * Adding to the page after next will create a new page. + */ + addItem(item, page = -1, index = -1) { + if (this._items.has(item)) + throw new Error(`Item ${item} already added to IconGridLayout`); + + if (page > this._pages.length) + throw new Error(`Cannot add ${item} to page ${page}`); + + if (!this._container) + return; + + this._container.add_child(item); + this._addItemToPage(item, page, index); + } + + /** + * appendItem: + * @param {Clutter.Actor} item: item to append to the grid + * + * Appends @item to the grid. @item must not be part of the grid. + */ + appendItem(item) { + this.addItem(item); + } + + /** + * moveItem: + * @param {Clutter.Actor} item: item to move + * @param {int} newPage: new page of the item + * @param {int} newPosition: new page of the item + * + * Moves @item to the grid. @item must be part of the grid. + */ + moveItem(item, newPage, newPosition) { + if (!this._items.has(item)) + throw new Error(`Item ${item} is not part of the IconGridLayout`); + + this._removeItemData(item); + this._addItemToPage(item, newPage, newPosition); + } + + /** + * removeItem: + * @param {Clutter.Actor} item: item to remove from the grid + * + * Removes @item to the grid. @item must be part of the grid. + */ + removeItem(item) { + if (!this._items.has(item)) + throw new Error(`Item ${item} is not part of the IconGridLayout`); + + if (!this._container) + return; + + this._container.remove_child(item); + this._removeItemData(item); + } + + /** + * getItemsAtPage: + * @param {int} pageIndex: page index + * + * Retrieves the children at page @pageIndex. Children may be invisible. + * + * @returns {Array} an array of {Clutter.Actor}s + */ + getItemsAtPage(pageIndex) { + if (pageIndex >= this._pages.length) + throw new Error(`IconGridLayout does not have page ${pageIndex}`); + + return [...this._pages[pageIndex].children]; + } + + /** + * getItemPosition: + * @param {BaseIcon} item: the item + * + * Retrieves the position of @item is its page, or -1 if @item is not + * part of the grid. + * + * @returns {[int, int]} the page and position of @item + */ + getItemPosition(item) { + if (!this._items.has(item)) + return [-1, -1]; + + const itemData = this._items.get(item); + const visibleItems = this._getVisibleChildrenForPage(itemData.pageIndex); + + return [itemData.pageIndex, visibleItems.indexOf(item)]; + } + + /** + * getItemAt: + * @param {int} page: the page + * @param {int} position: the position in page + * + * Retrieves the item at @page and @position. + * + * @returns {BaseItem} the item at @page and @position, or null + */ + getItemAt(page, position) { + if (page < 0 || page >= this._pages.length) + return null; + + const visibleItems = this._getVisibleChildrenForPage(page); + + if (position < 0 || position >= visibleItems.length) + return null; + + return visibleItems[position]; + } + + /** + * getItemPage: + * @param {BaseIcon} item: the item + * + * Retrieves the page @item is in, or -1 if @item is not part of the grid. + * + * @returns {int} the page where @item is in + */ + getItemPage(item) { + if (!this._items.has(item)) + return -1; + + const itemData = this._items.get(item); + return itemData.pageIndex; + } + + ensureIconSizeUpdated() { + if (this._updateIconSizesLaterId === 0) + return Promise.resolve(); + + return new Promise( + resolve => this._iconSizeUpdateResolveCbs.push(resolve)); + } + + adaptToSize(pageWidth, pageHeight) { + if (this._pageWidth === pageWidth && this._pageHeight === pageHeight) + return; + + this._pageWidth = pageWidth; + this._pageHeight = pageHeight; + this._pageSizeChanged = true; + + if (this._updateIconSizesLaterId === 0) { + this._updateIconSizesLaterId = + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + const iconSize = this._findBestIconSize(); + + if (this._iconSize !== iconSize) { + this._iconSize = iconSize; + + for (const child of this._container) + child.icon.setIconSize(iconSize); + + this.notify('icon-size'); + } + + this._updateIconSizesLaterId = 0; + return GLib.SOURCE_REMOVE; + }); + } + } + + /** + * getDropTarget: + * @param {int} x: position of the horizontal axis + * @param {int} y: position of the vertical axis + * + * Retrieves the item located at (@x, @y), as well as the drag location. + * Both @x and @y are relative to the grid. + * + * @returns {[Clutter.Actor, DragLocation]} the item and drag location + * under (@x, @y) + */ + getDropTarget(x, y) { + const childSize = this._getChildrenMaxSize(); + const [leftEmptySpace, topEmptySpace, hSpacing, vSpacing] = + this._calculateSpacing(childSize); + + const isRtl = + Clutter.get_default_text_direction() === Clutter.TextDirection.RTL; + + let page = this._orientation === Clutter.Orientation.VERTICAL + ? Math.floor(y / this._pageHeight) + : Math.floor(x / this._pageWidth); + + // Out of bounds + if (page >= this._pages.length) + return [null, DragLocation.INVALID]; + + if (isRtl && this._orientation === Clutter.Orientation.HORIZONTAL) + page = swap(page, this._pages.length); + + // Page-relative coordinates from now on + x %= this._pageWidth; + y %= this._pageHeight; + + if (x < leftEmptySpace || y < topEmptySpace) + return [null, DragLocation.INVALID]; + + const gridWidth = + childSize * this._columnsPerPage + + hSpacing * (this._columnsPerPage - 1); + const gridHeight = + childSize * this._rowsPerPage + + vSpacing * (this._rowsPerPage - 1); + + if (x > leftEmptySpace + gridWidth || y > topEmptySpace + gridHeight) + return [null, DragLocation.INVALID]; + + const halfHSpacing = hSpacing / 2; + const halfVSpacing = vSpacing / 2; + const visibleItems = this._getVisibleChildrenForPage(page); + + for (const item of visibleItems) { + const childBox = item.allocation.copy(); + + // Page offset + switch (this._orientation) { + case Clutter.Orientation.HORIZONTAL: + childBox.set_origin(childBox.x1 % this._pageWidth, childBox.y1); + break; + case Clutter.Orientation.VERTICAL: + childBox.set_origin(childBox.x1, childBox.y1 % this._pageHeight); + break; + } + + // Outside the icon boundaries + if (x < childBox.x1 - halfHSpacing || + x > childBox.x2 + halfHSpacing || + y < childBox.y1 - halfVSpacing || + y > childBox.y2 + halfVSpacing) + continue; + + let dragLocation; + + if (x < childBox.x1 + LEFT_DIVIDER_LEEWAY) + dragLocation = DragLocation.START_EDGE; + else if (x > childBox.x2 - RIGHT_DIVIDER_LEEWAY) + dragLocation = DragLocation.END_EDGE; + else + dragLocation = DragLocation.ON_ICON; + + if (isRtl) { + if (dragLocation === DragLocation.START_EDGE) + dragLocation = DragLocation.END_EDGE; + else if (dragLocation === DragLocation.END_EDGE) + dragLocation = DragLocation.START_EDGE; + } + + return [item, dragLocation]; + } + + return [null, DragLocation.EMPTY_SPACE]; + } + + // eslint-disable-next-line camelcase + get allow_incomplete_pages() { + return this._allowIncompletePages; + } + + // eslint-disable-next-line camelcase + set allow_incomplete_pages(v) { + if (this._allowIncompletePages === v) + return; + + this._allowIncompletePages = v; + this.notify('allow-incomplete-pages'); + } + + // eslint-disable-next-line camelcase + get column_spacing() { + return this._columnSpacing; + } + + // eslint-disable-next-line camelcase + set column_spacing(v) { + if (this._columnSpacing === v) + return; + + this._columnSpacing = v; + this.notify('column-spacing'); + } + + // eslint-disable-next-line camelcase + get columns_per_page() { + return this._columnsPerPage; + } + + // eslint-disable-next-line camelcase + set columns_per_page(v) { + if (this._columnsPerPage === v) + return; + + this._columnsPerPage = v; + this.notify('columns-per-page'); + } + + // eslint-disable-next-line camelcase + get fixed_icon_size() { + return this._fixedIconSize; + } + + // eslint-disable-next-line camelcase + set fixed_icon_size(v) { + if (this._fixedIconSize === v) + return; + + this._fixedIconSize = v; + this.notify('fixed-icon-size'); + } + + // eslint-disable-next-line camelcase + get icon_size() { + return this._iconSize; + } + + // eslint-disable-next-line camelcase + get last_row_align() { + return this._lastRowAlign; + } + + // eslint-disable-next-line camelcase + get max_column_spacing() { + return this._maxColumnSpacing; + } + + // eslint-disable-next-line camelcase + set max_column_spacing(v) { + if (this._maxColumnSpacing === v) + return; + + this._maxColumnSpacing = v; + this.notify('max-column-spacing'); + } + + // eslint-disable-next-line camelcase + get max_row_spacing() { + return this._maxRowSpacing; + } + + // eslint-disable-next-line camelcase + set max_row_spacing(v) { + if (this._maxRowSpacing === v) + return; + + this._maxRowSpacing = v; + this.notify('max-row-spacing'); + } + + // eslint-disable-next-line camelcase + set last_row_align(v) { + if (this._lastRowAlign === v) + return; + + this._lastRowAlign = v; + this.notify('last-row-align'); + } + + get nPages() { + return this._pages.length; + } + + // eslint-disable-next-line camelcase + get page_halign() { + return this._pageHAlign; + } + + // eslint-disable-next-line camelcase + set page_halign(v) { + if (this._pageHAlign === v) + return; + + this._pageHAlign = v; + this.notify('page-halign'); + } + + // eslint-disable-next-line camelcase + get page_valign() { + return this._pageVAlign; + } + + // eslint-disable-next-line camelcase + set page_valign(v) { + if (this._pageVAlign === v) + return; + + this._pageVAlign = v; + this.notify('page-valign'); + } + + // eslint-disable-next-line camelcase + get row_spacing() { + return this._rowSpacing; + } + + // eslint-disable-next-line camelcase + set row_spacing(v) { + if (this._rowSpacing === v) + return; + + this._rowSpacing = v; + this.notify('row-spacing'); + } + + // eslint-disable-next-line camelcase + get rows_per_page() { + return this._rowsPerPage; + } + + // eslint-disable-next-line camelcase + set rows_per_page(v) { + if (this._rowsPerPage === v) + return; + + this._rowsPerPage = v; + this.notify('rows-per-page'); + } + + get orientation() { + return this._orientation; + } + + set orientation(v) { + if (this._orientation === v) + return; + + switch (v) { + case Clutter.Orientation.VERTICAL: + this.request_mode = Clutter.RequestMode.HEIGHT_FOR_WIDTH; + break; + case Clutter.Orientation.HORIZONTAL: + this.request_mode = Clutter.RequestMode.WIDTH_FOR_HEIGHT; + break; + } + + this._orientation = v; + this.notify('orientation'); + } + + get pageHeight() { + return this._pageHeight; + } + + get pageWidth() { + return this._pageWidth; + } +}); + +var IconGrid = GObject.registerClass({ + Signals: { + 'pages-changed': {}, + 'animation-done': {}, + }, +}, class IconGrid extends St.Viewport { + _init(layoutParams = {}) { + layoutParams = Params.parse(layoutParams, { + allow_incomplete_pages: false, + orientation: Clutter.Orientation.VERTICAL, + columns_per_page: 6, + rows_per_page: 4, + page_halign: Clutter.ActorAlign.CENTER, + page_valign: Clutter.ActorAlign.CENTER, + last_row_align: Clutter.ActorAlign.START, + column_spacing: 0, + row_spacing: 0, + }); + const layoutManager = new IconGridLayout(layoutParams); + const pagesChangedId = layoutManager.connect('pages-changed', + () => this.emit('pages-changed')); + + super._init({ + style_class: 'icon-grid', + layoutManager, + x_expand: true, + y_expand: true, + }); + + this._gridModes = defaultGridModes; + this._currentPage = 0; + this._currentMode = -1; + this._clonesAnimating = []; + + this.connect('actor-added', this._childAdded.bind(this)); + this.connect('actor-removed', this._childRemoved.bind(this)); + this.connect('destroy', () => layoutManager.disconnect(pagesChangedId)); + } + + _getChildrenToAnimate() { + const layoutManager = this.layout_manager; + const children = layoutManager.getItemsAtPage(this._currentPage); + + return children.filter(c => c.visible); + } + + _resetAnimationActors() { + this._clonesAnimating.forEach(clone => { + clone.source.reactive = true; + clone.source.opacity = 255; + clone.destroy(); + }); + this._clonesAnimating = []; + } + + _animationDone() { + this._resetAnimationActors(); + this.emit('animation-done'); + } + + _childAdded(grid, child) { + child._iconGridKeyFocusInId = child.connect('key-focus-in', () => { + this._ensureItemIsVisible(child); + }); + + child._paintVisible = child.opacity > 0; + child._opacityChangedId = child.connect('notify::opacity', () => { + let paintVisible = child._paintVisible; + child._paintVisible = child.opacity > 0; + if (paintVisible !== child._paintVisible) + this.queue_relayout(); + }); + } + + _ensureItemIsVisible(item) { + if (!this.contains(item)) + throw new Error(`${item} is not a child of IconGrid`); + + const itemPage = this.layout_manager.getItemPage(item); + this.goToPage(itemPage); + } + + _setGridMode(modeIndex) { + if (this._currentMode === modeIndex) + return; + + this._currentMode = modeIndex; + + if (modeIndex !== -1) { + const newMode = this._gridModes[modeIndex]; + + this.layout_manager.rows_per_page = newMode.rows; + this.layout_manager.columns_per_page = newMode.columns; + } + } + + _findBestModeForSize(width, height) { + const sizeRatio = width / height; + let closestRatio = Infinity; + let bestMode = -1; + + for (let modeIndex in this._gridModes) { + const mode = this._gridModes[modeIndex]; + const modeRatio = mode.columns / mode.rows; + + if (Math.abs(sizeRatio - modeRatio) < Math.abs(sizeRatio - closestRatio)) { + closestRatio = modeRatio; + bestMode = modeIndex; + } + } + + this._setGridMode(bestMode); + } + + _childRemoved(grid, child) { + child.disconnect(child._iconGridKeyFocusInId); + delete child._iconGridKeyFocusInId; + + child.disconnect(child._opacityChangedId); + delete child._opacityChangedId; + delete child._paintVisible; + } + + vfunc_unmap() { + // Cancel animations when hiding the overview, to avoid icons + // swarming into the void ... + this._resetAnimationActors(); + super.vfunc_unmap(); + } + + vfunc_style_changed() { + super.vfunc_style_changed(); + + const node = this.get_theme_node(); + this.layout_manager.column_spacing = node.get_length('column-spacing'); + this.layout_manager.row_spacing = node.get_length('row-spacing'); + + let [found, value] = node.lookup_length('max-column-spacing', false); + this.layout_manager.max_column_spacing = found ? value : -1; + + [found, value] = node.lookup_length('max-row-spacing', false); + this.layout_manager.max_row_spacing = found ? value : -1; + } + + /** + * addItem: + * @param {Clutter.Actor} item: item to append to the grid + * @param {int} page: page number + * @param {int} index: position in the page + * + * Adds @item to the grid. @item must not be part of the grid. + * + * If @index exceeds the number of items per page, @item will + * be added to the next page. + * + * @page must be a number between 0 and the number of pages. + * Adding to the page after next will create a new page. + */ + addItem(item, page = -1, index = -1) { + if (!(item.icon instanceof BaseIcon)) + throw new Error('Only items with a BaseIcon icon property can be added to IconGrid'); + + this.layout_manager.addItem(item, page, index); + } + + /** + * appendItem: + * @param {Clutter.Actor} item: item to append to the grid + * + * Appends @item to the grid. @item must not be part of the grid. + */ + appendItem(item) { + this.layout_manager.appendItem(item); + } + + /** + * moveItem: + * @param {Clutter.Actor} item: item to move + * @param {int} newPage: new page of the item + * @param {int} newPosition: new page of the item + * + * Moves @item to the grid. @item must be part of the grid. + */ + moveItem(item, newPage, newPosition) { + this.layout_manager.moveItem(item, newPage, newPosition); + this.queue_relayout(); + } + + /** + * removeItem: + * @param {Clutter.Actor} item: item to remove from the grid + * + * Removes @item to the grid. @item must be part of the grid. + */ + removeItem(item) { + if (!this.contains(item)) + throw new Error(`Item ${item} is not part of the IconGrid`); + + this.layout_manager.removeItem(item); + } + + /** + * goToPage: + * @param {int} pageIndex: page index + * @param {boolean} animate: animate the page transition + * + * Moves the current page to @pageIndex. @pageIndex must be a valid page + * number. + */ + goToPage(pageIndex, animate = true) { + if (pageIndex >= this.nPages) + throw new Error(`IconGrid does not have page ${pageIndex}`); + + let newValue; + let adjustment; + switch (this.layout_manager.orientation) { + case Clutter.Orientation.VERTICAL: + adjustment = this.vadjustment; + newValue = pageIndex * this.layout_manager.pageHeight; + break; + case Clutter.Orientation.HORIZONTAL: + adjustment = this.hadjustment; + newValue = pageIndex * this.layout_manager.pageWidth; + break; + } + + this._currentPage = pageIndex; + + if (!this.mapped) + animate = false; + + adjustment.ease(newValue, { + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + duration: animate ? PAGE_SWITCH_TIME : 0, + }); + } + + /** + * getItemPage: + * @param {BaseIcon} item: the item + * + * Retrieves the page @item is in, or -1 if @item is not part of the grid. + * + * @returns {int} the page where @item is in + */ + getItemPage(item) { + return this.layout_manager.getItemPage(item); + } + + /** + * getItemPosition: + * @param {BaseIcon} item: the item + * + * Retrieves the position of @item is its page, or -1 if @item is not + * part of the grid. + * + * @returns {[int, int]} the page and position of @item + */ + getItemPosition(item) { + if (!this.contains(item)) + return [-1, -1]; + + const layoutManager = this.layout_manager; + return layoutManager.getItemPosition(item); + } + + /** + * getItemAt: + * @param {int} page: the page + * @param {int} position: the position in page + * + * Retrieves the item at @page and @position. + * + * @returns {BaseItem} the item at @page and @position, or null + */ + getItemAt(page, position) { + const layoutManager = this.layout_manager; + return layoutManager.getItemAt(page, position); + } + + /** + * getItemsAtPage: + * @param {int} page: the page index + * + * Retrieves the children at page @page, including invisible children. + * + * @returns {Array} an array of {Clutter.Actor}s + */ + getItemsAtPage(page) { + if (page < 0 || page > this.nPages) + throw new Error(`Page ${page} does not exist at IconGrid`); + + const layoutManager = this.layout_manager; + return layoutManager.getItemsAtPage(page); + } + + get currentPage() { + return this._currentPage; + } + + set currentPage(v) { + this.goToPage(v); + } + + get nPages() { + return this.layout_manager.nPages; + } + + adaptToSize(width, height) { + this._findBestModeForSize(width, height); + this.layout_manager.adaptToSize(width, height); + } + + async animateSpring(animationDirection, sourceActor) { + this._resetAnimationActors(); + + let actors = this._getChildrenToAnimate(); + if (actors.length == 0) { + this._animationDone(); + return; + } + + await this.layout_manager.ensureIconSizeUpdated(); + + let [sourceX, sourceY] = sourceActor.get_transformed_position(); + let [sourceWidth, sourceHeight] = sourceActor.get_size(); + // Get the center + let [sourceCenterX, sourceCenterY] = [sourceX + sourceWidth / 2, sourceY + sourceHeight / 2]; + // Design decision, 1/2 of the source actor size. + let [sourceScaledWidth, sourceScaledHeight] = [sourceWidth / 2, sourceHeight / 2]; + + actors.forEach(actor => { + let [actorX, actorY] = actor._transformedPosition = actor.get_transformed_position(); + let [x, y] = [actorX - sourceX, actorY - sourceY]; + actor._distance = Math.sqrt(x * x + y * y); + }); + let maxDist = actors.reduce((prev, cur) => { + return Math.max(prev, cur._distance); + }, 0); + let minDist = actors.reduce((prev, cur) => { + return Math.min(prev, cur._distance); + }, Infinity); + let normalization = maxDist - minDist; + + actors.forEach(actor => { + let clone = new Clutter.Clone({ source: actor }); + this._clonesAnimating.push(clone); + Main.uiGroup.add_actor(clone); + }); + + /* + * ^ + * | These need to be separate loops because Main.uiGroup.add_actor + * | is excessively slow if done inside the below loop and we want the + * | below loop to complete within one frame interval (#2065, !1002). + * v + */ + + this._clonesAnimating.forEach(actorClone => { + const actor = actorClone.source; + actor.opacity = 0; + actor.reactive = false; + + let [width, height] = actor.get_size(); + actorClone.set_size(width, height); + let scaleX = sourceScaledWidth / width; + let scaleY = sourceScaledHeight / height; + let [adjustedSourcePositionX, adjustedSourcePositionY] = [sourceCenterX - sourceScaledWidth / 2, sourceCenterY - sourceScaledHeight / 2]; + + let movementParams, fadeParams; + if (animationDirection == AnimationDirection.IN) { + let isLastItem = actor._distance == minDist; + + actorClone.opacity = 0; + actorClone.set_scale(scaleX, scaleY); + actorClone.set_translation( + adjustedSourcePositionX, adjustedSourcePositionY, 0); + + let delay = (1 - (actor._distance - minDist) / normalization) * ANIMATION_MAX_DELAY_FOR_ITEM; + let [finalX, finalY] = actor._transformedPosition; + movementParams = { + translation_x: finalX, + translation_y: finalY, + scale_x: 1, + scale_y: 1, + duration: ANIMATION_TIME_IN, + mode: Clutter.AnimationMode.EASE_IN_OUT_QUAD, + delay, + }; + + if (isLastItem) + movementParams.onComplete = this._animationDone.bind(this); + + fadeParams = { + opacity: 255, + duration: ANIMATION_FADE_IN_TIME_FOR_ITEM, + mode: Clutter.AnimationMode.EASE_IN_OUT_QUAD, + delay, + }; + } else { + let isLastItem = actor._distance == maxDist; + + let [startX, startY] = actor._transformedPosition; + actorClone.set_translation(startX, startY, 0); + + let delay = (actor._distance - minDist) / normalization * ANIMATION_MAX_DELAY_OUT_FOR_ITEM; + movementParams = { + translation_x: adjustedSourcePositionX, + translation_y: adjustedSourcePositionY, + scale_x: scaleX, + scale_y: scaleY, + duration: ANIMATION_TIME_OUT, + mode: Clutter.AnimationMode.EASE_IN_OUT_QUAD, + delay, + }; + + if (isLastItem) + movementParams.onComplete = this._animationDone.bind(this); + + fadeParams = { + opacity: 0, + duration: ANIMATION_FADE_IN_TIME_FOR_ITEM, + mode: Clutter.AnimationMode.EASE_IN_OUT_QUAD, + delay: ANIMATION_TIME_OUT + delay - ANIMATION_FADE_IN_TIME_FOR_ITEM, + }; + } + + actorClone.ease(movementParams); + actorClone.ease(fadeParams); + }); + } + + setGridModes(modes) { + this._gridModes = modes ? modes : defaultGridModes; + this.queue_relayout(); + } + + getDropTarget(x, y) { + const layoutManager = this.layout_manager; + return layoutManager.getDropTarget(x, y, this._currentPage); + } + + get itemsPerPage() { + const layoutManager = this.layout_manager; + return layoutManager.rows_per_page * layoutManager.columns_per_page; + } +}); diff --git a/js/ui/inhibitShortcutsDialog.js b/js/ui/inhibitShortcutsDialog.js new file mode 100644 index 0000000..d16c55a --- /dev/null +++ b/js/ui/inhibitShortcutsDialog.js @@ -0,0 +1,163 @@ +/* exported InhibitShortcutsDialog */ +const { Clutter, Gio, GLib, GObject, Gtk, Meta, Pango, Shell, St } = imports.gi; + +const Dialog = imports.ui.dialog; +const ModalDialog = imports.ui.modalDialog; +const PermissionStore = imports.misc.permissionStore; + +const WAYLAND_KEYBINDINGS_SCHEMA = 'org.gnome.mutter.wayland.keybindings'; + +const APP_ALLOWLIST = ['gnome-control-center.desktop']; +const APP_PERMISSIONS_TABLE = 'gnome'; +const APP_PERMISSIONS_ID = 'shortcuts-inhibitor'; +const GRANTED = 'GRANTED'; +const DENIED = 'DENIED'; + +var DialogResponse = Meta.InhibitShortcutsDialogResponse; + +var InhibitShortcutsDialog = GObject.registerClass({ + Implements: [Meta.InhibitShortcutsDialog], + Properties: { + 'window': GObject.ParamSpec.override('window', Meta.InhibitShortcutsDialog), + }, +}, class InhibitShortcutsDialog extends GObject.Object { + _init(window) { + super._init(); + this._window = window; + + this._dialog = new ModalDialog.ModalDialog(); + this._buildLayout(); + } + + get window() { + return this._window; + } + + set window(window) { + this._window = window; + } + + get _app() { + let windowTracker = Shell.WindowTracker.get_default(); + return windowTracker.get_window_app(this._window); + } + + _getRestoreAccel() { + let settings = new Gio.Settings({ schema_id: WAYLAND_KEYBINDINGS_SCHEMA }); + let accel = settings.get_strv('restore-shortcuts')[0] || ''; + return Gtk.accelerator_get_label.apply(null, + Gtk.accelerator_parse(accel)); + } + + _shouldUsePermStore() { + return this._app && !this._app.is_window_backed(); + } + + _saveToPermissionStore(grant) { + if (!this._shouldUsePermStore() || this._permStore == null) + return; + + let permissions = {}; + permissions[this._app.get_id()] = [grant]; + let data = GLib.Variant.new('av', {}); + + this._permStore.SetRemote(APP_PERMISSIONS_TABLE, + true, + APP_PERMISSIONS_ID, + permissions, + data, + (result, error) => { + if (error != null) + log(error.message); + }); + } + + _buildLayout() { + let name = this._app ? this._app.get_name() : this._window.title; + + let content = new Dialog.MessageDialogContent({ + title: _('Allow inhibiting shortcuts'), + description: name + /* Translators: %s is an application name like "Settings" */ + ? _('The application %s wants to inhibit shortcuts').format(name) + : _('An application wants to inhibit shortcuts'), + }); + + let restoreAccel = this._getRestoreAccel(); + if (restoreAccel) { + let restoreLabel = new St.Label({ + /* Translators: %s is a keyboard shortcut like "Super+x" */ + text: _('You can restore shortcuts by pressing %s.').format(restoreAccel), + style_class: 'message-dialog-description', + }); + restoreLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + restoreLabel.clutter_text.line_wrap = true; + content.add_child(restoreLabel); + } + + this._dialog.contentLayout.add_child(content); + + this._dialog.addButton({ label: _("Deny"), + action: () => { + this._saveToPermissionStore(DENIED); + this._emitResponse(DialogResponse.DENY); + }, + key: Clutter.KEY_Escape }); + + this._dialog.addButton({ label: _("Allow"), + action: () => { + this._saveToPermissionStore(GRANTED); + this._emitResponse(DialogResponse.ALLOW); + }, + default: true }); + } + + _emitResponse(response) { + this.emit('response', response); + this._dialog.close(); + } + + vfunc_show() { + if (this._app && APP_ALLOWLIST.includes(this._app.get_id())) { + this._emitResponse(DialogResponse.ALLOW); + return; + } + + if (!this._shouldUsePermStore()) { + this._dialog.open(); + return; + } + + /* Check with the permission store */ + let appId = this._app.get_id(); + this._permStore = new PermissionStore.PermissionStore((proxy, error) => { + if (error) { + log(error.message); + this._dialog.open(); + return; + } + + this._permStore.LookupRemote(APP_PERMISSIONS_TABLE, + APP_PERMISSIONS_ID, + (res, err) => { + if (err) { + this._dialog.open(); + log(err.message); + return; + } + + let [permissions] = res; + if (permissions[appId] === undefined) // Not found + this._dialog.open(); + else if (permissions[appId] == GRANTED) + this._emitResponse(DialogResponse.ALLOW); + else + this._emitResponse(DialogResponse.DENY); + }); + }); + } + + vfunc_hide() { + this._dialog.close(); + } +}); diff --git a/js/ui/kbdA11yDialog.js b/js/ui/kbdA11yDialog.js new file mode 100644 index 0000000..3cf56b1 --- /dev/null +++ b/js/ui/kbdA11yDialog.js @@ -0,0 +1,73 @@ +/* exported KbdA11yDialog */ +const { Clutter, Gio, GObject } = imports.gi; + +const Dialog = imports.ui.dialog; +const ModalDialog = imports.ui.modalDialog; + +const KEYBOARD_A11Y_SCHEMA = 'org.gnome.desktop.a11y.keyboard'; +const KEY_STICKY_KEYS_ENABLED = 'stickykeys-enable'; +const KEY_SLOW_KEYS_ENABLED = 'slowkeys-enable'; + +var KbdA11yDialog = GObject.registerClass( +class KbdA11yDialog extends GObject.Object { + _init() { + super._init(); + + this._a11ySettings = new Gio.Settings({ schema_id: KEYBOARD_A11Y_SCHEMA }); + + let seat = Clutter.get_default_backend().get_default_seat(); + seat.connect('kbd-a11y-flags-changed', + this._showKbdA11yDialog.bind(this)); + } + + _showKbdA11yDialog(seat, newFlags, whatChanged) { + let dialog = new ModalDialog.ModalDialog(); + let title, description; + let key, enabled; + + if (whatChanged & Clutter.KeyboardA11yFlags.SLOW_KEYS_ENABLED) { + key = KEY_SLOW_KEYS_ENABLED; + enabled = (newFlags & Clutter.KeyboardA11yFlags.SLOW_KEYS_ENABLED) > 0; + title = enabled + ? _("Slow Keys Turned On") + : _("Slow Keys Turned Off"); + description = _('You just held down the Shift key for 8 seconds. This is the shortcut ' + + 'for the Slow Keys feature, which affects the way your keyboard works.'); + + } else if (whatChanged & Clutter.KeyboardA11yFlags.STICKY_KEYS_ENABLED) { + key = KEY_STICKY_KEYS_ENABLED; + enabled = (newFlags & Clutter.KeyboardA11yFlags.STICKY_KEYS_ENABLED) > 0; + title = enabled + ? _("Sticky Keys Turned On") + : _("Sticky Keys Turned Off"); + description = enabled + ? _("You just pressed the Shift key 5 times in a row. This is the shortcut " + + "for the Sticky Keys feature, which affects the way your keyboard works.") + : _("You just pressed two keys at once, or pressed the Shift key 5 times in a row. " + + "This turns off the Sticky Keys feature, which affects the way your keyboard works."); + } else { + return; + } + + let content = new Dialog.MessageDialogContent({ title, description }); + dialog.contentLayout.add_child(content); + + dialog.addButton({ label: enabled ? _("Leave On") : _("Turn On"), + action: () => { + this._a11ySettings.set_boolean(key, true); + dialog.close(); + }, + default: enabled, + key: !enabled ? Clutter.KEY_Escape : null }); + + dialog.addButton({ label: enabled ? _("Turn Off") : _("Leave Off"), + action: () => { + this._a11ySettings.set_boolean(key, false); + dialog.close(); + }, + default: !enabled, + key: enabled ? Clutter.KEY_Escape : null }); + + dialog.open(); + } +}); diff --git a/js/ui/keyboard.js b/js/ui/keyboard.js new file mode 100644 index 0000000..e2574f0 --- /dev/null +++ b/js/ui/keyboard.js @@ -0,0 +1,1967 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported KeyboardManager */ + +const { Clutter, Gio, GLib, GObject, Meta, St } = imports.gi; +const ByteArray = imports.byteArray; +const Signals = imports.signals; + +const InputSourceManager = imports.ui.status.keyboard; +const IBusManager = imports.misc.ibusManager; +const BoxPointer = imports.ui.boxpointer; +const Layout = imports.ui.layout; +const Main = imports.ui.main; +const PageIndicators = imports.ui.pageIndicators; +const PopupMenu = imports.ui.popupMenu; + +var KEYBOARD_REST_TIME = Layout.KEYBOARD_ANIMATION_TIME * 2; +var KEY_LONG_PRESS_TIME = 250; +var PANEL_SWITCH_ANIMATION_TIME = 500; +var PANEL_SWITCH_RELATIVE_DISTANCE = 1 / 3; /* A third of the actor width */ + +const A11Y_APPLICATIONS_SCHEMA = 'org.gnome.desktop.a11y.applications'; +const SHOW_KEYBOARD = 'screen-keyboard-enabled'; + +/* KeyContainer puts keys in a grid where a 1:1 key takes this size */ +const KEY_SIZE = 2; + +const defaultKeysPre = [ + [[], [], [{ width: 1.5, level: 1, extraClassName: 'shift-key-lowercase', icon: 'keyboard-shift-filled-symbolic' }], [{ label: '?123', width: 1.5, level: 2 }]], + [[], [], [{ width: 1.5, level: 0, extraClassName: 'shift-key-uppercase', icon: 'keyboard-shift-filled-symbolic' }], [{ label: '?123', width: 1.5, level: 2 }]], + [[], [], [{ label: '=/<', width: 1.5, level: 3 }], [{ label: 'ABC', width: 1.5, level: 0 }]], + [[], [], [{ label: '?123', width: 1.5, level: 2 }], [{ label: 'ABC', width: 1.5, level: 0 }]], +]; + +const defaultKeysPost = [ + [[{ width: 1.5, keyval: Clutter.KEY_BackSpace, icon: 'edit-clear-symbolic' }], + [{ width: 2, keyval: Clutter.KEY_Return, extraClassName: 'enter-key', icon: 'keyboard-enter-symbolic' }], + [{ width: 3, level: 1, right: true, extraClassName: 'shift-key-lowercase', icon: 'keyboard-shift-filled-symbolic' }], + [{ action: 'emoji', icon: 'face-smile-symbolic' }, { action: 'languageMenu', extraClassName: 'layout-key', icon: 'keyboard-layout-filled-symbolic' }, { action: 'hide', extraClassName: 'hide-key', icon: 'go-down-symbolic' }]], + [[{ width: 1.5, keyval: Clutter.KEY_BackSpace, icon: 'edit-clear-symbolic' }], + [{ width: 2, keyval: Clutter.KEY_Return, extraClassName: 'enter-key', icon: 'keyboard-enter-symbolic' }], + [{ width: 3, level: 0, right: true, extraClassName: 'shift-key-uppercase', icon: 'keyboard-shift-filled-symbolic' }], + [{ action: 'emoji', icon: 'face-smile-symbolic' }, { action: 'languageMenu', extraClassName: 'layout-key', icon: 'keyboard-layout-filled-symbolic' }, { action: 'hide', extraClassName: 'hide-key', icon: 'go-down-symbolic' }]], + [[{ width: 1.5, keyval: Clutter.KEY_BackSpace, icon: 'edit-clear-symbolic' }], + [{ width: 2, keyval: Clutter.KEY_Return, extraClassName: 'enter-key', icon: 'keyboard-enter-symbolic' }], + [{ label: '=/<', width: 3, level: 3, right: true }], + [{ action: 'emoji', icon: 'face-smile-symbolic' }, { action: 'languageMenu', extraClassName: 'layout-key', icon: 'keyboard-layout-filled-symbolic' }, { action: 'hide', extraClassName: 'hide-key', icon: 'go-down-symbolic' }]], + [[{ width: 1.5, keyval: Clutter.KEY_BackSpace, icon: 'edit-clear-symbolic' }], + [{ width: 2, keyval: Clutter.KEY_Return, extraClassName: 'enter-key', icon: 'keyboard-enter-symbolic' }], + [{ label: '?123', width: 3, level: 2, right: true }], + [{ action: 'emoji', icon: 'face-smile-symbolic' }, { action: 'languageMenu', extraClassName: 'layout-key', icon: 'keyboard-layout-filled-symbolic' }, { action: 'hide', extraClassName: 'hide-key', icon: 'go-down-symbolic' }]], +]; + +var AspectContainer = GObject.registerClass( +class AspectContainer extends St.Widget { + _init(params) { + super._init(params); + this._ratio = 1; + } + + setRatio(relWidth, relHeight) { + this._ratio = relWidth / relHeight; + this.queue_relayout(); + } + + vfunc_get_preferred_width(forHeight) { + let [min, nat] = super.vfunc_get_preferred_width(forHeight); + + if (forHeight > 0) + nat = forHeight * this._ratio; + + return [min, nat]; + } + + vfunc_get_preferred_height(forWidth) { + let [min, nat] = super.vfunc_get_preferred_height(forWidth); + + if (forWidth > 0) + nat = forWidth / this._ratio; + + return [min, nat]; + } + + vfunc_allocate(box) { + if (box.get_width() > 0 && box.get_height() > 0) { + let sizeRatio = box.get_width() / box.get_height(); + + if (sizeRatio >= this._ratio) { + /* Restrict horizontally */ + let width = box.get_height() * this._ratio; + let diff = box.get_width() - width; + + box.x1 += Math.floor(diff / 2); + box.x2 -= Math.ceil(diff / 2); + } else { + /* Restrict vertically, align to bottom */ + let height = box.get_width() / this._ratio; + box.y1 = box.y2 - Math.floor(height); + } + } + + super.vfunc_allocate(box); + } +}); + +var KeyContainer = GObject.registerClass( +class KeyContainer extends St.Widget { + _init() { + let gridLayout = new Clutter.GridLayout({ orientation: Clutter.Orientation.HORIZONTAL, + column_homogeneous: true, + row_homogeneous: true }); + super._init({ + layout_manager: gridLayout, + x_expand: true, + y_expand: true, + }); + this._gridLayout = gridLayout; + this._currentRow = 0; + this._currentCol = 0; + this._maxCols = 0; + + this._currentRow = null; + this._rows = []; + } + + appendRow() { + this._currentRow++; + this._currentCol = 0; + + let row = { + keys: [], + width: 0, + }; + this._rows.push(row); + } + + appendKey(key, width = 1, height = 1) { + let keyInfo = { + key, + left: this._currentCol, + top: this._currentRow, + width, + height, + }; + + let row = this._rows[this._rows.length - 1]; + row.keys.push(keyInfo); + row.width += width; + + this._currentCol += width; + this._maxCols = Math.max(this._currentCol, this._maxCols); + } + + layoutButtons(container) { + let nCol = 0, nRow = 0; + + for (let i = 0; i < this._rows.length; i++) { + let row = this._rows[i]; + + /* When starting a new row, see if we need some padding */ + if (nCol == 0) { + let diff = this._maxCols - row.width; + if (diff >= 1) + nCol = diff * KEY_SIZE / 2; + else + nCol = diff * KEY_SIZE; + } + + for (let j = 0; j < row.keys.length; j++) { + let keyInfo = row.keys[j]; + let width = keyInfo.width * KEY_SIZE; + let height = keyInfo.height * KEY_SIZE; + + this._gridLayout.attach(keyInfo.key, nCol, nRow, width, height); + nCol += width; + } + + nRow += KEY_SIZE; + nCol = 0; + } + + if (container) + container.setRatio(this._maxCols, this._rows.length); + } +}); + +var Suggestions = GObject.registerClass( +class Suggestions extends St.BoxLayout { + _init() { + super._init({ + style_class: 'word-suggestions', + vertical: false, + x_align: Clutter.ActorAlign.CENTER, + }); + this.show(); + } + + add(word, callback) { + let button = new St.Button({ label: word }); + button.connect('clicked', callback); + this.add_child(button); + } + + clear() { + this.remove_all_children(); + } +}); + +var LanguageSelectionPopup = class extends PopupMenu.PopupMenu { + constructor(actor) { + super(actor, 0.5, St.Side.BOTTOM); + + let inputSourceManager = InputSourceManager.getInputSourceManager(); + let inputSources = inputSourceManager.inputSources; + + let item; + for (let i in inputSources) { + let is = inputSources[i]; + + item = this.addAction(is.displayName, () => { + inputSourceManager.activateInputSource(is, true); + }); + item.can_focus = false; + } + + this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + item = this.addSettingsAction(_("Region & Language Settings"), 'gnome-region-panel.desktop'); + item.can_focus = false; + + this._capturedEventId = 0; + + this._unmapId = actor.connect('notify::mapped', () => { + if (!actor.is_mapped()) + this.close(true); + }); + } + + _onCapturedEvent(actor, event) { + if (event.get_source() == this.actor || + this.actor.contains(event.get_source())) + return Clutter.EVENT_PROPAGATE; + + if (event.type() == Clutter.EventType.BUTTON_RELEASE || event.type() == Clutter.EventType.TOUCH_END) + this.close(true); + + return Clutter.EVENT_STOP; + } + + open(animate) { + super.open(animate); + this._capturedEventId = global.stage.connect('captured-event', + this._onCapturedEvent.bind(this)); + } + + close(animate) { + super.close(animate); + if (this._capturedEventId != 0) { + global.stage.disconnect(this._capturedEventId); + this._capturedEventId = 0; + } + } + + destroy() { + if (this._capturedEventId != 0) + global.stage.disconnect(this._capturedEventId); + if (this._unmapId != 0) + this.sourceActor.disconnect(this._unmapId); + super.destroy(); + } +}; + +var Key = GObject.registerClass({ + Signals: { + 'activated': {}, + 'long-press': {}, + 'pressed': { param_types: [GObject.TYPE_UINT, GObject.TYPE_STRING] }, + 'released': { param_types: [GObject.TYPE_UINT, GObject.TYPE_STRING] }, + }, +}, class Key extends St.BoxLayout { + _init(key, extendedKeys, icon = null) { + super._init({ style_class: 'key-container' }); + + this.key = key || ""; + this.keyButton = this._makeKey(this.key, icon); + + /* Add the key in a container, so keys can be padded without losing + * logical proportions between those. + */ + this.add_child(this.keyButton); + this.connect('destroy', this._onDestroy.bind(this)); + + this._extendedKeys = extendedKeys; + this._extendedKeyboard = null; + this._pressTimeoutId = 0; + this._capturedPress = false; + + this._capturedEventId = 0; + this._unmapId = 0; + this._longPress = false; + } + + _onDestroy() { + if (this._boxPointer) { + this._boxPointer.destroy(); + this._boxPointer = null; + } + + this.cancel(); + } + + _ensureExtendedKeysPopup() { + if (this._extendedKeys.length === 0) + return; + + if (this._boxPointer) + return; + + this._boxPointer = new BoxPointer.BoxPointer(St.Side.BOTTOM); + this._boxPointer.hide(); + Main.layoutManager.addTopChrome(this._boxPointer); + this._boxPointer.setPosition(this.keyButton, 0.5); + + // Adds style to existing keyboard style to avoid repetition + this._boxPointer.add_style_class_name('keyboard-subkeys'); + this._getExtendedKeys(); + this.keyButton._extendedKeys = this._extendedKeyboard; + } + + _getKeyval(key) { + let unicode = key.length ? key.charCodeAt(0) : undefined; + return Clutter.unicode_to_keysym(unicode); + } + + _press(key) { + this.emit('activated'); + + if (key !== this.key || this._extendedKeys.length === 0) + this.emit('pressed', this._getKeyval(key), key); + + if (key == this.key) { + this._pressTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, + KEY_LONG_PRESS_TIME, + () => { + this._longPress = true; + this._pressTimeoutId = 0; + + this.emit('long-press'); + + if (this._extendedKeys.length > 0) { + this._touchPressed = false; + this._ensureExtendedKeysPopup(); + this.keyButton.set_hover(false); + this.keyButton.fake_release(); + this._showSubkeys(); + } + + return GLib.SOURCE_REMOVE; + }); + } + } + + _release(key) { + if (this._pressTimeoutId != 0) { + GLib.source_remove(this._pressTimeoutId); + this._pressTimeoutId = 0; + } + + if (!this._longPress && key === this.key && this._extendedKeys.length > 0) + this.emit('pressed', this._getKeyval(key), key); + + this.emit('released', this._getKeyval(key), key); + this._hideSubkeys(); + this._longPress = false; + } + + cancel() { + if (this._pressTimeoutId != 0) { + GLib.source_remove(this._pressTimeoutId); + this._pressTimeoutId = 0; + } + this._touchPressed = false; + this.keyButton.set_hover(false); + this.keyButton.fake_release(); + } + + _onCapturedEvent(actor, event) { + let type = event.type(); + let press = type == Clutter.EventType.BUTTON_PRESS || type == Clutter.EventType.TOUCH_BEGIN; + let release = type == Clutter.EventType.BUTTON_RELEASE || type == Clutter.EventType.TOUCH_END; + + if (event.get_source() == this._boxPointer.bin || + this._boxPointer.bin.contains(event.get_source())) + return Clutter.EVENT_PROPAGATE; + + if (press) + this._capturedPress = true; + else if (release && this._capturedPress) + this._hideSubkeys(); + + return Clutter.EVENT_STOP; + } + + _showSubkeys() { + this._boxPointer.open(BoxPointer.PopupAnimation.FULL); + this._capturedEventId = global.stage.connect('captured-event', + this._onCapturedEvent.bind(this)); + this._unmapId = this.keyButton.connect('notify::mapped', () => { + if (!this.keyButton.is_mapped()) + this._hideSubkeys(); + }); + } + + _hideSubkeys() { + if (this._boxPointer) + this._boxPointer.close(BoxPointer.PopupAnimation.FULL); + if (this._capturedEventId) { + global.stage.disconnect(this._capturedEventId); + this._capturedEventId = 0; + } + if (this._unmapId) { + this.keyButton.disconnect(this._unmapId); + this._unmapId = 0; + } + this._capturedPress = false; + } + + _makeKey(key, icon) { + let button = new St.Button({ + style_class: 'keyboard-key', + x_expand: true, + }); + + if (icon) { + let child = new St.Icon({ icon_name: icon }); + button.set_child(child); + this._icon = child; + } else { + let label = GLib.markup_escape_text(key, -1); + button.set_label(label); + } + + button.keyWidth = 1; + button.connect('button-press-event', () => { + this._press(key); + return Clutter.EVENT_PROPAGATE; + }); + button.connect('button-release-event', () => { + this._release(key); + return Clutter.EVENT_PROPAGATE; + }); + button.connect('touch-event', (actor, event) => { + // We only handle touch events here on wayland. On X11 + // we do get emulated pointer events, which already works + // for single-touch cases. Besides, the X11 passive touch grab + // set up by Mutter will make us see first the touch events + // and later the pointer events, so it will look like two + // unrelated series of events, we want to avoid double handling + // in these cases. + if (!Meta.is_wayland_compositor()) + return Clutter.EVENT_PROPAGATE; + + if (!this._touchPressed && + event.type() == Clutter.EventType.TOUCH_BEGIN) { + this._touchPressed = true; + this._press(key); + } else if (this._touchPressed && + event.type() == Clutter.EventType.TOUCH_END) { + this._touchPressed = false; + this._release(key); + } + return Clutter.EVENT_PROPAGATE; + }); + + return button; + } + + _getExtendedKeys() { + this._extendedKeyboard = new St.BoxLayout({ + style_class: 'key-container', + vertical: false, + }); + for (let i = 0; i < this._extendedKeys.length; ++i) { + let extendedKey = this._extendedKeys[i]; + let key = this._makeKey(extendedKey); + + key.extendedKey = extendedKey; + this._extendedKeyboard.add(key); + + key.set_size(...this.keyButton.allocation.get_size()); + this.keyButton.connect('notify::allocation', + () => key.set_size(...this.keyButton.allocation.get_size())); + } + this._boxPointer.bin.add_actor(this._extendedKeyboard); + } + + get subkeys() { + return this._boxPointer; + } + + setWidth(width) { + this.keyButton.keyWidth = width; + } + + setLatched(latched) { + if (!this._icon) + return; + + if (latched) { + this.keyButton.add_style_pseudo_class('latched'); + this._icon.icon_name = 'keyboard-caps-lock-filled-symbolic'; + } else { + this.keyButton.remove_style_pseudo_class('latched'); + this._icon.icon_name = 'keyboard-shift-filled-symbolic'; + } + } +}); + +var KeyboardModel = class { + constructor(groupName) { + let names = [groupName]; + if (groupName.includes('+')) + names.push(groupName.replace(/\+.*/, '')); + names.push('us'); + + for (let i = 0; i < names.length; i++) { + try { + this._model = this._loadModel(names[i]); + break; + } catch (e) { + } + } + } + + _loadModel(groupName) { + let file = Gio.File.new_for_uri('resource:///org/gnome/shell/osk-layouts/%s.json'.format(groupName)); + let [success_, contents] = file.load_contents(null); + contents = ByteArray.toString(contents); + + return JSON.parse(contents); + } + + getLevels() { + return this._model.levels; + } + + getKeysForLevel(levelName) { + return this._model.levels.find(level => level == levelName); + } +}; + +var FocusTracker = class { + constructor() { + this._currentWindow = null; + this._rect = null; + + global.display.connect('notify::focus-window', () => { + this._setCurrentWindow(global.display.focus_window); + this.emit('window-changed', this._currentWindow); + }); + + global.display.connect('grab-op-begin', (display, window, op) => { + if (window == this._currentWindow && + (op == Meta.GrabOp.MOVING || op == Meta.GrabOp.KEYBOARD_MOVING)) + this.emit('reset'); + }); + + /* Valid for wayland clients */ + Main.inputMethod.connect('cursor-location-changed', (o, rect) => { + let newRect = { x: rect.get_x(), y: rect.get_y(), width: rect.get_width(), height: rect.get_height() }; + this._setCurrentRect(newRect); + }); + + this._ibusManager = IBusManager.getIBusManager(); + this._ibusManager.connect('set-cursor-location', (manager, rect) => { + /* Valid for X11 clients only */ + if (Main.inputMethod.currentFocus) + return; + + this._setCurrentRect(rect); + }); + this._ibusManager.connect('focus-in', () => { + this.emit('focus-changed', true); + }); + this._ibusManager.connect('focus-out', () => { + this.emit('focus-changed', false); + }); + } + + get currentWindow() { + return this._currentWindow; + } + + _setCurrentWindow(window) { + this._currentWindow = window; + } + + _setCurrentRect(rect) { + if (this._currentWindow) { + let frameRect = this._currentWindow.get_frame_rect(); + rect.x -= frameRect.x; + rect.y -= frameRect.y; + } + + if (this._rect && + this._rect.x == rect.x && + this._rect.y == rect.y && + this._rect.width == rect.width && + this._rect.height == rect.height) + return; + + this._rect = rect; + this.emit('position-changed'); + } + + getCurrentRect() { + let rect = { x: this._rect.x, y: this._rect.y, + width: this._rect.width, height: this._rect.height }; + + if (this._currentWindow) { + let frameRect = this._currentWindow.get_frame_rect(); + rect.x += frameRect.x; + rect.y += frameRect.y; + } + + return rect; + } +}; +Signals.addSignalMethods(FocusTracker.prototype); + +var EmojiPager = GObject.registerClass({ + Properties: { + 'delta': GObject.ParamSpec.int( + 'delta', 'delta', 'delta', + GObject.ParamFlags.READWRITE, + GLib.MININT32, GLib.MAXINT32, 0), + }, + Signals: { + 'emoji': { param_types: [GObject.TYPE_STRING] }, + 'page-changed': { + param_types: [GObject.TYPE_INT, GObject.TYPE_INT, GObject.TYPE_INT], + }, + }, +}, class EmojiPager extends St.Widget { + _init(sections, nCols, nRows) { + super._init({ + layout_manager: new Clutter.BinLayout(), + reactive: true, + clip_to_allocation: true, + y_expand: true, + }); + this._sections = sections; + this._nCols = nCols; + this._nRows = nRows; + + this._pages = []; + this._panel = null; + this._curPage = null; + this._followingPage = null; + this._followingPanel = null; + this._currentKey = null; + this._delta = 0; + this._width = null; + + this._initPagingInfo(); + + let panAction = new Clutter.PanAction({ interpolate: false }); + panAction.connect('pan', this._onPan.bind(this)); + panAction.connect('gesture-begin', this._onPanBegin.bind(this)); + panAction.connect('gesture-cancel', this._onPanCancel.bind(this)); + panAction.connect('gesture-end', this._onPanEnd.bind(this)); + this._panAction = panAction; + this.add_action(panAction); + } + + get delta() { + return this._delta; + } + + set delta(value) { + if (value > this._width) + value = this._width; + else if (value < -this._width) + value = -this._width; + + if (this._delta == value) + return; + + this._delta = value; + this.notify('delta'); + + if (value == 0) + return; + + let relValue = Math.abs(value / this._width); + let followingPage = this.getFollowingPage(); + + if (this._followingPage != followingPage) { + if (this._followingPanel) { + this._followingPanel.destroy(); + this._followingPanel = null; + } + + if (followingPage != null) { + this._followingPanel = this._generatePanel(followingPage); + this._followingPanel.set_pivot_point(0.5, 0.5); + this.add_child(this._followingPanel); + this.set_child_below_sibling(this._followingPanel, this._panel); + } + + this._followingPage = followingPage; + } + + this._panel.translation_x = value; + this._panel.opacity = 255 * (1 - Math.pow(relValue, 3)); + + if (this._followingPanel) { + this._followingPanel.scale_x = 0.8 + (0.2 * relValue); + this._followingPanel.scale_y = 0.8 + (0.2 * relValue); + this._followingPanel.opacity = 255 * relValue; + } + } + + _prevPage(nPage) { + return (nPage + this._pages.length - 1) % this._pages.length; + } + + _nextPage(nPage) { + return (nPage + 1) % this._pages.length; + } + + getFollowingPage() { + if (this.delta == 0) + return null; + + if ((this.delta < 0 && global.stage.text_direction == Clutter.TextDirection.LTR) || + (this.delta > 0 && global.stage.text_direction == Clutter.TextDirection.RTL)) + return this._nextPage(this._curPage); + else + return this._prevPage(this._curPage); + } + + _onPan(action) { + let [dist_, dx, dy_] = action.get_motion_delta(0); + this.delta += dx; + + if (this._currentKey != null) { + this._currentKey.cancel(); + this._currentKey = null; + } + + return false; + } + + _onPanBegin() { + this._width = this.width; + return true; + } + + _onPanEnd() { + if (Math.abs(this._delta) < this.width * PANEL_SWITCH_RELATIVE_DISTANCE) { + this._onPanCancel(); + } else { + let value; + if (this._delta > 0) + value = this._width; + else if (this._delta < 0) + value = -this._width; + + let relDelta = Math.abs(this._delta - value) / this._width; + let time = PANEL_SWITCH_ANIMATION_TIME * Math.abs(relDelta); + + this.remove_all_transitions(); + this.ease_property('delta', value, { + duration: time, + onComplete: () => { + this.setCurrentPage(this.getFollowingPage()); + }, + }); + } + } + + _onPanCancel() { + let relDelta = Math.abs(this._delta) / this.width; + let time = PANEL_SWITCH_ANIMATION_TIME * Math.abs(relDelta); + + this.remove_all_transitions(); + this.ease_property('delta', 0, { + duration: time, + }); + } + + _initPagingInfo() { + for (let i = 0; i < this._sections.length; i++) { + let section = this._sections[i]; + let itemsPerPage = this._nCols * this._nRows; + let nPages = Math.ceil(section.keys.length / itemsPerPage); + let page = -1; + let pageKeys; + + for (let j = 0; j < section.keys.length; j++) { + if (j % itemsPerPage == 0) { + page++; + pageKeys = []; + this._pages.push({ pageKeys, nPages, page, section: this._sections[i] }); + } + + pageKeys.push(section.keys[j]); + } + } + } + + _lookupSection(section, nPage) { + for (let i = 0; i < this._pages.length; i++) { + let page = this._pages[i]; + + if (page.section == section && page.page == nPage) + return i; + } + + return -1; + } + + _generatePanel(nPage) { + let gridLayout = new Clutter.GridLayout({ orientation: Clutter.Orientation.HORIZONTAL, + column_homogeneous: true, + row_homogeneous: true }); + let panel = new St.Widget({ layout_manager: gridLayout, + style_class: 'emoji-page', + x_expand: true, + y_expand: true }); + + /* Set an expander actor so all proportions are right despite the panel + * not having all rows/cols filled in. + */ + let expander = new Clutter.Actor(); + gridLayout.attach(expander, 0, 0, this._nCols, this._nRows); + + let page = this._pages[nPage]; + let col = 0; + let row = 0; + + for (let i = 0; i < page.pageKeys.length; i++) { + let modelKey = page.pageKeys[i]; + let key = new Key(modelKey.label, modelKey.variants); + + key.keyButton.set_button_mask(0); + + key.connect('activated', () => { + this._currentKey = key; + }); + key.connect('long-press', () => { + this._panAction.cancel(); + }); + key.connect('released', (actor, keyval, str) => { + if (this._currentKey != key) + return; + this._currentKey = null; + this.emit('emoji', str); + }); + + gridLayout.attach(key, col, row, 1, 1); + + col++; + if (col >= this._nCols) { + col = 0; + row++; + } + } + + return panel; + } + + setCurrentPage(nPage) { + if (this._curPage == nPage) + return; + + this._curPage = nPage; + + if (this._panel) { + this._panel.destroy(); + this._panel = null; + } + + /* Reuse followingPage if possible */ + if (nPage == this._followingPage) { + this._panel = this._followingPanel; + this._followingPanel = null; + } + + if (this._followingPanel) + this._followingPanel.destroy(); + + this._followingPanel = null; + this._followingPage = null; + this._delta = 0; + + if (!this._panel) { + this._panel = this._generatePanel(nPage); + this.add_child(this._panel); + } + + let page = this._pages[nPage]; + this.emit('page-changed', page.section.label, page.page, page.nPages); + } + + setCurrentSection(section, nPage) { + for (let i = 0; i < this._pages.length; i++) { + let page = this._pages[i]; + + if (page.section == section && page.page == nPage) { + this.setCurrentPage(i); + break; + } + } + } +}); + +var EmojiSelection = GObject.registerClass({ + Signals: { + 'emoji-selected': { param_types: [GObject.TYPE_STRING] }, + 'close-request': {}, + 'toggle': {}, + }, +}, class EmojiSelection extends St.BoxLayout { + _init() { + super._init({ + style_class: 'emoji-panel', + x_expand: true, + y_expand: true, + vertical: true, + }); + + this._sections = [ + { first: 'grinning face', label: '🙂️' }, + { first: 'selfie', label: '👍️' }, + { first: 'monkey face', label: '🌷️' }, + { first: 'grapes', label: '🍴️' }, + { first: 'globe showing Europe-Africa', label: '✈️' }, + { first: 'jack-o-lantern', label: '🏃️' }, + { first: 'muted speaker', label: '🔔️' }, + { first: 'ATM sign', label: '❤️' }, + { first: 'chequered flag', label: '🚩️' }, + ]; + + this._populateSections(); + + this._emojiPager = new EmojiPager(this._sections, 11, 3); + this._emojiPager.connect('page-changed', (pager, sectionLabel, page, nPages) => { + this._onPageChanged(sectionLabel, page, nPages); + }); + this._emojiPager.connect('emoji', (pager, str) => { + this.emit('emoji-selected', str); + }); + this.add_child(this._emojiPager); + + this._pageIndicator = new PageIndicators.PageIndicators( + Clutter.Orientation.HORIZONTAL); + this.add_child(this._pageIndicator); + this._pageIndicator.setReactive(false); + + this._emojiPager.connect('notify::delta', () => { + this._updateIndicatorPosition(); + }); + + let bottomRow = this._createBottomRow(); + this.add_child(bottomRow); + + this._curPage = 0; + } + + vfunc_map() { + this._emojiPager.setCurrentPage(0); + super.vfunc_map(); + } + + _onPageChanged(sectionLabel, page, nPages) { + this._curPage = page; + this._pageIndicator.setNPages(nPages); + this._updateIndicatorPosition(); + + for (let i = 0; i < this._sections.length; i++) { + let sect = this._sections[i]; + sect.button.setLatched(sectionLabel == sect.label); + } + } + + _updateIndicatorPosition() { + this._pageIndicator.setCurrentPosition(this._curPage - + this._emojiPager.delta / this._emojiPager.width); + } + + _findSection(emoji) { + for (let i = 0; i < this._sections.length; i++) { + if (this._sections[i].first == emoji) + return this._sections[i]; + } + + return null; + } + + _populateSections() { + let file = Gio.File.new_for_uri('resource:///org/gnome/shell/osk-layouts/emoji.json'); + let [success_, contents] = file.load_contents(null); + + if (contents instanceof Uint8Array) + contents = imports.byteArray.toString(contents); + let emoji = JSON.parse(contents); + + let variants = []; + let currentKey = 0; + let currentSection = null; + + for (let i = 0; i < emoji.length; i++) { + /* Group variants of a same emoji so they appear on the key popover */ + if (emoji[i].name.startsWith(emoji[currentKey].name)) { + variants.push(emoji[i].char); + if (i < emoji.length - 1) + continue; + } + + let newSection = this._findSection(emoji[currentKey].name); + if (newSection != null) { + currentSection = newSection; + currentSection.keys = []; + } + + /* Create the key */ + let label = emoji[currentKey].char + String.fromCharCode(0xFE0F); + currentSection.keys.push({ label, variants }); + currentKey = i; + variants = []; + } + } + + _createBottomRow() { + let row = new KeyContainer(); + let key; + + row.appendRow(); + + key = new Key('ABC', []); + key.keyButton.add_style_class_name('default-key'); + key.connect('released', () => this.emit('toggle')); + row.appendKey(key, 1.5); + + for (let i = 0; i < this._sections.length; i++) { + let section = this._sections[i]; + + key = new Key(section.label, []); + key.connect('released', () => this._emojiPager.setCurrentSection(section, 0)); + row.appendKey(key); + + section.button = key; + } + + key = new Key(null, [], 'go-down-symbolic'); + key.keyButton.add_style_class_name('default-key'); + key.keyButton.add_style_class_name('hide-key'); + key.connect('released', () => { + this.emit('close-request'); + }); + row.appendKey(key); + row.layoutButtons(); + + let actor = new AspectContainer({ layout_manager: new Clutter.BinLayout(), + x_expand: true, y_expand: true }); + actor.add_child(row); + /* Regular keyboard layouts are 11.5×4 grids, optimize for that + * at the moment. Ideally this should be as wide as the current + * keymap. + */ + actor.setRatio(11.5, 1); + + return actor; + } +}); + +var Keypad = GObject.registerClass({ + Signals: { + 'keyval': { param_types: [GObject.TYPE_UINT] }, + }, +}, class Keypad extends AspectContainer { + _init() { + let keys = [ + { label: '1', keyval: Clutter.KEY_1, left: 0, top: 0 }, + { label: '2', keyval: Clutter.KEY_2, left: 1, top: 0 }, + { label: '3', keyval: Clutter.KEY_3, left: 2, top: 0 }, + { label: '4', keyval: Clutter.KEY_4, left: 0, top: 1 }, + { label: '5', keyval: Clutter.KEY_5, left: 1, top: 1 }, + { label: '6', keyval: Clutter.KEY_6, left: 2, top: 1 }, + { label: '7', keyval: Clutter.KEY_7, left: 0, top: 2 }, + { label: '8', keyval: Clutter.KEY_8, left: 1, top: 2 }, + { label: '9', keyval: Clutter.KEY_9, left: 2, top: 2 }, + { label: '0', keyval: Clutter.KEY_0, left: 1, top: 3 }, + { keyval: Clutter.KEY_BackSpace, icon: 'edit-clear-symbolic', left: 3, top: 0 }, + { keyval: Clutter.KEY_Return, extraClassName: 'enter-key', icon: 'keyboard-enter-symbolic', left: 3, top: 1, height: 2 }, + ]; + + super._init({ + layout_manager: new Clutter.BinLayout(), + x_expand: true, + y_expand: true, + }); + + let gridLayout = new Clutter.GridLayout({ orientation: Clutter.Orientation.HORIZONTAL, + column_homogeneous: true, + row_homogeneous: true }); + this._box = new St.Widget({ layout_manager: gridLayout, x_expand: true, y_expand: true }); + this.add_child(this._box); + + for (let i = 0; i < keys.length; i++) { + let cur = keys[i]; + let key = new Key(cur.label || "", [], cur.icon); + + if (keys[i].extraClassName) + key.keyButton.add_style_class_name(cur.extraClassName); + + let w, h; + w = cur.width || 1; + h = cur.height || 1; + gridLayout.attach(key, cur.left, cur.top, w, h); + + key.connect('released', () => { + this.emit('keyval', cur.keyval); + }); + } + } +}); + +var KeyboardManager = class KeyBoardManager { + constructor() { + this._keyboard = null; + this._a11yApplicationsSettings = new Gio.Settings({ schema_id: A11Y_APPLICATIONS_SCHEMA }); + this._a11yApplicationsSettings.connect('changed', this._syncEnabled.bind(this)); + + this._seat = Clutter.get_default_backend().get_default_seat(); + this._seat.connect('notify::touch-mode', this._syncEnabled.bind(this)); + + this._lastDevice = null; + global.backend.connect('last-device-changed', (backend, device) => { + if (device.device_type === Clutter.InputDeviceType.KEYBOARD_DEVICE) + return; + + this._lastDevice = device; + this._syncEnabled(); + }); + this._syncEnabled(); + } + + _lastDeviceIsTouchscreen() { + if (!this._lastDevice) + return false; + + let deviceType = this._lastDevice.get_device_type(); + return deviceType == Clutter.InputDeviceType.TOUCHSCREEN_DEVICE; + } + + _syncEnabled() { + let enableKeyboard = this._a11yApplicationsSettings.get_boolean(SHOW_KEYBOARD); + let autoEnabled = this._seat.get_touch_mode() && this._lastDeviceIsTouchscreen(); + let enabled = enableKeyboard || autoEnabled; + + if (!enabled && !this._keyboard) + return; + + if (enabled && !this._keyboard) { + this._keyboard = new Keyboard(); + } else if (!enabled && this._keyboard) { + this._keyboard.setCursorLocation(null); + this._keyboard.destroy(); + this._keyboard = null; + Main.layoutManager.hideKeyboard(true); + } + } + + get keyboardActor() { + return this._keyboard; + } + + get visible() { + return this._keyboard && this._keyboard.visible; + } + + open(monitor) { + if (this._keyboard) + this._keyboard.open(monitor); + } + + close() { + if (this._keyboard) + this._keyboard.close(); + } + + addSuggestion(text, callback) { + if (this._keyboard) + this._keyboard.addSuggestion(text, callback); + } + + resetSuggestions() { + if (this._keyboard) + this._keyboard.resetSuggestions(); + } + + shouldTakeEvent(event) { + if (!this._keyboard) + return false; + + let actor = event.get_source(); + return Main.layoutManager.keyboardBox.contains(actor) || + !!actor._extendedKeys || !!actor.extendedKey; + } +}; + +var Keyboard = GObject.registerClass( +class Keyboard extends St.BoxLayout { + _init() { + super._init({ name: 'keyboard', vertical: true }); + this._focusInExtendedKeys = false; + this._emojiActive = false; + + this._languagePopup = null; + this._currentFocusWindow = null; + this._animFocusedWindow = null; + this._delayedAnimFocusWindow = null; + + this._latched = false; // current level is latched + + this._suggestions = null; + this._emojiKeyVisible = Meta.is_wayland_compositor(); + + this._focusTracker = new FocusTracker(); + this._connectSignal(this._focusTracker, 'position-changed', + this._onFocusPositionChanged.bind(this)); + this._connectSignal(this._focusTracker, 'reset', () => { + this._delayedAnimFocusWindow = null; + this._animFocusedWindow = null; + this._oskFocusWindow = null; + }); + // Valid only for X11 + if (!Meta.is_wayland_compositor()) { + this._connectSignal(this._focusTracker, 'focus-changed', (_tracker, focused) => { + if (focused) + this.open(Main.layoutManager.focusIndex); + else + this.close(); + }); + } + + this._showIdleId = 0; + + this._keyboardVisible = false; + this._connectSignal(Main.layoutManager, 'keyboard-visible-changed', (_lm, visible) => { + this._keyboardVisible = visible; + }); + this._keyboardRequested = false; + this._keyboardRestingId = 0; + + this._connectSignal(Main.layoutManager, 'monitors-changed', this._relayout.bind(this)); + + this._setupKeyboard(); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + _connectSignal(obj, signal, callback) { + if (!this._connectionsIDs) + this._connectionsIDs = []; + + let id = obj.connect(signal, callback); + this._connectionsIDs.push([obj, id]); + return id; + } + + get visible() { + return this._keyboardVisible && super.visible; + } + + set visible(visible) { + super.visible = visible; + } + + _onFocusPositionChanged(focusTracker) { + let rect = focusTracker.getCurrentRect(); + this.setCursorLocation(focusTracker.currentWindow, rect.x, rect.y, rect.width, rect.height); + } + + _onDestroy() { + for (let [obj, id] of this._connectionsIDs) + obj.disconnect(id); + delete this._connectionsIDs; + + this._clearShowIdle(); + + this._keyboardController.destroy(); + + Main.layoutManager.untrackChrome(this); + Main.layoutManager.keyboardBox.remove_actor(this); + + if (this._languagePopup) { + this._languagePopup.destroy(); + this._languagePopup = null; + } + } + + _setupKeyboard() { + Main.layoutManager.keyboardBox.add_actor(this); + Main.layoutManager.trackChrome(this); + + this._keyboardController = new KeyboardController(); + + this._groups = {}; + this._currentPage = null; + + this._suggestions = new Suggestions(); + this.add_child(this._suggestions); + + this._aspectContainer = new AspectContainer({ + layout_manager: new Clutter.BinLayout(), + y_expand: true, + }); + this.add_child(this._aspectContainer); + + this._emojiSelection = new EmojiSelection(); + this._emojiSelection.connect('toggle', this._toggleEmoji.bind(this)); + this._emojiSelection.connect('close-request', () => this.close()); + this._emojiSelection.connect('emoji-selected', (selection, emoji) => { + this._keyboardController.commitString(emoji); + }); + + this._aspectContainer.add_child(this._emojiSelection); + this._emojiSelection.hide(); + + this._keypad = new Keypad(); + this._connectSignal(this._keypad, 'keyval', (_keypad, keyval) => { + this._keyboardController.keyvalPress(keyval); + this._keyboardController.keyvalRelease(keyval); + }); + this._aspectContainer.add_child(this._keypad); + this._keypad.hide(); + this._keypadVisible = false; + + this._ensureKeysForGroup(this._keyboardController.getCurrentGroup()); + this._setActiveLayer(0); + + // Keyboard models are defined in LTR, we must override + // the locale setting in order to avoid flipping the + // keyboard on RTL locales. + this.text_direction = Clutter.TextDirection.LTR; + + this._connectSignal(this._keyboardController, 'active-group', + this._onGroupChanged.bind(this)); + this._connectSignal(this._keyboardController, 'groups-changed', + this._onKeyboardGroupsChanged.bind(this)); + this._connectSignal(this._keyboardController, 'panel-state', + this._onKeyboardStateChanged.bind(this)); + this._connectSignal(this._keyboardController, 'keypad-visible', + this._onKeypadVisible.bind(this)); + this._connectSignal(global.stage, 'notify::key-focus', + this._onKeyFocusChanged.bind(this)); + + if (Meta.is_wayland_compositor()) { + this._connectSignal(this._keyboardController, 'emoji-visible', + this._onEmojiKeyVisible.bind(this)); + } + + this._relayout(); + } + + _onKeyFocusChanged() { + let focus = global.stage.key_focus; + + // Showing an extended key popup and clicking a key from the extended keys + // will grab focus, but ignore that + let extendedKeysWereFocused = this._focusInExtendedKeys; + this._focusInExtendedKeys = focus && (focus._extendedKeys || focus.extendedKey); + if (this._focusInExtendedKeys || extendedKeysWereFocused) + return; + + if (!(focus instanceof Clutter.Text)) { + this.close(); + return; + } + + if (!this._showIdleId) { + this._showIdleId = GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + this.open(Main.layoutManager.focusIndex); + this._showIdleId = 0; + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._showIdleId, '[gnome-shell] this.open'); + } + } + + _createLayersForGroup(groupName) { + let keyboardModel = new KeyboardModel(groupName); + let layers = {}; + let levels = keyboardModel.getLevels(); + for (let i = 0; i < levels.length; i++) { + let currentLevel = levels[i]; + /* There are keyboard maps which consist of 3 levels (no uppercase, + * basically). We however make things consistent by skipping that + * second level. + */ + let level = i >= 1 && levels.length == 3 ? i + 1 : i; + + let layout = new KeyContainer(); + layout.shiftKeys = []; + + this._loadRows(currentLevel, level, levels.length, layout); + layers[level] = layout; + this._aspectContainer.add_child(layout); + layout.layoutButtons(this._aspectContainer); + + layout.hide(); + } + + return layers; + } + + _ensureKeysForGroup(group) { + if (!this._groups[group]) + this._groups[group] = this._createLayersForGroup(group); + } + + _addRowKeys(keys, layout) { + for (let i = 0; i < keys.length; ++i) { + let key = keys[i]; + let button = new Key(key.shift(), key); + + /* Space key gets special width, dependent on the number of surrounding keys */ + if (button.key == ' ') + button.setWidth(keys.length <= 3 ? 5 : 3); + + button.connect('pressed', (actor, keyval, str) => { + if (!Main.inputMethod.currentFocus || + !this._keyboardController.commitString(str, true)) { + if (keyval != 0) { + this._keyboardController.keyvalPress(keyval); + button._keyvalPress = true; + } + } + }); + button.connect('released', (actor, keyval, _str) => { + if (keyval != 0) { + if (button._keyvalPress) + this._keyboardController.keyvalRelease(keyval); + button._keyvalPress = false; + } + + if (!this._latched) + this._setActiveLayer(0); + }); + + layout.appendKey(button, button.keyButton.keyWidth); + } + } + + _popupLanguageMenu(keyActor) { + if (this._languagePopup) + this._languagePopup.destroy(); + + this._languagePopup = new LanguageSelectionPopup(keyActor); + Main.layoutManager.addTopChrome(this._languagePopup.actor); + this._languagePopup.open(true); + } + + _loadDefaultKeys(keys, layout, numLevels, numKeys) { + let extraButton; + for (let i = 0; i < keys.length; i++) { + let key = keys[i]; + let keyval = key.keyval; + let switchToLevel = key.level; + let action = key.action; + let icon = key.icon; + + /* Skip emoji button if necessary */ + if (!this._emojiKeyVisible && action == 'emoji') + continue; + + extraButton = new Key(key.label || '', [], icon); + + extraButton.keyButton.add_style_class_name('default-key'); + if (key.extraClassName != null) + extraButton.keyButton.add_style_class_name(key.extraClassName); + if (key.width != null) + extraButton.setWidth(key.width); + + let actor = extraButton.keyButton; + + extraButton.connect('pressed', () => { + if (switchToLevel != null) { + this._setActiveLayer(switchToLevel); + // Shift only gets latched on long press + this._latched = switchToLevel != 1; + } else if (keyval != null) { + this._keyboardController.keyvalPress(keyval); + } + }); + extraButton.connect('released', () => { + if (keyval != null) + this._keyboardController.keyvalRelease(keyval); + else if (action == 'hide') + this.close(); + else if (action == 'languageMenu') + this._popupLanguageMenu(actor); + else if (action == 'emoji') + this._toggleEmoji(); + }); + + if (switchToLevel == 0) { + layout.shiftKeys.push(extraButton); + } else if (switchToLevel == 1) { + extraButton.connect('long-press', () => { + this._latched = true; + this._setCurrentLevelLatched(this._currentPage, this._latched); + }); + } + + /* Fixup default keys based on the number of levels/keys */ + if (switchToLevel == 1 && numLevels == 3) { + // Hide shift key if the keymap has no uppercase level + if (key.right) { + /* Only hide the key actor, so the container still takes space */ + extraButton.keyButton.hide(); + } else { + extraButton.hide(); + } + extraButton.setWidth(1.5); + } else if (key.right && numKeys > 8) { + extraButton.setWidth(2); + } else if (keyval == Clutter.KEY_Return && numKeys > 9) { + extraButton.setWidth(1.5); + } else if (!this._emojiKeyVisible && (action == 'hide' || action == 'languageMenu')) { + extraButton.setWidth(1.5); + } + + layout.appendKey(extraButton, extraButton.keyButton.keyWidth); + } + } + + _updateCurrentPageVisible() { + if (this._currentPage) + this._currentPage.visible = !this._emojiActive && !this._keypadVisible; + } + + _setEmojiActive(active) { + this._emojiActive = active; + this._emojiSelection.visible = this._emojiActive; + this._updateCurrentPageVisible(); + } + + _toggleEmoji() { + this._setEmojiActive(!this._emojiActive); + } + + _setCurrentLevelLatched(layout, latched) { + for (let i = 0; i < layout.shiftKeys.length; i++) { + let key = layout.shiftKeys[i]; + key.setLatched(latched); + } + } + + _getDefaultKeysForRow(row, numRows, level) { + /* The first 2 rows in defaultKeysPre/Post belong together with + * the first 2 rows on each keymap. On keymaps that have more than + * 4 rows, the last 2 default key rows must be respectively + * assigned to the 2 last keymap ones. + */ + if (row < 2) { + return [defaultKeysPre[level][row], defaultKeysPost[level][row]]; + } else if (row >= numRows - 2) { + let defaultRow = row - (numRows - 2) + 2; + return [defaultKeysPre[level][defaultRow], defaultKeysPost[level][defaultRow]]; + } else { + return [null, null]; + } + } + + _mergeRowKeys(layout, pre, row, post, numLevels) { + if (pre != null) + this._loadDefaultKeys(pre, layout, numLevels, row.length); + + this._addRowKeys(row, layout); + + if (post != null) + this._loadDefaultKeys(post, layout, numLevels, row.length); + } + + _loadRows(model, level, numLevels, layout) { + let rows = model.rows; + for (let i = 0; i < rows.length; ++i) { + layout.appendRow(); + let [pre, post] = this._getDefaultKeysForRow(i, rows.length, level); + this._mergeRowKeys(layout, pre, rows[i], post, numLevels); + } + } + + _getGridSlots() { + let numOfHorizSlots = 0, numOfVertSlots; + let rows = this._currentPage.get_children(); + numOfVertSlots = rows.length; + + for (let i = 0; i < rows.length; ++i) { + let keyboardRow = rows[i]; + let keys = keyboardRow.get_children(); + + numOfHorizSlots = Math.max(numOfHorizSlots, keys.length); + } + + return [numOfHorizSlots, numOfVertSlots]; + } + + _relayout() { + let monitor = Main.layoutManager.keyboardMonitor; + + if (!monitor) + return; + + let maxHeight = monitor.height / 3; + this.width = monitor.width; + + if (monitor.width > monitor.height) { + this.height = maxHeight; + } else { + /* In portrait mode, lack of horizontal space means we won't be + * able to make the OSK that big while keeping size ratio, so + * we allow the OSK being smaller than 1/3rd of the monitor height + * there. + */ + const forWidth = this.get_theme_node().adjust_for_width(monitor.width); + const [, natHeight] = this.get_preferred_height(forWidth); + this.height = Math.min(maxHeight, natHeight); + } + } + + _onGroupChanged() { + this._ensureKeysForGroup(this._keyboardController.getCurrentGroup()); + this._setActiveLayer(0); + } + + _onKeyboardGroupsChanged() { + let nonGroupActors = [this._emojiSelection, this._keypad]; + this._aspectContainer.get_children().filter(c => !nonGroupActors.includes(c)).forEach(c => { + c.destroy(); + }); + + this._groups = {}; + this._onGroupChanged(); + } + + _onKeypadVisible(controller, visible) { + if (visible == this._keypadVisible) + return; + + this._keypadVisible = visible; + this._keypad.visible = this._keypadVisible; + this._updateCurrentPageVisible(); + } + + _onEmojiKeyVisible(controller, visible) { + if (visible == this._emojiKeyVisible) + return; + + this._emojiKeyVisible = visible; + /* Rebuild keyboard widgetry to include emoji button */ + this._onKeyboardGroupsChanged(); + } + + _onKeyboardStateChanged(controller, state) { + let enabled; + if (state == Clutter.InputPanelState.OFF) + enabled = false; + else if (state == Clutter.InputPanelState.ON) + enabled = true; + else if (state == Clutter.InputPanelState.TOGGLE) + enabled = this._keyboardVisible == false; + else + return; + + if (enabled) + this.open(Main.layoutManager.focusIndex); + else + this.close(); + } + + _setActiveLayer(activeLevel) { + let activeGroupName = this._keyboardController.getCurrentGroup(); + let layers = this._groups[activeGroupName]; + let currentPage = layers[activeLevel]; + + if (this._currentPage == currentPage) { + this._updateCurrentPageVisible(); + return; + } + + if (this._currentPage != null) { + this._setCurrentLevelLatched(this._currentPage, false); + this._currentPage.disconnect(this._currentPage._destroyID); + this._currentPage.hide(); + delete this._currentPage._destroyID; + } + + this._currentPage = currentPage; + this._currentPage._destroyID = this._currentPage.connect('destroy', () => { + this._currentPage = null; + }); + this._updateCurrentPageVisible(); + } + + _clearKeyboardRestTimer() { + if (!this._keyboardRestingId) + return; + GLib.source_remove(this._keyboardRestingId); + this._keyboardRestingId = 0; + } + + open(monitor) { + this._clearShowIdle(); + this._keyboardRequested = true; + + if (this._keyboardVisible) { + if (monitor != Main.layoutManager.keyboardIndex) { + Main.layoutManager.keyboardIndex = monitor; + this._relayout(); + } + return; + } + + this._clearKeyboardRestTimer(); + this._keyboardRestingId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, + KEYBOARD_REST_TIME, + () => { + this._clearKeyboardRestTimer(); + this._open(monitor); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._keyboardRestingId, '[gnome-shell] this._clearKeyboardRestTimer'); + } + + _open(monitor) { + if (!this._keyboardRequested) + return; + + Main.layoutManager.keyboardIndex = monitor; + this._relayout(); + Main.layoutManager.showKeyboard(); + + this._setEmojiActive(false); + + if (this._delayedAnimFocusWindow) { + this._setAnimationWindow(this._delayedAnimFocusWindow); + this._delayedAnimFocusWindow = null; + } + } + + close() { + this._clearShowIdle(); + this._keyboardRequested = false; + + if (!this._keyboardVisible) + return; + + this._clearKeyboardRestTimer(); + this._keyboardRestingId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, + KEYBOARD_REST_TIME, + () => { + this._clearKeyboardRestTimer(); + this._close(); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._keyboardRestingId, '[gnome-shell] this._clearKeyboardRestTimer'); + } + + _close() { + if (this._keyboardRequested) + return; + + Main.layoutManager.hideKeyboard(); + this.setCursorLocation(null); + } + + resetSuggestions() { + if (this._suggestions) + this._suggestions.clear(); + } + + addSuggestion(text, callback) { + if (!this._suggestions) + return; + this._suggestions.add(text, callback); + this._suggestions.show(); + } + + _clearShowIdle() { + if (!this._showIdleId) + return; + GLib.source_remove(this._showIdleId); + this._showIdleId = 0; + } + + _windowSlideAnimationComplete(window, delta) { + // Synchronize window positions again. + let frameRect = window.get_frame_rect(); + frameRect.y += delta; + window.move_frame(true, frameRect.x, frameRect.y); + } + + _animateWindow(window, show) { + let windowActor = window.get_compositor_private(); + let deltaY = Main.layoutManager.keyboardBox.height; + if (!windowActor) + return; + + if (show) { + windowActor.ease({ + y: windowActor.y - deltaY, + duration: Layout.KEYBOARD_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._windowSlideAnimationComplete(window, -deltaY); + }, + }); + } else { + windowActor.ease({ + y: windowActor.y + deltaY, + duration: Layout.KEYBOARD_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_IN_QUAD, + onComplete: () => { + this._windowSlideAnimationComplete(window, deltaY); + }, + }); + } + } + + _setAnimationWindow(window) { + if (this._animFocusedWindow == window) + return; + + if (this._animFocusedWindow) + this._animateWindow(this._animFocusedWindow, false); + if (window) + this._animateWindow(window, true); + + this._animFocusedWindow = window; + } + + setCursorLocation(window, x, y, w, h) { + let monitor = Main.layoutManager.keyboardMonitor; + + if (window && monitor) { + let keyboardHeight = Main.layoutManager.keyboardBox.height; + + if (y + h >= monitor.y + monitor.height - keyboardHeight) { + if (this._keyboardVisible) + this._setAnimationWindow(window); + else + this._delayedAnimFocusWindow = window; + } else if (y < keyboardHeight) { + this._delayedAnimFocusWindow = null; + this._setAnimationWindow(null); + } + } else { + this._setAnimationWindow(null); + } + + this._oskFocusWindow = window; + } +}); + +var KeyboardController = class { + constructor() { + let seat = Clutter.get_default_backend().get_default_seat(); + this._virtualDevice = seat.create_virtual_device(Clutter.InputDeviceType.KEYBOARD_DEVICE); + + this._inputSourceManager = InputSourceManager.getInputSourceManager(); + this._sourceChangedId = this._inputSourceManager.connect('current-source-changed', + this._onSourceChanged.bind(this)); + this._sourcesModifiedId = this._inputSourceManager.connect('sources-changed', + this._onSourcesModified.bind(this)); + this._currentSource = this._inputSourceManager.currentSource; + + this._notifyContentPurposeId = Main.inputMethod.connect( + 'notify::content-purpose', this._onContentPurposeHintsChanged.bind(this)); + this._notifyContentHintsId = Main.inputMethod.connect( + 'notify::content-hints', this._onContentPurposeHintsChanged.bind(this)); + this._notifyInputPanelStateId = Main.inputMethod.connect( + 'input-panel-state', (o, state) => this.emit('panel-state', state)); + } + + destroy() { + this._inputSourceManager.disconnect(this._sourceChangedId); + this._inputSourceManager.disconnect(this._sourcesModifiedId); + Main.inputMethod.disconnect(this._notifyContentPurposeId); + Main.inputMethod.disconnect(this._notifyContentHintsId); + Main.inputMethod.disconnect(this._notifyInputPanelStateId); + + // Make sure any buttons pressed by the virtual device are released + // immediately instead of waiting for the next GC cycle + this._virtualDevice.run_dispose(); + } + + _onSourcesModified() { + this.emit('groups-changed'); + } + + _onSourceChanged(inputSourceManager, _oldSource) { + let source = inputSourceManager.currentSource; + this._currentSource = source; + this.emit('active-group', source.id); + } + + _onContentPurposeHintsChanged(method) { + let purpose = method.content_purpose; + let emojiVisible = false; + let keypadVisible = false; + + if (purpose == Clutter.InputContentPurpose.NORMAL || + purpose == Clutter.InputContentPurpose.ALPHA || + purpose == Clutter.InputContentPurpose.PASSWORD || + purpose == Clutter.InputContentPurpose.TERMINAL) + emojiVisible = true; + if (purpose == Clutter.InputContentPurpose.DIGITS || + purpose == Clutter.InputContentPurpose.NUMBER || + purpose == Clutter.InputContentPurpose.PHONE) + keypadVisible = true; + + this.emit('emoji-visible', emojiVisible); + this.emit('keypad-visible', keypadVisible); + } + + getGroups() { + let inputSources = this._inputSourceManager.inputSources; + let groups = []; + + for (let i in inputSources) { + let is = inputSources[i]; + groups[is.index] = is.xkbId; + } + + return groups; + } + + getCurrentGroup() { + return this._currentSource.xkbId; + } + + commitString(string, fromKey) { + if (string == null) + return false; + /* Let ibus methods fall through keyval emission */ + if (fromKey && this._currentSource.type == InputSourceManager.INPUT_SOURCE_TYPE_IBUS) + return false; + + Main.inputMethod.commit(string); + return true; + } + + keyvalPress(keyval) { + this._virtualDevice.notify_keyval(Clutter.get_current_event_time(), + keyval, Clutter.KeyState.PRESSED); + } + + keyvalRelease(keyval) { + this._virtualDevice.notify_keyval(Clutter.get_current_event_time(), + keyval, Clutter.KeyState.RELEASED); + } +}; +Signals.addSignalMethods(KeyboardController.prototype); diff --git a/js/ui/layout.js b/js/ui/layout.js new file mode 100644 index 0000000..b082ec8 --- /dev/null +++ b/js/ui/layout.js @@ -0,0 +1,1409 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported MonitorConstraint, LayoutManager */ + +const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; +const Signals = imports.signals; + +const Background = imports.ui.background; +const BackgroundMenu = imports.ui.backgroundMenu; + +const DND = imports.ui.dnd; +const Main = imports.ui.main; +const Params = imports.misc.params; +const Ripples = imports.ui.ripples; + +var STARTUP_ANIMATION_TIME = 500; +var KEYBOARD_ANIMATION_TIME = 150; +var BACKGROUND_FADE_ANIMATION_TIME = 1000; + +var HOT_CORNER_PRESSURE_THRESHOLD = 100; // pixels +var HOT_CORNER_PRESSURE_TIMEOUT = 1000; // ms + +function isPopupMetaWindow(actor) { + switch (actor.meta_window.get_window_type()) { + case Meta.WindowType.DROPDOWN_MENU: + case Meta.WindowType.POPUP_MENU: + case Meta.WindowType.COMBO: + return true; + default: + return false; + } +} + +var MonitorConstraint = GObject.registerClass({ + Properties: { + 'primary': GObject.ParamSpec.boolean('primary', + 'Primary', 'Track primary monitor', + GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE, + false), + 'index': GObject.ParamSpec.int('index', + 'Monitor index', 'Track specific monitor', + GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE, + -1, 64, -1), + 'work-area': GObject.ParamSpec.boolean('work-area', + 'Work-area', 'Track monitor\'s work-area', + GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE, + false), + }, +}, class MonitorConstraint extends Clutter.Constraint { + _init(props) { + this._primary = false; + this._index = -1; + this._workArea = false; + + super._init(props); + } + + get primary() { + return this._primary; + } + + set primary(v) { + if (v) + this._index = -1; + this._primary = v; + if (this.actor) + this.actor.queue_relayout(); + this.notify('primary'); + } + + get index() { + return this._index; + } + + set index(v) { + this._primary = false; + this._index = v; + if (this.actor) + this.actor.queue_relayout(); + this.notify('index'); + } + + // eslint-disable-next-line camelcase + get work_area() { + return this._workArea; + } + + // eslint-disable-next-line camelcase + set work_area(v) { + if (v == this._workArea) + return; + this._workArea = v; + if (this.actor) + this.actor.queue_relayout(); + this.notify('work-area'); + } + + vfunc_set_actor(actor) { + if (actor) { + if (!this._monitorsChangedId) { + this._monitorsChangedId = + Main.layoutManager.connect('monitors-changed', () => { + this.actor.queue_relayout(); + }); + } + + if (!this._workareasChangedId) { + this._workareasChangedId = + global.display.connect('workareas-changed', () => { + if (this._workArea) + this.actor.queue_relayout(); + }); + } + } else { + if (this._monitorsChangedId) + Main.layoutManager.disconnect(this._monitorsChangedId); + this._monitorsChangedId = 0; + + if (this._workareasChangedId) + global.display.disconnect(this._workareasChangedId); + this._workareasChangedId = 0; + } + + super.vfunc_set_actor(actor); + } + + vfunc_update_allocation(actor, actorBox) { + if (!this._primary && this._index < 0) + return; + + if (!Main.layoutManager.primaryMonitor) + return; + + let index; + if (this._primary) + index = Main.layoutManager.primaryIndex; + else + index = Math.min(this._index, Main.layoutManager.monitors.length - 1); + + let rect; + if (this._workArea) { + let workspaceManager = global.workspace_manager; + let ws = workspaceManager.get_workspace_by_index(0); + rect = ws.get_work_area_for_monitor(index); + } else { + rect = Main.layoutManager.monitors[index]; + } + + actorBox.init_rect(rect.x, rect.y, rect.width, rect.height); + } +}); + +var Monitor = class Monitor { + constructor(index, geometry, geometryScale) { + this.index = index; + this.x = geometry.x; + this.y = geometry.y; + this.width = geometry.width; + this.height = geometry.height; + this.geometry_scale = geometryScale; + } + + get inFullscreen() { + return global.display.get_monitor_in_fullscreen(this.index); + } +}; + +const UiActor = GObject.registerClass( +class UiActor extends St.Widget { + vfunc_get_preferred_width(_forHeight) { + let width = global.stage.width; + return [width, width]; + } + + vfunc_get_preferred_height(_forWidth) { + let height = global.stage.height; + return [height, height]; + } +}); + +const defaultParams = { + trackFullscreen: false, + affectsStruts: false, + affectsInputRegion: true, +}; + +var LayoutManager = GObject.registerClass({ + Signals: { 'hot-corners-changed': {}, + 'startup-complete': {}, + 'startup-prepared': {}, + 'monitors-changed': {}, + 'system-modal-opened': {}, + 'keyboard-visible-changed': { param_types: [GObject.TYPE_BOOLEAN] } }, +}, class LayoutManager extends GObject.Object { + _init() { + super._init(); + + this._rtl = Clutter.get_default_text_direction() == Clutter.TextDirection.RTL; + this.monitors = []; + this.primaryMonitor = null; + this.primaryIndex = -1; + this.hotCorners = []; + + this._keyboardIndex = -1; + this._rightPanelBarrier = null; + + this._inOverview = false; + this._updateRegionIdle = 0; + + this._trackedActors = []; + this._topActors = []; + this._isPopupWindowVisible = false; + this._startingUp = true; + this._pendingLoadBackground = false; + + // Set up stage hierarchy to group all UI actors under one container. + this.uiGroup = new UiActor({ name: 'uiGroup' }); + this.uiGroup.set_flags(Clutter.ActorFlags.NO_LAYOUT); + + global.stage.add_child(this.uiGroup); + + global.stage.remove_actor(global.window_group); + this.uiGroup.add_actor(global.window_group); + + // Using addChrome() to add actors to uiGroup will position actors + // underneath the top_window_group. + // To insert actors at the top of uiGroup, we use addTopChrome() or + // add the actor directly using uiGroup.add_actor(). + global.stage.remove_actor(global.top_window_group); + this.uiGroup.add_actor(global.top_window_group); + + this.overviewGroup = new St.Widget({ name: 'overviewGroup', + visible: false, + reactive: true }); + this.addChrome(this.overviewGroup); + + this.screenShieldGroup = new St.Widget({ + name: 'screenShieldGroup', + visible: false, + clip_to_allocation: true, + layout_manager: new Clutter.BinLayout(), + }); + this.addChrome(this.screenShieldGroup); + + this.panelBox = new St.BoxLayout({ name: 'panelBox', + vertical: true }); + this.addChrome(this.panelBox, { affectsStruts: true, + trackFullscreen: true }); + this.panelBox.connect('notify::allocation', + this._panelBoxChanged.bind(this)); + + this.modalDialogGroup = new St.Widget({ name: 'modalDialogGroup', + layout_manager: new Clutter.BinLayout() }); + this.uiGroup.add_actor(this.modalDialogGroup); + + this.keyboardBox = new St.BoxLayout({ name: 'keyboardBox', + reactive: true, + track_hover: true }); + this.addTopChrome(this.keyboardBox); + this._keyboardHeightNotifyId = 0; + + // A dummy actor that tracks the mouse or text cursor, based on the + // position and size set in setDummyCursorGeometry. + this.dummyCursor = new St.Widget({ width: 0, height: 0, opacity: 0 }); + this.uiGroup.add_actor(this.dummyCursor); + + let feedbackGroup = Meta.get_feedback_group_for_display(global.display); + global.stage.remove_actor(feedbackGroup); + this.uiGroup.add_actor(feedbackGroup); + + this._backgroundGroup = new Meta.BackgroundGroup(); + global.window_group.add_child(this._backgroundGroup); + global.window_group.set_child_below_sibling(this._backgroundGroup, null); + this._bgManagers = []; + + this._interfaceSettings = new Gio.Settings({ + schema_id: 'org.gnome.desktop.interface', + }); + + this._interfaceSettings.connect('changed::enable-hot-corners', + this._updateHotCorners.bind(this)); + + // Need to update struts on new workspaces when they are added + let workspaceManager = global.workspace_manager; + workspaceManager.connect('notify::n-workspaces', + this._queueUpdateRegions.bind(this)); + + let display = global.display; + display.connect('restacked', + this._windowsRestacked.bind(this)); + display.connect('in-fullscreen-changed', + this._updateFullscreen.bind(this)); + + let monitorManager = Meta.MonitorManager.get(); + monitorManager.connect('monitors-changed', + this._monitorsChanged.bind(this)); + this._monitorsChanged(); + } + + // This is called by Main after everything else is constructed + init() { + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + + this._loadBackground(); + } + + showOverview() { + this.overviewGroup.show(); + + this._inOverview = true; + this._updateVisibility(); + } + + hideOverview() { + this.overviewGroup.hide(); + + this._inOverview = false; + this._updateVisibility(); + } + + _sessionUpdated() { + this._updateVisibility(); + this._queueUpdateRegions(); + } + + _updateMonitors() { + let display = global.display; + + this.monitors = []; + let nMonitors = display.get_n_monitors(); + for (let i = 0; i < nMonitors; i++) { + this.monitors.push(new Monitor(i, + display.get_monitor_geometry(i), + display.get_monitor_scale(i))); + } + + if (nMonitors == 0) { + this.primaryIndex = this.bottomIndex = -1; + } else if (nMonitors == 1) { + this.primaryIndex = this.bottomIndex = 0; + } else { + // If there are monitors below the primary, then we need + // to split primary from bottom. + this.primaryIndex = this.bottomIndex = display.get_primary_monitor(); + for (let i = 0; i < this.monitors.length; i++) { + let monitor = this.monitors[i]; + if (this._isAboveOrBelowPrimary(monitor)) { + if (monitor.y > this.monitors[this.bottomIndex].y) + this.bottomIndex = i; + } + } + } + if (this.primaryIndex != -1) { + this.primaryMonitor = this.monitors[this.primaryIndex]; + this.bottomMonitor = this.monitors[this.bottomIndex]; + + if (this._pendingLoadBackground) { + this._loadBackground(); + this._pendingLoadBackground = false; + } + } else { + this.primaryMonitor = null; + this.bottomMonitor = null; + } + } + + _updateHotCorners() { + // destroy old hot corners + this.hotCorners.forEach(corner => { + if (corner) + corner.destroy(); + }); + this.hotCorners = []; + + if (!this._interfaceSettings.get_boolean('enable-hot-corners')) { + this.emit('hot-corners-changed'); + return; + } + + let size = this.panelBox.height; + + // build new hot corners + for (let i = 0; i < this.monitors.length; i++) { + let monitor = this.monitors[i]; + let cornerX = this._rtl ? monitor.x + monitor.width : monitor.x; + let cornerY = monitor.y; + + let haveTopLeftCorner = true; + + if (i != this.primaryIndex) { + // Check if we have a top left (right for RTL) corner. + // I.e. if there is no monitor directly above or to the left(right) + let besideX = this._rtl ? monitor.x + 1 : cornerX - 1; + let besideY = cornerY; + let aboveX = cornerX; + let aboveY = cornerY - 1; + + for (let j = 0; j < this.monitors.length; j++) { + if (i == j) + continue; + let otherMonitor = this.monitors[j]; + if (besideX >= otherMonitor.x && + besideX < otherMonitor.x + otherMonitor.width && + besideY >= otherMonitor.y && + besideY < otherMonitor.y + otherMonitor.height) { + haveTopLeftCorner = false; + break; + } + if (aboveX >= otherMonitor.x && + aboveX < otherMonitor.x + otherMonitor.width && + aboveY >= otherMonitor.y && + aboveY < otherMonitor.y + otherMonitor.height) { + haveTopLeftCorner = false; + break; + } + } + } + + if (haveTopLeftCorner) { + let corner = new HotCorner(this, monitor, cornerX, cornerY); + corner.setBarrierSize(size); + this.hotCorners.push(corner); + } else { + this.hotCorners.push(null); + } + } + + this.emit('hot-corners-changed'); + } + + _addBackgroundMenu(bgManager) { + BackgroundMenu.addBackgroundMenu(bgManager.backgroundActor, this); + } + + _createBackgroundManager(monitorIndex) { + let bgManager = new Background.BackgroundManager({ container: this._backgroundGroup, + layoutManager: this, + monitorIndex }); + + bgManager.connect('changed', this._addBackgroundMenu.bind(this)); + this._addBackgroundMenu(bgManager); + + return bgManager; + } + + _showSecondaryBackgrounds() { + for (let i = 0; i < this.monitors.length; i++) { + if (i != this.primaryIndex) { + let backgroundActor = this._bgManagers[i].backgroundActor; + backgroundActor.show(); + backgroundActor.opacity = 0; + backgroundActor.ease({ + opacity: 255, + duration: BACKGROUND_FADE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + } + } + + _waitLoaded(bgManager) { + return new Promise(resolve => { + const id = bgManager.connect('loaded', () => { + bgManager.disconnect(id); + resolve(); + }); + }); + } + + _updateBackgrounds() { + for (let i = 0; i < this._bgManagers.length; i++) + this._bgManagers[i].destroy(); + + this._bgManagers = []; + + if (Main.sessionMode.isGreeter) + return Promise.resolve(); + + for (let i = 0; i < this.monitors.length; i++) { + let bgManager = this._createBackgroundManager(i); + this._bgManagers.push(bgManager); + + if (i != this.primaryIndex && this._startingUp) + bgManager.backgroundActor.hide(); + } + + return Promise.all(this._bgManagers.map(this._waitLoaded)); + } + + _updateKeyboardBox() { + this.keyboardBox.set_position(this.keyboardMonitor.x, + this.keyboardMonitor.y + this.keyboardMonitor.height); + this.keyboardBox.set_size(this.keyboardMonitor.width, -1); + } + + _updateBoxes() { + this.screenShieldGroup.set_position(0, 0); + this.screenShieldGroup.set_size(global.screen_width, global.screen_height); + + if (!this.primaryMonitor) + return; + + this.panelBox.set_position(this.primaryMonitor.x, this.primaryMonitor.y); + this.panelBox.set_size(this.primaryMonitor.width, -1); + + this.keyboardIndex = this.primaryIndex; + } + + _panelBoxChanged() { + this._updatePanelBarrier(); + + let size = this.panelBox.height; + this.hotCorners.forEach(corner => { + if (corner) + corner.setBarrierSize(size); + }); + } + + _updatePanelBarrier() { + if (this._rightPanelBarrier) { + this._rightPanelBarrier.destroy(); + this._rightPanelBarrier = null; + } + + if (!this.primaryMonitor) + return; + + if (this.panelBox.height) { + let primary = this.primaryMonitor; + + this._rightPanelBarrier = new Meta.Barrier({ display: global.display, + x1: primary.x + primary.width, y1: primary.y, + x2: primary.x + primary.width, y2: primary.y + this.panelBox.height, + directions: Meta.BarrierDirection.NEGATIVE_X }); + } + } + + _monitorsChanged() { + this._updateMonitors(); + this._updateBoxes(); + this._updateHotCorners(); + this._updateBackgrounds(); + this._updateFullscreen(); + this._updateVisibility(); + this._queueUpdateRegions(); + + this.emit('monitors-changed'); + } + + _isAboveOrBelowPrimary(monitor) { + let primary = this.monitors[this.primaryIndex]; + let monitorLeft = monitor.x, monitorRight = monitor.x + monitor.width; + let primaryLeft = primary.x, primaryRight = primary.x + primary.width; + + if ((monitorLeft >= primaryLeft && monitorLeft < primaryRight) || + (monitorRight > primaryLeft && monitorRight <= primaryRight) || + (primaryLeft >= monitorLeft && primaryLeft < monitorRight) || + (primaryRight > monitorLeft && primaryRight <= monitorRight)) + return true; + + return false; + } + + get currentMonitor() { + let index = global.display.get_current_monitor(); + return this.monitors[index]; + } + + get keyboardMonitor() { + return this.monitors[this.keyboardIndex]; + } + + get focusIndex() { + let i = Main.layoutManager.primaryIndex; + + if (global.stage.key_focus != null) + i = this.findIndexForActor(global.stage.key_focus); + else if (global.display.focus_window != null) + i = global.display.focus_window.get_monitor(); + return i; + } + + get focusMonitor() { + if (this.focusIndex < 0) + return null; + return this.monitors[this.focusIndex]; + } + + set keyboardIndex(v) { + this._keyboardIndex = v; + this._updateKeyboardBox(); + } + + get keyboardIndex() { + return this._keyboardIndex; + } + + _loadBackground() { + if (!this.primaryMonitor) { + this._pendingLoadBackground = true; + return; + } + this._systemBackground = new Background.SystemBackground(); + this._systemBackground.hide(); + + global.stage.insert_child_below(this._systemBackground, null); + + let constraint = new Clutter.BindConstraint({ source: global.stage, + coordinate: Clutter.BindCoordinate.ALL }); + this._systemBackground.add_constraint(constraint); + + let signalId = this._systemBackground.connect('loaded', () => { + this._systemBackground.disconnect(signalId); + + // We're mostly prepared for the startup animation + // now, but since a lot is going on asynchronously + // during startup, let's defer the startup animation + // until the event loop is uncontended and idle. + // This helps to prevent us from running the animation + // when the system is bogged down + const id = GLib.idle_add(GLib.PRIORITY_LOW, () => { + this._systemBackground.show(); + global.stage.show(); + this._prepareStartupAnimation(); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] Startup Animation'); + }); + } + + // Startup Animations + // + // We have two different animations, depending on whether we're a greeter + // or a normal session. + // + // In the greeter, we want to animate the panel from the top, and smoothly + // fade the login dialog on top of whatever plymouth left on screen which + // we get as a still frame background before drawing anything else. + // + // Here we just have the code to animate the panel, and fade up the background. + // The login dialog animation is handled by modalDialog.js + // + // When starting a normal user session, we want to grow it out of the middle + // of the screen. + + async _prepareStartupAnimation() { + // During the initial transition, add a simple actor to block all events, + // so they don't get delivered to X11 windows that have been transformed. + this._coverPane = new Clutter.Actor({ opacity: 0, + width: global.screen_width, + height: global.screen_height, + reactive: true }); + this.addChrome(this._coverPane); + + if (Meta.is_restart()) { + // On restart, we don't do an animation. Force an update of the + // regions immediately so that maximized windows restore to the + // right size taking struts into account. + this._updateRegions(); + } else if (Main.sessionMode.isGreeter) { + this.panelBox.translation_y = -this.panelBox.height; + } else { + // We need to force an update of the regions now before we scale + // the UI group to get the correct allocation for the struts. + this._updateRegions(); + + this.keyboardBox.hide(); + + let monitor = this.primaryMonitor; + let x = monitor.x + monitor.width / 2.0; + let y = monitor.y + monitor.height / 2.0; + + this.uiGroup.set_pivot_point(x / global.screen_width, + y / global.screen_height); + this.uiGroup.scale_x = this.uiGroup.scale_y = 0.75; + this.uiGroup.opacity = 0; + global.window_group.set_clip(monitor.x, monitor.y, monitor.width, monitor.height); + + await this._updateBackgrounds(); + } + + this.emit('startup-prepared'); + + this._startupAnimation(); + } + + _startupAnimation() { + if (Meta.is_restart()) + this._startupAnimationComplete(); + else if (Main.sessionMode.isGreeter) + this._startupAnimationGreeter(); + else + this._startupAnimationSession(); + } + + _startupAnimationGreeter() { + this.panelBox.ease({ + translation_y: 0, + duration: STARTUP_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._startupAnimationComplete(), + }); + } + + _startupAnimationSession() { + this.uiGroup.ease({ + scale_x: 1, + scale_y: 1, + opacity: 255, + duration: STARTUP_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._startupAnimationComplete(), + }); + } + + _startupAnimationComplete() { + this._coverPane.destroy(); + this._coverPane = null; + + this._systemBackground.destroy(); + this._systemBackground = null; + + this._startingUp = false; + + this.keyboardBox.show(); + + if (!Main.sessionMode.isGreeter) { + this._showSecondaryBackgrounds(); + global.window_group.remove_clip(); + } + + this._queueUpdateRegions(); + + this.emit('startup-complete'); + } + + showKeyboard() { + this.keyboardBox.show(); + this.keyboardBox.ease({ + translation_y: -this.keyboardBox.height, + opacity: 255, + duration: KEYBOARD_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._showKeyboardComplete(); + }, + }); + this.emit('keyboard-visible-changed', true); + } + + _showKeyboardComplete() { + // Poke Chrome to update the input shape; it doesn't notice + // anchor point changes + this._updateRegions(); + + this._keyboardHeightNotifyId = this.keyboardBox.connect('notify::height', () => { + this.keyboardBox.translation_y = -this.keyboardBox.height; + }); + } + + hideKeyboard(immediate) { + if (this._keyboardHeightNotifyId) { + this.keyboardBox.disconnect(this._keyboardHeightNotifyId); + this._keyboardHeightNotifyId = 0; + } + this.keyboardBox.ease({ + translation_y: 0, + opacity: 0, + duration: immediate ? 0 : KEYBOARD_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_IN_QUAD, + onComplete: () => { + this._hideKeyboardComplete(); + }, + }); + + this.emit('keyboard-visible-changed', false); + } + + _hideKeyboardComplete() { + this.keyboardBox.hide(); + this._updateRegions(); + } + + // setDummyCursorGeometry: + // + // The cursor dummy is a standard widget commonly used for popup + // menus and box pointers to track, as the box pointer API only + // tracks actors. If you want to pop up a menu based on where the + // user clicked, or where the text cursor is, the cursor dummy + // is what you should use. Given that the menu should not track + // the actual mouse pointer as it moves, you need to call this + // function before you show the menu to ensure it is at the right + // position and has the right size. + setDummyCursorGeometry(x, y, w, h) { + this.dummyCursor.set_position(Math.round(x), Math.round(y)); + this.dummyCursor.set_size(Math.round(w), Math.round(h)); + } + + // addChrome: + // @actor: an actor to add to the chrome + // @params: (optional) additional params + // + // Adds @actor to the chrome, and (unless %affectsInputRegion in + // @params is %false) extends the input region to include it. + // Changes in @actor's size, position, and visibility will + // automatically result in appropriate changes to the input + // region. + // + // If %affectsStruts in @params is %true (and @actor is along a + // screen edge), then @actor's size and position will also affect + // the window manager struts. Changes to @actor's visibility will + // NOT affect whether or not the strut is present, however. + // + // If %trackFullscreen in @params is %true, the actor's visibility + // will be bound to the presence of fullscreen windows on the same + // monitor (it will be hidden whenever a fullscreen window is visible, + // and shown otherwise) + addChrome(actor, params) { + this.uiGroup.add_actor(actor); + if (this.uiGroup.contains(global.top_window_group)) + this.uiGroup.set_child_below_sibling(actor, global.top_window_group); + this._trackActor(actor, params); + } + + // addTopChrome: + // @actor: an actor to add to the chrome + // @params: (optional) additional params + // + // Like addChrome(), but adds @actor above all windows, including popups. + addTopChrome(actor, params) { + this.uiGroup.add_actor(actor); + this._trackActor(actor, params); + } + + // trackChrome: + // @actor: a descendant of the chrome to begin tracking + // @params: parameters describing how to track @actor + // + // Tells the chrome to track @actor. This can be used to extend the + // struts or input region to cover specific children. + // + // @params can have any of the same values as in addChrome(), + // though some possibilities don't make sense. By default, @actor has + // the same params as its chrome ancestor. + trackChrome(actor, params = {}) { + let ancestor = actor.get_parent(); + let index = this._findActor(ancestor); + while (ancestor && index == -1) { + ancestor = ancestor.get_parent(); + index = this._findActor(ancestor); + } + + let ancestorData = ancestor + ? this._trackedActors[index] + : defaultParams; + // We can't use Params.parse here because we want to drop + // the extra values like ancestorData.actor + for (let prop in defaultParams) { + if (!Object.prototype.hasOwnProperty.call(params, prop)) + params[prop] = ancestorData[prop]; + } + + this._trackActor(actor, params); + } + + // untrackChrome: + // @actor: an actor previously tracked via trackChrome() + // + // Undoes the effect of trackChrome() + untrackChrome(actor) { + this._untrackActor(actor); + } + + // removeChrome: + // @actor: a chrome actor + // + // Removes @actor from the chrome + removeChrome(actor) { + this.uiGroup.remove_actor(actor); + this._untrackActor(actor); + } + + _findActor(actor) { + for (let i = 0; i < this._trackedActors.length; i++) { + let actorData = this._trackedActors[i]; + if (actorData.actor == actor) + return i; + } + return -1; + } + + _trackActor(actor, params) { + if (this._findActor(actor) != -1) + throw new Error('trying to re-track existing chrome actor'); + + let actorData = Params.parse(params, defaultParams); + actorData.actor = actor; + actorData.visibleId = actor.connect('notify::visible', + this._queueUpdateRegions.bind(this)); + actorData.allocationId = actor.connect('notify::allocation', + this._queueUpdateRegions.bind(this)); + actorData.destroyId = actor.connect('destroy', + this._untrackActor.bind(this)); + // Note that destroying actor will unset its parent, so we don't + // need to connect to 'destroy' too. + + this._trackedActors.push(actorData); + this._updateActorVisibility(actorData); + this._queueUpdateRegions(); + } + + _untrackActor(actor) { + let i = this._findActor(actor); + + if (i == -1) + return; + let actorData = this._trackedActors[i]; + + this._trackedActors.splice(i, 1); + actor.disconnect(actorData.visibleId); + actor.disconnect(actorData.allocationId); + actor.disconnect(actorData.destroyId); + + this._queueUpdateRegions(); + } + + _updateActorVisibility(actorData) { + if (!actorData.trackFullscreen) + return; + + let monitor = this.findMonitorForActor(actorData.actor); + actorData.actor.visible = !(global.window_group.visible && + monitor && + monitor.inFullscreen); + } + + _updateVisibility() { + let windowsVisible = Main.sessionMode.hasWindows && !this._inOverview; + + global.window_group.visible = windowsVisible; + global.top_window_group.visible = windowsVisible; + + this._trackedActors.forEach(this._updateActorVisibility.bind(this)); + } + + getWorkAreaForMonitor(monitorIndex) { + // Assume that all workspaces will have the same + // struts and pick the first one. + let workspaceManager = global.workspace_manager; + let ws = workspaceManager.get_workspace_by_index(0); + return ws.get_work_area_for_monitor(monitorIndex); + } + + // This call guarantees that we return some monitor to simplify usage of it + // In practice all tracked actors should be visible on some monitor anyway + findIndexForActor(actor) { + let [x, y] = actor.get_transformed_position(); + let [w, h] = actor.get_transformed_size(); + let rect = new Meta.Rectangle({ x, y, width: w, height: h }); + return global.display.get_monitor_index_for_rect(rect); + } + + findMonitorForActor(actor) { + let index = this.findIndexForActor(actor); + if (index >= 0 && index < this.monitors.length) + return this.monitors[index]; + return null; + } + + _queueUpdateRegions() { + if (this._startingUp) + return; + + if (!this._updateRegionIdle) { + this._updateRegionIdle = Meta.later_add(Meta.LaterType.BEFORE_REDRAW, + this._updateRegions.bind(this)); + } + } + + _getWindowActorsForWorkspace(workspace) { + return global.get_window_actors().filter(actor => { + let win = actor.meta_window; + return win.located_on_workspace(workspace); + }); + } + + _updateFullscreen() { + this._updateVisibility(); + this._queueUpdateRegions(); + } + + _windowsRestacked() { + let changed = false; + + if (this._isPopupWindowVisible != global.top_window_group.get_children().some(isPopupMetaWindow)) + changed = true; + + if (changed) { + this._updateVisibility(); + this._queueUpdateRegions(); + } + } + + _updateRegions() { + if (this._updateRegionIdle) { + Meta.later_remove(this._updateRegionIdle); + delete this._updateRegionIdle; + } + + // No need to update when we have a modal. + if (Main.modalCount > 0) + return GLib.SOURCE_REMOVE; + + let rects = [], struts = [], i; + let isPopupMenuVisible = global.top_window_group.get_children().some(isPopupMetaWindow); + let wantsInputRegion = !isPopupMenuVisible; + + for (i = 0; i < this._trackedActors.length; i++) { + let actorData = this._trackedActors[i]; + if (!(actorData.affectsInputRegion && wantsInputRegion) && !actorData.affectsStruts) + continue; + + let [x, y] = actorData.actor.get_transformed_position(); + let [w, h] = actorData.actor.get_transformed_size(); + x = Math.round(x); + y = Math.round(y); + w = Math.round(w); + h = Math.round(h); + + if (actorData.affectsInputRegion && wantsInputRegion && actorData.actor.get_paint_visibility()) + rects.push(new Meta.Rectangle({ x, y, width: w, height: h })); + + let monitor = null; + if (actorData.affectsStruts) + monitor = this.findMonitorForActor(actorData.actor); + + if (monitor) { + // Limit struts to the size of the screen + let x1 = Math.max(x, 0); + let x2 = Math.min(x + w, global.screen_width); + let y1 = Math.max(y, 0); + let y2 = Math.min(y + h, global.screen_height); + + // Metacity wants to know what side of the monitor the + // strut is considered to be attached to. First, we find + // the monitor that contains the strut. If the actor is + // only touching one edge, or is touching the entire + // border of that monitor, then it's obvious which side + // to call it. If it's in a corner, we pick a side + // arbitrarily. If it doesn't touch any edges, or it + // spans the width/height across the middle of the + // screen, then we don't create a strut for it at all. + + let side; + if (x1 <= monitor.x && x2 >= monitor.x + monitor.width) { + if (y1 <= monitor.y) + side = Meta.Side.TOP; + else if (y2 >= monitor.y + monitor.height) + side = Meta.Side.BOTTOM; + else + continue; + } else if (y1 <= monitor.y && y2 >= monitor.y + monitor.height) { + if (x1 <= monitor.x) + side = Meta.Side.LEFT; + else if (x2 >= monitor.x + monitor.width) + side = Meta.Side.RIGHT; + else + continue; + } else if (x1 <= monitor.x) { + side = Meta.Side.LEFT; + } else if (y1 <= monitor.y) { + side = Meta.Side.TOP; + } else if (x2 >= monitor.x + monitor.width) { + side = Meta.Side.RIGHT; + } else if (y2 >= monitor.y + monitor.height) { + side = Meta.Side.BOTTOM; + } else { + continue; + } + + let strutRect = new Meta.Rectangle({ x: x1, y: y1, width: x2 - x1, height: y2 - y1 }); + let strut = new Meta.Strut({ rect: strutRect, side }); + struts.push(strut); + } + } + + global.set_stage_input_region(rects); + this._isPopupWindowVisible = isPopupMenuVisible; + + let workspaceManager = global.workspace_manager; + for (let w = 0; w < workspaceManager.n_workspaces; w++) { + let workspace = workspaceManager.get_workspace_by_index(w); + workspace.set_builtin_struts(struts); + } + + return GLib.SOURCE_REMOVE; + } + + modalEnded() { + // We don't update the stage input region while in a modal, + // so queue an update now. + this._queueUpdateRegions(); + } +}); + + +// HotCorner: +// +// This class manages a "hot corner" that can toggle switching to +// overview. +var HotCorner = GObject.registerClass( +class HotCorner extends Clutter.Actor { + _init(layoutManager, monitor, x, y) { + super._init(); + + // We use this flag to mark the case where the user has entered the + // hot corner and has not left both the hot corner and a surrounding + // guard area (the "environs"). This avoids triggering the hot corner + // multiple times due to an accidental jitter. + this._entered = false; + + this._monitor = monitor; + + this._x = x; + this._y = y; + + this._setupFallbackCornerIfNeeded(layoutManager); + + this._pressureBarrier = new PressureBarrier(HOT_CORNER_PRESSURE_THRESHOLD, + HOT_CORNER_PRESSURE_TIMEOUT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW); + this._pressureBarrier.connect('trigger', this._toggleOverview.bind(this)); + + let px = 0.0; + let py = 0.0; + if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) { + px = 1.0; + py = 0.0; + } + + this._ripples = new Ripples.Ripples(px, py, 'ripple-box'); + this._ripples.addTo(layoutManager.uiGroup); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + setBarrierSize(size) { + if (this._verticalBarrier) { + this._pressureBarrier.removeBarrier(this._verticalBarrier); + this._verticalBarrier.destroy(); + this._verticalBarrier = null; + } + + if (this._horizontalBarrier) { + this._pressureBarrier.removeBarrier(this._horizontalBarrier); + this._horizontalBarrier.destroy(); + this._horizontalBarrier = null; + } + + if (size > 0) { + if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) { + this._verticalBarrier = new Meta.Barrier({ display: global.display, + x1: this._x, x2: this._x, y1: this._y, y2: this._y + size, + directions: Meta.BarrierDirection.NEGATIVE_X }); + this._horizontalBarrier = new Meta.Barrier({ display: global.display, + x1: this._x - size, x2: this._x, y1: this._y, y2: this._y, + directions: Meta.BarrierDirection.POSITIVE_Y }); + } else { + this._verticalBarrier = new Meta.Barrier({ display: global.display, + x1: this._x, x2: this._x, y1: this._y, y2: this._y + size, + directions: Meta.BarrierDirection.POSITIVE_X }); + this._horizontalBarrier = new Meta.Barrier({ display: global.display, + x1: this._x, x2: this._x + size, y1: this._y, y2: this._y, + directions: Meta.BarrierDirection.POSITIVE_Y }); + } + + this._pressureBarrier.addBarrier(this._verticalBarrier); + this._pressureBarrier.addBarrier(this._horizontalBarrier); + } + } + + _setupFallbackCornerIfNeeded(layoutManager) { + if (!global.display.supports_extended_barriers()) { + this.set({ + name: 'hot-corner-environs', + x: this._x, + y: this._y, + width: 3, + height: 3, + reactive: true, + }); + + this._corner = new Clutter.Actor({ name: 'hot-corner', + width: 1, + height: 1, + opacity: 0, + reactive: true }); + this._corner._delegate = this; + + this.add_child(this._corner); + layoutManager.addChrome(this); + + if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) { + this._corner.set_position(this.width - this._corner.width, 0); + this.set_pivot_point(1.0, 0.0); + this.translation_x = -this.width; + } else { + this._corner.set_position(0, 0); + } + + this._corner.connect('enter-event', + this._onCornerEntered.bind(this)); + this._corner.connect('leave-event', + this._onCornerLeft.bind(this)); + } + } + + _onDestroy() { + this.setBarrierSize(0); + this._pressureBarrier.destroy(); + this._pressureBarrier = null; + + this._ripples.destroy(); + } + + _toggleOverview() { + if (this._monitor.inFullscreen && !Main.overview.visible) + return; + + if (Main.overview.shouldToggleByCornerOrButton()) { + Main.overview.toggle(); + if (Main.overview.animationInProgress) + this._ripples.playAnimation(this._x, this._y); + } + } + + handleDragOver(source, _actor, _x, _y, _time) { + if (source != Main.xdndHandler) + return DND.DragMotionResult.CONTINUE; + + this._toggleOverview(); + + return DND.DragMotionResult.CONTINUE; + } + + _onCornerEntered() { + if (!this._entered) { + this._entered = true; + this._toggleOverview(); + } + return Clutter.EVENT_PROPAGATE; + } + + _onCornerLeft(actor, event) { + if (event.get_related() != this) + this._entered = false; + // Consume event, otherwise this will confuse onEnvironsLeft + return Clutter.EVENT_STOP; + } + + vfunc_leave_event(crossingEvent) { + if (crossingEvent.related != this._corner) + this._entered = false; + return Clutter.EVENT_PROPAGATE; + } +}); + +var PressureBarrier = class PressureBarrier { + constructor(threshold, timeout, actionMode) { + this._threshold = threshold; + this._timeout = timeout; + this._actionMode = actionMode; + this._barriers = []; + this._eventFilter = null; + + this._isTriggered = false; + this._reset(); + } + + addBarrier(barrier) { + barrier._pressureHitId = barrier.connect('hit', this._onBarrierHit.bind(this)); + barrier._pressureLeftId = barrier.connect('left', this._onBarrierLeft.bind(this)); + + this._barriers.push(barrier); + } + + _disconnectBarrier(barrier) { + barrier.disconnect(barrier._pressureHitId); + barrier.disconnect(barrier._pressureLeftId); + } + + removeBarrier(barrier) { + this._disconnectBarrier(barrier); + this._barriers.splice(this._barriers.indexOf(barrier), 1); + } + + destroy() { + this._barriers.forEach(this._disconnectBarrier.bind(this)); + this._barriers = []; + } + + setEventFilter(filter) { + this._eventFilter = filter; + } + + _reset() { + this._barrierEvents = []; + this._currentPressure = 0; + this._lastTime = 0; + } + + _isHorizontal(barrier) { + return barrier.y1 == barrier.y2; + } + + _getDistanceAcrossBarrier(barrier, event) { + if (this._isHorizontal(barrier)) + return Math.abs(event.dy); + else + return Math.abs(event.dx); + } + + _getDistanceAlongBarrier(barrier, event) { + if (this._isHorizontal(barrier)) + return Math.abs(event.dx); + else + return Math.abs(event.dy); + } + + _trimBarrierEvents() { + // Events are guaranteed to be sorted in time order from + // oldest to newest, so just look for the first old event, + // and then chop events after that off. + let i = 0; + let threshold = this._lastTime - this._timeout; + + while (i < this._barrierEvents.length) { + let [time, distance_] = this._barrierEvents[i]; + if (time >= threshold) + break; + i++; + } + + let firstNewEvent = i; + + for (i = 0; i < firstNewEvent; i++) { + let [time_, distance] = this._barrierEvents[i]; + this._currentPressure -= distance; + } + + this._barrierEvents = this._barrierEvents.slice(firstNewEvent); + } + + _onBarrierLeft(barrier, _event) { + barrier._isHit = false; + if (this._barriers.every(b => !b._isHit)) { + this._reset(); + this._isTriggered = false; + } + } + + _trigger() { + this._isTriggered = true; + this.emit('trigger'); + this._reset(); + } + + _onBarrierHit(barrier, event) { + barrier._isHit = true; + + // If we've triggered the barrier, wait until the pointer has the + // left the barrier hitbox until we trigger it again. + if (this._isTriggered) + return; + + if (this._eventFilter && this._eventFilter(event)) + return; + + // Throw out all events not in the proper keybinding mode + if (!(this._actionMode & Main.actionMode)) + return; + + let slide = this._getDistanceAlongBarrier(barrier, event); + let distance = this._getDistanceAcrossBarrier(barrier, event); + + if (distance >= this._threshold) { + this._trigger(); + return; + } + + // Throw out events where the cursor is move more + // along the axis of the barrier than moving with + // the barrier. + if (slide > distance) + return; + + this._lastTime = event.time; + + this._trimBarrierEvents(); + distance = Math.min(15, distance); + + this._barrierEvents.push([event.time, distance]); + this._currentPressure += distance; + + if (this._currentPressure >= this._threshold) + this._trigger(); + } +}; +Signals.addSignalMethods(PressureBarrier.prototype); diff --git a/js/ui/lightbox.js b/js/ui/lightbox.js new file mode 100644 index 0000000..9aaef1a --- /dev/null +++ b/js/ui/lightbox.js @@ -0,0 +1,293 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Lightbox */ + +const { Clutter, GObject, Shell, St } = imports.gi; + +const Params = imports.misc.params; + +var DEFAULT_FADE_FACTOR = 0.4; +var VIGNETTE_BRIGHTNESS = 0.5; +var VIGNETTE_SHARPNESS = 0.7; + +const VIGNETTE_DECLARATIONS = '\ +uniform float brightness;\n\ +uniform float vignette_sharpness;\n'; + +const VIGNETTE_CODE = '\ +cogl_color_out.a = cogl_color_in.a;\n\ +cogl_color_out.rgb = vec3(0.0, 0.0, 0.0);\n\ +vec2 position = cogl_tex_coord_in[0].xy - 0.5;\n\ +float t = length(2.0 * position);\n\ +t = clamp(t, 0.0, 1.0);\n\ +float pixel_brightness = mix(1.0, 1.0 - vignette_sharpness, t);\n\ +cogl_color_out.a = cogl_color_out.a * (1 - pixel_brightness * brightness);'; + +var RadialShaderEffect = GObject.registerClass({ + Properties: { + 'brightness': GObject.ParamSpec.float( + 'brightness', 'brightness', 'brightness', + GObject.ParamFlags.READWRITE, + 0, 1, 1), + 'sharpness': GObject.ParamSpec.float( + 'sharpness', 'sharpness', 'sharpness', + GObject.ParamFlags.READWRITE, + 0, 1, 0), + }, +}, class RadialShaderEffect extends Shell.GLSLEffect { + _init(params) { + this._brightness = undefined; + this._sharpness = undefined; + + super._init(params); + + this._brightnessLocation = this.get_uniform_location('brightness'); + this._sharpnessLocation = this.get_uniform_location('vignette_sharpness'); + + this.brightness = 1.0; + this.sharpness = 0.0; + } + + vfunc_build_pipeline() { + this.add_glsl_snippet(Shell.SnippetHook.FRAGMENT, + VIGNETTE_DECLARATIONS, VIGNETTE_CODE, true); + } + + get brightness() { + return this._brightness; + } + + set brightness(v) { + if (this._brightness == v) + return; + this._brightness = v; + this.set_uniform_float(this._brightnessLocation, + 1, [this._brightness]); + this.notify('brightness'); + } + + get sharpness() { + return this._sharpness; + } + + set sharpness(v) { + if (this._sharpness == v) + return; + this._sharpness = v; + this.set_uniform_float(this._sharpnessLocation, + 1, [this._sharpness]); + this.notify('sharpness'); + } +}); + +/** + * Lightbox: + * @container: parent Clutter.Container + * @params: (optional) additional parameters: + * - inhibitEvents: whether to inhibit events for @container + * - width: shade actor width + * - height: shade actor height + * - fadeFactor: fading opacity factor + * - radialEffect: whether to enable the GLSL radial effect + * + * Lightbox creates a dark translucent "shade" actor to hide the + * contents of @container, and allows you to specify particular actors + * in @container to highlight by bringing them above the shade. It + * tracks added and removed actors in @container while the lightboxing + * is active, and ensures that all actors are returned to their + * original stacking order when the lightboxing is removed. (However, + * if actors are restacked by outside code while the lightboxing is + * active, the lightbox may later revert them back to their original + * order.) + * + * By default, the shade window will have the height and width of + * @container and will track any changes in its size. You can override + * this by passing an explicit width and height in @params. + */ +var Lightbox = GObject.registerClass({ + Properties: { + 'active': GObject.ParamSpec.boolean( + 'active', 'active', 'active', GObject.ParamFlags.READABLE, false), + }, +}, class Lightbox extends St.Bin { + _init(container, params) { + params = Params.parse(params, { + inhibitEvents: false, + width: null, + height: null, + fadeFactor: DEFAULT_FADE_FACTOR, + radialEffect: false, + }); + + super._init({ + reactive: params.inhibitEvents, + width: params.width, + height: params.height, + visible: false, + }); + + this._active = false; + this._container = container; + this._children = container.get_children(); + this._fadeFactor = params.fadeFactor; + this._radialEffect = Clutter.feature_available(Clutter.FeatureFlags.SHADERS_GLSL) && params.radialEffect; + + if (this._radialEffect) + this.add_effect(new RadialShaderEffect({ name: 'radial' })); + else + this.set({ opacity: 0, style_class: 'lightbox' }); + + container.add_actor(this); + container.set_child_above_sibling(this, null); + + this.connect('destroy', this._onDestroy.bind(this)); + + if (!params.width || !params.height) { + this.add_constraint(new Clutter.BindConstraint({ + source: container, + coordinate: Clutter.BindCoordinate.ALL, + })); + } + + this._actorAddedSignalId = container.connect('actor-added', this._actorAdded.bind(this)); + this._actorRemovedSignalId = container.connect('actor-removed', this._actorRemoved.bind(this)); + + this._highlighted = null; + } + + get active() { + return this._active; + } + + _actorAdded(container, newChild) { + let children = this._container.get_children(); + let myIndex = children.indexOf(this); + let newChildIndex = children.indexOf(newChild); + + if (newChildIndex > myIndex) { + // The child was added above the shade (presumably it was + // made the new top-most child). Move it below the shade, + // and add it to this._children as the new topmost actor. + this._container.set_child_above_sibling(this, newChild); + this._children.push(newChild); + } else if (newChildIndex == 0) { + // Bottom of stack + this._children.unshift(newChild); + } else { + // Somewhere else; insert it into the correct spot + let prevChild = this._children.indexOf(children[newChildIndex - 1]); + if (prevChild != -1) // paranoia + this._children.splice(prevChild + 1, 0, newChild); + } + } + + lightOn(fadeInTime) { + this.remove_all_transitions(); + + let easeProps = { + duration: fadeInTime || 0, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }; + + let onComplete = () => { + this._active = true; + this.notify('active'); + }; + + this.show(); + + if (this._radialEffect) { + this.ease_property( + '@effects.radial.brightness', VIGNETTE_BRIGHTNESS, easeProps); + this.ease_property( + '@effects.radial.sharpness', VIGNETTE_SHARPNESS, + Object.assign({ onComplete }, easeProps)); + } else { + this.ease(Object.assign(easeProps, { + opacity: 255 * this._fadeFactor, + onComplete, + })); + } + } + + lightOff(fadeOutTime) { + this.remove_all_transitions(); + + this._active = false; + this.notify('active'); + + let easeProps = { + duration: fadeOutTime || 0, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }; + + let onComplete = () => this.hide(); + + if (this._radialEffect) { + this.ease_property( + '@effects.radial.brightness', 1.0, easeProps); + this.ease_property( + '@effects.radial.sharpness', 0.0, Object.assign({ onComplete }, easeProps)); + } else { + this.ease(Object.assign(easeProps, { opacity: 0, onComplete })); + } + } + + _actorRemoved(container, child) { + let index = this._children.indexOf(child); + if (index != -1) // paranoia + this._children.splice(index, 1); + + if (child == this._highlighted) + this._highlighted = null; + } + + /** + * highlight: + * @param {Clutter.Actor=} window: actor to highlight + * + * Highlights the indicated actor and unhighlights any other + * currently-highlighted actor. With no arguments or a false/null + * argument, all actors will be unhighlighted. + */ + highlight(window) { + if (this._highlighted == window) + return; + + // Walk this._children raising and lowering actors as needed. + // Things get a little tricky if the to-be-raised and + // to-be-lowered actors were originally adjacent, in which + // case we may need to indicate some *other* actor as the new + // sibling of the to-be-lowered one. + + let below = this; + for (let i = this._children.length - 1; i >= 0; i--) { + if (this._children[i] == window) + this._container.set_child_above_sibling(this._children[i], null); + else if (this._children[i] == this._highlighted) + this._container.set_child_below_sibling(this._children[i], below); + else + below = this._children[i]; + } + + this._highlighted = window; + } + + /** + * _onDestroy: + * + * This is called when the lightbox' actor is destroyed, either + * by destroying its container or by explicitly calling this.destroy(). + */ + _onDestroy() { + if (this._actorAddedSignalId) { + this._container.disconnect(this._actorAddedSignalId); + this._actorAddedSignalId = 0; + } + if (this._actorRemovedSignalId) { + this._container.disconnect(this._actorRemovedSignalId); + this._actorRemovedSignalId = 0; + } + + this.highlight(null); + } +}); diff --git a/js/ui/locatePointer.js b/js/ui/locatePointer.js new file mode 100644 index 0000000..6ae2941 --- /dev/null +++ b/js/ui/locatePointer.js @@ -0,0 +1,39 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported LocatePointer */ + +const { Gio } = imports.gi; +const Ripples = imports.ui.ripples; +const Main = imports.ui.main; + +const LOCATE_POINTER_KEY = "locate-pointer"; +const LOCATE_POINTER_SCHEMA = "org.gnome.desktop.interface"; + +var LocatePointer = class { + constructor() { + this._settings = new Gio.Settings({ schema_id: LOCATE_POINTER_SCHEMA }); + this._settings.connect(`changed::${LOCATE_POINTER_KEY}`, () => this._syncEnabled()); + this._syncEnabled(); + } + + _syncEnabled() { + let enabled = this._settings.get_boolean(LOCATE_POINTER_KEY); + if (enabled == !!this._ripples) + return; + + if (enabled) { + this._ripples = new Ripples.Ripples(0.5, 0.5, 'ripple-pointer-location'); + this._ripples.addTo(Main.uiGroup); + } else { + this._ripples.destroy(); + this._ripples = null; + } + } + + show() { + if (!this._ripples) + return; + + let [x, y] = global.get_pointer(); + this._ripples.playAnimation(x, y); + } +}; diff --git a/js/ui/lookingGlass.js b/js/ui/lookingGlass.js new file mode 100644 index 0000000..5abee0f --- /dev/null +++ b/js/ui/lookingGlass.js @@ -0,0 +1,1373 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported LookingGlass */ + +const { Clutter, Cogl, Gio, GLib, GObject, + Graphene, Meta, Pango, Shell, St } = imports.gi; +const Signals = imports.signals; +const System = imports.system; + +const History = imports.misc.history; +const ExtensionUtils = imports.misc.extensionUtils; +const ShellEntry = imports.ui.shellEntry; +const Main = imports.ui.main; +const JsParse = imports.misc.jsParse; + +const { ExtensionState } = ExtensionUtils; + +const CHEVRON = '>>> '; + +/* Imports...feel free to add here as needed */ +var commandHeader = 'const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; ' + + 'const Main = imports.ui.main; ' + + /* Utility functions...we should probably be able to use these + * in the shell core code too. */ + 'const stage = global.stage; ' + + /* Special lookingGlass functions */ + 'const inspect = Main.lookingGlass.inspect.bind(Main.lookingGlass); ' + + 'const it = Main.lookingGlass.getIt(); ' + + 'const r = Main.lookingGlass.getResult.bind(Main.lookingGlass); '; + +const HISTORY_KEY = 'looking-glass-history'; +// Time between tabs for them to count as a double-tab event +var AUTO_COMPLETE_DOUBLE_TAB_DELAY = 500; +var AUTO_COMPLETE_SHOW_COMPLETION_ANIMATION_DURATION = 200; +var AUTO_COMPLETE_GLOBAL_KEYWORDS = _getAutoCompleteGlobalKeywords(); + +const LG_ANIMATION_TIME = 500; + +function _getAutoCompleteGlobalKeywords() { + const keywords = ['true', 'false', 'null', 'new']; + // Don't add the private properties of globalThis (i.e., ones starting with '_') + const windowProperties = Object.getOwnPropertyNames(globalThis).filter( + a => a.charAt(0) !== '_'); + const headerProperties = JsParse.getDeclaredConstants(commandHeader); + + return keywords.concat(windowProperties).concat(headerProperties); +} + +var AutoComplete = class AutoComplete { + constructor(entry) { + this._entry = entry; + this._entry.connect('key-press-event', this._entryKeyPressEvent.bind(this)); + this._lastTabTime = global.get_current_time(); + } + + _processCompletionRequest(event) { + if (event.completions.length == 0) + return; + + // Unique match = go ahead and complete; multiple matches + single tab = complete the common starting string; + // multiple matches + double tab = emit a suggest event with all possible options + if (event.completions.length == 1) { + this.additionalCompletionText(event.completions[0], event.attrHead); + this.emit('completion', { completion: event.completions[0], type: 'whole-word' }); + } else if (event.completions.length > 1 && event.tabType === 'single') { + let commonPrefix = JsParse.getCommonPrefix(event.completions); + + if (commonPrefix.length > 0) { + this.additionalCompletionText(commonPrefix, event.attrHead); + this.emit('completion', { completion: commonPrefix, type: 'prefix' }); + this.emit('suggest', { completions: event.completions }); + } + } else if (event.completions.length > 1 && event.tabType === 'double') { + this.emit('suggest', { completions: event.completions }); + } + } + + _entryKeyPressEvent(actor, event) { + let cursorPos = this._entry.clutter_text.get_cursor_position(); + let text = this._entry.get_text(); + if (cursorPos != -1) + text = text.slice(0, cursorPos); + + if (event.get_key_symbol() == Clutter.KEY_Tab) { + let [completions, attrHead] = JsParse.getCompletions(text, commandHeader, AUTO_COMPLETE_GLOBAL_KEYWORDS); + let currTime = global.get_current_time(); + if ((currTime - this._lastTabTime) < AUTO_COMPLETE_DOUBLE_TAB_DELAY) { + this._processCompletionRequest({ tabType: 'double', + completions, + attrHead }); + } else { + this._processCompletionRequest({ tabType: 'single', + completions, + attrHead }); + } + this._lastTabTime = currTime; + } + return Clutter.EVENT_PROPAGATE; + } + + // Insert characters of text not already included in head at cursor position. i.e., if text="abc" and head="a", + // the string "bc" will be appended to this._entry + additionalCompletionText(text, head) { + let additionalCompletionText = text.slice(head.length); + let cursorPos = this._entry.clutter_text.get_cursor_position(); + + this._entry.clutter_text.insert_text(additionalCompletionText, cursorPos); + } +}; +Signals.addSignalMethods(AutoComplete.prototype); + + +var Notebook = GObject.registerClass({ + Signals: { 'selection': { param_types: [Clutter.Actor.$gtype] } }, +}, class Notebook extends St.BoxLayout { + _init() { + super._init({ + vertical: true, + y_expand: true, + }); + + this.tabControls = new St.BoxLayout({ style_class: 'labels' }); + + this._selectedIndex = -1; + this._tabs = []; + } + + appendPage(name, child) { + let labelBox = new St.BoxLayout({ style_class: 'notebook-tab', + reactive: true, + track_hover: true }); + let label = new St.Button({ label: name }); + label.connect('clicked', () => { + this.selectChild(child); + return true; + }); + labelBox.add_child(label); + this.tabControls.add(labelBox); + + let scrollview = new St.ScrollView({ y_expand: true }); + scrollview.get_hscroll_bar().hide(); + scrollview.add_actor(child); + + let tabData = { child, + labelBox, + label, + scrollView: scrollview, + _scrollToBottom: false }; + this._tabs.push(tabData); + scrollview.hide(); + this.add_child(scrollview); + + let vAdjust = scrollview.vscroll.adjustment; + vAdjust.connect('changed', () => this._onAdjustScopeChanged(tabData)); + vAdjust.connect('notify::value', () => this._onAdjustValueChanged(tabData)); + + if (this._selectedIndex == -1) + this.selectIndex(0); + } + + _unselect() { + if (this._selectedIndex < 0) + return; + let tabData = this._tabs[this._selectedIndex]; + tabData.labelBox.remove_style_pseudo_class('selected'); + tabData.scrollView.hide(); + this._selectedIndex = -1; + } + + selectIndex(index) { + if (index == this._selectedIndex) + return; + if (index < 0) { + this._unselect(); + this.emit('selection', null); + return; + } + + // Focus the new tab before unmapping the old one + let tabData = this._tabs[index]; + if (!tabData.scrollView.navigate_focus(null, St.DirectionType.TAB_FORWARD, false)) + this.grab_key_focus(); + + this._unselect(); + + tabData.labelBox.add_style_pseudo_class('selected'); + tabData.scrollView.show(); + this._selectedIndex = index; + this.emit('selection', tabData.child); + } + + selectChild(child) { + if (child == null) { + this.selectIndex(-1); + } else { + for (let i = 0; i < this._tabs.length; i++) { + let tabData = this._tabs[i]; + if (tabData.child == child) { + this.selectIndex(i); + return; + } + } + } + } + + scrollToBottom(index) { + let tabData = this._tabs[index]; + tabData._scrollToBottom = true; + + } + + _onAdjustValueChanged(tabData) { + let vAdjust = tabData.scrollView.vscroll.adjustment; + if (vAdjust.value < (vAdjust.upper - vAdjust.lower - 0.5)) + tabData._scrolltoBottom = false; + } + + _onAdjustScopeChanged(tabData) { + if (!tabData._scrollToBottom) + return; + let vAdjust = tabData.scrollView.vscroll.adjustment; + vAdjust.value = vAdjust.upper - vAdjust.page_size; + } + + nextTab() { + let nextIndex = this._selectedIndex; + if (nextIndex < this._tabs.length - 1) + ++nextIndex; + + this.selectIndex(nextIndex); + } + + prevTab() { + let prevIndex = this._selectedIndex; + if (prevIndex > 0) + --prevIndex; + + this.selectIndex(prevIndex); + } +}); + +function objectToString(o) { + if (typeof o == typeof objectToString) { + // special case this since the default is way, way too verbose + return '<js function>'; + } else { + if (o === undefined) + return 'undefined'; + + if (o === null) + return 'null'; + + return o.toString(); + } +} + +var ObjLink = GObject.registerClass( +class ObjLink extends St.Button { + _init(lookingGlass, o, title) { + let text; + if (title) + text = title; + else + text = objectToString(o); + text = GLib.markup_escape_text(text, -1); + + super._init({ + reactive: true, + track_hover: true, + style_class: 'shell-link', + label: text, + x_align: Clutter.ActorAlign.START, + }); + this.get_child().single_line_mode = true; + + this._obj = o; + this._lookingGlass = lookingGlass; + } + + vfunc_clicked() { + this._lookingGlass.inspectObject(this._obj, this); + } +}); + +var Result = GObject.registerClass( +class Result extends St.BoxLayout { + _init(lookingGlass, command, o, index) { + super._init({ vertical: true }); + + this.index = index; + this.o = o; + + this._lookingGlass = lookingGlass; + + let cmdTxt = new St.Label({ text: command }); + cmdTxt.clutter_text.ellipsize = Pango.EllipsizeMode.END; + this.add(cmdTxt); + let box = new St.BoxLayout({}); + this.add(box); + let resultTxt = new St.Label({ text: 'r(%d) = '.format(index) }); + resultTxt.clutter_text.ellipsize = Pango.EllipsizeMode.END; + box.add(resultTxt); + let objLink = new ObjLink(this._lookingGlass, o); + box.add(objLink); + } +}); + +var WindowList = GObject.registerClass({ +}, class WindowList extends St.BoxLayout { + _init(lookingGlass) { + super._init({ name: 'Windows', vertical: true, style: 'spacing: 8px' }); + let tracker = Shell.WindowTracker.get_default(); + this._updateId = Main.initializeDeferredWork(this, this._updateWindowList.bind(this)); + global.display.connect('window-created', this._updateWindowList.bind(this)); + tracker.connect('tracked-windows-changed', this._updateWindowList.bind(this)); + + this._lookingGlass = lookingGlass; + } + + _updateWindowList() { + if (!this._lookingGlass.isOpen) + return; + + this.destroy_all_children(); + let windows = global.get_window_actors(); + let tracker = Shell.WindowTracker.get_default(); + for (let i = 0; i < windows.length; i++) { + let metaWindow = windows[i].metaWindow; + // Avoid multiple connections + if (!metaWindow._lookingGlassManaged) { + metaWindow.connect('unmanaged', this._updateWindowList.bind(this)); + metaWindow._lookingGlassManaged = true; + } + let box = new St.BoxLayout({ vertical: true }); + this.add(box); + let windowLink = new ObjLink(this._lookingGlass, metaWindow, metaWindow.title); + box.add_child(windowLink); + let propsBox = new St.BoxLayout({ vertical: true, style: 'padding-left: 6px;' }); + box.add(propsBox); + propsBox.add(new St.Label({ text: 'wmclass: %s'.format(metaWindow.get_wm_class()) })); + let app = tracker.get_window_app(metaWindow); + if (app != null && !app.is_window_backed()) { + let icon = app.create_icon_texture(22); + let propBox = new St.BoxLayout({ style: 'spacing: 6px; ' }); + propsBox.add(propBox); + propBox.add_child(new St.Label({ text: 'app: ' })); + let appLink = new ObjLink(this._lookingGlass, app, app.get_id()); + propBox.add_child(appLink); + propBox.add_child(icon); + } else { + propsBox.add(new St.Label({ text: '<untracked>' })); + } + } + } + + update() { + this._updateWindowList(); + } +}); + +var ObjInspector = GObject.registerClass( +class ObjInspector extends St.ScrollView { + _init(lookingGlass) { + super._init({ + pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }), + }); + + this._obj = null; + this._previousObj = null; + + this._parentList = []; + + this.get_hscroll_bar().hide(); + this._container = new St.BoxLayout({ + name: 'LookingGlassPropertyInspector', + style_class: 'lg-dialog', + vertical: true, + x_expand: true, + y_expand: true, + }); + this.add_actor(this._container); + + this._lookingGlass = lookingGlass; + } + + selectObject(obj, skipPrevious) { + if (!skipPrevious) + this._previousObj = this._obj; + else + this._previousObj = null; + this._obj = obj; + + this._container.destroy_all_children(); + + let hbox = new St.BoxLayout({ style_class: 'lg-obj-inspector-title' }); + this._container.add_actor(hbox); + let label = new St.Label({ + text: 'Inspecting: %s: %s'.format(typeof obj, objectToString(obj)), + x_expand: true, + }); + label.single_line_mode = true; + hbox.add_child(label); + let button = new St.Button({ label: 'Insert', style_class: 'lg-obj-inspector-button' }); + button.connect('clicked', this._onInsert.bind(this)); + hbox.add(button); + + if (this._previousObj != null) { + button = new St.Button({ label: 'Back', style_class: 'lg-obj-inspector-button' }); + button.connect('clicked', this._onBack.bind(this)); + hbox.add(button); + } + + button = new St.Button({ style_class: 'window-close' }); + button.add_actor(new St.Icon({ icon_name: 'window-close-symbolic' })); + button.connect('clicked', this.close.bind(this)); + hbox.add(button); + if (typeof obj == typeof {}) { + let properties = []; + for (let propName in obj) + properties.push(propName); + properties.sort(); + + for (let i = 0; i < properties.length; i++) { + let propName = properties[i]; + let link; + try { + let prop = obj[propName]; + link = new ObjLink(this._lookingGlass, prop); + } catch (e) { + link = new St.Label({ text: '<error>' }); + } + let box = new St.BoxLayout(); + box.add(new St.Label({ text: '%s: '.format(propName) })); + box.add(link); + this._container.add_actor(box); + } + } + } + + open(sourceActor) { + if (this._open) + return; + this._previousObj = null; + this._open = true; + this.show(); + if (sourceActor) { + this.set_scale(0, 0); + this.ease({ + scale_x: 1, + scale_y: 1, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: 200, + }); + } else { + this.set_scale(1, 1); + } + } + + close() { + if (!this._open) + return; + this._open = false; + this.hide(); + this._previousObj = null; + this._obj = null; + } + + _onInsert() { + let obj = this._obj; + this.close(); + this._lookingGlass.insertObject(obj); + } + + _onBack() { + this.selectObject(this._previousObj, true); + } +}); + +var RedBorderEffect = GObject.registerClass( +class RedBorderEffect extends Clutter.Effect { + _init() { + super._init(); + this._pipeline = null; + } + + vfunc_paint(paintContext) { + let framebuffer = paintContext.get_framebuffer(); + let coglContext = framebuffer.get_context(); + let actor = this.get_actor(); + actor.continue_paint(paintContext); + + if (!this._pipeline) { + let color = new Cogl.Color(); + color.init_from_4ub(0xff, 0, 0, 0xc4); + + this._pipeline = new Cogl.Pipeline(coglContext); + this._pipeline.set_color(color); + } + + let alloc = actor.get_allocation_box(); + let width = 2; + + // clockwise order + framebuffer.draw_rectangle(this._pipeline, + 0, 0, alloc.get_width(), width); + framebuffer.draw_rectangle(this._pipeline, + alloc.get_width() - width, width, + alloc.get_width(), alloc.get_height()); + framebuffer.draw_rectangle(this._pipeline, + 0, alloc.get_height(), + alloc.get_width() - width, alloc.get_height() - width); + framebuffer.draw_rectangle(this._pipeline, + 0, alloc.get_height() - width, + width, width); + } +}); + +var Inspector = GObject.registerClass({ + Signals: { 'closed': {}, + 'target': { param_types: [Clutter.Actor.$gtype, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] } }, +}, class Inspector extends Clutter.Actor { + _init(lookingGlass) { + super._init({ width: 0, height: 0 }); + + Main.uiGroup.add_actor(this); + + let eventHandler = new St.BoxLayout({ name: 'LookingGlassDialog', + vertical: false, + reactive: true }); + this._eventHandler = eventHandler; + this.add_actor(eventHandler); + this._displayText = new St.Label({ x_expand: true }); + eventHandler.add_child(this._displayText); + + eventHandler.connect('key-press-event', this._onKeyPressEvent.bind(this)); + eventHandler.connect('button-press-event', this._onButtonPressEvent.bind(this)); + eventHandler.connect('scroll-event', this._onScrollEvent.bind(this)); + eventHandler.connect('motion-event', this._onMotionEvent.bind(this)); + + let seat = Clutter.get_default_backend().get_default_seat(); + this._pointerDevice = seat.get_pointer(); + this._keyboardDevice = seat.get_keyboard(); + + this._pointerDevice.grab(eventHandler); + this._keyboardDevice.grab(eventHandler); + + // this._target is the actor currently shown by the inspector. + // this._pointerTarget is the actor directly under the pointer. + // Normally these are the same, but if you use the scroll wheel + // to drill down, they'll diverge until you either scroll back + // out, or move the pointer outside of _pointerTarget. + this._target = null; + this._pointerTarget = null; + + this._lookingGlass = lookingGlass; + } + + vfunc_allocate(box) { + this.set_allocation(box); + + if (!this._eventHandler) + return; + + let primary = Main.layoutManager.primaryMonitor; + + let [, , natWidth, natHeight] = + this._eventHandler.get_preferred_size(); + + let childBox = new Clutter.ActorBox(); + childBox.x1 = primary.x + Math.floor((primary.width - natWidth) / 2); + childBox.x2 = childBox.x1 + natWidth; + childBox.y1 = primary.y + Math.floor((primary.height - natHeight) / 2); + childBox.y2 = childBox.y1 + natHeight; + this._eventHandler.allocate(childBox); + } + + _close() { + this._pointerDevice.ungrab(); + this._keyboardDevice.ungrab(); + this._eventHandler.destroy(); + this._eventHandler = null; + this.emit('closed'); + } + + _onKeyPressEvent(actor, event) { + if (event.get_key_symbol() === Clutter.KEY_Escape) + this._close(); + return Clutter.EVENT_STOP; + } + + _onButtonPressEvent(actor, event) { + if (this._target) { + let [stageX, stageY] = event.get_coords(); + this.emit('target', this._target, stageX, stageY); + } + this._close(); + return Clutter.EVENT_STOP; + } + + _onScrollEvent(actor, event) { + switch (event.get_scroll_direction()) { + case Clutter.ScrollDirection.UP: { + // select parent + let parent = this._target.get_parent(); + if (parent != null) { + this._target = parent; + this._update(event); + } + break; + } + + case Clutter.ScrollDirection.DOWN: + // select child + if (this._target != this._pointerTarget) { + let child = this._pointerTarget; + while (child) { + let parent = child.get_parent(); + if (parent == this._target) + break; + child = parent; + } + if (child) { + this._target = child; + this._update(event); + } + } + break; + + default: + break; + } + return Clutter.EVENT_STOP; + } + + _onMotionEvent(actor, event) { + this._update(event); + return Clutter.EVENT_STOP; + } + + _update(event) { + let [stageX, stageY] = event.get_coords(); + let target = global.stage.get_actor_at_pos(Clutter.PickMode.ALL, + stageX, + stageY); + + if (target != this._pointerTarget) + this._target = target; + this._pointerTarget = target; + + let position = '[inspect x: %d y: %d]'.format(stageX, stageY); + this._displayText.text = ''; + this._displayText.text = '%s %s'.format(position, this._target); + + this._lookingGlass.setBorderPaintTarget(this._target); + } +}); + +var Extensions = GObject.registerClass({ +}, class Extensions extends St.BoxLayout { + _init(lookingGlass) { + super._init({ vertical: true, name: 'lookingGlassExtensions' }); + + this._lookingGlass = lookingGlass; + this._noExtensions = new St.Label({ style_class: 'lg-extensions-none', + text: _("No extensions installed") }); + this._numExtensions = 0; + this._extensionsList = new St.BoxLayout({ vertical: true, + style_class: 'lg-extensions-list' }); + this._extensionsList.add(this._noExtensions); + this.add(this._extensionsList); + + Main.extensionManager.getUuids().forEach(uuid => { + this._loadExtension(null, uuid); + }); + + Main.extensionManager.connect('extension-loaded', + this._loadExtension.bind(this)); + } + + _loadExtension(o, uuid) { + let extension = Main.extensionManager.lookup(uuid); + // There can be cases where we create dummy extension metadata + // that's not really a proper extension. Don't bother with these. + if (!extension.metadata.name) + return; + + let extensionDisplay = this._createExtensionDisplay(extension); + if (this._numExtensions == 0) + this._extensionsList.remove_actor(this._noExtensions); + + this._numExtensions++; + this._extensionsList.add(extensionDisplay); + } + + _onViewSource(actor) { + let extension = actor._extension; + let uri = extension.dir.get_uri(); + Gio.app_info_launch_default_for_uri(uri, global.create_app_launch_context(0, -1)); + this._lookingGlass.close(); + } + + _onWebPage(actor) { + let extension = actor._extension; + Gio.app_info_launch_default_for_uri(extension.metadata.url, global.create_app_launch_context(0, -1)); + this._lookingGlass.close(); + } + + _onViewErrors(actor) { + let extension = actor._extension; + let shouldShow = !actor._isShowing; + + if (shouldShow) { + let errors = extension.errors; + let errorDisplay = new St.BoxLayout({ vertical: true }); + if (errors && errors.length) { + for (let i = 0; i < errors.length; i++) + errorDisplay.add(new St.Label({ text: errors[i] })); + } else { + /* Translators: argument is an extension UUID. */ + let message = _("%s has not emitted any errors.").format(extension.uuid); + errorDisplay.add(new St.Label({ text: message })); + } + + actor._errorDisplay = errorDisplay; + actor._parentBox.add(errorDisplay); + actor.label = _("Hide Errors"); + } else { + actor._errorDisplay.destroy(); + actor._errorDisplay = null; + actor.label = _("Show Errors"); + } + + actor._isShowing = shouldShow; + } + + _stateToString(extensionState) { + switch (extensionState) { + case ExtensionState.ENABLED: + return _("Enabled"); + case ExtensionState.DISABLED: + case ExtensionState.INITIALIZED: + return _("Disabled"); + case ExtensionState.ERROR: + return _("Error"); + case ExtensionState.OUT_OF_DATE: + return _("Out of date"); + case ExtensionState.DOWNLOADING: + return _("Downloading"); + } + return 'Unknown'; // Not translated, shouldn't appear + } + + _createExtensionDisplay(extension) { + let box = new St.BoxLayout({ style_class: 'lg-extension', vertical: true }); + let name = new St.Label({ + style_class: 'lg-extension-name', + text: extension.metadata.name, + x_expand: true, + }); + box.add_child(name); + let description = new St.Label({ + style_class: 'lg-extension-description', + text: extension.metadata.description || 'No description', + x_expand: true, + }); + box.add_child(description); + + let metaBox = new St.BoxLayout({ style_class: 'lg-extension-meta' }); + box.add(metaBox); + let state = new St.Label({ style_class: 'lg-extension-state', + text: this._stateToString(extension.state) }); + metaBox.add(state); + + let viewsource = new St.Button({ reactive: true, + track_hover: true, + style_class: 'shell-link', + label: _("View Source") }); + viewsource._extension = extension; + viewsource.connect('clicked', this._onViewSource.bind(this)); + metaBox.add(viewsource); + + if (extension.metadata.url) { + let webpage = new St.Button({ reactive: true, + track_hover: true, + style_class: 'shell-link', + label: _("Web Page") }); + webpage._extension = extension; + webpage.connect('clicked', this._onWebPage.bind(this)); + metaBox.add(webpage); + } + + let viewerrors = new St.Button({ reactive: true, + track_hover: true, + style_class: 'shell-link', + label: _("Show Errors") }); + viewerrors._extension = extension; + viewerrors._parentBox = box; + viewerrors._isShowing = false; + viewerrors.connect('clicked', this._onViewErrors.bind(this)); + metaBox.add(viewerrors); + + return box; + } +}); + + +var ActorLink = GObject.registerClass({ + Signals: { + 'inspect-actor': {}, + }, +}, class ActorLink extends St.Button { + _init(actor) { + this._arrow = new St.Icon({ + icon_name: 'pan-end-symbolic', + icon_size: 8, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }), + }); + + const label = new St.Label({ + text: actor.toString(), + x_align: Clutter.ActorAlign.START, + }); + + const inspectButton = new St.Button({ + child: new St.Icon({ + icon_name: 'insert-object-symbolic', + icon_size: 12, + y_align: Clutter.ActorAlign.CENTER, + }), + reactive: true, + x_expand: true, + x_align: Clutter.ActorAlign.START, + y_align: Clutter.ActorAlign.CENTER, + }); + inspectButton.connect('clicked', () => this.emit('inspect-actor')); + + const box = new St.BoxLayout(); + box.add_child(this._arrow); + box.add_child(label); + box.add_child(inspectButton); + + super._init({ + reactive: true, + track_hover: true, + toggle_mode: true, + style_class: 'actor-link', + child: box, + x_align: Clutter.ActorAlign.START, + }); + + this._actor = actor; + } + + vfunc_clicked() { + this._arrow.ease({ + rotation_angle_z: this.checked ? 90 : 0, + duration: 250, + }); + } +}); + +var ActorTreeViewer = GObject.registerClass( +class ActorTreeViewer extends St.BoxLayout { + _init(lookingGlass) { + super._init(); + + this._lookingGlass = lookingGlass; + this._actorData = new Map(); + } + + _showActorChildren(actor) { + const data = this._actorData.get(actor); + if (!data || data.visible) + return; + + data.visible = true; + data.actorAddedId = actor.connect('actor-added', (container, child) => { + this._addActor(data.children, child); + }); + data.actorRemovedId = actor.connect('actor-removed', (container, child) => { + this._removeActor(child); + }); + + for (let child of actor) + this._addActor(data.children, child); + } + + _hideActorChildren(actor) { + const data = this._actorData.get(actor); + if (!data || !data.visible) + return; + + for (let child of actor) + this._removeActor(child); + + data.visible = false; + if (data.actorAddedId > 0) { + actor.disconnect(data.actorAddedId); + data.actorAddedId = 0; + } + if (data.actorRemovedId > 0) { + actor.disconnect(data.actorRemovedId); + data.actorRemovedId = 0; + } + data.children.remove_all_children(); + } + + _addActor(container, actor) { + if (this._actorData.has(actor)) + return; + + if (actor === this._lookingGlass) + return; + + const button = new ActorLink(actor); + button.connect('notify::checked', () => { + this._lookingGlass.setBorderPaintTarget(actor); + if (button.checked) + this._showActorChildren(actor); + else + this._hideActorChildren(actor); + }); + button.connect('inspect-actor', () => { + this._lookingGlass.inspectObject(actor, button); + }); + + const mainContainer = new St.BoxLayout({ vertical: true }); + const childrenContainer = new St.BoxLayout({ + vertical: true, + style: 'padding: 0 0 0 18px', + }); + + mainContainer.add_child(button); + mainContainer.add_child(childrenContainer); + + this._actorData.set(actor, { + button, + container: mainContainer, + children: childrenContainer, + visible: false, + actorAddedId: 0, + actorRemovedId: 0, + actorDestroyedId: actor.connect('destroy', () => this._removeActor(actor)), + }); + + let belowChild = null; + const nextSibling = actor.get_next_sibling(); + if (nextSibling && this._actorData.has(nextSibling)) + belowChild = this._actorData.get(nextSibling).container; + + container.insert_child_above(mainContainer, belowChild); + } + + _removeActor(actor) { + const data = this._actorData.get(actor); + if (!data) + return; + + for (let child of actor) + this._removeActor(child); + + if (data.actorAddedId > 0) { + actor.disconnect(data.actorAddedId); + data.actorAddedId = 0; + } + if (data.actorRemovedId > 0) { + actor.disconnect(data.actorRemovedId); + data.actorRemovedId = 0; + } + if (data.actorDestroyedId > 0) { + actor.disconnect(data.actorDestroyedId); + data.actorDestroyedId = 0; + } + data.container.destroy(); + this._actorData.delete(actor); + } + + vfunc_map() { + super.vfunc_map(); + this._addActor(this, global.stage); + } + + vfunc_unmap() { + super.vfunc_unmap(); + this._removeActor(global.stage); + } +}); + +var LookingGlass = GObject.registerClass( +class LookingGlass extends St.BoxLayout { + _init() { + super._init({ + name: 'LookingGlassDialog', + style_class: 'lg-dialog', + vertical: true, + visible: false, + reactive: true, + }); + + this._borderPaintTarget = null; + this._redBorderEffect = new RedBorderEffect(); + + this._open = false; + + this._it = null; + this._offset = 0; + + // Sort of magic, but...eh. + this._maxItems = 150; + + this._interfaceSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' }); + this._interfaceSettings.connect('changed::monospace-font-name', + this._updateFont.bind(this)); + this._updateFont(); + + // We want it to appear to slide out from underneath the panel + Main.uiGroup.add_actor(this); + Main.uiGroup.set_child_below_sibling(this, + Main.layoutManager.panelBox); + Main.layoutManager.panelBox.connect('notify::allocation', + this._queueResize.bind(this)); + Main.layoutManager.keyboardBox.connect('notify::allocation', + this._queueResize.bind(this)); + + this._objInspector = new ObjInspector(this); + Main.uiGroup.add_actor(this._objInspector); + this._objInspector.hide(); + + let toolbar = new St.BoxLayout({ name: 'Toolbar' }); + this.add_actor(toolbar); + let inspectIcon = new St.Icon({ icon_name: 'gtk-color-picker', + icon_size: 24 }); + toolbar.add_actor(inspectIcon); + inspectIcon.reactive = true; + inspectIcon.connect('button-press-event', () => { + let inspector = new Inspector(this); + inspector.connect('target', (i, target, stageX, stageY) => { + this._pushResult('inspect(%d, %d)'.format(Math.round(stageX), Math.round(stageY)), target); + }); + inspector.connect('closed', () => { + this.show(); + global.stage.set_key_focus(this._entry); + }); + this.hide(); + return Clutter.EVENT_STOP; + }); + + let gcIcon = new St.Icon({ icon_name: 'user-trash-full', + icon_size: 24 }); + toolbar.add_actor(gcIcon); + gcIcon.reactive = true; + gcIcon.connect('button-press-event', () => { + gcIcon.icon_name = 'user-trash'; + System.gc(); + this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => { + gcIcon.icon_name = 'user-trash-full'; + this._timeoutId = 0; + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._timeoutId, '[gnome-shell] gcIcon.icon_name = \'user-trash-full\''); + return Clutter.EVENT_PROPAGATE; + }); + + let notebook = new Notebook(); + this._notebook = notebook; + this.add_child(notebook); + + let emptyBox = new St.Bin({ x_expand: true }); + toolbar.add_child(emptyBox); + toolbar.add_actor(notebook.tabControls); + + this._evalBox = new St.BoxLayout({ name: 'EvalBox', vertical: true }); + notebook.appendPage('Evaluator', this._evalBox); + + this._resultsArea = new St.BoxLayout({ + name: 'ResultsArea', + vertical: true, + y_expand: true, + }); + this._evalBox.add_child(this._resultsArea); + + this._entryArea = new St.BoxLayout({ + name: 'EntryArea', + y_align: Clutter.ActorAlign.END, + }); + this._evalBox.add_actor(this._entryArea); + + let label = new St.Label({ text: CHEVRON }); + this._entryArea.add(label); + + this._entry = new St.Entry({ + can_focus: true, + x_expand: true, + }); + ShellEntry.addContextMenu(this._entry); + this._entryArea.add_child(this._entry); + + this._windowList = new WindowList(this); + notebook.appendPage('Windows', this._windowList); + + this._extensions = new Extensions(this); + notebook.appendPage('Extensions', this._extensions); + + this._actorTreeViewer = new ActorTreeViewer(this); + notebook.appendPage('Actors', this._actorTreeViewer); + + this._entry.clutter_text.connect('activate', (o, _e) => { + // Hide any completions we are currently showing + this._hideCompletions(); + + let text = o.get_text(); + // Ensure we don't get newlines in the command; the history file is + // newline-separated. + text = text.replace('\n', ' '); + // Strip leading and trailing whitespace + text = text.replace(/^\s+/g, '').replace(/\s+$/g, ''); + if (text == '') + return true; + this._evaluate(text); + return true; + }); + + this._history = new History.HistoryManager({ gsettingsKey: HISTORY_KEY, + entry: this._entry.clutter_text }); + + this._autoComplete = new AutoComplete(this._entry); + this._autoComplete.connect('suggest', (a, e) => { + this._showCompletions(e.completions); + }); + // If a completion is completed unambiguously, the currently-displayed completion + // suggestions become irrelevant. + this._autoComplete.connect('completion', (a, e) => { + if (e.type == 'whole-word') + this._hideCompletions(); + }); + + this._resize(); + } + + _updateFont() { + let fontName = this._interfaceSettings.get_string('monospace-font-name'); + let fontDesc = Pango.FontDescription.from_string(fontName); + // We ignore everything but size and style; you'd be crazy to set your system-wide + // monospace font to be bold/oblique/etc. Could easily be added here. + let size = fontDesc.get_size() / 1024.; + let unit = fontDesc.get_size_is_absolute() ? 'px' : 'pt'; + this.style = 'font-size: %d%s; font-family: "%s";'.format( + size, unit, fontDesc.get_family()); + } + + setBorderPaintTarget(obj) { + if (this._borderPaintTarget != null) + this._borderPaintTarget.remove_effect(this._redBorderEffect); + this._borderPaintTarget = obj; + if (this._borderPaintTarget != null) + this._borderPaintTarget.add_effect(this._redBorderEffect); + } + + _pushResult(command, obj) { + let index = this._resultsArea.get_n_children() + this._offset; + let result = new Result(this, CHEVRON + command, obj, index); + this._resultsArea.add(result); + if (obj instanceof Clutter.Actor) + this.setBorderPaintTarget(obj); + + if (this._resultsArea.get_n_children() > this._maxItems) { + this._resultsArea.get_first_child().destroy(); + this._offset++; + } + this._it = obj; + + // Scroll to bottom + this._notebook.scrollToBottom(0); + } + + _showCompletions(completions) { + if (!this._completionActor) { + this._completionActor = new St.Label({ name: 'LookingGlassAutoCompletionText', style_class: 'lg-completions-text' }); + this._completionActor.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._completionActor.clutter_text.line_wrap = true; + this._evalBox.insert_child_below(this._completionActor, this._entryArea); + } + + this._completionActor.set_text(completions.join(', ')); + + // Setting the height to -1 allows us to get its actual preferred height rather than + // whatever was last set when animating + this._completionActor.set_height(-1); + let [, naturalHeight] = this._completionActor.get_preferred_height(this._resultsArea.get_width()); + + // Don't reanimate if we are already visible + if (this._completionActor.visible) { + this._completionActor.height = naturalHeight; + } else { + let settings = St.Settings.get(); + let duration = AUTO_COMPLETE_SHOW_COMPLETION_ANIMATION_DURATION / settings.slow_down_factor; + this._completionActor.show(); + this._completionActor.remove_all_transitions(); + this._completionActor.ease({ + height: naturalHeight, + opacity: 255, + duration, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + } + + _hideCompletions() { + if (this._completionActor) { + let settings = St.Settings.get(); + let duration = AUTO_COMPLETE_SHOW_COMPLETION_ANIMATION_DURATION / settings.slow_down_factor; + this._completionActor.remove_all_transitions(); + this._completionActor.ease({ + height: 0, + opacity: 0, + duration, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._completionActor.hide(); + }, + }); + } + } + + _evaluate(command) { + this._history.addItem(command); + + let lines = command.split(';'); + lines.push('return %s'.format(lines.pop())); + + let fullCmd = commandHeader + lines.join(';'); + + let resultObj; + try { + resultObj = Function(fullCmd)(); + } catch (e) { + resultObj = '<exception %s>'.format(e.toString()); + } + + this._pushResult(command, resultObj); + this._entry.text = ''; + } + + inspect(x, y) { + return global.stage.get_actor_at_pos(Clutter.PickMode.REACTIVE, x, y); + } + + getIt() { + return this._it; + } + + getResult(idx) { + try { + return this._resultsArea.get_child_at_index(idx - this._offset).o; + } catch (e) { + throw new Error('Unknown result at index %d'.format(idx)); + } + } + + toggle() { + if (this._open) + this.close(); + else + this.open(); + } + + _queueResize() { + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + this._resize(); + return GLib.SOURCE_REMOVE; + }); + } + + _resize() { + let primary = Main.layoutManager.primaryMonitor; + let myWidth = primary.width * 0.7; + let availableHeight = primary.height - Main.layoutManager.keyboardBox.height; + let myHeight = Math.min(primary.height * 0.7, availableHeight * 0.9); + this.x = primary.x + (primary.width - myWidth) / 2; + this._hiddenY = primary.y + Main.layoutManager.panelBox.height - myHeight; + this._targetY = this._hiddenY + myHeight; + this.y = this._hiddenY; + this.width = myWidth; + this.height = myHeight; + this._objInspector.set_size(Math.floor(myWidth * 0.8), Math.floor(myHeight * 0.8)); + this._objInspector.set_position(this.x + Math.floor(myWidth * 0.1), + this._targetY + Math.floor(myHeight * 0.1)); + } + + insertObject(obj) { + this._pushResult('<insert>', obj); + } + + inspectObject(obj, sourceActor) { + this._objInspector.open(sourceActor); + this._objInspector.selectObject(obj); + } + + // Handle key events which are relevant for all tabs of the LookingGlass + vfunc_key_press_event(keyPressEvent) { + let symbol = keyPressEvent.keyval; + if (symbol == Clutter.KEY_Escape) { + if (this._objInspector.visible) + this._objInspector.close(); + else + this.close(); + return Clutter.EVENT_STOP; + } + // Ctrl+PgUp and Ctrl+PgDown switches tabs in the notebook view + if (keyPressEvent.modifier_state & Clutter.ModifierType.CONTROL_MASK) { + if (symbol == Clutter.KEY_Page_Up) + this._notebook.prevTab(); + else if (symbol == Clutter.KEY_Page_Down) + this._notebook.nextTab(); + } + return super.vfunc_key_press_event(keyPressEvent); + } + + open() { + if (this._open) + return; + + if (!Main.pushModal(this._entry, { actionMode: Shell.ActionMode.LOOKING_GLASS })) + return; + + this._notebook.selectIndex(0); + this.show(); + this._open = true; + this._history.lastItem(); + + this.remove_all_transitions(); + + // We inverse compensate for the slow-down so you can change the factor + // through LookingGlass without long waits. + let duration = LG_ANIMATION_TIME / St.Settings.get().slow_down_factor; + this.ease({ + y: this._targetY, + duration, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + + this._windowList.update(); + } + + close() { + if (!this._open) + return; + + this._objInspector.hide(); + + this._open = false; + this.remove_all_transitions(); + + this.setBorderPaintTarget(null); + + Main.popModal(this._entry); + + let settings = St.Settings.get(); + let duration = Math.min(LG_ANIMATION_TIME / settings.slow_down_factor, + LG_ANIMATION_TIME); + this.ease({ + y: this._hiddenY, + duration, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this.hide(), + }); + } + + get isOpen() { + return this._open; + } +}); diff --git a/js/ui/magnifier.js b/js/ui/magnifier.js new file mode 100644 index 0000000..3a778b6 --- /dev/null +++ b/js/ui/magnifier.js @@ -0,0 +1,1989 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +const { Atspi, Clutter, GDesktopEnums, + Gio, GLib, GObject, Meta, Shell, St } = imports.gi; +const Signals = imports.signals; + +const Background = imports.ui.background; +const FocusCaretTracker = imports.ui.focusCaretTracker; +const Main = imports.ui.main; +const MagnifierDBus = imports.ui.magnifierDBus; +const Params = imports.misc.params; +const PointerWatcher = imports.ui.pointerWatcher; + +var CROSSHAIRS_CLIP_SIZE = [100, 100]; +var NO_CHANGE = 0.0; + +var POINTER_REST_TIME = 1000; // milliseconds + +// Settings +const MAGNIFIER_SCHEMA = 'org.gnome.desktop.a11y.magnifier'; +const SCREEN_POSITION_KEY = 'screen-position'; +const MAG_FACTOR_KEY = 'mag-factor'; +const INVERT_LIGHTNESS_KEY = 'invert-lightness'; +const COLOR_SATURATION_KEY = 'color-saturation'; +const BRIGHT_RED_KEY = 'brightness-red'; +const BRIGHT_GREEN_KEY = 'brightness-green'; +const BRIGHT_BLUE_KEY = 'brightness-blue'; +const CONTRAST_RED_KEY = 'contrast-red'; +const CONTRAST_GREEN_KEY = 'contrast-green'; +const CONTRAST_BLUE_KEY = 'contrast-blue'; +const LENS_MODE_KEY = 'lens-mode'; +const CLAMP_MODE_KEY = 'scroll-at-edges'; +const MOUSE_TRACKING_KEY = 'mouse-tracking'; +const FOCUS_TRACKING_KEY = 'focus-tracking'; +const CARET_TRACKING_KEY = 'caret-tracking'; +const SHOW_CROSS_HAIRS_KEY = 'show-cross-hairs'; +const CROSS_HAIRS_THICKNESS_KEY = 'cross-hairs-thickness'; +const CROSS_HAIRS_COLOR_KEY = 'cross-hairs-color'; +const CROSS_HAIRS_OPACITY_KEY = 'cross-hairs-opacity'; +const CROSS_HAIRS_LENGTH_KEY = 'cross-hairs-length'; +const CROSS_HAIRS_CLIP_KEY = 'cross-hairs-clip'; + +var MouseSpriteContent = GObject.registerClass({ + Implements: [Clutter.Content], +}, class MouseSpriteContent extends GObject.Object { + _init() { + super._init(); + this._texture = null; + } + + vfunc_get_preferred_size() { + if (!this._texture) + return [false, 0, 0]; + + return [true, this._texture.get_width(), this._texture.get_height()]; + } + + vfunc_paint_content(actor, node, _paintContext) { + if (!this._texture) + return; + + let color = Clutter.Color.get_static(Clutter.StaticColor.WHITE); + let [minFilter, magFilter] = actor.get_content_scaling_filters(); + let textureNode = new Clutter.TextureNode(this._texture, + color, minFilter, magFilter); + textureNode.set_name('MouseSpriteContent'); + node.add_child(textureNode); + + textureNode.add_rectangle(actor.get_content_box()); + } + + get texture() { + return this._texture; + } + + set texture(coglTexture) { + if (this._texture == coglTexture) + return; + + let oldTexture = this._texture; + this._texture = coglTexture; + this.invalidate(); + + if (!oldTexture || !coglTexture || + oldTexture.get_width() != coglTexture.get_width() || + oldTexture.get_height() != coglTexture.get_height()) + this.invalidate_size(); + } +}); + +var Magnifier = class Magnifier { + constructor() { + // Magnifier is a manager of ZoomRegions. + this._zoomRegions = []; + + // Create small clutter tree for the magnified mouse. + let cursorTracker = Meta.CursorTracker.get_for_display(global.display); + this._cursorTracker = cursorTracker; + + this._mouseSprite = new Clutter.Actor({ request_mode: Clutter.RequestMode.CONTENT_SIZE }); + this._mouseSprite.content = new MouseSpriteContent(); + + // Create the first ZoomRegion and initialize it according to the + // magnification settings. + + [this.xMouse, this.yMouse] = global.get_pointer(); + + let aZoomRegion = new ZoomRegion(this, this._mouseSprite); + this._zoomRegions.push(aZoomRegion); + this._settingsInit(aZoomRegion); + aZoomRegion.scrollContentsTo(this.xMouse, this.yMouse); + + St.Settings.get().connect('notify::magnifier-active', () => { + this.setActive(St.Settings.get().magnifier_active); + }); + + // Export to dbus. + new MagnifierDBus.ShellMagnifier(); + this.setActive(St.Settings.get().magnifier_active); + } + + /** + * showSystemCursor: + * Show the system mouse pointer. + */ + showSystemCursor() { + const seat = Clutter.get_default_backend().get_default_seat(); + + if (seat.is_unfocus_inhibited()) + seat.uninhibit_unfocus(); + this._cursorTracker.set_pointer_visible(true); + } + + /** + * hideSystemCursor: + * Hide the system mouse pointer. + */ + hideSystemCursor() { + const seat = Clutter.get_default_backend().get_default_seat(); + + if (!seat.is_unfocus_inhibited()) + seat.inhibit_unfocus(); + this._cursorTracker.set_pointer_visible(false); + } + + /** + * setActive: + * Show/hide all the zoom regions. + * @param {bool} activate: Boolean to activate or de-activate the magnifier. + */ + setActive(activate) { + let isActive = this.isActive(); + + this._zoomRegions.forEach(zoomRegion => { + zoomRegion.setActive(activate); + }); + + if (isActive === activate) + return; + + if (activate) { + this._updateMouseSprite(); + this._cursorSpriteChangedId = + this._cursorTracker.connect('cursor-changed', + this._updateMouseSprite.bind(this)); + Meta.disable_unredirect_for_display(global.display); + this.startTrackingMouse(); + } else { + this._cursorTracker.disconnect(this._cursorSpriteChangedId); + this._mouseSprite.content.texture = null; + Meta.enable_unredirect_for_display(global.display); + this.stopTrackingMouse(); + } + + if (this._crossHairs) + this._crossHairs.setEnabled(activate); + + // Make sure system mouse pointer is shown when all zoom regions are + // invisible. + if (!activate) + this.showSystemCursor(); + + // Notify interested parties of this change + this.emit('active-changed', activate); + } + + /** + * isActive: + * @returns {bool} Whether the magnifier is active. + */ + isActive() { + // Sufficient to check one ZoomRegion since Magnifier's active + // state applies to all of them. + if (this._zoomRegions.length == 0) + return false; + else + return this._zoomRegions[0].isActive(); + } + + /** + * startTrackingMouse: + * Turn on mouse tracking, if not already doing so. + */ + startTrackingMouse() { + if (!this._pointerWatch) { + let interval = 1000 / Clutter.get_default_frame_rate(); + this._pointerWatch = PointerWatcher.getPointerWatcher().addWatch(interval, this.scrollToMousePos.bind(this)); + } + } + + /** + * stopTrackingMouse: + * Turn off mouse tracking, if not already doing so. + */ + stopTrackingMouse() { + if (this._pointerWatch) + this._pointerWatch.remove(); + + this._pointerWatch = null; + } + + /** + * isTrackingMouse: + * @returns {bool} whether the magnifier is currently tracking the mouse + */ + isTrackingMouse() { + return !!this._mouseTrackingId; + } + + /** + * scrollToMousePos: + * Position all zoom regions' ROI relative to the current location of the + * system pointer. + * @returns {bool} true. + */ + scrollToMousePos() { + let [xMouse, yMouse] = global.get_pointer(); + + if (xMouse != this.xMouse || yMouse != this.yMouse) { + this.xMouse = xMouse; + this.yMouse = yMouse; + + let sysMouseOverAny = false; + this._zoomRegions.forEach(zoomRegion => { + if (zoomRegion.scrollToMousePos()) + sysMouseOverAny = true; + }); + if (sysMouseOverAny) + this.hideSystemCursor(); + else + this.showSystemCursor(); + } + return true; + } + + /** + * createZoomRegion: + * Create a ZoomRegion instance with the given properties. + * @param {number} xMagFactor: + * The power to set horizontal magnification of the ZoomRegion. A value + * of 1.0 means no magnification, a value of 2.0 doubles the size. + * @param {number} yMagFactor: + * The power to set the vertical magnification of the ZoomRegion. + * @param {{x: number, y: number, width: number, height: number}} roi: + * The reg Object that defines the region to magnify, given in + * unmagnified coordinates. + * @param {{x: number, y: number, width: number, height: number}} viewPort: + * Object that defines the position of the ZoomRegion on screen. + * @returns {ZoomRegion} the newly created ZoomRegion. + */ + createZoomRegion(xMagFactor, yMagFactor, roi, viewPort) { + let zoomRegion = new ZoomRegion(this, this._mouseSprite); + zoomRegion.setViewPort(viewPort); + + // We ignore the redundant width/height on the ROI + let fixedROI = Object.create(roi); + fixedROI.width = viewPort.width / xMagFactor; + fixedROI.height = viewPort.height / yMagFactor; + zoomRegion.setROI(fixedROI); + + zoomRegion.addCrosshairs(this._crossHairs); + return zoomRegion; + } + + /** + * addZoomRegion: + * Append the given ZoomRegion to the list of currently defined ZoomRegions + * for this Magnifier instance. + * @param {ZoomRegion} zoomRegion: The zoomRegion to add. + */ + addZoomRegion(zoomRegion) { + if (zoomRegion) { + this._zoomRegions.push(zoomRegion); + if (!this.isTrackingMouse()) + this.startTrackingMouse(); + } + } + + /** + * getZoomRegions: + * Return a list of ZoomRegion's for this Magnifier. + * @returns {number[]} The Magnifier's zoom region list. + */ + getZoomRegions() { + return this._zoomRegions; + } + + /** + * clearAllZoomRegions: + * Remove all the zoom regions from this Magnfier's ZoomRegion list. + */ + clearAllZoomRegions() { + for (let i = 0; i < this._zoomRegions.length; i++) + this._zoomRegions[i].setActive(false); + + this._zoomRegions.length = 0; + this.stopTrackingMouse(); + this.showSystemCursor(); + } + + /** + * addCrosshairs: + * Add and show a cross hair centered on the magnified mouse. + */ + addCrosshairs() { + if (!this._crossHairs) + this._crossHairs = new Crosshairs(); + + let thickness = this._settings.get_int(CROSS_HAIRS_THICKNESS_KEY); + let color = this._settings.get_string(CROSS_HAIRS_COLOR_KEY); + let opacity = this._settings.get_double(CROSS_HAIRS_OPACITY_KEY); + let length = this._settings.get_int(CROSS_HAIRS_LENGTH_KEY); + let clip = this._settings.get_boolean(CROSS_HAIRS_CLIP_KEY); + + this.setCrosshairsThickness(thickness); + this.setCrosshairsColor(color); + this.setCrosshairsOpacity(opacity); + this.setCrosshairsLength(length); + this.setCrosshairsClip(clip); + + let theCrossHairs = this._crossHairs; + this._zoomRegions.forEach(zoomRegion => { + zoomRegion.addCrosshairs(theCrossHairs); + }); + } + + /** + * setCrosshairsVisible: + * Show or hide the cross hair. + * @param {bool} visible: Flag that indicates show (true) or hide (false). + */ + setCrosshairsVisible(visible) { + if (visible) { + if (!this._crossHairs) + this.addCrosshairs(); + this._crossHairs.show(); + } else { + // eslint-disable-next-line no-lonely-if + if (this._crossHairs) + this._crossHairs.hide(); + } + } + + /** + * setCrosshairsColor: + * Set the color of the crosshairs for all ZoomRegions. + * @param {string} color: The color as a string, e.g. '#ff0000ff' or 'red'. + */ + setCrosshairsColor(color) { + if (this._crossHairs) { + let [res_, clutterColor] = Clutter.Color.from_string(color); + this._crossHairs.setColor(clutterColor); + } + } + + /** + * getCrosshairsColor: + * Get the color of the crosshairs. + * @returns {string} The color as a string, e.g. '#0000ffff' or 'blue'. + */ + getCrosshairsColor() { + if (this._crossHairs) { + let clutterColor = this._crossHairs.getColor(); + return clutterColor.to_string(); + } else { + return '#00000000'; + } + } + + /** + * setCrosshairsThickness: + * Set the crosshairs thickness for all ZoomRegions. + * @param {number} thickness: The width of the vertical and + * horizontal lines of the crosshairs. + */ + setCrosshairsThickness(thickness) { + if (this._crossHairs) + this._crossHairs.setThickness(thickness); + } + + /** + * getCrosshairsThickness: + * Get the crosshairs thickness. + * @returns {number} The width of the vertical and horizontal + * lines of the crosshairs. + */ + getCrosshairsThickness() { + if (this._crossHairs) + return this._crossHairs.getThickness(); + else + return 0; + } + + /** + * setCrosshairsOpacity: + * @param {number} opacity: Value between 0.0 (transparent) + * and 1.0 (fully opaque). + */ + setCrosshairsOpacity(opacity) { + if (this._crossHairs) + this._crossHairs.setOpacity(opacity * 255); + } + + /** + * getCrosshairsOpacity: + * @returns {number} Value between 0.0 (transparent) and 1.0 (fully opaque). + */ + getCrosshairsOpacity() { + if (this._crossHairs) + return this._crossHairs.getOpacity() / 255.0; + else + return 0.0; + } + + /** + * setCrosshairsLength: + * Set the crosshairs length for all ZoomRegions. + * @param {number} length: The length of the vertical and horizontal + * lines making up the crosshairs. + */ + setCrosshairsLength(length) { + if (this._crossHairs) { + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + this._crossHairs.setLength(length / scaleFactor); + } + } + + /** + * getCrosshairsLength: + * Get the crosshairs length. + * @returns {number} The length of the vertical and horizontal + * lines making up the crosshairs. + */ + getCrosshairsLength() { + if (this._crossHairs) + return this._crossHairs.getLength(); + else + return 0; + } + + /** + * setCrosshairsClip: + * Set whether the crosshairs are clipped at their intersection. + * @param {bool} clip: Flag to indicate whether to clip the crosshairs. + */ + setCrosshairsClip(clip) { + if (!this._crossHairs) + return; + + // Setting no clipping on crosshairs means a zero sized clip rectangle. + this._crossHairs.setClip(clip ? CROSSHAIRS_CLIP_SIZE : [0, 0]); + } + + /** + * getCrosshairsClip: + * Get whether the crosshairs are clipped by the mouse image. + * @returns {bool} Whether the crosshairs are clipped. + */ + getCrosshairsClip() { + if (this._crossHairs) { + let [clipWidth, clipHeight] = this._crossHairs.getClip(); + return clipWidth > 0 && clipHeight > 0; + } else { + return false; + } + } + + // Private methods // + + _updateMouseSprite() { + this._updateSpriteTexture(); + let [xHot, yHot] = this._cursorTracker.get_hot(); + this._mouseSprite.set({ + translation_x: -xHot, + translation_y: -yHot, + }); + } + + _updateSpriteTexture() { + let sprite = this._cursorTracker.get_sprite(); + + if (sprite) { + this._mouseSprite.content.texture = sprite; + this._mouseSprite.show(); + } else { + this._mouseSprite.hide(); + } + } + + _settingsInit(zoomRegion) { + this._settings = new Gio.Settings({ schema_id: MAGNIFIER_SCHEMA }); + + this._settings.connect(`changed::${SCREEN_POSITION_KEY}`, + this._updateScreenPosition.bind(this)); + this._settings.connect(`changed::${MAG_FACTOR_KEY}`, + this._updateMagFactor.bind(this)); + this._settings.connect(`changed::${LENS_MODE_KEY}`, + this._updateLensMode.bind(this)); + this._settings.connect(`changed::${CLAMP_MODE_KEY}`, + this._updateClampMode.bind(this)); + this._settings.connect(`changed::${MOUSE_TRACKING_KEY}`, + this._updateMouseTrackingMode.bind(this)); + this._settings.connect(`changed::${FOCUS_TRACKING_KEY}`, + this._updateFocusTrackingMode.bind(this)); + this._settings.connect(`changed::${CARET_TRACKING_KEY}`, + this._updateCaretTrackingMode.bind(this)); + + this._settings.connect(`changed::${INVERT_LIGHTNESS_KEY}`, + this._updateInvertLightness.bind(this)); + this._settings.connect(`changed::${COLOR_SATURATION_KEY}`, + this._updateColorSaturation.bind(this)); + + this._settings.connect(`changed::${BRIGHT_RED_KEY}`, + this._updateBrightness.bind(this)); + this._settings.connect(`changed::${BRIGHT_GREEN_KEY}`, + this._updateBrightness.bind(this)); + this._settings.connect(`changed::${BRIGHT_BLUE_KEY}`, + this._updateBrightness.bind(this)); + + this._settings.connect(`changed::${CONTRAST_RED_KEY}`, + this._updateContrast.bind(this)); + this._settings.connect(`changed::${CONTRAST_GREEN_KEY}`, + this._updateContrast.bind(this)); + this._settings.connect(`changed::${CONTRAST_BLUE_KEY}`, + this._updateContrast.bind(this)); + + this._settings.connect(`changed::${SHOW_CROSS_HAIRS_KEY}`, () => { + this.setCrosshairsVisible(this._settings.get_boolean(SHOW_CROSS_HAIRS_KEY)); + }); + + this._settings.connect(`changed::${CROSS_HAIRS_THICKNESS_KEY}`, () => { + this.setCrosshairsThickness(this._settings.get_int(CROSS_HAIRS_THICKNESS_KEY)); + }); + + this._settings.connect(`changed::${CROSS_HAIRS_COLOR_KEY}`, () => { + this.setCrosshairsColor(this._settings.get_string(CROSS_HAIRS_COLOR_KEY)); + }); + + this._settings.connect(`changed::${CROSS_HAIRS_OPACITY_KEY}`, () => { + this.setCrosshairsOpacity(this._settings.get_double(CROSS_HAIRS_OPACITY_KEY)); + }); + + this._settings.connect(`changed::${CROSS_HAIRS_LENGTH_KEY}`, () => { + this.setCrosshairsLength(this._settings.get_int(CROSS_HAIRS_LENGTH_KEY)); + }); + + this._settings.connect(`changed::${CROSS_HAIRS_CLIP_KEY}`, () => { + this.setCrosshairsClip(this._settings.get_boolean(CROSS_HAIRS_CLIP_KEY)); + }); + + if (zoomRegion) { + // Mag factor is accurate to two decimal places. + let aPref = parseFloat(this._settings.get_double(MAG_FACTOR_KEY).toFixed(2)); + if (aPref != 0.0) + zoomRegion.setMagFactor(aPref, aPref); + + aPref = this._settings.get_enum(SCREEN_POSITION_KEY); + if (aPref) + zoomRegion.setScreenPosition(aPref); + + zoomRegion.setLensMode(this._settings.get_boolean(LENS_MODE_KEY)); + zoomRegion.setClampScrollingAtEdges(!this._settings.get_boolean(CLAMP_MODE_KEY)); + + aPref = this._settings.get_enum(MOUSE_TRACKING_KEY); + if (aPref) + zoomRegion.setMouseTrackingMode(aPref); + + aPref = this._settings.get_enum(FOCUS_TRACKING_KEY); + if (aPref) + zoomRegion.setFocusTrackingMode(aPref); + + aPref = this._settings.get_enum(CARET_TRACKING_KEY); + if (aPref) + zoomRegion.setCaretTrackingMode(aPref); + + aPref = this._settings.get_boolean(INVERT_LIGHTNESS_KEY); + if (aPref) + zoomRegion.setInvertLightness(aPref); + + aPref = this._settings.get_double(COLOR_SATURATION_KEY); + if (aPref) + zoomRegion.setColorSaturation(aPref); + + let bc = {}; + bc.r = this._settings.get_double(BRIGHT_RED_KEY); + bc.g = this._settings.get_double(BRIGHT_GREEN_KEY); + bc.b = this._settings.get_double(BRIGHT_BLUE_KEY); + zoomRegion.setBrightness(bc); + + bc.r = this._settings.get_double(CONTRAST_RED_KEY); + bc.g = this._settings.get_double(CONTRAST_GREEN_KEY); + bc.b = this._settings.get_double(CONTRAST_BLUE_KEY); + zoomRegion.setContrast(bc); + } + + let showCrosshairs = this._settings.get_boolean(SHOW_CROSS_HAIRS_KEY); + this.addCrosshairs(); + this.setCrosshairsVisible(showCrosshairs); + } + + _updateScreenPosition() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + let position = this._settings.get_enum(SCREEN_POSITION_KEY); + this._zoomRegions[0].setScreenPosition(position); + if (position != GDesktopEnums.MagnifierScreenPosition.FULL_SCREEN) + this._updateLensMode(); + } + } + + _updateMagFactor() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + // Mag factor is accurate to two decimal places. + let magFactor = parseFloat(this._settings.get_double(MAG_FACTOR_KEY).toFixed(2)); + this._zoomRegions[0].setMagFactor(magFactor, magFactor); + } + } + + _updateLensMode() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) + this._zoomRegions[0].setLensMode(this._settings.get_boolean(LENS_MODE_KEY)); + } + + _updateClampMode() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + this._zoomRegions[0].setClampScrollingAtEdges( + !this._settings.get_boolean(CLAMP_MODE_KEY)); + } + } + + _updateMouseTrackingMode() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + this._zoomRegions[0].setMouseTrackingMode( + this._settings.get_enum(MOUSE_TRACKING_KEY)); + } + } + + _updateFocusTrackingMode() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + this._zoomRegions[0].setFocusTrackingMode( + this._settings.get_enum(FOCUS_TRACKING_KEY)); + } + } + + _updateCaretTrackingMode() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + this._zoomRegions[0].setCaretTrackingMode( + this._settings.get_enum(CARET_TRACKING_KEY)); + } + } + + _updateInvertLightness() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + this._zoomRegions[0].setInvertLightness( + this._settings.get_boolean(INVERT_LIGHTNESS_KEY)); + } + } + + _updateColorSaturation() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + this._zoomRegions[0].setColorSaturation( + this._settings.get_double(COLOR_SATURATION_KEY)); + } + } + + _updateBrightness() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + let brightness = {}; + brightness.r = this._settings.get_double(BRIGHT_RED_KEY); + brightness.g = this._settings.get_double(BRIGHT_GREEN_KEY); + brightness.b = this._settings.get_double(BRIGHT_BLUE_KEY); + this._zoomRegions[0].setBrightness(brightness); + } + } + + _updateContrast() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + let contrast = {}; + contrast.r = this._settings.get_double(CONTRAST_RED_KEY); + contrast.g = this._settings.get_double(CONTRAST_GREEN_KEY); + contrast.b = this._settings.get_double(CONTRAST_BLUE_KEY); + this._zoomRegions[0].setContrast(contrast); + } + } +}; +Signals.addSignalMethods(Magnifier.prototype); + +var ZoomRegion = class ZoomRegion { + constructor(magnifier, mouseSourceActor) { + this._magnifier = magnifier; + this._focusCaretTracker = new FocusCaretTracker.FocusCaretTracker(); + + this._mouseTrackingMode = GDesktopEnums.MagnifierMouseTrackingMode.NONE; + this._focusTrackingMode = GDesktopEnums.MagnifierFocusTrackingMode.NONE; + this._caretTrackingMode = GDesktopEnums.MagnifierCaretTrackingMode.NONE; + this._clampScrollingAtEdges = false; + this._lensMode = false; + this._screenPosition = GDesktopEnums.MagnifierScreenPosition.FULL_SCREEN; + this._invertLightness = false; + this._colorSaturation = 1.0; + this._brightness = { r: NO_CHANGE, g: NO_CHANGE, b: NO_CHANGE }; + this._contrast = { r: NO_CHANGE, g: NO_CHANGE, b: NO_CHANGE }; + + this._magView = null; + this._background = null; + this._uiGroupClone = null; + this._mouseSourceActor = mouseSourceActor; + this._mouseActor = null; + this._crossHairs = null; + this._crossHairsActor = null; + + this._viewPortX = 0; + this._viewPortY = 0; + this._viewPortWidth = global.screen_width; + this._viewPortHeight = global.screen_height; + this._xCenter = this._viewPortWidth / 2; + this._yCenter = this._viewPortHeight / 2; + this._xMagFactor = 1; + this._yMagFactor = 1; + this._followingCursor = false; + this._xFocus = 0; + this._yFocus = 0; + this._xCaret = 0; + this._yCaret = 0; + + this._pointerIdleMonitor = Meta.IdleMonitor.get_core(); + this._scrollContentsTimerId = 0; + } + + _connectSignals() { + if (this._signalConnections) + return; + + this._signalConnections = []; + let id = Main.layoutManager.connect('monitors-changed', + this._monitorsChanged.bind(this)); + this._signalConnections.push([Main.layoutManager, id]); + + id = this._focusCaretTracker.connect('caret-moved', this._updateCaret.bind(this)); + this._signalConnections.push([this._focusCaretTracker, id]); + + id = this._focusCaretTracker.connect('focus-changed', this._updateFocus.bind(this)); + this._signalConnections.push([this._focusCaretTracker, id]); + } + + _disconnectSignals() { + for (let [obj, id] of this._signalConnections) + obj.disconnect(id); + + delete this._signalConnections; + } + + _updateScreenPosition() { + if (this._screenPosition == GDesktopEnums.MagnifierScreenPosition.NONE) { + this._setViewPort({ + x: this._viewPortX, + y: this._viewPortY, + width: this._viewPortWidth, + height: this._viewPortHeight, + }); + } else { + this.setScreenPosition(this._screenPosition); + } + } + + _updateFocus(caller, event) { + let component = event.source.get_component_iface(); + if (!component || event.detail1 != 1) + return; + let extents; + try { + extents = component.get_extents(Atspi.CoordType.SCREEN); + } catch (e) { + log(`Failed to read extents of focused component: ${e.message}`); + return; + } + + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + let [xFocus, yFocus] = [(extents.x + (extents.width / 2)) * scaleFactor, + (extents.y + (extents.height / 2)) * scaleFactor]; + + if (this._xFocus !== xFocus || this._yFocus !== yFocus) { + [this._xFocus, this._yFocus] = [xFocus, yFocus]; + this._centerFromFocusPosition(); + } + } + + _updateCaret(caller, event) { + let text = event.source.get_text_iface(); + if (!text) + return; + let extents; + try { + extents = text.get_character_extents(text.get_caret_offset(), 0); + } catch (e) { + log(`Failed to read extents of text caret: ${e.message}`); + return; + } + + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + let [xCaret, yCaret] = [extents.x * scaleFactor, extents.y * scaleFactor]; + + // Ignore event(s) if the caret size is none (0x0). This happens a lot if + // the cursor offset can't be translated into a location. This is a work + // around. + if (extents.width === 0 && extents.height === 0) + return; + + if (this._xCaret !== xCaret || this._yCaret !== yCaret) { + [this._xCaret, this._yCaret] = [xCaret, yCaret]; + this._centerFromCaretPosition(); + } + } + + /** + * setActive: + * @param {bool} activate: Boolean to show/hide the ZoomRegion. + */ + setActive(activate) { + if (activate == this.isActive()) + return; + + if (activate) { + this._createActors(); + if (this._isMouseOverRegion()) + this._magnifier.hideSystemCursor(); + this._updateScreenPosition(); + this._updateMagViewGeometry(); + this._updateCloneGeometry(); + this._updateMousePosition(); + this._connectSignals(); + } else { + this._disconnectSignals(); + this._destroyActors(); + } + + this._syncCaretTracking(); + this._syncFocusTracking(); + } + + /** + * isActive: + * @returns {bool} Whether this ZoomRegion is active + */ + isActive() { + return this._magView != null; + } + + /** + * setMagFactor: + * @param {number} xMagFactor: The power to set the horizontal + * magnification factor to of the magnified view. A value of 1.0 + * means no magnification. A value of 2.0 doubles the size. + * @param {number} yMagFactor: The power to set the vertical + * magnification factor to of the magnified view. + */ + setMagFactor(xMagFactor, yMagFactor) { + this._changeROI({ xMagFactor, + yMagFactor, + redoCursorTracking: this._followingCursor, + animate: true }); + } + + /** + * getMagFactor: + * @returns {number[]} an array, [xMagFactor, yMagFactor], containing + * the horizontal and vertical magnification powers. A value of + * 1.0 means no magnification. A value of 2.0 means the contents + * are doubled in size, and so on. + */ + getMagFactor() { + return [this._xMagFactor, this._yMagFactor]; + } + + /** + * setMouseTrackingMode + * @param {GDesktopEnums.MagnifierMouseTrackingMode} mode: the new mode + */ + setMouseTrackingMode(mode) { + if (mode >= GDesktopEnums.MagnifierMouseTrackingMode.NONE && + mode <= GDesktopEnums.MagnifierMouseTrackingMode.PUSH) + this._mouseTrackingMode = mode; + } + + /** + * getMouseTrackingMode + * @returns {GDesktopEnums.MagnifierMouseTrackingMode} the current mode + */ + getMouseTrackingMode() { + return this._mouseTrackingMode; + } + + /** + * setFocusTrackingMode + * @param {GDesktopEnums.MagnifierFocusTrackingMode} mode: the new mode + */ + setFocusTrackingMode(mode) { + this._focusTrackingMode = mode; + this._syncFocusTracking(); + } + + /** + * setCaretTrackingMode + * @param {GDesktopEnums.MagnifierCaretTrackingMode} mode: the new mode + */ + setCaretTrackingMode(mode) { + this._caretTrackingMode = mode; + this._syncCaretTracking(); + } + + _syncFocusTracking() { + let enabled = this._focusTrackingMode != GDesktopEnums.MagnifierFocusTrackingMode.NONE && + this.isActive(); + + if (enabled) + this._focusCaretTracker.registerFocusListener(); + else + this._focusCaretTracker.deregisterFocusListener(); + } + + _syncCaretTracking() { + let enabled = this._caretTrackingMode != GDesktopEnums.MagnifierCaretTrackingMode.NONE && + this.isActive(); + + if (enabled) + this._focusCaretTracker.registerCaretListener(); + else + this._focusCaretTracker.deregisterCaretListener(); + } + + /** + * setViewPort + * Sets the position and size of the ZoomRegion on screen. + * @param {{x: number, y: number, width: number, height: number}} viewPort: + * Object defining the position and size of the view port. + * The values are in stage coordinate space. + */ + setViewPort(viewPort) { + this._setViewPort(viewPort); + this._screenPosition = GDesktopEnums.MagnifierScreenPosition.NONE; + } + + /** + * setROI + * Sets the "region of interest" that the ZoomRegion is magnifying. + * @param {{x: number, y: number, width: number, height: number}} roi: + * Object that defines the region of the screen to magnify. + * The values are in screen (unmagnified) coordinate space. + */ + setROI(roi) { + if (roi.width <= 0 || roi.height <= 0) + return; + + this._followingCursor = false; + this._changeROI({ xMagFactor: this._viewPortWidth / roi.width, + yMagFactor: this._viewPortHeight / roi.height, + xCenter: roi.x + roi.width / 2, + yCenter: roi.y + roi.height / 2 }); + } + + /** + * getROI: + * Retrieves the "region of interest" -- the rectangular bounds of that part + * of the desktop that the magnified view is showing (x, y, width, height). + * The bounds are given in non-magnified coordinates. + * @returns {number[]} an array, [x, y, width, height], representing + * the bounding rectangle of what is shown in the magnified view. + */ + getROI() { + let roiWidth = this._viewPortWidth / this._xMagFactor; + let roiHeight = this._viewPortHeight / this._yMagFactor; + + return [this._xCenter - roiWidth / 2, + this._yCenter - roiHeight / 2, + roiWidth, roiHeight]; + } + + /** + * setLensMode: + * Turn lens mode on/off. In full screen mode, lens mode does nothing since + * a lens the size of the screen is pointless. + * @param {bool} lensMode: Whether lensMode should be active + */ + setLensMode(lensMode) { + this._lensMode = lensMode; + if (!this._lensMode) + this.setScreenPosition(this._screenPosition); + } + + /** + * isLensMode: + * Is lens mode on or off? + * @returns {bool} The lens mode state. + */ + isLensMode() { + return this._lensMode; + } + + /** + * setClampScrollingAtEdges: + * Stop vs. allow scrolling of the magnified contents when it scroll beyond + * the edges of the screen. + * @param {bool} clamp: Boolean to turn on/off clamping. + */ + setClampScrollingAtEdges(clamp) { + this._clampScrollingAtEdges = clamp; + if (clamp) + this._changeROI(); + } + + /** + * setTopHalf: + * Magnifier view occupies the top half of the screen. + */ + setTopHalf() { + let viewPort = {}; + viewPort.x = 0; + viewPort.y = 0; + viewPort.width = global.screen_width; + viewPort.height = global.screen_height / 2; + this._setViewPort(viewPort); + this._screenPosition = GDesktopEnums.MagnifierScreenPosition.TOP_HALF; + } + + /** + * setBottomHalf: + * Magnifier view occupies the bottom half of the screen. + */ + setBottomHalf() { + let viewPort = {}; + viewPort.x = 0; + viewPort.y = global.screen_height / 2; + viewPort.width = global.screen_width; + viewPort.height = global.screen_height / 2; + this._setViewPort(viewPort); + this._screenPosition = GDesktopEnums.MagnifierScreenPosition.BOTTOM_HALF; + } + + /** + * setLeftHalf: + * Magnifier view occupies the left half of the screen. + */ + setLeftHalf() { + let viewPort = {}; + viewPort.x = 0; + viewPort.y = 0; + viewPort.width = global.screen_width / 2; + viewPort.height = global.screen_height; + this._setViewPort(viewPort); + this._screenPosition = GDesktopEnums.MagnifierScreenPosition.LEFT_HALF; + } + + /** + * setRightHalf: + * Magnifier view occupies the right half of the screen. + */ + setRightHalf() { + let viewPort = {}; + viewPort.x = global.screen_width / 2; + viewPort.y = 0; + viewPort.width = global.screen_width / 2; + viewPort.height = global.screen_height; + this._setViewPort(viewPort); + this._screenPosition = GDesktopEnums.MagnifierScreenPosition.RIGHT_HALF; + } + + /** + * setFullScreenMode: + * Set the ZoomRegion to full-screen mode. + * Note: disallows lens mode. + */ + setFullScreenMode() { + let viewPort = {}; + viewPort.x = 0; + viewPort.y = 0; + viewPort.width = global.screen_width; + viewPort.height = global.screen_height; + this.setViewPort(viewPort); + + this._screenPosition = GDesktopEnums.MagnifierScreenPosition.FULL_SCREEN; + } + + /** + * setScreenPosition: + * Positions the zoom region to one of the enumerated positions on the + * screen. + * @param {GDesktopEnums.MagnifierScreenPosition} inPosition: the position + */ + setScreenPosition(inPosition) { + switch (inPosition) { + case GDesktopEnums.MagnifierScreenPosition.FULL_SCREEN: + this.setFullScreenMode(); + break; + case GDesktopEnums.MagnifierScreenPosition.TOP_HALF: + this.setTopHalf(); + break; + case GDesktopEnums.MagnifierScreenPosition.BOTTOM_HALF: + this.setBottomHalf(); + break; + case GDesktopEnums.MagnifierScreenPosition.LEFT_HALF: + this.setLeftHalf(); + break; + case GDesktopEnums.MagnifierScreenPosition.RIGHT_HALF: + this.setRightHalf(); + break; + } + } + + /** + * getScreenPosition: + * Tell the outside world what the current mode is -- magnifiying the + * top half, bottom half, etc. + * @returns {GDesktopEnums.MagnifierScreenPosition}: the current position. + */ + getScreenPosition() { + return this._screenPosition; + } + + _clearScrollContentsTimer() { + if (this._scrollContentsTimerId !== 0) { + GLib.source_remove(this._scrollContentsTimerId); + this._scrollContentsTimerId = 0; + } + } + + /** + * scrollToMousePos: + * Set the region of interest based on the position of the system pointer. + * @returns {bool}: Whether the system mouse pointer is over the + * magnified view. + */ + scrollToMousePos() { + this._followingCursor = true; + if (this._mouseTrackingMode != GDesktopEnums.MagnifierMouseTrackingMode.NONE) + this._changeROI({ redoCursorTracking: true }); + else + this._updateMousePosition(); + + this._clearScrollContentsTimer(); + this._scrollContentsTimerId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, POINTER_REST_TIME, () => { + this._followingCursor = false; + if (this._xDelayed !== null && this._yDelayed !== null) { + this._scrollContentsToDelayed(this._xDelayed, this._yDelayed); + this._xDelayed = null; + this._yDelayed = null; + } + + this._scrollContentsTimerId = 0; + + return GLib.SOURCE_REMOVE; + }); + + // Determine whether the system mouse pointer is over this zoom region. + return this._isMouseOverRegion(); + } + + _scrollContentsToDelayed(x, y) { + if (this._followingCursor) { + this._xDelayed = x; + this._yDelayed = y; + } else { + this.scrollContentsTo(x, y); + } + } + + /** + * scrollContentsTo: + * Shift the contents of the magnified view such it is centered on the given + * coordinate. + * @param {number} x: The x-coord of the point to center on. + * @param {number} y: The y-coord of the point to center on. + */ + scrollContentsTo(x, y) { + if (x < 0 || x > global.screen_width || + y < 0 || y > global.screen_height) + return; + + this._clearScrollContentsTimer(); + + this._followingCursor = false; + this._changeROI({ xCenter: x, + yCenter: y, + animate: true }); + } + + /** + * addCrosshairs: + * Add crosshairs centered on the magnified mouse. + * @param {Crosshairs} crossHairs: Crosshairs instance + */ + addCrosshairs(crossHairs) { + this._crossHairs = crossHairs; + + // If the crossHairs is not already within a larger container, add it + // to this zoom region. Otherwise, add a clone. + if (crossHairs && this.isActive()) + this._crossHairsActor = crossHairs.addToZoomRegion(this, this._mouseActor); + } + + /** + * setInvertLightness: + * Set whether to invert the lightness of the magnified view. + * @param {bool} flag: whether brightness should be inverted + */ + setInvertLightness(flag) { + this._invertLightness = flag; + if (this._magShaderEffects) + this._magShaderEffects.setInvertLightness(this._invertLightness); + } + + /** + * getInvertLightness: + * Retrieve whether the lightness is inverted. + * @returns {bool} whether brightness should be inverted + */ + getInvertLightness() { + return this._invertLightness; + } + + /** + * setColorSaturation: + * Set the color saturation of the magnified view. + * @param {number} saturation: A value from 0.0 to 1.0 that defines + * the color saturation, with 0.0 defining no color (grayscale), + * and 1.0 defining full color. + */ + setColorSaturation(saturation) { + this._colorSaturation = saturation; + if (this._magShaderEffects) + this._magShaderEffects.setColorSaturation(this._colorSaturation); + } + + /** + * getColorSaturation: + * Retrieve the color saturation of the magnified view. + * @returns {number} the color saturation + */ + getColorSaturation() { + return this._colorSaturation; + } + + /** + * setBrightness: + * Alter the brightness of the magnified view. + * @param {Object} brightness: Object containing the contrast for the + * red, green, and blue channels. Values of 0.0 represent "standard" + * brightness (no change), whereas values less or greater than + * 0.0 indicate decreased or incresaed brightness, respectively. + * + * {number} brightness.r - the red component + * {number} brightness.g - the green component + * {number} brightness.b - the blue component + */ + setBrightness(brightness) { + this._brightness.r = brightness.r; + this._brightness.g = brightness.g; + this._brightness.b = brightness.b; + if (this._magShaderEffects) + this._magShaderEffects.setBrightness(this._brightness); + } + + /** + * setContrast: + * Alter the contrast of the magnified view. + * @param {Object} contrast: Object containing the contrast for the + * red, green, and blue channels. Values of 0.0 represent "standard" + * contrast (no change), whereas values less or greater than + * 0.0 indicate decreased or incresaed contrast, respectively. + * + * {number} contrast.r - the red component + * {number} contrast.g - the green component + * {number} contrast.b - the blue component + */ + setContrast(contrast) { + this._contrast.r = contrast.r; + this._contrast.g = contrast.g; + this._contrast.b = contrast.b; + if (this._magShaderEffects) + this._magShaderEffects.setContrast(this._contrast); + } + + /** + * getContrast: + * Retrieve the contrast of the magnified view. + * @returns {{r: number, g: number, b: number}}: Object containing + * the contrast for the red, green, and blue channels. + */ + getContrast() { + let contrast = {}; + contrast.r = this._contrast.r; + contrast.g = this._contrast.g; + contrast.b = this._contrast.b; + return contrast; + } + + // Private methods // + + _createActors() { + // The root actor for the zoom region + this._magView = new St.Bin({ style_class: 'magnifier-zoom-region' }); + global.stage.add_actor(this._magView); + + // hide the magnified region from CLUTTER_PICK_ALL + Shell.util_set_hidden_from_pick(this._magView, true); + + // Add a group to clip the contents of the magnified view. + let mainGroup = new Clutter.Actor({ clip_to_allocation: true }); + this._magView.set_child(mainGroup); + + // Add a background for when the magnified uiGroup is scrolled + // out of view (don't want to see desktop showing through). + this._background = new Background.SystemBackground(); + mainGroup.add_actor(this._background); + + // Clone the group that contains all of UI on the screen. This is the + // chrome, the windows, etc. + this._uiGroupClone = new Clutter.Clone({ source: Main.uiGroup, + clip_to_allocation: true }); + mainGroup.add_actor(this._uiGroupClone); + + // Add either the given mouseSourceActor to the ZoomRegion, or a clone of + // it. + if (this._mouseSourceActor.get_parent() != null) + this._mouseActor = new Clutter.Clone({ source: this._mouseSourceActor }); + else + this._mouseActor = this._mouseSourceActor; + mainGroup.add_actor(this._mouseActor); + + if (this._crossHairs) + this._crossHairsActor = this._crossHairs.addToZoomRegion(this, this._mouseActor); + else + this._crossHairsActor = null; + + // Contrast and brightness effects. + this._magShaderEffects = new MagShaderEffects(mainGroup); + this._magShaderEffects.setColorSaturation(this._colorSaturation); + this._magShaderEffects.setInvertLightness(this._invertLightness); + this._magShaderEffects.setBrightness(this._brightness); + this._magShaderEffects.setContrast(this._contrast); + } + + _destroyActors() { + if (this._mouseActor == this._mouseSourceActor) + this._mouseActor.get_parent().remove_actor(this._mouseActor); + if (this._crossHairs) + this._crossHairs.removeFromParent(this._crossHairsActor); + + this._magShaderEffects.destroyEffects(); + this._magShaderEffects = null; + this._magView.destroy(); + this._magView = null; + this._background = null; + this._uiGroupClone = null; + this._mouseActor = null; + this._crossHairsActor = null; + } + + _setViewPort(viewPort, fromROIUpdate) { + // Sets the position of the zoom region on the screen + + let width = Math.round(Math.min(viewPort.width, global.screen_width)); + let height = Math.round(Math.min(viewPort.height, global.screen_height)); + let x = Math.max(viewPort.x, 0); + let y = Math.max(viewPort.y, 0); + + x = Math.round(Math.min(x, global.screen_width - width)); + y = Math.round(Math.min(y, global.screen_height - height)); + + this._viewPortX = x; + this._viewPortY = y; + this._viewPortWidth = width; + this._viewPortHeight = height; + + this._updateMagViewGeometry(); + + if (!fromROIUpdate) + this._changeROI({ redoCursorTracking: this._followingCursor }); // will update mouse + + if (this.isActive() && this._isMouseOverRegion()) + this._magnifier.hideSystemCursor(); + } + + _changeROI(params) { + // Updates the area we are viewing; the magnification factors + // and center can be set explicitly, or we can recompute + // the position based on the mouse cursor position + + params = Params.parse(params, { xMagFactor: this._xMagFactor, + yMagFactor: this._yMagFactor, + xCenter: this._xCenter, + yCenter: this._yCenter, + redoCursorTracking: false, + animate: false }); + + if (params.xMagFactor <= 0) + params.xMagFactor = this._xMagFactor; + if (params.yMagFactor <= 0) + params.yMagFactor = this._yMagFactor; + + this._xMagFactor = params.xMagFactor; + this._yMagFactor = params.yMagFactor; + + if (params.redoCursorTracking && + this._mouseTrackingMode != GDesktopEnums.MagnifierMouseTrackingMode.NONE) { + // This depends on this.xMagFactor/yMagFactor already being updated + [params.xCenter, params.yCenter] = this._centerFromMousePosition(); + } + + if (this._clampScrollingAtEdges) { + let roiWidth = this._viewPortWidth / this._xMagFactor; + let roiHeight = this._viewPortHeight / this._yMagFactor; + + params.xCenter = Math.min(params.xCenter, global.screen_width - roiWidth / 2); + params.xCenter = Math.max(params.xCenter, roiWidth / 2); + params.yCenter = Math.min(params.yCenter, global.screen_height - roiHeight / 2); + params.yCenter = Math.max(params.yCenter, roiHeight / 2); + } + + this._xCenter = params.xCenter; + this._yCenter = params.yCenter; + + // If in lens mode, move the magnified view such that it is centered + // over the actual mouse. However, in full screen mode, the "lens" is + // the size of the screen -- pointless to move such a large lens around. + if (this._lensMode && !this._isFullScreen()) { + this._setViewPort({ x: this._xCenter - this._viewPortWidth / 2, + y: this._yCenter - this._viewPortHeight / 2, + width: this._viewPortWidth, + height: this._viewPortHeight }, true); + } + + this._updateCloneGeometry(params.animate); + } + + _isMouseOverRegion() { + // Return whether the system mouse sprite is over this ZoomRegion. If the + // mouse's position is not given, then it is fetched. + let mouseIsOver = false; + if (this.isActive()) { + let xMouse = this._magnifier.xMouse; + let yMouse = this._magnifier.yMouse; + + mouseIsOver = + xMouse >= this._viewPortX && xMouse < (this._viewPortX + this._viewPortWidth) && + yMouse >= this._viewPortY && yMouse < (this._viewPortY + this._viewPortHeight); + } + return mouseIsOver; + } + + _isFullScreen() { + // Does the magnified view occupy the whole screen? Note that this + // doesn't necessarily imply + // this._screenPosition = GDesktopEnums.MagnifierScreenPosition.FULL_SCREEN; + + if (this._viewPortX != 0 || this._viewPortY != 0) + return false; + if (this._viewPortWidth != global.screen_width || + this._viewPortHeight != global.screen_height) + return false; + return true; + } + + _centerFromMousePosition() { + // Determines where the center should be given the current cursor + // position and mouse tracking mode + + let xMouse = this._magnifier.xMouse; + let yMouse = this._magnifier.yMouse; + + if (this._mouseTrackingMode == GDesktopEnums.MagnifierMouseTrackingMode.PROPORTIONAL) + return this._centerFromPointProportional(xMouse, yMouse); + else if (this._mouseTrackingMode == GDesktopEnums.MagnifierMouseTrackingMode.PUSH) + return this._centerFromPointPush(xMouse, yMouse); + else if (this._mouseTrackingMode == GDesktopEnums.MagnifierMouseTrackingMode.CENTERED) + return this._centerFromPointCentered(xMouse, yMouse); + + return null; // Should never be hit + } + + _centerFromCaretPosition() { + let xCaret = this._xCaret; + let yCaret = this._yCaret; + + if (this._caretTrackingMode == GDesktopEnums.MagnifierCaretTrackingMode.PROPORTIONAL) + [xCaret, yCaret] = this._centerFromPointProportional(xCaret, yCaret); + else if (this._caretTrackingMode == GDesktopEnums.MagnifierCaretTrackingMode.PUSH) + [xCaret, yCaret] = this._centerFromPointPush(xCaret, yCaret); + else if (this._caretTrackingMode == GDesktopEnums.MagnifierCaretTrackingMode.CENTERED) + [xCaret, yCaret] = this._centerFromPointCentered(xCaret, yCaret); + + this._scrollContentsToDelayed(xCaret, yCaret); + } + + _centerFromFocusPosition() { + let xFocus = this._xFocus; + let yFocus = this._yFocus; + + if (this._focusTrackingMode == GDesktopEnums.MagnifierFocusTrackingMode.PROPORTIONAL) + [xFocus, yFocus] = this._centerFromPointProportional(xFocus, yFocus); + else if (this._focusTrackingMode == GDesktopEnums.MagnifierFocusTrackingMode.PUSH) + [xFocus, yFocus] = this._centerFromPointPush(xFocus, yFocus); + else if (this._focusTrackingMode == GDesktopEnums.MagnifierFocusTrackingMode.CENTERED) + [xFocus, yFocus] = this._centerFromPointCentered(xFocus, yFocus); + + this._scrollContentsToDelayed(xFocus, yFocus); + } + + _centerFromPointPush(xPoint, yPoint) { + let [xRoi, yRoi, widthRoi, heightRoi] = this.getROI(); + let [cursorWidth, cursorHeight] = this._mouseSourceActor.get_size(); + let xPos = xRoi + widthRoi / 2; + let yPos = yRoi + heightRoi / 2; + let xRoiRight = xRoi + widthRoi - cursorWidth; + let yRoiBottom = yRoi + heightRoi - cursorHeight; + + if (xPoint < xRoi) + xPos -= xRoi - xPoint; + else if (xPoint > xRoiRight) + xPos += xPoint - xRoiRight; + + if (yPoint < yRoi) + yPos -= yRoi - yPoint; + else if (yPoint > yRoiBottom) + yPos += yPoint - yRoiBottom; + + return [xPos, yPos]; + } + + _centerFromPointProportional(xPoint, yPoint) { + let [xRoi_, yRoi_, widthRoi, heightRoi] = this.getROI(); + let halfScreenWidth = global.screen_width / 2; + let halfScreenHeight = global.screen_height / 2; + // We want to pad with a constant distance after zooming, so divide + // by the magnification factor. + let unscaledPadding = Math.min(this._viewPortWidth, this._viewPortHeight) / 5; + let xPadding = unscaledPadding / this._xMagFactor; + let yPadding = unscaledPadding / this._yMagFactor; + let xProportion = (xPoint - halfScreenWidth) / halfScreenWidth; // -1 ... 1 + let yProportion = (yPoint - halfScreenHeight) / halfScreenHeight; // -1 ... 1 + let xPos = xPoint - xProportion * (widthRoi / 2 - xPadding); + let yPos = yPoint - yProportion * (heightRoi / 2 - yPadding); + + return [xPos, yPos]; + } + + _centerFromPointCentered(xPoint, yPoint) { + return [xPoint, yPoint]; + } + + _screenToViewPort(screenX, screenY) { + // Converts coordinates relative to the (unmagnified) screen to coordinates + // relative to the origin of this._magView + return [this._viewPortWidth / 2 + (screenX - this._xCenter) * this._xMagFactor, + this._viewPortHeight / 2 + (screenY - this._yCenter) * this._yMagFactor]; + } + + _updateMagViewGeometry() { + if (!this.isActive()) + return; + + if (this._isFullScreen()) + this._magView.add_style_class_name('full-screen'); + else + this._magView.remove_style_class_name('full-screen'); + + this._magView.set_size(this._viewPortWidth, this._viewPortHeight); + this._magView.set_position(this._viewPortX, this._viewPortY); + } + + _updateCloneGeometry(animate = false) { + if (!this.isActive()) + return; + + let [x, y] = this._screenToViewPort(0, 0); + this._uiGroupClone.ease({ + x: Math.round(x), + y: Math.round(y), + scale_x: this._xMagFactor, + scale_y: this._yMagFactor, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: animate ? 100 : 0, + }); + + let [mouseX, mouseY] = this._getMousePosition(); + this._mouseActor.ease({ + x: mouseX, + y: mouseY, + scale_x: this._xMagFactor, + scale_y: this._yMagFactor, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: animate ? 100 : 0, + }); + + if (this._crossHairsActor) { + let [crossX, crossY] = this._getCrossHairsPosition(); + this._crossHairsActor.ease({ + x: crossX, + y: crossY, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: animate ? 100 : 0, + }); + } + } + + _updateMousePosition() { + let [xMagMouse, yMagMouse] = this._getMousePosition(); + this._mouseActor.set_position(xMagMouse, yMagMouse); + + if (this._crossHairsActor) { + let [crossX, crossY] = this._getCrossHairsPosition(); + this._crossHairsActor.set_position(crossX, crossY); + } + } + + _getMousePosition() { + let [xMagMouse, yMagMouse] = this._screenToViewPort( + this._magnifier.xMouse, this._magnifier.yMouse); + return [Math.round(xMagMouse), Math.round(yMagMouse)]; + } + + _getCrossHairsPosition() { + let [xMagMouse, yMagMouse] = this._getMousePosition(); + let [groupWidth, groupHeight] = this._crossHairsActor.get_size(); + + return [xMagMouse - groupWidth / 2, yMagMouse - groupHeight / 2]; + } + + _monitorsChanged() { + this._background.set_size(global.screen_width, global.screen_height); + this._updateScreenPosition(); + } +}; + +var Crosshairs = GObject.registerClass( +class Crosshairs extends Clutter.Actor { + _init() { + + // Set the group containing the crosshairs to three times the desktop + // size in case the crosshairs need to appear to be infinite in + // length (i.e., extend beyond the edges of the view they appear in). + let groupWidth = global.screen_width * 3; + let groupHeight = global.screen_height * 3; + + super._init({ + clip_to_allocation: false, + width: groupWidth, + height: groupHeight, + }); + this._horizLeftHair = new Clutter.Actor(); + this._horizRightHair = new Clutter.Actor(); + this._vertTopHair = new Clutter.Actor(); + this._vertBottomHair = new Clutter.Actor(); + this.add_actor(this._horizLeftHair); + this.add_actor(this._horizRightHair); + this.add_actor(this._vertTopHair); + this.add_actor(this._vertBottomHair); + this._clipSize = [0, 0]; + this._clones = []; + this.reCenter(); + this._monitorsChangedId = 0; + } + + _monitorsChanged() { + this.set_size(global.screen_width * 3, global.screen_height * 3); + this.reCenter(); + } + + setEnabled(enabled) { + if (enabled && this._monitorsChangedId === 0) { + this._monitorsChangedId = Main.layoutManager.connect( + 'monitors-changed', this._monitorsChanged.bind(this)); + } else if (!enabled && this._monitorsChangedId !== 0) { + Main.layoutManager.disconnect(this._monitorsChangedId); + this._monitorsChangedId = 0; + } + } + + /** + * addToZoomRegion + * Either add the crosshairs actor to the given ZoomRegion, or, if it is + * already part of some other ZoomRegion, create a clone of the crosshairs + * actor, and add the clone instead. Returns either the original or the + * clone. + * @param {ZoomRegion} zoomRegion: The container to add the crosshairs + * group to. + * @param {Clutter.Actor} magnifiedMouse: The mouse actor for the + * zoom region -- used to position the crosshairs and properly + * layer them below the mouse. + * @returns {Clutter.Actor} The crosshairs actor, or its clone. + */ + addToZoomRegion(zoomRegion, magnifiedMouse) { + let crosshairsActor = null; + if (zoomRegion && magnifiedMouse) { + let container = magnifiedMouse.get_parent(); + if (container) { + crosshairsActor = this; + if (this.get_parent() != null) { + crosshairsActor = new Clutter.Clone({ source: this }); + this._clones.push(crosshairsActor); + + // Clones don't share visibility. + this.bind_property('visible', crosshairsActor, 'visible', + GObject.BindingFlags.SYNC_CREATE); + } + + container.add_actor(crosshairsActor); + container.set_child_above_sibling(magnifiedMouse, crosshairsActor); + let [xMouse, yMouse] = magnifiedMouse.get_position(); + let [crosshairsWidth, crosshairsHeight] = crosshairsActor.get_size(); + crosshairsActor.set_position(xMouse - crosshairsWidth / 2, yMouse - crosshairsHeight / 2); + } + } + return crosshairsActor; + } + + /** + * removeFromParent: + * @param {Clutter.Actor} childActor: the actor returned from + * addToZoomRegion + * Remove the crosshairs actor from its parent container, or destroy the + * child actor if it was just a clone of the crosshairs actor. + */ + removeFromParent(childActor) { + if (childActor == this) + childActor.get_parent().remove_actor(childActor); + else + childActor.destroy(); + } + + /** + * setColor: + * Set the color of the crosshairs. + * @param {Clutter.Color} clutterColor: The color + */ + setColor(clutterColor) { + this._horizLeftHair.background_color = clutterColor; + this._horizRightHair.background_color = clutterColor; + this._vertTopHair.background_color = clutterColor; + this._vertBottomHair.background_color = clutterColor; + } + + /** + * getColor: + * Get the color of the crosshairs. + * @returns {ClutterColor} the crosshairs color + */ + getColor() { + return this._horizLeftHair.get_color(); + } + + /** + * setThickness: + * Set the width of the vertical and horizontal lines of the crosshairs. + * @param {number} thickness: the new thickness value + */ + setThickness(thickness) { + this._horizLeftHair.set_height(thickness); + this._horizRightHair.set_height(thickness); + this._vertTopHair.set_width(thickness); + this._vertBottomHair.set_width(thickness); + this.reCenter(); + } + + /** + * getThickness: + * Get the width of the vertical and horizontal lines of the crosshairs. + * @returns {number} The thickness of the crosshairs. + */ + getThickness() { + return this._horizLeftHair.get_height(); + } + + /** + * setOpacity: + * Set how opaque the crosshairs are. + * @param {number} opacity: Value between 0 (fully transparent) + * and 255 (full opaque). + */ + setOpacity(opacity) { + // set_opacity() throws an exception for values outside the range + // [0, 255]. + if (opacity < 0) + opacity = 0; + else if (opacity > 255) + opacity = 255; + + this._horizLeftHair.set_opacity(opacity); + this._horizRightHair.set_opacity(opacity); + this._vertTopHair.set_opacity(opacity); + this._vertBottomHair.set_opacity(opacity); + } + + /** + * setLength: + * Set the length of the vertical and horizontal lines in the crosshairs. + * @param {number} length: The length of the crosshairs. + */ + setLength(length) { + this._horizLeftHair.set_width(length); + this._horizRightHair.set_width(length); + this._vertTopHair.set_height(length); + this._vertBottomHair.set_height(length); + this.reCenter(); + } + + /** + * getLength: + * Get the length of the vertical and horizontal lines in the crosshairs. + * @returns {number} The length of the crosshairs. + */ + getLength() { + return this._horizLeftHair.get_width(); + } + + /** + * setClip: + * Set the width and height of the rectangle that clips the crosshairs at + * their intersection + * @param {number[]} size: Array of [width, height] defining the size + * of the clip rectangle. + */ + setClip(size) { + if (size) { + // Take a chunk out of the crosshairs where it intersects the + // mouse. + this._clipSize = size; + this.reCenter(); + } else { + // Restore the missing chunk. + this._clipSize = [0, 0]; + this.reCenter(); + } + } + + /** + * reCenter: + * Reposition the horizontal and vertical hairs such that they cross at + * the center of crosshairs group. If called with the dimensions of + * the clip rectangle, these are used to update the size of the clip. + * @param {number[]=} clipSize: If present, the clip's [width, height]. + */ + reCenter(clipSize) { + let [groupWidth, groupHeight] = this.get_size(); + let leftLength = this._horizLeftHair.get_width(); + let topLength = this._vertTopHair.get_height(); + let thickness = this._horizLeftHair.get_height(); + + // Deal with clip rectangle. + if (clipSize) + this._clipSize = clipSize; + let clipWidth = this._clipSize[0]; + let clipHeight = this._clipSize[1]; + + let left = groupWidth / 2 - clipWidth / 2 - leftLength - thickness / 2; + let right = groupWidth / 2 + clipWidth / 2 + thickness / 2; + let top = groupHeight / 2 - clipHeight / 2 - topLength - thickness / 2; + let bottom = groupHeight / 2 + clipHeight / 2 + thickness / 2; + this._horizLeftHair.set_position(left, (groupHeight - thickness) / 2); + this._horizRightHair.set_position(right, (groupHeight - thickness) / 2); + this._vertTopHair.set_position((groupWidth - thickness) / 2, top); + this._vertBottomHair.set_position((groupWidth - thickness) / 2, bottom); + } +}); + +var MagShaderEffects = class MagShaderEffects { + constructor(uiGroupClone) { + this._inverse = new Shell.InvertLightnessEffect(); + this._brightnessContrast = new Clutter.BrightnessContrastEffect(); + this._colorDesaturation = new Clutter.DesaturateEffect(); + this._inverse.set_enabled(false); + this._brightnessContrast.set_enabled(false); + + this._magView = uiGroupClone; + this._magView.add_effect(this._inverse); + this._magView.add_effect(this._brightnessContrast); + this._magView.add_effect(this._colorDesaturation); + } + + /** + * destroyEffects: + * Remove contrast and brightness effects from the magnified view, and + * lose the reference to the actor they were applied to. Don't use this + * object after calling this. + */ + destroyEffects() { + this._magView.clear_effects(); + this._colorDesaturation = null; + this._brightnessContrast = null; + this._inverse = null; + this._magView = null; + } + + /** + * setInvertLightness: + * Enable/disable invert lightness effect. + * @param {bool} invertFlag: Enabled flag. + */ + setInvertLightness(invertFlag) { + this._inverse.set_enabled(invertFlag); + } + + setColorSaturation(factor) { + this._colorDesaturation.set_factor(1.0 - factor); + } + + /** + * setBrightness: + * Set the brightness of the magnified view. + * @param {Object} brightness: Object containing the contrast for the + * red, green, and blue channels. Values of 0.0 represent "standard" + * brightness (no change), whereas values less or greater than + * 0.0 indicate decreased or incresaed brightness, respectively. + * + * {number} brightness.r - the red component + * {number} brightness.g - the green component + * {number} brightness.b - the blue component + */ + setBrightness(brightness) { + let bRed = brightness.r; + let bGreen = brightness.g; + let bBlue = brightness.b; + this._brightnessContrast.set_brightness_full(bRed, bGreen, bBlue); + + // Enable the effect if the brightness OR contrast change are such that + // it modifies the brightness and/or contrast. + let [cRed, cGreen, cBlue] = this._brightnessContrast.get_contrast(); + this._brightnessContrast.set_enabled( + bRed !== NO_CHANGE || bGreen !== NO_CHANGE || bBlue !== NO_CHANGE || + cRed !== NO_CHANGE || cGreen !== NO_CHANGE || cBlue !== NO_CHANGE); + } + + /** + * Set the contrast of the magnified view. + * @param {Object} contrast: Object containing the contrast for the + * red, green, and blue channels. Values of 0.0 represent "standard" + * contrast (no change), whereas values less or greater than + * 0.0 indicate decreased or incresaed contrast, respectively. + * + * {number} contrast.r - the red component + * {number} contrast.g - the green component + * {number} contrast.b - the blue component + */ + setContrast(contrast) { + let cRed = contrast.r; + let cGreen = contrast.g; + let cBlue = contrast.b; + + this._brightnessContrast.set_contrast_full(cRed, cGreen, cBlue); + + // Enable the effect if the contrast OR brightness change are such that + // it modifies the brightness and/or contrast. + // should be able to use Clutter.color_equal(), but that complains of + // a null first argument. + let [bRed, bGreen, bBlue] = this._brightnessContrast.get_brightness(); + this._brightnessContrast.set_enabled( + cRed !== NO_CHANGE || cGreen !== NO_CHANGE || cBlue !== NO_CHANGE || + bRed !== NO_CHANGE || bGreen !== NO_CHANGE || bBlue !== NO_CHANGE); + } +}; diff --git a/js/ui/magnifierDBus.js b/js/ui/magnifierDBus.js new file mode 100644 index 0000000..6f962d1 --- /dev/null +++ b/js/ui/magnifierDBus.js @@ -0,0 +1,351 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported ShellMagnifier */ + +const Gio = imports.gi.Gio; +const Main = imports.ui.main; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const MAG_SERVICE_PATH = '/org/gnome/Magnifier'; +const ZOOM_SERVICE_PATH = '/org/gnome/Magnifier/ZoomRegion'; + +// Subset of gnome-mag's Magnifier dbus interface -- to be expanded. See: +// http://git.gnome.org/browse/gnome-mag/tree/xml/...Magnifier.xml +const MagnifierIface = loadInterfaceXML('org.gnome.Magnifier'); + +// Subset of gnome-mag's ZoomRegion dbus interface -- to be expanded. See: +// http://git.gnome.org/browse/gnome-mag/tree/xml/...ZoomRegion.xml +const ZoomRegionIface = loadInterfaceXML('org.gnome.Magnifier.ZoomRegion'); + +// For making unique ZoomRegion DBus proxy object paths of the form: +// '/org/gnome/Magnifier/ZoomRegion/zoomer0', +// '/org/gnome/Magnifier/ZoomRegion/zoomer1', etc. +let _zoomRegionInstanceCount = 0; + +var ShellMagnifier = class ShellMagnifier { + constructor() { + this._zoomers = {}; + + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(MagnifierIface, this); + this._dbusImpl.export(Gio.DBus.session, MAG_SERVICE_PATH); + } + + /** + * setActive: + * @param {bool} activate: activate or de-activate the magnifier. + */ + setActive(activate) { + Main.magnifier.setActive(activate); + } + + /** + * isActive: + * @returns {bool} Whether the magnifier is active. + */ + isActive() { + return Main.magnifier.isActive(); + } + + /** + * showCursor: + * Show the system mouse pointer. + */ + showCursor() { + Main.magnifier.showSystemCursor(); + } + + /** + * hideCursor: + * Hide the system mouse pointer. + */ + hideCursor() { + Main.magnifier.hideSystemCursor(); + } + + /** + * createZoomRegion: + * Create a new ZoomRegion and return its object path. + * @param {number} xMagFactor: + * The power to set horizontal magnification of the ZoomRegion. + * A value of 1.0 means no magnification. A value of 2.0 doubles + * the size. + * @param {number} yMagFactor: + * The power to set the vertical magnification of the + * ZoomRegion. + * @param {number[]} roi + * Array of integers defining the region of the screen/desktop + * to magnify. The array has the form [left, top, right, bottom]. + * @param {number[]} viewPort + * Array of integers, [left, top, right, bottom] that defines + * the position of the ZoomRegion on screen. + * + * FIXME: The arguments here are redundant, since the width and height of + * the ROI are determined by the viewport and magnification factors. + * We ignore the passed in width and height. + * + * @returns {ZoomRegion} The newly created ZoomRegion. + */ + createZoomRegion(xMagFactor, yMagFactor, roi, viewPort) { + let ROI = { x: roi[0], y: roi[1], width: roi[2] - roi[0], height: roi[3] - roi[1] }; + let viewBox = { x: viewPort[0], y: viewPort[1], width: viewPort[2] - viewPort[0], height: viewPort[3] - viewPort[1] }; + let realZoomRegion = Main.magnifier.createZoomRegion(xMagFactor, yMagFactor, ROI, viewBox); + let objectPath = `${ZOOM_SERVICE_PATH}/zoomer${_zoomRegionInstanceCount}`; + _zoomRegionInstanceCount++; + + let zoomRegionProxy = new ShellMagnifierZoomRegion(objectPath, realZoomRegion); + let proxyAndZoomRegion = {}; + proxyAndZoomRegion.proxy = zoomRegionProxy; + proxyAndZoomRegion.zoomRegion = realZoomRegion; + this._zoomers[objectPath] = proxyAndZoomRegion; + return objectPath; + } + + /** + * addZoomRegion: + * Append the given ZoomRegion to the magnifier's list of ZoomRegions. + * @param {string} zoomerObjectPath: The object path for the zoom + * region proxy. + * @returns {bool} whether the region was added successfully + */ + addZoomRegion(zoomerObjectPath) { + let proxyAndZoomRegion = this._zoomers[zoomerObjectPath]; + if (proxyAndZoomRegion && proxyAndZoomRegion.zoomRegion) { + Main.magnifier.addZoomRegion(proxyAndZoomRegion.zoomRegion); + return true; + } else { + return false; + } + } + + /** + * getZoomRegions: + * Return a list of ZoomRegion object paths for this Magnifier. + * @returns {string[]}: The Magnifier's zoom region list as an array + * of DBus object paths. + */ + getZoomRegions() { + // There may be more ZoomRegions in the magnifier itself than have + // been added through dbus. Make sure all of them are associated with + // an object path and proxy. + let zoomRegions = Main.magnifier.getZoomRegions(); + let objectPaths = []; + let thoseZoomers = this._zoomers; + zoomRegions.forEach(aZoomRegion => { + let found = false; + for (let objectPath in thoseZoomers) { + let proxyAndZoomRegion = thoseZoomers[objectPath]; + if (proxyAndZoomRegion.zoomRegion === aZoomRegion) { + objectPaths.push(objectPath); + found = true; + break; + } + } + if (!found) { + // Got a ZoomRegion with no DBus proxy, make one. + let newPath = `${ZOOM_SERVICE_PATH}/zoomer${_zoomRegionInstanceCount}`; + _zoomRegionInstanceCount++; + let zoomRegionProxy = new ShellMagnifierZoomRegion(newPath, aZoomRegion); + let proxyAndZoomer = {}; + proxyAndZoomer.proxy = zoomRegionProxy; + proxyAndZoomer.zoomRegion = aZoomRegion; + thoseZoomers[newPath] = proxyAndZoomer; + objectPaths.push(newPath); + } + }); + return objectPaths; + } + + /** + * clearAllZoomRegions: + * Remove all the zoom regions from this Magnfier's ZoomRegion list. + */ + clearAllZoomRegions() { + Main.magnifier.clearAllZoomRegions(); + for (let objectPath in this._zoomers) { + let proxyAndZoomer = this._zoomers[objectPath]; + proxyAndZoomer.proxy.destroy(); + proxyAndZoomer.proxy = null; + proxyAndZoomer.zoomRegion = null; + delete this._zoomers[objectPath]; + } + this._zoomers = {}; + } + + /** + * fullScreenCapable: + * Consult if the Magnifier can magnify in full-screen mode. + * @returns {bool} Always return true. + */ + fullScreenCapable() { + return true; + } + + /** + * setCrosswireSize: + * Set the crosswire size of all ZoomRegions. + * @param {number} size: The thickness of each line in the cross wire. + */ + setCrosswireSize(size) { + Main.magnifier.setCrosshairsThickness(size); + } + + /** + * getCrosswireSize: + * Get the crosswire size of all ZoomRegions. + * @returns {number}: The thickness of each line in the cross wire. + */ + getCrosswireSize() { + return Main.magnifier.getCrosshairsThickness(); + } + + /** + * setCrosswireLength: + * Set the crosswire length of all zoom-regions.. + * @param {number} length: The length of each line in the cross wire. + */ + setCrosswireLength(length) { + Main.magnifier.setCrosshairsLength(length); + } + + /** + * getCrosswireSize: + * Get the crosswire length of all zoom-regions. + * @returns {number} size: The length of each line in the cross wire. + */ + getCrosswireLength() { + return Main.magnifier.getCrosshairsLength(); + } + + /** + * setCrosswireClip: + * Set if the crosswire will be clipped by the cursor image.. + * @param {bool} clip: Flag to indicate whether to clip the crosswire. + */ + setCrosswireClip(clip) { + Main.magnifier.setCrosshairsClip(clip); + } + + /** + * getCrosswireClip: + * Get the crosswire clip value. + * @returns {bool}: Whether the crosswire is clipped by the cursor image. + */ + getCrosswireClip() { + return Main.magnifier.getCrosshairsClip(); + } + + /** + * setCrosswireColor: + * Set the crosswire color of all ZoomRegions. + * @param {number} color: Unsigned int of the form rrggbbaa. + */ + setCrosswireColor(color) { + Main.magnifier.setCrosshairsColor('#%08x'.format(color)); + } + + /** + * getCrosswireClip: + * Get the crosswire color of all ZoomRegions. + * @returns {number}: The crosswire color as an unsigned int in + * the form rrggbbaa. + */ + getCrosswireColor() { + let colorString = Main.magnifier.getCrosshairsColor(); + // Drop the leading '#'. + return parseInt(colorString.slice(1), 16); + } +}; + +/** + * ShellMagnifierZoomRegion: + * Object that implements the DBus ZoomRegion interface. + * @zoomerObjectPath: String that is the path to a DBus ZoomRegion. + * @zoomRegion: The actual zoom region associated with the object path. + */ +var ShellMagnifierZoomRegion = class ShellMagnifierZoomRegion { + constructor(zoomerObjectPath, zoomRegion) { + this._zoomRegion = zoomRegion; + + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(ZoomRegionIface, this); + this._dbusImpl.export(Gio.DBus.session, zoomerObjectPath); + } + + /** + * setMagFactor: + * @param {number} xMagFactor: The power to set the horizontal + * magnification factor to of the magnified view. A value of + * 1.0 means no magnification. A value of 2.0 doubles the size. + * @param {number} yMagFactor: The power to set the vertical + * magnification factor to of the magnified view. + */ + setMagFactor(xMagFactor, yMagFactor) { + this._zoomRegion.setMagFactor(xMagFactor, yMagFactor); + } + + /** + * getMagFactor: + * @returns {number[]}: [xMagFactor, yMagFactor], containing the horizontal + * and vertical magnification powers. A value of 1.0 means no + * magnification. A value of 2.0 means the contents are doubled + * in size, and so on. + */ + getMagFactor() { + return this._zoomRegion.getMagFactor(); + } + + /** + * setRoi: + * Sets the "region of interest" that the ZoomRegion is magnifying. + * @param {number[]} roi: [left, top, right, bottom], defining the + * region of the screen to magnify. + * The values are in screen (unmagnified) coordinate space. + */ + setRoi(roi) { + let roiObject = { x: roi[0], y: roi[1], width: roi[2] - roi[0], height: roi[3] - roi[1] }; + this._zoomRegion.setROI(roiObject); + } + + /** + * getRoi: + * Retrieves the "region of interest" -- the rectangular bounds of that part + * of the desktop that the magnified view is showing (x, y, width, height). + * The bounds are given in non-magnified coordinates. + * @returns {Array}: [left, top, right, bottom], representing the bounding + * rectangle of what is shown in the magnified view. + */ + getRoi() { + let roi = this._zoomRegion.getROI(); + roi[2] += roi[0]; + roi[3] += roi[1]; + return roi; + } + + /** + * Set the "region of interest" by centering the given screen coordinate + * within the zoom region. + * @param {number} x: The x-coord of the point to place at the + * center of the zoom region. + * @param {number} y: The y-coord. + * @returns {bool} Whether the shift was successful (for GS-mag, this + * is always true). + */ + shiftContentsTo(x, y) { + this._zoomRegion.scrollContentsTo(x, y); + return true; + } + + /** + * moveResize + * Sets the position and size of the ZoomRegion on screen. + * @param {number[]} viewPort: [left, top, right, bottom], defining + * the position and size on screen to place the zoom region. + */ + moveResize(viewPort) { + let viewRect = { x: viewPort[0], y: viewPort[1], width: viewPort[2] - viewPort[0], height: viewPort[3] - viewPort[1] }; + this._zoomRegion.setViewPort(viewRect); + } + + destroy() { + this._dbusImpl.unexport(); + } +}; diff --git a/js/ui/main.js b/js/ui/main.js new file mode 100644 index 0000000..bff730c --- /dev/null +++ b/js/ui/main.js @@ -0,0 +1,856 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported componentManager, notificationDaemon, windowAttentionHandler, + ctrlAltTabManager, padOsdService, osdWindowManager, + osdMonitorLabeler, shellMountOpDBusService, shellDBusService, + shellAccessDialogDBusService, shellAudioSelectionDBusService, + screenSaverDBus, uiGroup, magnifier, xdndHandler, keyboard, + kbdA11yDialog, introspectService, start, pushModal, popModal, + activateWindow, createLookingGlass, initializeDeferredWork, + getThemeStylesheet, setThemeStylesheet */ + +const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; + +const AccessDialog = imports.ui.accessDialog; +const AudioDeviceSelection = imports.ui.audioDeviceSelection; +const Components = imports.ui.components; +const CtrlAltTab = imports.ui.ctrlAltTab; +const EndSessionDialog = imports.ui.endSessionDialog; +const ExtensionSystem = imports.ui.extensionSystem; +const ExtensionDownloader = imports.ui.extensionDownloader; +const InputMethod = imports.misc.inputMethod; +const Introspect = imports.misc.introspect; +const Keyboard = imports.ui.keyboard; +const MessageTray = imports.ui.messageTray; +const ModalDialog = imports.ui.modalDialog; +const OsdWindow = imports.ui.osdWindow; +const OsdMonitorLabeler = imports.ui.osdMonitorLabeler; +const Overview = imports.ui.overview; +const PadOsd = imports.ui.padOsd; +const Panel = imports.ui.panel; +const Params = imports.misc.params; +const RunDialog = imports.ui.runDialog; +const Layout = imports.ui.layout; +const LoginManager = imports.misc.loginManager; +const LookingGlass = imports.ui.lookingGlass; +const NotificationDaemon = imports.ui.notificationDaemon; +const WindowAttentionHandler = imports.ui.windowAttentionHandler; +const ScreenShield = imports.ui.screenShield; +const Scripting = imports.ui.scripting; +const SessionMode = imports.ui.sessionMode; +const ShellDBus = imports.ui.shellDBus; +const ShellMountOperation = imports.ui.shellMountOperation; +const WindowManager = imports.ui.windowManager; +const Magnifier = imports.ui.magnifier; +const XdndHandler = imports.ui.xdndHandler; +const KbdA11yDialog = imports.ui.kbdA11yDialog; +const LocatePointer = imports.ui.locatePointer; +const PointerA11yTimeout = imports.ui.pointerA11yTimeout; +const ParentalControlsManager = imports.misc.parentalControlsManager; + +const A11Y_SCHEMA = 'org.gnome.desktop.a11y.keyboard'; +const STICKY_KEYS_ENABLE = 'stickykeys-enable'; +const LOG_DOMAIN = 'GNOME Shell'; +const GNOMESHELL_STARTED_MESSAGE_ID = 'f3ea493c22934e26811cd62abe8e203a'; + +var componentManager = null; +var extensionManager = null; +var panel = null; +var overview = null; +var runDialog = null; +var lookingGlass = null; +var wm = null; +var messageTray = null; +var screenShield = null; +var notificationDaemon = null; +var windowAttentionHandler = null; +var ctrlAltTabManager = null; +var padOsdService = null; +var osdWindowManager = null; +var osdMonitorLabeler = null; +var sessionMode = null; +var shellAccessDialogDBusService = null; +var shellAudioSelectionDBusService = null; +var shellDBusService = null; +var shellMountOpDBusService = null; +var screenSaverDBus = null; +var modalCount = 0; +var actionMode = Shell.ActionMode.NONE; +var modalActorFocusStack = []; +var uiGroup = null; +var magnifier = null; +var xdndHandler = null; +var keyboard = null; +var layoutManager = null; +var kbdA11yDialog = null; +var inputMethod = null; +var introspectService = null; +var locatePointer = null; +let _startDate; +let _defaultCssStylesheet = null; +let _cssStylesheet = null; +let _a11ySettings = null; +let _themeResource = null; +let _oskResource = null; + +Gio._promisify(Gio._LocalFilePrototype, 'delete_async', 'delete_finish'); +Gio._promisify(Gio._LocalFilePrototype, 'touch_async', 'touch_finish'); + +let _remoteAccessInhibited = false; + +function _sessionUpdated() { + if (sessionMode.isPrimary) + _loadDefaultStylesheet(); + + wm.setCustomKeybindingHandler('panel-main-menu', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + sessionMode.hasOverview ? overview.toggle.bind(overview) : null); + wm.allowKeybinding('overlay-key', Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW); + + wm.allowKeybinding('locate-pointer-key', Shell.ActionMode.ALL); + + wm.setCustomKeybindingHandler('panel-run-dialog', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + sessionMode.hasRunDialog ? openRunDialog : null); + + if (!sessionMode.hasRunDialog) { + if (runDialog) + runDialog.close(); + if (lookingGlass) + lookingGlass.close(); + } + + let remoteAccessController = global.backend.get_remote_access_controller(); + if (remoteAccessController) { + if (sessionMode.allowScreencast && _remoteAccessInhibited) { + remoteAccessController.uninhibit_remote_access(); + _remoteAccessInhibited = false; + } else if (!sessionMode.allowScreencast && !_remoteAccessInhibited) { + remoteAccessController.inhibit_remote_access(); + _remoteAccessInhibited = true; + } + } +} + +function start() { + // These are here so we don't break compatibility. + global.logError = globalThis.log; + global.log = globalThis.log; + + // Chain up async errors reported from C + global.connect('notify-error', (global, msg, detail) => { + notifyError(msg, detail); + }); + + let currentDesktop = GLib.getenv('XDG_CURRENT_DESKTOP'); + if (!currentDesktop || !currentDesktop.split(':').includes('GNOME')) + Gio.DesktopAppInfo.set_desktop_env('GNOME'); + + sessionMode = new SessionMode.SessionMode(); + sessionMode.connect('updated', _sessionUpdated); + + St.Settings.get().connect('notify::gtk-theme', _loadDefaultStylesheet); + + // Initialize ParentalControlsManager before the UI + ParentalControlsManager.getDefault(); + + _initializeUI(); + + shellAccessDialogDBusService = new AccessDialog.AccessDialogDBus(); + shellAudioSelectionDBusService = new AudioDeviceSelection.AudioDeviceSelectionDBus(); + shellDBusService = new ShellDBus.GnomeShell(); + shellMountOpDBusService = new ShellMountOperation.GnomeShellMountOpHandler(); + + const watchId = Gio.DBus.session.watch_name('org.gnome.Shell.Notifications', + Gio.BusNameWatcherFlags.AUTO_START, + bus => bus.unwatch_name(watchId), + bus => bus.unwatch_name(watchId)); + + _sessionUpdated(); +} + +function _initializeUI() { + // Ensure ShellWindowTracker and ShellAppUsage are initialized; this will + // also initialize ShellAppSystem first. ShellAppSystem + // needs to load all the .desktop files, and ShellWindowTracker + // will use those to associate with windows. Right now + // the Monitor doesn't listen for installed app changes + // and recalculate application associations, so to avoid + // races for now we initialize it here. It's better to + // be predictable anyways. + Shell.WindowTracker.get_default(); + Shell.AppUsage.get_default(); + + reloadThemeResource(); + _loadOskLayouts(); + _loadDefaultStylesheet(); + + new AnimationsSettings(); + + // Setup the stage hierarchy early + layoutManager = new Layout.LayoutManager(); + + // Various parts of the codebase still refer to Main.uiGroup + // instead of using the layoutManager. This keeps that code + // working until it's updated. + uiGroup = layoutManager.uiGroup; + + padOsdService = new PadOsd.PadOsdService(); + xdndHandler = new XdndHandler.XdndHandler(); + ctrlAltTabManager = new CtrlAltTab.CtrlAltTabManager(); + osdWindowManager = new OsdWindow.OsdWindowManager(); + osdMonitorLabeler = new OsdMonitorLabeler.OsdMonitorLabeler(); + overview = new Overview.Overview(); + kbdA11yDialog = new KbdA11yDialog.KbdA11yDialog(); + wm = new WindowManager.WindowManager(); + magnifier = new Magnifier.Magnifier(); + locatePointer = new LocatePointer.LocatePointer(); + + if (LoginManager.canLock()) + screenShield = new ScreenShield.ScreenShield(); + + inputMethod = new InputMethod.InputMethod(); + Clutter.get_default_backend().set_input_method(inputMethod); + + messageTray = new MessageTray.MessageTray(); + panel = new Panel.Panel(); + keyboard = new Keyboard.KeyboardManager(); + notificationDaemon = new NotificationDaemon.NotificationDaemon(); + windowAttentionHandler = new WindowAttentionHandler.WindowAttentionHandler(); + componentManager = new Components.ComponentManager(); + + introspectService = new Introspect.IntrospectService(); + + layoutManager.init(); + overview.init(); + + new PointerA11yTimeout.PointerA11yTimeout(); + + _a11ySettings = new Gio.Settings({ schema_id: A11Y_SCHEMA }); + + global.display.connect('overlay-key', () => { + if (!_a11ySettings.get_boolean(STICKY_KEYS_ENABLE)) + overview.toggle(); + }); + + global.connect('locate-pointer', () => { + locatePointer.show(); + }); + + global.display.connect('show-restart-message', (display, message) => { + showRestartMessage(message); + return true; + }); + + global.display.connect('restart', () => { + global.reexec_self(); + return true; + }); + + global.display.connect('gl-video-memory-purged', loadTheme); + + // Provide the bus object for gnome-session to + // initiate logouts. + EndSessionDialog.init(); + + // We're ready for the session manager to move to the next phase + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + Shell.util_sd_notify(); + Meta.register_with_session(); + return GLib.SOURCE_REMOVE; + }); + + _startDate = new Date(); + + ExtensionDownloader.init(); + extensionManager = new ExtensionSystem.ExtensionManager(); + extensionManager.init(); + + if (sessionMode.isGreeter && screenShield) { + layoutManager.connect('startup-prepared', () => { + screenShield.showDialog(); + }); + } + + layoutManager.connect('startup-complete', () => { + if (actionMode == Shell.ActionMode.NONE) + actionMode = Shell.ActionMode.NORMAL; + + if (screenShield) + screenShield.lockIfWasLocked(); + + if (sessionMode.currentMode != 'gdm' && + sessionMode.currentMode != 'initial-setup') { + GLib.log_structured(LOG_DOMAIN, GLib.LogLevelFlags.LEVEL_MESSAGE, { + 'MESSAGE': 'GNOME Shell started at %s'.format(_startDate), + 'MESSAGE_ID': GNOMESHELL_STARTED_MESSAGE_ID, + }); + } + + let credentials = new Gio.Credentials(); + if (credentials.get_unix_user() === 0) { + notify(_('Logged in as a privileged user'), + _('Running a session as a privileged user should be avoided for security reasons. If possible, you should log in as a normal user.')); + } + + if (sessionMode.currentMode !== 'gdm' && + sessionMode.currentMode !== 'initial-setup') + _handleLockScreenWarning(); + + LoginManager.registerSessionWithGDM(); + + let perfModuleName = GLib.getenv("SHELL_PERF_MODULE"); + if (perfModuleName) { + let perfOutput = GLib.getenv("SHELL_PERF_OUTPUT"); + let module = eval('imports.perf.%s;'.format(perfModuleName)); + Scripting.runPerfScript(module, perfOutput); + } + }); +} + +async function _handleLockScreenWarning() { + const path = '%s/lock-warning-shown'.format(global.userdatadir); + const file = Gio.File.new_for_path(path); + + const hasLockScreen = screenShield !== null; + if (hasLockScreen) { + try { + await file.delete_async(0, null); + } catch (e) { + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND)) + logError(e); + } + } else { + try { + if (!await file.touch_async()) + return; + } catch (e) { + logError(e); + } + + notify( + _('Screen Lock disabled'), + _('Screen Locking requires the GNOME display manager.')); + } +} + +function _getStylesheet(name) { + let stylesheet; + + stylesheet = Gio.File.new_for_uri('resource:///org/gnome/shell/theme/%s'.format(name)); + if (stylesheet.query_exists(null)) + return stylesheet; + + let dataDirs = GLib.get_system_data_dirs(); + for (let i = 0; i < dataDirs.length; i++) { + let path = GLib.build_filenamev([dataDirs[i], 'gnome-shell', 'theme', name]); + stylesheet = Gio.file_new_for_path(path); + if (stylesheet.query_exists(null)) + return stylesheet; + } + + stylesheet = Gio.File.new_for_path('%s/theme/%s'.format(global.datadir, name)); + if (stylesheet.query_exists(null)) + return stylesheet; + + return null; +} + +function _getDefaultStylesheet() { + let stylesheet = null; + let name = sessionMode.stylesheetName; + + // Look for a high-contrast variant first when using GTK+'s HighContrast + // theme + if (St.Settings.get().gtk_theme == 'HighContrast') + stylesheet = _getStylesheet(name.replace('.css', '-high-contrast.css')); + + if (stylesheet == null) + stylesheet = _getStylesheet(sessionMode.stylesheetName); + + return stylesheet; +} + +function _loadDefaultStylesheet() { + let stylesheet = _getDefaultStylesheet(); + if (_defaultCssStylesheet && _defaultCssStylesheet.equal(stylesheet)) + return; + + _defaultCssStylesheet = stylesheet; + loadTheme(); +} + +/** + * getThemeStylesheet: + * + * Get the theme CSS file that the shell will load + * + * @returns {?Gio.File}: A #GFile that contains the theme CSS, + * null if using the default + */ +function getThemeStylesheet() { + return _cssStylesheet; +} + +/** + * setThemeStylesheet: + * @param {string=} cssStylesheet: A file path that contains the theme CSS, + * set it to null to use the default + * + * Set the theme CSS file that the shell will load + */ +function setThemeStylesheet(cssStylesheet) { + _cssStylesheet = cssStylesheet ? Gio.File.new_for_path(cssStylesheet) : null; +} + +function reloadThemeResource() { + if (_themeResource) + _themeResource._unregister(); + + _themeResource = Gio.Resource.load('%s/%s'.format(global.datadir, + sessionMode.themeResourceName)); + _themeResource._register(); +} + +function _loadOskLayouts() { + _oskResource = Gio.Resource.load('%s/gnome-shell-osk-layouts.gresource'.format(global.datadir)); + _oskResource._register(); +} + +/** + * loadTheme: + * + * Reloads the theme CSS file + */ +function loadTheme() { + let themeContext = St.ThemeContext.get_for_stage(global.stage); + let previousTheme = themeContext.get_theme(); + + let theme = new St.Theme({ + application_stylesheet: _cssStylesheet, + default_stylesheet: _defaultCssStylesheet, + }); + + if (theme.default_stylesheet == null) + throw new Error("No valid stylesheet found for '%s'".format(sessionMode.stylesheetName)); + + if (previousTheme) { + let customStylesheets = previousTheme.get_custom_stylesheets(); + + for (let i = 0; i < customStylesheets.length; i++) + theme.load_stylesheet(customStylesheets[i]); + } + + themeContext.set_theme(theme); +} + +/** + * notify: + * @param {string} msg: A message + * @param {string} details: Additional information + */ +function notify(msg, details) { + let source = new MessageTray.SystemNotificationSource(); + messageTray.add(source); + let notification = new MessageTray.Notification(source, msg, details); + notification.setTransient(true); + source.showNotification(notification); +} + +/** + * notifyError: + * @param {string} msg: An error message + * @param {string} details: Additional information + * + * See shell_global_notify_problem(). + */ +function notifyError(msg, details) { + // Also print to stderr so it's logged somewhere + if (details) + log('error: %s: %s'.format(msg, details)); + else + log('error: %s'.format(msg)); + + notify(msg, details); +} + +function _findModal(actor) { + for (let i = 0; i < modalActorFocusStack.length; i++) { + if (modalActorFocusStack[i].actor == actor) + return i; + } + return -1; +} + +/** + * pushModal: + * @param {Clutter.Actor} actor: actor which will be given keyboard focus + * @param {Object=} params: optional parameters + * + * Ensure we are in a mode where all keyboard and mouse input goes to + * the stage, and focus @actor. Multiple calls to this function act in + * a stacking fashion; the effect will be undone when an equal number + * of popModal() invocations have been made. + * + * Next, record the current Clutter keyboard focus on a stack. If the + * modal stack returns to this actor, reset the focus to the actor + * which was focused at the time pushModal() was invoked. + * + * @params may be used to provide the following parameters: + * - timestamp: used to associate the call with a specific user initiated + * event. If not provided then the value of + * global.get_current_time() is assumed. + * + * - options: Meta.ModalOptions flags to indicate that the pointer is + * already grabbed + * + * - actionMode: used to set the current Shell.ActionMode to filter + * global keybindings; the default of NONE will filter + * out all keybindings + * + * @returns {bool}: true iff we successfully acquired a grab or already had one + */ +function pushModal(actor, params) { + params = Params.parse(params, { timestamp: global.get_current_time(), + options: 0, + actionMode: Shell.ActionMode.NONE }); + + if (modalCount == 0) { + if (!global.begin_modal(params.timestamp, params.options)) { + log('pushModal: invocation of begin_modal failed'); + return false; + } + Meta.disable_unredirect_for_display(global.display); + } + + modalCount += 1; + let actorDestroyId = actor.connect('destroy', () => { + let index = _findModal(actor); + if (index >= 0) + popModal(actor); + }); + + let prevFocus = global.stage.get_key_focus(); + let prevFocusDestroyId; + if (prevFocus != null) { + prevFocusDestroyId = prevFocus.connect('destroy', () => { + const index = modalActorFocusStack.findIndex( + record => record.prevFocus === prevFocus); + + if (index >= 0) + modalActorFocusStack[index].prevFocus = null; + }); + } + modalActorFocusStack.push({ actor, + destroyId: actorDestroyId, + prevFocus, + prevFocusDestroyId, + actionMode }); + + actionMode = params.actionMode; + global.stage.set_key_focus(actor); + return true; +} + +/** + * popModal: + * @param {Clutter.Actor} actor: the actor passed to original invocation + * of pushModal() + * @param {number=} timestamp: optional timestamp + * + * Reverse the effect of pushModal(). If this invocation is undoing + * the topmost invocation, then the focus will be restored to the + * previous focus at the time when pushModal() was invoked. + * + * @timestamp is optionally used to associate the call with a specific user + * initiated event. If not provided then the value of + * global.get_current_time() is assumed. + */ +function popModal(actor, timestamp) { + if (timestamp == undefined) + timestamp = global.get_current_time(); + + let focusIndex = _findModal(actor); + if (focusIndex < 0) { + global.stage.set_key_focus(null); + global.end_modal(timestamp); + actionMode = Shell.ActionMode.NORMAL; + + throw new Error('incorrect pop'); + } + + modalCount -= 1; + + let record = modalActorFocusStack[focusIndex]; + record.actor.disconnect(record.destroyId); + + if (focusIndex == modalActorFocusStack.length - 1) { + if (record.prevFocus) + record.prevFocus.disconnect(record.prevFocusDestroyId); + actionMode = record.actionMode; + global.stage.set_key_focus(record.prevFocus); + } else { + // If we have: + // global.stage.set_focus(a); + // Main.pushModal(b); + // Main.pushModal(c); + // Main.pushModal(d); + // + // then we have the stack: + // [{ prevFocus: a, actor: b }, + // { prevFocus: b, actor: c }, + // { prevFocus: c, actor: d }] + // + // When actor c is destroyed/popped, if we only simply remove the + // record, then the focus stack will be [a, c], rather than the correct + // [a, b]. Shift the focus stack up before removing the record to ensure + // that we get the correct result. + let t = modalActorFocusStack[modalActorFocusStack.length - 1]; + if (t.prevFocus) + t.prevFocus.disconnect(t.prevFocusDestroyId); + // Remove from the middle, shift the focus chain up + for (let i = modalActorFocusStack.length - 1; i > focusIndex; i--) { + modalActorFocusStack[i].prevFocus = modalActorFocusStack[i - 1].prevFocus; + modalActorFocusStack[i].prevFocusDestroyId = modalActorFocusStack[i - 1].prevFocusDestroyId; + modalActorFocusStack[i].actionMode = modalActorFocusStack[i - 1].actionMode; + } + } + modalActorFocusStack.splice(focusIndex, 1); + + if (modalCount > 0) + return; + + layoutManager.modalEnded(); + global.end_modal(timestamp); + Meta.enable_unredirect_for_display(global.display); + actionMode = Shell.ActionMode.NORMAL; +} + +function createLookingGlass() { + if (lookingGlass == null) + lookingGlass = new LookingGlass.LookingGlass(); + + return lookingGlass; +} + +function openRunDialog() { + if (runDialog == null) + runDialog = new RunDialog.RunDialog(); + + runDialog.open(); +} + +/** + * activateWindow: + * @param {Meta.Window} window: the window to activate + * @param {number=} time: current event time + * @param {number=} workspaceNum: window's workspace number + * + * Activates @window, switching to its workspace first if necessary, + * and switching out of the overview if it's currently active + */ +function activateWindow(window, time, workspaceNum) { + let workspaceManager = global.workspace_manager; + let activeWorkspaceNum = workspaceManager.get_active_workspace_index(); + let windowWorkspaceNum = workspaceNum !== undefined ? workspaceNum : window.get_workspace().index(); + + if (!time) + time = global.get_current_time(); + + if (windowWorkspaceNum != activeWorkspaceNum) { + let workspace = workspaceManager.get_workspace_by_index(windowWorkspaceNum); + workspace.activate_with_focus(window, time); + } else { + window.activate(time); + } + + overview.hide(); + panel.closeCalendar(); +} + +// TODO - replace this timeout with some system to guess when the user might +// be e.g. just reading the screen and not likely to interact. +var DEFERRED_TIMEOUT_SECONDS = 20; +var _deferredWorkData = {}; +// Work scheduled for some point in the future +var _deferredWorkQueue = []; +// Work we need to process before the next redraw +var _beforeRedrawQueue = []; +// Counter to assign work ids +var _deferredWorkSequence = 0; +var _deferredTimeoutId = 0; + +function _runDeferredWork(workId) { + if (!_deferredWorkData[workId]) + return; + let index = _deferredWorkQueue.indexOf(workId); + if (index < 0) + return; + + _deferredWorkQueue.splice(index, 1); + _deferredWorkData[workId].callback(); + if (_deferredWorkQueue.length == 0 && _deferredTimeoutId > 0) { + GLib.source_remove(_deferredTimeoutId); + _deferredTimeoutId = 0; + } +} + +function _runAllDeferredWork() { + while (_deferredWorkQueue.length > 0) + _runDeferredWork(_deferredWorkQueue[0]); +} + +function _runBeforeRedrawQueue() { + for (let i = 0; i < _beforeRedrawQueue.length; i++) { + let workId = _beforeRedrawQueue[i]; + _runDeferredWork(workId); + } + _beforeRedrawQueue = []; +} + +function _queueBeforeRedraw(workId) { + _beforeRedrawQueue.push(workId); + if (_beforeRedrawQueue.length == 1) { + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + _runBeforeRedrawQueue(); + return false; + }); + } +} + +/** + * initializeDeferredWork: + * @param {Clutter.Actor} actor: an actor + * @param {callback} callback: Function to invoke to perform work + * + * This function sets up a callback to be invoked when either the + * given actor is mapped, or after some period of time when the machine + * is idle. This is useful if your actor isn't always visible on the + * screen (for example, all actors in the overview), and you don't want + * to consume resources updating if the actor isn't actually going to be + * displaying to the user. + * + * Note that queueDeferredWork is called by default immediately on + * initialization as well, under the assumption that new actors + * will need it. + * + * @returns {string}: A string work identifier + */ +function initializeDeferredWork(actor, callback) { + // Turn into a string so we can use as an object property + let workId = (++_deferredWorkSequence).toString(); + _deferredWorkData[workId] = { actor, + callback }; + actor.connect('notify::mapped', () => { + if (!(actor.mapped && _deferredWorkQueue.includes(workId))) + return; + _queueBeforeRedraw(workId); + }); + actor.connect('destroy', () => { + let index = _deferredWorkQueue.indexOf(workId); + if (index >= 0) + _deferredWorkQueue.splice(index, 1); + delete _deferredWorkData[workId]; + }); + queueDeferredWork(workId); + return workId; +} + +/** + * queueDeferredWork: + * @param {string} workId: work identifier + * + * Ensure that the work identified by @workId will be + * run on map or timeout. You should call this function + * for example when data being displayed by the actor has + * changed. + */ +function queueDeferredWork(workId) { + let data = _deferredWorkData[workId]; + if (!data) { + let message = 'Invalid work id %d'.format(workId); + logError(new Error(message), message); + return; + } + if (!_deferredWorkQueue.includes(workId)) + _deferredWorkQueue.push(workId); + if (data.actor.mapped) { + _queueBeforeRedraw(workId); + } else if (_deferredTimeoutId == 0) { + _deferredTimeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, DEFERRED_TIMEOUT_SECONDS, () => { + _runAllDeferredWork(); + _deferredTimeoutId = 0; + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(_deferredTimeoutId, '[gnome-shell] _runAllDeferredWork'); + } +} + +var RestartMessage = GObject.registerClass( +class RestartMessage extends ModalDialog.ModalDialog { + _init(message) { + super._init({ shellReactive: true, + styleClass: 'restart-message headline', + shouldFadeIn: false, + destroyOnClose: true }); + + let label = new St.Label({ + text: message, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + + this.contentLayout.add_child(label); + this.buttonLayout.hide(); + } +}); + +function showRestartMessage(message) { + let restartMessage = new RestartMessage(message); + restartMessage.open(); +} + +var AnimationsSettings = class { + constructor() { + let backend = global.backend; + if (!backend.is_rendering_hardware_accelerated()) { + St.Settings.get().inhibit_animations(); + return; + } + + let isXvnc = Shell.util_has_x11_display_extension( + global.display, 'VNC-EXTENSION'); + if (isXvnc) { + St.Settings.get().inhibit_animations(); + return; + } + + let remoteAccessController = backend.get_remote_access_controller(); + if (!remoteAccessController) + return; + + this._handles = new Set(); + remoteAccessController.connect('new-handle', + (_, handle) => this._onNewRemoteAccessHandle(handle)); + } + + _onRemoteAccessHandleStopped(handle) { + let settings = St.Settings.get(); + + settings.uninhibit_animations(); + this._handles.delete(handle); + } + + _onNewRemoteAccessHandle(handle) { + if (!handle.get_disable_animations()) + return; + + let settings = St.Settings.get(); + + settings.inhibit_animations(); + this._handles.add(handle); + handle.connect('stopped', this._onRemoteAccessHandleStopped.bind(this)); + } +}; diff --git a/js/ui/messageList.js b/js/ui/messageList.js new file mode 100644 index 0000000..a79f87b --- /dev/null +++ b/js/ui/messageList.js @@ -0,0 +1,737 @@ +/* exported MessageListSection */ +const { Atk, Clutter, Gio, GLib, + GObject, Graphene, Meta, Pango, St } = imports.gi; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; + +const Util = imports.misc.util; + +var MESSAGE_ANIMATION_TIME = 100; + +var DEFAULT_EXPAND_LINES = 6; + +function _fixMarkup(text, allowMarkup) { + if (allowMarkup) { + // Support &, ", ', < and >, escape all other + // occurrences of '&'. + let _text = text.replace(/&(?!amp;|quot;|apos;|lt;|gt;)/g, '&'); + + // 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, '<'); + + try { + Pango.parse_markup(_text, -1, ''); + return _text; + } catch (e) {} + } + + // !allowMarkup, or invalid markup + return GLib.markup_escape_text(text, -1); +} + +var URLHighlighter = GObject.registerClass( +class URLHighlighter extends St.Label { + _init(text = '', lineWrap, allowMarkup) { + super._init({ + reactive: true, + style_class: 'url-highlighter', + x_expand: true, + x_align: Clutter.ActorAlign.START, + }); + this._linkColor = '#ccccff'; + this.connect('style-changed', () => { + let [hasColor, color] = this.get_theme_node().lookup_color('link-color', false); + if (hasColor) { + let linkColor = color.to_string().substr(0, 7); + if (linkColor != this._linkColor) { + this._linkColor = linkColor; + this._highlightUrls(); + } + } + }); + this.clutter_text.line_wrap = lineWrap; + this.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR; + + this.setMarkup(text, allowMarkup); + } + + vfunc_button_press_event(buttonEvent) { + // Don't try to URL highlight when invisible. + // The MessageTray doesn't actually hide us, so + // we need to check for paint opacities as well. + if (!this.visible || this.get_paint_opacity() == 0) + return Clutter.EVENT_PROPAGATE; + + // Keep Notification from seeing this and taking + // a pointer grab, which would block our button-release-event + // handler, if an URL is clicked + return this._findUrlAtPos(buttonEvent) != -1; + } + + vfunc_button_release_event(buttonEvent) { + if (!this.visible || this.get_paint_opacity() == 0) + return Clutter.EVENT_PROPAGATE; + + let urlId = this._findUrlAtPos(buttonEvent); + if (urlId != -1) { + let url = this._urls[urlId].url; + if (!url.includes(':')) + url = 'http://%s'.format(url); + + Gio.app_info_launch_default_for_uri( + url, global.create_app_launch_context(0, -1)); + return Clutter.EVENT_STOP; + } + return Clutter.EVENT_PROPAGATE; + } + + vfunc_motion_event(motionEvent) { + if (!this.visible || this.get_paint_opacity() == 0) + return Clutter.EVENT_PROPAGATE; + + let urlId = this._findUrlAtPos(motionEvent); + if (urlId != -1 && !this._cursorChanged) { + global.display.set_cursor(Meta.Cursor.POINTING_HAND); + this._cursorChanged = true; + } else if (urlId == -1) { + global.display.set_cursor(Meta.Cursor.DEFAULT); + this._cursorChanged = false; + } + return Clutter.EVENT_PROPAGATE; + } + + vfunc_leave_event(crossingEvent) { + if (!this.visible || this.get_paint_opacity() == 0) + return Clutter.EVENT_PROPAGATE; + + if (this._cursorChanged) { + this._cursorChanged = false; + global.display.set_cursor(Meta.Cursor.DEFAULT); + } + return super.vfunc_leave_event(crossingEvent); + } + + setMarkup(text, allowMarkup) { + text = text ? _fixMarkup(text, allowMarkup) : ''; + this._text = text; + + this.clutter_text.set_markup(text); + /* clutter_text.text contain text without markup */ + this._urls = Util.findUrls(this.clutter_text.text); + this._highlightUrls(); + } + + _highlightUrls() { + // text here contain markup + let urls = Util.findUrls(this._text); + let markup = ''; + let pos = 0; + for (let i = 0; i < urls.length; i++) { + let url = urls[i]; + let str = this._text.substr(pos, url.pos - pos); + markup += '%s<span foreground="%s"><u>%s</u></span>'.format(str, this._linkColor, url.url); + pos = url.pos + url.url.length; + } + markup += this._text.substr(pos); + this.clutter_text.set_markup(markup); + } + + _findUrlAtPos(event) { + let { x, y } = event; + [, x, y] = this.transform_stage_point(x, y); + let findPos = -1; + for (let i = 0; i < this.clutter_text.text.length; i++) { + let [, px, py, lineHeight] = this.clutter_text.position_to_coords(i); + if (py > y || py + lineHeight < y || x < px) + continue; + findPos = i; + } + if (findPos != -1) { + for (let i = 0; i < this._urls.length; i++) { + if (findPos >= this._urls[i].pos && + this._urls[i].pos + this._urls[i].url.length > findPos) + return i; + } + } + return -1; + } +}); + +var ScaleLayout = GObject.registerClass( +class ScaleLayout extends Clutter.BinLayout { + _init(params) { + this._container = null; + super._init(params); + } + + _connectContainer(container) { + if (this._container == container) + return; + + if (this._container) { + for (let id of this._signals) + this._container.disconnect(id); + } + + this._container = container; + this._signals = []; + + if (this._container) { + for (let signal of ['notify::scale-x', 'notify::scale-y']) { + let id = this._container.connect(signal, () => { + this.layout_changed(); + }); + this._signals.push(id); + } + } + } + + vfunc_get_preferred_width(container, forHeight) { + this._connectContainer(container); + + let [min, nat] = super.vfunc_get_preferred_width(container, forHeight); + return [Math.floor(min * container.scale_x), + Math.floor(nat * container.scale_x)]; + } + + vfunc_get_preferred_height(container, forWidth) { + this._connectContainer(container); + + let [min, nat] = super.vfunc_get_preferred_height(container, forWidth); + return [Math.floor(min * container.scale_y), + Math.floor(nat * container.scale_y)]; + } +}); + +var LabelExpanderLayout = GObject.registerClass({ + Properties: { + 'expansion': GObject.ParamSpec.double('expansion', + 'Expansion', + 'Expansion of the layout, between 0 (collapsed) ' + + 'and 1 (fully expanded', + GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE, + 0, 1, 0), + }, +}, class LabelExpanderLayout extends Clutter.LayoutManager { + _init(params) { + this._expansion = 0; + this._expandLines = DEFAULT_EXPAND_LINES; + + super._init(params); + } + + get expansion() { + return this._expansion; + } + + set expansion(v) { + if (v == this._expansion) + return; + this._expansion = v; + this.notify('expansion'); + + let visibleIndex = this._expansion > 0 ? 1 : 0; + for (let i = 0; this._container && i < this._container.get_n_children(); i++) + this._container.get_child_at_index(i).visible = i == visibleIndex; + + this.layout_changed(); + } + + set expandLines(v) { + if (v == this._expandLines) + return; + this._expandLines = v; + if (this._expansion > 0) + this.layout_changed(); + } + + vfunc_set_container(container) { + this._container = container; + } + + vfunc_get_preferred_width(container, forHeight) { + let [min, nat] = [0, 0]; + + for (let i = 0; i < container.get_n_children(); i++) { + if (i > 1) + break; // we support one unexpanded + one expanded child + + let child = container.get_child_at_index(i); + let [childMin, childNat] = child.get_preferred_width(forHeight); + [min, nat] = [Math.max(min, childMin), Math.max(nat, childNat)]; + } + + return [min, nat]; + } + + vfunc_get_preferred_height(container, forWidth) { + let [min, nat] = [0, 0]; + + let children = container.get_children(); + if (children[0]) + [min, nat] = children[0].get_preferred_height(forWidth); + + if (children[1]) { + let [min2, nat2] = children[1].get_preferred_height(forWidth); + let [expMin, expNat] = [Math.min(min2, min * this._expandLines), + Math.min(nat2, nat * this._expandLines)]; + [min, nat] = [min + this._expansion * (expMin - min), + nat + this._expansion * (expNat - nat)]; + } + + return [min, nat]; + } + + vfunc_allocate(container, box) { + for (let i = 0; i < container.get_n_children(); i++) { + let child = container.get_child_at_index(i); + + if (child.visible) + child.allocate(box); + } + + } +}); + + +var Message = GObject.registerClass({ + Signals: { + 'close': {}, + 'expanded': {}, + 'unexpanded': {}, + }, +}, class Message extends St.Button { + _init(title, body) { + super._init({ + style_class: 'message', + accessible_role: Atk.Role.NOTIFICATION, + can_focus: true, + x_expand: true, + y_expand: true, + }); + + this.expanded = false; + this._useBodyMarkup = false; + + let vbox = new St.BoxLayout({ + vertical: true, + x_expand: true, + }); + this.set_child(vbox); + + let hbox = new St.BoxLayout(); + vbox.add_actor(hbox); + + this._actionBin = new St.Widget({ layout_manager: new ScaleLayout(), + visible: false }); + vbox.add_actor(this._actionBin); + + this._iconBin = new St.Bin({ style_class: 'message-icon-bin', + y_expand: true, + y_align: Clutter.ActorAlign.START, + visible: false }); + hbox.add_actor(this._iconBin); + + let contentBox = new St.BoxLayout({ style_class: 'message-content', + vertical: true, x_expand: true }); + hbox.add_actor(contentBox); + + this._mediaControls = new St.BoxLayout(); + hbox.add_actor(this._mediaControls); + + let titleBox = new St.BoxLayout(); + contentBox.add_actor(titleBox); + + this.titleLabel = new St.Label({ style_class: 'message-title' }); + this.setTitle(title); + titleBox.add_actor(this.titleLabel); + + this._secondaryBin = new St.Bin({ + style_class: 'message-secondary-bin', + x_expand: true, y_expand: true, + }); + titleBox.add_actor(this._secondaryBin); + + let closeIcon = new St.Icon({ icon_name: 'window-close-symbolic', + icon_size: 16 }); + this._closeButton = new St.Button({ + style_class: 'message-close-button', + child: closeIcon, opacity: 0, + }); + titleBox.add_actor(this._closeButton); + + this._bodyStack = new St.Widget({ x_expand: true }); + this._bodyStack.layout_manager = new LabelExpanderLayout(); + contentBox.add_actor(this._bodyStack); + + this.bodyLabel = new URLHighlighter('', false, this._useBodyMarkup); + this.bodyLabel.add_style_class_name('message-body'); + this._bodyStack.add_actor(this.bodyLabel); + this.setBody(body); + + this._closeButton.connect('clicked', this.close.bind(this)); + let actorHoverId = this.connect('notify::hover', this._sync.bind(this)); + this._closeButton.connect('destroy', this.disconnect.bind(this, actorHoverId)); + this.connect('destroy', this._onDestroy.bind(this)); + this._sync(); + } + + close() { + this.emit('close'); + } + + setIcon(actor) { + this._iconBin.child = actor; + this._iconBin.visible = actor != null; + } + + setSecondaryActor(actor) { + this._secondaryBin.child = actor; + } + + setTitle(text) { + let title = text ? _fixMarkup(text.replace(/\n/g, ' '), false) : ''; + this.titleLabel.clutter_text.set_markup(title); + } + + setBody(text) { + this._bodyText = text; + this.bodyLabel.setMarkup(text ? text.replace(/\n/g, ' ') : '', + this._useBodyMarkup); + if (this._expandedLabel) + this._expandedLabel.setMarkup(text, this._useBodyMarkup); + } + + setUseBodyMarkup(enable) { + if (this._useBodyMarkup === enable) + return; + this._useBodyMarkup = enable; + if (this.bodyLabel) + this.setBody(this._bodyText); + } + + setActionArea(actor) { + if (actor == null) { + if (this._actionBin.get_n_children() > 0) + this._actionBin.get_child_at_index(0).destroy(); + return; + } + + if (this._actionBin.get_n_children() > 0) + throw new Error('Message already has an action area'); + + this._actionBin.add_actor(actor); + this._actionBin.visible = this.expanded; + } + + addMediaControl(iconName, callback) { + let icon = new St.Icon({ icon_name: iconName, icon_size: 16 }); + let button = new St.Button({ style_class: 'message-media-control', + child: icon }); + button.connect('clicked', callback); + this._mediaControls.add_actor(button); + return button; + } + + setExpandedBody(actor) { + if (actor == null) { + if (this._bodyStack.get_n_children() > 1) + this._bodyStack.get_child_at_index(1).destroy(); + return; + } + + if (this._bodyStack.get_n_children() > 1) + throw new Error('Message already has an expanded body actor'); + + this._bodyStack.insert_child_at_index(actor, 1); + } + + setExpandedLines(nLines) { + this._bodyStack.layout_manager.expandLines = nLines; + } + + expand(animate) { + this.expanded = true; + + this._actionBin.visible = this._actionBin.get_n_children() > 0; + + if (this._bodyStack.get_n_children() < 2) { + this._expandedLabel = new URLHighlighter(this._bodyText, + true, this._useBodyMarkup); + this.setExpandedBody(this._expandedLabel); + } + + if (animate) { + this._bodyStack.ease_property('@layout.expansion', 1, { + progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: MessageTray.ANIMATION_TIME, + }); + + this._actionBin.scale_y = 0; + this._actionBin.ease({ + scale_y: 1, + duration: MessageTray.ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } else { + this._bodyStack.layout_manager.expansion = 1; + this._actionBin.scale_y = 1; + } + + this.emit('expanded'); + } + + unexpand(animate) { + if (animate) { + this._bodyStack.ease_property('@layout.expansion', 0, { + progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: MessageTray.ANIMATION_TIME, + }); + + this._actionBin.ease({ + scale_y: 0, + duration: MessageTray.ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._actionBin.hide(); + this.expanded = false; + }, + }); + } else { + this._bodyStack.layout_manager.expansion = 0; + this._actionBin.scale_y = 0; + this.expanded = false; + } + + this.emit('unexpanded'); + } + + canClose() { + return false; + } + + _sync() { + let visible = this.hover && this.canClose(); + this._closeButton.opacity = visible ? 255 : 0; + this._closeButton.reactive = visible; + } + + _onDestroy() { + } + + vfunc_key_press_event(keyEvent) { + let keysym = keyEvent.keyval; + + if (keysym == Clutter.KEY_Delete || + keysym == Clutter.KEY_KP_Delete) { + this.close(); + return Clutter.EVENT_STOP; + } + return super.vfunc_key_press_event(keyEvent); + } +}); + +var MessageListSection = GObject.registerClass({ + Properties: { + 'can-clear': GObject.ParamSpec.boolean( + 'can-clear', 'can-clear', 'can-clear', + GObject.ParamFlags.READABLE, + false), + 'empty': GObject.ParamSpec.boolean( + 'empty', 'empty', 'empty', + GObject.ParamFlags.READABLE, + true), + }, + Signals: { + 'can-clear-changed': {}, + 'empty-changed': {}, + 'message-focused': { param_types: [Message.$gtype] }, + }, +}, class MessageListSection extends St.BoxLayout { + _init() { + super._init({ + style_class: 'message-list-section', + clip_to_allocation: true, + vertical: true, + x_expand: true, + }); + + this._list = new St.BoxLayout({ style_class: 'message-list-section-list', + vertical: true }); + this.add_actor(this._list); + + this._list.connect('actor-added', this._sync.bind(this)); + this._list.connect('actor-removed', this._sync.bind(this)); + + let id = Main.sessionMode.connect('updated', + this._sync.bind(this)); + this.connect('destroy', () => { + Main.sessionMode.disconnect(id); + }); + + this._empty = true; + this._canClear = false; + this._sync(); + } + + get empty() { + return this._empty; + } + + get canClear() { + return this._canClear; + } + + get _messages() { + return this._list.get_children().map(i => i.child); + } + + _onKeyFocusIn(messageActor) { + this.emit('message-focused', messageActor); + } + + get allowed() { + return true; + } + + addMessage(message, animate) { + this.addMessageAtIndex(message, -1, animate); + } + + addMessageAtIndex(message, index, animate) { + if (this._messages.includes(message)) + throw new Error('Message was already added previously'); + + let listItem = new St.Bin({ + child: message, + layout_manager: new ScaleLayout(), + pivot_point: new Graphene.Point({ x: .5, y: .5 }), + }); + listItem._connectionsIds = []; + + listItem._connectionsIds.push(message.connect('key-focus-in', + this._onKeyFocusIn.bind(this))); + listItem._connectionsIds.push(message.connect('close', () => { + this.removeMessage(message, true); + })); + listItem._connectionsIds.push(message.connect('destroy', () => { + listItem._connectionsIds.forEach(id => message.disconnect(id)); + listItem.destroy(); + })); + + this._list.insert_child_at_index(listItem, index); + + if (animate) { + listItem.set({ scale_x: 0, scale_y: 0 }); + listItem.ease({ + scale_x: 1, + scale_y: 1, + duration: MESSAGE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + } + + moveMessage(message, index, animate) { + if (!this._messages.includes(message)) + throw new Error(`Impossible to move untracked message`); + + let listItem = message.get_parent(); + + if (!animate) { + this._list.set_child_at_index(listItem, index); + return; + } + + let onComplete = () => { + this._list.set_child_at_index(listItem, index); + listItem.ease({ + scale_x: 1, + scale_y: 1, + duration: MESSAGE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + }; + listItem.ease({ + scale_x: 0, + scale_y: 0, + duration: MESSAGE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete, + }); + } + + removeMessage(message, animate) { + if (!this._messages.includes(message)) + throw new Error(`Impossible to remove untracked message`); + + let listItem = message.get_parent(); + listItem._connectionsIds.forEach(id => message.disconnect(id)); + + if (animate) { + listItem.ease({ + scale_x: 0, + scale_y: 0, + duration: MESSAGE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + listItem.destroy(); + global.sync_pointer(); + }, + }); + } else { + listItem.destroy(); + global.sync_pointer(); + } + } + + clear() { + let messages = this._messages.filter(msg => msg.canClose()); + + // If there are few messages, letting them all zoom out looks OK + if (messages.length < 2) { + messages.forEach(message => { + message.close(); + }); + } else { + // Otherwise we slide them out one by one, and then zoom them + // out "off-screen" in the end to smoothly shrink the parent + let delay = MESSAGE_ANIMATION_TIME / Math.max(messages.length, 5); + for (let i = 0; i < messages.length; i++) { + let message = messages[i]; + message.get_parent().ease({ + translation_x: this._list.width, + opacity: 0, + duration: MESSAGE_ANIMATION_TIME, + delay: i * delay, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => message.close(), + }); + } + } + } + + _shouldShow() { + return !this.empty; + } + + _sync() { + let messages = this._messages; + let empty = messages.length == 0; + + if (this._empty != empty) { + this._empty = empty; + this.notify('empty'); + } + + let canClear = messages.some(m => m.canClose()); + if (this._canClear != canClear) { + this._canClear = canClear; + this.notify('can-clear'); + } + + this.visible = this.allowed && this._shouldShow(); + } +}); diff --git a/js/ui/messageTray.js b/js/ui/messageTray.js new file mode 100644 index 0000000..546079f --- /dev/null +++ b/js/ui/messageTray.js @@ -0,0 +1,1481 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported NotificationPolicy, NotificationGenericPolicy, + NotificationApplicationPolicy, Source, SourceActor, + SystemNotificationSource, MessageTray */ + +const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; + +const Calendar = imports.ui.calendar; +const GnomeSession = imports.misc.gnomeSession; +const Layout = imports.ui.layout; +const Main = imports.ui.main; +const Params = imports.misc.params; + +const SHELL_KEYBINDINGS_SCHEMA = 'org.gnome.shell.keybindings'; + +var ANIMATION_TIME = 200; +var NOTIFICATION_TIMEOUT = 4000; + +var HIDE_TIMEOUT = 200; +var LONGER_HIDE_TIMEOUT = 600; + +var MAX_NOTIFICATIONS_IN_QUEUE = 3; +var MAX_NOTIFICATIONS_PER_SOURCE = 3; +var MAX_NOTIFICATION_BUTTONS = 3; + +// We delay hiding of the tray if the mouse is within MOUSE_LEFT_ACTOR_THRESHOLD +// range from the point where it left the tray. +var MOUSE_LEFT_ACTOR_THRESHOLD = 20; + +var IDLE_TIME = 1000; + +var State = { + HIDDEN: 0, + SHOWING: 1, + SHOWN: 2, + HIDING: 3, +}; + +// These reasons are useful when we destroy the notifications received through +// the notification daemon. We use EXPIRED for notifications that we dismiss +// and the user did not interact with, DISMISSED for all other notifications +// that were destroyed as a result of a user action, SOURCE_CLOSED for the +// notifications that were requested to be destroyed by the associated source, +// and REPLACED for notifications that were destroyed as a consequence of a +// newer version having replaced them. +var NotificationDestroyedReason = { + EXPIRED: 1, + DISMISSED: 2, + SOURCE_CLOSED: 3, + REPLACED: 4, +}; + +// Message tray has its custom Urgency enumeration. LOW, NORMAL and CRITICAL +// urgency values map to the corresponding values for the notifications received +// through the notification daemon. HIGH urgency value is used for chats received +// through the Telepathy client. +var Urgency = { + LOW: 0, + NORMAL: 1, + HIGH: 2, + CRITICAL: 3, +}; + +// The privacy of the details of a notification. USER is for notifications which +// contain private information to the originating user account (for example, +// details of an e-mail they’ve received). SYSTEM is for notifications which +// contain information private to the physical system (for example, battery +// status) and hence the same for every user. This affects whether the content +// of a notification is shown on the lock screen. +var PrivacyScope = { + USER: 0, + SYSTEM: 1, +}; + +var FocusGrabber = class FocusGrabber { + constructor(actor) { + this._actor = actor; + this._prevKeyFocusActor = null; + this._focusActorChangedId = 0; + this._focused = false; + } + + grabFocus() { + if (this._focused) + return; + + this._prevKeyFocusActor = global.stage.get_key_focus(); + + this._focusActorChangedId = global.stage.connect('notify::key-focus', this._focusActorChanged.bind(this)); + + if (!this._actor.navigate_focus(null, St.DirectionType.TAB_FORWARD, false)) + this._actor.grab_key_focus(); + + this._focused = true; + } + + _focusUngrabbed() { + if (!this._focused) + return false; + + if (this._focusActorChangedId > 0) { + global.stage.disconnect(this._focusActorChangedId); + this._focusActorChangedId = 0; + } + + this._focused = false; + return true; + } + + _focusActorChanged() { + let focusedActor = global.stage.get_key_focus(); + if (!focusedActor || !this._actor.contains(focusedActor)) + this._focusUngrabbed(); + } + + ungrabFocus() { + if (!this._focusUngrabbed()) + return; + + if (this._prevKeyFocusActor) { + global.stage.set_key_focus(this._prevKeyFocusActor); + this._prevKeyFocusActor = null; + } else { + let focusedActor = global.stage.get_key_focus(); + if (focusedActor && this._actor.contains(focusedActor)) + global.stage.set_key_focus(null); + } + } +}; + +// NotificationPolicy: +// An object that holds all bits of configurable policy related to a notification +// source, such as whether to play sound or honour the critical bit. +// +// A notification without a policy object will inherit the default one. +var NotificationPolicy = GObject.registerClass({ + GTypeFlags: GObject.TypeFlags.ABSTRACT, + Properties: { + 'enable': GObject.ParamSpec.boolean( + 'enable', 'enable', 'enable', GObject.ParamFlags.READABLE, true), + 'enable-sound': GObject.ParamSpec.boolean( + 'enable-sound', 'enable-sound', 'enable-sound', + GObject.ParamFlags.READABLE, true), + 'show-banners': GObject.ParamSpec.boolean( + 'show-banners', 'show-banners', 'show-banners', + GObject.ParamFlags.READABLE, true), + 'force-expanded': GObject.ParamSpec.boolean( + 'force-expanded', 'force-expanded', 'force-expanded', + GObject.ParamFlags.READABLE, false), + 'show-in-lock-screen': GObject.ParamSpec.boolean( + 'show-in-lock-screen', 'show-in-lock-screen', 'show-in-lock-screen', + GObject.ParamFlags.READABLE, false), + 'details-in-lock-screen': GObject.ParamSpec.boolean( + 'details-in-lock-screen', 'details-in-lock-screen', 'details-in-lock-screen', + GObject.ParamFlags.READABLE, false), + }, +}, class NotificationPolicy extends GObject.Object { + // Do nothing for the default policy. These methods are only useful for the + // GSettings policy. + store() { } + + destroy() { + this.run_dispose(); + } + + get enable() { + return true; + } + + get enableSound() { + return true; + } + + get showBanners() { + return true; + } + + get forceExpanded() { + return false; + } + + get showInLockScreen() { + return false; + } + + get detailsInLockScreen() { + return false; + } +}); + +var NotificationGenericPolicy = GObject.registerClass({ +}, class NotificationGenericPolicy extends NotificationPolicy { + _init() { + super._init(); + this.id = 'generic'; + + this._masterSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.notifications' }); + this._masterSettings.connect('changed', this._changed.bind(this)); + } + + destroy() { + this._masterSettings.run_dispose(); + + super.destroy(); + } + + _changed(settings, key) { + if (this.constructor.find_property(key)) + this.notify(key); + } + + get showBanners() { + return this._masterSettings.get_boolean('show-banners'); + } + + get showInLockScreen() { + return this._masterSettings.get_boolean('show-in-lock-screen'); + } +}); + +var NotificationApplicationPolicy = GObject.registerClass({ +}, class NotificationApplicationPolicy extends NotificationPolicy { + _init(id) { + super._init(); + + this.id = id; + this._canonicalId = this._canonicalizeId(id); + + this._masterSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.notifications' }); + this._settings = new Gio.Settings({ + schema_id: 'org.gnome.desktop.notifications.application', + path: '/org/gnome/desktop/notifications/application/%s/'.format(this._canonicalId), + }); + + this._masterSettings.connect('changed', this._changed.bind(this)); + this._settings.connect('changed', this._changed.bind(this)); + } + + store() { + this._settings.set_string('application-id', '%s.desktop'.format(this.id)); + + let apps = this._masterSettings.get_strv('application-children'); + if (!apps.includes(this._canonicalId)) { + apps.push(this._canonicalId); + this._masterSettings.set_strv('application-children', apps); + } + } + + destroy() { + this._masterSettings.run_dispose(); + this._settings.run_dispose(); + + super.destroy(); + } + + _changed(settings, key) { + if (this.constructor.find_property(key)) + this.notify(key); + } + + _canonicalizeId(id) { + // Keys are restricted to lowercase alphanumeric characters and dash, + // and two dashes cannot be in succession + return id.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/--+/g, '-'); + } + + get enable() { + return this._settings.get_boolean('enable'); + } + + get enableSound() { + return this._settings.get_boolean('enable-sound-alerts'); + } + + get showBanners() { + return this._masterSettings.get_boolean('show-banners') && + this._settings.get_boolean('show-banners'); + } + + get forceExpanded() { + return this._settings.get_boolean('force-expanded'); + } + + get showInLockScreen() { + return this._masterSettings.get_boolean('show-in-lock-screen') && + this._settings.get_boolean('show-in-lock-screen'); + } + + get detailsInLockScreen() { + return this._settings.get_boolean('details-in-lock-screen'); + } +}); + +// Notification: +// @source: the notification's Source +// @title: the title +// @banner: the banner text +// @params: optional additional params +// +// Creates a notification. In the banner mode, the notification +// will show an icon, @title (in bold) and @banner, all on a single +// line (with @banner ellipsized if necessary). +// +// The notification will be expandable if either it has additional +// elements that were added to it or if the @banner text did not +// fit fully in the banner mode. When the notification is expanded, +// the @banner text from the top line is always removed. The complete +// @banner text is added as the first element in the content section, +// unless 'customContent' parameter with the value 'true' is specified +// in @params. +// +// Additional notification content can be added with addActor() and +// addBody() methods. The notification content is put inside a +// scrollview, so if it gets too tall, the notification will scroll +// rather than continue to grow. In addition to this main content +// area, there is also a single-row action area, which is not +// scrolled and can contain a single actor. The action area can +// be set by calling setActionArea() method. There is also a +// convenience method addButton() for adding a button to the action +// area. +// +// If @params contains a 'customContent' parameter with the value %true, +// then @banner will not be shown in the body of the notification when the +// notification is expanded and calls to update() will not clear the content +// unless 'clear' parameter with value %true is explicitly specified. +// +// By default, the icon shown is the same as the source's. +// However, if @params contains a 'gicon' parameter, the passed in gicon +// will be used. +// +// You can add a secondary icon to the banner with 'secondaryGIcon'. There +// is no fallback for this icon. +// +// If @params contains 'bannerMarkup', with the value %true, a subset (<b>, +// <i> and <u>) of the markup in [1] will be interpreted within @banner. If +// the parameter is not present, then anything that looks like markup +// in @banner will appear literally in the output. +// +// If @params contains a 'clear' parameter with the value %true, then +// the content and the action area of the notification will be cleared. +// The content area is also always cleared if 'customContent' is false +// because it might contain the @banner that didn't fit in the banner mode. +// +// If @params contains 'soundName' or 'soundFile', the corresponding +// event sound is played when the notification is shown (if the policy for +// @source allows playing sounds). +// +// [1] https://developer.gnome.org/notification-spec/#markup +var Notification = GObject.registerClass({ + Properties: { + 'acknowledged': GObject.ParamSpec.boolean( + 'acknowledged', 'acknowledged', 'acknowledged', + GObject.ParamFlags.READWRITE, + false), + }, + Signals: { + 'activated': {}, + 'destroy': { param_types: [GObject.TYPE_UINT] }, + 'updated': { param_types: [GObject.TYPE_BOOLEAN] }, + }, +}, class Notification extends GObject.Object { + _init(source, title, banner, params) { + super._init(); + + this.source = source; + this.title = title; + this.urgency = Urgency.NORMAL; + // 'transient' is a reserved keyword in JS, so we have to use an alternate variable name + this.isTransient = false; + this.privacyScope = PrivacyScope.USER; + this.forFeedback = false; + this._acknowledged = false; + this.bannerBodyText = null; + this.bannerBodyMarkup = false; + this._soundName = null; + this._soundFile = null; + this._soundPlayed = false; + this.actions = []; + this.setResident(false); + + // If called with only one argument we assume the caller + // will call .update() later on. This is the case of + // NotificationDaemon, which wants to use the same code + // for new and updated notifications + if (arguments.length != 1) + this.update(title, banner, params); + } + + // update: + // @title: the new title + // @banner: the new banner + // @params: as in the Notification constructor + // + // Updates the notification by regenerating its icon and updating + // the title/banner. If @params.clear is %true, it will also + // remove any additional actors/action buttons previously added. + update(title, banner, params) { + params = Params.parse(params, { gicon: null, + secondaryGIcon: null, + bannerMarkup: false, + clear: false, + datetime: null, + soundName: null, + soundFile: null }); + + this.title = title; + this.bannerBodyText = banner; + this.bannerBodyMarkup = params.bannerMarkup; + + if (params.datetime) + this.datetime = params.datetime; + else + this.datetime = GLib.DateTime.new_now_local(); + + if (params.gicon || params.clear) + this.gicon = params.gicon; + + if (params.secondaryGIcon || params.clear) + this.secondaryGIcon = params.secondaryGIcon; + + if (params.clear) + this.actions = []; + + if (this._soundName != params.soundName || + this._soundFile != params.soundFile) { + this._soundName = params.soundName; + this._soundFile = params.soundFile; + this._soundPlayed = false; + } + + this.emit('updated', params.clear); + } + + // addAction: + // @label: the label for the action's button + // @callback: the callback for the action + addAction(label, callback) { + this.actions.push({ label, callback }); + } + + get acknowledged() { + return this._acknowledged; + } + + set acknowledged(v) { + if (this._acknowledged == v) + return; + this._acknowledged = v; + this.notify('acknowledged'); + } + + setUrgency(urgency) { + this.urgency = urgency; + } + + setResident(resident) { + this.resident = resident; + + if (this.resident) { + if (this._activatedId) { + this.disconnect(this._activatedId); + this._activatedId = 0; + } + } else if (!this._activatedId) { + this._activatedId = this.connect_after('activated', () => this.destroy()); + } + } + + setTransient(isTransient) { + this.isTransient = isTransient; + } + + setForFeedback(forFeedback) { + this.forFeedback = forFeedback; + } + + setPrivacyScope(privacyScope) { + this.privacyScope = privacyScope; + } + + playSound() { + if (this._soundPlayed) + return; + + if (!this.source.policy.enableSound) { + this._soundPlayed = true; + return; + } + + let player = global.display.get_sound_player(); + if (this._soundName) + player.play_from_theme(this._soundName, this.title, null); + else if (this._soundFile) + player.play_from_file(this._soundFile, this.title, null); + } + + // Allow customizing the banner UI: + // the default implementation defers the creation to + // the source (which will create a NotificationBanner), + // so customization can be done by subclassing either + // Notification or Source + createBanner() { + return this.source.createBanner(this); + } + + activate() { + this.emit('activated'); + } + + destroy(reason = NotificationDestroyedReason.DISMISSED) { + if (this._activatedId) { + this.disconnect(this._activatedId); + delete this._activatedId; + } + + this.emit('destroy', reason); + this.run_dispose(); + } +}); + +var NotificationBanner = GObject.registerClass({ + Signals: { + 'done-displaying': {}, + 'unfocused': {}, + }, +}, class NotificationBanner extends Calendar.NotificationMessage { + _init(notification) { + super._init(notification); + + this.can_focus = false; + this.add_style_class_name('notification-banner'); + + this._buttonBox = null; + + this._addActions(); + this._addSecondaryIcon(); + + this._activatedId = this.notification.connect('activated', () => { + // We hide all types of notifications once the user clicks on + // them because the common outcome of clicking should be the + // relevant window being brought forward and the user's + // attention switching to the window. + this.emit('done-displaying'); + }); + } + + _disconnectNotificationSignals() { + super._disconnectNotificationSignals(); + + if (this._activatedId) + this.notification.disconnect(this._activatedId); + this._activatedId = 0; + } + + _onUpdated(n, clear) { + super._onUpdated(n, clear); + + if (clear) { + this.setSecondaryActor(null); + this.setActionArea(null); + this._buttonBox = null; + } + + this._addActions(); + this._addSecondaryIcon(); + } + + _addActions() { + this.notification.actions.forEach(action => { + this.addAction(action.label, action.callback); + }); + } + + _addSecondaryIcon() { + if (this.notification.secondaryGIcon) { + let icon = new St.Icon({ gicon: this.notification.secondaryGIcon, + x_align: Clutter.ActorAlign.END }); + this.setSecondaryActor(icon); + } + } + + addButton(button, callback) { + if (!this._buttonBox) { + this._buttonBox = new St.BoxLayout({ style_class: 'notification-actions', + x_expand: true }); + this.setActionArea(this._buttonBox); + global.focus_manager.add_group(this._buttonBox); + } + + if (this._buttonBox.get_n_children() >= MAX_NOTIFICATION_BUTTONS) + return null; + + this._buttonBox.add(button); + button.connect('clicked', () => { + callback(); + + if (!this.notification.resident) { + // We don't hide a resident notification when the user invokes one of its actions, + // because it is common for such notifications to update themselves with new + // information based on the action. We'd like to display the updated information + // in place, rather than pop-up a new notification. + this.emit('done-displaying'); + this.notification.destroy(); + } + }); + + return button; + } + + addAction(label, callback) { + let button = new St.Button({ style_class: 'notification-button', + label, + x_expand: true, + can_focus: true }); + + return this.addButton(button, callback); + } +}); + +var SourceActor = GObject.registerClass( +class SourceActor extends St.Widget { + _init(source, size) { + super._init(); + + this._source = source; + this._size = size; + + this.connect('destroy', () => { + this._source.disconnect(this._iconUpdatedId); + this._actorDestroyed = true; + }); + this._actorDestroyed = false; + + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + this._iconBin = new St.Bin({ x_expand: true, + height: size * scaleFactor, + width: size * scaleFactor }); + + this.add_actor(this._iconBin); + + this._iconUpdatedId = this._source.connect('icon-updated', this._updateIcon.bind(this)); + this._updateIcon(); + } + + setIcon(icon) { + this._iconBin.child = icon; + this._iconSet = true; + } + + _updateIcon() { + if (this._actorDestroyed) + return; + + if (!this._iconSet) + this._iconBin.child = this._source.createIcon(this._size); + } +}); + +var Source = GObject.registerClass({ + Properties: { + 'count': GObject.ParamSpec.int( + 'count', 'count', 'count', + GObject.ParamFlags.READABLE, + 0, GLib.MAXINT32, 0), + 'policy': GObject.ParamSpec.object( + 'policy', 'policy', 'policy', + GObject.ParamFlags.READWRITE, + NotificationPolicy.$gtype), + 'title': GObject.ParamSpec.string( + 'title', 'title', 'title', + GObject.ParamFlags.READWRITE, + null), + }, + Signals: { + 'destroy': { param_types: [GObject.TYPE_UINT] }, + 'icon-updated': {}, + 'notification-added': { param_types: [Notification.$gtype] }, + 'notification-show': { param_types: [Notification.$gtype] }, + }, +}, class Source extends GObject.Object { + _init(title, iconName) { + super._init({ title }); + + this.SOURCE_ICON_SIZE = 48; + + this.iconName = iconName; + + this.isChat = false; + + this.notifications = []; + + this._policy = this._createPolicy(); + } + + get policy() { + return this._policy; + } + + set policy(policy) { + if (this._policy) + this._policy.destroy(); + this._policy = policy; + } + + get count() { + return this.notifications.length; + } + + get unseenCount() { + return this.notifications.filter(n => !n.acknowledged).length; + } + + get countVisible() { + return this.count > 1; + } + + countUpdated() { + super.notify('count'); + } + + _createPolicy() { + return new NotificationGenericPolicy(); + } + + get narrowestPrivacyScope() { + return this.notifications.every(n => n.privacyScope == PrivacyScope.SYSTEM) + ? PrivacyScope.SYSTEM + : PrivacyScope.USER; + } + + setTitle(newTitle) { + if (this.title == newTitle) + return; + + this.title = newTitle; + this.notify('title'); + } + + createBanner(notification) { + return new NotificationBanner(notification); + } + + // Called to create a new icon actor. + // Provides a sane default implementation, override if you need + // something more fancy. + createIcon(size) { + return new St.Icon({ gicon: this.getIcon(), + icon_size: size }); + } + + getIcon() { + return new Gio.ThemedIcon({ name: this.iconName }); + } + + _onNotificationDestroy(notification) { + let index = this.notifications.indexOf(notification); + if (index < 0) + return; + + this.notifications.splice(index, 1); + this.countUpdated(); + + if (this.notifications.length == 0) + this.destroy(); + } + + pushNotification(notification) { + if (this.notifications.includes(notification)) + return; + + while (this.notifications.length >= MAX_NOTIFICATIONS_PER_SOURCE) + this.notifications.shift().destroy(NotificationDestroyedReason.EXPIRED); + + notification.connect('destroy', this._onNotificationDestroy.bind(this)); + notification.connect('notify::acknowledged', this.countUpdated.bind(this)); + this.notifications.push(notification); + this.emit('notification-added', notification); + + this.countUpdated(); + } + + showNotification(notification) { + notification.acknowledged = false; + this.pushNotification(notification); + + if (this.policy.showBanners || notification.urgency == Urgency.CRITICAL) + this.emit('notification-show', notification); + } + + notify(propName) { + if (propName instanceof Notification) { + try { + throw new Error('Source.notify() has been moved to Source.showNotification()' + + 'this code will break in the future'); + } catch (e) { + logError(e); + this.showNotification(propName); + return; + } + } + + super.notify(propName); + } + + destroy(reason) { + let notifications = this.notifications; + this.notifications = []; + + for (let i = 0; i < notifications.length; i++) + notifications[i].destroy(reason); + + this.emit('destroy', reason); + + this.policy.destroy(); + this.run_dispose(); + } + + iconUpdated() { + this.emit('icon-updated'); + } + + // To be overridden by subclasses + open() { + } + + destroyNonResidentNotifications() { + for (let i = this.notifications.length - 1; i >= 0; i--) { + if (!this.notifications[i].resident) + this.notifications[i].destroy(); + } + } +}); + +var MessageTray = GObject.registerClass({ + Signals: { + 'queue-changed': {}, + 'source-added': { param_types: [Source.$gtype] }, + 'source-removed': { param_types: [Source.$gtype] }, + }, +}, class MessageTray extends St.Widget { + _init() { + super._init({ + visible: false, + clip_to_allocation: true, + layout_manager: new Clutter.BinLayout(), + }); + + this._presence = new GnomeSession.Presence((proxy, _error) => { + this._onStatusChanged(proxy.status); + }); + this._busy = false; + this._bannerBlocked = false; + this._presence.connectSignal('StatusChanged', (proxy, senderName, [status]) => { + this._onStatusChanged(status); + }); + + global.stage.connect('enter-event', (a, ev) => { + // HACK: St uses ClutterInputDevice for hover tracking, which + // misses relevant X11 events when untracked actors are + // involved (read: the notification banner in normal mode), + // so fix up Clutter's view of the pointer position in + // that case. + let related = ev.get_related(); + if (!related || this.contains(related)) + global.sync_pointer(); + }); + + let constraint = new Layout.MonitorConstraint({ primary: true }); + Main.layoutManager.panelBox.bind_property('visible', + constraint, 'work-area', + GObject.BindingFlags.SYNC_CREATE); + this.add_constraint(constraint); + + this._bannerBin = new St.Widget({ name: 'notification-container', + reactive: true, + track_hover: true, + y_align: Clutter.ActorAlign.START, + x_align: Clutter.ActorAlign.CENTER, + y_expand: true, + x_expand: true, + layout_manager: new Clutter.BinLayout() }); + this._bannerBin.connect('key-release-event', + this._onNotificationKeyRelease.bind(this)); + this._bannerBin.connect('notify::hover', + this._onNotificationHoverChanged.bind(this)); + this.add_actor(this._bannerBin); + + this._notificationFocusGrabber = new FocusGrabber(this._bannerBin); + this._notificationQueue = []; + this._notification = null; + this._banner = null; + this._bannerClickedId = 0; + + this._userActiveWhileNotificationShown = false; + + this.idleMonitor = Meta.IdleMonitor.get_core(); + + this._useLongerNotificationLeftTimeout = false; + + // pointerInNotification is sort of a misnomer -- it tracks whether + // a message tray notification should expand. The value is + // partially driven by the hover state of the notification, but has + // a lot of complex state related to timeouts and the current + // state of the pointer when a notification pops up. + this._pointerInNotification = false; + + // This tracks this._bannerBin.hover and is used to fizzle + // out non-changing hover notifications in onNotificationHoverChanged. + this._notificationHovered = false; + + this._notificationState = State.HIDDEN; + this._notificationTimeoutId = 0; + this._notificationRemoved = false; + + Main.layoutManager.addChrome(this, { affectsInputRegion: false }); + Main.layoutManager.trackChrome(this._bannerBin, { affectsInputRegion: true }); + + global.display.connect('in-fullscreen-changed', this._updateState.bind(this)); + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + + Main.overview.connect('window-drag-begin', + this._onDragBegin.bind(this)); + Main.overview.connect('window-drag-cancelled', + this._onDragEnd.bind(this)); + Main.overview.connect('window-drag-end', + this._onDragEnd.bind(this)); + + Main.overview.connect('item-drag-begin', + this._onDragBegin.bind(this)); + Main.overview.connect('item-drag-cancelled', + this._onDragEnd.bind(this)); + Main.overview.connect('item-drag-end', + this._onDragEnd.bind(this)); + + Main.xdndHandler.connect('drag-begin', + this._onDragBegin.bind(this)); + Main.xdndHandler.connect('drag-end', + this._onDragEnd.bind(this)); + + Main.wm.addKeybinding('focus-active-notification', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.NONE, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._expandActiveNotification.bind(this)); + + this._sources = new Map(); + + this._sessionUpdated(); + } + + _sessionUpdated() { + this._updateState(); + } + + _onDragBegin() { + Shell.util_set_hidden_from_pick(this, true); + } + + _onDragEnd() { + Shell.util_set_hidden_from_pick(this, false); + } + + get bannerAlignment() { + return this._bannerBin.get_x_align(); + } + + set bannerAlignment(align) { + this._bannerBin.set_x_align(align); + } + + _onNotificationKeyRelease(actor, event) { + if (event.get_key_symbol() == Clutter.KEY_Escape && event.get_state() == 0) { + this._expireNotification(); + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_PROPAGATE; + } + + _expireNotification() { + this._notificationExpired = true; + this._updateState(); + } + + get queueCount() { + return this._notificationQueue.length; + } + + set bannerBlocked(v) { + if (this._bannerBlocked == v) + return; + this._bannerBlocked = v; + this._updateState(); + } + + contains(source) { + return this._sources.has(source); + } + + add(source) { + if (this.contains(source)) { + log('Trying to re-add source %s'.format(source.title)); + return; + } + + // Register that we got a notification for this source + source.policy.store(); + + source.policy.connect('notify::enable', () => { + this._onSourceEnableChanged(source.policy, source); + }); + source.policy.connect('notify', this._updateState.bind(this)); + this._onSourceEnableChanged(source.policy, source); + } + + _addSource(source) { + let obj = { + showId: 0, + destroyId: 0, + }; + + this._sources.set(source, obj); + + obj.showId = source.connect('notification-show', this._onNotificationShow.bind(this)); + obj.destroyId = source.connect('destroy', this._onSourceDestroy.bind(this)); + + this.emit('source-added', source); + } + + _removeSource(source) { + let obj = this._sources.get(source); + this._sources.delete(source); + + source.disconnect(obj.showId); + source.disconnect(obj.destroyId); + + this.emit('source-removed', source); + } + + getSources() { + return [...this._sources.keys()]; + } + + _onSourceEnableChanged(policy, source) { + let wasEnabled = this.contains(source); + let shouldBeEnabled = policy.enable; + + if (wasEnabled != shouldBeEnabled) { + if (shouldBeEnabled) + this._addSource(source); + else + this._removeSource(source); + } + } + + _onSourceDestroy(source) { + this._removeSource(source); + } + + _onNotificationDestroy(notification) { + if (this._notification == notification && (this._notificationState == State.SHOWN || this._notificationState == State.SHOWING)) { + this._updateNotificationTimeout(0); + this._notificationRemoved = true; + this._updateState(); + return; + } + + let index = this._notificationQueue.indexOf(notification); + if (index != -1) { + this._notificationQueue.splice(index, 1); + this.emit('queue-changed'); + } + } + + _onNotificationShow(_source, notification) { + if (this._notification == notification) { + // If a notification that is being shown is updated, we update + // how it is shown and extend the time until it auto-hides. + // If a new notification is updated while it is being hidden, + // we stop hiding it and show it again. + this._updateShowingNotification(); + } else if (!this._notificationQueue.includes(notification)) { + // If the queue is "full", we skip banner mode and just show a small + // indicator in the panel; however do make an exception for CRITICAL + // notifications, as only banner mode allows expansion. + let bannerCount = this._notification ? 1 : 0; + let full = this.queueCount + bannerCount >= MAX_NOTIFICATIONS_IN_QUEUE; + if (!full || notification.urgency == Urgency.CRITICAL) { + notification.connect('destroy', + this._onNotificationDestroy.bind(this)); + this._notificationQueue.push(notification); + this._notificationQueue.sort( + (n1, n2) => n2.urgency - n1.urgency); + this.emit('queue-changed'); + } + } + this._updateState(); + } + + _resetNotificationLeftTimeout() { + this._useLongerNotificationLeftTimeout = false; + if (this._notificationLeftTimeoutId) { + GLib.source_remove(this._notificationLeftTimeoutId); + this._notificationLeftTimeoutId = 0; + this._notificationLeftMouseX = -1; + this._notificationLeftMouseY = -1; + } + } + + _onNotificationHoverChanged() { + if (this._bannerBin.hover == this._notificationHovered) + return; + + this._notificationHovered = this._bannerBin.hover; + if (this._notificationHovered) { + this._resetNotificationLeftTimeout(); + + if (this._showNotificationMouseX >= 0) { + let actorAtShowNotificationPosition = + global.stage.get_actor_at_pos(Clutter.PickMode.ALL, this._showNotificationMouseX, this._showNotificationMouseY); + this._showNotificationMouseX = -1; + this._showNotificationMouseY = -1; + // Don't set this._pointerInNotification to true if the pointer was initially in the area where the notification + // popped up. That way we will not be expanding notifications that happen to pop up over the pointer + // automatically. Instead, the user is able to expand the notification by mousing away from it and then + // mousing back in. Because this is an expected action, we set the boolean flag that indicates that a longer + // timeout should be used before popping down the notification. + if (this._bannerBin.contains(actorAtShowNotificationPosition)) { + this._useLongerNotificationLeftTimeout = true; + return; + } + } + + this._pointerInNotification = true; + this._updateState(); + } else { + // We record the position of the mouse the moment it leaves the tray. These coordinates are used in + // this._onNotificationLeftTimeout() to determine if the mouse has moved far enough during the initial timeout for us + // to consider that the user intended to leave the tray and therefore hide the tray. If the mouse is still + // close to its previous position, we extend the timeout once. + let [x, y] = global.get_pointer(); + this._notificationLeftMouseX = x; + this._notificationLeftMouseY = y; + + // We wait just a little before hiding the message tray in case the user quickly moves the mouse back into it. + // We wait for a longer period if the notification popped up where the mouse pointer was already positioned. + // That gives the user more time to mouse away from the notification and mouse back in in order to expand it. + let timeout = this._useLongerNotificationLeftTimeout ? LONGER_HIDE_TIMEOUT : HIDE_TIMEOUT; + this._notificationLeftTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, timeout, this._onNotificationLeftTimeout.bind(this)); + GLib.Source.set_name_by_id(this._notificationLeftTimeoutId, '[gnome-shell] this._onNotificationLeftTimeout'); + } + } + + _onStatusChanged(status) { + if (status == GnomeSession.PresenceStatus.BUSY) { + // remove notification and allow the summary to be closed now + this._updateNotificationTimeout(0); + this._busy = true; + } else if (status != GnomeSession.PresenceStatus.IDLE) { + // We preserve the previous value of this._busy if the status turns to IDLE + // so that we don't start showing notifications queued during the BUSY state + // as the screensaver gets activated. + this._busy = false; + } + + this._updateState(); + } + + _onNotificationLeftTimeout() { + let [x, y] = global.get_pointer(); + // We extend the timeout once if the mouse moved no further than MOUSE_LEFT_ACTOR_THRESHOLD to either side. + if (this._notificationLeftMouseX > -1 && + y < this._notificationLeftMouseY + MOUSE_LEFT_ACTOR_THRESHOLD && + y > this._notificationLeftMouseY - MOUSE_LEFT_ACTOR_THRESHOLD && + x < this._notificationLeftMouseX + MOUSE_LEFT_ACTOR_THRESHOLD && + x > this._notificationLeftMouseX - MOUSE_LEFT_ACTOR_THRESHOLD) { + this._notificationLeftMouseX = -1; + this._notificationLeftTimeoutId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + LONGER_HIDE_TIMEOUT, + this._onNotificationLeftTimeout.bind(this)); + GLib.Source.set_name_by_id(this._notificationLeftTimeoutId, '[gnome-shell] this._onNotificationLeftTimeout'); + } else { + this._notificationLeftTimeoutId = 0; + this._useLongerNotificationLeftTimeout = false; + this._pointerInNotification = false; + this._updateNotificationTimeout(0); + this._updateState(); + } + return GLib.SOURCE_REMOVE; + } + + _escapeTray() { + this._pointerInNotification = false; + this._updateNotificationTimeout(0); + this._updateState(); + } + + // All of the logic for what happens when occurs here; the various + // event handlers merely update variables such as + // 'this._pointerInNotification', 'this._traySummoned', etc, and + // _updateState() figures out what (if anything) needs to be done + // at the present time. + _updateState() { + let hasMonitor = Main.layoutManager.primaryMonitor != null; + this.visible = !this._bannerBlocked && hasMonitor && this._banner != null; + if (this._bannerBlocked || !hasMonitor) + return; + + // If our state changes caused _updateState to be called, + // just exit now to prevent reentrancy issues. + if (this._updatingState) + return; + + this._updatingState = true; + + // Filter out acknowledged notifications. + let changed = false; + this._notificationQueue = this._notificationQueue.filter(n => { + changed = changed || n.acknowledged; + return !n.acknowledged; + }); + + if (changed) + this.emit('queue-changed'); + + let hasNotifications = Main.sessionMode.hasNotifications; + + if (this._notificationState == State.HIDDEN) { + let nextNotification = this._notificationQueue[0] || null; + if (hasNotifications && nextNotification) { + let limited = this._busy || Main.layoutManager.primaryMonitor.inFullscreen; + let showNextNotification = !limited || nextNotification.forFeedback || nextNotification.urgency == Urgency.CRITICAL; + if (showNextNotification) + this._showNotification(); + } + } else if (this._notificationState == State.SHOWN) { + let expired = (this._userActiveWhileNotificationShown && + this._notificationTimeoutId == 0 && + this._notification.urgency != Urgency.CRITICAL && + !this._banner.focused && + !this._pointerInNotification) || this._notificationExpired; + let mustClose = this._notificationRemoved || !hasNotifications || expired; + + if (mustClose) { + let animate = hasNotifications && !this._notificationRemoved; + this._hideNotification(animate); + } else if (this._pointerInNotification && !this._banner.expanded) { + this._expandBanner(false); + } else if (this._pointerInNotification) { + this._ensureBannerFocused(); + } + } + + this._updatingState = false; + + // Clean transient variables that are used to communicate actions + // to updateState() + this._notificationExpired = false; + } + + _onIdleMonitorBecameActive() { + this._userActiveWhileNotificationShown = true; + this._updateNotificationTimeout(2000); + this._updateState(); + } + + _showNotification() { + this._notification = this._notificationQueue.shift(); + this.emit('queue-changed'); + + this._userActiveWhileNotificationShown = this.idleMonitor.get_idletime() <= IDLE_TIME; + if (!this._userActiveWhileNotificationShown) { + // If the user isn't active, set up a watch to let us know + // when the user becomes active. + this.idleMonitor.add_user_active_watch(this._onIdleMonitorBecameActive.bind(this)); + } + + this._banner = this._notification.createBanner(); + this._bannerClickedId = this._banner.connect('done-displaying', + this._escapeTray.bind(this)); + this._bannerUnfocusedId = this._banner.connect('unfocused', () => { + this._updateState(); + }); + + this._bannerBin.add_actor(this._banner); + + this._bannerBin.opacity = 0; + this._bannerBin.y = -this._banner.height; + this.show(); + + Meta.disable_unredirect_for_display(global.display); + this._updateShowingNotification(); + + let [x, y] = global.get_pointer(); + // We save the position of the mouse at the time when we started showing the notification + // in order to determine if the notification popped up under it. We make that check if + // the user starts moving the mouse and _onNotificationHoverChanged() gets called. We don't + // expand the notification if it just happened to pop up under the mouse unless the user + // explicitly mouses away from it and then mouses back in. + this._showNotificationMouseX = x; + this._showNotificationMouseY = y; + // We save the coordinates of the mouse at the time when we started showing the notification + // and then we update it in _notificationTimeout(). We don't pop down the notification if + // the mouse is moving towards it or within it. + this._lastSeenMouseX = x; + this._lastSeenMouseY = y; + + this._resetNotificationLeftTimeout(); + } + + _updateShowingNotification() { + this._notification.acknowledged = true; + this._notification.playSound(); + + // We auto-expand notifications with CRITICAL urgency, or for which the relevant setting + // is on in the control center. + if (this._notification.urgency == Urgency.CRITICAL || + this._notification.source.policy.forceExpanded) + this._expandBanner(true); + + // We tween all notifications to full opacity. This ensures that both new notifications and + // notifications that might have been in the process of hiding get full opacity. + // + // We tween any notification showing in the banner mode to the appropriate height + // (which is banner height or expanded height, depending on the notification state) + // This ensures that both new notifications and notifications in the banner mode that might + // have been in the process of hiding are shown with the correct height. + // + // We use this._showNotificationCompleted() onComplete callback to extend the time the updated + // notification is being shown. + + this._notificationState = State.SHOWING; + this._bannerBin.remove_all_transitions(); + this._bannerBin.ease({ + opacity: 255, + duration: ANIMATION_TIME, + mode: Clutter.AnimationMode.LINEAR, + }); + this._bannerBin.ease({ + y: 0, + duration: ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_BACK, + onComplete: () => { + this._notificationState = State.SHOWN; + this._showNotificationCompleted(); + this._updateState(); + }, + }); + } + + _showNotificationCompleted() { + if (this._notification.urgency != Urgency.CRITICAL) + this._updateNotificationTimeout(NOTIFICATION_TIMEOUT); + } + + _updateNotificationTimeout(timeout) { + if (this._notificationTimeoutId) { + GLib.source_remove(this._notificationTimeoutId); + this._notificationTimeoutId = 0; + } + if (timeout > 0) { + this._notificationTimeoutId = + GLib.timeout_add(GLib.PRIORITY_DEFAULT, timeout, + this._notificationTimeout.bind(this)); + GLib.Source.set_name_by_id(this._notificationTimeoutId, '[gnome-shell] this._notificationTimeout'); + } + } + + _notificationTimeout() { + let [x, y] = global.get_pointer(); + if (y < this._lastSeenMouseY - 10 && !this._notificationHovered) { + // The mouse is moving towards the notification, so don't + // hide it yet. (We just create a new timeout (and destroy + // the old one) each time because the bookkeeping is + // simpler.) + this._updateNotificationTimeout(1000); + } else if (this._useLongerNotificationLeftTimeout && !this._notificationLeftTimeoutId && + (x != this._lastSeenMouseX || y != this._lastSeenMouseY)) { + // Refresh the timeout if the notification originally + // popped up under the pointer, and the pointer is hovering + // inside it. + this._updateNotificationTimeout(1000); + } else { + this._notificationTimeoutId = 0; + this._updateState(); + } + + this._lastSeenMouseX = x; + this._lastSeenMouseY = y; + return GLib.SOURCE_REMOVE; + } + + _hideNotification(animate) { + this._notificationFocusGrabber.ungrabFocus(); + + if (this._bannerClickedId) { + this._banner.disconnect(this._bannerClickedId); + this._bannerClickedId = 0; + } + if (this._bannerUnfocusedId) { + this._banner.disconnect(this._bannerUnfocusedId); + this._bannerUnfocusedId = 0; + } + + this._resetNotificationLeftTimeout(); + this._bannerBin.remove_all_transitions(); + + if (animate) { + this._notificationState = State.HIDING; + this._bannerBin.ease({ + opacity: 0, + duration: ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_BACK, + }); + this._bannerBin.ease({ + y: -this._bannerBin.height, + duration: ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_BACK, + onComplete: () => { + this._notificationState = State.HIDDEN; + this._hideNotificationCompleted(); + this._updateState(); + }, + }); + } else { + this._bannerBin.y = -this._bannerBin.height; + this._bannerBin.opacity = 0; + this._notificationState = State.HIDDEN; + this._hideNotificationCompleted(); + } + } + + _hideNotificationCompleted() { + let notification = this._notification; + this._notification = null; + if (!this._notificationRemoved && notification.isTransient) + notification.destroy(NotificationDestroyedReason.EXPIRED); + + this._pointerInNotification = false; + this._notificationRemoved = false; + Meta.enable_unredirect_for_display(global.display); + + this._banner.destroy(); + this._banner = null; + this.hide(); + } + + _expandActiveNotification() { + if (!this._banner) + return; + + this._expandBanner(false); + } + + _expandBanner(autoExpanding) { + // Don't animate changes in notifications that are auto-expanding. + this._banner.expand(!autoExpanding); + + // Don't focus notifications that are auto-expanding. + if (!autoExpanding) + this._ensureBannerFocused(); + } + + _ensureBannerFocused() { + this._notificationFocusGrabber.grabFocus(); + } +}); + +var SystemNotificationSource = GObject.registerClass( +class SystemNotificationSource extends Source { + _init() { + super._init(_("System Information"), 'dialog-information-symbolic'); + } + + open() { + this.destroy(); + } +}); diff --git a/js/ui/modalDialog.js b/js/ui/modalDialog.js new file mode 100644 index 0000000..caa8744 --- /dev/null +++ b/js/ui/modalDialog.js @@ -0,0 +1,264 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported ModalDialog */ + +const { Atk, Clutter, GObject, Shell, St } = imports.gi; + +const Dialog = imports.ui.dialog; +const Layout = imports.ui.layout; +const Lightbox = imports.ui.lightbox; +const Main = imports.ui.main; +const Params = imports.misc.params; + +var OPEN_AND_CLOSE_TIME = 100; +var FADE_OUT_DIALOG_TIME = 1000; + +var State = { + OPENED: 0, + CLOSED: 1, + OPENING: 2, + CLOSING: 3, + FADED_OUT: 4, +}; + +var ModalDialog = GObject.registerClass({ + Properties: { + 'state': GObject.ParamSpec.int('state', 'Dialog state', 'state', + GObject.ParamFlags.READABLE, + Math.min(...Object.values(State)), + Math.max(...Object.values(State)), + State.CLOSED), + }, + Signals: { 'opened': {}, 'closed': {} }, +}, class ModalDialog extends St.Widget { + _init(params) { + super._init({ visible: false, + x: 0, + y: 0, + accessible_role: Atk.Role.DIALOG }); + + params = Params.parse(params, { shellReactive: false, + styleClass: null, + actionMode: Shell.ActionMode.SYSTEM_MODAL, + shouldFadeIn: true, + shouldFadeOut: true, + destroyOnClose: true }); + + this._state = State.CLOSED; + this._hasModal = false; + this._actionMode = params.actionMode; + this._shellReactive = params.shellReactive; + this._shouldFadeIn = params.shouldFadeIn; + this._shouldFadeOut = params.shouldFadeOut; + this._destroyOnClose = params.destroyOnClose; + + Main.layoutManager.modalDialogGroup.add_actor(this); + + let constraint = new Clutter.BindConstraint({ source: global.stage, + coordinate: Clutter.BindCoordinate.ALL }); + this.add_constraint(constraint); + + this.backgroundStack = new St.Widget({ + layout_manager: new Clutter.BinLayout(), + x_expand: true, + y_expand: true, + }); + this._backgroundBin = new St.Bin({ child: this.backgroundStack }); + this._monitorConstraint = new Layout.MonitorConstraint(); + this._backgroundBin.add_constraint(this._monitorConstraint); + this.add_actor(this._backgroundBin); + + this.dialogLayout = new Dialog.Dialog(this.backgroundStack, params.styleClass); + this.contentLayout = this.dialogLayout.contentLayout; + this.buttonLayout = this.dialogLayout.buttonLayout; + + if (!this._shellReactive) { + this._lightbox = new Lightbox.Lightbox(this, + { inhibitEvents: true, + radialEffect: true }); + this._lightbox.highlight(this._backgroundBin); + + this._eventBlocker = new Clutter.Actor({ reactive: true }); + this.backgroundStack.add_actor(this._eventBlocker); + } + + global.focus_manager.add_group(this.dialogLayout); + this._initialKeyFocus = null; + this._initialKeyFocusDestroyId = 0; + this._savedKeyFocus = null; + } + + get state() { + return this._state; + } + + _setState(state) { + if (this._state == state) + return; + + this._state = state; + this.notify('state'); + } + + clearButtons() { + this.dialogLayout.clearButtons(); + } + + setButtons(buttons) { + this.clearButtons(); + + for (let buttonInfo of buttons) + this.addButton(buttonInfo); + } + + addButton(buttonInfo) { + return this.dialogLayout.addButton(buttonInfo); + } + + _fadeOpen(onPrimary) { + if (onPrimary) + this._monitorConstraint.primary = true; + else + this._monitorConstraint.index = global.display.get_current_monitor(); + + this._setState(State.OPENING); + + this.dialogLayout.opacity = 255; + if (this._lightbox) + this._lightbox.lightOn(); + this.opacity = 0; + this.show(); + this.ease({ + opacity: 255, + duration: this._shouldFadeIn ? OPEN_AND_CLOSE_TIME : 0, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._setState(State.OPENED); + this.emit('opened'); + }, + }); + } + + setInitialKeyFocus(actor) { + if (this._initialKeyFocusDestroyId) + this._initialKeyFocus.disconnect(this._initialKeyFocusDestroyId); + + this._initialKeyFocus = actor; + + this._initialKeyFocusDestroyId = actor.connect('destroy', () => { + this._initialKeyFocus = null; + this._initialKeyFocusDestroyId = 0; + }); + } + + open(timestamp, onPrimary) { + if (this.state == State.OPENED || this.state == State.OPENING) + return true; + + if (!this.pushModal(timestamp)) + return false; + + this._fadeOpen(onPrimary); + return true; + } + + _closeComplete() { + this._setState(State.CLOSED); + this.hide(); + this.emit('closed'); + + if (this._destroyOnClose) + this.destroy(); + } + + close(timestamp) { + if (this.state == State.CLOSED || this.state == State.CLOSING) + return; + + this._setState(State.CLOSING); + this.popModal(timestamp); + this._savedKeyFocus = null; + + if (this._shouldFadeOut) { + this.ease({ + opacity: 0, + duration: OPEN_AND_CLOSE_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._closeComplete(), + }); + } else { + this._closeComplete(); + } + } + + // Drop modal status without closing the dialog; this makes the + // dialog insensitive as well, so it needs to be followed shortly + // by either a close() or a pushModal() + popModal(timestamp) { + if (!this._hasModal) + return; + + let focus = global.stage.key_focus; + if (focus && this.contains(focus)) + this._savedKeyFocus = focus; + else + this._savedKeyFocus = null; + Main.popModal(this, timestamp); + this._hasModal = false; + + if (!this._shellReactive) + this.backgroundStack.set_child_above_sibling(this._eventBlocker, null); + } + + pushModal(timestamp) { + if (this._hasModal) + return true; + + let params = { actionMode: this._actionMode }; + if (timestamp) + params['timestamp'] = timestamp; + if (!Main.pushModal(this, params)) + return false; + + Main.layoutManager.emit('system-modal-opened'); + + this._hasModal = true; + if (this._savedKeyFocus) { + this._savedKeyFocus.grab_key_focus(); + this._savedKeyFocus = null; + } else { + let focus = this._initialKeyFocus || this.dialogLayout.initialKeyFocus; + focus.grab_key_focus(); + } + + if (!this._shellReactive) + this.backgroundStack.set_child_below_sibling(this._eventBlocker, null); + return true; + } + + // This method is like close, but fades the dialog out much slower, + // and leaves the lightbox in place. Once in the faded out state, + // the dialog can be brought back by an open call, or the lightbox + // can be dismissed by a close call. + // + // The main point of this method is to give some indication to the user + // that the dialog response has been acknowledged but will take a few + // moments before being processed. + // e.g., if a user clicked "Log Out" then the dialog should go away + // immediately, but the lightbox should remain until the logout is + // complete. + _fadeOutDialog(timestamp) { + if (this.state == State.CLOSED || this.state == State.CLOSING) + return; + + if (this.state == State.FADED_OUT) + return; + + this.popModal(timestamp); + this.dialogLayout.ease({ + opacity: 0, + duration: FADE_OUT_DIALOG_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => (this.state = State.FADED_OUT), + }); + } +}); diff --git a/js/ui/mpris.js b/js/ui/mpris.js new file mode 100644 index 0000000..3650c57 --- /dev/null +++ b/js/ui/mpris.js @@ -0,0 +1,300 @@ +/* exported MediaSection */ +const { Gio, GObject, Shell, St } = imports.gi; +const Signals = imports.signals; + +const Main = imports.ui.main; +const MessageList = imports.ui.messageList; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const DBusIface = loadInterfaceXML('org.freedesktop.DBus'); +const DBusProxy = Gio.DBusProxy.makeProxyWrapper(DBusIface); + +const MprisIface = loadInterfaceXML('org.mpris.MediaPlayer2'); +const MprisProxy = Gio.DBusProxy.makeProxyWrapper(MprisIface); + +const MprisPlayerIface = loadInterfaceXML('org.mpris.MediaPlayer2.Player'); +const MprisPlayerProxy = Gio.DBusProxy.makeProxyWrapper(MprisPlayerIface); + +const MPRIS_PLAYER_PREFIX = 'org.mpris.MediaPlayer2.'; + +var MediaMessage = GObject.registerClass( +class MediaMessage extends MessageList.Message { + _init(player) { + super._init('', ''); + + this._player = player; + + this._icon = new St.Icon({ style_class: 'media-message-cover-icon' }); + this.setIcon(this._icon); + + this._prevButton = this.addMediaControl('media-skip-backward-symbolic', + () => { + this._player.previous(); + }); + + this._playPauseButton = this.addMediaControl(null, + () => { + this._player.playPause(); + }); + + this._nextButton = this.addMediaControl('media-skip-forward-symbolic', + () => { + this._player.next(); + }); + + this._updateHandlerId = + this._player.connect('changed', this._update.bind(this)); + this._closedHandlerId = + this._player.connect('closed', this.close.bind(this)); + this._update(); + } + + _onDestroy() { + super._onDestroy(); + this._player.disconnect(this._updateHandlerId); + this._player.disconnect(this._closedHandlerId); + } + + vfunc_clicked() { + this._player.raise(); + Main.panel.closeCalendar(); + } + + _updateNavButton(button, sensitive) { + button.reactive = sensitive; + } + + _update() { + this.setTitle(this._player.trackArtists.join(', ')); + this.setBody(this._player.trackTitle); + + if (this._player.trackCoverUrl) { + let file = Gio.File.new_for_uri(this._player.trackCoverUrl); + this._icon.gicon = new Gio.FileIcon({ file }); + this._icon.remove_style_class_name('fallback'); + } else { + this._icon.icon_name = 'audio-x-generic-symbolic'; + this._icon.add_style_class_name('fallback'); + } + + let isPlaying = this._player.status == 'Playing'; + let iconName = isPlaying + ? 'media-playback-pause-symbolic' + : 'media-playback-start-symbolic'; + this._playPauseButton.child.icon_name = iconName; + + this._updateNavButton(this._prevButton, this._player.canGoPrevious); + this._updateNavButton(this._nextButton, this._player.canGoNext); + } +}); + +var MprisPlayer = class MprisPlayer { + constructor(busName) { + this._mprisProxy = new MprisProxy(Gio.DBus.session, busName, + '/org/mpris/MediaPlayer2', + this._onMprisProxyReady.bind(this)); + this._playerProxy = new MprisPlayerProxy(Gio.DBus.session, busName, + '/org/mpris/MediaPlayer2', + this._onPlayerProxyReady.bind(this)); + + this._visible = false; + this._trackArtists = []; + this._trackTitle = ''; + this._trackCoverUrl = ''; + this._busName = busName; + } + + get status() { + return this._playerProxy.PlaybackStatus; + } + + get trackArtists() { + return this._trackArtists; + } + + get trackTitle() { + return this._trackTitle; + } + + get trackCoverUrl() { + return this._trackCoverUrl; + } + + playPause() { + this._playerProxy.PlayPauseRemote(); + } + + get canGoNext() { + return this._playerProxy.CanGoNext; + } + + next() { + this._playerProxy.NextRemote(); + } + + get canGoPrevious() { + return this._playerProxy.CanGoPrevious; + } + + previous() { + this._playerProxy.PreviousRemote(); + } + + raise() { + // The remote Raise() method may run into focus stealing prevention, + // so prefer activating the app via .desktop file if possible + let app = null; + if (this._mprisProxy.DesktopEntry) { + let desktopId = '%s.desktop'.format(this._mprisProxy.DesktopEntry); + app = Shell.AppSystem.get_default().lookup_app(desktopId); + } + + if (app) + app.activate(); + else if (this._mprisProxy.CanRaise) + this._mprisProxy.RaiseRemote(); + } + + _close() { + this._mprisProxy.disconnect(this._ownerNotifyId); + this._mprisProxy = null; + + this._playerProxy.disconnect(this._propsChangedId); + this._playerProxy = null; + + this.emit('closed'); + } + + _onMprisProxyReady() { + this._ownerNotifyId = this._mprisProxy.connect('notify::g-name-owner', + () => { + if (!this._mprisProxy.g_name_owner) + this._close(); + }); + // It is possible for the bus to disappear before the previous signal + // is connected, so we must ensure that the bus still exists at this + // point. + if (!this._mprisProxy.g_name_owner) + this._close(); + } + + _onPlayerProxyReady() { + this._propsChangedId = this._playerProxy.connect('g-properties-changed', + this._updateState.bind(this)); + this._updateState(); + } + + _updateState() { + let metadata = {}; + for (let prop in this._playerProxy.Metadata) + metadata[prop] = this._playerProxy.Metadata[prop].deep_unpack(); + + // Validate according to the spec; some clients send buggy metadata: + // https://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata + this._trackArtists = metadata['xesam:artist']; + if (!Array.isArray(this._trackArtists) || + !this._trackArtists.every(artist => typeof artist === 'string')) { + if (typeof this._trackArtists !== 'undefined') { + log(('Received faulty track artist metadata from %s; ' + + 'expected an array of strings, got %s (%s)').format( + this._busName, this._trackArtists, typeof this._trackArtists)); + } + this._trackArtists = [_("Unknown artist")]; + } + + this._trackTitle = metadata['xesam:title']; + if (typeof this._trackTitle !== 'string') { + if (typeof this._trackTitle !== 'undefined') { + log(('Received faulty track title metadata from %s; ' + + 'expected a string, got %s (%s)').format( + this._busName, this._trackTitle, typeof this._trackTitle)); + } + this._trackTitle = _("Unknown title"); + } + + this._trackCoverUrl = metadata['mpris:artUrl']; + if (typeof this._trackCoverUrl !== 'string') { + if (typeof this._trackCoverUrl !== 'undefined') { + log(('Received faulty track cover art metadata from %s; ' + + 'expected a string, got %s (%s)').format( + this._busName, this._trackCoverUrl, typeof this._trackCoverUrl)); + } + this._trackCoverUrl = ''; + } + + this.emit('changed'); + + let visible = this._playerProxy.CanPlay; + + if (this._visible != visible) { + this._visible = visible; + if (visible) + this.emit('show'); + else + this.emit('hide'); + } + } +}; +Signals.addSignalMethods(MprisPlayer.prototype); + +var MediaSection = GObject.registerClass( +class MediaSection extends MessageList.MessageListSection { + _init() { + super._init(); + + this._players = new Map(); + + this._proxy = new DBusProxy(Gio.DBus.session, + 'org.freedesktop.DBus', + '/org/freedesktop/DBus', + this._onProxyReady.bind(this)); + } + + get allowed() { + return !Main.sessionMode.isGreeter; + } + + _addPlayer(busName) { + if (this._players.get(busName)) + return; + + let player = new MprisPlayer(busName); + let message = null; + player.connect('closed', + () => { + this._players.delete(busName); + }); + player.connect('show', () => { + message = new MediaMessage(player); + this.addMessage(message, true); + }); + player.connect('hide', () => { + this.removeMessage(message, true); + message = null; + }); + + this._players.set(busName, player); + } + + _onProxyReady() { + this._proxy.ListNamesRemote(([names]) => { + names.forEach(name => { + if (!name.startsWith(MPRIS_PLAYER_PREFIX)) + return; + + this._addPlayer(name); + }); + }); + this._proxy.connectSignal('NameOwnerChanged', + this._onNameOwnerChanged.bind(this)); + } + + _onNameOwnerChanged(proxy, sender, [name, oldOwner, newOwner]) { + if (!name.startsWith(MPRIS_PLAYER_PREFIX)) + return; + + if (newOwner && !oldOwner) + this._addPlayer(name); + } +}); diff --git a/js/ui/notificationDaemon.js b/js/ui/notificationDaemon.js new file mode 100644 index 0000000..a92192e --- /dev/null +++ b/js/ui/notificationDaemon.js @@ -0,0 +1,785 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported NotificationDaemon */ + +const { GdkPixbuf, Gio, GLib, GObject, Shell, St } = imports.gi; + +const Config = imports.misc.config; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; +const Params = imports.misc.params; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const FdoNotificationsIface = loadInterfaceXML('org.freedesktop.Notifications'); + +var NotificationClosedReason = { + EXPIRED: 1, + DISMISSED: 2, + APP_CLOSED: 3, + UNDEFINED: 4, +}; + +var Urgency = { + LOW: 0, + NORMAL: 1, + CRITICAL: 2, +}; + +const rewriteRules = { + 'XChat': [ + { pattern: /^XChat: Private message from: (\S*) \(.*\)$/, + replacement: '<$1>' }, + { pattern: /^XChat: New public message from: (\S*) \((.*)\)$/, + replacement: '$2 <$1>' }, + { pattern: /^XChat: Highlighted message from: (\S*) \((.*)\)$/, + replacement: '$2 <$1>' }, + ], +}; + +var FdoNotificationDaemon = class FdoNotificationDaemon { + constructor() { + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(FdoNotificationsIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/freedesktop/Notifications'); + + this._sources = []; + this._notifications = {}; + + this._nextNotificationId = 1; + + Shell.WindowTracker.get_default().connect('notify::focus-app', + this._onFocusAppChanged.bind(this)); + Main.overview.connect('hidden', + this._onFocusAppChanged.bind(this)); + } + + _imageForNotificationData(hints) { + if (hints['image-data']) { + let [width, height, rowStride, hasAlpha, + bitsPerSample, nChannels_, data] = hints['image-data']; + return Shell.util_create_pixbuf_from_data(data, GdkPixbuf.Colorspace.RGB, hasAlpha, + bitsPerSample, width, height, rowStride); + } else if (hints['image-path']) { + return this._iconForNotificationData(hints['image-path']); + } + return null; + } + + _fallbackIconForNotificationData(hints) { + let stockIcon; + switch (hints.urgency) { + case Urgency.LOW: + case Urgency.NORMAL: + stockIcon = 'dialog-information'; + break; + case Urgency.CRITICAL: + stockIcon = 'dialog-error'; + break; + } + return new Gio.ThemedIcon({ name: stockIcon }); + } + + _iconForNotificationData(icon) { + if (icon) { + if (icon.substr(0, 7) == 'file://') + return new Gio.FileIcon({ file: Gio.File.new_for_uri(icon) }); + else if (icon[0] == '/') + return new Gio.FileIcon({ file: Gio.File.new_for_path(icon) }); + else + return new Gio.ThemedIcon({ name: icon }); + } + return null; + } + + _lookupSource(title, pid) { + for (let i = 0; i < this._sources.length; i++) { + let source = this._sources[i]; + if (source.pid == pid && source.initialTitle == title) + return source; + } + return null; + } + + // Returns the source associated with ndata.notification if it is set. + // If the existing or requested source is associated with a tray icon + // and passed in pid matches a pid of an existing source, the title + // match is ignored to enable representing a tray icon and notifications + // from the same application with a single source. + // + // If no existing source is found, a new source is created as long as + // pid is provided. + _getSource(title, pid, ndata, sender) { + if (!pid && !(ndata && ndata.notification)) + throw new Error('Either a pid or ndata.notification is needed'); + + // We use notification's source for the notifications we still have + // around that are getting replaced because we don't keep sources + // for transient notifications in this._sources, but we still want + // the notification associated with them to get replaced correctly. + if (ndata && ndata.notification) + return ndata.notification.source; + + let source = this._lookupSource(title, pid); + if (source) { + source.setTitle(title); + return source; + } + + let appId = ndata ? ndata.hints['desktop-entry'] || null : null; + source = new FdoNotificationDaemonSource(title, pid, sender, appId); + + this._sources.push(source); + source.connect('destroy', () => { + let index = this._sources.indexOf(source); + if (index >= 0) + this._sources.splice(index, 1); + }); + + Main.messageTray.add(source); + return source; + } + + NotifyAsync(params, invocation) { + let [appName, replacesId, icon, summary, body, actions, hints, timeout] = params; + let id; + + for (let hint in hints) { + // unpack the variants + hints[hint] = hints[hint].deep_unpack(); + } + + hints = Params.parse(hints, { urgency: Urgency.NORMAL }, true); + + // Filter out chat, presence, calls and invitation notifications from + // Empathy, since we handle that information from telepathyClient.js + // + // Note that empathy uses im.received for one to one chats and + // x-empathy.im.mentioned for multi-user, so we're good here + if (appName == 'Empathy' && hints['category'] == 'im.received') { + // Ignore replacesId since we already sent back a + // NotificationClosed for that id. + id = this._nextNotificationId++; + let idleId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this._emitNotificationClosed(id, NotificationClosedReason.DISMISSED); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(idleId, '[gnome-shell] this._emitNotificationClosed'); + return invocation.return_value(GLib.Variant.new('(u)', [id])); + } + + let rewrites = rewriteRules[appName]; + if (rewrites) { + for (let i = 0; i < rewrites.length; i++) { + let rule = rewrites[i]; + if (summary.search(rule.pattern) != -1) + summary = summary.replace(rule.pattern, rule.replacement); + } + } + + // Be compatible with the various hints for image data and image path + // 'image-data' and 'image-path' are the latest name of these hints, introduced in 1.2 + + if (!hints['image-path'] && hints['image_path']) + hints['image-path'] = hints['image_path']; // version 1.1 of the spec + + if (!hints['image-data']) { + if (hints['image_data']) + hints['image-data'] = hints['image_data']; // version 1.1 of the spec + else if (hints['icon_data'] && !hints['image-path']) + // early versions of the spec; 'icon_data' should only be used if 'image-path' is not available + hints['image-data'] = hints['icon_data']; + } + + let ndata = { appName, + icon, + summary, + body, + actions, + hints, + timeout }; + if (replacesId != 0 && this._notifications[replacesId]) { + ndata.id = id = replacesId; + ndata.notification = this._notifications[replacesId].notification; + } else { + replacesId = 0; + ndata.id = id = this._nextNotificationId++; + } + this._notifications[id] = ndata; + + let sender = invocation.get_sender(); + let pid = hints['sender-pid']; + + let source = this._getSource(appName, pid, ndata, sender, null); + this._notifyForSource(source, ndata); + + return invocation.return_value(GLib.Variant.new('(u)', [id])); + } + + _notifyForSource(source, ndata) { + let [id_, icon, summary, body, actions, hints, notification] = + [ndata.id, ndata.icon, ndata.summary, ndata.body, + ndata.actions, ndata.hints, ndata.notification]; + + if (notification == null) { + notification = new MessageTray.Notification(source); + ndata.notification = notification; + notification.connect('destroy', (n, reason) => { + delete this._notifications[ndata.id]; + let notificationClosedReason; + switch (reason) { + case MessageTray.NotificationDestroyedReason.EXPIRED: + notificationClosedReason = NotificationClosedReason.EXPIRED; + break; + case MessageTray.NotificationDestroyedReason.DISMISSED: + notificationClosedReason = NotificationClosedReason.DISMISSED; + break; + case MessageTray.NotificationDestroyedReason.SOURCE_CLOSED: + notificationClosedReason = NotificationClosedReason.APP_CLOSED; + break; + } + this._emitNotificationClosed(ndata.id, notificationClosedReason); + }); + } + + // 'image-data' (or 'image-path') takes precedence over 'app-icon'. + let gicon = this._imageForNotificationData(hints); + + if (!gicon) + gicon = this._iconForNotificationData(icon); + + if (!gicon) + gicon = this._fallbackIconForNotificationData(hints); + + notification.update(summary, body, { gicon, + bannerMarkup: true, + clear: true, + soundFile: hints['sound-file'], + soundName: hints['sound-name'] }); + + let hasDefaultAction = false; + + if (actions.length) { + for (let i = 0; i < actions.length - 1; i += 2) { + let [actionId, label] = [actions[i], actions[i + 1]]; + if (actionId == 'default') { + hasDefaultAction = true; + } else { + notification.addAction(label, () => { + this._emitActionInvoked(ndata.id, actionId); + }); + } + } + } + + if (hasDefaultAction) { + notification.connect('activated', () => { + this._emitActionInvoked(ndata.id, 'default'); + }); + } else { + notification.connect('activated', () => { + source.open(); + }); + } + + switch (hints.urgency) { + case Urgency.LOW: + notification.setUrgency(MessageTray.Urgency.LOW); + break; + case Urgency.NORMAL: + notification.setUrgency(MessageTray.Urgency.NORMAL); + break; + case Urgency.CRITICAL: + notification.setUrgency(MessageTray.Urgency.CRITICAL); + break; + } + notification.setResident(!!hints.resident); + // 'transient' is a reserved keyword in JS, so we have to retrieve the value + // of the 'transient' hint with hints['transient'] rather than hints.transient + notification.setTransient(!!hints['transient']); + + let privacyScope = hints['x-gnome-privacy-scope'] || 'user'; + notification.setPrivacyScope(privacyScope == 'system' + ? MessageTray.PrivacyScope.SYSTEM + : MessageTray.PrivacyScope.USER); + + let sourceGIcon = source.useNotificationIcon ? gicon : null; + source.processNotification(notification, sourceGIcon); + } + + CloseNotification(id) { + let ndata = this._notifications[id]; + if (ndata) { + if (ndata.notification) + ndata.notification.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); + delete this._notifications[id]; + } + } + + GetCapabilities() { + return [ + 'actions', + // 'action-icons', + 'body', + // 'body-hyperlinks', + // 'body-images', + 'body-markup', + // 'icon-multi', + 'icon-static', + 'persistence', + 'sound', + ]; + } + + GetServerInformation() { + return [ + Config.PACKAGE_NAME, + 'GNOME', + Config.PACKAGE_VERSION, + '1.2', + ]; + } + + _onFocusAppChanged() { + let tracker = Shell.WindowTracker.get_default(); + if (!tracker.focus_app) + return; + + for (let i = 0; i < this._sources.length; i++) { + let source = this._sources[i]; + if (source.app == tracker.focus_app) { + source.destroyNonResidentNotifications(); + return; + } + } + } + + _emitNotificationClosed(id, reason) { + this._dbusImpl.emit_signal('NotificationClosed', + GLib.Variant.new('(uu)', [id, reason])); + } + + _emitActionInvoked(id, action) { + this._dbusImpl.emit_signal('ActionInvoked', + GLib.Variant.new('(us)', [id, action])); + } +}; + +var FdoNotificationDaemonSource = GObject.registerClass( +class FdoNotificationDaemonSource extends MessageTray.Source { + _init(title, pid, sender, appId) { + this.pid = pid; + this.initialTitle = title; + this.app = this._getApp(appId); + + super._init(title); + + if (this.app) + this.title = this.app.get_name(); + else + this.useNotificationIcon = true; + + if (sender) { + this._nameWatcherId = Gio.DBus.session.watch_name(sender, + Gio.BusNameWatcherFlags.NONE, + null, + this._onNameVanished.bind(this)); + } else { + this._nameWatcherId = 0; + } + } + + _createPolicy() { + if (this.app && this.app.get_app_info()) { + let id = this.app.get_id().replace(/\.desktop$/, ''); + return new MessageTray.NotificationApplicationPolicy(id); + } else { + return new MessageTray.NotificationGenericPolicy(); + } + } + + _onNameVanished() { + // Destroy the notification source when its sender is removed from DBus. + // Only do so if this.app is set to avoid removing "notify-send" sources, senders + // of which аre removed from DBus immediately. + // Sender being removed from DBus would normally result in a tray icon being removed, + // so allow the code path that handles the tray icon being removed to handle that case. + if (this.app) + this.destroy(); + } + + processNotification(notification, gicon) { + if (gicon) + this._gicon = gicon; + this.iconUpdated(); + + let tracker = Shell.WindowTracker.get_default(); + if (notification.resident && this.app && tracker.focus_app == this.app) + this.pushNotification(notification); + else + this.showNotification(notification); + } + + _getApp(appId) { + const appSys = Shell.AppSystem.get_default(); + let app; + + app = Shell.WindowTracker.get_default().get_app_from_pid(this.pid); + if (app != null) + return app; + + if (appId) + app = appSys.lookup_app('%s.desktop'.format(appId)); + + if (!app) + app = appSys.lookup_app('%s.desktop'.format(this.initialTitle)); + + return app; + } + + setTitle(title) { + // Do nothing if .app is set, we don't want to override the + // app name with whatever is provided through libnotify (usually + // garbage) + if (this.app) + return; + + super.setTitle(title); + } + + open() { + this.openApp(); + this.destroyNonResidentNotifications(); + } + + openApp() { + if (this.app == null) + return; + + this.app.activate(); + Main.overview.hide(); + Main.panel.closeCalendar(); + } + + destroy() { + if (this._nameWatcherId) { + Gio.DBus.session.unwatch_name(this._nameWatcherId); + this._nameWatcherId = 0; + } + + super.destroy(); + } + + createIcon(size) { + if (this.app) { + return this.app.create_icon_texture(size); + } else if (this._gicon) { + return new St.Icon({ gicon: this._gicon, + icon_size: size }); + } else { + return null; + } + } +}); + +const PRIORITY_URGENCY_MAP = { + low: MessageTray.Urgency.LOW, + normal: MessageTray.Urgency.NORMAL, + high: MessageTray.Urgency.HIGH, + urgent: MessageTray.Urgency.CRITICAL, +}; + +var GtkNotificationDaemonNotification = GObject.registerClass( +class GtkNotificationDaemonNotification extends MessageTray.Notification { + _init(source, notification) { + super._init(source); + this._serialized = GLib.Variant.new('a{sv}', notification); + + let { title, + body, + icon: gicon, + urgent, + priority, + buttons, + "default-action": defaultAction, + "default-action-target": defaultActionTarget, + timestamp: time } = notification; + + if (priority) { + let urgency = PRIORITY_URGENCY_MAP[priority.unpack()]; + this.setUrgency(urgency != undefined ? urgency : MessageTray.Urgency.NORMAL); + } else if (urgent) { + this.setUrgency(urgent.unpack() + ? MessageTray.Urgency.CRITICAL + : MessageTray.Urgency.NORMAL); + } else { + this.setUrgency(MessageTray.Urgency.NORMAL); + } + + if (buttons) { + buttons.deep_unpack().forEach(button => { + this.addAction(button.label.unpack(), () => { + this._onButtonClicked(button); + }); + }); + } + + this._defaultAction = defaultAction ? defaultAction.unpack() : null; + this._defaultActionTarget = defaultActionTarget; + + this.update(title.unpack(), body ? body.unpack() : null, + { gicon: gicon ? Gio.icon_deserialize(gicon) : null, + datetime: time ? GLib.DateTime.new_from_unix_local(time.unpack()) : null }); + } + + _activateAction(namespacedActionId, target) { + if (namespacedActionId) { + if (namespacedActionId.startsWith('app.')) { + let actionId = namespacedActionId.slice('app.'.length); + this.source.activateAction(actionId, target); + } + } else { + this.source.open(); + } + } + + _onButtonClicked(button) { + let { action, target } = button; + this._activateAction(action.unpack(), target); + } + + activate() { + this._activateAction(this._defaultAction, this._defaultActionTarget); + super.activate(); + } + + serialize() { + return this._serialized; + } +}); + +const FdoApplicationIface = loadInterfaceXML('org.freedesktop.Application'); +const FdoApplicationProxy = Gio.DBusProxy.makeProxyWrapper(FdoApplicationIface); + +function objectPathFromAppId(appId) { + return '/' + appId.replace(/\./g, '/').replace(/-/g, '_'); +} + +function getPlatformData() { + let startupId = GLib.Variant.new('s', '_TIME%s'.format(global.get_current_time())); + return { "desktop-startup-id": startupId }; +} + +function InvalidAppError() {} + +var GtkNotificationDaemonAppSource = GObject.registerClass( +class GtkNotificationDaemonAppSource extends MessageTray.Source { + _init(appId) { + let objectPath = objectPathFromAppId(appId); + if (!GLib.Variant.is_object_path(objectPath)) + throw new InvalidAppError(); + + let app = Shell.AppSystem.get_default().lookup_app('%s.desktop'.format(appId)); + if (!app) + throw new InvalidAppError(); + + this._appId = appId; + this._app = app; + this._objectPath = objectPath; + + super._init(app.get_name()); + + this._notifications = {}; + this._notificationPending = false; + } + + createIcon(size) { + return this._app.create_icon_texture(size); + } + + _createPolicy() { + return new MessageTray.NotificationApplicationPolicy(this._appId); + } + + _createApp(callback) { + return new FdoApplicationProxy(Gio.DBus.session, this._appId, this._objectPath, callback); + } + + _createNotification(params) { + return new GtkNotificationDaemonNotification(this, params); + } + + activateAction(actionId, target) { + this._createApp((app, error) => { + if (error == null) + app.ActivateActionRemote(actionId, target ? [target] : [], getPlatformData()); + else + logError(error, 'Failed to activate application proxy'); + }); + Main.overview.hide(); + Main.panel.closeCalendar(); + } + + open() { + this._createApp((app, error) => { + if (error == null) + app.ActivateRemote(getPlatformData()); + else + logError(error, 'Failed to open application proxy'); + }); + Main.overview.hide(); + Main.panel.closeCalendar(); + } + + addNotification(notificationId, notificationParams, showBanner) { + this._notificationPending = true; + + if (this._notifications[notificationId]) + this._notifications[notificationId].destroy(MessageTray.NotificationDestroyedReason.REPLACED); + + let notification = this._createNotification(notificationParams); + notification.connect('destroy', () => { + delete this._notifications[notificationId]; + }); + this._notifications[notificationId] = notification; + + if (showBanner) + this.showNotification(notification); + else + this.pushNotification(notification); + + this._notificationPending = false; + } + + destroy(reason) { + if (this._notificationPending) + return; + super.destroy(reason); + } + + removeNotification(notificationId) { + if (this._notifications[notificationId]) + this._notifications[notificationId].destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); + } + + serialize() { + let notifications = []; + for (let notificationId in this._notifications) { + let notification = this._notifications[notificationId]; + notifications.push([notificationId, notification.serialize()]); + } + return [this._appId, notifications]; + } +}); + +const GtkNotificationsIface = loadInterfaceXML('org.gtk.Notifications'); + +var GtkNotificationDaemon = class GtkNotificationDaemon { + constructor() { + this._sources = {}; + + this._loadNotifications(); + + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(GtkNotificationsIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/gtk/Notifications'); + + Gio.DBus.session.own_name('org.gtk.Notifications', Gio.BusNameOwnerFlags.REPLACE, null, null); + } + + _ensureAppSource(appId) { + if (this._sources[appId]) + return this._sources[appId]; + + let source = new GtkNotificationDaemonAppSource(appId); + + source.connect('destroy', () => { + delete this._sources[appId]; + this._saveNotifications(); + }); + source.connect('notify::count', this._saveNotifications.bind(this)); + Main.messageTray.add(source); + this._sources[appId] = source; + return source; + } + + _loadNotifications() { + this._isLoading = true; + + try { + let value = global.get_persistent_state('a(sa(sv))', 'notifications'); + if (value) { + let sources = value.deep_unpack(); + sources.forEach(([appId, notifications]) => { + if (notifications.length == 0) + return; + + let source; + try { + source = this._ensureAppSource(appId); + } catch (e) { + if (e instanceof InvalidAppError) + return; + throw e; + } + + notifications.forEach(([notificationId, notification]) => { + source.addNotification(notificationId, notification.deep_unpack(), false); + }); + }); + } + } catch (e) { + logError(e, 'Failed to load saved notifications'); + } finally { + this._isLoading = false; + } + } + + _saveNotifications() { + if (this._isLoading) + return; + + let sources = []; + for (let appId in this._sources) { + let source = this._sources[appId]; + sources.push(source.serialize()); + } + + global.set_persistent_state('notifications', new GLib.Variant('a(sa(sv))', sources)); + } + + AddNotificationAsync(params, invocation) { + let [appId, notificationId, notification] = params; + + let source; + try { + source = this._ensureAppSource(appId); + } catch (e) { + if (e instanceof InvalidAppError) { + invocation.return_dbus_error('org.gtk.Notifications.InvalidApp', 'The app by ID "%s" could not be found'.format(appId)); + return; + } + throw e; + } + + let timestamp = GLib.DateTime.new_now_local().to_unix(); + notification['timestamp'] = new GLib.Variant('x', timestamp); + + source.addNotification(notificationId, notification, true); + + invocation.return_value(null); + } + + RemoveNotificationAsync(params, invocation) { + let [appId, notificationId] = params; + let source = this._sources[appId]; + if (source) + source.removeNotification(notificationId); + + invocation.return_value(null); + } +}; + +var NotificationDaemon = class NotificationDaemon { + constructor() { + this._fdoNotificationDaemon = new FdoNotificationDaemon(); + this._gtkNotificationDaemon = new GtkNotificationDaemon(); + } +}; diff --git a/js/ui/osdMonitorLabeler.js b/js/ui/osdMonitorLabeler.js new file mode 100644 index 0000000..b242ecc --- /dev/null +++ b/js/ui/osdMonitorLabeler.js @@ -0,0 +1,114 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported OsdMonitorLabeler */ + +const { Clutter, Gio, GObject, Meta, St } = imports.gi; + +const Main = imports.ui.main; + +var OsdMonitorLabel = GObject.registerClass( +class OsdMonitorLabel extends St.Widget { + _init(monitor, label) { + super._init({ x_expand: true, y_expand: true }); + + this._monitor = monitor; + + this._box = new St.BoxLayout({ style_class: 'osd-window', + vertical: true }); + this.add_actor(this._box); + + this._label = new St.Label({ style_class: 'osd-monitor-label', + text: label }); + this._box.add(this._label); + + Main.uiGroup.add_child(this); + Main.uiGroup.set_child_above_sibling(this, null); + this._position(); + + Meta.disable_unredirect_for_display(global.display); + this.connect('destroy', () => { + Meta.enable_unredirect_for_display(global.display); + }); + } + + _position() { + let workArea = Main.layoutManager.getWorkAreaForMonitor(this._monitor); + + if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) + this._box.x = workArea.x + (workArea.width - this._box.width); + else + this._box.x = workArea.x; + + this._box.y = workArea.y; + } +}); + +var OsdMonitorLabeler = class { + constructor() { + this._monitorManager = Meta.MonitorManager.get(); + this._client = null; + this._clientWatchId = 0; + this._osdLabels = []; + this._monitorLabels = null; + Main.layoutManager.connect('monitors-changed', + this._reset.bind(this)); + this._reset(); + } + + _reset() { + for (let i in this._osdLabels) + this._osdLabels[i].destroy(); + this._osdLabels = []; + this._monitorLabels = new Map(); + let monitors = Main.layoutManager.monitors; + for (let i in monitors) + this._monitorLabels.set(monitors[i].index, []); + } + + _trackClient(client) { + if (this._client) + return this._client == client; + + this._client = client; + this._clientWatchId = Gio.bus_watch_name(Gio.BusType.SESSION, client, 0, null, + (c, name) => { + this.hide(name); + }); + return true; + } + + _untrackClient(client) { + if (!this._client || this._client != client) + return false; + + Gio.bus_unwatch_name(this._clientWatchId); + this._clientWatchId = 0; + this._client = null; + return true; + } + + show(client, params) { + if (!this._trackClient(client)) + return; + + this._reset(); + + for (let connector in params) { + let monitor = this._monitorManager.get_monitor_for_connector(connector); + if (monitor == -1) + continue; + this._monitorLabels.get(monitor).push(params[connector].deep_unpack()); + } + + for (let [monitor, labels] of this._monitorLabels.entries()) { + labels.sort(); + this._osdLabels.push(new OsdMonitorLabel(monitor, labels.join(' '))); + } + } + + hide(client) { + if (!this._untrackClient(client)) + return; + + this._reset(); + } +}; diff --git a/js/ui/osdWindow.js b/js/ui/osdWindow.js new file mode 100644 index 0000000..bae4b86 --- /dev/null +++ b/js/ui/osdWindow.js @@ -0,0 +1,250 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported OsdWindowManager */ + +const { Clutter, GLib, GObject, Meta, St } = imports.gi; + +const BarLevel = imports.ui.barLevel; +const Layout = imports.ui.layout; +const Main = imports.ui.main; + +var HIDE_TIMEOUT = 1500; +var FADE_TIME = 100; +var LEVEL_ANIMATION_TIME = 100; + +var OsdWindowConstraint = GObject.registerClass( +class OsdWindowConstraint extends Clutter.Constraint { + _init(props) { + this._minSize = 0; + super._init(props); + } + + set minSize(v) { + this._minSize = v; + if (this.actor) + this.actor.queue_relayout(); + } + + vfunc_update_allocation(actor, actorBox) { + // Clutter will adjust the allocation for margins, + // so add it to our minimum size + let minSize = this._minSize + actor.margin_top + actor.margin_bottom; + let [width, height] = actorBox.get_size(); + + // Enforce a ratio of 1 + let size = Math.ceil(Math.max(minSize, height)); + actorBox.set_size(size, size); + + // Recenter + let [x, y] = actorBox.get_origin(); + actorBox.set_origin(Math.ceil(x + width / 2 - size / 2), + Math.ceil(y + height / 2 - size / 2)); + } +}); + +var OsdWindow = GObject.registerClass( +class OsdWindow extends St.Widget { + _init(monitorIndex) { + super._init({ + x_expand: true, + y_expand: true, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + + this._monitorIndex = monitorIndex; + let constraint = new Layout.MonitorConstraint({ index: monitorIndex }); + this.add_constraint(constraint); + + this._boxConstraint = new OsdWindowConstraint(); + this._box = new St.BoxLayout({ style_class: 'osd-window', + vertical: true }); + this._box.add_constraint(this._boxConstraint); + this.add_actor(this._box); + + this._icon = new St.Icon({ y_expand: true }); + this._box.add_child(this._icon); + + this._label = new St.Label(); + this._box.add(this._label); + + this._level = new BarLevel.BarLevel({ + style_class: 'level', + value: 0, + }); + this._box.add(this._level); + + this._hideTimeoutId = 0; + this._reset(); + + this.connect('destroy', this._onDestroy.bind(this)); + + this._monitorsChangedId = + Main.layoutManager.connect('monitors-changed', + this._relayout.bind(this)); + let themeContext = St.ThemeContext.get_for_stage(global.stage); + this._scaleChangedId = + themeContext.connect('notify::scale-factor', + this._relayout.bind(this)); + this._relayout(); + Main.uiGroup.add_child(this); + } + + _onDestroy() { + if (this._monitorsChangedId) + Main.layoutManager.disconnect(this._monitorsChangedId); + this._monitorsChangedId = 0; + + let themeContext = St.ThemeContext.get_for_stage(global.stage); + if (this._scaleChangedId) + themeContext.disconnect(this._scaleChangedId); + this._scaleChangedId = 0; + } + + setIcon(icon) { + this._icon.gicon = icon; + } + + setLabel(label) { + this._label.visible = label != undefined; + if (label) + this._label.text = label; + } + + setLevel(value) { + this._level.visible = value != undefined; + if (value != undefined) { + if (this.visible) { + this._level.ease_property('value', value, { + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: LEVEL_ANIMATION_TIME, + }); + } else { + this._level.value = value; + } + } + } + + setMaxLevel(maxLevel = 1) { + this._level.maximum_value = maxLevel; + } + + show() { + if (!this._icon.gicon) + return; + + if (!this.visible) { + Meta.disable_unredirect_for_display(global.display); + super.show(); + this.opacity = 0; + this.get_parent().set_child_above_sibling(this, null); + + this.ease({ + opacity: 255, + duration: FADE_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + if (this._hideTimeoutId) + GLib.source_remove(this._hideTimeoutId); + this._hideTimeoutId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, HIDE_TIMEOUT, this._hide.bind(this)); + GLib.Source.set_name_by_id(this._hideTimeoutId, '[gnome-shell] this._hide'); + } + + cancel() { + if (!this._hideTimeoutId) + return; + + GLib.source_remove(this._hideTimeoutId); + this._hide(); + } + + _hide() { + this._hideTimeoutId = 0; + this.ease({ + opacity: 0, + duration: FADE_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._reset(); + Meta.enable_unredirect_for_display(global.display); + }, + }); + return GLib.SOURCE_REMOVE; + } + + _reset() { + super.hide(); + this.setLabel(null); + this.setMaxLevel(null); + this.setLevel(null); + } + + _relayout() { + /* assume 110x110 on a 640x480 display and scale from there */ + let monitor = Main.layoutManager.monitors[this._monitorIndex]; + if (!monitor) + return; // we are about to be removed + + let scalew = monitor.width / 640.0; + let scaleh = monitor.height / 480.0; + let scale = Math.min(scalew, scaleh); + let popupSize = 110 * Math.max(1, scale); + + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + this._icon.icon_size = popupSize / (2 * scaleFactor); + this._box.translation_y = Math.round(monitor.height / 4); + this._boxConstraint.minSize = popupSize; + } +}); + +var OsdWindowManager = class { + constructor() { + this._osdWindows = []; + Main.layoutManager.connect('monitors-changed', + this._monitorsChanged.bind(this)); + this._monitorsChanged(); + } + + _monitorsChanged() { + for (let i = 0; i < Main.layoutManager.monitors.length; i++) { + if (this._osdWindows[i] == undefined) + this._osdWindows[i] = new OsdWindow(i); + } + + for (let i = Main.layoutManager.monitors.length; i < this._osdWindows.length; i++) { + this._osdWindows[i].destroy(); + this._osdWindows[i] = null; + } + + this._osdWindows.length = Main.layoutManager.monitors.length; + } + + _showOsdWindow(monitorIndex, icon, label, level, maxLevel) { + this._osdWindows[monitorIndex].setIcon(icon); + this._osdWindows[monitorIndex].setLabel(label); + this._osdWindows[monitorIndex].setMaxLevel(maxLevel); + this._osdWindows[monitorIndex].setLevel(level); + this._osdWindows[monitorIndex].show(); + } + + show(monitorIndex, icon, label, level, maxLevel) { + if (monitorIndex != -1) { + for (let i = 0; i < this._osdWindows.length; i++) { + if (i == monitorIndex) + this._showOsdWindow(i, icon, label, level, maxLevel); + else + this._osdWindows[i].cancel(); + } + } else { + for (let i = 0; i < this._osdWindows.length; i++) + this._showOsdWindow(i, icon, label, level, maxLevel); + } + } + + hideAll() { + for (let i = 0; i < this._osdWindows.length; i++) + this._osdWindows[i].cancel(); + } +}; diff --git a/js/ui/overview.js b/js/ui/overview.js new file mode 100644 index 0000000..9682f8e --- /dev/null +++ b/js/ui/overview.js @@ -0,0 +1,705 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Overview */ + +const { Clutter, GLib, GObject, Meta, Shell, St } = imports.gi; +const Signals = imports.signals; + +// Time for initial animation going into Overview mode; +// this is defined here to make it available in imports. +var ANIMATION_TIME = 250; + +const Background = imports.ui.background; +const DND = imports.ui.dnd; +const LayoutManager = imports.ui.layout; +const Lightbox = imports.ui.lightbox; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; +const OverviewControls = imports.ui.overviewControls; +const Params = imports.misc.params; +const WorkspaceThumbnail = imports.ui.workspaceThumbnail; + +// Must be less than ANIMATION_TIME, since we switch to +// or from the overview completely after ANIMATION_TIME, +// and don't want the shading animation to get cut off +var SHADE_ANIMATION_TIME = 200; + +var DND_WINDOW_SWITCH_TIMEOUT = 750; + +var OVERVIEW_ACTIVATION_TIMEOUT = 0.5; + +var ShellInfo = class { + constructor() { + this._source = null; + this._undoCallback = null; + } + + _onUndoClicked() { + if (this._undoCallback) + this._undoCallback(); + this._undoCallback = null; + + if (this._source) + this._source.destroy(); + } + + setMessage(text, options) { + options = Params.parse(options, { + undoCallback: null, + forFeedback: false, + }); + + let undoCallback = options.undoCallback; + let forFeedback = options.forFeedback; + + if (this._source == null) { + this._source = new MessageTray.SystemNotificationSource(); + this._source.connect('destroy', () => { + this._source = null; + }); + Main.messageTray.add(this._source); + } + + let notification = null; + if (this._source.notifications.length == 0) { + notification = new MessageTray.Notification(this._source, text, null); + notification.setTransient(true); + notification.setForFeedback(forFeedback); + } else { + notification = this._source.notifications[0]; + notification.update(text, null, { clear: true }); + } + + this._undoCallback = undoCallback; + if (undoCallback) + notification.addAction(_("Undo"), this._onUndoClicked.bind(this)); + + this._source.showNotification(notification); + } +}; + +var OverviewActor = GObject.registerClass( +class OverviewActor extends St.BoxLayout { + _init() { + super._init({ + name: 'overview', + /* Translators: This is the main view to select + activities. See also note for "Activities" string. */ + accessible_name: _("Overview"), + vertical: true, + }); + + this.add_constraint(new LayoutManager.MonitorConstraint({ primary: true })); + + // Add a clone of the panel to the overview so spacing and such is + // automatic + let panelGhost = new St.Bin({ + child: new Clutter.Clone({ source: Main.panel }), + reactive: false, + opacity: 0, + }); + this.add_actor(panelGhost); + + this._searchEntry = new St.Entry({ + style_class: 'search-entry', + /* Translators: this is the text displayed + in the search entry when no search is + active; it should not exceed ~30 + characters. */ + hint_text: _('Type to search'), + track_hover: true, + can_focus: true, + }); + this._searchEntry.set_offscreen_redirect(Clutter.OffscreenRedirect.ALWAYS); + let searchEntryBin = new St.Bin({ + child: this._searchEntry, + x_align: Clutter.ActorAlign.CENTER, + }); + this.add_actor(searchEntryBin); + + this._controls = new OverviewControls.ControlsManager(this._searchEntry); + + // Add our same-line elements after the search entry + this.add_child(this._controls); + } + + get dash() { + return this._controls.dash; + } + + get searchEntry() { + return this._searchEntry; + } + + get viewSelector() { + return this._controls.viewSelector; + } +}); + +var Overview = class { + constructor() { + this._initCalled = false; + this._visible = false; + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + this._sessionUpdated(); + } + + get dash() { + return this._overview.dash; + } + + get dashIconSize() { + logError(new Error('Usage of Overview.\'dashIconSize\' is deprecated, ' + + 'use \'dash.iconSize\' property instead')); + return this.dash.iconSize; + } + + get viewSelector() { + return this._overview.viewSelector; + } + + get animationInProgress() { + return this._animationInProgress; + } + + get visible() { + return this._visible; + } + + get visibleTarget() { + return this._visibleTarget; + } + + _createOverview() { + if (this._overview) + return; + + if (this.isDummy) + return; + + // The main Background actors are inside global.window_group which are + // hidden when displaying the overview, so we create a new + // one. Instances of this class share a single CoglTexture behind the + // scenes which allows us to show the background with different + // rendering options without duplicating the texture data. + this._backgroundGroup = new Meta.BackgroundGroup({ reactive: true }); + Main.layoutManager.overviewGroup.add_child(this._backgroundGroup); + this._bgManagers = []; + + this._desktopFade = new St.Widget(); + Main.layoutManager.overviewGroup.add_child(this._desktopFade); + + this._activationTime = 0; + + this._visible = false; // animating to overview, in overview, animating out + this._shown = false; // show() and not hide() + this._modal = false; // have a modal grab + this._animationInProgress = false; + this._visibleTarget = false; + + // During transitions, we raise this to the top to avoid having the overview + // area be reactive; it causes too many issues such as double clicks on + // Dash elements, or mouseover handlers in the workspaces. + this._coverPane = new Clutter.Actor({ opacity: 0, + reactive: true }); + Main.layoutManager.overviewGroup.add_child(this._coverPane); + this._coverPane.connect('event', () => Clutter.EVENT_STOP); + this._coverPane.hide(); + + // XDND + this._dragMonitor = { + dragMotion: this._onDragMotion.bind(this), + }; + + + Main.layoutManager.overviewGroup.connect('scroll-event', + this._onScrollEvent.bind(this)); + Main.xdndHandler.connect('drag-begin', this._onDragBegin.bind(this)); + Main.xdndHandler.connect('drag-end', this._onDragEnd.bind(this)); + + global.display.connect('restacked', this._onRestacked.bind(this)); + + this._windowSwitchTimeoutId = 0; + this._windowSwitchTimestamp = 0; + this._lastActiveWorkspaceIndex = -1; + this._lastHoveredWindow = null; + + if (this._initCalled) + this.init(); + } + + _updateBackgrounds() { + for (let i = 0; i < this._bgManagers.length; i++) + this._bgManagers[i].destroy(); + + this._bgManagers = []; + + for (let i = 0; i < Main.layoutManager.monitors.length; i++) { + let bgManager = new Background.BackgroundManager({ container: this._backgroundGroup, + monitorIndex: i, + vignette: true }); + this._bgManagers.push(bgManager); + } + } + + _unshadeBackgrounds() { + let backgrounds = this._backgroundGroup.get_children(); + for (let i = 0; i < backgrounds.length; i++) { + backgrounds[i].ease_property('@content.brightness', 1.0, { + duration: SHADE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + backgrounds[i].ease_property('@content.vignette-sharpness', 0.0, { + duration: SHADE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + } + + _shadeBackgrounds() { + let backgrounds = this._backgroundGroup.get_children(); + for (let i = 0; i < backgrounds.length; i++) { + backgrounds[i].ease_property('@content.brightness', + Lightbox.VIGNETTE_BRIGHTNESS, { + duration: SHADE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + backgrounds[i].ease_property('@content.vignette-sharpness', + Lightbox.VIGNETTE_SHARPNESS, { + duration: SHADE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + } + + _sessionUpdated() { + const { hasOverview } = Main.sessionMode; + if (!hasOverview) + this.hide(); + + this.isDummy = !hasOverview; + this._createOverview(); + } + + // The members we construct that are implemented in JS might + // want to access the overview as Main.overview to connect + // signal handlers and so forth. So we create them after + // construction in this init() method. + init() { + this._initCalled = true; + + if (this.isDummy) + return; + + this._overview = new OverviewActor(); + this._overview._delegate = this; + Main.layoutManager.overviewGroup.add_child(this._overview); + this._overview.connect('notify::allocation', () => this.emit('relayout')); + + this._shellInfo = new ShellInfo(); + + Main.layoutManager.connect('monitors-changed', this._relayout.bind(this)); + this._relayout(); + } + + addSearchProvider(provider) { + this.viewSelector.addSearchProvider(provider); + } + + removeSearchProvider(provider) { + this.viewSelector.removeSearchProvider(provider); + } + + // + // options: + // - undoCallback (function): the callback to be called if undo support is needed + // - forFeedback (boolean): whether the message is for direct feedback of a user action + // + setMessage(text, options) { + if (this.isDummy) + return; + + this._shellInfo.setMessage(text, options); + } + + _onDragBegin() { + this._inXdndDrag = true; + + DND.addDragMonitor(this._dragMonitor); + // Remember the workspace we started from + let workspaceManager = global.workspace_manager; + this._lastActiveWorkspaceIndex = workspaceManager.get_active_workspace_index(); + } + + _onDragEnd(time) { + this._inXdndDrag = false; + + // In case the drag was canceled while in the overview + // we have to go back to where we started and hide + // the overview + if (this._shown) { + let workspaceManager = global.workspace_manager; + workspaceManager.get_workspace_by_index(this._lastActiveWorkspaceIndex).activate(time); + this.hide(); + } + this._resetWindowSwitchTimeout(); + this._lastHoveredWindow = null; + DND.removeDragMonitor(this._dragMonitor); + this.endItemDrag(); + } + + _resetWindowSwitchTimeout() { + if (this._windowSwitchTimeoutId != 0) { + GLib.source_remove(this._windowSwitchTimeoutId); + this._windowSwitchTimeoutId = 0; + } + } + + _onDragMotion(dragEvent) { + let targetIsWindow = dragEvent.targetActor && + dragEvent.targetActor._delegate && + dragEvent.targetActor._delegate.metaWindow && + !(dragEvent.targetActor._delegate instanceof WorkspaceThumbnail.WindowClone); + + this._windowSwitchTimestamp = global.get_current_time(); + + if (targetIsWindow && + dragEvent.targetActor._delegate.metaWindow == this._lastHoveredWindow) + return DND.DragMotionResult.CONTINUE; + + this._lastHoveredWindow = null; + + this._resetWindowSwitchTimeout(); + + if (targetIsWindow) { + this._lastHoveredWindow = dragEvent.targetActor._delegate.metaWindow; + this._windowSwitchTimeoutId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + DND_WINDOW_SWITCH_TIMEOUT, + () => { + this._windowSwitchTimeoutId = 0; + Main.activateWindow(dragEvent.targetActor._delegate.metaWindow, + this._windowSwitchTimestamp); + this.hide(); + this._lastHoveredWindow = null; + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._windowSwitchTimeoutId, '[gnome-shell] Main.activateWindow'); + } + + return DND.DragMotionResult.CONTINUE; + } + + _onScrollEvent(actor, event) { + this.emit('scroll-event', event); + return Clutter.EVENT_PROPAGATE; + } + + addAction(action) { + if (this.isDummy) + return; + + this._backgroundGroup.add_action(action); + } + + _getDesktopClone() { + let windows = global.get_window_actors().filter( + w => w.meta_window.get_window_type() === Meta.WindowType.DESKTOP); + if (windows.length == 0) + return null; + + let window = windows[0]; + let clone = new Clutter.Clone({ source: window, + x: window.x, y: window.y }); + clone.source.connect('destroy', () => { + clone.destroy(); + }); + return clone; + } + + _relayout() { + // To avoid updating the position and size of the workspaces + // we just hide the overview. The positions will be updated + // when it is next shown. + this.hide(); + + this._coverPane.set_position(0, 0); + this._coverPane.set_size(global.screen_width, global.screen_height); + + this._updateBackgrounds(); + } + + _onRestacked() { + let stack = global.get_window_actors(); + let stackIndices = {}; + + for (let i = 0; i < stack.length; i++) { + // Use the stable sequence for an integer to use as a hash key + stackIndices[stack[i].get_meta_window().get_stable_sequence()] = i; + } + + this.emit('windows-restacked', stackIndices); + } + + beginItemDrag(source) { + this.emit('item-drag-begin', source); + this._inItemDrag = true; + } + + cancelledItemDrag(source) { + this.emit('item-drag-cancelled', source); + } + + endItemDrag(source) { + if (!this._inItemDrag) + return; + this.emit('item-drag-end', source); + this._inItemDrag = false; + } + + beginWindowDrag(window) { + this.emit('window-drag-begin', window); + this._inWindowDrag = true; + } + + cancelledWindowDrag(window) { + this.emit('window-drag-cancelled', window); + } + + endWindowDrag(window) { + if (!this._inWindowDrag) + return; + this.emit('window-drag-end', window); + this._inWindowDrag = false; + } + + focusSearch() { + this.show(); + this._overview.searchEntry.grab_key_focus(); + } + + fadeInDesktop() { + this._desktopFade.opacity = 0; + this._desktopFade.show(); + this._desktopFade.ease({ + opacity: 255, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: ANIMATION_TIME, + }); + } + + fadeOutDesktop() { + if (!this._desktopFade.get_n_children()) { + let clone = this._getDesktopClone(); + if (!clone) + return; + + this._desktopFade.add_child(clone); + } + + this._desktopFade.opacity = 255; + this._desktopFade.show(); + this._desktopFade.ease({ + opacity: 0, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: ANIMATION_TIME, + }); + } + + // Checks if the Activities button is currently sensitive to + // clicks. The first call to this function within the + // OVERVIEW_ACTIVATION_TIMEOUT time of the hot corner being + // triggered will return false. This avoids opening and closing + // the overview if the user both triggered the hot corner and + // clicked the Activities button. + shouldToggleByCornerOrButton() { + if (this._animationInProgress) + return false; + if (this._inItemDrag || this._inWindowDrag) + return false; + if (!this._activationTime || + GLib.get_monotonic_time() / GLib.USEC_PER_SEC - this._activationTime > OVERVIEW_ACTIVATION_TIMEOUT) + return true; + return false; + } + + _syncGrab() { + // We delay grab changes during animation so that when removing the + // overview we don't have a problem with the release of a press/release + // going to an application. + if (this._animationInProgress) + return true; + + if (this._shown) { + let shouldBeModal = !this._inXdndDrag; + if (shouldBeModal && !this._modal) { + let actionMode = Shell.ActionMode.OVERVIEW; + if (Main.pushModal(this._overview, { actionMode })) { + this._modal = true; + } else { + this.hide(); + return false; + } + } + } else { + // eslint-disable-next-line no-lonely-if + if (this._modal) { + Main.popModal(this._overview); + this._modal = false; + } + } + return true; + } + + // show: + // + // Animates the overview visible and grabs mouse and keyboard input + show() { + if (this.isDummy) + return; + if (this._shown) + return; + this._shown = true; + + if (!this._syncGrab()) + return; + + Main.layoutManager.showOverview(); + this._animateVisible(); + } + + + _animateVisible() { + if (this._visible || this._animationInProgress) + return; + + this._visible = true; + this._animationInProgress = true; + this._visibleTarget = true; + this._activationTime = GLib.get_monotonic_time() / GLib.USEC_PER_SEC; + + Meta.disable_unredirect_for_display(global.display); + this.viewSelector.animateToOverview(); + + this._overview.opacity = 0; + this._overview.ease({ + opacity: 255, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: ANIMATION_TIME, + onComplete: () => this._showDone(), + }); + this._shadeBackgrounds(); + + Main.layoutManager.overviewGroup.set_child_above_sibling( + this._coverPane, null); + this._coverPane.show(); + this.emit('showing'); + } + + _showDone() { + this._animationInProgress = false; + this._desktopFade.hide(); + this._coverPane.hide(); + + this.emit('shown'); + // Handle any calls to hide* while we were showing + if (!this._shown) + this._animateNotVisible(); + + this._syncGrab(); + global.sync_pointer(); + } + + // hide: + // + // Reverses the effect of show() + hide() { + if (this.isDummy) + return; + + if (!this._shown) + return; + + let event = Clutter.get_current_event(); + if (event) { + let type = event.type(); + let button = type == Clutter.EventType.BUTTON_PRESS || + type == Clutter.EventType.BUTTON_RELEASE; + let ctrl = (event.get_state() & Clutter.ModifierType.CONTROL_MASK) != 0; + if (button && ctrl) + return; + } + + this._shown = false; + + this._animateNotVisible(); + this._syncGrab(); + } + + _animateNotVisible() { + if (!this._visible || this._animationInProgress) + return; + + this._animationInProgress = true; + this._visibleTarget = false; + + this.viewSelector.animateFromOverview(); + + // Make other elements fade out. + this._overview.ease({ + opacity: 0, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: ANIMATION_TIME, + onComplete: () => this._hideDone(), + }); + this._unshadeBackgrounds(); + + Main.layoutManager.overviewGroup.set_child_above_sibling( + this._coverPane, null); + this._coverPane.show(); + this.emit('hiding'); + } + + _hideDone() { + // Re-enable unredirection + Meta.enable_unredirect_for_display(global.display); + + this.viewSelector.hide(); + this._desktopFade.hide(); + this._coverPane.hide(); + + this._visible = false; + this._animationInProgress = false; + + this.emit('hidden'); + // Handle any calls to show* while we were hiding + if (this._shown) + this._animateVisible(); + else + Main.layoutManager.hideOverview(); + + this._syncGrab(); + } + + toggle() { + if (this.isDummy) + return; + + if (this._visible) + this.hide(); + else + this.show(); + } + + getShowAppsButton() { + logError(new Error('Usage of Overview.\'getShowAppsButton\' is deprecated, ' + + 'use \'dash.showAppsButton\' property instead')); + + return this.dash.showAppsButton; + } + + get searchEntry() { + return this._overview.searchEntry; + } +}; +Signals.addSignalMethods(Overview.prototype); diff --git a/js/ui/overviewControls.js b/js/ui/overviewControls.js new file mode 100644 index 0000000..b5e89bb --- /dev/null +++ b/js/ui/overviewControls.js @@ -0,0 +1,519 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported ControlsManager */ + +const { Clutter, GObject, Meta, St } = imports.gi; + +const Dash = imports.ui.dash; +const Main = imports.ui.main; +const Params = imports.misc.params; +const ViewSelector = imports.ui.viewSelector; +const WorkspaceThumbnail = imports.ui.workspaceThumbnail; +const Overview = imports.ui.overview; + +var SIDE_CONTROLS_ANIMATION_TIME = Overview.ANIMATION_TIME; + +function getRtlSlideDirection(direction, actor) { + let rtl = actor.text_direction == Clutter.TextDirection.RTL; + if (rtl) { + direction = direction == SlideDirection.LEFT + ? SlideDirection.RIGHT : SlideDirection.LEFT; + } + return direction; +} + +var SlideDirection = { + LEFT: 0, + RIGHT: 1, +}; + +var SlideLayout = GObject.registerClass({ + Properties: { + 'slide-x': GObject.ParamSpec.double( + 'slide-x', 'slide-x', 'slide-x', + GObject.ParamFlags.READWRITE, + 0, 1, 1), + }, +}, class SlideLayout extends Clutter.FixedLayout { + _init(params) { + this._slideX = 1; + this._direction = SlideDirection.LEFT; + + super._init(params); + } + + vfunc_get_preferred_width(container, forHeight) { + let child = container.get_first_child(); + + let [minWidth, natWidth] = child.get_preferred_width(forHeight); + + minWidth *= this._slideX; + natWidth *= this._slideX; + + return [minWidth, natWidth]; + } + + vfunc_allocate(container, box) { + let child = container.get_first_child(); + + let availWidth = Math.round(box.x2 - box.x1); + let availHeight = Math.round(box.y2 - box.y1); + let [, natWidth] = child.get_preferred_width(availHeight); + + // Align the actor inside the clipped box, as the actor's alignment + // flags only determine what to do if the allocated box is bigger + // than the actor's box. + let realDirection = getRtlSlideDirection(this._direction, child); + let alignX = realDirection == SlideDirection.LEFT + ? availWidth - natWidth + : availWidth - natWidth * this._slideX; + + let actorBox = new Clutter.ActorBox(); + actorBox.x1 = box.x1 + alignX; + actorBox.x2 = actorBox.x1 + (child.x_expand ? availWidth : natWidth); + actorBox.y1 = box.y1; + actorBox.y2 = actorBox.y1 + availHeight; + + child.allocate(actorBox); + } + + // eslint-disable-next-line camelcase + set slide_x(value) { + if (this._slideX == value) + return; + this._slideX = value; + this.notify('slide-x'); + this.layout_changed(); + } + + // eslint-disable-next-line camelcase + get slide_x() { + return this._slideX; + } + + set slideDirection(direction) { + this._direction = direction; + this.layout_changed(); + } + + get slideDirection() { + return this._direction; + } +}); + +var SlidingControl = GObject.registerClass( +class SlidingControl extends St.Widget { + _init(params) { + params = Params.parse(params, { slideDirection: SlideDirection.LEFT }); + + this.layout = new SlideLayout(); + this.layout.slideDirection = params.slideDirection; + super._init({ + layout_manager: this.layout, + style_class: 'overview-controls', + clip_to_allocation: true, + }); + + this._visible = true; + this._inDrag = false; + + Main.overview.connect('hiding', this._onOverviewHiding.bind(this)); + + Main.overview.connect('item-drag-begin', this._onDragBegin.bind(this)); + Main.overview.connect('item-drag-end', this._onDragEnd.bind(this)); + Main.overview.connect('item-drag-cancelled', this._onDragEnd.bind(this)); + + Main.overview.connect('window-drag-begin', this._onWindowDragBegin.bind(this)); + Main.overview.connect('window-drag-cancelled', this._onWindowDragEnd.bind(this)); + Main.overview.connect('window-drag-end', this._onWindowDragEnd.bind(this)); + } + + _getSlide() { + throw new GObject.NotImplementedError('_getSlide in %s'.format(this.constructor.name)); + } + + _updateSlide() { + this.ease_property('@layout.slide-x', this._getSlide(), { + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: SIDE_CONTROLS_ANIMATION_TIME, + }); + } + + getVisibleWidth() { + let child = this.get_first_child(); + let [, , natWidth] = child.get_preferred_size(); + return natWidth; + } + + _getTranslation() { + let child = this.get_first_child(); + let direction = getRtlSlideDirection(this.layout.slideDirection, child); + let visibleWidth = this.getVisibleWidth(); + + if (direction == SlideDirection.LEFT) + return -visibleWidth; + else + return visibleWidth; + } + + _updateTranslation() { + let translationStart = 0; + let translationEnd = 0; + let translation = this._getTranslation(); + + let shouldShow = this._getSlide() > 0; + if (shouldShow) + translationStart = translation; + else + translationEnd = translation; + + if (this.translation_x === translationEnd) + return; + + this.translation_x = translationStart; + this.ease({ + translation_x: translationEnd, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: SIDE_CONTROLS_ANIMATION_TIME, + }); + } + + _onOverviewHiding() { + // We need to explicitly slideOut since showing pages + // doesn't imply sliding out, instead, hiding the overview does. + this.slideOut(); + } + + _onWindowDragBegin() { + this._onDragBegin(); + } + + _onWindowDragEnd() { + this._onDragEnd(); + } + + _onDragBegin() { + this._inDrag = true; + this._updateTranslation(); + this._updateSlide(); + } + + _onDragEnd() { + this._inDrag = false; + this._updateSlide(); + } + + fadeIn() { + this.ease({ + opacity: 255, + duration: SIDE_CONTROLS_ANIMATION_TIME / 2, + mode: Clutter.AnimationMode.EASE_IN_QUAD, + }); + } + + fadeHalf() { + this.ease({ + opacity: 128, + duration: SIDE_CONTROLS_ANIMATION_TIME / 2, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + slideIn() { + this._visible = true; + // we will update slide_x and the translation from pageEmpty + } + + slideOut() { + this._visible = false; + this._updateTranslation(); + // we will update slide_x from pageEmpty + } + + pageEmpty() { + // When pageEmpty is received, there's no visible view in the + // selector; this means we can now safely set the full slide for + // the next page, since slideIn or slideOut might have been called, + // changing the visibility + this.remove_transition('@layout.slide-x'); + this.layout.slide_x = this._getSlide(); + this._updateTranslation(); + } +}); + +var ThumbnailsSlider = GObject.registerClass( +class ThumbnailsSlider extends SlidingControl { + _init(thumbnailsBox) { + super._init({ slideDirection: SlideDirection.RIGHT }); + + this._thumbnailsBox = thumbnailsBox; + + this.request_mode = Clutter.RequestMode.WIDTH_FOR_HEIGHT; + this.reactive = true; + this.track_hover = true; + this.add_actor(this._thumbnailsBox); + + Main.layoutManager.connect('monitors-changed', this._updateSlide.bind(this)); + global.workspace_manager.connect('active-workspace-changed', + this._updateSlide.bind(this)); + global.workspace_manager.connect('notify::n-workspaces', + this._updateSlide.bind(this)); + this.connect('notify::hover', this._updateSlide.bind(this)); + this._thumbnailsBox.bind_property('visible', this, 'visible', GObject.BindingFlags.SYNC_CREATE); + } + + _getAlwaysZoomOut() { + // Always show the pager on hover, during a drag, or if workspaces are + // actually used, e.g. there are windows on any non-active workspace + let workspaceManager = global.workspace_manager; + let alwaysZoomOut = this.hover || + this._inDrag || + !Meta.prefs_get_dynamic_workspaces() || + workspaceManager.n_workspaces > 2 || + workspaceManager.get_active_workspace_index() != 0; + + if (!alwaysZoomOut) { + let monitors = Main.layoutManager.monitors; + let primary = Main.layoutManager.primaryMonitor; + + /* Look for any monitor to the right of the primary, if there is + * one, we always keep zoom out, otherwise its hard to reach + * the thumbnail area without passing into the next monitor. */ + for (let i = 0; i < monitors.length; i++) { + if (monitors[i].x >= primary.x + primary.width) { + alwaysZoomOut = true; + break; + } + } + } + + return alwaysZoomOut; + } + + getNonExpandedWidth() { + let child = this.get_first_child(); + return child.get_theme_node().get_length('visible-width'); + } + + _onDragEnd() { + this.sync_hover(); + super._onDragEnd(); + } + + _getSlide() { + if (!this._visible) + return 0; + + let alwaysZoomOut = this._getAlwaysZoomOut(); + if (alwaysZoomOut) + return 1; + + let child = this.get_first_child(); + let preferredHeight = child.get_preferred_height(-1)[1]; + let expandedWidth = child.get_preferred_width(preferredHeight)[1]; + + return this.getNonExpandedWidth() / expandedWidth; + } + + getVisibleWidth() { + let alwaysZoomOut = this._getAlwaysZoomOut(); + if (alwaysZoomOut) + return super.getVisibleWidth(); + else + return this.getNonExpandedWidth(); + } +}); + +var DashSlider = GObject.registerClass( +class DashSlider extends SlidingControl { + _init(dash) { + super._init({ slideDirection: SlideDirection.LEFT }); + + this._dash = dash; + + // SlideLayout reads the actor's expand flags to decide + // whether to allocate the natural size to its child, or the whole + // available allocation + this._dash.x_expand = true; + + this.x_expand = true; + this.x_align = Clutter.ActorAlign.START; + this.y_expand = true; + + this.add_actor(this._dash); + + this._dash.connect('icon-size-changed', this._updateSlide.bind(this)); + } + + _getSlide() { + if (this._visible || this._inDrag) + return 1; + else + return 0; + } + + _onWindowDragBegin() { + this.fadeHalf(); + } + + _onWindowDragEnd() { + this.fadeIn(); + } +}); + +var DashSpacer = GObject.registerClass( +class DashSpacer extends St.Widget { + _init(params) { + super._init(params); + + this._bindConstraint = null; + } + + setDashActor(dashActor) { + if (this._bindConstraint) { + this.remove_constraint(this._bindConstraint); + this._bindConstraint = null; + } + + if (dashActor) { + this._bindConstraint = new Clutter.BindConstraint({ source: dashActor, + coordinate: Clutter.BindCoordinate.SIZE }); + this.add_constraint(this._bindConstraint); + } + } + + vfunc_get_preferred_width(forHeight) { + if (this._bindConstraint) + return this._bindConstraint.source.get_preferred_width(forHeight); + return super.vfunc_get_preferred_width(forHeight); + } + + vfunc_get_preferred_height(forWidth) { + if (this._bindConstraint) + return this._bindConstraint.source.get_preferred_height(forWidth); + return super.vfunc_get_preferred_height(forWidth); + } +}); + +var ControlsLayout = GObject.registerClass({ + Signals: { 'allocation-changed': {} }, +}, class ControlsLayout extends Clutter.BinLayout { + vfunc_allocate(container, box) { + super.vfunc_allocate(container, box); + this.emit('allocation-changed'); + } +}); + +var ControlsManager = GObject.registerClass( +class ControlsManager extends St.Widget { + _init(searchEntry) { + let layout = new ControlsLayout(); + super._init({ + layout_manager: layout, + x_expand: true, + y_expand: true, + clip_to_allocation: true, + }); + + this.dash = new Dash.Dash(); + this._dashSlider = new DashSlider(this.dash); + this._dashSpacer = new DashSpacer(); + this._dashSpacer.setDashActor(this._dashSlider); + + let workspaceManager = global.workspace_manager; + let activeWorkspaceIndex = workspaceManager.get_active_workspace_index(); + + this._workspaceAdjustment = new St.Adjustment({ + actor: this, + value: activeWorkspaceIndex, + lower: 0, + page_increment: 1, + page_size: 1, + step_increment: 0, + upper: workspaceManager.n_workspaces, + }); + + this._nWorkspacesNotifyId = + workspaceManager.connect('notify::n-workspaces', + this._updateAdjustment.bind(this)); + + this._thumbnailsBox = + new WorkspaceThumbnail.ThumbnailsBox(this._workspaceAdjustment); + this._thumbnailsSlider = new ThumbnailsSlider(this._thumbnailsBox); + + this.viewSelector = new ViewSelector.ViewSelector(searchEntry, + this._workspaceAdjustment, this.dash.showAppsButton); + this.viewSelector.connect('page-changed', this._setVisibility.bind(this)); + this.viewSelector.connect('page-empty', this._onPageEmpty.bind(this)); + + this._group = new St.BoxLayout({ name: 'overview-group', + x_expand: true, y_expand: true }); + this.add_actor(this._group); + + this.add_actor(this._dashSlider); + + this._group.add_actor(this._dashSpacer); + this._group.add_child(this.viewSelector); + this._group.add_actor(this._thumbnailsSlider); + + Main.overview.connect('showing', this._updateSpacerVisibility.bind(this)); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + _onDestroy() { + global.workspace_manager.disconnect(this._nWorkspacesNotifyId); + } + + _updateAdjustment() { + let workspaceManager = global.workspace_manager; + let newNumWorkspaces = workspaceManager.n_workspaces; + let activeIndex = workspaceManager.get_active_workspace_index(); + + this._workspaceAdjustment.upper = newNumWorkspaces; + + // A workspace might have been inserted or removed before the active + // one, causing the adjustment to go out of sync, so update the value + this._workspaceAdjustment.remove_transition('value'); + this._workspaceAdjustment.value = activeIndex; + } + + _setVisibility() { + // Ignore the case when we're leaving the overview, since + // actors will be made visible again when entering the overview + // next time, and animating them while doing so is just + // unnecessary noise + if (!Main.overview.visible || + (Main.overview.animationInProgress && !Main.overview.visibleTarget)) + return; + + let activePage = this.viewSelector.getActivePage(); + let dashVisible = activePage == ViewSelector.ViewPage.WINDOWS || + activePage == ViewSelector.ViewPage.APPS; + let thumbnailsVisible = activePage == ViewSelector.ViewPage.WINDOWS; + + if (dashVisible) + this._dashSlider.slideIn(); + else + this._dashSlider.slideOut(); + + if (thumbnailsVisible) + this._thumbnailsSlider.slideIn(); + else + this._thumbnailsSlider.slideOut(); + } + + _updateSpacerVisibility() { + if (Main.overview.animationInProgress && !Main.overview.visibleTarget) + return; + + let activePage = this.viewSelector.getActivePage(); + this._dashSpacer.visible = activePage == ViewSelector.ViewPage.WINDOWS; + } + + _onPageEmpty() { + this._dashSlider.pageEmpty(); + this._thumbnailsSlider.pageEmpty(); + + this._updateSpacerVisibility(); + } +}); diff --git a/js/ui/padOsd.js b/js/ui/padOsd.js new file mode 100644 index 0000000..9ab1cbb --- /dev/null +++ b/js/ui/padOsd.js @@ -0,0 +1,993 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported PadOsd, PadOsdService */ + +const { Atk, Clutter, GDesktopEnums, Gio, + GLib, GObject, Gtk, Meta, Pango, Rsvg, St } = imports.gi; +const ByteArray = imports.byteArray; +const Signals = imports.signals; + +const Main = imports.ui.main; +const PopupMenu = imports.ui.popupMenu; +const Layout = imports.ui.layout; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const ACTIVE_COLOR = "#729fcf"; + +const LTR = 0; +const RTL = 1; + +const CW = 0; +const CCW = 1; + +const UP = 0; +const DOWN = 1; + +var PadChooser = GObject.registerClass({ + Signals: { 'pad-selected': { param_types: [Clutter.InputDevice.$gtype] } }, +}, class PadChooser extends St.Button { + _init(device, groupDevices) { + super._init({ + style_class: 'pad-chooser-button', + toggle_mode: true, + }); + this.currentDevice = device; + this._padChooserMenu = null; + + let arrow = new St.Icon({ + style_class: 'popup-menu-arrow', + icon_name: 'pan-down-symbolic', + accessible_role: Atk.Role.ARROW, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + this.set_child(arrow); + this._ensureMenu(groupDevices); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + vfunc_clicked() { + if (this.get_checked()) { + if (this._padChooserMenu != null) + this._padChooserMenu.open(true); + else + this.set_checked(false); + } else { + this._padChooserMenu.close(true); + } + } + + _ensureMenu(devices) { + this._padChooserMenu = new PopupMenu.PopupMenu(this, 0.5, St.Side.TOP); + this._padChooserMenu.connect('menu-closed', () => { + this.set_checked(false); + }); + this._padChooserMenu.actor.hide(); + Main.uiGroup.add_actor(this._padChooserMenu.actor); + + for (let i = 0; i < devices.length; i++) { + let device = devices[i]; + if (device == this.currentDevice) + continue; + + this._padChooserMenu.addAction(device.get_device_name(), () => { + this.emit('pad-selected', device); + }); + } + } + + _onDestroy() { + this._padChooserMenu.destroy(); + } + + update(devices) { + if (this._padChooserMenu) + this._padChooserMenu.actor.destroy(); + this.set_checked(false); + this._ensureMenu(devices); + } +}); + +var KeybindingEntry = GObject.registerClass({ + Signals: { 'keybinding-edited': { param_types: [GObject.TYPE_STRING] } }, +}, class KeybindingEntry extends St.Entry { + _init() { + super._init({ hint_text: _("New shortcut…"), style: 'width: 10em' }); + } + + vfunc_captured_event(event) { + if (event.type() != Clutter.EventType.KEY_PRESS) + return Clutter.EVENT_PROPAGATE; + + let str = Gtk.accelerator_name_with_keycode(null, + event.get_key_symbol(), + event.get_key_code(), + event.get_state()); + this.set_text(str); + this.emit('keybinding-edited', str); + return Clutter.EVENT_STOP; + } +}); + +var ActionComboBox = GObject.registerClass({ + Signals: { 'action-selected': { param_types: [GObject.TYPE_INT] } }, +}, class ActionComboBox extends St.Button { + _init() { + super._init({ style_class: 'button' }); + this.set_toggle_mode(true); + + let boxLayout = new Clutter.BoxLayout({ orientation: Clutter.Orientation.HORIZONTAL, + spacing: 6 }); + let box = new St.Widget({ layout_manager: boxLayout }); + this.set_child(box); + + this._label = new St.Label({ style_class: 'combo-box-label' }); + box.add_child(this._label); + + let arrow = new St.Icon({ style_class: 'popup-menu-arrow', + icon_name: 'pan-down-symbolic', + accessible_role: Atk.Role.ARROW, + y_expand: true, + y_align: Clutter.ActorAlign.CENTER }); + box.add_child(arrow); + + this._editMenu = new PopupMenu.PopupMenu(this, 0, St.Side.TOP); + this._editMenu.connect('menu-closed', () => { + this.set_checked(false); + }); + this._editMenu.actor.hide(); + Main.uiGroup.add_actor(this._editMenu.actor); + + this._actionLabels = new Map(); + this._actionLabels.set(GDesktopEnums.PadButtonAction.NONE, _("Application defined")); + this._actionLabels.set(GDesktopEnums.PadButtonAction.HELP, _("Show on-screen help")); + this._actionLabels.set(GDesktopEnums.PadButtonAction.SWITCH_MONITOR, _("Switch monitor")); + this._actionLabels.set(GDesktopEnums.PadButtonAction.KEYBINDING, _("Assign keystroke")); + + this._buttonItems = []; + + for (let [action, label] of this._actionLabels.entries()) { + let selectedAction = action; + let item = this._editMenu.addAction(label, () => { + this._onActionSelected(selectedAction); + }); + + /* These actions only apply to pad buttons */ + if (selectedAction == GDesktopEnums.PadButtonAction.HELP || + selectedAction == GDesktopEnums.PadButtonAction.SWITCH_MONITOR) + this._buttonItems.push(item); + } + + this.setAction(GDesktopEnums.PadButtonAction.NONE); + } + + _onActionSelected(action) { + this.setAction(action); + this.popdown(); + this.emit('action-selected', action); + } + + setAction(action) { + this._label.set_text(this._actionLabels.get(action)); + } + + popup() { + this._editMenu.open(true); + } + + popdown() { + this._editMenu.close(true); + } + + vfunc_clicked() { + if (this.get_checked()) + this.popup(); + else + this.popdown(); + } + + setButtonActionsActive(active) { + this._buttonItems.forEach(item => item.setSensitive(active)); + } +}); + +var ActionEditor = GObject.registerClass({ + Signals: { 'done': {} }, +}, class ActionEditor extends St.Widget { + _init() { + let boxLayout = new Clutter.BoxLayout({ orientation: Clutter.Orientation.HORIZONTAL, + spacing: 12 }); + + super._init({ layout_manager: boxLayout }); + + this._actionComboBox = new ActionComboBox(); + this._actionComboBox.connect('action-selected', this._onActionSelected.bind(this)); + this.add_actor(this._actionComboBox); + + this._keybindingEdit = new KeybindingEntry(); + this._keybindingEdit.connect('keybinding-edited', this._onKeybindingEdited.bind(this)); + this.add_actor(this._keybindingEdit); + + this._doneButton = new St.Button({ label: _("Done"), + style_class: 'button', + x_expand: false }); + this._doneButton.connect('clicked', this._onEditingDone.bind(this)); + this.add_actor(this._doneButton); + } + + _updateKeybindingEntryState() { + if (this._currentAction == GDesktopEnums.PadButtonAction.KEYBINDING) { + this._keybindingEdit.set_text(this._currentKeybinding); + this._keybindingEdit.show(); + this._keybindingEdit.grab_key_focus(); + } else { + this._keybindingEdit.hide(); + } + } + + setSettings(settings, action) { + this._buttonSettings = settings; + + this._currentAction = this._buttonSettings.get_enum('action'); + this._currentKeybinding = this._buttonSettings.get_string('keybinding'); + this._actionComboBox.setAction(this._currentAction); + this._updateKeybindingEntryState(); + + let isButton = action == Meta.PadActionType.BUTTON; + this._actionComboBox.setButtonActionsActive(isButton); + } + + close() { + this._actionComboBox.popdown(); + this.hide(); + } + + _onKeybindingEdited(entry, keybinding) { + this._currentKeybinding = keybinding; + } + + _onActionSelected(menu, action) { + this._currentAction = action; + this._updateKeybindingEntryState(); + } + + _storeSettings() { + if (!this._buttonSettings) + return; + + let keybinding = null; + + if (this._currentAction == GDesktopEnums.PadButtonAction.KEYBINDING) + keybinding = this._currentKeybinding; + + this._buttonSettings.set_enum('action', this._currentAction); + + if (keybinding) + this._buttonSettings.set_string('keybinding', keybinding); + else + this._buttonSettings.reset('keybinding'); + } + + _onEditingDone() { + this._storeSettings(); + this.close(); + this.emit('done'); + } +}); + +var PadDiagram = GObject.registerClass({ + Properties: { + 'left-handed': GObject.ParamSpec.boolean('left-handed', + 'left-handed', 'Left handed', + GObject.ParamFlags.READWRITE | + GObject.ParamFlags.CONSTRUCT_ONLY, + false), + 'image': GObject.ParamSpec.string('image', 'image', 'Image', + GObject.ParamFlags.READWRITE | + GObject.ParamFlags.CONSTRUCT_ONLY, + null), + 'editor-actor': GObject.ParamSpec.object('editor-actor', + 'editor-actor', + 'Editor actor', + GObject.ParamFlags.READWRITE | + GObject.ParamFlags.CONSTRUCT_ONLY, + Clutter.Actor.$gtype), + }, +}, class PadDiagram extends St.DrawingArea { + _init(params) { + let file = Gio.File.new_for_uri('resource:///org/gnome/shell/theme/pad-osd.css'); + let [success_, css] = file.load_contents(null); + css = ByteArray.toString(css); + this._curEdited = null; + this._prevEdited = null; + this._css = css; + this._labels = []; + this._activeButtons = []; + super._init(params); + } + + // eslint-disable-next-line camelcase + get left_handed() { + return this._leftHanded; + } + + // eslint-disable-next-line camelcase + set left_handed(leftHanded) { + this._leftHanded = leftHanded; + } + + get image() { + return this._imagePath; + } + + set image(imagePath) { + let originalHandle = Rsvg.Handle.new_from_file(imagePath); + let dimensions = originalHandle.get_dimensions(); + this._imageWidth = dimensions.width; + this._imageHeight = dimensions.height; + + this._imagePath = imagePath; + this._handle = this._composeStyledDiagram(); + this._initLabels(); + } + + // eslint-disable-next-line camelcase + get editor_actor() { + return this._editorActor; + } + + // eslint-disable-next-line camelcase + set editor_actor(actor) { + actor.hide(); + this._editorActor = actor; + this.add_actor(actor); + } + + _initLabels() { + let i = 0; + for (i = 0; ; i++) { + if (!this._addLabel(Meta.PadActionType.BUTTON, i)) + break; + } + + for (i = 0; ; i++) { + if (!this._addLabel(Meta.PadActionType.RING, i, CW) || + !this._addLabel(Meta.PadActionType.RING, i, CCW)) + break; + } + + for (i = 0; ; i++) { + if (!this._addLabel(Meta.PadActionType.STRIP, i, UP) || + !this._addLabel(Meta.PadActionType.STRIP, i, DOWN)) + break; + } + } + + _wrappingSvgHeader() { + return '<?xml version="1.0" encoding="UTF-8" standalone="no"?>' + + '<svg version="1.1" xmlns="http://www.w3.org/2000/svg" ' + + 'xmlns:xi="http://www.w3.org/2001/XInclude" ' + + 'width="%d" height="%d">'.format(this._imageWidth, this._imageHeight) + + '<style type="text/css">'; + } + + _wrappingSvgFooter() { + return '</style>' + + '<xi:include href="' + this._imagePath + '" />' + + '</svg>'; + } + + _cssString() { + let css = this._css; + + for (let i = 0; i < this._activeButtons.length; i++) { + let ch = String.fromCharCode('A'.charCodeAt() + this._activeButtons[i]); + css += '.%s.Leader { stroke: %s !important; }'.format(ch, ACTIVE_COLOR); + css += '.%s.Button { stroke: %s !important; fill: %s !important; }'.format(ch, ACTIVE_COLOR, ACTIVE_COLOR); + } + + return css; + } + + _composeStyledDiagram() { + let svgData = ''; + + if (!GLib.file_test(this._imagePath, GLib.FileTest.EXISTS)) + return null; + + svgData += this._wrappingSvgHeader(); + svgData += this._cssString(); + svgData += this._wrappingSvgFooter(); + + let istream = new Gio.MemoryInputStream(); + istream.add_bytes(new GLib.Bytes(svgData)); + + return Rsvg.Handle.new_from_stream_sync(istream, + Gio.File.new_for_path(this._imagePath), + 0, null); + } + + _updateDiagramScale() { + [this._actorWidth, this._actorHeight] = this.get_size(); + let dimensions = this._handle.get_dimensions(); + let scaleX = this._actorWidth / dimensions.width; + let scaleY = this._actorHeight / dimensions.height; + this._scale = Math.min(scaleX, scaleY); + } + + _allocateChild(child, x, y, direction) { + let [, natHeight] = child.get_preferred_height(-1); + let [, natWidth] = child.get_preferred_width(natHeight); + let childBox = new Clutter.ActorBox(); + + // I miss Cairo.Matrix + let dimensions = this._handle.get_dimensions(); + x = x * this._scale + this._actorWidth / 2 - dimensions.width / 2 * this._scale; + y = y * this._scale + this._actorHeight / 2 - dimensions.height / 2 * this._scale; + + if (direction == LTR) { + childBox.x1 = x; + childBox.x2 = x + natWidth; + } else { + childBox.x1 = x - natWidth; + childBox.x2 = x; + } + + childBox.y1 = y - natHeight / 2; + childBox.y2 = y + natHeight / 2; + child.allocate(childBox); + } + + vfunc_allocate(box) { + super.vfunc_allocate(box); + if (this._handle === null) + return; + + this._updateDiagramScale(); + + for (let i = 0; i < this._labels.length; i++) { + const { label, x, y, arrangement } = this._labels[i]; + this._allocateChild(label, x, y, arrangement); + } + + if (this._editorActor && this._curEdited) { + const { x, y, arrangement } = this._curEdited; + this._allocateChild(this._editorActor, x, y, arrangement); + } + } + + vfunc_repaint() { + if (this._handle == null) + return; + + if (this._scale == null) + this._updateDiagramScale(); + + let [width, height] = this.get_surface_size(); + let dimensions = this._handle.get_dimensions(); + let cr = this.get_context(); + + cr.save(); + cr.translate(width / 2, height / 2); + cr.scale(this._scale, this._scale); + if (this._leftHanded) + cr.rotate(Math.PI); + cr.translate(-dimensions.width / 2, -dimensions.height / 2); + this._handle.render_cairo(cr); + cr.restore(); + cr.$dispose(); + } + + _getItemLabelCoords(labelName, leaderName) { + if (this._handle == null) + return [false]; + + let leaderPos, leaderSize, pos; + let found, direction; + + [found, pos] = this._handle.get_position_sub('#%s'.format(labelName)); + if (!found) + return [false]; + + [found, leaderPos] = this._handle.get_position_sub('#%s'.format(leaderName)); + [found, leaderSize] = this._handle.get_dimensions_sub('#%s'.format(leaderName)); + if (!found) + return [false]; + + if (pos.x > leaderPos.x + leaderSize.width) + direction = LTR; + else + direction = RTL; + + if (this._leftHanded) { + direction = 1 - direction; + pos.x = this._imageWidth - pos.x; + pos.y = this._imageHeight - pos.y; + } + + return [true, pos.x, pos.y, direction]; + } + + _getButtonLabels(button) { + let ch = String.fromCharCode('A'.charCodeAt() + button); + let labelName = 'Label%s'.format(ch); + let leaderName = 'Leader%s'.format(ch); + return [labelName, leaderName]; + } + + _getRingLabels(number, dir) { + let numStr = number > 0 ? (number + 1).toString() : ''; + let dirStr = dir == CW ? 'CW' : 'CCW'; + let labelName = 'LabelRing%s%s'.format(numStr, dirStr); + let leaderName = 'LeaderRing%s%s'.format(numStr, dirStr); + return [labelName, leaderName]; + } + + _getStripLabels(number, dir) { + let numStr = number > 0 ? (number + 1).toString() : ''; + let dirStr = dir == UP ? 'Up' : 'Down'; + let labelName = 'LabelStrip%s%s'.format(numStr, dirStr); + let leaderName = 'LeaderStrip%s%s'.format(numStr, dirStr); + return [labelName, leaderName]; + } + + _getLabelCoords(action, idx, dir) { + if (action == Meta.PadActionType.BUTTON) + return this._getItemLabelCoords(...this._getButtonLabels(idx)); + else if (action == Meta.PadActionType.RING) + return this._getItemLabelCoords(...this._getRingLabels(idx, dir)); + else if (action == Meta.PadActionType.STRIP) + return this._getItemLabelCoords(...this._getStripLabels(idx, dir)); + + return [false]; + } + + _invalidateSvg() { + if (this._handle == null) + return; + this._handle = this._composeStyledDiagram(); + this.queue_repaint(); + } + + activateButton(button) { + this._activeButtons.push(button); + this._invalidateSvg(); + } + + deactivateButton(button) { + for (let i = 0; i < this._activeButtons.length; i++) { + if (this._activeButtons[i] == button) + this._activeButtons.splice(i, 1); + } + this._invalidateSvg(); + } + + _addLabel(action, idx, dir) { + let [found, x, y, arrangement] = this._getLabelCoords(action, idx, dir); + if (!found) + return false; + + let label = new St.Label(); + this._labels.push({ label, action, idx, dir, x, y, arrangement }); + this.add_actor(label); + return true; + } + + updateLabels(getText) { + for (let i = 0; i < this._labels.length; i++) { + const { label, action, idx, dir } = this._labels[i]; + let str = getText(action, idx, dir); + label.set_text(str); + } + + this.queue_relayout(); + } + + _applyLabel(label, action, idx, dir, str) { + if (str !== null) + label.set_text(str); + label.show(); + } + + stopEdition(continues, str) { + this._editorActor.hide(); + + if (this._prevEdited) { + const { label, action, idx, dir } = this._prevEdited; + this._applyLabel(label, action, idx, dir, str); + this._prevEdited = null; + } + + if (this._curEdited) { + const { label, action, idx, dir } = this._curEdited; + this._applyLabel(label, action, idx, dir, str); + if (continues) + this._prevEdited = this._curEdited; + this._curEdited = null; + } + + this.queue_relayout(); + } + + startEdition(action, idx, dir) { + let editedLabel; + + if (this._curEdited) + return; + + for (let i = 0; i < this._labels.length; i++) { + if (action == this._labels[i].action && + idx == this._labels[i].idx && dir == this._labels[i].dir) { + this._curEdited = this._labels[i]; + editedLabel = this._curEdited.label; + break; + } + } + + if (this._curEdited == null) + return; + this._editorActor.show(); + editedLabel.hide(); + this.queue_relayout(); + } +}); + +var PadOsd = GObject.registerClass({ + Signals: { + 'pad-selected': { param_types: [Clutter.InputDevice.$gtype] }, + 'closed': {}, + }, +}, class PadOsd extends St.BoxLayout { + _init(padDevice, settings, imagePath, editionMode, monitorIndex) { + super._init({ + style_class: 'pad-osd-window', + vertical: true, + x_expand: true, + y_expand: true, + reactive: true, + }); + + this.padDevice = padDevice; + this._groupPads = [padDevice]; + this._settings = settings; + this._imagePath = imagePath; + this._editionMode = editionMode; + this._capturedEventId = global.stage.connect('captured-event', this._onCapturedEvent.bind(this)); + this._padChooser = null; + + let seat = Clutter.get_default_backend().get_default_seat(); + this._deviceAddedId = seat.connect('device-added', (_seat, device) => { + if (device.get_device_type() == Clutter.InputDeviceType.PAD_DEVICE && + this.padDevice.is_grouped(device)) { + this._groupPads.push(device); + this._updatePadChooser(); + } + }); + this._deviceRemovedId = seat.connect('device-removed', (_seat, device) => { + // If the device is being removed, destroy the padOsd. + if (device == this.padDevice) { + this.destroy(); + } else if (this._groupPads.includes(device)) { + // Or update the pad chooser if the device belongs to + // the same group. + this._groupPads.splice(this._groupPads.indexOf(device), 1); + this._updatePadChooser(); + + } + }); + + seat.list_devices().forEach(device => { + if (device != this.padDevice && + device.get_device_type() == Clutter.InputDeviceType.PAD_DEVICE && + this.padDevice.is_grouped(device)) + this._groupPads.push(device); + }); + + this.connect('destroy', this._onDestroy.bind(this)); + Main.uiGroup.add_actor(this); + + this._monitorIndex = monitorIndex; + let constraint = new Layout.MonitorConstraint({ index: monitorIndex }); + this.add_constraint(constraint); + + this._titleBox = new St.BoxLayout({ style_class: 'pad-osd-title-box', + vertical: false, + x_expand: false, + x_align: Clutter.ActorAlign.CENTER }); + this.add_actor(this._titleBox); + + let labelBox = new St.BoxLayout({ style_class: 'pad-osd-title-menu-box', + vertical: true }); + this._titleBox.add_actor(labelBox); + + this._titleLabel = new St.Label({ style: 'font-side: larger; font-weight: bold;', + x_align: Clutter.ActorAlign.CENTER }); + this._titleLabel.clutter_text.set_ellipsize(Pango.EllipsizeMode.NONE); + this._titleLabel.clutter_text.set_text(padDevice.get_device_name()); + labelBox.add_actor(this._titleLabel); + + this._tipLabel = new St.Label({ x_align: Clutter.ActorAlign.CENTER }); + labelBox.add_actor(this._tipLabel); + + this._updatePadChooser(); + + this._actionEditor = new ActionEditor(); + this._actionEditor.connect('done', this._endActionEdition.bind(this)); + + this._padDiagram = new PadDiagram({ image: this._imagePath, + left_handed: settings.get_boolean('left-handed'), + editor_actor: this._actionEditor, + x_expand: true, + y_expand: true }); + this.add_actor(this._padDiagram); + this._updateActionLabels(); + + let buttonBox = new St.Widget({ layout_manager: new Clutter.BinLayout(), + x_expand: true, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER }); + this.add_actor(buttonBox); + this._editButton = new St.Button({ + label: _('Edit…'), + style_class: 'button', + can_focus: true, + x_align: Clutter.ActorAlign.CENTER, + }); + this._editButton.connect('clicked', () => { + this.setEditionMode(true); + }); + buttonBox.add_actor(this._editButton); + + this._syncEditionMode(); + Main.pushModal(this); + } + + _updatePadChooser() { + if (this._groupPads.length > 1) { + if (this._padChooser == null) { + this._padChooser = new PadChooser(this.padDevice, this._groupPads); + this._padChooser.connect('pad-selected', (chooser, pad) => { + this._requestForOtherPad(pad); + }); + this._titleBox.add_child(this._padChooser); + } else { + this._padChooser.update(this._groupPads); + } + } else if (this._padChooser != null) { + this._padChooser.destroy(); + this._padChooser = null; + } + } + + _requestForOtherPad(pad) { + if (pad == this.padDevice || !this._groupPads.includes(pad)) + return; + + let editionMode = this._editionMode; + this.destroy(); + global.display.request_pad_osd(pad, editionMode); + } + + _getActionText(type, number) { + let str = global.display.get_pad_action_label(this.padDevice, type, number); + return str ? str : _("None"); + } + + _updateActionLabels() { + this._padDiagram.updateLabels(this._getActionText.bind(this)); + } + + _onCapturedEvent(actor, event) { + let isModeSwitch = + (event.type() == Clutter.EventType.PAD_BUTTON_PRESS || + event.type() == Clutter.EventType.PAD_BUTTON_RELEASE) && + this.padDevice.get_mode_switch_button_group(event.get_button()) >= 0; + + if (event.type() == Clutter.EventType.PAD_BUTTON_PRESS && + event.get_source_device() == this.padDevice) { + this._padDiagram.activateButton(event.get_button()); + + /* Buttons that switch between modes cannot be edited */ + if (this._editionMode && !isModeSwitch) + this._startButtonActionEdition(event.get_button()); + return Clutter.EVENT_STOP; + } else if (event.type() == Clutter.EventType.PAD_BUTTON_RELEASE && + event.get_source_device() == this.padDevice) { + this._padDiagram.deactivateButton(event.get_button()); + + if (isModeSwitch) { + this._endActionEdition(); + this._updateActionLabels(); + } + return Clutter.EVENT_STOP; + } else if (event.type() == Clutter.EventType.KEY_PRESS && + (!this._editionMode || event.get_key_symbol() === Clutter.KEY_Escape)) { + if (this._editedAction != null) + this._endActionEdition(); + else + this.destroy(); + return Clutter.EVENT_STOP; + } else if (event.get_source_device() == this.padDevice && + event.type() == Clutter.EventType.PAD_STRIP) { + if (this._editionMode) { + let [retval_, number, mode] = event.get_pad_event_details(); + this._startStripActionEdition(number, UP, mode); + } + } else if (event.get_source_device() == this.padDevice && + event.type() == Clutter.EventType.PAD_RING) { + if (this._editionMode) { + let [retval_, number, mode] = event.get_pad_event_details(); + this._startRingActionEdition(number, CCW, mode); + } + } + + // If the event comes from another pad in the same group, + // show the OSD for it. + if (this._groupPads.includes(event.get_source_device())) { + this._requestForOtherPad(event.get_source_device()); + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_PROPAGATE; + } + + _syncEditionMode() { + this._editButton.set_reactive(!this._editionMode); + this._editButton.save_easing_state(); + this._editButton.set_easing_duration(200); + this._editButton.set_opacity(this._editionMode ? 128 : 255); + this._editButton.restore_easing_state(); + + let title; + + if (this._editionMode) { + title = _("Press a button to configure"); + this._tipLabel.set_text(_("Press Esc to exit")); + } else { + title = this.padDevice.get_device_name(); + this._tipLabel.set_text(_("Press any key to exit")); + } + + this._titleLabel.set_text(title); + } + + _isEditedAction(type, number, dir) { + if (!this._editedAction) + return false; + + return this._editedAction.type == type && + this._editedAction.number == number && + this._editedAction.dir == dir; + } + + _followUpActionEdition(str) { + let { type, dir, number, mode } = this._editedAction; + let hasNextAction = type == Meta.PadActionType.RING && dir == CCW || + type == Meta.PadActionType.STRIP && dir == UP; + if (!hasNextAction) + return false; + + this._padDiagram.stopEdition(true, str); + this._editedAction = null; + if (type == Meta.PadActionType.RING) + this._startRingActionEdition(number, CW, mode); + else + this._startStripActionEdition(number, DOWN, mode); + + return true; + } + + _endActionEdition() { + this._actionEditor.close(); + + if (this._editedAction != null) { + let str = global.display.get_pad_action_label(this.padDevice, + this._editedAction.type, + this._editedAction.number); + if (this._followUpActionEdition(str)) + return; + + this._padDiagram.stopEdition(false, str ? str : _("None")); + this._editedAction = null; + } + + this._editedActionSettings = null; + } + + _startActionEdition(key, type, number, dir, mode) { + if (this._isEditedAction(type, number, dir)) + return; + + this._endActionEdition(); + this._editedAction = { type, number, dir, mode }; + + let settingsPath = this._settings.path + key + '/'; + this._editedActionSettings = Gio.Settings.new_with_path('org.gnome.desktop.peripherals.tablet.pad-button', + settingsPath); + this._actionEditor.setSettings(this._editedActionSettings, type); + this._padDiagram.startEdition(type, number, dir); + } + + _startButtonActionEdition(button) { + let ch = String.fromCharCode('A'.charCodeAt() + button); + let key = 'button%s'.format(ch); + this._startActionEdition(key, Meta.PadActionType.BUTTON, button); + } + + _startRingActionEdition(ring, dir, mode) { + let ch = String.fromCharCode('A'.charCodeAt() + ring); + let key = 'ring%s-%s-mode-%d'.format(ch, dir == CCW ? 'ccw' : 'cw', mode); + this._startActionEdition(key, Meta.PadActionType.RING, ring, dir, mode); + } + + _startStripActionEdition(strip, dir, mode) { + let ch = String.fromCharCode('A'.charCodeAt() + strip); + let key = 'strip%s-%s-mode-%d'.format(ch, dir == UP ? 'up' : 'down', mode); + this._startActionEdition(key, Meta.PadActionType.STRIP, strip, dir, mode); + } + + setEditionMode(editionMode) { + if (this._editionMode == editionMode) + return; + + this._editionMode = editionMode; + this._syncEditionMode(); + } + + _onDestroy() { + Main.popModal(this); + this._actionEditor.close(); + + let seat = Clutter.get_default_backend().get_default_seat(); + if (this._deviceRemovedId != 0) { + seat.disconnect(this._deviceRemovedId); + this._deviceRemovedId = 0; + } + if (this._deviceAddedId != 0) { + seat.disconnect(this._deviceAddedId); + this._deviceAddedId = 0; + } + + if (this._capturedEventId != 0) { + global.stage.disconnect(this._capturedEventId); + this._capturedEventId = 0; + } + + this.emit('closed'); + } +}); + +const PadOsdIface = loadInterfaceXML('org.gnome.Shell.Wacom.PadOsd'); + +var PadOsdService = class { + constructor() { + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(PadOsdIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell/Wacom'); + Gio.DBus.session.own_name('org.gnome.Shell.Wacom.PadOsd', Gio.BusNameOwnerFlags.REPLACE, null, null); + } + + ShowAsync(params, invocation) { + let [deviceNode, editionMode] = params; + let seat = Clutter.get_default_backend().get_default_seat(); + let devices = seat.list_devices(); + let padDevice = null; + + devices.forEach(device => { + if (deviceNode == device.get_device_node() && + device.get_device_type() == Clutter.InputDeviceType.PAD_DEVICE) + padDevice = device; + }); + + if (padDevice == null) { + invocation.return_error_literal(Gio.IOErrorEnum, + Gio.IOErrorEnum.CANCELLED, + "Invalid params"); + return; + } + + global.display.request_pad_osd(padDevice, editionMode); + invocation.return_value(null); + } +}; +Signals.addSignalMethods(PadOsdService.prototype); diff --git a/js/ui/pageIndicators.js b/js/ui/pageIndicators.js new file mode 100644 index 0000000..8ac44d4 --- /dev/null +++ b/js/ui/pageIndicators.js @@ -0,0 +1,194 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported PageIndicators, AnimatedPageIndicators */ + +const { Clutter, GLib, Graphene, GObject, Meta, St } = imports.gi; + +const { ANIMATION_TIME_OUT, ANIMATION_MAX_DELAY_OUT_FOR_ITEM, AnimationDirection } = imports.ui.iconGrid; + +const INDICATOR_INACTIVE_OPACITY = 128; +const INDICATOR_INACTIVE_OPACITY_HOVER = 255; +const INDICATOR_INACTIVE_SCALE = 2 / 3; +const INDICATOR_INACTIVE_SCALE_PRESSED = 0.5; + +var INDICATORS_BASE_TIME = 250; +var INDICATORS_BASE_TIME_OUT = 125; +var INDICATORS_ANIMATION_DELAY = 125; +var INDICATORS_ANIMATION_DELAY_OUT = 62.5; +var INDICATORS_ANIMATION_MAX_TIME = 750; +var SWITCH_TIME = 400; +var INDICATORS_ANIMATION_MAX_TIME_OUT = + Math.min(SWITCH_TIME, + ANIMATION_TIME_OUT + ANIMATION_MAX_DELAY_OUT_FOR_ITEM); + +var ANIMATION_DELAY = 100; + +var PageIndicators = GObject.registerClass({ + Signals: { 'page-activated': { param_types: [GObject.TYPE_INT] } }, +}, class PageIndicators extends St.BoxLayout { + _init(orientation = Clutter.Orientation.VERTICAL) { + let vertical = orientation == Clutter.Orientation.VERTICAL; + super._init({ + style_class: 'page-indicators', + vertical, + x_expand: true, y_expand: true, + x_align: vertical ? Clutter.ActorAlign.END : Clutter.ActorAlign.CENTER, + y_align: vertical ? Clutter.ActorAlign.CENTER : Clutter.ActorAlign.END, + reactive: true, + clip_to_allocation: true, + }); + this._nPages = 0; + this._currentPosition = 0; + this._reactive = true; + this._reactive = true; + } + + vfunc_get_preferred_height(forWidth) { + // We want to request the natural height of all our children as our + // natural height, so we chain up to St.BoxLayout, but we only request 0 + // as minimum height, since it's not that important if some indicators + // are not shown + let [, natHeight] = super.vfunc_get_preferred_height(forWidth); + return [0, natHeight]; + } + + setReactive(reactive) { + let children = this.get_children(); + for (let i = 0; i < children.length; i++) + children[i].reactive = reactive; + + this._reactive = reactive; + } + + setNPages(nPages) { + if (this._nPages == nPages) + return; + + let diff = nPages - this._nPages; + if (diff > 0) { + for (let i = 0; i < diff; i++) { + let pageIndex = this._nPages + i; + let indicator = new St.Button({ style_class: 'page-indicator', + button_mask: St.ButtonMask.ONE | + St.ButtonMask.TWO | + St.ButtonMask.THREE, + reactive: this._reactive }); + indicator.child = new St.Widget({ + style_class: 'page-indicator-icon', + pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }), + }); + indicator.connect('clicked', () => { + this.emit('page-activated', pageIndex); + }); + indicator.connect('notify::hover', () => { + this._updateIndicator(indicator, pageIndex); + }); + indicator.connect('notify::pressed', () => { + this._updateIndicator(indicator, pageIndex); + }); + this._updateIndicator(indicator, pageIndex); + this.add_actor(indicator); + } + } else { + let children = this.get_children().splice(diff); + for (let i = 0; i < children.length; i++) + children[i].destroy(); + } + this._nPages = nPages; + this.visible = this._nPages > 1; + } + + _updateIndicator(indicator, pageIndex) { + let progress = + Math.max(1 - Math.abs(this._currentPosition - pageIndex), 0); + + let inactiveScale = indicator.pressed + ? INDICATOR_INACTIVE_SCALE_PRESSED : INDICATOR_INACTIVE_SCALE; + let inactiveOpacity = indicator.hover + ? INDICATOR_INACTIVE_OPACITY_HOVER : INDICATOR_INACTIVE_OPACITY; + + let scale = inactiveScale + (1 - inactiveScale) * progress; + let opacity = inactiveOpacity + (255 - inactiveOpacity) * progress; + + indicator.child.set_scale(scale, scale); + indicator.child.opacity = opacity; + } + + setCurrentPosition(currentPosition) { + this._currentPosition = currentPosition; + + let children = this.get_children(); + for (let i = 0; i < children.length; i++) + this._updateIndicator(children[i], i); + } + + get nPages() { + return this._nPages; + } +}); + +var AnimatedPageIndicators = GObject.registerClass( +class AnimatedPageIndicators extends PageIndicators { + _init() { + super._init(); + this.connect('destroy', this._onDestroy.bind(this)); + } + + _onDestroy() { + if (this.animateLater) { + Meta.later_remove(this.animateLater); + this.animateLater = 0; + } + } + + vfunc_map() { + super.vfunc_map(); + + // Implicit animations are skipped for unmapped actors, and our + // children aren't mapped yet, so defer to a later handler + this.animateLater = Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + this.animateLater = 0; + this.animateIndicators(AnimationDirection.IN); + return GLib.SOURCE_REMOVE; + }); + } + + animateIndicators(animationDirection) { + if (!this.mapped) + return; + + let children = this.get_children(); + if (children.length == 0) + return; + + for (let i = 0; i < this._nPages; i++) + children[i].remove_all_transitions(); + + let offset; + if (this.get_text_direction() == Clutter.TextDirection.RTL) + offset = -children[0].width; + else + offset = children[0].width; + + let isAnimationIn = animationDirection == AnimationDirection.IN; + let delay = isAnimationIn + ? INDICATORS_ANIMATION_DELAY + : INDICATORS_ANIMATION_DELAY_OUT; + let baseTime = isAnimationIn ? INDICATORS_BASE_TIME : INDICATORS_BASE_TIME_OUT; + let totalAnimationTime = baseTime + delay * this._nPages; + let maxTime = isAnimationIn + ? INDICATORS_ANIMATION_MAX_TIME + : INDICATORS_ANIMATION_MAX_TIME_OUT; + if (totalAnimationTime > maxTime) + delay -= (totalAnimationTime - maxTime) / this._nPages; + + for (let i = 0; i < this._nPages; i++) { + children[i].translation_x = isAnimationIn ? offset : 0; + children[i].ease({ + translation_x: isAnimationIn ? 0 : offset, + duration: baseTime + delay * i, + mode: Clutter.AnimationMode.EASE_IN_OUT_QUAD, + delay: isAnimationIn ? ANIMATION_DELAY : 0, + }); + } + } +}); diff --git a/js/ui/panel.js b/js/ui/panel.js new file mode 100644 index 0000000..272368a --- /dev/null +++ b/js/ui/panel.js @@ -0,0 +1,1175 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Panel */ + +const { Atk, Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; +const Cairo = imports.cairo; + +const Animation = imports.ui.animation; +const Config = imports.misc.config; +const CtrlAltTab = imports.ui.ctrlAltTab; +const DND = imports.ui.dnd; +const Overview = imports.ui.overview; +const PopupMenu = imports.ui.popupMenu; +const PanelMenu = imports.ui.panelMenu; +const Main = imports.ui.main; + +var PANEL_ICON_SIZE = 16; +var APP_MENU_ICON_MARGIN = 0; + +var BUTTON_DND_ACTIVATION_TIMEOUT = 250; + +// To make sure the panel corners blend nicely with the panel, +// we draw background and borders the same way, e.g. drawing +// them as filled shapes from the outside inwards instead of +// using cairo stroke(). So in order to give the border the +// appearance of being drawn on top of the background, we need +// to blend border and background color together. +// For that purpose we use the following helper methods, taken +// from st-theme-node-drawing.c +function _norm(x) { + return Math.round(x / 255); +} + +function _over(srcColor, dstColor) { + let src = _premultiply(srcColor); + let dst = _premultiply(dstColor); + let result = new Clutter.Color(); + + result.alpha = src.alpha + _norm((255 - src.alpha) * dst.alpha); + result.red = src.red + _norm((255 - src.alpha) * dst.red); + result.green = src.green + _norm((255 - src.alpha) * dst.green); + result.blue = src.blue + _norm((255 - src.alpha) * dst.blue); + + return _unpremultiply(result); +} + +function _premultiply(color) { + return new Clutter.Color({ red: _norm(color.red * color.alpha), + green: _norm(color.green * color.alpha), + blue: _norm(color.blue * color.alpha), + alpha: color.alpha }); +} + +function _unpremultiply(color) { + if (color.alpha == 0) + return new Clutter.Color(); + + let red = Math.min((color.red * 255 + 127) / color.alpha, 255); + let green = Math.min((color.green * 255 + 127) / color.alpha, 255); + let blue = Math.min((color.blue * 255 + 127) / color.alpha, 255); + return new Clutter.Color({ red, green, blue, alpha: color.alpha }); +} + +class AppMenu extends PopupMenu.PopupMenu { + constructor(sourceActor) { + super(sourceActor, 0.5, St.Side.TOP); + + this.actor.add_style_class_name('app-menu'); + + this._app = null; + this._appSystem = Shell.AppSystem.get_default(); + + this._windowsChangedId = 0; + + /* Translators: This is the heading of a list of open windows */ + this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem(_("Open Windows"))); + + this._windowSection = new PopupMenu.PopupMenuSection(); + this.addMenuItem(this._windowSection); + + this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + this._newWindowItem = this.addAction(_("New Window"), () => { + this._app.open_new_window(-1); + }); + + this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + this._actionSection = new PopupMenu.PopupMenuSection(); + this.addMenuItem(this._actionSection); + + this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + this._detailsItem = this.addAction(_('Show Details'), async () => { + let id = this._app.get_id(); + let args = GLib.Variant.new('(ss)', [id, '']); + const bus = await Gio.DBus.get(Gio.BusType.SESSION, null); + bus.call( + 'org.gnome.Software', + '/org/gnome/Software', + 'org.gtk.Actions', 'Activate', + new GLib.Variant('(sava{sv})', ['details', [args], null]), + null, 0, -1, null); + }); + + this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + this.addAction(_("Quit"), () => { + this._app.request_quit(); + }); + + this._appSystem.connect('installed-changed', () => { + this._updateDetailsVisibility(); + }); + this._updateDetailsVisibility(); + } + + _updateDetailsVisibility() { + let sw = this._appSystem.lookup_app('org.gnome.Software.desktop'); + this._detailsItem.visible = sw != null; + } + + isEmpty() { + if (!this._app) + return true; + return super.isEmpty(); + } + + setApp(app) { + if (this._app == app) + return; + + if (this._windowsChangedId) + this._app.disconnect(this._windowsChangedId); + this._windowsChangedId = 0; + + this._app = app; + + if (app) { + this._windowsChangedId = app.connect('windows-changed', () => { + this._updateWindowsSection(); + }); + } + + this._updateWindowsSection(); + + let appInfo = app ? app.app_info : null; + let actions = appInfo ? appInfo.list_actions() : []; + + this._actionSection.removeAll(); + actions.forEach(action => { + let label = appInfo.get_action_name(action); + this._actionSection.addAction(label, event => { + this._app.launch_action(action, event.get_time(), -1); + }); + }); + + this._newWindowItem.visible = + app && app.can_open_new_window() && !actions.includes('new-window'); + } + + _updateWindowsSection() { + this._windowSection.removeAll(); + + if (!this._app) + return; + + let windows = this._app.get_windows(); + windows.forEach(window => { + let title = window.title || this._app.get_name(); + let item = this._windowSection.addAction(title, event => { + Main.activateWindow(window, event.get_time()); + }); + let id = window.connect('notify::title', () => { + item.label.text = window.title || this._app.get_name(); + }); + item.connect('destroy', () => window.disconnect(id)); + }); + } +} + +/** + * AppMenuButton: + * + * This class manages the "application menu" component. It tracks the + * currently focused application. However, when an app is launched, + * this menu also handles startup notification for it. So when we + * have an active startup notification, we switch modes to display that. + */ +var AppMenuButton = GObject.registerClass({ + Signals: { 'changed': {} }, +}, class AppMenuButton extends PanelMenu.Button { + _init(panel) { + super._init(0.0, null, true); + + this.accessible_role = Atk.Role.MENU; + + this._startingApps = []; + + this._menuManager = panel.menuManager; + this._targetApp = null; + this._busyNotifyId = 0; + + let bin = new St.Bin({ name: 'appMenu' }); + this.add_actor(bin); + + this.bind_property("reactive", this, "can-focus", 0); + this.reactive = false; + + this._container = new St.BoxLayout({ style_class: 'panel-status-menu-box' }); + bin.set_child(this._container); + + let textureCache = St.TextureCache.get_default(); + textureCache.connect('icon-theme-changed', + this._onIconThemeChanged.bind(this)); + + let iconEffect = new Clutter.DesaturateEffect(); + this._iconBox = new St.Bin({ + style_class: 'app-menu-icon', + y_align: Clutter.ActorAlign.CENTER, + }); + this._iconBox.add_effect(iconEffect); + this._container.add_actor(this._iconBox); + + this._iconBox.connect('style-changed', () => { + let themeNode = this._iconBox.get_theme_node(); + iconEffect.enabled = themeNode.get_icon_style() == St.IconStyle.SYMBOLIC; + }); + + this._label = new St.Label({ y_expand: true, + y_align: Clutter.ActorAlign.CENTER }); + this._container.add_actor(this._label); + this._arrow = PopupMenu.arrowIcon(St.Side.BOTTOM); + this._container.add_actor(this._arrow); + + this._visible = !Main.overview.visible; + if (!this._visible) + this.hide(); + this._overviewHidingId = Main.overview.connect('hiding', this._sync.bind(this)); + this._overviewShowingId = Main.overview.connect('showing', this._sync.bind(this)); + + this._spinner = new Animation.Spinner(PANEL_ICON_SIZE, { + animate: true, + hideOnStop: true, + }); + this._container.add_actor(this._spinner); + + let menu = new AppMenu(this); + this.setMenu(menu); + this._menuManager.addMenu(menu); + + let tracker = Shell.WindowTracker.get_default(); + let appSys = Shell.AppSystem.get_default(); + this._focusAppNotifyId = + tracker.connect('notify::focus-app', this._focusAppChanged.bind(this)); + this._appStateChangedSignalId = + appSys.connect('app-state-changed', this._onAppStateChanged.bind(this)); + this._switchWorkspaceNotifyId = + global.window_manager.connect('switch-workspace', this._sync.bind(this)); + + this._sync(); + } + + fadeIn() { + if (this._visible) + return; + + this._visible = true; + this.reactive = true; + this.show(); + this.remove_all_transitions(); + this.ease({ + opacity: 255, + duration: Overview.ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + fadeOut() { + if (!this._visible) + return; + + this._visible = false; + this.reactive = false; + this.remove_all_transitions(); + this.ease({ + opacity: 0, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: Overview.ANIMATION_TIME, + onComplete: () => this.hide(), + }); + } + + _syncIcon() { + if (!this._targetApp) + return; + + let icon = this._targetApp.create_icon_texture(PANEL_ICON_SIZE - APP_MENU_ICON_MARGIN); + this._iconBox.set_child(icon); + } + + _onIconThemeChanged() { + if (this._iconBox.child == null) + return; + + this._syncIcon(); + } + + stopAnimation() { + this._spinner.stop(); + } + + startAnimation() { + this._spinner.play(); + } + + _onAppStateChanged(appSys, app) { + let state = app.state; + if (state != Shell.AppState.STARTING) + this._startingApps = this._startingApps.filter(a => a != app); + else if (state == Shell.AppState.STARTING) + this._startingApps.push(app); + // For now just resync on all running state changes; this is mainly to handle + // cases where the focused window's application changes without the focus + // changing. An example case is how we map OpenOffice.org based on the window + // title which is a dynamic property. + this._sync(); + } + + _focusAppChanged() { + let tracker = Shell.WindowTracker.get_default(); + let focusedApp = tracker.focus_app; + if (!focusedApp) { + // If the app has just lost focus to the panel, pretend + // nothing happened; otherwise you can't keynav to the + // app menu. + if (global.stage.key_focus != null) + return; + } + this._sync(); + } + + _findTargetApp() { + let workspaceManager = global.workspace_manager; + let workspace = workspaceManager.get_active_workspace(); + let tracker = Shell.WindowTracker.get_default(); + let focusedApp = tracker.focus_app; + if (focusedApp && focusedApp.is_on_workspace(workspace)) + return focusedApp; + + for (let i = 0; i < this._startingApps.length; i++) { + if (this._startingApps[i].is_on_workspace(workspace)) + return this._startingApps[i]; + } + + return null; + } + + _sync() { + let targetApp = this._findTargetApp(); + + if (this._targetApp != targetApp) { + if (this._busyNotifyId) { + this._targetApp.disconnect(this._busyNotifyId); + this._busyNotifyId = 0; + } + + this._targetApp = targetApp; + + if (this._targetApp) { + this._busyNotifyId = this._targetApp.connect('notify::busy', this._sync.bind(this)); + this._label.set_text(this._targetApp.get_name()); + this.set_accessible_name(this._targetApp.get_name()); + } + } + + let visible = this._targetApp != null && !Main.overview.visibleTarget; + if (visible) + this.fadeIn(); + else + this.fadeOut(); + + let isBusy = this._targetApp != null && + (this._targetApp.get_state() == Shell.AppState.STARTING || + this._targetApp.get_busy()); + if (isBusy) + this.startAnimation(); + else + this.stopAnimation(); + + this.reactive = visible && !isBusy; + + this._syncIcon(); + this.menu.setApp(this._targetApp); + this.emit('changed'); + } + + _onDestroy() { + if (this._appStateChangedSignalId > 0) { + let appSys = Shell.AppSystem.get_default(); + appSys.disconnect(this._appStateChangedSignalId); + this._appStateChangedSignalId = 0; + } + if (this._focusAppNotifyId > 0) { + let tracker = Shell.WindowTracker.get_default(); + tracker.disconnect(this._focusAppNotifyId); + this._focusAppNotifyId = 0; + } + if (this._overviewHidingId > 0) { + Main.overview.disconnect(this._overviewHidingId); + this._overviewHidingId = 0; + } + if (this._overviewShowingId > 0) { + Main.overview.disconnect(this._overviewShowingId); + this._overviewShowingId = 0; + } + if (this._switchWorkspaceNotifyId > 0) { + global.window_manager.disconnect(this._switchWorkspaceNotifyId); + this._switchWorkspaceNotifyId = 0; + } + + super._onDestroy(); + } +}); + +var ActivitiesButton = GObject.registerClass( +class ActivitiesButton extends PanelMenu.Button { + _init() { + super._init(0.0, null, true); + this.accessible_role = Atk.Role.TOGGLE_BUTTON; + + this.name = 'panelActivities'; + + /* Translators: If there is no suitable word for "Activities" + in your language, you can use the word for "Overview". */ + this._label = new St.Label({ text: _("Activities"), + y_align: Clutter.ActorAlign.CENTER }); + this.add_actor(this._label); + + this.label_actor = this._label; + + Main.overview.connect('showing', () => { + this.add_style_pseudo_class('overview'); + this.add_accessible_state(Atk.StateType.CHECKED); + }); + Main.overview.connect('hiding', () => { + this.remove_style_pseudo_class('overview'); + this.remove_accessible_state(Atk.StateType.CHECKED); + }); + + this._xdndTimeOut = 0; + } + + handleDragOver(source, _actor, _x, _y, _time) { + if (source != Main.xdndHandler) + return DND.DragMotionResult.CONTINUE; + + if (this._xdndTimeOut != 0) + GLib.source_remove(this._xdndTimeOut); + this._xdndTimeOut = GLib.timeout_add(GLib.PRIORITY_DEFAULT, BUTTON_DND_ACTIVATION_TIMEOUT, () => { + this._xdndToggleOverview(); + }); + GLib.Source.set_name_by_id(this._xdndTimeOut, '[gnome-shell] this._xdndToggleOverview'); + + return DND.DragMotionResult.CONTINUE; + } + + vfunc_captured_event(event) { + if (event.type() == Clutter.EventType.BUTTON_PRESS || + event.type() == Clutter.EventType.TOUCH_BEGIN) { + if (!Main.overview.shouldToggleByCornerOrButton()) + return Clutter.EVENT_STOP; + } + return Clutter.EVENT_PROPAGATE; + } + + vfunc_event(event) { + if (event.type() == Clutter.EventType.TOUCH_END || + event.type() == Clutter.EventType.BUTTON_RELEASE) { + if (Main.overview.shouldToggleByCornerOrButton()) + Main.overview.toggle(); + } + + return Clutter.EVENT_PROPAGATE; + } + + vfunc_key_release_event(keyEvent) { + let symbol = keyEvent.keyval; + if (symbol == Clutter.KEY_Return || symbol == Clutter.KEY_space) { + if (Main.overview.shouldToggleByCornerOrButton()) { + Main.overview.toggle(); + return Clutter.EVENT_STOP; + } + } + + return Clutter.EVENT_PROPAGATE; + } + + _xdndToggleOverview() { + let [x, y] = global.get_pointer(); + let pickedActor = global.stage.get_actor_at_pos(Clutter.PickMode.REACTIVE, x, y); + + if (pickedActor == this && Main.overview.shouldToggleByCornerOrButton()) + Main.overview.toggle(); + + GLib.source_remove(this._xdndTimeOut); + this._xdndTimeOut = 0; + return GLib.SOURCE_REMOVE; + } +}); + +var PanelCorner = GObject.registerClass( +class PanelCorner extends St.DrawingArea { + _init(side) { + this._side = side; + + super._init({ style_class: 'panel-corner' }); + } + + _findRightmostButton(container) { + if (!container.get_children) + return null; + + let children = container.get_children(); + + if (!children || children.length == 0) + return null; + + // Start at the back and work backward + let index; + for (index = children.length - 1; index >= 0; index--) { + if (children[index].visible) + break; + } + if (index < 0) + return null; + + if (!(children[index] instanceof St.Widget)) + return null; + + if (!children[index].has_style_class_name('panel-menu') && + !children[index].has_style_class_name('panel-button')) + return this._findRightmostButton(children[index]); + + return children[index]; + } + + _findLeftmostButton(container) { + if (!container.get_children) + return null; + + let children = container.get_children(); + + if (!children || children.length == 0) + return null; + + // Start at the front and work forward + let index; + for (index = 0; index < children.length; index++) { + if (children[index].visible) + break; + } + if (index == children.length) + return null; + + if (!(children[index] instanceof St.Widget)) + return null; + + if (!children[index].has_style_class_name('panel-menu') && + !children[index].has_style_class_name('panel-button')) + return this._findLeftmostButton(children[index]); + + return children[index]; + } + + setStyleParent(box) { + let side = this._side; + + let rtlAwareContainer = box instanceof St.BoxLayout; + if (rtlAwareContainer && + box.get_text_direction() == Clutter.TextDirection.RTL) { + if (this._side == St.Side.LEFT) + side = St.Side.RIGHT; + else if (this._side == St.Side.RIGHT) + side = St.Side.LEFT; + } + + let button; + if (side == St.Side.LEFT) + button = this._findLeftmostButton(box); + else if (side == St.Side.RIGHT) + button = this._findRightmostButton(box); + + if (button) { + if (this._button) { + if (this._buttonStyleChangedSignalId) { + this._button.disconnect(this._buttonStyleChangedSignalId); + this._button.style = null; + } + + if (this._buttonDestroySignalId) + this._button.disconnect(this._buttonDestroySignalId); + } + + this._button = button; + + this._buttonDestroySignalId = button.connect('destroy', () => { + if (this._button == button) { + this._button = null; + this._buttonStyleChangedSignalId = 0; + } + }); + + // Synchronize the locate button's pseudo classes with this corner + this._buttonStyleChangedSignalId = button.connect('style-changed', + () => { + let pseudoClass = button.get_style_pseudo_class(); + this.set_style_pseudo_class(pseudoClass); + }); + + // The corner doesn't support theme transitions, so override + // the .panel-button default + button.style = 'transition-duration: 0ms'; + } + } + + vfunc_repaint() { + let node = this.get_theme_node(); + + let cornerRadius = node.get_length("-panel-corner-radius"); + let borderWidth = node.get_length('-panel-corner-border-width'); + + let backgroundColor = node.get_color('-panel-corner-background-color'); + let borderColor = node.get_color('-panel-corner-border-color'); + + let overlap = borderColor.alpha != 0; + let offsetY = overlap ? 0 : borderWidth; + + let cr = this.get_context(); + cr.setOperator(Cairo.Operator.SOURCE); + + cr.moveTo(0, offsetY); + if (this._side == St.Side.LEFT) { + cr.arc(cornerRadius, + borderWidth + cornerRadius, + cornerRadius, Math.PI, 3 * Math.PI / 2); + } else { + cr.arc(0, + borderWidth + cornerRadius, + cornerRadius, 3 * Math.PI / 2, 2 * Math.PI); + } + cr.lineTo(cornerRadius, offsetY); + cr.closePath(); + + let savedPath = cr.copyPath(); + + let xOffsetDirection = this._side == St.Side.LEFT ? -1 : 1; + let over = _over(borderColor, backgroundColor); + Clutter.cairo_set_source_color(cr, over); + cr.fill(); + + if (overlap) { + let offset = borderWidth; + Clutter.cairo_set_source_color(cr, backgroundColor); + + cr.save(); + cr.translate(xOffsetDirection * offset, -offset); + cr.appendPath(savedPath); + cr.fill(); + cr.restore(); + } + + cr.$dispose(); + } + + vfunc_style_changed() { + super.vfunc_style_changed(); + let node = this.get_theme_node(); + + let cornerRadius = node.get_length("-panel-corner-radius"); + let borderWidth = node.get_length('-panel-corner-border-width'); + + this.set_size(cornerRadius, borderWidth + cornerRadius); + this.translation_y = -borderWidth; + } +}); + +var AggregateLayout = GObject.registerClass( +class AggregateLayout extends Clutter.BoxLayout { + _init(params = {}) { + params['orientation'] = Clutter.Orientation.VERTICAL; + super._init(params); + + this._sizeChildren = []; + } + + addSizeChild(actor) { + this._sizeChildren.push(actor); + this.layout_changed(); + } + + vfunc_get_preferred_width(container, forHeight) { + let themeNode = container.get_theme_node(); + let minWidth = themeNode.get_min_width(); + let natWidth = minWidth; + + for (let i = 0; i < this._sizeChildren.length; i++) { + let child = this._sizeChildren[i]; + let [childMin, childNat] = child.get_preferred_width(forHeight); + minWidth = Math.max(minWidth, childMin); + natWidth = Math.max(natWidth, childNat); + } + return [minWidth, natWidth]; + } +}); + +var AggregateMenu = GObject.registerClass( +class AggregateMenu extends PanelMenu.Button { + _init() { + super._init(0.0, C_("System menu in the top bar", "System"), false); + this.menu.actor.add_style_class_name('aggregate-menu'); + + let menuLayout = new AggregateLayout(); + this.menu.box.set_layout_manager(menuLayout); + + this._indicators = new St.BoxLayout({ style_class: 'panel-status-indicators-box' }); + this.add_child(this._indicators); + + if (Config.HAVE_NETWORKMANAGER) + this._network = new imports.ui.status.network.NMApplet(); + else + this._network = null; + + if (Config.HAVE_BLUETOOTH) + this._bluetooth = new imports.ui.status.bluetooth.Indicator(); + else + this._bluetooth = null; + + this._remoteAccess = new imports.ui.status.remoteAccess.RemoteAccessApplet(); + this._power = new imports.ui.status.power.Indicator(); + this._rfkill = new imports.ui.status.rfkill.Indicator(); + this._volume = new imports.ui.status.volume.Indicator(); + this._brightness = new imports.ui.status.brightness.Indicator(); + this._system = new imports.ui.status.system.Indicator(); + this._location = new imports.ui.status.location.Indicator(); + this._nightLight = new imports.ui.status.nightLight.Indicator(); + this._thunderbolt = new imports.ui.status.thunderbolt.Indicator(); + + this._indicators.add_child(this._remoteAccess); + this._indicators.add_child(this._thunderbolt); + this._indicators.add_child(this._location); + this._indicators.add_child(this._nightLight); + if (this._network) + this._indicators.add_child(this._network); + if (this._bluetooth) + this._indicators.add_child(this._bluetooth); + this._indicators.add_child(this._rfkill); + this._indicators.add_child(this._volume); + this._indicators.add_child(this._power); + this._indicators.add_child(PopupMenu.arrowIcon(St.Side.BOTTOM)); + + this.menu.addMenuItem(this._volume.menu); + this.menu.addMenuItem(this._brightness.menu); + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + if (this._network) + this.menu.addMenuItem(this._network.menu); + + if (this._bluetooth) + this.menu.addMenuItem(this._bluetooth.menu); + + this.menu.addMenuItem(this._remoteAccess.menu); + this.menu.addMenuItem(this._location.menu); + this.menu.addMenuItem(this._rfkill.menu); + this.menu.addMenuItem(this._power.menu); + this.menu.addMenuItem(this._nightLight.menu); + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + this.menu.addMenuItem(this._system.menu); + + menuLayout.addSizeChild(this._location.menu.actor); + menuLayout.addSizeChild(this._rfkill.menu.actor); + menuLayout.addSizeChild(this._power.menu.actor); + menuLayout.addSizeChild(this._system.menu.actor); + } +}); + +const PANEL_ITEM_IMPLEMENTATIONS = { + 'activities': ActivitiesButton, + 'aggregateMenu': AggregateMenu, + 'appMenu': AppMenuButton, + 'dateMenu': imports.ui.dateMenu.DateMenuButton, + 'a11y': imports.ui.status.accessibility.ATIndicator, + 'keyboard': imports.ui.status.keyboard.InputSourceIndicator, + 'dwellClick': imports.ui.status.dwellClick.DwellClickIndicator, +}; + +var Panel = GObject.registerClass( +class Panel extends St.Widget { + _init() { + super._init({ name: 'panel', + reactive: true }); + + this.set_offscreen_redirect(Clutter.OffscreenRedirect.ALWAYS); + + this._sessionStyle = null; + + this.statusArea = {}; + + this.menuManager = new PopupMenu.PopupMenuManager(this); + + this._leftBox = new St.BoxLayout({ name: 'panelLeft' }); + this.add_child(this._leftBox); + this._centerBox = new St.BoxLayout({ name: 'panelCenter' }); + this.add_child(this._centerBox); + this._rightBox = new St.BoxLayout({ name: 'panelRight' }); + this.add_child(this._rightBox); + + this._leftCorner = new PanelCorner(St.Side.LEFT); + this.add_child(this._leftCorner); + + this._rightCorner = new PanelCorner(St.Side.RIGHT); + this.add_child(this._rightCorner); + + Main.overview.connect('showing', () => { + this.add_style_pseudo_class('overview'); + }); + Main.overview.connect('hiding', () => { + this.remove_style_pseudo_class('overview'); + }); + + Main.layoutManager.panelBox.add(this); + Main.ctrlAltTabManager.addGroup(this, _("Top Bar"), 'focus-top-bar-symbolic', + { sortGroup: CtrlAltTab.SortGroup.TOP }); + + Main.sessionMode.connect('updated', this._updatePanel.bind(this)); + + global.display.connect('workareas-changed', () => this.queue_relayout()); + this._updatePanel(); + } + + vfunc_get_preferred_width(_forHeight) { + let primaryMonitor = Main.layoutManager.primaryMonitor; + + if (primaryMonitor) + return [0, primaryMonitor.width]; + + return [0, 0]; + } + + vfunc_allocate(box) { + this.set_allocation(box); + + let allocWidth = box.x2 - box.x1; + let allocHeight = box.y2 - box.y1; + + let [, leftNaturalWidth] = this._leftBox.get_preferred_width(-1); + let [, centerNaturalWidth] = this._centerBox.get_preferred_width(-1); + let [, rightNaturalWidth] = this._rightBox.get_preferred_width(-1); + + let sideWidth, centerWidth; + centerWidth = centerNaturalWidth; + + // get workspace area and center date entry relative to it + let monitor = Main.layoutManager.findMonitorForActor(this); + let centerOffset = 0; + if (monitor) { + let workArea = Main.layoutManager.getWorkAreaForMonitor(monitor.index); + centerOffset = 2 * (workArea.x - monitor.x) + workArea.width - monitor.width; + } + + sideWidth = Math.max(0, (allocWidth - centerWidth + centerOffset) / 2); + + let childBox = new Clutter.ActorBox(); + + childBox.y1 = 0; + childBox.y2 = allocHeight; + if (this.get_text_direction() == Clutter.TextDirection.RTL) { + childBox.x1 = Math.max(allocWidth - Math.min(Math.floor(sideWidth), + leftNaturalWidth), + 0); + childBox.x2 = allocWidth; + } else { + childBox.x1 = 0; + childBox.x2 = Math.min(Math.floor(sideWidth), + leftNaturalWidth); + } + this._leftBox.allocate(childBox); + + childBox.x1 = Math.ceil(sideWidth); + childBox.y1 = 0; + childBox.x2 = childBox.x1 + centerWidth; + childBox.y2 = allocHeight; + this._centerBox.allocate(childBox); + + childBox.y1 = 0; + childBox.y2 = allocHeight; + if (this.get_text_direction() == Clutter.TextDirection.RTL) { + childBox.x1 = 0; + childBox.x2 = Math.min(Math.floor(sideWidth), + rightNaturalWidth); + } else { + childBox.x1 = Math.max(allocWidth - Math.min(Math.floor(sideWidth), + rightNaturalWidth), + 0); + childBox.x2 = allocWidth; + } + this._rightBox.allocate(childBox); + + let cornerWidth, cornerHeight; + + [, cornerWidth] = this._leftCorner.get_preferred_width(-1); + [, cornerHeight] = this._leftCorner.get_preferred_height(-1); + childBox.x1 = 0; + childBox.x2 = cornerWidth; + childBox.y1 = allocHeight; + childBox.y2 = allocHeight + cornerHeight; + this._leftCorner.allocate(childBox); + + [, cornerWidth] = this._rightCorner.get_preferred_width(-1); + [, cornerHeight] = this._rightCorner.get_preferred_height(-1); + childBox.x1 = allocWidth - cornerWidth; + childBox.x2 = allocWidth; + childBox.y1 = allocHeight; + childBox.y2 = allocHeight + cornerHeight; + this._rightCorner.allocate(childBox); + } + + _tryDragWindow(event) { + if (Main.modalCount > 0) + return Clutter.EVENT_PROPAGATE; + + if (event.source != this) + return Clutter.EVENT_PROPAGATE; + + let { x, y } = event; + let dragWindow = this._getDraggableWindowForPosition(x); + + if (!dragWindow) + return Clutter.EVENT_PROPAGATE; + + return global.display.begin_grab_op( + dragWindow, + Meta.GrabOp.MOVING, + false, /* pointer grab */ + true, /* frame action */ + event.button || -1, + event.modifier_state, + event.time, + x, y) ? Clutter.EVENT_STOP : Clutter.EVENT_PROPAGATE; + } + + vfunc_button_press_event(buttonEvent) { + if (buttonEvent.button != 1) + return Clutter.EVENT_PROPAGATE; + + return this._tryDragWindow(buttonEvent); + } + + vfunc_touch_event(touchEvent) { + if (touchEvent.type != Clutter.EventType.TOUCH_BEGIN) + return Clutter.EVENT_PROPAGATE; + + return this._tryDragWindow(touchEvent); + } + + vfunc_key_press_event(keyEvent) { + let symbol = keyEvent.keyval; + if (symbol == Clutter.KEY_Escape) { + global.display.focus_default_window(keyEvent.time); + return Clutter.EVENT_STOP; + } + + return super.vfunc_key_press_event(keyEvent); + } + + _toggleMenu(indicator) { + if (!indicator || !indicator.mapped) + return; // menu not supported by current session mode + + let menu = indicator.menu; + if (!indicator.reactive) + return; + + menu.toggle(); + if (menu.isOpen) + menu.actor.navigate_focus(null, St.DirectionType.TAB_FORWARD, false); + } + + toggleAppMenu() { + this._toggleMenu(this.statusArea.appMenu); + } + + toggleCalendar() { + this._toggleMenu(this.statusArea.dateMenu); + } + + closeCalendar() { + let indicator = this.statusArea.dateMenu; + if (!indicator) // calendar not supported by current session mode + return; + + let menu = indicator.menu; + if (!indicator.reactive) + return; + + menu.close(); + } + + set boxOpacity(value) { + let isReactive = value > 0; + + this._leftBox.opacity = value; + this._leftBox.reactive = isReactive; + this._centerBox.opacity = value; + this._centerBox.reactive = isReactive; + this._rightBox.opacity = value; + this._rightBox.reactive = isReactive; + } + + get boxOpacity() { + return this._leftBox.opacity; + } + + _updatePanel() { + let panel = Main.sessionMode.panel; + this._hideIndicators(); + this._updateBox(panel.left, this._leftBox); + this._updateBox(panel.center, this._centerBox); + this._updateBox(panel.right, this._rightBox); + + if (panel.left.includes('dateMenu')) + Main.messageTray.bannerAlignment = Clutter.ActorAlign.START; + else if (panel.right.includes('dateMenu')) + Main.messageTray.bannerAlignment = Clutter.ActorAlign.END; + // Default to center if there is no dateMenu + else + Main.messageTray.bannerAlignment = Clutter.ActorAlign.CENTER; + + if (this._sessionStyle) + this._removeStyleClassName(this._sessionStyle); + + this._sessionStyle = Main.sessionMode.panelStyle; + if (this._sessionStyle) + this._addStyleClassName(this._sessionStyle); + + if (this.get_text_direction() == Clutter.TextDirection.RTL) { + this._leftCorner.setStyleParent(this._rightBox); + this._rightCorner.setStyleParent(this._leftBox); + } else { + this._leftCorner.setStyleParent(this._leftBox); + this._rightCorner.setStyleParent(this._rightBox); + } + } + + _hideIndicators() { + for (let role in PANEL_ITEM_IMPLEMENTATIONS) { + let indicator = this.statusArea[role]; + if (!indicator) + continue; + indicator.container.hide(); + } + } + + _ensureIndicator(role) { + let indicator = this.statusArea[role]; + if (!indicator) { + let constructor = PANEL_ITEM_IMPLEMENTATIONS[role]; + if (!constructor) { + // This icon is not implemented (this is a bug) + return null; + } + indicator = new constructor(this); + this.statusArea[role] = indicator; + } + return indicator; + } + + _updateBox(elements, box) { + let nChildren = box.get_n_children(); + + for (let i = 0; i < elements.length; i++) { + let role = elements[i]; + let indicator = this._ensureIndicator(role); + if (indicator == null) + continue; + + this._addToPanelBox(role, indicator, i + nChildren, box); + } + } + + _addToPanelBox(role, indicator, position, box) { + let container = indicator.container; + container.show(); + + let parent = container.get_parent(); + if (parent) + parent.remove_actor(container); + + + box.insert_child_at_index(container, position); + if (indicator.menu) + this.menuManager.addMenu(indicator.menu); + this.statusArea[role] = indicator; + let destroyId = indicator.connect('destroy', emitter => { + delete this.statusArea[role]; + emitter.disconnect(destroyId); + }); + indicator.connect('menu-set', this._onMenuSet.bind(this)); + this._onMenuSet(indicator); + } + + addToStatusArea(role, indicator, position, box) { + if (this.statusArea[role]) + throw new Error('Extension point conflict: there is already a status indicator for role %s'.format(role)); + + if (!(indicator instanceof PanelMenu.Button)) + throw new TypeError('Status indicator must be an instance of PanelMenu.Button'); + + position = position || 0; + let boxes = { + left: this._leftBox, + center: this._centerBox, + right: this._rightBox, + }; + let boxContainer = boxes[box] || this._rightBox; + this.statusArea[role] = indicator; + this._addToPanelBox(role, indicator, position, boxContainer); + return indicator; + } + + _addStyleClassName(className) { + this.add_style_class_name(className); + this._rightCorner.add_style_class_name(className); + this._leftCorner.add_style_class_name(className); + } + + _removeStyleClassName(className) { + this.remove_style_class_name(className); + this._rightCorner.remove_style_class_name(className); + this._leftCorner.remove_style_class_name(className); + } + + _onMenuSet(indicator) { + if (!indicator.menu || indicator.menu._openChangedId) + return; + + indicator.menu._openChangedId = indicator.menu.connect('open-state-changed', + (menu, isOpen) => { + let boxAlignment; + if (this._leftBox.contains(indicator.container)) + boxAlignment = Clutter.ActorAlign.START; + else if (this._centerBox.contains(indicator.container)) + boxAlignment = Clutter.ActorAlign.CENTER; + else if (this._rightBox.contains(indicator.container)) + boxAlignment = Clutter.ActorAlign.END; + + if (boxAlignment == Main.messageTray.bannerAlignment) + Main.messageTray.bannerBlocked = isOpen; + }); + } + + _getDraggableWindowForPosition(stageX) { + let workspaceManager = global.workspace_manager; + const windows = workspaceManager.get_active_workspace().list_windows(); + const allWindowsByStacking = + global.display.sort_windows_by_stacking(windows).reverse(); + + return allWindowsByStacking.find(metaWindow => { + let rect = metaWindow.get_frame_rect(); + return metaWindow.is_on_primary_monitor() && + metaWindow.showing_on_its_workspace() && + metaWindow.get_window_type() != Meta.WindowType.DESKTOP && + metaWindow.maximized_vertically && + stageX > rect.x && stageX < rect.x + rect.width; + }); + } +}); diff --git a/js/ui/panelMenu.js b/js/ui/panelMenu.js new file mode 100644 index 0000000..81d3449 --- /dev/null +++ b/js/ui/panelMenu.js @@ -0,0 +1,228 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Button, SystemIndicator */ + +const { Atk, Clutter, GObject, St } = imports.gi; + +const Main = imports.ui.main; +const Params = imports.misc.params; +const PopupMenu = imports.ui.popupMenu; + +var ButtonBox = GObject.registerClass( +class ButtonBox extends St.Widget { + _init(params) { + params = Params.parse(params, { + style_class: 'panel-button', + x_expand: true, + y_expand: true, + }, true); + + super._init(params); + + this._delegate = this; + + this.container = new St.Bin({ child: this }); + + this.connect('style-changed', this._onStyleChanged.bind(this)); + this.connect('destroy', this._onDestroy.bind(this)); + + this._minHPadding = this._natHPadding = 0.0; + } + + _onStyleChanged(actor) { + let themeNode = actor.get_theme_node(); + + this._minHPadding = themeNode.get_length('-minimum-hpadding'); + this._natHPadding = themeNode.get_length('-natural-hpadding'); + } + + vfunc_get_preferred_width(_forHeight) { + let child = this.get_first_child(); + let minimumSize, naturalSize; + + if (child) + [minimumSize, naturalSize] = child.get_preferred_width(-1); + else + minimumSize = naturalSize = 0; + + minimumSize += 2 * this._minHPadding; + naturalSize += 2 * this._natHPadding; + + return [minimumSize, naturalSize]; + } + + vfunc_get_preferred_height(_forWidth) { + let child = this.get_first_child(); + + if (child) + return child.get_preferred_height(-1); + + return [0, 0]; + } + + vfunc_allocate(box) { + this.set_allocation(box); + + let child = this.get_first_child(); + if (!child) + return; + + let [, natWidth] = child.get_preferred_width(-1); + + let availWidth = box.x2 - box.x1; + let availHeight = box.y2 - box.y1; + + let childBox = new Clutter.ActorBox(); + if (natWidth + 2 * this._natHPadding <= availWidth) { + childBox.x1 = this._natHPadding; + childBox.x2 = availWidth - this._natHPadding; + } else { + childBox.x1 = this._minHPadding; + childBox.x2 = availWidth - this._minHPadding; + } + + childBox.y1 = 0; + childBox.y2 = availHeight; + + child.allocate(childBox); + } + + _onDestroy() { + this.container.child = null; + this.container.destroy(); + } +}); + +var Button = GObject.registerClass({ + Signals: { 'menu-set': {} }, +}, class PanelMenuButton extends ButtonBox { + _init(menuAlignment, nameText, dontCreateMenu) { + super._init({ reactive: true, + can_focus: true, + track_hover: true, + accessible_name: nameText ? nameText : "", + accessible_role: Atk.Role.MENU }); + + if (dontCreateMenu) + this.menu = new PopupMenu.PopupDummyMenu(this); + else + this.setMenu(new PopupMenu.PopupMenu(this, menuAlignment, St.Side.TOP, 0)); + } + + setSensitive(sensitive) { + this.reactive = sensitive; + this.can_focus = sensitive; + this.track_hover = sensitive; + } + + setMenu(menu) { + if (this.menu) + this.menu.destroy(); + + this.menu = menu; + if (this.menu) { + this.menu.actor.add_style_class_name('panel-menu'); + this.menu.connect('open-state-changed', this._onOpenStateChanged.bind(this)); + this.menu.actor.connect('key-press-event', this._onMenuKeyPress.bind(this)); + + Main.uiGroup.add_actor(this.menu.actor); + this.menu.actor.hide(); + } + this.emit('menu-set'); + } + + vfunc_event(event) { + if (this.menu && + (event.type() == Clutter.EventType.TOUCH_BEGIN || + event.type() == Clutter.EventType.BUTTON_PRESS)) + this.menu.toggle(); + + return Clutter.EVENT_PROPAGATE; + } + + vfunc_hide() { + super.vfunc_hide(); + + if (this.menu) + this.menu.close(); + } + + _onMenuKeyPress(actor, event) { + if (global.focus_manager.navigate_from_event(event)) + return Clutter.EVENT_STOP; + + let symbol = event.get_key_symbol(); + if (symbol == Clutter.KEY_Left || symbol == Clutter.KEY_Right) { + let group = global.focus_manager.get_group(this); + if (group) { + let direction = symbol == Clutter.KEY_Left ? St.DirectionType.LEFT : St.DirectionType.RIGHT; + group.navigate_focus(this, direction, false); + return Clutter.EVENT_STOP; + } + } + return Clutter.EVENT_PROPAGATE; + } + + _onOpenStateChanged(menu, open) { + if (open) + this.add_style_pseudo_class('active'); + else + this.remove_style_pseudo_class('active'); + + // Setting the max-height won't do any good if the minimum height of the + // menu is higher then the screen; it's useful if part of the menu is + // scrollable so the minimum height is smaller than the natural height + let workArea = Main.layoutManager.getWorkAreaForMonitor(Main.layoutManager.primaryIndex); + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + let verticalMargins = this.menu.actor.margin_top + this.menu.actor.margin_bottom; + + // The workarea and margin dimensions are in physical pixels, but CSS + // measures are in logical pixels, so make sure to consider the scale + // factor when computing max-height + let maxHeight = Math.round((workArea.height - verticalMargins) / scaleFactor); + this.menu.actor.style = 'max-height: %spx;'.format(maxHeight); + } + + _onDestroy() { + if (this.menu) + this.menu.destroy(); + super._onDestroy(); + } +}); + +/* SystemIndicator: + * + * This class manages one system indicator, which are the icons + * that you see at the top right. A system indicator is composed + * of an icon and a menu section, which will be composed into the + * aggregate menu. + */ +var SystemIndicator = GObject.registerClass( +class SystemIndicator extends St.BoxLayout { + _init() { + super._init({ + style_class: 'panel-status-indicators-box', + reactive: true, + visible: false, + }); + this.menu = new PopupMenu.PopupMenuSection(); + } + + get indicators() { + let klass = this.constructor.name; + let { stack } = new Error(); + log(`Usage of indicator.indicators is deprecated for ${klass}\n${stack}`); + return this; + } + + _syncIndicatorsVisible() { + this.visible = this.get_children().some(a => a.visible); + } + + _addIndicator() { + let icon = new St.Icon({ style_class: 'system-status-icon' }); + this.add_actor(icon); + icon.connect('notify::visible', this._syncIndicatorsVisible.bind(this)); + this._syncIndicatorsVisible(); + return icon; + } +}); diff --git a/js/ui/pointerA11yTimeout.js b/js/ui/pointerA11yTimeout.js new file mode 100644 index 0000000..263cc3e --- /dev/null +++ b/js/ui/pointerA11yTimeout.js @@ -0,0 +1,134 @@ +/* exported PointerA11yTimeout */ +const { Clutter, GObject, Meta, St } = imports.gi; +const Main = imports.ui.main; +const Cairo = imports.cairo; + +const SUCCESS_ZOOM_OUT_DURATION = 150; + +var PieTimer = GObject.registerClass({ + Properties: { + 'angle': GObject.ParamSpec.double( + 'angle', 'angle', 'angle', + GObject.ParamFlags.READWRITE, + 0, 2 * Math.PI, 0), + }, +}, class PieTimer extends St.DrawingArea { + _init() { + this._angle = 0; + super._init({ + style_class: 'pie-timer', + opacity: 0, + visible: false, + can_focus: false, + reactive: false, + }); + + this.set_pivot_point(0.5, 0.5); + } + + get angle() { + return this._angle; + } + + set angle(angle) { + if (this._angle == angle) + return; + + this._angle = angle; + this.notify('angle'); + this.queue_repaint(); + } + + vfunc_repaint() { + let node = this.get_theme_node(); + let backgroundColor = node.get_color('-pie-background-color'); + let borderColor = node.get_color('-pie-border-color'); + let borderWidth = node.get_length('-pie-border-width'); + let [width, height] = this.get_surface_size(); + let radius = Math.min(width / 2, height / 2); + + let startAngle = 3 * Math.PI / 2; + let endAngle = startAngle + this._angle; + + let cr = this.get_context(); + cr.setLineCap(Cairo.LineCap.ROUND); + cr.setLineJoin(Cairo.LineJoin.ROUND); + cr.translate(width / 2, height / 2); + + if (this._angle < 2 * Math.PI) + cr.moveTo(0, 0); + + cr.arc(0, 0, radius - borderWidth, startAngle, endAngle); + + if (this._angle < 2 * Math.PI) + cr.lineTo(0, 0); + + cr.closePath(); + + cr.setLineWidth(0); + Clutter.cairo_set_source_color(cr, backgroundColor); + cr.fillPreserve(); + + cr.setLineWidth(borderWidth); + Clutter.cairo_set_source_color(cr, borderColor); + cr.stroke(); + + cr.$dispose(); + } + + start(x, y, duration) { + this.x = x - this.width / 2; + this.y = y - this.height / 2; + this.show(); + + this.ease({ + opacity: 255, + duration: duration / 4, + mode: Clutter.AnimationMode.EASE_IN_QUAD, + }); + + this.ease_property('angle', 2 * Math.PI, { + duration, + mode: Clutter.AnimationMode.LINEAR, + onComplete: this._onTransitionComplete.bind(this), + }); + } + + _onTransitionComplete() { + this.ease({ + scale_x: 2, + scale_y: 2, + opacity: 0, + duration: SUCCESS_ZOOM_OUT_DURATION, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onStopped: () => this.destroy(), + }); + } +}); + +var PointerA11yTimeout = class PointerA11yTimeout { + constructor() { + let seat = Clutter.get_default_backend().get_default_seat(); + + seat.connect('ptr-a11y-timeout-started', (o, device, type, timeout) => { + let [x, y] = global.get_pointer(); + + this._pieTimer = new PieTimer(); + Main.uiGroup.add_actor(this._pieTimer); + Main.uiGroup.set_child_above_sibling(this._pieTimer, null); + + this._pieTimer.start(x, y, timeout); + + if (type == Clutter.PointerA11yTimeoutType.GESTURE) + global.display.set_cursor(Meta.Cursor.CROSSHAIR); + }); + + seat.connect('ptr-a11y-timeout-stopped', (o, device, type, clicked) => { + if (!clicked) + this._pieTimer.destroy(); + + if (type == Clutter.PointerA11yTimeoutType.GESTURE) + global.display.set_cursor(Meta.Cursor.DEFAULT); + }); + } +}; diff --git a/js/ui/pointerWatcher.js b/js/ui/pointerWatcher.js new file mode 100644 index 0000000..9dbdcf6 --- /dev/null +++ b/js/ui/pointerWatcher.js @@ -0,0 +1,125 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported getPointerWatcher */ + +const { GLib, Meta } = imports.gi; + +// We stop polling if the user is idle for more than this amount of time +var IDLE_TIME = 1000; + +// This file implements a reasonably efficient system for tracking the position +// of the mouse pointer. We simply query the pointer from the X server in a loop, +// but we turn off the polling when the user is idle. + +let _pointerWatcher = null; +function getPointerWatcher() { + if (_pointerWatcher == null) + _pointerWatcher = new PointerWatcher(); + + return _pointerWatcher; +} + +var PointerWatch = class { + constructor(watcher, interval, callback) { + this.watcher = watcher; + this.interval = interval; + this.callback = callback; + } + + // remove: + // remove this watch. This function may safely be called + // while the callback is executing. + remove() { + this.watcher._removeWatch(this); + } +}; + +var PointerWatcher = class { + constructor() { + this._idleMonitor = Meta.IdleMonitor.get_core(); + this._idleMonitor.add_idle_watch(IDLE_TIME, this._onIdleMonitorBecameIdle.bind(this)); + this._idle = this._idleMonitor.get_idletime() > IDLE_TIME; + this._watches = []; + this.pointerX = null; + this.pointerY = null; + } + + // addWatch: + // @interval: hint as to the time resolution needed. When the user is + // not idle, the position of the pointer will be queried at least + // once every this many milliseconds. + // @callback to call when the pointer position changes - takes + // two arguments, X and Y. + // + // Set up a watch on the position of the mouse pointer. Returns a + // PointerWatch object which has a remove() method to remove the watch. + addWatch(interval, callback) { + // Avoid unreliably calling the watch for the current position + this._updatePointer(); + + let watch = new PointerWatch(this, interval, callback); + this._watches.push(watch); + this._updateTimeout(); + return watch; + } + + _removeWatch(watch) { + for (let i = 0; i < this._watches.length; i++) { + if (this._watches[i] == watch) { + this._watches.splice(i, 1); + this._updateTimeout(); + return; + } + } + } + + _onIdleMonitorBecameActive() { + this._idle = false; + this._updatePointer(); + this._updateTimeout(); + } + + _onIdleMonitorBecameIdle() { + this._idle = true; + this._idleMonitor.add_user_active_watch(this._onIdleMonitorBecameActive.bind(this)); + this._updateTimeout(); + } + + _updateTimeout() { + if (this._timeoutId) { + GLib.source_remove(this._timeoutId); + this._timeoutId = 0; + } + + if (this._idle || this._watches.length == 0) + return; + + let minInterval = this._watches[0].interval; + for (let i = 1; i < this._watches.length; i++) + minInterval = Math.min(this._watches[i].interval, minInterval); + + this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, minInterval, + this._onTimeout.bind(this)); + GLib.Source.set_name_by_id(this._timeoutId, '[gnome-shell] this._onTimeout'); + } + + _onTimeout() { + this._updatePointer(); + return GLib.SOURCE_CONTINUE; + } + + _updatePointer() { + let [x, y] = global.get_pointer(); + if (this.pointerX == x && this.pointerY == y) + return; + + this.pointerX = x; + this.pointerY = y; + + for (let i = 0; i < this._watches.length;) { + let watch = this._watches[i]; + watch.callback(x, y); + if (watch == this._watches[i]) // guard against self-removal + i++; + } + } +}; diff --git a/js/ui/popupMenu.js b/js/ui/popupMenu.js new file mode 100644 index 0000000..a618f8f --- /dev/null +++ b/js/ui/popupMenu.js @@ -0,0 +1,1411 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported PopupMenuItem, PopupSeparatorMenuItem, Switch, PopupSwitchMenuItem, + PopupImageMenuItem, PopupMenu, PopupDummyMenu, PopupSubMenu, + PopupMenuSection, PopupSubMenuMenuItem, PopupMenuManager */ + +const { Atk, Clutter, Gio, GObject, Graphene, Shell, St } = imports.gi; +const Signals = imports.signals; + +const BoxPointer = imports.ui.boxpointer; +const GrabHelper = imports.ui.grabHelper; +const Main = imports.ui.main; +const Params = imports.misc.params; + +var Ornament = { + NONE: 0, + DOT: 1, + CHECK: 2, + HIDDEN: 3, +}; + +function isPopupMenuItemVisible(child) { + if (child._delegate instanceof PopupMenuSection) { + if (child._delegate.isEmpty()) + return false; + } + return child.visible; +} + +/** + * arrowIcon + * @param {St.Side} side - Side to which the arrow points. + * @returns {St.Icon} a new arrow icon + */ +function arrowIcon(side) { + let iconName; + switch (side) { + case St.Side.TOP: + iconName = 'pan-up-symbolic'; + break; + case St.Side.RIGHT: + iconName = 'pan-end-symbolic'; + break; + case St.Side.BOTTOM: + iconName = 'pan-down-symbolic'; + break; + case St.Side.LEFT: + iconName = 'pan-start-symbolic'; + break; + } + + let arrow = new St.Icon({ style_class: 'popup-menu-arrow', + icon_name: iconName, + accessible_role: Atk.Role.ARROW, + y_expand: true, + y_align: Clutter.ActorAlign.CENTER }); + + return arrow; +} + +var PopupBaseMenuItem = GObject.registerClass({ + Properties: { + 'active': GObject.ParamSpec.boolean('active', 'active', 'active', + GObject.ParamFlags.READWRITE, + false), + 'sensitive': GObject.ParamSpec.boolean('sensitive', 'sensitive', 'sensitive', + GObject.ParamFlags.READWRITE, + true), + }, + Signals: { + 'activate': { param_types: [Clutter.Event.$gtype] }, + }, +}, class PopupBaseMenuItem extends St.BoxLayout { + _init(params) { + params = Params.parse(params, { + reactive: true, + activate: true, + hover: true, + style_class: null, + can_focus: true, + }); + super._init({ style_class: 'popup-menu-item', + reactive: params.reactive, + track_hover: params.reactive, + can_focus: params.can_focus, + accessible_role: Atk.Role.MENU_ITEM }); + this._delegate = this; + + this._ornament = Ornament.NONE; + this._ornamentLabel = new St.Label({ style_class: 'popup-menu-ornament' }); + this.add(this._ornamentLabel); + + this._parent = null; + this._active = false; + this._activatable = params.reactive && params.activate; + this._sensitive = true; + + if (!this._activatable) + this.add_style_class_name('popup-inactive-menu-item'); + + if (params.style_class) + this.add_style_class_name(params.style_class); + + if (params.reactive && params.hover) + this.bind_property('hover', this, 'active', GObject.BindingFlags.SYNC_CREATE); + } + + get actor() { + /* This is kept for compatibility with current implementation, and we + don't want to warn here yet since PopupMenu depends on this */ + return this; + } + + _getTopMenu() { + if (this._parent) + return this._parent._getTopMenu(); + else + return this; + } + + _setParent(parent) { + this._parent = parent; + } + + vfunc_button_press_event() { + if (!this._activatable) + return Clutter.EVENT_PROPAGATE; + + // This is the CSS active state + this.add_style_pseudo_class('active'); + return Clutter.EVENT_PROPAGATE; + } + + vfunc_button_release_event() { + if (!this._activatable) + return Clutter.EVENT_PROPAGATE; + + this.remove_style_pseudo_class('active'); + this.activate(Clutter.get_current_event()); + return Clutter.EVENT_STOP; + } + + vfunc_touch_event(touchEvent) { + if (!this._activatable) + return Clutter.EVENT_PROPAGATE; + + if (touchEvent.type == Clutter.EventType.TOUCH_END) { + this.remove_style_pseudo_class('active'); + this.activate(Clutter.get_current_event()); + return Clutter.EVENT_STOP; + } else if (touchEvent.type == Clutter.EventType.TOUCH_BEGIN) { + // This is the CSS active state + this.add_style_pseudo_class('active'); + } + return Clutter.EVENT_PROPAGATE; + } + + vfunc_key_press_event(keyEvent) { + if (!this._activatable) + return super.vfunc_key_press_event(keyEvent); + + let state = keyEvent.modifier_state; + + // if user has a modifier down (except capslock and numlock) + // then don't handle the key press here + state &= ~Clutter.ModifierType.LOCK_MASK; + state &= ~Clutter.ModifierType.MOD2_MASK; + state &= Clutter.ModifierType.MODIFIER_MASK; + + if (state) + return Clutter.EVENT_PROPAGATE; + + let symbol = keyEvent.keyval; + if (symbol == Clutter.KEY_space || symbol == Clutter.KEY_Return) { + this.activate(Clutter.get_current_event()); + return Clutter.EVENT_STOP; + } + return Clutter.EVENT_PROPAGATE; + } + + vfunc_key_focus_in() { + super.vfunc_key_focus_in(); + this.active = true; + } + + vfunc_key_focus_out() { + super.vfunc_key_focus_out(); + this.active = false; + } + + activate(event) { + this.emit('activate', event); + } + + get active() { + return this._active; + } + + set active(active) { + let activeChanged = active != this.active; + if (activeChanged) { + this._active = active; + if (active) { + this.add_style_class_name('selected'); + if (this.can_focus) + this.grab_key_focus(); + } else { + this.remove_style_class_name('selected'); + // Remove the CSS active state if the user press the button and + // while holding moves to another menu item, so we don't paint all items. + // The correct behaviour would be to set the new item with the CSS + // active state as well, but button-press-event is not triggered, + // so we should track it in our own, which would involve some work + // in the container + this.remove_style_pseudo_class('active'); + } + this.notify('active'); + } + } + + syncSensitive() { + let sensitive = this.sensitive; + this.reactive = sensitive; + this.can_focus = sensitive; + this.notify('sensitive'); + return sensitive; + } + + getSensitive() { + let parentSensitive = this._parent ? this._parent.sensitive : true; + return this._activatable && this._sensitive && parentSensitive; + } + + setSensitive(sensitive) { + if (this._sensitive == sensitive) + return; + + this._sensitive = sensitive; + this.syncSensitive(); + } + + get sensitive() { + return this.getSensitive(); + } + + set sensitive(sensitive) { + this.setSensitive(sensitive); + } + + setOrnament(ornament) { + if (ornament == this._ornament) + return; + + this._ornament = ornament; + + if (ornament == Ornament.DOT) { + this._ornamentLabel.text = '\u2022'; + this.add_accessible_state(Atk.StateType.CHECKED); + } else if (ornament == Ornament.CHECK) { + this._ornamentLabel.text = '\u2713'; + this.add_accessible_state(Atk.StateType.CHECKED); + } else if (ornament == Ornament.NONE || ornament == Ornament.HIDDEN) { + this._ornamentLabel.text = ''; + this.remove_accessible_state(Atk.StateType.CHECKED); + } + + this._ornamentLabel.visible = ornament != Ornament.HIDDEN; + } +}); + +var PopupMenuItem = GObject.registerClass( +class PopupMenuItem extends PopupBaseMenuItem { + _init(text, params) { + super._init(params); + + this.label = new St.Label({ text }); + this.add_child(this.label); + this.label_actor = this.label; + } +}); + + +var PopupSeparatorMenuItem = GObject.registerClass( +class PopupSeparatorMenuItem extends PopupBaseMenuItem { + _init(text) { + super._init({ + style_class: 'popup-separator-menu-item', + reactive: false, + can_focus: false, + }); + + this.label = new St.Label({ text: text || '' }); + this.add(this.label); + this.label_actor = this.label; + + this.label.connect('notify::text', + this._syncVisibility.bind(this)); + this._syncVisibility(); + + this._separator = new St.Widget({ + style_class: 'popup-separator-menu-item-separator', + x_expand: true, + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + this.add_child(this._separator); + } + + _syncVisibility() { + this.label.visible = this.label.text != ''; + } +}); + +var Switch = GObject.registerClass({ + Properties: { + 'state': GObject.ParamSpec.boolean( + 'state', 'state', 'state', + GObject.ParamFlags.READWRITE, + false), + }, +}, class Switch extends St.Bin { + _init(state) { + this._state = false; + + super._init({ + style_class: 'toggle-switch', + accessible_role: Atk.Role.CHECK_BOX, + can_focus: true, + state, + }); + } + + get state() { + return this._state; + } + + set state(state) { + if (this._state === state) + return; + + if (state) + this.add_style_pseudo_class('checked'); + else + this.remove_style_pseudo_class('checked'); + + this._state = state; + this.notify('state'); + } + + toggle() { + this.state = !this.state; + } +}); + +var PopupSwitchMenuItem = GObject.registerClass({ + Signals: { 'toggled': { param_types: [GObject.TYPE_BOOLEAN] } }, +}, class PopupSwitchMenuItem extends PopupBaseMenuItem { + _init(text, active, params) { + super._init(params); + + this.label = new St.Label({ text }); + this._switch = new Switch(active); + + this.accessible_role = Atk.Role.CHECK_MENU_ITEM; + this.checkAccessibleState(); + this.label_actor = this.label; + + this.add_child(this.label); + + this._statusBin = new St.Bin({ + x_align: Clutter.ActorAlign.END, + x_expand: true, + }); + this.add_child(this._statusBin); + + this._statusLabel = new St.Label({ + text: '', + style_class: 'popup-status-menu-item', + }); + this._statusBin.child = this._switch; + } + + setStatus(text) { + if (text != null) { + this._statusLabel.text = text; + this._statusBin.child = this._statusLabel; + this.reactive = false; + this.accessible_role = Atk.Role.MENU_ITEM; + } else { + this._statusBin.child = this._switch; + this.reactive = true; + this.accessible_role = Atk.Role.CHECK_MENU_ITEM; + } + this.checkAccessibleState(); + } + + activate(event) { + if (this._switch.mapped) + this.toggle(); + + // we allow pressing space to toggle the switch + // without closing the menu + if (event.type() == Clutter.EventType.KEY_PRESS && + event.get_key_symbol() == Clutter.KEY_space) + return; + + super.activate(event); + } + + toggle() { + this._switch.toggle(); + this.emit('toggled', this._switch.state); + this.checkAccessibleState(); + } + + get state() { + return this._switch.state; + } + + setToggleState(state) { + this._switch.state = state; + this.checkAccessibleState(); + } + + checkAccessibleState() { + switch (this.accessible_role) { + case Atk.Role.CHECK_MENU_ITEM: + if (this._switch.state) + this.add_accessible_state(Atk.StateType.CHECKED); + else + this.remove_accessible_state(Atk.StateType.CHECKED); + break; + default: + this.remove_accessible_state(Atk.StateType.CHECKED); + } + } +}); + +var PopupImageMenuItem = GObject.registerClass( +class PopupImageMenuItem extends PopupBaseMenuItem { + _init(text, icon, params) { + super._init(params); + + this._icon = new St.Icon({ style_class: 'popup-menu-icon', + x_align: Clutter.ActorAlign.END }); + this.add_child(this._icon); + this.label = new St.Label({ text }); + this.add_child(this.label); + this.label_actor = this.label; + + this.setIcon(icon); + } + + setIcon(icon) { + // The 'icon' parameter can be either a Gio.Icon or a string. + if (icon instanceof GObject.Object && GObject.type_is_a(icon, Gio.Icon)) + this._icon.gicon = icon; + else + this._icon.icon_name = icon; + } +}); + +var PopupMenuBase = class { + constructor(sourceActor, styleClass) { + if (this.constructor === PopupMenuBase) + throw new TypeError('Cannot instantiate abstract class %s'.format(this.constructor.name)); + + this.sourceActor = sourceActor; + this.focusActor = sourceActor; + this._parent = null; + + this.box = new St.BoxLayout({ + vertical: true, + x_expand: true, + y_expand: true, + }); + + if (styleClass !== undefined) + this.box.style_class = styleClass; + this.length = 0; + + this.isOpen = false; + + // If set, we don't send events (including crossing events) to the source actor + // for the menu which causes its prelight state to freeze + this.blockSourceEvents = false; + + this._activeMenuItem = null; + this._settingsActions = { }; + + this._sensitive = true; + + this._sessionUpdatedId = Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + } + + _getTopMenu() { + if (this._parent) + return this._parent._getTopMenu(); + else + return this; + } + + _setParent(parent) { + this._parent = parent; + } + + getSensitive() { + let parentSensitive = this._parent ? this._parent.sensitive : true; + return this._sensitive && parentSensitive; + } + + setSensitive(sensitive) { + this._sensitive = sensitive; + this.emit('notify::sensitive'); + } + + get sensitive() { + return this.getSensitive(); + } + + set sensitive(sensitive) { + this.setSensitive(sensitive); + } + + _sessionUpdated() { + this._setSettingsVisibility(Main.sessionMode.allowSettings); + this.close(); + } + + addAction(title, callback, icon) { + let menuItem; + if (icon != undefined) + menuItem = new PopupImageMenuItem(title, icon); + else + menuItem = new PopupMenuItem(title); + + this.addMenuItem(menuItem); + menuItem.connect('activate', (o, event) => { + callback(event); + }); + + return menuItem; + } + + addSettingsAction(title, desktopFile) { + let menuItem = this.addAction(title, () => { + let app = Shell.AppSystem.get_default().lookup_app(desktopFile); + + if (!app) { + log('Settings panel for desktop file %s could not be loaded!'.format(desktopFile)); + return; + } + + Main.overview.hide(); + app.activate(); + }); + + menuItem.visible = Main.sessionMode.allowSettings; + this._settingsActions[desktopFile] = menuItem; + + return menuItem; + } + + _setSettingsVisibility(visible) { + for (let id in this._settingsActions) { + let item = this._settingsActions[id]; + item.visible = visible; + } + } + + isEmpty() { + let hasVisibleChildren = this.box.get_children().some(child => { + if (child._delegate instanceof PopupSeparatorMenuItem) + return false; + return isPopupMenuItemVisible(child); + }); + + return !hasVisibleChildren; + } + + itemActivated(animate) { + if (animate == undefined) + animate = BoxPointer.PopupAnimation.FULL; + + this._getTopMenu().close(animate); + } + + _subMenuActiveChanged(submenu, submenuItem) { + if (this._activeMenuItem && this._activeMenuItem != submenuItem) + this._activeMenuItem.active = false; + this._activeMenuItem = submenuItem; + this.emit('active-changed', submenuItem); + } + + _connectItemSignals(menuItem) { + menuItem._activeChangeId = menuItem.connect('notify::active', () => { + let active = menuItem.active; + if (active && this._activeMenuItem != menuItem) { + if (this._activeMenuItem) + this._activeMenuItem.active = false; + this._activeMenuItem = menuItem; + this.emit('active-changed', menuItem); + } else if (!active && this._activeMenuItem == menuItem) { + this._activeMenuItem = null; + this.emit('active-changed', null); + } + }); + menuItem._sensitiveChangeId = menuItem.connect('notify::sensitive', () => { + let sensitive = menuItem.sensitive; + if (!sensitive && this._activeMenuItem == menuItem) { + if (!this.actor.navigate_focus(menuItem.actor, + St.DirectionType.TAB_FORWARD, + true)) + this.actor.grab_key_focus(); + } else if (sensitive && this._activeMenuItem == null) { + if (global.stage.get_key_focus() == this.actor) + menuItem.actor.grab_key_focus(); + } + }); + menuItem._activateId = menuItem.connect_after('activate', () => { + this.emit('activate', menuItem); + this.itemActivated(BoxPointer.PopupAnimation.FULL); + }); + + menuItem._parentSensitiveChangeId = this.connect('notify::sensitive', () => { + menuItem.syncSensitive(); + }); + + // the weird name is to avoid a conflict with some random property + // the menuItem may have, called destroyId + // (FIXME: in the future it may make sense to have container objects + // like PopupMenuManager does) + menuItem._popupMenuDestroyId = menuItem.connect('destroy', () => { + menuItem.disconnect(menuItem._popupMenuDestroyId); + menuItem.disconnect(menuItem._activateId); + menuItem.disconnect(menuItem._activeChangeId); + menuItem.disconnect(menuItem._sensitiveChangeId); + this.disconnect(menuItem._parentSensitiveChangeId); + if (menuItem == this._activeMenuItem) + this._activeMenuItem = null; + }); + } + + _updateSeparatorVisibility(menuItem) { + if (menuItem.label.text) + return; + + let children = this.box.get_children(); + + let index = children.indexOf(menuItem.actor); + + if (index < 0) + return; + + let childBeforeIndex = index - 1; + + while (childBeforeIndex >= 0 && !isPopupMenuItemVisible(children[childBeforeIndex])) + childBeforeIndex--; + + if (childBeforeIndex < 0 || + children[childBeforeIndex]._delegate instanceof PopupSeparatorMenuItem) { + menuItem.actor.hide(); + return; + } + + let childAfterIndex = index + 1; + + while (childAfterIndex < children.length && !isPopupMenuItemVisible(children[childAfterIndex])) + childAfterIndex++; + + if (childAfterIndex >= children.length || + children[childAfterIndex]._delegate instanceof PopupSeparatorMenuItem) { + menuItem.actor.hide(); + return; + } + + menuItem.show(); + } + + moveMenuItem(menuItem, position) { + let items = this._getMenuItems(); + let i = 0; + + while (i < items.length && position > 0) { + if (items[i] != menuItem) + position--; + i++; + } + + if (i < items.length) { + if (items[i] != menuItem) + this.box.set_child_below_sibling(menuItem.actor, items[i].actor); + } else { + this.box.set_child_above_sibling(menuItem.actor, null); + } + } + + addMenuItem(menuItem, position) { + let beforeItem = null; + if (position == undefined) { + this.box.add(menuItem.actor); + } else { + let items = this._getMenuItems(); + if (position < items.length) { + beforeItem = items[position].actor; + this.box.insert_child_below(menuItem.actor, beforeItem); + } else { + this.box.add(menuItem.actor); + } + } + + if (menuItem instanceof PopupMenuSection) { + let activeChangeId = menuItem.connect('active-changed', this._subMenuActiveChanged.bind(this)); + + let parentOpenStateChangedId = this.connect('open-state-changed', (self, open) => { + if (open) + menuItem.open(); + else + menuItem.close(); + }); + let parentClosingId = this.connect('menu-closed', () => { + menuItem.emit('menu-closed'); + }); + let subMenuSensitiveChangedId = this.connect('notify::sensitive', () => { + menuItem.emit('notify::sensitive'); + }); + + menuItem.connect('destroy', () => { + menuItem.disconnect(activeChangeId); + this.disconnect(subMenuSensitiveChangedId); + this.disconnect(parentOpenStateChangedId); + this.disconnect(parentClosingId); + this.length--; + }); + } else if (menuItem instanceof PopupSubMenuMenuItem) { + if (beforeItem == null) + this.box.add(menuItem.menu.actor); + else + this.box.insert_child_below(menuItem.menu.actor, beforeItem); + + this._connectItemSignals(menuItem); + let subMenuActiveChangeId = menuItem.menu.connect('active-changed', this._subMenuActiveChanged.bind(this)); + let closingId = this.connect('menu-closed', () => { + menuItem.menu.close(BoxPointer.PopupAnimation.NONE); + }); + + menuItem.connect('destroy', () => { + menuItem.menu.disconnect(subMenuActiveChangeId); + this.disconnect(closingId); + }); + } else if (menuItem instanceof PopupSeparatorMenuItem) { + this._connectItemSignals(menuItem); + + // updateSeparatorVisibility needs to get called any time the + // separator's adjacent siblings change visibility or position. + // open-state-changed isn't exactly that, but doing it in more + // precise ways would require a lot more bookkeeping. + let openStateChangeId = this.connect('open-state-changed', () => { + this._updateSeparatorVisibility(menuItem); + }); + let destroyId = menuItem.connect('destroy', () => { + this.disconnect(openStateChangeId); + menuItem.disconnect(destroyId); + }); + } else if (menuItem instanceof PopupBaseMenuItem) { + this._connectItemSignals(menuItem); + } else { + throw TypeError("Invalid argument to PopupMenuBase.addMenuItem()"); + } + + menuItem._setParent(this); + + this.length++; + } + + _getMenuItems() { + return this.box.get_children().map(a => a._delegate).filter(item => { + return item instanceof PopupBaseMenuItem || item instanceof PopupMenuSection; + }); + } + + get firstMenuItem() { + let items = this._getMenuItems(); + if (items.length) + return items[0]; + else + return null; + } + + get numMenuItems() { + return this._getMenuItems().length; + } + + removeAll() { + let children = this._getMenuItems(); + for (let i = 0; i < children.length; i++) { + let item = children[i]; + item.destroy(); + } + } + + toggle() { + if (this.isOpen) + this.close(BoxPointer.PopupAnimation.FULL); + else + this.open(BoxPointer.PopupAnimation.FULL); + } + + destroy() { + this.close(); + this.removeAll(); + this.actor.destroy(); + + this.emit('destroy'); + + Main.sessionMode.disconnect(this._sessionUpdatedId); + this._sessionUpdatedId = 0; + } +}; +Signals.addSignalMethods(PopupMenuBase.prototype); + +var PopupMenu = class extends PopupMenuBase { + constructor(sourceActor, arrowAlignment, arrowSide) { + super(sourceActor, 'popup-menu-content'); + + this._arrowAlignment = arrowAlignment; + this._arrowSide = arrowSide; + + this._boxPointer = new BoxPointer.BoxPointer(arrowSide); + this.actor = this._boxPointer; + this.actor._delegate = this; + this.actor.style_class = 'popup-menu-boxpointer'; + + this._boxPointer.bin.set_child(this.box); + this.actor.add_style_class_name('popup-menu'); + + global.focus_manager.add_group(this.actor); + this.actor.reactive = true; + + if (this.sourceActor) { + this._keyPressId = this.sourceActor.connect('key-press-event', + this._onKeyPress.bind(this)); + this._notifyMappedId = this.sourceActor.connect('notify::mapped', + () => { + if (!this.sourceActor.mapped) + this.close(); + }); + } + + this._systemModalOpenedId = 0; + this._openedSubMenu = null; + } + + _setOpenedSubMenu(submenu) { + if (this._openedSubMenu) + this._openedSubMenu.close(true); + + this._openedSubMenu = submenu; + } + + _onKeyPress(actor, event) { + // Disable toggling the menu by keyboard + // when it cannot be toggled by pointer + if (!actor.reactive) + return Clutter.EVENT_PROPAGATE; + + let navKey; + switch (this._boxPointer.arrowSide) { + case St.Side.TOP: + navKey = Clutter.KEY_Down; + break; + case St.Side.BOTTOM: + navKey = Clutter.KEY_Up; + break; + case St.Side.LEFT: + navKey = Clutter.KEY_Right; + break; + case St.Side.RIGHT: + navKey = Clutter.KEY_Left; + break; + } + + let state = event.get_state(); + + // if user has a modifier down (except capslock and numlock) + // then don't handle the key press here + state &= ~Clutter.ModifierType.LOCK_MASK; + state &= ~Clutter.ModifierType.MOD2_MASK; + state &= Clutter.ModifierType.MODIFIER_MASK; + + if (state) + return Clutter.EVENT_PROPAGATE; + + let symbol = event.get_key_symbol(); + if (symbol == Clutter.KEY_space || symbol == Clutter.KEY_Return) { + this.toggle(); + return Clutter.EVENT_STOP; + } else if (symbol == Clutter.KEY_Escape && this.isOpen) { + this.close(); + return Clutter.EVENT_STOP; + } else if (symbol == navKey) { + if (!this.isOpen) + this.toggle(); + this.actor.navigate_focus(null, St.DirectionType.TAB_FORWARD, false); + return Clutter.EVENT_STOP; + } else { + return Clutter.EVENT_PROPAGATE; + } + } + + setArrowOrigin(origin) { + this._boxPointer.setArrowOrigin(origin); + } + + setSourceAlignment(alignment) { + this._boxPointer.setSourceAlignment(alignment); + } + + open(animate) { + if (this.isOpen) + return; + + if (this.isEmpty()) + return; + + if (!this._systemModalOpenedId) { + this._systemModalOpenedId = + Main.layoutManager.connect('system-modal-opened', () => this.close()); + } + + this.isOpen = true; + + this._boxPointer.setPosition(this.sourceActor, this._arrowAlignment); + this._boxPointer.open(animate); + + this.actor.get_parent().set_child_above_sibling(this.actor, null); + + this.emit('open-state-changed', true); + } + + close(animate) { + if (this._activeMenuItem) + this._activeMenuItem.active = false; + + if (this._boxPointer.visible) { + this._boxPointer.close(animate, () => { + this.emit('menu-closed'); + }); + } + + if (!this.isOpen) + return; + + this.isOpen = false; + this.emit('open-state-changed', false); + } + + destroy() { + if (this._keyPressId) + this.sourceActor.disconnect(this._keyPressId); + + if (this._notifyMappedId) + this.sourceActor.disconnect(this._notifyMappedId); + + if (this._systemModalOpenedId) + Main.layoutManager.disconnect(this._systemModalOpenedId); + this._systemModalOpenedId = 0; + + super.destroy(); + } +}; + +var PopupDummyMenu = class { + constructor(sourceActor) { + this.sourceActor = sourceActor; + this.actor = sourceActor; + this.actor._delegate = this; + } + + getSensitive() { + return true; + } + + get sensitive() { + return this.getSensitive(); + } + + open() { + this.emit('open-state-changed', true); + } + + close() { + this.emit('open-state-changed', false); + } + + toggle() {} + + destroy() { + this.emit('destroy'); + } +}; +Signals.addSignalMethods(PopupDummyMenu.prototype); + +var PopupSubMenu = class extends PopupMenuBase { + constructor(sourceActor, sourceArrow) { + super(sourceActor); + + this._arrow = sourceArrow; + + // Since a function of a submenu might be to provide a "More.." expander + // with long content, we make it scrollable - the scrollbar will only take + // effect if a CSS max-height is set on the top menu. + this.actor = new St.ScrollView({ style_class: 'popup-sub-menu', + hscrollbar_policy: St.PolicyType.NEVER, + vscrollbar_policy: St.PolicyType.NEVER }); + + this.actor.add_actor(this.box); + this.actor._delegate = this; + this.actor.clip_to_allocation = true; + this.actor.connect('key-press-event', this._onKeyPressEvent.bind(this)); + this.actor.hide(); + } + + _needsScrollbar() { + let topMenu = this._getTopMenu(); + let [, topNaturalHeight] = topMenu.actor.get_preferred_height(-1); + let topThemeNode = topMenu.actor.get_theme_node(); + + let topMaxHeight = topThemeNode.get_max_height(); + return topMaxHeight >= 0 && topNaturalHeight >= topMaxHeight; + } + + getSensitive() { + return this._sensitive && this.sourceActor.sensitive; + } + + get sensitive() { + return this.getSensitive(); + } + + open(animate) { + if (this.isOpen) + return; + + if (this.isEmpty()) + return; + + this.isOpen = true; + this.emit('open-state-changed', true); + + this.actor.show(); + + let needsScrollbar = this._needsScrollbar(); + + // St.ScrollView always requests space horizontally for a possible vertical + // scrollbar if in AUTOMATIC mode. Doing better would require implementation + // of width-for-height in St.BoxLayout and St.ScrollView. This looks bad + // when we *don't* need it, so turn off the scrollbar when that's true. + // Dynamic changes in whether we need it aren't handled properly. + this.actor.vscrollbar_policy = + needsScrollbar ? St.PolicyType.AUTOMATIC : St.PolicyType.NEVER; + + if (needsScrollbar) + this.actor.add_style_pseudo_class('scrolled'); + else + this.actor.remove_style_pseudo_class('scrolled'); + + // It looks funny if we animate with a scrollbar (at what point is + // the scrollbar added?) so just skip that case + if (animate && needsScrollbar) + animate = false; + + let targetAngle = this.actor.text_direction == Clutter.TextDirection.RTL ? -90 : 90; + + if (animate) { + let [, naturalHeight] = this.actor.get_preferred_height(-1); + this.actor.height = 0; + this.actor.ease({ + height: naturalHeight, + duration: 250, + mode: Clutter.AnimationMode.EASE_OUT_EXPO, + onComplete: () => this.actor.set_height(-1), + }); + this._arrow.ease({ + rotation_angle_z: targetAngle, + duration: 250, + mode: Clutter.AnimationMode.EASE_OUT_EXPO, + }); + } else { + this._arrow.rotation_angle_z = targetAngle; + } + } + + close(animate) { + if (!this.isOpen) + return; + + this.isOpen = false; + this.emit('open-state-changed', false); + + if (this._activeMenuItem) + this._activeMenuItem.active = false; + + if (animate && this._needsScrollbar()) + animate = false; + + if (animate) { + this.actor.ease({ + height: 0, + duration: 250, + mode: Clutter.AnimationMode.EASE_OUT_EXPO, + onComplete: () => { + this.actor.hide(); + this.actor.set_height(-1); + }, + }); + this._arrow.ease({ + rotation_angle_z: 0, + duration: 250, + mode: Clutter.AnimationMode.EASE_OUT_EXPO, + }); + } else { + this._arrow.rotation_angle_z = 0; + this.actor.hide(); + } + } + + _onKeyPressEvent(actor, event) { + // Move focus back to parent menu if the user types Left. + + if (this.isOpen && event.get_key_symbol() == Clutter.KEY_Left) { + this.close(BoxPointer.PopupAnimation.FULL); + this.sourceActor._delegate.active = true; + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_PROPAGATE; + } +}; + +/** + * PopupMenuSection: + * + * A section of a PopupMenu which is handled like a submenu + * (you can add and remove items, you can destroy it, you + * can add it to another menu), but is completely transparent + * to the user + */ +var PopupMenuSection = class extends PopupMenuBase { + constructor() { + super(); + + this.actor = this.box; + this.actor._delegate = this; + this.isOpen = true; + } + + // deliberately ignore any attempt to open() or close(), but emit the + // corresponding signal so children can still pick it up + open() { + this.emit('open-state-changed', true); + } + + close() { + this.emit('open-state-changed', false); + } +}; + +var PopupSubMenuMenuItem = GObject.registerClass( +class PopupSubMenuMenuItem extends PopupBaseMenuItem { + _init(text, wantIcon) { + super._init(); + + this.add_style_class_name('popup-submenu-menu-item'); + + if (wantIcon) { + this.icon = new St.Icon({ style_class: 'popup-menu-icon' }); + this.add_child(this.icon); + } + + this.label = new St.Label({ text, + y_expand: true, + y_align: Clutter.ActorAlign.CENTER }); + this.add_child(this.label); + this.label_actor = this.label; + + let expander = new St.Bin({ + style_class: 'popup-menu-item-expander', + x_expand: true, + }); + this.add_child(expander); + + this._triangle = arrowIcon(St.Side.RIGHT); + this._triangle.pivot_point = new Graphene.Point({ x: 0.5, y: 0.6 }); + + this._triangleBin = new St.Widget({ y_expand: true, + y_align: Clutter.ActorAlign.CENTER }); + this._triangleBin.add_child(this._triangle); + + this.add_child(this._triangleBin); + this.add_accessible_state(Atk.StateType.EXPANDABLE); + + this.menu = new PopupSubMenu(this, this._triangle); + this.menu.connect('open-state-changed', this._subMenuOpenStateChanged.bind(this)); + this.connect('destroy', () => this.menu.destroy()); + } + + _setParent(parent) { + super._setParent(parent); + this.menu._setParent(parent); + } + + syncSensitive() { + let sensitive = super.syncSensitive(); + this._triangle.visible = sensitive; + if (!sensitive) + this.menu.close(false); + } + + _subMenuOpenStateChanged(menu, open) { + if (open) { + this.add_style_pseudo_class('open'); + this._getTopMenu()._setOpenedSubMenu(this.menu); + this.add_accessible_state(Atk.StateType.EXPANDED); + this.add_style_pseudo_class('checked'); + } else { + this.remove_style_pseudo_class('open'); + this._getTopMenu()._setOpenedSubMenu(null); + this.remove_accessible_state(Atk.StateType.EXPANDED); + this.remove_style_pseudo_class('checked'); + } + } + + setSubmenuShown(open) { + if (open) + this.menu.open(BoxPointer.PopupAnimation.FULL); + else + this.menu.close(BoxPointer.PopupAnimation.FULL); + } + + _setOpenState(open) { + this.setSubmenuShown(open); + } + + _getOpenState() { + return this.menu.isOpen; + } + + vfunc_key_press_event(keyPressEvent) { + let symbol = keyPressEvent.keyval; + + if (symbol == Clutter.KEY_Right) { + this._setOpenState(true); + this.menu.actor.navigate_focus(null, St.DirectionType.DOWN, false); + return Clutter.EVENT_STOP; + } else if (symbol == Clutter.KEY_Left && this._getOpenState()) { + this._setOpenState(false); + return Clutter.EVENT_STOP; + } + + return super.vfunc_key_press_event(keyPressEvent); + } + + activate(_event) { + this._setOpenState(true); + } + + vfunc_button_release_event() { + // Since we override the parent, we need to manage what the parent does + // with the active style class + this.remove_style_pseudo_class('active'); + this._setOpenState(!this._getOpenState()); + return Clutter.EVENT_PROPAGATE; + } + + vfunc_touch_event(touchEvent) { + if (touchEvent.type == Clutter.EventType.TOUCH_END) { + // Since we override the parent, we need to manage what the parent does + // with the active style class + this.remove_style_pseudo_class('active'); + this._setOpenState(!this._getOpenState()); + } + return Clutter.EVENT_PROPAGATE; + } +}); + +/* Basic implementation of a menu manager. + * Call addMenu to add menus + */ +var PopupMenuManager = class { + constructor(owner, grabParams) { + grabParams = Params.parse(grabParams, + { actionMode: Shell.ActionMode.POPUP }); + this._grabHelper = new GrabHelper.GrabHelper(owner, grabParams); + this._menus = []; + } + + addMenu(menu, position) { + if (this._findMenu(menu) > -1) + return; + + let menudata = { + menu, + openStateChangeId: menu.connect('open-state-changed', this._onMenuOpenState.bind(this)), + destroyId: menu.connect('destroy', this._onMenuDestroy.bind(this)), + enterId: 0, + focusInId: 0, + }; + + let source = menu.sourceActor; + if (source) { + if (!menu.blockSourceEvents) + this._grabHelper.addActor(source); + menudata.enterId = source.connect('enter-event', + () => this._onMenuSourceEnter(menu)); + menudata.focusInId = source.connect('key-focus-in', () => { + this._onMenuSourceEnter(menu); + }); + } + + if (position == undefined) + this._menus.push(menudata); + else + this._menus.splice(position, 0, menudata); + } + + removeMenu(menu) { + if (menu == this.activeMenu) + this._grabHelper.ungrab({ actor: menu.actor }); + + let position = this._findMenu(menu); + if (position == -1) // not a menu we manage + return; + + let menudata = this._menus[position]; + menu.disconnect(menudata.openStateChangeId); + menu.disconnect(menudata.destroyId); + + if (menudata.enterId) + menu.sourceActor.disconnect(menudata.enterId); + if (menudata.focusInId) + menu.sourceActor.disconnect(menudata.focusInId); + + if (menu.sourceActor) + this._grabHelper.removeActor(menu.sourceActor); + this._menus.splice(position, 1); + } + + get activeMenu() { + let firstGrab = this._grabHelper.grabStack[0]; + if (firstGrab) + return firstGrab.actor._delegate; + else + return null; + } + + ignoreRelease() { + return this._grabHelper.ignoreRelease(); + } + + _onMenuOpenState(menu, open) { + if (open) { + if (this.activeMenu) + this.activeMenu.close(BoxPointer.PopupAnimation.FADE); + this._grabHelper.grab({ + actor: menu.actor, + focus: menu.focusActor, + onUngrab: isUser => this._closeMenu(isUser, menu), + }); + } else { + this._grabHelper.ungrab({ actor: menu.actor }); + } + } + + _changeMenu(newMenu) { + newMenu.open(this.activeMenu + ? BoxPointer.PopupAnimation.FADE + : BoxPointer.PopupAnimation.FULL); + } + + _onMenuSourceEnter(menu) { + if (!this._grabHelper.grabbed) + return Clutter.EVENT_PROPAGATE; + + if (this._grabHelper.isActorGrabbed(menu.actor)) + return Clutter.EVENT_PROPAGATE; + + this._changeMenu(menu); + return Clutter.EVENT_PROPAGATE; + } + + _onMenuDestroy(menu) { + this.removeMenu(menu); + } + + _findMenu(item) { + for (let i = 0; i < this._menus.length; i++) { + let menudata = this._menus[i]; + if (item == menudata.menu) + return i; + } + return -1; + } + + _closeMenu(isUser, menu) { + // If this isn't a user action, we called close() + // on the BoxPointer ourselves, so we shouldn't + // reanimate. + if (isUser) + menu.close(BoxPointer.PopupAnimation.FULL); + } +}; diff --git a/js/ui/remoteSearch.js b/js/ui/remoteSearch.js new file mode 100644 index 0000000..77ad317 --- /dev/null +++ b/js/ui/remoteSearch.js @@ -0,0 +1,335 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported loadRemoteSearchProviders */ + +const { GdkPixbuf, Gio, GLib, Shell, St } = imports.gi; + +const FileUtils = imports.misc.fileUtils; + +const KEY_FILE_GROUP = 'Shell Search Provider'; + +const SearchProviderIface = ` +<node> +<interface name="org.gnome.Shell.SearchProvider"> +<method name="GetInitialResultSet"> + <arg type="as" direction="in" /> + <arg type="as" direction="out" /> +</method> +<method name="GetSubsearchResultSet"> + <arg type="as" direction="in" /> + <arg type="as" direction="in" /> + <arg type="as" direction="out" /> +</method> +<method name="GetResultMetas"> + <arg type="as" direction="in" /> + <arg type="aa{sv}" direction="out" /> +</method> +<method name="ActivateResult"> + <arg type="s" direction="in" /> +</method> +</interface> +</node>`; + +const SearchProvider2Iface = ` +<node> +<interface name="org.gnome.Shell.SearchProvider2"> +<method name="GetInitialResultSet"> + <arg type="as" direction="in" /> + <arg type="as" direction="out" /> +</method> +<method name="GetSubsearchResultSet"> + <arg type="as" direction="in" /> + <arg type="as" direction="in" /> + <arg type="as" direction="out" /> +</method> +<method name="GetResultMetas"> + <arg type="as" direction="in" /> + <arg type="aa{sv}" direction="out" /> +</method> +<method name="ActivateResult"> + <arg type="s" direction="in" /> + <arg type="as" direction="in" /> + <arg type="u" direction="in" /> +</method> +<method name="LaunchSearch"> + <arg type="as" direction="in" /> + <arg type="u" direction="in" /> +</method> +</interface> +</node>`; + +var SearchProviderProxyInfo = Gio.DBusInterfaceInfo.new_for_xml(SearchProviderIface); +var SearchProvider2ProxyInfo = Gio.DBusInterfaceInfo.new_for_xml(SearchProvider2Iface); + +function loadRemoteSearchProviders(searchSettings, callback) { + let objectPaths = {}; + let loadedProviders = []; + + function loadRemoteSearchProvider(file) { + let keyfile = new GLib.KeyFile(); + let path = file.get_path(); + + try { + keyfile.load_from_file(path, 0); + } catch (e) { + return; + } + + if (!keyfile.has_group(KEY_FILE_GROUP)) + return; + + let remoteProvider; + try { + let group = KEY_FILE_GROUP; + let busName = keyfile.get_string(group, 'BusName'); + let objectPath = keyfile.get_string(group, 'ObjectPath'); + + if (objectPaths[objectPath]) + return; + + let appInfo = null; + try { + let desktopId = keyfile.get_string(group, 'DesktopId'); + appInfo = Gio.DesktopAppInfo.new(desktopId); + if (!appInfo.should_show()) + return; + } catch (e) { + log(`Ignoring search provider ${path}: missing DesktopId`); + return; + } + + let autoStart = true; + try { + autoStart = keyfile.get_boolean(group, 'AutoStart'); + } catch (e) { + // ignore error + } + + let version = '1'; + try { + version = keyfile.get_string(group, 'Version'); + } catch (e) { + // ignore error + } + + if (version >= 2) + remoteProvider = new RemoteSearchProvider2(appInfo, busName, objectPath, autoStart); + else + remoteProvider = new RemoteSearchProvider(appInfo, busName, objectPath, autoStart); + + remoteProvider.defaultEnabled = true; + try { + remoteProvider.defaultEnabled = !keyfile.get_boolean(group, 'DefaultDisabled'); + } catch (e) { + // ignore error + } + + objectPaths[objectPath] = remoteProvider; + loadedProviders.push(remoteProvider); + } catch (e) { + log('Failed to add search provider %s: %s'.format(path, e.toString())); + } + } + + if (searchSettings.get_boolean('disable-external')) { + callback([]); + return; + } + + FileUtils.collectFromDatadirs('search-providers', false, loadRemoteSearchProvider); + + let sortOrder = searchSettings.get_strv('sort-order'); + + // Special case gnome-control-center to be always active and always first + sortOrder.unshift('gnome-control-center.desktop'); + + loadedProviders = loadedProviders.filter(provider => { + let appId = provider.appInfo.get_id(); + + if (provider.defaultEnabled) { + let disabled = searchSettings.get_strv('disabled'); + return !disabled.includes(appId); + } else { + let enabled = searchSettings.get_strv('enabled'); + return enabled.includes(appId); + } + }); + + loadedProviders.sort((providerA, providerB) => { + let idxA, idxB; + let appIdA, appIdB; + + appIdA = providerA.appInfo.get_id(); + appIdB = providerB.appInfo.get_id(); + + idxA = sortOrder.indexOf(appIdA); + idxB = sortOrder.indexOf(appIdB); + + // if no provider is found in the order, use alphabetical order + if ((idxA == -1) && (idxB == -1)) { + let nameA = providerA.appInfo.get_name(); + let nameB = providerB.appInfo.get_name(); + + return GLib.utf8_collate(nameA, nameB); + } + + // if providerA isn't found, it's sorted after providerB + if (idxA == -1) + return 1; + + // if providerB isn't found, it's sorted after providerA + if (idxB == -1) + return -1; + + // finally, if both providers are found, return their order in the list + return idxA - idxB; + }); + + callback(loadedProviders); +} + +var RemoteSearchProvider = class { + constructor(appInfo, dbusName, dbusPath, autoStart, proxyInfo) { + if (!proxyInfo) + proxyInfo = SearchProviderProxyInfo; + + let gFlags = Gio.DBusProxyFlags.DO_NOT_LOAD_PROPERTIES; + if (autoStart) + gFlags |= Gio.DBusProxyFlags.DO_NOT_AUTO_START_AT_CONSTRUCTION; + else + gFlags |= Gio.DBusProxyFlags.DO_NOT_AUTO_START; + + this.proxy = new Gio.DBusProxy({ g_bus_type: Gio.BusType.SESSION, + g_name: dbusName, + g_object_path: dbusPath, + g_interface_info: proxyInfo, + g_interface_name: proxyInfo.name, + gFlags }); + this.proxy.init_async(GLib.PRIORITY_DEFAULT, null); + + this.appInfo = appInfo; + this.id = appInfo.get_id(); + this.isRemoteProvider = true; + this.canLaunchSearch = false; + } + + createIcon(size, meta) { + let gicon = null; + let icon = null; + + if (meta['icon']) { + gicon = Gio.icon_deserialize(meta['icon']); + } else if (meta['gicon']) { + gicon = Gio.icon_new_for_string(meta['gicon']); + } else if (meta['icon-data']) { + let [width, height, rowStride, hasAlpha, + bitsPerSample, nChannels_, data] = meta['icon-data']; + gicon = Shell.util_create_pixbuf_from_data(data, GdkPixbuf.Colorspace.RGB, hasAlpha, + bitsPerSample, width, height, rowStride); + } + + if (gicon) + icon = new St.Icon({ gicon, icon_size: size }); + return icon; + } + + filterResults(results, maxNumber) { + if (results.length <= maxNumber) + return results; + + let regularResults = results.filter(r => !r.startsWith('special:')); + let specialResults = results.filter(r => r.startsWith('special:')); + + return regularResults.slice(0, maxNumber).concat(specialResults.slice(0, maxNumber)); + } + + _getResultsFinished(results, error, callback) { + if (error) { + if (error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) + return; + + log('Received error from D-Bus search provider %s: %s'.format(this.id, String(error))); + callback([]); + return; + } + + callback(results[0]); + } + + getInitialResultSet(terms, callback, cancellable) { + this.proxy.GetInitialResultSetRemote(terms, + (results, error) => { + this._getResultsFinished(results, error, callback); + }, + cancellable); + } + + getSubsearchResultSet(previousResults, newTerms, callback, cancellable) { + this.proxy.GetSubsearchResultSetRemote(previousResults, newTerms, + (results, error) => { + this._getResultsFinished(results, error, callback); + }, + cancellable); + } + + _getResultMetasFinished(results, error, callback) { + if (error) { + if (!error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) + log('Received error from D-Bus search provider %s during GetResultMetas: %s'.format(this.id, String(error))); + callback([]); + return; + } + let metas = results[0]; + let resultMetas = []; + for (let i = 0; i < metas.length; i++) { + for (let prop in metas[i]) { + // we can use the serialized icon variant directly + if (prop != 'icon') + metas[i][prop] = metas[i][prop].deep_unpack(); + } + + resultMetas.push({ id: metas[i]['id'], + name: metas[i]['name'], + description: metas[i]['description'], + createIcon: size => { + return this.createIcon(size, metas[i]); + }, + clipboardText: metas[i]['clipboardText'] }); + } + callback(resultMetas); + } + + getResultMetas(ids, callback, cancellable) { + this.proxy.GetResultMetasRemote(ids, + (results, error) => { + this._getResultMetasFinished(results, error, callback); + }, + cancellable); + } + + activateResult(id) { + this.proxy.ActivateResultRemote(id); + } + + launchSearch(_terms) { + // the provider is not compatible with the new version of the interface, launch + // the app itself but warn so we can catch the error in logs + log(`Search provider ${this.appInfo.get_id()} does not implement LaunchSearch`); + this.appInfo.launch([], global.create_app_launch_context(0, -1)); + } +}; + +var RemoteSearchProvider2 = class extends RemoteSearchProvider { + constructor(appInfo, dbusName, dbusPath, autoStart) { + super(appInfo, dbusName, dbusPath, autoStart, SearchProvider2ProxyInfo); + + this.canLaunchSearch = true; + } + + activateResult(id, terms) { + this.proxy.ActivateResultRemote(id, terms, global.get_current_time()); + } + + launchSearch(terms) { + this.proxy.LaunchSearchRemote(terms, global.get_current_time()); + } +}; diff --git a/js/ui/ripples.js b/js/ui/ripples.js new file mode 100644 index 0000000..f380622 --- /dev/null +++ b/js/ui/ripples.js @@ -0,0 +1,104 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Ripples */ + +const { Clutter, St } = imports.gi; + +// Shamelessly copied from the layout "hotcorner" ripples implementation +var Ripples = class Ripples { + constructor(px, py, styleClass) { + this._x = 0; + this._y = 0; + + this._px = px; + this._py = py; + + this._ripple1 = new St.BoxLayout({ style_class: styleClass, + opacity: 0, + can_focus: false, + reactive: false, + visible: false }); + this._ripple1.set_pivot_point(px, py); + + this._ripple2 = new St.BoxLayout({ style_class: styleClass, + opacity: 0, + can_focus: false, + reactive: false, + visible: false }); + this._ripple2.set_pivot_point(px, py); + + this._ripple3 = new St.BoxLayout({ style_class: styleClass, + opacity: 0, + can_focus: false, + reactive: false, + visible: false }); + this._ripple3.set_pivot_point(px, py); + } + + destroy() { + this._ripple1.destroy(); + this._ripple2.destroy(); + this._ripple3.destroy(); + } + + _animRipple(ripple, delay, duration, startScale, startOpacity, finalScale) { + // We draw a ripple by using a source image and animating it scaling + // outwards and fading away. We want the ripples to move linearly + // or it looks unrealistic, but if the opacity of the ripple goes + // linearly to zero it fades away too quickly, so we use a separate + // tween to give a non-linear curve to the fade-away and make + // it more visible in the middle section. + + ripple.x = this._x; + ripple.y = this._y; + ripple.visible = true; + ripple.opacity = 255 * Math.sqrt(startOpacity); + ripple.scale_x = ripple.scale_y = startScale; + ripple.set_translation(-this._px * ripple.width, -this._py * ripple.height, 0.0); + + ripple.ease({ + opacity: 0, + delay, + duration, + mode: Clutter.AnimationMode.EASE_IN_QUAD, + }); + ripple.ease({ + scale_x: finalScale, + scale_y: finalScale, + delay, + duration, + mode: Clutter.AnimationMode.LINEAR, + onComplete: () => (ripple.visible = false), + }); + } + + addTo(stage) { + if (this._stage !== undefined) + throw new Error('Ripples already added'); + + this._stage = stage; + this._stage.add_actor(this._ripple1); + this._stage.add_actor(this._ripple2); + this._stage.add_actor(this._ripple3); + } + + playAnimation(x, y) { + if (this._stage === undefined) + throw new Error('Ripples not added'); + + this._x = x; + this._y = y; + + this._stage.set_child_above_sibling(this._ripple1, null); + this._stage.set_child_above_sibling(this._ripple2, this._ripple1); + this._stage.set_child_above_sibling(this._ripple3, this._ripple2); + + // Show three concentric ripples expanding outwards; the exact + // parameters were found by trial and error, so don't look + // for them to make perfect sense mathematically + + // delay time scale opacity => scale + this._animRipple(this._ripple1, 0, 830, 0.25, 1.0, 1.5); + this._animRipple(this._ripple2, 50, 1000, 0.0, 0.7, 1.25); + this._animRipple(this._ripple3, 350, 1000, 0.0, 0.3, 1); + } +}; diff --git a/js/ui/runDialog.js b/js/ui/runDialog.js new file mode 100644 index 0000000..356a974 --- /dev/null +++ b/js/ui/runDialog.js @@ -0,0 +1,256 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported RunDialog */ + +const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; + +const Dialog = imports.ui.dialog; +const Main = imports.ui.main; +const ModalDialog = imports.ui.modalDialog; +const ShellEntry = imports.ui.shellEntry; +const Util = imports.misc.util; +const History = imports.misc.history; + +const HISTORY_KEY = 'command-history'; + +const LOCKDOWN_SCHEMA = 'org.gnome.desktop.lockdown'; +const DISABLE_COMMAND_LINE_KEY = 'disable-command-line'; + +const TERMINAL_SCHEMA = 'org.gnome.desktop.default-applications.terminal'; +const EXEC_KEY = 'exec'; +const EXEC_ARG_KEY = 'exec-arg'; + +var RunDialog = GObject.registerClass( +class RunDialog extends ModalDialog.ModalDialog { + _init() { + super._init({ + styleClass: 'run-dialog', + destroyOnClose: false, + }); + + this._lockdownSettings = new Gio.Settings({ schema_id: LOCKDOWN_SCHEMA }); + this._terminalSettings = new Gio.Settings({ schema_id: TERMINAL_SCHEMA }); + global.settings.connect('changed::development-tools', () => { + this._enableInternalCommands = global.settings.get_boolean('development-tools'); + }); + this._enableInternalCommands = global.settings.get_boolean('development-tools'); + + this._internalCommands = { + 'lg': () => Main.createLookingGlass().open(), + + 'r': this._restart.bind(this), + + // Developer brain backwards compatibility + 'restart': this._restart.bind(this), + + 'debugexit': () => Meta.quit(Meta.ExitCode.ERROR), + + // rt is short for "reload theme" + 'rt': () => { + Main.reloadThemeResource(); + Main.loadTheme(); + }, + + 'check_cloexec_fds': () => { + Shell.util_check_cloexec_fds(); + }, + }; + + let title = _('Run a Command'); + + let content = new Dialog.MessageDialogContent({ title }); + this.contentLayout.add_actor(content); + + let entry = new St.Entry({ + style_class: 'run-dialog-entry', + can_focus: true, + }); + ShellEntry.addContextMenu(entry); + + this._entryText = entry.clutter_text; + content.add_child(entry); + this.setInitialKeyFocus(this._entryText); + + let defaultDescriptionText = _('Press ESC to close'); + + this._descriptionLabel = new St.Label({ + style_class: 'run-dialog-description', + text: defaultDescriptionText, + }); + content.add_child(this._descriptionLabel); + + this._commandError = false; + + this._pathCompleter = new Gio.FilenameCompleter(); + + this._history = new History.HistoryManager({ + gsettingsKey: HISTORY_KEY, + entry: this._entryText, + }); + this._entryText.connect('activate', o => { + this.popModal(); + this._run(o.get_text(), + Clutter.get_current_event().get_state() & Clutter.ModifierType.CONTROL_MASK); + if (!this._commandError || + !this.pushModal()) + this.close(); + }); + this._entryText.connect('key-press-event', (o, e) => { + let symbol = e.get_key_symbol(); + if (symbol === Clutter.KEY_Tab) { + let text = o.get_text(); + let prefix; + if (text.lastIndexOf(' ') == -1) + prefix = text; + else + prefix = text.substr(text.lastIndexOf(' ') + 1); + let postfix = this._getCompletion(prefix); + if (postfix != null && postfix.length > 0) { + o.insert_text(postfix, -1); + o.set_cursor_position(text.length + postfix.length); + } + return Clutter.EVENT_STOP; + } + return Clutter.EVENT_PROPAGATE; + }); + this._entryText.connect('text-changed', () => { + this._descriptionLabel.set_text(defaultDescriptionText); + }); + } + + vfunc_key_release_event(event) { + if (event.keyval === Clutter.KEY_Escape) { + this.close(); + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_PROPAGATE; + } + + _getCommandCompletion(text) { + function _getCommon(s1, s2) { + if (s1 == null) + return s2; + + let k = 0; + for (; k < s1.length && k < s2.length; k++) { + if (s1[k] != s2[k]) + break; + } + if (k == 0) + return ''; + return s1.substr(0, k); + } + + let paths = GLib.getenv('PATH').split(':'); + paths.push(GLib.get_home_dir()); + let someResults = paths.map(path => { + let results = []; + try { + let file = Gio.File.new_for_path(path); + let fileEnum = file.enumerate_children('standard::name', Gio.FileQueryInfoFlags.NONE, null); + let info; + while ((info = fileEnum.next_file(null))) { + let name = info.get_name(); + if (name.slice(0, text.length) == text) + results.push(name); + } + } catch (e) { + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND) && + !e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_DIRECTORY)) + log(e); + } + return results; + }); + let results = someResults.reduce((a, b) => a.concat(b), []); + + if (!results.length) + return null; + + let common = results.reduce(_getCommon, null); + return common.substr(text.length); + } + + _getCompletion(text) { + if (text.includes('/')) + return this._pathCompleter.get_completion_suffix(text); + else + return this._getCommandCompletion(text); + } + + _run(input, inTerminal) { + let command = input; + + this._history.addItem(input); + this._commandError = false; + let f; + if (this._enableInternalCommands) + f = this._internalCommands[input]; + else + f = null; + if (f) { + f(); + } else if (input) { + try { + if (inTerminal) { + let exec = this._terminalSettings.get_string(EXEC_KEY); + let execArg = this._terminalSettings.get_string(EXEC_ARG_KEY); + command = '%s %s %s'.format(exec, execArg, input); + } + Util.trySpawnCommandLine(command); + } catch (e) { + // Mmmh, that failed - see if @input matches an existing file + let path = null; + if (input.charAt(0) == '/') { + path = input; + } else { + if (input.charAt(0) == '~') + input = input.slice(1); + path = '%s/%s'.format(GLib.get_home_dir(), input); + } + + if (GLib.file_test(path, GLib.FileTest.EXISTS)) { + let file = Gio.file_new_for_path(path); + try { + Gio.app_info_launch_default_for_uri(file.get_uri(), + global.create_app_launch_context(0, -1)); + } catch (err) { + // The exception from gjs contains an error string like: + // Error invoking Gio.app_info_launch_default_for_uri: No application + // is registered as handling this file + // We are only interested in the part after the first colon. + let message = err.message.replace(/[^:]*: *(.+)/, '$1'); + this._showError(message); + } + } else { + this._showError(e.message); + } + } + } + } + + _showError(message) { + this._commandError = true; + this._descriptionLabel.set_text(message); + } + + _restart() { + if (Meta.is_wayland_compositor()) { + this._showError(_("Restart is not available on Wayland")); + return; + } + this._shouldFadeOut = false; + this.close(); + Meta.restart(_("Restarting…")); + } + + open() { + this._history.lastItem(); + this._entryText.set_text(''); + this._commandError = false; + + if (this._lockdownSettings.get_boolean(DISABLE_COMMAND_LINE_KEY)) + return; + + super.open(); + } +}); diff --git a/js/ui/screenShield.js b/js/ui/screenShield.js new file mode 100644 index 0000000..fa3a7a8 --- /dev/null +++ b/js/ui/screenShield.js @@ -0,0 +1,650 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +const { AccountsService, Clutter, Gio, + GLib, Graphene, Meta, Shell, St } = imports.gi; +const Signals = imports.signals; + +const GnomeSession = imports.misc.gnomeSession; +const OVirt = imports.gdm.oVirt; +const LoginManager = imports.misc.loginManager; +const Lightbox = imports.ui.lightbox; +const Main = imports.ui.main; +const Overview = imports.ui.overview; +const MessageTray = imports.ui.messageTray; +const ShellDBus = imports.ui.shellDBus; +const SmartcardManager = imports.misc.smartcardManager; + +const { adjustAnimationTime } = imports.ui.environment; + +const SCREENSAVER_SCHEMA = 'org.gnome.desktop.screensaver'; +const LOCK_ENABLED_KEY = 'lock-enabled'; +const LOCK_DELAY_KEY = 'lock-delay'; + +const LOCKDOWN_SCHEMA = 'org.gnome.desktop.lockdown'; +const DISABLE_LOCK_KEY = 'disable-lock-screen'; + +const LOCKED_STATE_STR = 'screenShield.locked'; + +// ScreenShield animation time +// - STANDARD_FADE_TIME is used when the session goes idle +// - MANUAL_FADE_TIME is used for lowering the shield when asked by the user, +// or when cancelling the dialog +// - CURTAIN_SLIDE_TIME is used when raising the shield before unlocking +var STANDARD_FADE_TIME = 10000; +var MANUAL_FADE_TIME = 300; +var CURTAIN_SLIDE_TIME = 300; + +/** + * If you are setting org.gnome.desktop.session.idle-delay directly in dconf, + * rather than through System Settings, you also need to set + * org.gnome.settings-daemon.plugins.power.sleep-display-ac and + * org.gnome.settings-daemon.plugins.power.sleep-display-battery to the same value. + * This will ensure that the screen blanks at the right time when it fades out. + * https://bugzilla.gnome.org/show_bug.cgi?id=668703 explains the dependency. + */ +var ScreenShield = class { + constructor() { + this.actor = Main.layoutManager.screenShieldGroup; + + this._lockScreenState = MessageTray.State.HIDDEN; + this._lockScreenGroup = new St.Widget({ + x_expand: true, + y_expand: true, + reactive: true, + can_focus: true, + name: 'lockScreenGroup', + visible: false, + }); + + this._lockDialogGroup = new St.Widget({ + x_expand: true, + y_expand: true, + reactive: true, + can_focus: true, + pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }), + name: 'lockDialogGroup', + }); + + this.actor.add_actor(this._lockScreenGroup); + this.actor.add_actor(this._lockDialogGroup); + + this._presence = new GnomeSession.Presence((proxy, error) => { + if (error) { + logError(error, 'Error while reading gnome-session presence'); + return; + } + + this._onStatusChanged(proxy.status); + }); + this._presence.connectSignal('StatusChanged', (proxy, senderName, [status]) => { + this._onStatusChanged(status); + }); + + this._screenSaverDBus = new ShellDBus.ScreenSaverDBus(this); + + this._smartcardManager = SmartcardManager.getSmartcardManager(); + this._smartcardManager.connect('smartcard-inserted', + (manager, token) => { + if (this._isLocked && token.UsedToLogin) + this._activateDialog(); + }); + + this._oVirtCredentialsManager = OVirt.getOVirtCredentialsManager(); + this._oVirtCredentialsManager.connect('user-authenticated', + () => { + if (this._isLocked) + this._activateDialog(); + }); + + this._loginManager = LoginManager.getLoginManager(); + this._loginManager.connect('prepare-for-sleep', + this._prepareForSleep.bind(this)); + + this._loginSession = null; + this._loginManager.getCurrentSessionProxy(sessionProxy => { + this._loginSession = sessionProxy; + this._loginSession.connectSignal('Lock', + () => this.lock(false)); + this._loginSession.connectSignal('Unlock', + () => this.deactivate(false)); + this._loginSession.connect('g-properties-changed', this._syncInhibitor.bind(this)); + this._syncInhibitor(); + }); + + this._settings = new Gio.Settings({ schema_id: SCREENSAVER_SCHEMA }); + this._settings.connect('changed::%s'.format(LOCK_ENABLED_KEY), this._syncInhibitor.bind(this)); + + this._lockSettings = new Gio.Settings({ schema_id: LOCKDOWN_SCHEMA }); + this._lockSettings.connect('changed::%s'.format(DISABLE_LOCK_KEY), this._syncInhibitor.bind(this)); + + this._isModal = false; + this._isGreeter = false; + this._isActive = false; + this._isLocked = false; + this._inUnlockAnimation = false; + this._activationTime = 0; + this._becameActiveId = 0; + this._lockTimeoutId = 0; + + // The "long" lightbox is used for the longer (20 seconds) fade from session + // to idle status, the "short" is used for quickly fading to black when locking + // manually + this._longLightbox = new Lightbox.Lightbox(Main.uiGroup, + { inhibitEvents: true, + fadeFactor: 1 }); + this._longLightbox.connect('notify::active', this._onLongLightbox.bind(this)); + this._shortLightbox = new Lightbox.Lightbox(Main.uiGroup, + { inhibitEvents: true, + fadeFactor: 1 }); + this._shortLightbox.connect('notify::active', this._onShortLightbox.bind(this)); + + this.idleMonitor = Meta.IdleMonitor.get_core(); + this._cursorTracker = Meta.CursorTracker.get_for_display(global.display); + + this._syncInhibitor(); + } + + _setActive(active) { + let prevIsActive = this._isActive; + this._isActive = active; + + if (prevIsActive != this._isActive) + this.emit('active-changed'); + + if (this._loginSession) + this._loginSession.SetLockedHintRemote(active); + + this._syncInhibitor(); + } + + _activateDialog() { + if (this._isLocked) { + this._ensureUnlockDialog(true /* allowCancel */); + this._dialog.activate(); + } else { + this.deactivate(true /* animate */); + } + } + + _maybeCancelDialog() { + if (!this._dialog) + return; + + this._dialog.cancel(); + if (this._isGreeter) { + // LoginDialog.cancel() will grab the key focus + // on its own, so ensure it stays on lock screen + // instead + this._dialog.grab_key_focus(); + } + } + + _becomeModal() { + if (this._isModal) + return true; + + this._isModal = Main.pushModal(this.actor, { actionMode: Shell.ActionMode.LOCK_SCREEN }); + if (this._isModal) + return true; + + // We failed to get a pointer grab, it means that + // something else has it. Try with a keyboard grab only + this._isModal = Main.pushModal(this.actor, { options: Meta.ModalOptions.POINTER_ALREADY_GRABBED, + actionMode: Shell.ActionMode.LOCK_SCREEN }); + return this._isModal; + } + + _syncInhibitor() { + let lockEnabled = this._settings.get_boolean(LOCK_ENABLED_KEY); + let lockLocked = this._lockSettings.get_boolean(DISABLE_LOCK_KEY); + let inhibit = this._loginSession && this._loginSession.Active && + !this._isActive && lockEnabled && !lockLocked && Main.sessionMode.unlockDialog; + if (inhibit) { + this._loginManager.inhibit(_("GNOME needs to lock the screen"), + inhibitor => { + if (this._inhibitor) + this._inhibitor.close(null); + this._inhibitor = inhibitor; + }); + } else { + if (this._inhibitor) + this._inhibitor.close(null); + this._inhibitor = null; + } + } + + _prepareForSleep(loginManager, aboutToSuspend) { + if (aboutToSuspend) { + if (this._settings.get_boolean(LOCK_ENABLED_KEY)) + this.lock(true); + } else { + this._wakeUpScreen(); + } + } + + _onStatusChanged(status) { + if (status != GnomeSession.PresenceStatus.IDLE) + return; + + this._maybeCancelDialog(); + + if (this._longLightbox.visible) { + // We're in the process of showing. + return; + } + + if (!this._becomeModal()) { + // We could not become modal, so we can't activate the + // screenshield. The user is probably very upset at this + // point, but any application using global grabs is broken + // Just tell him to stop using this app + // + // XXX: another option is to kick the user into the gdm login + // screen, where we're not affected by grabs + Main.notifyError(_("Unable to lock"), + _("Lock was blocked by an application")); + return; + } + + if (this._activationTime == 0) + this._activationTime = GLib.get_monotonic_time(); + + let shouldLock = this._settings.get_boolean(LOCK_ENABLED_KEY) && !this._isLocked; + + if (shouldLock) { + let lockTimeout = Math.max( + adjustAnimationTime(STANDARD_FADE_TIME), + this._settings.get_uint(LOCK_DELAY_KEY) * 1000); + this._lockTimeoutId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + lockTimeout, + () => { + this._lockTimeoutId = 0; + this.lock(false); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._lockTimeoutId, '[gnome-shell] this.lock'); + } + + this._activateFade(this._longLightbox, STANDARD_FADE_TIME); + } + + _activateFade(lightbox, time) { + Main.uiGroup.set_child_above_sibling(lightbox, null); + lightbox.lightOn(time); + + if (this._becameActiveId == 0) + this._becameActiveId = this.idleMonitor.add_user_active_watch(this._onUserBecameActive.bind(this)); + } + + _onUserBecameActive() { + // This function gets called here when the user becomes active + // after we activated a lightbox + // There are two possibilities here: + // - we're called when already locked; we just go back to the lock screen curtain + // - we're called because the session is IDLE but before the lightbox + // is fully shown; at this point isActive is false, so we just hide + // the lightbox, reset the activationTime and go back to the unlocked + // desktop + // using deactivate() is a little of overkill, but it ensures we + // don't forget of some bit like modal, DBus properties or idle watches + // + // Note: if the (long) lightbox is shown then we're necessarily + // active, because we call activate() without animation. + + this.idleMonitor.remove_watch(this._becameActiveId); + this._becameActiveId = 0; + + if (this._isLocked) { + this._longLightbox.lightOff(); + this._shortLightbox.lightOff(); + } else { + this.deactivate(false); + } + } + + _onLongLightbox(lightBox) { + if (lightBox.active) + this.activate(false); + } + + _onShortLightbox(lightBox) { + if (lightBox.active) + this._completeLockScreenShown(); + } + + showDialog() { + if (!this._becomeModal()) { + // In the login screen, this is a hard error. Fail-whale + log('Could not acquire modal grab for the login screen. Aborting login process.'); + Meta.quit(Meta.ExitCode.ERROR); + } + + this.actor.show(); + this._isGreeter = Main.sessionMode.isGreeter; + this._isLocked = true; + this._ensureUnlockDialog(true); + } + + _hideLockScreenComplete() { + this._lockScreenState = MessageTray.State.HIDDEN; + this._lockScreenGroup.hide(); + + if (this._dialog) { + this._dialog.grab_key_focus(); + this._dialog.navigate_focus(null, St.DirectionType.TAB_FORWARD, false); + } + } + + _showPointer() { + this._cursorTracker.set_pointer_visible(true); + + if (this._motionId) { + global.stage.disconnect(this._motionId); + this._motionId = 0; + } + } + + _hidePointerUntilMotion() { + this._motionId = global.stage.connect('captured-event', (stage, event) => { + if (event.type() === Clutter.EventType.MOTION) + this._showPointer(); + + return Clutter.EVENT_PROPAGATE; + }); + this._cursorTracker.set_pointer_visible(false); + } + + _hideLockScreen(animate) { + if (this._lockScreenState == MessageTray.State.HIDDEN) + return; + + this._lockScreenState = MessageTray.State.HIDING; + + this._lockDialogGroup.remove_all_transitions(); + + if (animate) { + // Animate the lock screen out of screen + // if velocity is not specified (i.e. we come here from pressing ESC), + // use the same speed regardless of original position + // if velocity is specified, it's in pixels per milliseconds + let h = global.stage.height; + let delta = h + this._lockDialogGroup.translation_y; + let velocity = global.stage.height / CURTAIN_SLIDE_TIME; + let duration = delta / velocity; + + this._lockDialogGroup.ease({ + translation_y: -h, + duration, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._hideLockScreenComplete(), + }); + } else { + this._hideLockScreenComplete(); + } + + this._showPointer(); + } + + _ensureUnlockDialog(allowCancel) { + if (!this._dialog) { + let constructor = Main.sessionMode.unlockDialog; + if (!constructor) { + // This session mode has no locking capabilities + this.deactivate(true); + return false; + } + + this._dialog = new constructor(this._lockDialogGroup); + + let time = global.get_current_time(); + if (!this._dialog.open(time)) { + // This is kind of an impossible error: we're already modal + // by the time we reach this... + log('Could not open login dialog: failed to acquire grab'); + this.deactivate(true); + return false; + } + + this._dialog.connect('failed', this._onUnlockFailed.bind(this)); + this._wakeUpScreenId = this._dialog.connect( + 'wake-up-screen', this._wakeUpScreen.bind(this)); + } + + this._dialog.allowCancel = allowCancel; + this._dialog.grab_key_focus(); + return true; + } + + _onUnlockFailed() { + this._resetLockScreen({ animateLockScreen: true, + fadeToBlack: false }); + } + + _resetLockScreen(params) { + // Don't reset the lock screen unless it is completely hidden + // This prevents the shield going down if the lock-delay timeout + // fires while the user is dragging (which has the potential + // to confuse our state) + if (this._lockScreenState != MessageTray.State.HIDDEN) + return; + + this._lockScreenGroup.show(); + this._lockScreenState = MessageTray.State.SHOWING; + + let fadeToBlack = params.fadeToBlack; + + if (params.animateLockScreen) { + this._lockDialogGroup.translation_y = -global.screen_height; + this._lockDialogGroup.remove_all_transitions(); + this._lockDialogGroup.ease({ + translation_y: 0, + duration: Overview.ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._lockScreenShown({ fadeToBlack, animateFade: true }); + }, + }); + } else { + this._lockDialogGroup.translation_y = 0; + this._lockScreenShown({ fadeToBlack, animateFade: false }); + } + + this._dialog.grab_key_focus(); + } + + _lockScreenShown(params) { + this._hidePointerUntilMotion(); + + this._lockScreenState = MessageTray.State.SHOWN; + + if (params.fadeToBlack && params.animateFade) { + // Take a beat + + let id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, MANUAL_FADE_TIME, () => { + this._activateFade(this._shortLightbox, MANUAL_FADE_TIME); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] this._activateFade'); + } else { + if (params.fadeToBlack) + this._activateFade(this._shortLightbox, 0); + + this._completeLockScreenShown(); + } + } + + _completeLockScreenShown() { + this._setActive(true); + this.emit('lock-screen-shown'); + } + + _wakeUpScreen() { + this._onUserBecameActive(); + this.emit('wake-up-screen'); + } + + get locked() { + return this._isLocked; + } + + get active() { + return this._isActive; + } + + get activationTime() { + return this._activationTime; + } + + deactivate(animate) { + if (this._dialog) + this._dialog.finish(() => this._continueDeactivate(animate)); + else + this._continueDeactivate(animate); + } + + _continueDeactivate(animate) { + this._hideLockScreen(animate); + + if (Main.sessionMode.currentMode == 'unlock-dialog') + Main.sessionMode.popMode('unlock-dialog'); + + this.emit('wake-up-screen'); + + if (this._isGreeter) { + // We don't want to "deactivate" any more than + // this. In particular, we don't want to drop + // the modal, hide ourselves or destroy the dialog + // But we do want to set isActive to false, so that + // gnome-session will reset the idle counter, and + // gnome-settings-daemon will stop blanking the screen + + this._activationTime = 0; + this._setActive(false); + return; + } + + if (this._dialog && !this._isGreeter) + this._dialog.popModal(); + + if (this._isModal) { + Main.popModal(this.actor); + this._isModal = false; + } + + this._longLightbox.lightOff(); + this._shortLightbox.lightOff(); + + this._lockDialogGroup.ease({ + translation_y: -global.screen_height, + duration: Overview.ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._completeDeactivate(), + }); + } + + _completeDeactivate() { + if (this._dialog) { + this._dialog.destroy(); + this._dialog = null; + } + + this.actor.hide(); + + if (this._becameActiveId != 0) { + this.idleMonitor.remove_watch(this._becameActiveId); + this._becameActiveId = 0; + } + + if (this._lockTimeoutId != 0) { + GLib.source_remove(this._lockTimeoutId); + this._lockTimeoutId = 0; + } + + this._activationTime = 0; + this._setActive(false); + this._isLocked = false; + this.emit('locked-changed'); + global.set_runtime_state(LOCKED_STATE_STR, null); + } + + activate(animate) { + if (this._activationTime == 0) + this._activationTime = GLib.get_monotonic_time(); + + if (!this._ensureUnlockDialog(true)) + return; + + this.actor.show(); + + if (Main.sessionMode.currentMode !== 'unlock-dialog') { + this._isGreeter = Main.sessionMode.isGreeter; + if (!this._isGreeter) + Main.sessionMode.pushMode('unlock-dialog'); + } + + this._resetLockScreen({ animateLockScreen: animate, + fadeToBlack: true }); + // On wayland, a crash brings down the entire session, so we don't + // need to defend against being restarted unlocked + if (!Meta.is_wayland_compositor()) + global.set_runtime_state(LOCKED_STATE_STR, GLib.Variant.new('b', true)); + + // We used to set isActive and emit active-changed here, + // but now we do that from lockScreenShown, which means + // there is a 0.3 seconds window during which the lock + // screen is effectively visible and the screen is locked, but + // the DBus interface reports the screensaver is off. + // This is because when we emit ActiveChanged(true), + // gnome-settings-daemon blanks the screen, and we don't want + // blank during the animation. + // This is not a problem for the idle fade case, because we + // activate without animation in that case. + } + + lock(animate) { + if (this._lockSettings.get_boolean(DISABLE_LOCK_KEY)) { + log('Screen lock is locked down, not locking'); // lock, lock - who's there? + return; + } + + // Warn the user if we can't become modal + if (!this._becomeModal()) { + Main.notifyError(_("Unable to lock"), + _("Lock was blocked by an application")); + return; + } + + // Clear the clipboard - otherwise, its contents may be leaked + // to unauthorized parties by pasting into the unlock dialog's + // password entry and unmasking the entry + St.Clipboard.get_default().set_text(St.ClipboardType.CLIPBOARD, ''); + St.Clipboard.get_default().set_text(St.ClipboardType.PRIMARY, ''); + + let userManager = AccountsService.UserManager.get_default(); + let user = userManager.get_user(GLib.get_user_name()); + + if (this._isGreeter) + this._isLocked = true; + else + this._isLocked = user.password_mode != AccountsService.UserPasswordMode.NONE; + + this.activate(animate); + + this.emit('locked-changed'); + } + + // If the previous shell crashed, and gnome-session restarted us, then re-lock + lockIfWasLocked() { + if (!this._settings.get_boolean(LOCK_ENABLED_KEY)) + return; + let wasLocked = global.get_runtime_state('b', LOCKED_STATE_STR); + if (wasLocked === null) + return; + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + this.lock(false); + return GLib.SOURCE_REMOVE; + }); + } +}; +Signals.addSignalMethods(ScreenShield.prototype); diff --git a/js/ui/screenshot.js b/js/ui/screenshot.js new file mode 100644 index 0000000..787e60f --- /dev/null +++ b/js/ui/screenshot.js @@ -0,0 +1,631 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported ScreenshotService */ + +const { Clutter, Gio, GObject, GLib, Meta, Shell, St } = imports.gi; + +const GrabHelper = imports.ui.grabHelper; +const Lightbox = imports.ui.lightbox; +const Main = imports.ui.main; + +Gio._promisify(Shell.Screenshot.prototype, 'pick_color', 'pick_color_finish'); +Gio._promisify(Shell.Screenshot.prototype, 'screenshot', 'screenshot_finish'); +Gio._promisify(Shell.Screenshot.prototype, + 'screenshot_window', 'screenshot_window_finish'); +Gio._promisify(Shell.Screenshot.prototype, + 'screenshot_area', 'screenshot_area_finish'); + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const ScreenshotIface = loadInterfaceXML('org.gnome.Shell.Screenshot'); + +var ScreenshotService = class { + constructor() { + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(ScreenshotIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell/Screenshot'); + + this._screenShooter = new Map(); + + this._lockdownSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.lockdown' }); + + Gio.DBus.session.own_name('org.gnome.Shell.Screenshot', Gio.BusNameOwnerFlags.REPLACE, null, null); + } + + _createScreenshot(invocation, needsDisk = true) { + let lockedDown = false; + if (needsDisk) + lockedDown = this._lockdownSettings.get_boolean('disable-save-to-disk'); + + let sender = invocation.get_sender(); + if (this._screenShooter.has(sender) || lockedDown) { + invocation.return_error_literal( + Gio.IOErrorEnum, Gio.IOErrorEnum.BUSY, + 'There is an ongoing operation for this sender'); + return null; + } + + let shooter = new Shell.Screenshot(); + shooter._watchNameId = + Gio.bus_watch_name(Gio.BusType.SESSION, sender, 0, null, + this._onNameVanished.bind(this)); + + this._screenShooter.set(sender, shooter); + + return shooter; + } + + _onNameVanished(connection, name) { + this._removeShooterForSender(name); + } + + _removeShooterForSender(sender) { + let shooter = this._screenShooter.get(sender); + if (!shooter) + return; + + Gio.bus_unwatch_name(shooter._watchNameId); + this._screenShooter.delete(sender); + } + + _checkArea(x, y, width, height) { + return x >= 0 && y >= 0 && + width > 0 && height > 0 && + x + width <= global.screen_width && + y + height <= global.screen_height; + } + + *_resolveRelativeFilename(filename) { + filename = filename.replace(/\.png$/, ''); + + let path = [ + GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_PICTURES), + GLib.get_home_dir(), + ].find(p => GLib.file_test(p, GLib.FileTest.EXISTS)); + + if (!path) + return null; + + yield Gio.File.new_for_path( + GLib.build_filenamev([path, `${filename}.png`])); + + for (let idx = 1; ; idx++) { + yield Gio.File.new_for_path( + GLib.build_filenamev([path, `${filename}-${idx}.png`])); + } + } + + _createStream(filename, invocation) { + if (filename == '') + return [Gio.MemoryOutputStream.new_resizable(), null]; + + if (GLib.path_is_absolute(filename)) { + try { + let file = Gio.File.new_for_path(filename); + let stream = file.replace(null, false, Gio.FileCreateFlags.NONE, null); + return [stream, file]; + } catch (e) { + invocation.return_value(GLib.Variant.new('(bs)', [false, ''])); + return [null, null]; + } + } + + for (let file of this._resolveRelativeFilename(filename)) { + try { + let stream = file.create(Gio.FileCreateFlags.NONE, null); + return [stream, file]; + } catch (e) { + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS)) + break; + } + } + + invocation.return_value(GLib.Variant.new('(bs)', [false, ''])); + return [null, null]; + } + + _onScreenshotComplete(area, stream, file, flash, invocation) { + if (flash) { + let flashspot = new Flashspot(area); + flashspot.fire(() => { + this._removeShooterForSender(invocation.get_sender()); + }); + } else { + this._removeShooterForSender(invocation.get_sender()); + } + + stream.close(null); + + let filenameUsed = ''; + if (file) { + filenameUsed = file.get_path(); + } else { + let bytes = stream.steal_as_bytes(); + let clipboard = St.Clipboard.get_default(); + clipboard.set_content(St.ClipboardType.CLIPBOARD, 'image/png', bytes); + } + + let retval = GLib.Variant.new('(bs)', [true, filenameUsed]); + invocation.return_value(retval); + } + + _scaleArea(x, y, width, height) { + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + x *= scaleFactor; + y *= scaleFactor; + width *= scaleFactor; + height *= scaleFactor; + return [x, y, width, height]; + } + + _unscaleArea(x, y, width, height) { + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + x /= scaleFactor; + y /= scaleFactor; + width /= scaleFactor; + height /= scaleFactor; + return [x, y, width, height]; + } + + async ScreenshotAreaAsync(params, invocation) { + let [x, y, width, height, flash, filename] = params; + [x, y, width, height] = this._scaleArea(x, y, width, height); + if (!this._checkArea(x, y, width, height)) { + invocation.return_error_literal(Gio.IOErrorEnum, + Gio.IOErrorEnum.CANCELLED, + "Invalid params"); + return; + } + let screenshot = this._createScreenshot(invocation); + if (!screenshot) + return; + + let [stream, file] = this._createStream(filename, invocation); + if (!stream) + return; + + try { + let [area] = + await screenshot.screenshot_area(x, y, width, height, stream); + this._onScreenshotComplete(area, stream, file, flash, invocation); + } catch (e) { + this._removeShooterForSender(invocation.get_sender()); + invocation.return_value(new GLib.Variant('(bs)', [false, ''])); + } + } + + async ScreenshotWindowAsync(params, invocation) { + let [includeFrame, includeCursor, flash, filename] = params; + let screenshot = this._createScreenshot(invocation); + if (!screenshot) + return; + + let [stream, file] = this._createStream(filename, invocation); + if (!stream) + return; + + try { + let [area] = + await screenshot.screenshot_window(includeFrame, includeCursor, stream); + this._onScreenshotComplete(area, stream, file, flash, invocation); + } catch (e) { + this._removeShooterForSender(invocation.get_sender()); + invocation.return_value(new GLib.Variant('(bs)', [false, ''])); + } + } + + async ScreenshotAsync(params, invocation) { + let [includeCursor, flash, filename] = params; + let screenshot = this._createScreenshot(invocation); + if (!screenshot) + return; + + let [stream, file] = this._createStream(filename, invocation); + if (!stream) + return; + + try { + let [area] = await screenshot.screenshot(includeCursor, stream); + this._onScreenshotComplete(area, stream, file, flash, invocation); + } catch (e) { + this._removeShooterForSender(invocation.get_sender()); + invocation.return_value(new GLib.Variant('(bs)', [false, ''])); + } + } + + async SelectAreaAsync(params, invocation) { + let selectArea = new SelectArea(); + try { + let areaRectangle = await selectArea.selectAsync(); + let retRectangle = this._unscaleArea( + areaRectangle.x, areaRectangle.y, + areaRectangle.width, areaRectangle.height); + invocation.return_value(GLib.Variant.new('(iiii)', retRectangle)); + } catch (e) { + invocation.return_error_literal( + Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED, + 'Operation was cancelled'); + } + } + + FlashAreaAsync(params, invocation) { + let [x, y, width, height] = params; + [x, y, width, height] = this._scaleArea(x, y, width, height); + if (!this._checkArea(x, y, width, height)) { + invocation.return_error_literal(Gio.IOErrorEnum, + Gio.IOErrorEnum.CANCELLED, + "Invalid params"); + return; + } + let flashspot = new Flashspot({ x, y, width, height }); + flashspot.fire(); + invocation.return_value(null); + } + + async PickColorAsync(params, invocation) { + const screenshot = this._createScreenshot(invocation, false); + if (!screenshot) + return; + + const pickPixel = new PickPixel(screenshot); + try { + const color = await pickPixel.pickAsync(); + const { red, green, blue } = color; + const retval = GLib.Variant.new('(a{sv})', [{ + color: GLib.Variant.new('(ddd)', [ + red / 255.0, + green / 255.0, + blue / 255.0, + ]), + }]); + invocation.return_value(retval); + } catch (e) { + invocation.return_error_literal( + Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED, + 'Operation was cancelled'); + } finally { + this._removeShooterForSender(invocation.get_sender()); + } + } +}; + +var SelectArea = GObject.registerClass( +class SelectArea extends St.Widget { + _init() { + this._startX = -1; + this._startY = -1; + this._lastX = 0; + this._lastY = 0; + this._result = null; + + super._init({ + visible: false, + reactive: true, + x: 0, + y: 0, + }); + Main.uiGroup.add_actor(this); + + this._grabHelper = new GrabHelper.GrabHelper(this); + + let constraint = new Clutter.BindConstraint({ source: global.stage, + coordinate: Clutter.BindCoordinate.ALL }); + this.add_constraint(constraint); + + this._rubberband = new St.Widget({ + style_class: 'select-area-rubberband', + visible: false, + }); + this.add_actor(this._rubberband); + } + + async selectAsync() { + global.display.set_cursor(Meta.Cursor.CROSSHAIR); + Main.uiGroup.set_child_above_sibling(this, null); + this.show(); + + try { + await this._grabHelper.grabAsync({ actor: this }); + } finally { + global.display.set_cursor(Meta.Cursor.DEFAULT); + + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this.destroy(); + return GLib.SOURCE_REMOVE; + }); + } + + return this._result; + } + + _getGeometry() { + return new Meta.Rectangle({ + x: Math.min(this._startX, this._lastX), + y: Math.min(this._startY, this._lastY), + width: Math.abs(this._startX - this._lastX) + 1, + height: Math.abs(this._startY - this._lastY) + 1, + }); + } + + vfunc_motion_event(motionEvent) { + if (this._startX == -1 || this._startY == -1 || this._result) + return Clutter.EVENT_PROPAGATE; + + [this._lastX, this._lastY] = [motionEvent.x, motionEvent.y]; + this._lastX = Math.floor(this._lastX); + this._lastY = Math.floor(this._lastY); + let geometry = this._getGeometry(); + + this._rubberband.set_position(geometry.x, geometry.y); + this._rubberband.set_size(geometry.width, geometry.height); + this._rubberband.show(); + + return Clutter.EVENT_PROPAGATE; + } + + vfunc_button_press_event(buttonEvent) { + [this._startX, this._startY] = [buttonEvent.x, buttonEvent.y]; + this._startX = Math.floor(this._startX); + this._startY = Math.floor(this._startY); + this._rubberband.set_position(this._startX, this._startY); + + return Clutter.EVENT_PROPAGATE; + } + + vfunc_button_release_event() { + this._result = this._getGeometry(); + this.ease({ + opacity: 0, + duration: 200, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._grabHelper.ungrab(), + }); + return Clutter.EVENT_PROPAGATE; + } +}); + +var RecolorEffect = GObject.registerClass({ + Properties: { + color: GObject.ParamSpec.boxed( + 'color', 'color', 'replacement color', + GObject.ParamFlags.WRITABLE, + Clutter.Color.$gtype), + chroma: GObject.ParamSpec.boxed( + 'chroma', 'chroma', 'color to replace', + GObject.ParamFlags.WRITABLE, + Clutter.Color.$gtype), + threshold: GObject.ParamSpec.float( + 'threshold', 'threshold', 'threshold', + GObject.ParamFlags.WRITABLE, + 0.0, 1.0, 0.0), + smoothing: GObject.ParamSpec.float( + 'smoothing', 'smoothing', 'smoothing', + GObject.ParamFlags.WRITABLE, + 0.0, 1.0, 0.0), + }, +}, class RecolorEffect extends Shell.GLSLEffect { + _init(params) { + this._color = new Clutter.Color(); + this._chroma = new Clutter.Color(); + this._threshold = 0; + this._smoothing = 0; + + this._colorLocation = null; + this._chromaLocation = null; + this._thresholdLocation = null; + this._smoothingLocation = null; + + super._init(params); + + this._colorLocation = this.get_uniform_location('recolor_color'); + this._chromaLocation = this.get_uniform_location('chroma_color'); + this._thresholdLocation = this.get_uniform_location('threshold'); + this._smoothingLocation = this.get_uniform_location('smoothing'); + + this._updateColorUniform(this._colorLocation, this._color); + this._updateColorUniform(this._chromaLocation, this._chroma); + this._updateFloatUniform(this._thresholdLocation, this._threshold); + this._updateFloatUniform(this._smoothingLocation, this._smoothing); + } + + _updateColorUniform(location, color) { + if (!location) + return; + + this.set_uniform_float(location, + 3, [color.red / 255, color.green / 255, color.blue / 255]); + this.queue_repaint(); + } + + _updateFloatUniform(location, value) { + if (!location) + return; + + this.set_uniform_float(location, 1, [value]); + this.queue_repaint(); + } + + set color(c) { + if (this._color.equal(c)) + return; + + this._color = c; + this.notify('color'); + + this._updateColorUniform(this._colorLocation, this._color); + } + + set chroma(c) { + if (this._chroma.equal(c)) + return; + + this._chroma = c; + this.notify('chroma'); + + this._updateColorUniform(this._chromaLocation, this._chroma); + } + + set threshold(value) { + if (this._threshold === value) + return; + + this._threshold = value; + this.notify('threshold'); + + this._updateFloatUniform(this._thresholdLocation, this._threshold); + } + + set smoothing(value) { + if (this._smoothing === value) + return; + + this._smoothing = value; + this.notify('smoothing'); + + this._updateFloatUniform(this._smoothingLocation, this._smoothing); + } + + vfunc_build_pipeline() { + // Conversion parameters from https://en.wikipedia.org/wiki/YCbCr + const decl = ` + vec3 rgb2yCrCb(vec3 c) { \n + float y = 0.299 * c.r + 0.587 * c.g + 0.114 * c.b; \n + float cr = 0.7133 * (c.r - y); \n + float cb = 0.5643 * (c.b - y); \n + return vec3(y, cr, cb); \n + } \n + \n + uniform vec3 chroma_color; \n + uniform vec3 recolor_color; \n + uniform float threshold; \n + uniform float smoothing; \n`; + const src = ` + vec3 mask = rgb2yCrCb(chroma_color.rgb); \n + vec3 yCrCb = rgb2yCrCb(cogl_color_out.rgb); \n + float blend = \n + smoothstep(threshold, \n + threshold + smoothing, \n + distance(yCrCb.gb, mask.gb)); \n + cogl_color_out.rgb = \n + mix(recolor_color, cogl_color_out.rgb, blend); \n`; + + this.add_glsl_snippet(Shell.SnippetHook.FRAGMENT, decl, src, false); + } +}); + +var PickPixel = GObject.registerClass( +class PickPixel extends St.Widget { + _init(screenshot) { + super._init({ visible: false, reactive: true }); + + this._screenshot = screenshot; + + this._result = null; + this._color = null; + this._inPick = false; + + Main.uiGroup.add_actor(this); + + this._grabHelper = new GrabHelper.GrabHelper(this); + + let constraint = new Clutter.BindConstraint({ source: global.stage, + coordinate: Clutter.BindCoordinate.ALL }); + this.add_constraint(constraint); + + const action = new Clutter.ClickAction(); + action.connect('clicked', async () => { + await this._pickColor(...action.get_coords()); + this._result = this._color; + this._grabHelper.ungrab(); + }); + this.add_action(action); + + this._recolorEffect = new RecolorEffect({ + chroma: new Clutter.Color({ + red: 80, + green: 219, + blue: 181, + }), + threshold: 0.04, + smoothing: 0.07, + }); + this._previewCursor = new St.Icon({ + icon_name: 'color-pick', + icon_size: Meta.prefs_get_cursor_size(), + effect: this._recolorEffect, + visible: false, + }); + Main.uiGroup.add_actor(this._previewCursor); + } + + async pickAsync() { + global.display.set_cursor(Meta.Cursor.BLANK); + Main.uiGroup.set_child_above_sibling(this, null); + this.show(); + + this._pickColor(...global.get_pointer()); + + try { + await this._grabHelper.grabAsync({ actor: this }); + } finally { + global.display.set_cursor(Meta.Cursor.DEFAULT); + this._previewCursor.destroy(); + + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this.destroy(); + return GLib.SOURCE_REMOVE; + }); + } + + return this._result; + } + + async _pickColor(x, y) { + if (this._inPick) + return; + + this._inPick = true; + this._previewCursor.set_position(x, y); + [this._color] = await this._screenshot.pick_color(x, y); + this._inPick = false; + + if (!this._color) + return; + + this._recolorEffect.color = this._color; + this._previewCursor.show(); + } + + vfunc_motion_event(motionEvent) { + const { x, y } = motionEvent; + this._pickColor(x, y); + return Clutter.EVENT_PROPAGATE; + } +}); + +var FLASHSPOT_ANIMATION_OUT_TIME = 500; // milliseconds + +var Flashspot = GObject.registerClass( +class Flashspot extends Lightbox.Lightbox { + _init(area) { + super._init(Main.uiGroup, { + inhibitEvents: true, + width: area.width, + height: area.height, + }); + this.style_class = 'flashspot'; + this.set_position(area.x, area.y); + } + + fire(doneCallback) { + this.set({ visible: true, opacity: 255 }); + this.ease({ + opacity: 0, + duration: FLASHSPOT_ANIMATION_OUT_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + if (doneCallback) + doneCallback(); + this.destroy(); + }, + }); + } +}); diff --git a/js/ui/scripting.js b/js/ui/scripting.js new file mode 100644 index 0000000..e4b29a4 --- /dev/null +++ b/js/ui/scripting.js @@ -0,0 +1,351 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported sleep, waitLeisure, createTestWindow, waitTestWindows, + destroyTestWindows, defineScriptEvent, scriptEvent, + collectStatistics, runPerfScript */ + +const { Gio, GLib, Meta, Shell } = imports.gi; + +const Config = imports.misc.config; +const Main = imports.ui.main; +const Params = imports.misc.params; +const Util = imports.misc.util; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +// This module provides functionality for driving the shell user interface +// in an automated fashion. The primary current use case for this is +// automated performance testing (see runPerfScript()), but it could +// be applied to other forms of automation, such as testing for +// correctness as well. +// +// When scripting an automated test we want to make a series of calls +// in a linear fashion, but we also want to be able to let the main +// loop run so actions can finish. For this reason we write the script +// as an async function that uses await when it wants to let the main +// loop run. +// +// await Scripting.sleep(1000); +// main.overview.show(); +// await Scripting.waitLeisure(); +// + +/** + * sleep: + * @param {number} milliseconds - number of milliseconds to wait + * @returns {Promise} that resolves after @milliseconds ms + * + * Used within an automation script to pause the the execution of the + * current script for the specified amount of time. Use as + * 'yield Scripting.sleep(500);' + */ +function sleep(milliseconds) { + return new Promise(resolve => { + let id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, milliseconds, () => { + resolve(); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] sleep'); + }); +} + +/** + * waitLeisure: + * @returns {Promise} that resolves when the shell is idle + * + * Used within an automation script to pause the the execution of the + * current script until the shell is completely idle. Use as + * 'yield Scripting.waitLeisure();' + */ +function waitLeisure() { + return new Promise(resolve => { + global.run_at_leisure(resolve); + }); +} + +const PerfHelperIface = loadInterfaceXML('org.gnome.Shell.PerfHelper'); +var PerfHelperProxy = Gio.DBusProxy.makeProxyWrapper(PerfHelperIface); +function PerfHelper() { + return new PerfHelperProxy(Gio.DBus.session, 'org.gnome.Shell.PerfHelper', '/org/gnome/Shell/PerfHelper'); +} + +let _perfHelper = null; +function _getPerfHelper() { + if (_perfHelper == null) + _perfHelper = new PerfHelper(); + + return _perfHelper; +} + +function _spawnPerfHelper() { + let path = Config.LIBEXECDIR; + let command = `${path}/gnome-shell-perf-helper`; + Util.trySpawnCommandLine(command); +} + +function _callRemote(obj, method, ...args) { + return new Promise((resolve, reject) => { + args.push((result, excp) => { + if (excp) + reject(excp); + else + resolve(); + }); + + method.apply(obj, args); + }); +} + +/** + * createTestWindow: + * @param {Object} params: options for window creation. + * {number} [params.width=640] - width of window, in pixels + * {number} [params.height=480] - height of window, in pixels + * {bool} [params.alpha=false] - whether the window should have an alpha channel + * {bool} [params.maximized=false] - whether the window should be created maximized + * {bool} [params.redraws=false] - whether the window should continually redraw itself + * @returns {Promise} + * + * Creates a window using gnome-shell-perf-helper for testing purposes. + * While this function can be used with yield in an automation + * script to pause until the D-Bus call to the helper process returns, + * because of the normal X asynchronous mapping process, to actually wait + * until the window has been mapped and exposed, use waitTestWindows(). + */ +function createTestWindow(params) { + params = Params.parse(params, { width: 640, + height: 480, + alpha: false, + maximized: false, + redraws: false }); + + let perfHelper = _getPerfHelper(); + return _callRemote(perfHelper, perfHelper.CreateWindowRemote, + params.width, params.height, + params.alpha, params.maximized, params.redraws); +} + +/** + * waitTestWindows: + * @returns {Promise} + * + * Used within an automation script to pause until all windows previously + * created with createTestWindow have been mapped and exposed. + */ +function waitTestWindows() { + let perfHelper = _getPerfHelper(); + return _callRemote(perfHelper, perfHelper.WaitWindowsRemote); +} + +/** + * destroyTestWindows: + * @returns {Promise} + * + * Destroys all windows previously created with createTestWindow(). + * While this function can be used with yield in an automation + * script to pause until the D-Bus call to the helper process returns, + * this doesn't guarantee that Mutter has actually finished the destroy + * process because of normal X asynchronicity. + */ +function destroyTestWindows() { + let perfHelper = _getPerfHelper(); + return _callRemote(perfHelper, perfHelper.DestroyWindowsRemote); +} + +/** + * defineScriptEvent + * @param {string} name: The event will be called script.<name> + * @param {string} description: Short human-readable description of the event + * + * Convenience function to define a zero-argument performance event + * within the 'script' namespace that is reserved for events defined locally + * within a performance automation script + */ +function defineScriptEvent(name, description) { + Shell.PerfLog.get_default().define_event(`script.${name}`, + description, + ""); +} + +/** + * scriptEvent + * @param {string} name: Name registered with defineScriptEvent() + * + * Convenience function to record a script-local performance event + * previously defined with defineScriptEvent + */ +function scriptEvent(name) { + Shell.PerfLog.get_default().event(`script.${name}`); +} + +/** + * collectStatistics + * + * Convenience function to trigger statistics collection + */ +function collectStatistics() { + Shell.PerfLog.get_default().collect_statistics(); +} + +function _collect(scriptModule, outputFile) { + let eventHandlers = {}; + + for (let f in scriptModule) { + let m = /([A-Za-z]+)_([A-Za-z]+)/.exec(f); + if (m) + eventHandlers[`${m[1]}.${m[2]}`] = scriptModule[f]; + } + + Shell.PerfLog.get_default().replay( + (time, eventName, signature, arg) => { + if (eventName in eventHandlers) + eventHandlers[eventName](time, arg); + }); + + if ('finish' in scriptModule) + scriptModule.finish(); + + if (outputFile) { + let f = Gio.file_new_for_path(outputFile); + let raw = f.replace(null, false, + Gio.FileCreateFlags.NONE, + null); + let out = Gio.BufferedOutputStream.new_sized(raw, 4096); + Shell.write_string_to_stream(out, "{\n"); + + Shell.write_string_to_stream(out, '"events":\n'); + Shell.PerfLog.get_default().dump_events(out); + + let monitors = Main.layoutManager.monitors; + let primary = Main.layoutManager.primaryIndex; + Shell.write_string_to_stream(out, ',\n"monitors":\n['); + for (let i = 0; i < monitors.length; i++) { + let monitor = monitors[i]; + if (i != 0) + Shell.write_string_to_stream(out, ', '); + Shell.write_string_to_stream(out, '"%s%dx%d+%d+%d"'.format(i == primary ? "*" : "", + monitor.width, monitor.height, + monitor.x, monitor.y)); + } + Shell.write_string_to_stream(out, ' ]'); + + Shell.write_string_to_stream(out, ',\n"metrics":\n[ '); + let first = true; + for (let name in scriptModule.METRICS) { + let metric = scriptModule.METRICS[name]; + // Extra checks here because JSON.stringify generates + // invalid JSON for undefined values + if (metric.description == null) { + log(`Error: No description found for metric ${name}`); + continue; + } + if (metric.units == null) { + log(`Error: No units found for metric ${name}`); + continue; + } + if (metric.value == null) { + log(`Error: No value found for metric ${name}`); + continue; + } + + if (!first) + Shell.write_string_to_stream(out, ',\n '); + first = false; + + Shell.write_string_to_stream(out, + `{ "name": ${JSON.stringify(name)},\n` + + ` "description": ${JSON.stringify(metric.description)},\n` + + ` "units": ${JSON.stringify(metric.units)},\n` + + ` "value": ${JSON.stringify(metric.value)} }`); + } + Shell.write_string_to_stream(out, ' ]'); + + Shell.write_string_to_stream(out, ',\n"log":\n'); + Shell.PerfLog.get_default().dump_log(out); + + Shell.write_string_to_stream(out, '\n}\n'); + out.close(null); + } else { + let metrics = []; + for (let metric in scriptModule.METRICS) + metrics.push(metric); + + metrics.sort(); + + print('------------------------------------------------------------'); + for (let i = 0; i < metrics.length; i++) { + let metric = metrics[i]; + print(`# ${scriptModule.METRICS[metric].description}`); + print(`${metric}: ${scriptModule.METRICS[metric].value}${scriptModule.METRICS[metric].units}`); + } + print('------------------------------------------------------------'); + } +} + +async function _runPerfScript(scriptModule, outputFile) { + try { + await scriptModule.run(); + } catch (err) { + log(`Script failed: ${err}\n${err.stack}`); + Meta.exit(Meta.ExitCode.ERROR); + } + + try { + _collect(scriptModule, outputFile); + } catch (err) { + log(`Script failed: ${err}\n${err.stack}`); + Meta.exit(Meta.ExitCode.ERROR); + } + Meta.exit(Meta.ExitCode.SUCCESS); +} + +/** + * runPerfScript + * @param {Object} scriptModule: module object with run and finish + * functions and event handlers + * @param {string} outputFile: path to write output to + * + * Runs a script for automated collection of performance data. The + * script is defined as a Javascript module with specified contents. + * + * First the run() function within the module will be called as a + * generator to automate a series of actions. These actions will + * trigger performance events and the script can also record its + * own performance events. + * + * Then the recorded event log is replayed using handler functions + * within the module. The handler for the event 'foo.bar' is called + * foo_bar(). + * + * Finally if the module has a function called finish(), that will + * be called. + * + * The event handler and finish functions are expected to fill in + * metrics to an object within the module called METRICS. Each + * property of this object represents an individual metric. The + * name of the property is the name of the metric, the value + * of the property is an object with the following properties: + * + * description: human readable description of the metric + * units: a string representing the units of the metric. It has + * the form '<unit> <unit> ... / <unit> / <unit> ...'. Certain + * unit values are recognized: s, ms, us, B, KiB, MiB. Other + * values can appear but are uninterpreted. Examples 's', + * '/ s', 'frames', 'frames / s', 'MiB / s / frame' + * value: computed value of the metric + * + * The resulting metrics will be written to @outputFile as JSON, or, + * if @outputFile is not provided, logged. + * + * After running the script and collecting statistics from the + * event log, GNOME Shell will exit. + **/ +function runPerfScript(scriptModule, outputFile) { + Shell.PerfLog.get_default().set_enabled(true); + _spawnPerfHelper(); + + Gio.bus_watch_name(Gio.BusType.SESSION, + 'org.gnome.Shell.PerfHelper', + Gio.BusNameWatcherFlags.NONE, + () => _runPerfScript(scriptModule, outputFile), + null); +} diff --git a/js/ui/search.js b/js/ui/search.js new file mode 100644 index 0000000..dd76735 --- /dev/null +++ b/js/ui/search.js @@ -0,0 +1,955 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported SearchResultsView */ + +const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; + +const AppDisplay = imports.ui.appDisplay; +const IconGrid = imports.ui.iconGrid; +const Main = imports.ui.main; +const ParentalControlsManager = imports.misc.parentalControlsManager; +const RemoteSearch = imports.ui.remoteSearch; +const Util = imports.misc.util; + +const SEARCH_PROVIDERS_SCHEMA = 'org.gnome.desktop.search-providers'; + +var MAX_LIST_SEARCH_RESULTS_ROWS = 5; + +var MaxWidthBox = GObject.registerClass( +class MaxWidthBox extends St.BoxLayout { + vfunc_allocate(box) { + let themeNode = this.get_theme_node(); + let maxWidth = themeNode.get_max_width(); + let availWidth = box.x2 - box.x1; + let adjustedBox = box; + + if (availWidth > maxWidth) { + let excessWidth = availWidth - maxWidth; + adjustedBox.x1 += Math.floor(excessWidth / 2); + adjustedBox.x2 -= Math.floor(excessWidth / 2); + } + + super.vfunc_allocate(adjustedBox); + } +}); + +var SearchResult = GObject.registerClass( +class SearchResult extends St.Button { + _init(provider, metaInfo, resultsView) { + this.provider = provider; + this.metaInfo = metaInfo; + this._resultsView = resultsView; + + super._init({ + reactive: true, + can_focus: true, + track_hover: true, + }); + } + + vfunc_clicked() { + this.activate(); + } + + activate() { + this.provider.activateResult(this.metaInfo.id, this._resultsView.terms); + + if (this.metaInfo.clipboardText) { + St.Clipboard.get_default().set_text( + St.ClipboardType.CLIPBOARD, this.metaInfo.clipboardText); + } + Main.overview.toggle(); + } +}); + +var ListSearchResult = GObject.registerClass( +class ListSearchResult extends SearchResult { + _init(provider, metaInfo, resultsView) { + super._init(provider, metaInfo, resultsView); + + this.style_class = 'list-search-result'; + + let content = new St.BoxLayout({ + style_class: 'list-search-result-content', + vertical: false, + x_align: Clutter.ActorAlign.START, + x_expand: true, + y_expand: true, + }); + this.set_child(content); + + this._termsChangedId = 0; + + let titleBox = new St.BoxLayout({ + style_class: 'list-search-result-title', + y_align: Clutter.ActorAlign.CENTER, + }); + + content.add_child(titleBox); + + // An icon for, or thumbnail of, content + let icon = this.metaInfo['createIcon'](this.ICON_SIZE); + if (icon) + titleBox.add(icon); + + let title = new St.Label({ + text: this.metaInfo['name'], + y_align: Clutter.ActorAlign.CENTER, + }); + titleBox.add_child(title); + + this.label_actor = title; + + if (this.metaInfo['description']) { + this._descriptionLabel = new St.Label({ + style_class: 'list-search-result-description', + y_align: Clutter.ActorAlign.CENTER, + }); + content.add_child(this._descriptionLabel); + + this._termsChangedId = + this._resultsView.connect('terms-changed', + this._highlightTerms.bind(this)); + + this._highlightTerms(); + } + + this.connect('destroy', this._onDestroy.bind(this)); + } + + get ICON_SIZE() { + return 24; + } + + _highlightTerms() { + let markup = this._resultsView.highlightTerms(this.metaInfo['description'].split('\n')[0]); + this._descriptionLabel.clutter_text.set_markup(markup); + } + + _onDestroy() { + if (this._termsChangedId) + this._resultsView.disconnect(this._termsChangedId); + this._termsChangedId = 0; + } +}); + +var GridSearchResult = GObject.registerClass( +class GridSearchResult extends SearchResult { + _init(provider, metaInfo, resultsView) { + super._init(provider, metaInfo, resultsView); + + this.style_class = 'grid-search-result'; + + this.icon = new IconGrid.BaseIcon(this.metaInfo['name'], + { createIcon: this.metaInfo['createIcon'] }); + let content = new St.Bin({ + child: this.icon, + x_align: Clutter.ActorAlign.START, + x_expand: true, + y_expand: true, + }); + this.set_child(content); + this.label_actor = this.icon.label; + } +}); + +var SearchResultsBase = GObject.registerClass({ + GTypeFlags: GObject.TypeFlags.ABSTRACT, + Properties: { + 'focus-child': GObject.ParamSpec.object( + 'focus-child', 'focus-child', 'focus-child', + GObject.ParamFlags.READABLE, + Clutter.Actor.$gtype), + }, +}, class SearchResultsBase extends St.BoxLayout { + _init(provider, resultsView) { + super._init({ style_class: 'search-section', vertical: true }); + + this.provider = provider; + this._resultsView = resultsView; + + this._terms = []; + this._focusChild = null; + + this._resultDisplayBin = new St.Bin(); + this.add_child(this._resultDisplayBin); + + let separator = new St.Widget({ style_class: 'search-section-separator' }); + this.add(separator); + + this._resultDisplays = {}; + + this._cancellable = new Gio.Cancellable(); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + _onDestroy() { + this._terms = []; + } + + _createResultDisplay(meta) { + if (this.provider.createResultObject) + return this.provider.createResultObject(meta, this._resultsView); + + return null; + } + + clear() { + this._cancellable.cancel(); + for (let resultId in this._resultDisplays) + this._resultDisplays[resultId].destroy(); + this._resultDisplays = {}; + this._clearResultDisplay(); + this.hide(); + } + + get focusChild() { + return this._focusChild; + } + + _keyFocusIn(actor) { + if (this._focusChild == actor) + return; + this._focusChild = actor; + this.notify('focus-child'); + } + + _setMoreCount(_count) { + } + + _ensureResultActors(results, callback) { + let metasNeeded = results.filter( + resultId => this._resultDisplays[resultId] === undefined); + + if (metasNeeded.length === 0) { + callback(true); + } else { + this._cancellable.cancel(); + this._cancellable.reset(); + + this.provider.getResultMetas(metasNeeded, metas => { + if (this._cancellable.is_cancelled()) { + if (metas.length > 0) + log('Search provider %s returned results after the request was canceled'.format(this.provider.id)); + callback(false); + return; + } + if (metas.length != metasNeeded.length) { + log('Wrong number of result metas returned by search provider %s: '.format(this.provider.id) + + 'expected %d but got %d'.format(metasNeeded.length, metas.length)); + callback(false); + return; + } + if (metas.some(meta => !meta.name || !meta.id)) { + log('Invalid result meta returned from search provider %s'.format(this.provider.id)); + callback(false); + return; + } + + metasNeeded.forEach((resultId, i) => { + let meta = metas[i]; + let display = this._createResultDisplay(meta); + display.connect('key-focus-in', this._keyFocusIn.bind(this)); + this._resultDisplays[resultId] = display; + }); + callback(true); + }, this._cancellable); + } + } + + updateSearch(providerResults, terms, callback) { + this._terms = terms; + if (providerResults.length == 0) { + this._clearResultDisplay(); + this.hide(); + callback(); + } else { + let maxResults = this._getMaxDisplayedResults(); + let results = maxResults > -1 + ? this.provider.filterResults(providerResults, maxResults) + : providerResults; + let moreCount = Math.max(providerResults.length - results.length, 0); + + this._ensureResultActors(results, successful => { + if (!successful) { + this._clearResultDisplay(); + callback(); + return; + } + + // To avoid CSS transitions causing flickering when + // the first search result stays the same, we hide the + // content while filling in the results. + this.hide(); + this._clearResultDisplay(); + results.forEach(resultId => { + this._addItem(this._resultDisplays[resultId]); + }); + this._setMoreCount(this.provider.canLaunchSearch ? moreCount : 0); + this.show(); + callback(); + }); + } + } +}); + +var ListSearchResults = GObject.registerClass( +class ListSearchResults extends SearchResultsBase { + _init(provider, resultsView) { + super._init(provider, resultsView); + + this._container = new St.BoxLayout({ style_class: 'search-section-content' }); + this.providerInfo = new ProviderInfo(provider); + this.providerInfo.connect('key-focus-in', this._keyFocusIn.bind(this)); + this.providerInfo.connect('clicked', () => { + this.providerInfo.animateLaunch(); + provider.launchSearch(this._terms); + Main.overview.toggle(); + }); + + this._container.add_child(this.providerInfo); + + this._content = new St.BoxLayout({ + style_class: 'list-search-results', + vertical: true, + x_expand: true, + }); + this._container.add_child(this._content); + + this._resultDisplayBin.set_child(this._container); + } + + _setMoreCount(count) { + this.providerInfo.setMoreCount(count); + } + + _getMaxDisplayedResults() { + return MAX_LIST_SEARCH_RESULTS_ROWS; + } + + _clearResultDisplay() { + this._content.remove_all_children(); + } + + _createResultDisplay(meta) { + return super._createResultDisplay(meta) || + new ListSearchResult(this.provider, meta, this._resultsView); + } + + _addItem(display) { + this._content.add_actor(display); + } + + getFirstResult() { + if (this._content.get_n_children() > 0) + return this._content.get_child_at_index(0); + else + return null; + } +}); + +var GridSearchResultsLayout = GObject.registerClass({ + Properties: { + 'spacing': GObject.ParamSpec.int('spacing', 'Spacing', 'Spacing', + GObject.ParamFlags.READWRITE, 0, GLib.MAXINT32, 0), + }, +}, class GridSearchResultsLayout extends Clutter.LayoutManager { + _init() { + super._init(); + this._spacing = 0; + } + + vfunc_set_container(container) { + this._container = container; + } + + vfunc_get_preferred_width(container, forHeight) { + let minWidth = 0; + let natWidth = 0; + let first = true; + + for (let child of container) { + if (!child.visible) + continue; + + const [childMinWidth, childNatWidth] = child.get_preferred_width(forHeight); + + minWidth = Math.max(minWidth, childMinWidth); + natWidth += childNatWidth; + + if (first) + first = false; + else + natWidth += this._spacing; + } + + return [minWidth, natWidth]; + } + + vfunc_get_preferred_height(container, forWidth) { + let minHeight = 0; + let natHeight = 0; + + for (let child of container) { + if (!child.visible) + continue; + + const [childMinHeight, childNatHeight] = child.get_preferred_height(forWidth); + + minHeight = Math.max(minHeight, childMinHeight); + natHeight = Math.max(natHeight, childNatHeight); + } + + return [minHeight, natHeight]; + } + + vfunc_allocate(container, box) { + const width = box.get_width(); + + const childBox = new Clutter.ActorBox(); + childBox.x1 = 0; + childBox.y1 = 0; + + let first = true; + for (let child of container) { + if (!child.visible) + continue; + + if (first) + first = false; + else + childBox.x1 += this._spacing; + + const [childWidth] = child.get_preferred_width(-1); + const [childHeight] = child.get_preferred_height(-1); + + if (childBox.x1 + childWidth <= width) + childBox.set_size(childWidth, childHeight); + else + childBox.set_size(0, 0); + + child.allocate(childBox); + + childBox.x1 += childWidth; + } + } + + columnsForWidth(width) { + if (!this._container) + return -1; + + const [minWidth] = this.get_preferred_width(this._container, -1); + + if (minWidth === 0) + return -1; + + let nCols = 0; + while (width > minWidth) { + width -= minWidth; + if (nCols > 0) + width -= this._spacing; + nCols++; + } + + return nCols; + } + + get spacing() { + return this._spacing; + } + + set spacing(v) { + if (this._spacing === v) + return; + this._spacing = v; + this.layout_changed(); + } +}); + +var GridSearchResults = GObject.registerClass( +class GridSearchResults extends SearchResultsBase { + _init(provider, resultsView) { + super._init(provider, resultsView); + + this._grid = new St.Widget({ style_class: 'grid-search-results' }); + this._grid.layout_manager = new GridSearchResultsLayout(); + + this._grid.connect('style-changed', () => { + const node = this._grid.get_theme_node(); + this._grid.layout_manager.spacing = node.get_length('spacing'); + }); + + this._resultDisplayBin.set_child(new St.Bin({ + child: this._grid, + x_align: Clutter.ActorAlign.CENTER, + })); + } + + _onDestroy() { + if (this._updateSearchLater) { + Meta.later_remove(this._updateSearchLater); + delete this._updateSearchLater; + } + + super._onDestroy(); + } + + updateSearch(...args) { + if (this._notifyAllocationId) + this.disconnect(this._notifyAllocationId); + if (this._updateSearchLater) { + Meta.later_remove(this._updateSearchLater); + delete this._updateSearchLater; + } + + // Make sure the maximum number of results calculated by + // _getMaxDisplayedResults() is updated after width changes. + this._notifyAllocationId = this.connect('notify::allocation', () => { + if (this._updateSearchLater) + return; + this._updateSearchLater = Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + delete this._updateSearchLater; + super.updateSearch(...args); + return GLib.SOURCE_REMOVE; + }); + }); + + super.updateSearch(...args); + } + + _getMaxDisplayedResults() { + let width = this.allocation.get_width(); + if (width == 0) + return -1; + + return this._grid.layout_manager.columnsForWidth(width); + } + + _clearResultDisplay() { + this._grid.remove_all_children(); + } + + _createResultDisplay(meta) { + return super._createResultDisplay(meta) || + new GridSearchResult(this.provider, meta, this._resultsView); + } + + _addItem(display) { + this._grid.add_child(display); + } + + getFirstResult() { + for (let child of this._grid) { + if (child.visible) + return child; + } + return null; + } +}); + +var SearchResultsView = GObject.registerClass({ + Signals: { 'terms-changed': {} }, +}, class SearchResultsView extends St.BoxLayout { + _init() { + super._init({ name: 'searchResults', vertical: true }); + + this._parentalControlsManager = ParentalControlsManager.getDefault(); + this._parentalControlsManager.connect('app-filter-changed', this._reloadRemoteProviders.bind(this)); + + this._content = new MaxWidthBox({ + name: 'searchResultsContent', + vertical: true, + x_expand: true, + }); + + this._scrollView = new St.ScrollView({ + overlay_scrollbars: true, + style_class: 'search-display vfade', + x_expand: true, + y_expand: true, + }); + this._scrollView.set_policy(St.PolicyType.NEVER, St.PolicyType.AUTOMATIC); + this._scrollView.add_actor(this._content); + + let action = new Clutter.PanAction({ interpolate: true }); + action.connect('pan', this._onPan.bind(this)); + this._scrollView.add_action(action); + + this.add_child(this._scrollView); + + this._statusText = new St.Label({ + style_class: 'search-statustext', + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + this._statusBin = new St.Bin({ y_expand: true }); + this.add_child(this._statusBin); + this._statusBin.add_actor(this._statusText); + + this._highlightDefault = false; + this._defaultResult = null; + this._startingSearch = false; + + this._terms = []; + this._results = {}; + + this._providers = []; + + this._highlightRegex = null; + + this._searchSettings = new Gio.Settings({ schema_id: SEARCH_PROVIDERS_SCHEMA }); + this._searchSettings.connect('changed::disabled', this._reloadRemoteProviders.bind(this)); + this._searchSettings.connect('changed::enabled', this._reloadRemoteProviders.bind(this)); + this._searchSettings.connect('changed::disable-external', this._reloadRemoteProviders.bind(this)); + this._searchSettings.connect('changed::sort-order', this._reloadRemoteProviders.bind(this)); + + this._searchTimeoutId = 0; + this._cancellable = new Gio.Cancellable(); + + this._registerProvider(new AppDisplay.AppSearchProvider()); + + let appSystem = Shell.AppSystem.get_default(); + appSystem.connect('installed-changed', this._reloadRemoteProviders.bind(this)); + this._reloadRemoteProviders(); + } + + get terms() { + return this._terms; + } + + _reloadRemoteProviders() { + let remoteProviders = this._providers.filter(p => p.isRemoteProvider); + remoteProviders.forEach(provider => { + this._unregisterProvider(provider); + }); + + RemoteSearch.loadRemoteSearchProviders(this._searchSettings, providers => { + providers.forEach(this._registerProvider.bind(this)); + }); + } + + _registerProvider(provider) { + provider.searchInProgress = false; + + // Filter out unwanted providers. + if (provider.appInfo && !this._parentalControlsManager.shouldShowApp(provider.appInfo)) + return; + + this._providers.push(provider); + this._ensureProviderDisplay(provider); + } + + _unregisterProvider(provider) { + let index = this._providers.indexOf(provider); + this._providers.splice(index, 1); + + if (provider.display) + provider.display.destroy(); + } + + _gotResults(results, provider) { + this._results[provider.id] = results; + this._updateResults(provider, results); + } + + _clearSearchTimeout() { + if (this._searchTimeoutId > 0) { + GLib.source_remove(this._searchTimeoutId); + this._searchTimeoutId = 0; + } + } + + _reset() { + this._terms = []; + this._results = {}; + this._clearDisplay(); + this._clearSearchTimeout(); + this._defaultResult = null; + this._startingSearch = false; + + this._updateSearchProgress(); + } + + _doSearch() { + this._startingSearch = false; + + let previousResults = this._results; + this._results = {}; + + this._providers.forEach(provider => { + provider.searchInProgress = true; + + let previousProviderResults = previousResults[provider.id]; + if (this._isSubSearch && previousProviderResults) { + provider.getSubsearchResultSet(previousProviderResults, + this._terms, + results => { + this._gotResults(results, provider); + }, + this._cancellable); + } else { + provider.getInitialResultSet(this._terms, + results => { + this._gotResults(results, provider); + }, + this._cancellable); + } + }); + + this._updateSearchProgress(); + + this._clearSearchTimeout(); + } + + _onSearchTimeout() { + this._searchTimeoutId = 0; + this._doSearch(); + return GLib.SOURCE_REMOVE; + } + + setTerms(terms) { + // Check for the case of making a duplicate previous search before + // setting state of the current search or cancelling the search. + // This will prevent incorrect state being as a result of a duplicate + // search while the previous search is still active. + let searchString = terms.join(' '); + let previousSearchString = this._terms.join(' '); + if (searchString == previousSearchString) + return; + + this._startingSearch = true; + + this._cancellable.cancel(); + this._cancellable.reset(); + + if (terms.length == 0) { + this._reset(); + return; + } + + let isSubSearch = false; + if (this._terms.length > 0) + isSubSearch = searchString.indexOf(previousSearchString) == 0; + + this._terms = terms; + this._isSubSearch = isSubSearch; + this._updateSearchProgress(); + + if (this._searchTimeoutId == 0) + this._searchTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 150, this._onSearchTimeout.bind(this)); + + let escapedTerms = this._terms.map(term => Shell.util_regex_escape(term)); + this._highlightRegex = new RegExp('(%s)'.format(escapedTerms.join('|')), 'gi'); + + this.emit('terms-changed'); + } + + _onPan(action) { + let [dist_, dx_, dy] = action.get_motion_delta(0); + let adjustment = this._scrollView.vscroll.adjustment; + adjustment.value -= (dy / this.height) * adjustment.page_size; + return false; + } + + _focusChildChanged(provider) { + Util.ensureActorVisibleInScrollView(this._scrollView, provider.focusChild); + } + + _ensureProviderDisplay(provider) { + if (provider.display) + return; + + let providerDisplay; + if (provider.appInfo) + providerDisplay = new ListSearchResults(provider, this); + else + providerDisplay = new GridSearchResults(provider, this); + + providerDisplay.connect('notify::focus-child', this._focusChildChanged.bind(this)); + providerDisplay.hide(); + this._content.add(providerDisplay); + provider.display = providerDisplay; + } + + _clearDisplay() { + this._providers.forEach(provider => { + provider.display.clear(); + }); + } + + _maybeSetInitialSelection() { + let newDefaultResult = null; + + let providers = this._providers; + for (let i = 0; i < providers.length; i++) { + let provider = providers[i]; + let display = provider.display; + + if (!display.visible) + continue; + + let firstResult = display.getFirstResult(); + if (firstResult) { + newDefaultResult = firstResult; + break; // select this one! + } + } + + if (newDefaultResult != this._defaultResult) { + this._setSelected(this._defaultResult, false); + this._setSelected(newDefaultResult, this._highlightDefault); + + this._defaultResult = newDefaultResult; + } + } + + get searchInProgress() { + if (this._startingSearch) + return true; + + return this._providers.some(p => p.searchInProgress); + } + + _updateSearchProgress() { + let haveResults = this._providers.some(provider => { + let display = provider.display; + return display.getFirstResult() != null; + }); + + this._scrollView.visible = haveResults; + this._statusBin.visible = !haveResults; + + if (!haveResults) { + if (this.searchInProgress) + this._statusText.set_text(_("Searching…")); + else + this._statusText.set_text(_("No results.")); + } + } + + _updateResults(provider, results) { + let terms = this._terms; + let display = provider.display; + + display.updateSearch(results, terms, () => { + provider.searchInProgress = false; + + this._maybeSetInitialSelection(); + this._updateSearchProgress(); + }); + } + + activateDefault() { + // If we have a search queued up, force the search now. + if (this._searchTimeoutId > 0) + this._doSearch(); + + if (this._defaultResult) + this._defaultResult.activate(); + } + + highlightDefault(highlight) { + this._highlightDefault = highlight; + this._setSelected(this._defaultResult, highlight); + } + + popupMenuDefault() { + // If we have a search queued up, force the search now. + if (this._searchTimeoutId > 0) + this._doSearch(); + + if (this._defaultResult) + this._defaultResult.popup_menu(); + } + + navigateFocus(direction) { + let rtl = this.get_text_direction() == Clutter.TextDirection.RTL; + if (direction == St.DirectionType.TAB_BACKWARD || + direction == (rtl + ? St.DirectionType.RIGHT + : St.DirectionType.LEFT) || + direction == St.DirectionType.UP) { + this.navigate_focus(null, direction, false); + return; + } + + let from = this._defaultResult ? this._defaultResult : null; + this.navigate_focus(from, direction, false); + } + + _setSelected(result, selected) { + if (!result) + return; + + if (selected) { + result.add_style_pseudo_class('selected'); + Util.ensureActorVisibleInScrollView(this._scrollView, result); + } else { + result.remove_style_pseudo_class('selected'); + } + } + + highlightTerms(description) { + if (!description) + return ''; + + if (!this._highlightRegex) + return description; + + return description.replace(this._highlightRegex, '<b>$1</b>'); + } +}); + +var ProviderInfo = GObject.registerClass( +class ProviderInfo extends St.Button { + _init(provider) { + this.provider = provider; + super._init({ + style_class: 'search-provider-icon', + reactive: true, + can_focus: true, + accessible_name: provider.appInfo.get_name(), + track_hover: true, + y_align: Clutter.ActorAlign.START, + }); + + this._content = new St.BoxLayout({ vertical: false, + style_class: 'list-search-provider-content' }); + this.set_child(this._content); + + let icon = new St.Icon({ icon_size: this.PROVIDER_ICON_SIZE, + gicon: provider.appInfo.get_icon() }); + + let detailsBox = new St.BoxLayout({ style_class: 'list-search-provider-details', + vertical: true, + x_expand: true }); + + let nameLabel = new St.Label({ text: provider.appInfo.get_name(), + x_align: Clutter.ActorAlign.START }); + + this._moreLabel = new St.Label({ x_align: Clutter.ActorAlign.START }); + + detailsBox.add_actor(nameLabel); + detailsBox.add_actor(this._moreLabel); + + + this._content.add_actor(icon); + this._content.add_actor(detailsBox); + } + + get PROVIDER_ICON_SIZE() { + return 32; + } + + animateLaunch() { + let appSys = Shell.AppSystem.get_default(); + let app = appSys.lookup_app(this.provider.appInfo.get_id()); + if (app.state == Shell.AppState.STOPPED) + IconGrid.zoomOutActor(this._content); + } + + setMoreCount(count) { + this._moreLabel.text = ngettext("%d more", "%d more", count).format(count); + this._moreLabel.visible = count > 0; + } +}); diff --git a/js/ui/sessionMode.js b/js/ui/sessionMode.js new file mode 100644 index 0000000..2136e94 --- /dev/null +++ b/js/ui/sessionMode.js @@ -0,0 +1,198 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported SessionMode, listModes */ + +const ByteArray = imports.byteArray; +const GLib = imports.gi.GLib; +const Signals = imports.signals; + +const FileUtils = imports.misc.fileUtils; +const Params = imports.misc.params; + +const Config = imports.misc.config; + +const DEFAULT_MODE = 'restrictive'; + +const _modes = { + 'restrictive': { + parentMode: null, + stylesheetName: 'gnome-shell.css', + themeResourceName: 'gnome-shell-theme.gresource', + hasOverview: false, + showCalendarEvents: false, + allowSettings: false, + allowExtensions: false, + allowScreencast: false, + enabledExtensions: [], + hasRunDialog: false, + hasWorkspaces: false, + hasWindows: false, + hasNotifications: false, + hasWmMenus: false, + isLocked: false, + isGreeter: false, + isPrimary: false, + unlockDialog: null, + components: [], + panel: { + left: [], + center: [], + right: [], + }, + panelStyle: null, + }, + + 'gdm': { + hasNotifications: true, + isGreeter: true, + isPrimary: true, + unlockDialog: imports.gdm.loginDialog.LoginDialog, + components: ['polkitAgent'], + panel: { + left: [], + center: ['dateMenu'], + right: ['dwellClick', 'a11y', 'keyboard', 'aggregateMenu'], + }, + panelStyle: 'login-screen', + }, + + 'unlock-dialog': { + isLocked: true, + unlockDialog: undefined, + components: ['polkitAgent', 'telepathyClient'], + panel: { + left: [], + center: [], + right: ['dwellClick', 'a11y', 'keyboard', 'aggregateMenu'], + }, + panelStyle: 'unlock-screen', + }, + + 'user': { + hasOverview: true, + showCalendarEvents: true, + allowSettings: true, + allowExtensions: true, + allowScreencast: true, + hasRunDialog: true, + hasWorkspaces: true, + hasWindows: true, + hasWmMenus: true, + hasNotifications: true, + isLocked: false, + isPrimary: true, + unlockDialog: imports.ui.unlockDialog.UnlockDialog, + components: Config.HAVE_NETWORKMANAGER + ? ['networkAgent', 'polkitAgent', 'telepathyClient', + 'keyring', 'autorunManager', 'automountManager'] + : ['polkitAgent', 'telepathyClient', + 'keyring', 'autorunManager', 'automountManager'], + + panel: { + left: ['activities', 'appMenu'], + center: ['dateMenu'], + right: ['dwellClick', 'a11y', 'keyboard', 'aggregateMenu'], + }, + }, +}; + +function _loadMode(file, info) { + let name = info.get_name(); + let suffix = name.indexOf('.json'); + let modeName = suffix == -1 ? name : name.slice(name, suffix); + + if (Object.prototype.hasOwnProperty.call(_modes, modeName)) + return; + + let fileContent, success_, newMode; + try { + [success_, fileContent] = file.load_contents(null); + fileContent = ByteArray.toString(fileContent); + newMode = JSON.parse(fileContent); + } catch (e) { + return; + } + + _modes[modeName] = {}; + const excludedProps = ['unlockDialog']; + for (let prop in _modes[DEFAULT_MODE]) { + if (newMode[prop] !== undefined && + !excludedProps.includes(prop)) + _modes[modeName][prop] = newMode[prop]; + } + _modes[modeName]['isPrimary'] = true; +} + +function _loadModes() { + FileUtils.collectFromDatadirs('modes', false, _loadMode); +} + +function listModes() { + _loadModes(); + let loop = new GLib.MainLoop(null, false); + let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + let names = Object.getOwnPropertyNames(_modes); + for (let i = 0; i < names.length; i++) { + if (_modes[names[i]].isPrimary) + print(names[i]); + } + loop.quit(); + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] listModes'); + loop.run(); +} + +var SessionMode = class { + constructor() { + _loadModes(); + let isPrimary = _modes[global.session_mode] && + _modes[global.session_mode].isPrimary; + let mode = isPrimary ? global.session_mode : 'user'; + this._modeStack = [mode]; + this._sync(); + } + + pushMode(mode) { + this._modeStack.push(mode); + this._sync(); + } + + popMode(mode) { + if (this.currentMode != mode || this._modeStack.length === 1) + throw new Error("Invalid SessionMode.popMode"); + this._modeStack.pop(); + this._sync(); + } + + switchMode(to) { + if (this.currentMode == to) + return; + this._modeStack[this._modeStack.length - 1] = to; + this._sync(); + } + + get currentMode() { + return this._modeStack[this._modeStack.length - 1]; + } + + _sync() { + let params = _modes[this.currentMode]; + let defaults; + if (params.parentMode) { + defaults = Params.parse(_modes[params.parentMode], + _modes[DEFAULT_MODE]); + } else { + defaults = _modes[DEFAULT_MODE]; + } + params = Params.parse(params, defaults); + + // A simplified version of Lang.copyProperties, handles + // undefined as a special case for "no change / inherit from previous mode" + for (let prop in params) { + if (params[prop] !== undefined) + this[prop] = params[prop]; + } + + this.emit('updated'); + } +}; +Signals.addSignalMethods(SessionMode.prototype); diff --git a/js/ui/shellDBus.js b/js/ui/shellDBus.js new file mode 100644 index 0000000..16217c8 --- /dev/null +++ b/js/ui/shellDBus.js @@ -0,0 +1,401 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported GnomeShell, ScreenSaverDBus */ + +const { Gio, GLib, Meta } = imports.gi; + +const Config = imports.misc.config; +const ExtensionDownloader = imports.ui.extensionDownloader; +const ExtensionUtils = imports.misc.extensionUtils; +const Main = imports.ui.main; +const Screenshot = imports.ui.screenshot; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const GnomeShellIface = loadInterfaceXML('org.gnome.Shell'); +const ScreenSaverIface = loadInterfaceXML('org.gnome.ScreenSaver'); + +var GnomeShell = class { + constructor() { + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(GnomeShellIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell'); + + this._extensionsService = new GnomeShellExtensions(); + this._screenshotService = new Screenshot.ScreenshotService(); + + this._grabbedAccelerators = new Map(); + this._grabbers = new Map(); + + global.display.connect('accelerator-activated', + (display, action, device, timestamp) => { + this._emitAcceleratorActivated(action, device, timestamp); + }); + + this._cachedOverviewVisible = false; + Main.overview.connect('showing', + this._checkOverviewVisibleChanged.bind(this)); + Main.overview.connect('hidden', + this._checkOverviewVisibleChanged.bind(this)); + } + + /** + * Eval: + * @param {string} code: A string containing JavaScript code + * @returns {Array} + * + * This function executes arbitrary code in the main + * loop, and returns a boolean success and + * JSON representation of the object as a string. + * + * If evaluation completes without throwing an exception, + * then the return value will be [true, JSON.stringify(result)]. + * If evaluation fails, then the return value will be + * [false, JSON.stringify(exception)]; + * + */ + Eval(code) { + if (!global.settings.get_boolean('development-tools')) + return [false, '']; + + let returnValue; + let success; + try { + returnValue = JSON.stringify(eval(code)); + // A hack; DBus doesn't have null/undefined + if (returnValue == undefined) + returnValue = ''; + success = true; + } catch (e) { + returnValue = `${e}`; + success = false; + } + return [success, returnValue]; + } + + FocusSearch() { + Main.overview.focusSearch(); + } + + ShowOSD(params) { + for (let param in params) + params[param] = params[param].deep_unpack(); + + let { connector, + label, + level, + max_level: maxLevel, + icon: serializedIcon } = params; + + let monitorIndex = -1; + if (connector) { + let monitorManager = Meta.MonitorManager.get(); + monitorIndex = monitorManager.get_monitor_for_connector(connector); + } + + let icon = null; + if (serializedIcon) + icon = Gio.Icon.new_for_string(serializedIcon); + + Main.osdWindowManager.show(monitorIndex, icon, label, level, maxLevel); + } + + FocusApp(id) { + this.ShowApplications(); + Main.overview.viewSelector.appDisplay.selectApp(id); + } + + ShowApplications() { + Main.overview.viewSelector.showApps(); + } + + GrabAcceleratorAsync(params, invocation) { + let [accel, modeFlags, grabFlags] = params; + let sender = invocation.get_sender(); + let bindingAction = this._grabAcceleratorForSender(accel, modeFlags, grabFlags, sender); + invocation.return_value(GLib.Variant.new('(u)', [bindingAction])); + } + + GrabAcceleratorsAsync(params, invocation) { + let [accels] = params; + let sender = invocation.get_sender(); + let bindingActions = []; + for (let i = 0; i < accels.length; i++) { + let [accel, modeFlags, grabFlags] = accels[i]; + bindingActions.push(this._grabAcceleratorForSender(accel, modeFlags, grabFlags, sender)); + } + invocation.return_value(GLib.Variant.new('(au)', [bindingActions])); + } + + UngrabAcceleratorAsync(params, invocation) { + let [action] = params; + let sender = invocation.get_sender(); + let ungrabSucceeded = this._ungrabAcceleratorForSender(action, sender); + + invocation.return_value(GLib.Variant.new('(b)', [ungrabSucceeded])); + } + + UngrabAcceleratorsAsync(params, invocation) { + let [actions] = params; + let sender = invocation.get_sender(); + let ungrabSucceeded = true; + + for (let i = 0; i < actions.length; i++) + ungrabSucceeded &= this._ungrabAcceleratorForSender(actions[i], sender); + + invocation.return_value(GLib.Variant.new('(b)', [ungrabSucceeded])); + } + + _emitAcceleratorActivated(action, device, timestamp) { + let destination = this._grabbedAccelerators.get(action); + if (!destination) + return; + + let connection = this._dbusImpl.get_connection(); + let info = this._dbusImpl.get_info(); + let params = { 'device-id': GLib.Variant.new('u', device.get_device_id()), + 'timestamp': GLib.Variant.new('u', timestamp), + 'action-mode': GLib.Variant.new('u', Main.actionMode) }; + + let deviceNode = device.get_device_node(); + if (deviceNode) + params['device-node'] = GLib.Variant.new('s', deviceNode); + + connection.emit_signal(destination, + this._dbusImpl.get_object_path(), + info ? info.name : null, + 'AcceleratorActivated', + GLib.Variant.new('(ua{sv})', [action, params])); + } + + _grabAcceleratorForSender(accelerator, modeFlags, grabFlags, sender) { + let bindingAction = global.display.grab_accelerator(accelerator, grabFlags); + if (bindingAction == Meta.KeyBindingAction.NONE) + return Meta.KeyBindingAction.NONE; + + let bindingName = Meta.external_binding_name_for_action(bindingAction); + Main.wm.allowKeybinding(bindingName, modeFlags); + + this._grabbedAccelerators.set(bindingAction, sender); + + if (!this._grabbers.has(sender)) { + let id = Gio.bus_watch_name(Gio.BusType.SESSION, sender, 0, null, + this._onGrabberBusNameVanished.bind(this)); + this._grabbers.set(sender, id); + } + + return bindingAction; + } + + _ungrabAccelerator(action) { + let ungrabSucceeded = global.display.ungrab_accelerator(action); + if (ungrabSucceeded) + this._grabbedAccelerators.delete(action); + + return ungrabSucceeded; + } + + _ungrabAcceleratorForSender(action, sender) { + let grabbedBy = this._grabbedAccelerators.get(action); + if (sender != grabbedBy) + return false; + + return this._ungrabAccelerator(action); + } + + _onGrabberBusNameVanished(connection, name) { + let grabs = this._grabbedAccelerators.entries(); + for (let [action, sender] of grabs) { + if (sender == name) + this._ungrabAccelerator(action); + } + Gio.bus_unwatch_name(this._grabbers.get(name)); + this._grabbers.delete(name); + } + + ShowMonitorLabelsAsync(params, invocation) { + let sender = invocation.get_sender(); + let [dict] = params; + Main.osdMonitorLabeler.show(sender, dict); + invocation.return_value(null); + } + + HideMonitorLabelsAsync(params, invocation) { + let sender = invocation.get_sender(); + Main.osdMonitorLabeler.hide(sender); + invocation.return_value(null); + } + + _checkOverviewVisibleChanged() { + if (Main.overview.visible !== this._cachedOverviewVisible) { + this._cachedOverviewVisible = Main.overview.visible; + this._dbusImpl.emit_property_changed('OverviewActive', new GLib.Variant('b', this._cachedOverviewVisible)); + } + } + + get Mode() { + return global.session_mode; + } + + get OverviewActive() { + return this._cachedOverviewVisible; + } + + set OverviewActive(visible) { + if (visible) + Main.overview.show(); + else + Main.overview.hide(); + } + + get ShellVersion() { + return Config.PACKAGE_VERSION; + } +}; + +const GnomeShellExtensionsIface = loadInterfaceXML('org.gnome.Shell.Extensions'); + +var GnomeShellExtensions = class { + constructor() { + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(GnomeShellExtensionsIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell'); + + this._userExtensionsEnabled = this.UserExtensionsEnabled; + global.settings.connect('changed::disable-user-extensions', () => { + if (this._userExtensionsEnabled === this.UserExtensionsEnabled) + return; + + this._userExtensionsEnabled = this.UserExtensionsEnabled; + this._dbusImpl.emit_property_changed('UserExtensionsEnabled', + new GLib.Variant('b', this._userExtensionsEnabled)); + }); + + Main.extensionManager.connect('extension-state-changed', + this._extensionStateChanged.bind(this)); + } + + ListExtensions() { + let out = {}; + Main.extensionManager.getUuids().forEach(uuid => { + let dbusObj = this.GetExtensionInfo(uuid); + out[uuid] = dbusObj; + }); + return out; + } + + GetExtensionInfo(uuid) { + let extension = Main.extensionManager.lookup(uuid) || {}; + return ExtensionUtils.serializeExtension(extension); + } + + GetExtensionErrors(uuid) { + let extension = Main.extensionManager.lookup(uuid); + if (!extension) + return []; + + if (!extension.errors) + return []; + + return extension.errors; + } + + InstallRemoteExtensionAsync([uuid], invocation) { + return ExtensionDownloader.installExtension(uuid, invocation); + } + + UninstallExtension(uuid) { + return ExtensionDownloader.uninstallExtension(uuid); + } + + EnableExtension(uuid) { + return Main.extensionManager.enableExtension(uuid); + } + + DisableExtension(uuid) { + return Main.extensionManager.disableExtension(uuid); + } + + LaunchExtensionPrefs(uuid) { + this.OpenExtensionPrefs(uuid, '', {}); + } + + OpenExtensionPrefs(uuid, parentWindow, options) { + Main.extensionManager.openExtensionPrefs(uuid, parentWindow, options); + } + + ReloadExtensionAsync(params, invocation) { + invocation.return_error_literal( + Gio.DBusError, + Gio.DBusError.NOT_SUPPORTED, + 'ReloadExtension is deprecated and does not work'); + } + + CheckForUpdates() { + ExtensionDownloader.checkForUpdates(); + } + + get ShellVersion() { + return Config.PACKAGE_VERSION; + } + + get UserExtensionsEnabled() { + return !global.settings.get_boolean('disable-user-extensions'); + } + + set UserExtensionsEnabled(enable) { + global.settings.set_boolean('disable-user-extensions', !enable); + } + + _extensionStateChanged(_, newState) { + let state = ExtensionUtils.serializeExtension(newState); + this._dbusImpl.emit_signal('ExtensionStateChanged', + new GLib.Variant('(sa{sv})', [newState.uuid, state])); + + this._dbusImpl.emit_signal('ExtensionStatusChanged', + GLib.Variant.new('(sis)', [newState.uuid, newState.state, newState.error])); + } +}; + +var ScreenSaverDBus = class { + constructor(screenShield) { + this._screenShield = screenShield; + screenShield.connect('active-changed', shield => { + this._dbusImpl.emit_signal('ActiveChanged', GLib.Variant.new('(b)', [shield.active])); + }); + screenShield.connect('wake-up-screen', () => { + this._dbusImpl.emit_signal('WakeUpScreen', null); + }); + + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(ScreenSaverIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/gnome/ScreenSaver'); + + Gio.DBus.session.own_name('org.gnome.ScreenSaver', Gio.BusNameOwnerFlags.REPLACE, null, null); + } + + LockAsync(parameters, invocation) { + let tmpId = this._screenShield.connect('lock-screen-shown', () => { + this._screenShield.disconnect(tmpId); + + invocation.return_value(null); + }); + + this._screenShield.lock(true); + } + + SetActive(active) { + if (active) + this._screenShield.activate(true); + else + this._screenShield.deactivate(false); + } + + GetActive() { + return this._screenShield.active; + } + + GetActiveTime() { + let started = this._screenShield.activationTime; + if (started > 0) + return Math.floor((GLib.get_monotonic_time() - started) / 1000000); + else + return 0; + } +}; diff --git a/js/ui/shellEntry.js b/js/ui/shellEntry.js new file mode 100644 index 0000000..38f5a83 --- /dev/null +++ b/js/ui/shellEntry.js @@ -0,0 +1,209 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported addContextMenu CapsLockWarning */ + +const { Clutter, GObject, Pango, Shell, St } = imports.gi; + +const BoxPointer = imports.ui.boxpointer; +const Main = imports.ui.main; +const Params = imports.misc.params; +const PopupMenu = imports.ui.popupMenu; + +var EntryMenu = class extends PopupMenu.PopupMenu { + constructor(entry) { + super(entry, 0, St.Side.TOP); + + this._entry = entry; + this._clipboard = St.Clipboard.get_default(); + + // Populate menu + let item; + item = new PopupMenu.PopupMenuItem(_("Copy")); + item.connect('activate', this._onCopyActivated.bind(this)); + this.addMenuItem(item); + this._copyItem = item; + + item = new PopupMenu.PopupMenuItem(_("Paste")); + item.connect('activate', this._onPasteActivated.bind(this)); + this.addMenuItem(item); + this._pasteItem = item; + + if (entry instanceof St.PasswordEntry) + this._makePasswordItem(); + + Main.uiGroup.add_actor(this.actor); + this.actor.hide(); + } + + _makePasswordItem() { + let item = new PopupMenu.PopupMenuItem(''); + item.connect('activate', this._onPasswordActivated.bind(this)); + this.addMenuItem(item); + this._passwordItem = item; + } + + open(animate) { + this._updatePasteItem(); + this._updateCopyItem(); + if (this._passwordItem) + this._updatePasswordItem(); + + super.open(animate); + this._entry.add_style_pseudo_class('focus'); + + let direction = St.DirectionType.TAB_FORWARD; + if (!this.actor.navigate_focus(null, direction, false)) + this.actor.grab_key_focus(); + } + + _updateCopyItem() { + let selection = this._entry.clutter_text.get_selection(); + this._copyItem.setSensitive(!this._entry.clutter_text.password_char && + selection && selection != ''); + } + + _updatePasteItem() { + this._clipboard.get_text(St.ClipboardType.CLIPBOARD, + (clipboard, text) => { + this._pasteItem.setSensitive(text && text != ''); + }); + } + + _updatePasswordItem() { + if (!this._entry.password_visible) + this._passwordItem.label.set_text(_("Show Text")); + else + this._passwordItem.label.set_text(_("Hide Text")); + } + + _onCopyActivated() { + let selection = this._entry.clutter_text.get_selection(); + this._clipboard.set_text(St.ClipboardType.CLIPBOARD, selection); + } + + _onPasteActivated() { + this._clipboard.get_text(St.ClipboardType.CLIPBOARD, + (clipboard, text) => { + if (!text) + return; + this._entry.clutter_text.delete_selection(); + let pos = this._entry.clutter_text.get_cursor_position(); + this._entry.clutter_text.insert_text(text, pos); + }); + } + + _onPasswordActivated() { + this._entry.password_visible = !this._entry.password_visible; + } +}; + +function _setMenuAlignment(entry, stageX) { + let [success, entryX] = entry.transform_stage_point(stageX, 0); + if (success) + entry.menu.setSourceAlignment(entryX / entry.width); +} + +function _onButtonPressEvent(actor, event, entry) { + if (entry.menu.isOpen) { + entry.menu.close(BoxPointer.PopupAnimation.FULL); + return Clutter.EVENT_STOP; + } else if (event.get_button() == 3) { + let [stageX] = event.get_coords(); + _setMenuAlignment(entry, stageX); + entry.menu.open(BoxPointer.PopupAnimation.FULL); + return Clutter.EVENT_STOP; + } + return Clutter.EVENT_PROPAGATE; +} + +function _onPopup(actor, entry) { + let cursorPosition = entry.clutter_text.get_cursor_position(); + let [success, textX, textY_, lineHeight_] = entry.clutter_text.position_to_coords(cursorPosition); + if (success) + entry.menu.setSourceAlignment(textX / entry.width); + entry.menu.open(BoxPointer.PopupAnimation.FULL); +} + +function addContextMenu(entry, params) { + if (entry.menu) + return; + + params = Params.parse(params, { actionMode: Shell.ActionMode.POPUP }); + + entry.menu = new EntryMenu(entry); + entry._menuManager = new PopupMenu.PopupMenuManager(entry, + { actionMode: params.actionMode }); + entry._menuManager.addMenu(entry.menu); + + // Add an event handler to both the entry and its clutter_text; the former + // so padding is included in the clickable area, the latter because the + // event processing of ClutterText prevents event-bubbling. + entry.clutter_text.connect('button-press-event', (actor, event) => { + _onButtonPressEvent(actor, event, entry); + }); + entry.connect('button-press-event', (actor, event) => { + _onButtonPressEvent(actor, event, entry); + }); + + entry.connect('popup-menu', actor => _onPopup(actor, entry)); + + entry.connect('destroy', () => { + entry.menu.destroy(); + entry.menu = null; + entry._menuManager = null; + }); +} + +var CapsLockWarning = GObject.registerClass( +class CapsLockWarning extends St.Label { + _init(params) { + let defaultParams = { style_class: 'caps-lock-warning-label' }; + super._init(Object.assign(defaultParams, params)); + + this.text = _('Caps lock is on.'); + + this.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this.clutter_text.line_wrap = true; + + let seat = Clutter.get_default_backend().get_default_seat(); + this._keymap = seat.get_keymap(); + this._stateChangedId = 0; + + this.connect('notify::mapped', () => { + if (this.is_mapped()) { + this._stateChangedId = this._keymap.connect('state-changed', + () => this._sync(true)); + } else { + this._keymap.disconnect(this._stateChangedId); + this._stateChangedId = 0; + } + + this._sync(false); + }); + + this.connect('destroy', () => { + if (this._stateChangedId) + this._keymap.disconnect(this._stateChangedId); + }); + } + + _sync(animate) { + let capsLockOn = this._keymap.get_caps_lock_state(); + + this.remove_all_transitions(); + + const { naturalHeightSet } = this; + this.natural_height_set = false; + let [, height] = this.get_preferred_height(-1); + this.natural_height_set = naturalHeightSet; + + this.ease({ + height: capsLockOn ? height : 0, + opacity: capsLockOn ? 255 : 0, + duration: animate ? 200 : 0, + onComplete: () => { + if (capsLockOn) + this.height = -1; + }, + }); + } +}); diff --git a/js/ui/shellMountOperation.js b/js/ui/shellMountOperation.js new file mode 100644 index 0000000..2956c0e --- /dev/null +++ b/js/ui/shellMountOperation.js @@ -0,0 +1,752 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported ShellMountOperation, GnomeShellMountOpHandler */ + +const { Clutter, Gio, GLib, GObject, Pango, Shell, St } = imports.gi; + +const Animation = imports.ui.animation; +const CheckBox = imports.ui.checkBox; +const Dialog = imports.ui.dialog; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; +const ModalDialog = imports.ui.modalDialog; +const Params = imports.misc.params; +const ShellEntry = imports.ui.shellEntry; + +const { loadInterfaceXML } = imports.misc.fileUtils; +const Util = imports.misc.util; + +var LIST_ITEM_ICON_SIZE = 48; +var WORK_SPINNER_ICON_SIZE = 16; + +const REMEMBER_MOUNT_PASSWORD_KEY = 'remember-mount-password'; + +/* ------ Common Utils ------- */ +function _setButtonsForChoices(dialog, oldChoices, choices) { + let buttons = []; + let buttonsChanged = oldChoices.length !== choices.length; + + for (let idx = 0; idx < choices.length; idx++) { + let button = idx; + + buttonsChanged = buttonsChanged || oldChoices[idx] !== choices[idx]; + + buttons.unshift({ + label: choices[idx], + action: () => dialog.emit('response', button), + }); + } + + if (buttonsChanged) + dialog.setButtons(buttons); +} + +function _setLabelsForMessage(content, message) { + let labels = message.split('\n'); + + content.title = labels.shift(); + content.description = labels.join('\n'); +} + +/* -------------------------------------------------------- */ + +var ShellMountOperation = class { + constructor(source, params) { + params = Params.parse(params, { existingDialog: null }); + + this._dialog = null; + this._dialogId = 0; + this._existingDialog = params.existingDialog; + this._processesDialog = null; + + this.mountOp = new Shell.MountOperation(); + + this.mountOp.connect('ask-question', + this._onAskQuestion.bind(this)); + this.mountOp.connect('ask-password', + this._onAskPassword.bind(this)); + this.mountOp.connect('show-processes-2', + this._onShowProcesses2.bind(this)); + this.mountOp.connect('aborted', + this.close.bind(this)); + this.mountOp.connect('show-unmount-progress', + this._onShowUnmountProgress.bind(this)); + } + + _closeExistingDialog() { + if (!this._existingDialog) + return; + + this._existingDialog.close(); + this._existingDialog = null; + } + + _onAskQuestion(op, message, choices) { + this._closeExistingDialog(); + this._dialog = new ShellMountQuestionDialog(); + + this._dialogId = this._dialog.connect('response', + (object, choice) => { + this.mountOp.set_choice(choice); + this.mountOp.reply(Gio.MountOperationResult.HANDLED); + + this.close(); + }); + + this._dialog.update(message, choices); + this._dialog.open(); + } + + _onAskPassword(op, message, defaultUser, defaultDomain, flags) { + if (this._existingDialog) { + this._dialog = this._existingDialog; + this._dialog.reaskPassword(); + } else { + this._dialog = new ShellMountPasswordDialog(message, flags); + } + + this._dialogId = this._dialog.connect('response', + (object, choice, password, remember, hiddenVolume, systemVolume, pim) => { + if (choice == -1) { + this.mountOp.reply(Gio.MountOperationResult.ABORTED); + } else { + if (remember) + this.mountOp.set_password_save(Gio.PasswordSave.PERMANENTLY); + else + this.mountOp.set_password_save(Gio.PasswordSave.NEVER); + + this.mountOp.set_password(password); + this.mountOp.set_is_tcrypt_hidden_volume(hiddenVolume); + this.mountOp.set_is_tcrypt_system_volume(systemVolume); + this.mountOp.set_pim(pim); + this.mountOp.reply(Gio.MountOperationResult.HANDLED); + } + }); + this._dialog.open(); + } + + close(_op) { + this._closeExistingDialog(); + this._processesDialog = null; + + if (this._dialog) { + this._dialog.close(); + this._dialog = null; + } + + if (this._notifier) { + this._notifier.done(); + this._notifier = null; + } + } + + _onShowProcesses2(op) { + this._closeExistingDialog(); + + let processes = op.get_show_processes_pids(); + let choices = op.get_show_processes_choices(); + let message = op.get_show_processes_message(); + + if (!this._processesDialog) { + this._processesDialog = new ShellProcessesDialog(); + this._dialog = this._processesDialog; + + this._dialogId = this._processesDialog.connect('response', + (object, choice) => { + if (choice == -1) { + this.mountOp.reply(Gio.MountOperationResult.ABORTED); + } else { + this.mountOp.set_choice(choice); + this.mountOp.reply(Gio.MountOperationResult.HANDLED); + } + + this.close(); + }); + this._processesDialog.open(); + } + + this._processesDialog.update(message, processes, choices); + } + + _onShowUnmountProgress(op, message, timeLeft, bytesLeft) { + if (!this._notifier) + this._notifier = new ShellUnmountNotifier(); + + if (bytesLeft == 0) + this._notifier.done(message); + else + this._notifier.show(message); + } + + borrowDialog() { + if (this._dialogId != 0) { + this._dialog.disconnect(this._dialogId); + this._dialogId = 0; + } + + return this._dialog; + } +}; + +var ShellUnmountNotifier = GObject.registerClass( +class ShellUnmountNotifier extends MessageTray.Source { + _init() { + super._init('', 'media-removable'); + + this._notification = null; + Main.messageTray.add(this); + } + + show(message) { + let [header, text] = message.split('\n', 2); + + if (!this._notification) { + this._notification = new MessageTray.Notification(this, header, text); + this._notification.setTransient(true); + this._notification.setUrgency(MessageTray.Urgency.CRITICAL); + } else { + this._notification.update(header, text); + } + + this.showNotification(this._notification); + } + + done(message) { + if (this._notification) { + this._notification.destroy(); + this._notification = null; + } + + if (message) { + let notification = new MessageTray.Notification(this, message, null); + notification.setTransient(true); + + this.showNotification(notification); + } + } +}); + +var ShellMountQuestionDialog = GObject.registerClass({ + Signals: { 'response': { param_types: [GObject.TYPE_INT] } }, +}, class ShellMountQuestionDialog extends ModalDialog.ModalDialog { + _init() { + super._init({ styleClass: 'mount-question-dialog' }); + + this._oldChoices = []; + + this._content = new Dialog.MessageDialogContent(); + this.contentLayout.add_child(this._content); + } + + vfunc_key_release_event(event) { + if (event.keyval === Clutter.KEY_Escape) { + this.emit('response', -1); + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_PROPAGATE; + } + + update(message, choices) { + _setLabelsForMessage(this._content, message); + _setButtonsForChoices(this, this._oldChoices, choices); + this._oldChoices = choices; + } +}); + +var ShellMountPasswordDialog = GObject.registerClass({ + Signals: { 'response': { param_types: [GObject.TYPE_INT, + GObject.TYPE_STRING, + GObject.TYPE_BOOLEAN, + GObject.TYPE_BOOLEAN, + GObject.TYPE_BOOLEAN, + GObject.TYPE_UINT] } }, +}, class ShellMountPasswordDialog extends ModalDialog.ModalDialog { + _init(message, flags) { + let strings = message.split('\n'); + let title = strings.shift() || null; + let description = strings.shift() || null; + super._init({ styleClass: 'prompt-dialog' }); + + let disksApp = Shell.AppSystem.get_default().lookup_app('org.gnome.DiskUtility.desktop'); + + let content = new Dialog.MessageDialogContent({ title, description }); + + let passwordGridLayout = new Clutter.GridLayout({ orientation: Clutter.Orientation.VERTICAL }); + let passwordGrid = new St.Widget({ + style_class: 'prompt-dialog-password-grid', + layout_manager: passwordGridLayout, + }); + passwordGridLayout.hookup_style(passwordGrid); + + let rtl = passwordGrid.get_text_direction() === Clutter.TextDirection.RTL; + let curGridRow = 0; + + if (flags & Gio.AskPasswordFlags.TCRYPT) { + this._hiddenVolume = new CheckBox.CheckBox(_("Hidden Volume")); + content.add_child(this._hiddenVolume); + + this._systemVolume = new CheckBox.CheckBox(_("Windows System Volume")); + content.add_child(this._systemVolume); + + this._keyfilesCheckbox = new CheckBox.CheckBox(_("Uses Keyfiles")); + this._keyfilesCheckbox.connect("clicked", this._onKeyfilesCheckboxClicked.bind(this)); + content.add_child(this._keyfilesCheckbox); + + this._keyfilesLabel = new St.Label({ visible: false }); + this._keyfilesLabel.clutter_text.set_markup( + /* Translators: %s is the Disks application */ + _('To unlock a volume that uses keyfiles, use the <i>%s</i> utility instead.') + .format(disksApp.get_name())); + this._keyfilesLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._keyfilesLabel.clutter_text.line_wrap = true; + content.add_child(this._keyfilesLabel); + + this._pimEntry = new St.PasswordEntry({ + style_class: 'prompt-dialog-password-entry', + hint_text: _('PIM Number'), + can_focus: true, + x_expand: true, + }); + this._pimEntry.clutter_text.connect('activate', this._onEntryActivate.bind(this)); + ShellEntry.addContextMenu(this._pimEntry); + + if (rtl) + passwordGridLayout.attach(this._pimEntry, 1, curGridRow, 1, 1); + else + passwordGridLayout.attach(this._pimEntry, 0, curGridRow, 1, 1); + curGridRow += 1; + } else { + this._hiddenVolume = null; + this._systemVolume = null; + this._pimEntry = null; + } + + this._passwordEntry = new St.PasswordEntry({ + style_class: 'prompt-dialog-password-entry', + hint_text: _('Password'), + can_focus: true, + x_expand: true, + }); + this._passwordEntry.clutter_text.connect('activate', this._onEntryActivate.bind(this)); + this.setInitialKeyFocus(this._passwordEntry); + ShellEntry.addContextMenu(this._passwordEntry); + + this._workSpinner = new Animation.Spinner(WORK_SPINNER_ICON_SIZE, { + animate: true, + }); + + if (rtl) { + passwordGridLayout.attach(this._workSpinner, 0, curGridRow, 1, 1); + passwordGridLayout.attach(this._passwordEntry, 1, curGridRow, 1, 1); + } else { + passwordGridLayout.attach(this._passwordEntry, 0, curGridRow, 1, 1); + passwordGridLayout.attach(this._workSpinner, 1, curGridRow, 1, 1); + } + curGridRow += 1; + + let warningBox = new St.BoxLayout({ vertical: true }); + + let capsLockWarning = new ShellEntry.CapsLockWarning(); + warningBox.add_child(capsLockWarning); + + this._errorMessageLabel = new St.Label({ + style_class: 'prompt-dialog-error-label', + opacity: 0, + }); + this._errorMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._errorMessageLabel.clutter_text.line_wrap = true; + warningBox.add_child(this._errorMessageLabel); + + passwordGridLayout.attach(warningBox, 0, curGridRow, 2, 1); + + content.add_child(passwordGrid); + + if (flags & Gio.AskPasswordFlags.SAVING_SUPPORTED) { + this._rememberChoice = new CheckBox.CheckBox(_("Remember Password")); + this._rememberChoice.checked = + global.settings.get_boolean(REMEMBER_MOUNT_PASSWORD_KEY); + content.add_child(this._rememberChoice); + } else { + this._rememberChoice = null; + } + + this.contentLayout.add_child(content); + + this._defaultButtons = [{ + label: _("Cancel"), + action: this._onCancelButton.bind(this), + key: Clutter.KEY_Escape, + }, { + label: _("Unlock"), + action: this._onUnlockButton.bind(this), + default: true, + }]; + + this._usesKeyfilesButtons = [{ + label: _("Cancel"), + action: this._onCancelButton.bind(this), + key: Clutter.KEY_Escape, + }, { + /* Translators: %s is the Disks application */ + label: _("Open %s").format(disksApp.get_name()), + action: this._onOpenDisksButton.bind(this), + default: true, + }]; + + this.setButtons(this._defaultButtons); + } + + reaskPassword() { + this._workSpinner.stop(); + this._passwordEntry.set_text(''); + this._errorMessageLabel.text = _('Sorry, that didn’t work. Please try again.'); + this._errorMessageLabel.opacity = 255; + + Util.wiggle(this._passwordEntry); + } + + _onCancelButton() { + this.emit('response', -1, '', false, false, false, 0); + } + + _onUnlockButton() { + this._onEntryActivate(); + } + + _onEntryActivate() { + let pim = 0; + if (this._pimEntry !== null) { + pim = this._pimEntry.get_text(); + + if (isNaN(pim)) { + this._pimEntry.set_text(''); + this._errorMessageLabel.text = _('The PIM must be a number or empty.'); + this._errorMessageLabel.opacity = 255; + return; + } + + this._errorMessageLabel.opacity = 0; + } + + global.settings.set_boolean(REMEMBER_MOUNT_PASSWORD_KEY, + this._rememberChoice && this._rememberChoice.checked); + + this._workSpinner.play(); + this.emit('response', 1, + this._passwordEntry.get_text(), + this._rememberChoice && + this._rememberChoice.checked, + this._hiddenVolume && + this._hiddenVolume.checked, + this._systemVolume && + this._systemVolume.checked, + parseInt(pim)); + } + + _onKeyfilesCheckboxClicked() { + let useKeyfiles = this._keyfilesCheckbox.checked; + this._passwordEntry.reactive = !useKeyfiles; + this._passwordEntry.can_focus = !useKeyfiles; + this._pimEntry.reactive = !useKeyfiles; + this._pimEntry.can_focus = !useKeyfiles; + this._rememberChoice.reactive = !useKeyfiles; + this._rememberChoice.can_focus = !useKeyfiles; + this._keyfilesLabel.visible = useKeyfiles; + this.setButtons(useKeyfiles ? this._usesKeyfilesButtons : this._defaultButtons); + } + + _onOpenDisksButton() { + let app = Shell.AppSystem.get_default().lookup_app('org.gnome.DiskUtility.desktop'); + if (app) { + app.activate(); + } else { + Main.notifyError( + /* Translators: %s is the Disks application */ + _("Unable to start %s").format(app.get_name()), + /* Translators: %s is the Disks application */ + _('Couldn’t find the %s application').format(app.get_name())); + } + this._onCancelButton(); + } +}); + +var ShellProcessesDialog = GObject.registerClass({ + Signals: { 'response': { param_types: [GObject.TYPE_INT] } }, +}, class ShellProcessesDialog extends ModalDialog.ModalDialog { + _init() { + super._init({ styleClass: 'processes-dialog' }); + + this._oldChoices = []; + + this._content = new Dialog.MessageDialogContent(); + this.contentLayout.add_child(this._content); + + this._applicationSection = new Dialog.ListSection(); + this._applicationSection.hide(); + this.contentLayout.add_child(this._applicationSection); + } + + vfunc_key_release_event(event) { + if (event.keyval === Clutter.KEY_Escape) { + this.emit('response', -1); + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_PROPAGATE; + } + + _setAppsForPids(pids) { + // remove all the items + this._applicationSection.list.destroy_all_children(); + + pids.forEach(pid => { + let tracker = Shell.WindowTracker.get_default(); + let app = tracker.get_app_from_pid(pid); + + if (!app) + return; + + let listItem = new Dialog.ListSectionItem({ + icon_actor: app.create_icon_texture(LIST_ITEM_ICON_SIZE), + title: app.get_name(), + }); + this._applicationSection.list.add_child(listItem); + }); + + this._applicationSection.visible = + this._applicationSection.list.get_n_children() > 0; + } + + update(message, processes, choices) { + this._setAppsForPids(processes); + _setLabelsForMessage(this._content, message); + _setButtonsForChoices(this, this._oldChoices, choices); + this._oldChoices = choices; + } +}); + +const GnomeShellMountOpIface = loadInterfaceXML('org.Gtk.MountOperationHandler'); + +var ShellMountOperationType = { + NONE: 0, + ASK_PASSWORD: 1, + ASK_QUESTION: 2, + SHOW_PROCESSES: 3, +}; + +var GnomeShellMountOpHandler = class { + constructor() { + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(GnomeShellMountOpIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/gtk/MountOperationHandler'); + Gio.bus_own_name_on_connection(Gio.DBus.session, 'org.gtk.MountOperationHandler', + Gio.BusNameOwnerFlags.REPLACE, null, null); + + this._dialog = null; + this._volumeMonitor = Gio.VolumeMonitor.get(); + + this._ensureEmptyRequest(); + } + + _ensureEmptyRequest() { + this._currentId = null; + this._currentInvocation = null; + this._currentType = ShellMountOperationType.NONE; + } + + _clearCurrentRequest(response, details) { + if (this._currentInvocation) { + this._currentInvocation.return_value( + GLib.Variant.new('(ua{sv})', [response, details])); + } + + this._ensureEmptyRequest(); + } + + _setCurrentRequest(invocation, id, type) { + let oldId = this._currentId; + let oldType = this._currentType; + let requestId = '%s@%s'.format(id, invocation.get_sender()); + + this._clearCurrentRequest(Gio.MountOperationResult.UNHANDLED, {}); + + this._currentInvocation = invocation; + this._currentId = requestId; + this._currentType = type; + + if (this._dialog && (oldId == requestId) && (oldType == type)) + return true; + + return false; + } + + _closeDialog() { + if (this._dialog) { + this._dialog.close(); + this._dialog = null; + } + } + + /** + * AskPassword: + * @param {Array} params + * {string} id: an opaque ID identifying the object for which + * the operation is requested + * {string} message: the message to display + * {string} icon_name: the name of an icon to display + * {string} default_user: the default username for display + * {string} default_domain: the default domain for display + * {Gio.AskPasswordFlags} flags: a set of GAskPasswordFlags + * {Gio.MountOperationResults} response: a GMountOperationResult + * {Object} response_details: a dictionary containing response details as + * entered by the user. The dictionary MAY contain the following + * properties: + * - "password" -> (s): a password to be used to complete the mount operation + * - "password_save" -> (u): a GPasswordSave + * @param {Gio.DBusMethodInvocation} invocation + * The ID must be unique in the context of the calling process. + * + * The dialog will stay visible until clients call the Close() method, or + * another dialog becomes visible. + * Calling AskPassword again for the same id will have the effect to clear + * the existing dialog and update it with a message indicating the previous + * attempt went wrong. + */ + AskPasswordAsync(params, invocation) { + let [id, message, iconName_, defaultUser_, defaultDomain_, flags] = params; + + if (this._setCurrentRequest(invocation, id, ShellMountOperationType.ASK_PASSWORD)) { + this._dialog.reaskPassword(); + return; + } + + this._closeDialog(); + + this._dialog = new ShellMountPasswordDialog(message, flags); + this._dialog.connect('response', + (object, choice, password, remember, hiddenVolume, systemVolume, pim) => { + let details = {}; + let response; + + if (choice == -1) { + response = Gio.MountOperationResult.ABORTED; + } else { + response = Gio.MountOperationResult.HANDLED; + + let passSave = remember ? Gio.PasswordSave.PERMANENTLY : Gio.PasswordSave.NEVER; + details['password_save'] = GLib.Variant.new('u', passSave); + details['password'] = GLib.Variant.new('s', password); + details['hidden_volume'] = GLib.Variant.new('b', hiddenVolume); + details['system_volume'] = GLib.Variant.new('b', systemVolume); + details['pim'] = GLib.Variant.new('u', pim); + } + + this._clearCurrentRequest(response, details); + }); + this._dialog.open(); + } + + /** + * AskQuestion: + * @param {Array} params - params + * {string} id: an opaque ID identifying the object for which + * the operation is requested + * The ID must be unique in the context of the calling process. + * {string} message: the message to display + * {string} icon_name: the name of an icon to display + * {string[]} choices: an array of choice strings + * @param {Gio.DBusMethodInvocation} invocation - invocation + * + * The dialog will stay visible until clients call the Close() method, or + * another dialog becomes visible. + * Calling AskQuestion again for the same id will have the effect to clear + * update the dialog with the new question. + */ + AskQuestionAsync(params, invocation) { + let [id, message, iconName_, choices] = params; + + if (this._setCurrentRequest(invocation, id, ShellMountOperationType.ASK_QUESTION)) { + this._dialog.update(message, choices); + return; + } + + this._closeDialog(); + + this._dialog = new ShellMountQuestionDialog(message); + this._dialog.connect('response', (object, choice) => { + let response; + let details = {}; + + if (choice == -1) { + response = Gio.MountOperationResult.ABORTED; + } else { + response = Gio.MountOperationResult.HANDLED; + details['choice'] = GLib.Variant.new('i', choice); + } + + this._clearCurrentRequest(response, details); + }); + + this._dialog.update(message, choices); + this._dialog.open(); + } + + /** + * ShowProcesses: + * @param {Array} params - params + * {string} id: an opaque ID identifying the object for which + * the operation is requested + * The ID must be unique in the context of the calling process. + * {string} message: the message to display + * {string} icon_name: the name of an icon to display + * {number[]} application_pids: the PIDs of the applications to display + * {string[]} choices: an array of choice strings + * @param {Gio.DBusMethodInvocation} invocation - invocation + * + * The dialog will stay visible until clients call the Close() method, or + * another dialog becomes visible. + * Calling ShowProcesses again for the same id will have the effect to clear + * the existing dialog and update it with the new message and the new list + * of processes. + */ + ShowProcessesAsync(params, invocation) { + let [id, message, iconName_, applicationPids, choices] = params; + + if (this._setCurrentRequest(invocation, id, ShellMountOperationType.SHOW_PROCESSES)) { + this._dialog.update(message, applicationPids, choices); + return; + } + + this._closeDialog(); + + this._dialog = new ShellProcessesDialog(); + this._dialog.connect('response', (object, choice) => { + let response; + let details = {}; + + if (choice == -1) { + response = Gio.MountOperationResult.ABORTED; + } else { + response = Gio.MountOperationResult.HANDLED; + details['choice'] = GLib.Variant.new('i', choice); + } + + this._clearCurrentRequest(response, details); + }); + + this._dialog.update(message, applicationPids, choices); + this._dialog.open(); + } + + /** + * Close: + * @param {Array} _params - params + * @param {Gio.DBusMethodInvocation} _invocation - invocation + * + * Closes a dialog previously opened by AskPassword, AskQuestion or ShowProcesses. + * If no dialog is open, does nothing. + */ + Close(_params, _invocation) { + this._clearCurrentRequest(Gio.MountOperationResult.UNHANDLED, {}); + this._closeDialog(); + } +}; diff --git a/js/ui/slider.js b/js/ui/slider.js new file mode 100644 index 0000000..ba3a233 --- /dev/null +++ b/js/ui/slider.js @@ -0,0 +1,213 @@ +/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */ +/* exported Slider */ + +const { Atk, Clutter, GObject } = imports.gi; + +const BarLevel = imports.ui.barLevel; + +var SLIDER_SCROLL_STEP = 0.02; /* Slider scrolling step in % */ + +var Slider = GObject.registerClass({ + Signals: { + 'drag-begin': {}, + 'drag-end': {}, + }, +}, class Slider extends BarLevel.BarLevel { + _init(value) { + super._init({ + value, + style_class: 'slider', + can_focus: true, + reactive: true, + accessible_role: Atk.Role.SLIDER, + x_expand: true, + }); + + this._releaseId = 0; + this._dragging = false; + + this._customAccessible.connect('get-minimum-increment', this._getMinimumIncrement.bind(this)); + } + + vfunc_repaint() { + super.vfunc_repaint(); + + // Add handle + let cr = this.get_context(); + let themeNode = this.get_theme_node(); + let [width, height] = this.get_surface_size(); + + let handleRadius = themeNode.get_length('-slider-handle-radius'); + + let handleBorderWidth = themeNode.get_length('-slider-handle-border-width'); + let [hasHandleColor, handleBorderColor] = + themeNode.lookup_color('-slider-handle-border-color', false); + + const ceiledHandleRadius = Math.ceil(handleRadius + handleBorderWidth); + const handleX = ceiledHandleRadius + + (width - 2 * ceiledHandleRadius) * this._value / this._maxValue; + const handleY = height / 2; + + let color = themeNode.get_foreground_color(); + Clutter.cairo_set_source_color(cr, color); + cr.arc(handleX, handleY, handleRadius, 0, 2 * Math.PI); + cr.fillPreserve(); + if (hasHandleColor && handleBorderWidth) { + Clutter.cairo_set_source_color(cr, handleBorderColor); + cr.setLineWidth(handleBorderWidth); + cr.stroke(); + } + cr.$dispose(); + } + + vfunc_button_press_event() { + return this.startDragging(Clutter.get_current_event()); + } + + startDragging(event) { + if (this._dragging) + return Clutter.EVENT_PROPAGATE; + + this._dragging = true; + + let device = event.get_device(); + let sequence = event.get_event_sequence(); + + if (sequence != null) + device.sequence_grab(sequence, this); + else + device.grab(this); + + this._grabbedDevice = device; + this._grabbedSequence = sequence; + + // We need to emit 'drag-begin' before moving the handle to make + // sure that no 'notify::value' signal is emitted before this one. + this.emit('drag-begin'); + + let absX, absY; + [absX, absY] = event.get_coords(); + this._moveHandle(absX, absY); + return Clutter.EVENT_STOP; + } + + _endDragging() { + if (this._dragging) { + if (this._releaseId) { + this.disconnect(this._releaseId); + this._releaseId = 0; + } + + if (this._grabbedSequence != null) + this._grabbedDevice.sequence_ungrab(this._grabbedSequence); + else + this._grabbedDevice.ungrab(); + + this._grabbedSequence = null; + this._grabbedDevice = null; + this._dragging = false; + + this.emit('drag-end'); + } + return Clutter.EVENT_STOP; + } + + vfunc_button_release_event() { + if (this._dragging && !this._grabbedSequence) + return this._endDragging(); + + return Clutter.EVENT_PROPAGATE; + } + + vfunc_touch_event() { + let event = Clutter.get_current_event(); + let device = event.get_device(); + let sequence = event.get_event_sequence(); + + if (!this._dragging && + event.type() == Clutter.EventType.TOUCH_BEGIN) { + this.startDragging(event); + return Clutter.EVENT_STOP; + } else if (device.sequence_get_grabbed_actor(sequence) == this) { + if (event.type() == Clutter.EventType.TOUCH_UPDATE) + return this._motionEvent(this, event); + else if (event.type() == Clutter.EventType.TOUCH_END) + return this._endDragging(); + } + + return Clutter.EVENT_PROPAGATE; + } + + scroll(event) { + let direction = event.get_scroll_direction(); + let delta; + + if (event.is_pointer_emulated()) + return Clutter.EVENT_PROPAGATE; + + if (direction == Clutter.ScrollDirection.DOWN) { + delta = -SLIDER_SCROLL_STEP; + } else if (direction == Clutter.ScrollDirection.UP) { + delta = SLIDER_SCROLL_STEP; + } else if (direction == Clutter.ScrollDirection.SMOOTH) { + let [, dy] = event.get_scroll_delta(); + // Even though the slider is horizontal, use dy to match + // the UP/DOWN above. + delta = -dy * SLIDER_SCROLL_STEP; + } + + this.value = Math.min(Math.max(0, this._value + delta), this._maxValue); + + return Clutter.EVENT_STOP; + } + + vfunc_scroll_event() { + return this.scroll(Clutter.get_current_event()); + } + + vfunc_motion_event() { + if (this._dragging && !this._grabbedSequence) + return this._motionEvent(this, Clutter.get_current_event()); + + return Clutter.EVENT_PROPAGATE; + } + + _motionEvent(actor, event) { + let absX, absY; + [absX, absY] = event.get_coords(); + this._moveHandle(absX, absY); + return Clutter.EVENT_STOP; + } + + vfunc_key_press_event(keyPressEvent) { + let key = keyPressEvent.keyval; + if (key == Clutter.KEY_Right || key == Clutter.KEY_Left) { + let delta = key == Clutter.KEY_Right ? 0.1 : -0.1; + this.value = Math.max(0, Math.min(this._value + delta, this._maxValue)); + return Clutter.EVENT_STOP; + } + return super.vfunc_key_press_event(keyPressEvent); + } + + _moveHandle(absX, _absY) { + let relX, sliderX; + [sliderX] = this.get_transformed_position(); + relX = absX - sliderX; + + let width = this._barLevelWidth; + let handleRadius = this.get_theme_node().get_length('-slider-handle-radius'); + + let newvalue; + if (relX < handleRadius) + newvalue = 0; + else if (relX > width - handleRadius) + newvalue = 1; + else + newvalue = (relX - handleRadius) / (width - 2 * handleRadius); + this.value = newvalue * this._maxValue; + } + + _getMinimumIncrement() { + return 0.1; + } +}); diff --git a/js/ui/status/accessibility.js b/js/ui/status/accessibility.js new file mode 100644 index 0000000..04406e2 --- /dev/null +++ b/js/ui/status/accessibility.js @@ -0,0 +1,200 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported ATIndicator */ + +const { Gio, GLib, GObject, St } = imports.gi; + +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; + +const A11Y_SCHEMA = 'org.gnome.desktop.a11y'; +const KEY_ALWAYS_SHOW = 'always-show-universal-access-status'; + +const A11Y_KEYBOARD_SCHEMA = 'org.gnome.desktop.a11y.keyboard'; +const KEY_STICKY_KEYS_ENABLED = 'stickykeys-enable'; +const KEY_BOUNCE_KEYS_ENABLED = 'bouncekeys-enable'; +const KEY_SLOW_KEYS_ENABLED = 'slowkeys-enable'; +const KEY_MOUSE_KEYS_ENABLED = 'mousekeys-enable'; + +const APPLICATIONS_SCHEMA = 'org.gnome.desktop.a11y.applications'; + +var DPI_FACTOR_LARGE = 1.25; + +const WM_SCHEMA = 'org.gnome.desktop.wm.preferences'; +const KEY_VISUAL_BELL = 'visual-bell'; + +const DESKTOP_INTERFACE_SCHEMA = 'org.gnome.desktop.interface'; +const KEY_GTK_THEME = 'gtk-theme'; +const KEY_ICON_THEME = 'icon-theme'; +const KEY_TEXT_SCALING_FACTOR = 'text-scaling-factor'; + +const HIGH_CONTRAST_THEME = 'HighContrast'; + +var ATIndicator = GObject.registerClass( +class ATIndicator extends PanelMenu.Button { + _init() { + super._init(0.5, _("Accessibility")); + + this._hbox = new St.BoxLayout({ style_class: 'panel-status-menu-box' }); + this._hbox.add_child(new St.Icon({ style_class: 'system-status-icon', + icon_name: 'preferences-desktop-accessibility-symbolic' })); + this._hbox.add_child(PopupMenu.arrowIcon(St.Side.BOTTOM)); + + this.add_child(this._hbox); + + this._a11ySettings = new Gio.Settings({ schema_id: A11Y_SCHEMA }); + this._a11ySettings.connect('changed::%s'.format(KEY_ALWAYS_SHOW), this._queueSyncMenuVisibility.bind(this)); + + let highContrast = this._buildHCItem(); + this.menu.addMenuItem(highContrast); + + let magnifier = this._buildItem(_("Zoom"), APPLICATIONS_SCHEMA, + 'screen-magnifier-enabled'); + this.menu.addMenuItem(magnifier); + + let textZoom = this._buildFontItem(); + this.menu.addMenuItem(textZoom); + + let screenReader = this._buildItem(_("Screen Reader"), APPLICATIONS_SCHEMA, + 'screen-reader-enabled'); + this.menu.addMenuItem(screenReader); + + let screenKeyboard = this._buildItem(_("Screen Keyboard"), APPLICATIONS_SCHEMA, + 'screen-keyboard-enabled'); + this.menu.addMenuItem(screenKeyboard); + + let visualBell = this._buildItem(_("Visual Alerts"), WM_SCHEMA, KEY_VISUAL_BELL); + this.menu.addMenuItem(visualBell); + + let stickyKeys = this._buildItem(_("Sticky Keys"), A11Y_KEYBOARD_SCHEMA, KEY_STICKY_KEYS_ENABLED); + this.menu.addMenuItem(stickyKeys); + + let slowKeys = this._buildItem(_("Slow Keys"), A11Y_KEYBOARD_SCHEMA, KEY_SLOW_KEYS_ENABLED); + this.menu.addMenuItem(slowKeys); + + let bounceKeys = this._buildItem(_("Bounce Keys"), A11Y_KEYBOARD_SCHEMA, KEY_BOUNCE_KEYS_ENABLED); + this.menu.addMenuItem(bounceKeys); + + let mouseKeys = this._buildItem(_("Mouse Keys"), A11Y_KEYBOARD_SCHEMA, KEY_MOUSE_KEYS_ENABLED); + this.menu.addMenuItem(mouseKeys); + + this._syncMenuVisibility(); + } + + _syncMenuVisibility() { + this._syncMenuVisibilityIdle = 0; + + let alwaysShow = this._a11ySettings.get_boolean(KEY_ALWAYS_SHOW); + let items = this.menu._getMenuItems(); + + this.visible = alwaysShow || items.some(f => !!f.state); + + return GLib.SOURCE_REMOVE; + } + + _queueSyncMenuVisibility() { + if (this._syncMenuVisibilityIdle) + return; + + this._syncMenuVisibilityIdle = GLib.idle_add(GLib.PRIORITY_DEFAULT, this._syncMenuVisibility.bind(this)); + GLib.Source.set_name_by_id(this._syncMenuVisibilityIdle, '[gnome-shell] this._syncMenuVisibility'); + } + + _buildItemExtended(string, initialValue, writable, onSet) { + let widget = new PopupMenu.PopupSwitchMenuItem(string, initialValue); + if (!writable) { + widget.reactive = false; + } else { + widget.connect('toggled', item => { + onSet(item.state); + }); + } + return widget; + } + + _buildItem(string, schema, key) { + let settings = new Gio.Settings({ schema_id: schema }); + let widget = this._buildItemExtended(string, + settings.get_boolean(key), + settings.is_writable(key), + enabled => settings.set_boolean(key, enabled)); + + settings.connect('changed::%s'.format(key), () => { + widget.setToggleState(settings.get_boolean(key)); + + this._queueSyncMenuVisibility(); + }); + + return widget; + } + + _buildHCItem() { + let interfaceSettings = new Gio.Settings({ schema_id: DESKTOP_INTERFACE_SCHEMA }); + let gtkTheme = interfaceSettings.get_string(KEY_GTK_THEME); + let iconTheme = interfaceSettings.get_string(KEY_ICON_THEME); + let hasHC = gtkTheme == HIGH_CONTRAST_THEME; + let highContrast = this._buildItemExtended( + _("High Contrast"), + hasHC, + interfaceSettings.is_writable(KEY_GTK_THEME) && + interfaceSettings.is_writable(KEY_ICON_THEME), + enabled => { + if (enabled) { + interfaceSettings.set_string(KEY_ICON_THEME, HIGH_CONTRAST_THEME); + interfaceSettings.set_string(KEY_GTK_THEME, HIGH_CONTRAST_THEME); + } else if (!hasHC) { + interfaceSettings.set_string(KEY_ICON_THEME, iconTheme); + interfaceSettings.set_string(KEY_GTK_THEME, gtkTheme); + } else { + interfaceSettings.reset(KEY_ICON_THEME); + interfaceSettings.reset(KEY_GTK_THEME); + } + }); + + interfaceSettings.connect('changed::%s'.format(KEY_GTK_THEME), () => { + let value = interfaceSettings.get_string(KEY_GTK_THEME); + if (value == HIGH_CONTRAST_THEME) { + highContrast.setToggleState(true); + } else { + highContrast.setToggleState(false); + gtkTheme = value; + } + + this._queueSyncMenuVisibility(); + }); + + interfaceSettings.connect('changed::%s'.format(KEY_ICON_THEME), () => { + let value = interfaceSettings.get_string(KEY_ICON_THEME); + if (value != HIGH_CONTRAST_THEME) + iconTheme = value; + }); + + return highContrast; + } + + _buildFontItem() { + let settings = new Gio.Settings({ schema_id: DESKTOP_INTERFACE_SCHEMA }); + let factor = settings.get_double(KEY_TEXT_SCALING_FACTOR); + let initialSetting = factor > 1.0; + let widget = this._buildItemExtended(_("Large Text"), + initialSetting, + settings.is_writable(KEY_TEXT_SCALING_FACTOR), + enabled => { + if (enabled) { + settings.set_double( + KEY_TEXT_SCALING_FACTOR, DPI_FACTOR_LARGE); + } else { + settings.reset(KEY_TEXT_SCALING_FACTOR); + } + }); + + settings.connect('changed::%s'.format(KEY_TEXT_SCALING_FACTOR), () => { + factor = settings.get_double(KEY_TEXT_SCALING_FACTOR); + let active = factor > 1.0; + widget.setToggleState(active); + + this._queueSyncMenuVisibility(); + }); + + return widget; + } +}); diff --git a/js/ui/status/bluetooth.js b/js/ui/status/bluetooth.js new file mode 100644 index 0000000..98ccc3d --- /dev/null +++ b/js/ui/status/bluetooth.js @@ -0,0 +1,158 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const { Gio, GLib, GnomeBluetooth, GObject } = imports.gi; + +const Main = imports.ui.main; +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const BUS_NAME = 'org.gnome.SettingsDaemon.Rfkill'; +const OBJECT_PATH = '/org/gnome/SettingsDaemon/Rfkill'; + +const RfkillManagerInterface = loadInterfaceXML('org.gnome.SettingsDaemon.Rfkill'); +const RfkillManagerProxy = Gio.DBusProxy.makeProxyWrapper(RfkillManagerInterface); + +const HAD_BLUETOOTH_DEVICES_SETUP = 'had-bluetooth-devices-setup'; + +var Indicator = GObject.registerClass( +class Indicator extends PanelMenu.SystemIndicator { + _init() { + super._init(); + + this._indicator = this._addIndicator(); + this._indicator.icon_name = 'bluetooth-active-symbolic'; + this._hadSetupDevices = global.settings.get_boolean(HAD_BLUETOOTH_DEVICES_SETUP); + + this._proxy = new RfkillManagerProxy(Gio.DBus.session, BUS_NAME, OBJECT_PATH, + (proxy, error) => { + if (error) { + log(error.message); + return; + } + + this._sync(); + }); + this._proxy.connect('g-properties-changed', this._queueSync.bind(this)); + + this._item = new PopupMenu.PopupSubMenuMenuItem(_("Bluetooth"), true); + this._item.icon.icon_name = 'bluetooth-active-symbolic'; + + this._toggleItem = new PopupMenu.PopupMenuItem(''); + this._toggleItem.connect('activate', () => { + this._proxy.BluetoothAirplaneMode = !this._proxy.BluetoothAirplaneMode; + }); + this._item.menu.addMenuItem(this._toggleItem); + + this._item.menu.addSettingsAction(_("Bluetooth Settings"), 'gnome-bluetooth-panel.desktop'); + this.menu.addMenuItem(this._item); + + this._syncId = 0; + this._adapter = null; + + this._client = new GnomeBluetooth.Client(); + this._model = this._client.get_model(); + this._model.connect('row-deleted', this._queueSync.bind(this)); + this._model.connect('row-changed', this._queueSync.bind(this)); + this._model.connect('row-inserted', this._sync.bind(this)); + Main.sessionMode.connect('updated', this._sync.bind(this)); + this._sync(); + } + + _setHadSetupDevices(value) { + if (this._hadSetupDevices === value) + return; + + this._hadSetupDevices = value; + global.settings.set_boolean( + HAD_BLUETOOTH_DEVICES_SETUP, this._hadSetupDevices); + } + + _getDefaultAdapter() { + let [ret, iter] = this._model.get_iter_first(); + while (ret) { + let isDefault = this._model.get_value(iter, + GnomeBluetooth.Column.DEFAULT); + let isPowered = this._model.get_value(iter, + GnomeBluetooth.Column.POWERED); + if (isDefault && isPowered) + return iter; + ret = this._model.iter_next(iter); + } + return null; + } + + _getDeviceInfos(adapter) { + if (!adapter) + return []; + + let deviceInfos = []; + let [ret, iter] = this._model.iter_children(adapter); + while (ret) { + const isPaired = this._model.get_value(iter, + GnomeBluetooth.Column.PAIRED); + const isTrusted = this._model.get_value(iter, + GnomeBluetooth.Column.TRUSTED); + + if (isPaired || isTrusted) { + deviceInfos.push({ + connected: this._model.get_value(iter, + GnomeBluetooth.Column.CONNECTED), + name: this._model.get_value(iter, + GnomeBluetooth.Column.ALIAS), + }); + } + + ret = this._model.iter_next(iter); + } + + return deviceInfos; + } + + _queueSync() { + if (this._syncId) + return; + this._syncId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this._syncId = 0; + this._sync(); + return GLib.SOURCE_REMOVE; + }); + } + + _sync() { + let adapter = this._getDefaultAdapter(); + let devices = this._getDeviceInfos(adapter); + const connectedDevices = devices.filter(dev => dev.connected); + const nConnectedDevices = connectedDevices.length; + + if (adapter && this._adapter) + this._setHadSetupDevices(devices.length > 0); + this._adapter = adapter; + + let sensitive = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter; + + this.menu.setSensitive(sensitive); + this._indicator.visible = nConnectedDevices > 0; + + // Remember if there were setup devices and show the menu + // if we've seen setup devices and we're not hard blocked + if (this._hadSetupDevices) + this._item.visible = !this._proxy.BluetoothHardwareAirplaneMode; + else + this._item.visible = this._proxy.BluetoothHasAirplaneMode && !this._proxy.BluetoothAirplaneMode; + + if (nConnectedDevices > 1) + /* Translators: this is the number of connected bluetooth devices */ + this._item.label.text = ngettext('%d Connected', '%d Connected', nConnectedDevices).format(nConnectedDevices); + else if (nConnectedDevices === 1) + this._item.label.text = connectedDevices[0].name; + else if (adapter === null) + this._item.label.text = _('Bluetooth Off'); + else + this._item.label.text = _('Bluetooth On'); + + this._toggleItem.label.text = this._proxy.BluetoothAirplaneMode ? _('Turn On') : _('Turn Off'); + } +}); diff --git a/js/ui/status/brightness.js b/js/ui/status/brightness.js new file mode 100644 index 0000000..1724788 --- /dev/null +++ b/js/ui/status/brightness.js @@ -0,0 +1,73 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const { Gio, GObject, St } = imports.gi; + +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; +const Slider = imports.ui.slider; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const BUS_NAME = 'org.gnome.SettingsDaemon.Power'; +const OBJECT_PATH = '/org/gnome/SettingsDaemon/Power'; + +const BrightnessInterface = loadInterfaceXML('org.gnome.SettingsDaemon.Power.Screen'); +const BrightnessProxy = Gio.DBusProxy.makeProxyWrapper(BrightnessInterface); + +var Indicator = GObject.registerClass( +class Indicator extends PanelMenu.SystemIndicator { + _init() { + super._init(); + this._proxy = new BrightnessProxy(Gio.DBus.session, BUS_NAME, OBJECT_PATH, + (proxy, error) => { + if (error) { + log(error.message); + return; + } + + this._proxy.connect('g-properties-changed', this._sync.bind(this)); + this._sync(); + }); + + this._item = new PopupMenu.PopupBaseMenuItem({ activate: false }); + this.menu.addMenuItem(this._item); + + this._slider = new Slider.Slider(0); + this._sliderChangedId = this._slider.connect('notify::value', + this._sliderChanged.bind(this)); + this._slider.accessible_name = _("Brightness"); + + let icon = new St.Icon({ icon_name: 'display-brightness-symbolic', + style_class: 'popup-menu-icon' }); + this._item.add(icon); + this._item.add_child(this._slider); + this._item.connect('button-press-event', (actor, event) => { + return this._slider.startDragging(event); + }); + this._item.connect('key-press-event', (actor, event) => { + return this._slider.emit('key-press-event', event); + }); + this._item.connect('scroll-event', (actor, event) => { + return this._slider.emit('scroll-event', event); + }); + } + + _sliderChanged() { + let percent = this._slider.value * 100; + this._proxy.Brightness = percent; + } + + _changeSlider(value) { + this._slider.block_signal_handler(this._sliderChangedId); + this._slider.value = value; + this._slider.unblock_signal_handler(this._sliderChangedId); + } + + _sync() { + let visible = this._proxy.Brightness >= 0; + this._item.visible = visible; + if (visible) + this._changeSlider(this._proxy.Brightness / 100.0); + } +}); diff --git a/js/ui/status/dwellClick.js b/js/ui/status/dwellClick.js new file mode 100644 index 0000000..ce13f73 --- /dev/null +++ b/js/ui/status/dwellClick.js @@ -0,0 +1,86 @@ +/* exported DwellClickIndicator */ +const { Clutter, Gio, GLib, GObject, St } = imports.gi; + +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; + +const MOUSE_A11Y_SCHEMA = 'org.gnome.desktop.a11y.mouse'; +const KEY_DWELL_CLICK_ENABLED = 'dwell-click-enabled'; +const KEY_DWELL_MODE = 'dwell-mode'; +const DWELL_MODE_WINDOW = 'window'; +const DWELL_CLICK_MODES = { + primary: { + name: _("Single Click"), + icon: 'pointer-primary-click-symbolic', + type: Clutter.PointerA11yDwellClickType.PRIMARY, + }, + double: { + name: _("Double Click"), + icon: 'pointer-double-click-symbolic', + type: Clutter.PointerA11yDwellClickType.DOUBLE, + }, + drag: { + name: _("Drag"), + icon: 'pointer-drag-symbolic', + type: Clutter.PointerA11yDwellClickType.DRAG, + }, + secondary: { + name: _("Secondary Click"), + icon: 'pointer-secondary-click-symbolic', + type: Clutter.PointerA11yDwellClickType.SECONDARY, + }, +}; + +var DwellClickIndicator = GObject.registerClass( +class DwellClickIndicator extends PanelMenu.Button { + _init() { + super._init(0.5, _("Dwell Click")); + + this._hbox = new St.BoxLayout({ style_class: 'panel-status-menu-box' }); + this._icon = new St.Icon({ style_class: 'system-status-icon', + icon_name: 'pointer-primary-click-symbolic' }); + this._hbox.add_child(this._icon); + this._hbox.add_child(PopupMenu.arrowIcon(St.Side.BOTTOM)); + + this.add_child(this._hbox); + + this._a11ySettings = new Gio.Settings({ schema_id: MOUSE_A11Y_SCHEMA }); + this._a11ySettings.connect('changed::%s'.format(KEY_DWELL_CLICK_ENABLED), this._syncMenuVisibility.bind(this)); + this._a11ySettings.connect('changed::%s'.format(KEY_DWELL_MODE), this._syncMenuVisibility.bind(this)); + + this._seat = Clutter.get_default_backend().get_default_seat(); + this._seat.connect('ptr-a11y-dwell-click-type-changed', this._updateClickType.bind(this)); + + this._addDwellAction(DWELL_CLICK_MODES.primary); + this._addDwellAction(DWELL_CLICK_MODES.double); + this._addDwellAction(DWELL_CLICK_MODES.drag); + this._addDwellAction(DWELL_CLICK_MODES.secondary); + + this._setClickType(DWELL_CLICK_MODES.primary); + this._syncMenuVisibility(); + } + + _syncMenuVisibility() { + this.visible = + this._a11ySettings.get_boolean(KEY_DWELL_CLICK_ENABLED) && + this._a11ySettings.get_string(KEY_DWELL_MODE) == DWELL_MODE_WINDOW; + + return GLib.SOURCE_REMOVE; + } + + _addDwellAction(mode) { + this.menu.addAction(mode.name, this._setClickType.bind(this, mode), mode.icon); + } + + _updateClickType(manager, clickType) { + for (let mode in DWELL_CLICK_MODES) { + if (DWELL_CLICK_MODES[mode].type == clickType) + this._icon.icon_name = DWELL_CLICK_MODES[mode].icon; + } + } + + _setClickType(mode) { + this._seat.set_pointer_a11y_dwell_click_type(mode.type); + this._icon.icon_name = mode.icon; + } +}); diff --git a/js/ui/status/keyboard.js b/js/ui/status/keyboard.js new file mode 100644 index 0000000..43fd89d --- /dev/null +++ b/js/ui/status/keyboard.js @@ -0,0 +1,1079 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported InputSourceIndicator */ + +const { Clutter, Gio, GLib, GObject, IBus, Meta, Shell, St } = imports.gi; +const Gettext = imports.gettext; +const Signals = imports.signals; + +const IBusManager = imports.misc.ibusManager; +const KeyboardManager = imports.misc.keyboardManager; +const Main = imports.ui.main; +const PopupMenu = imports.ui.popupMenu; +const PanelMenu = imports.ui.panelMenu; +const SwitcherPopup = imports.ui.switcherPopup; +const Util = imports.misc.util; + +var INPUT_SOURCE_TYPE_XKB = 'xkb'; +var INPUT_SOURCE_TYPE_IBUS = 'ibus'; + +var LayoutMenuItem = GObject.registerClass( +class LayoutMenuItem extends PopupMenu.PopupBaseMenuItem { + _init(displayName, shortName) { + super._init(); + + this.label = new St.Label({ + text: displayName, + x_expand: true, + }); + this.indicator = new St.Label({ text: shortName }); + this.add_child(this.label); + this.add(this.indicator); + this.label_actor = this.label; + } +}); + +var InputSource = class { + constructor(type, id, displayName, shortName, index) { + this.type = type; + this.id = id; + this.displayName = displayName; + this._shortName = shortName; + this.index = index; + + this.properties = null; + + this.xkbId = this._getXkbId(); + } + + get shortName() { + return this._shortName; + } + + set shortName(v) { + this._shortName = v; + this.emit('changed'); + } + + activate(interactive) { + this.emit('activate', !!interactive); + } + + _getXkbId() { + let engineDesc = IBusManager.getIBusManager().getEngineDesc(this.id); + if (!engineDesc) + return this.id; + + if (engineDesc.variant && engineDesc.variant.length > 0) + return '%s+%s'.format(engineDesc.layout, engineDesc.variant); + else + return engineDesc.layout; + } +}; +Signals.addSignalMethods(InputSource.prototype); + +var InputSourcePopup = GObject.registerClass( +class InputSourcePopup extends SwitcherPopup.SwitcherPopup { + _init(items, action, actionBackward) { + super._init(items); + + this._action = action; + this._actionBackward = actionBackward; + + this._switcherList = new InputSourceSwitcher(this._items); + } + + _keyPressHandler(keysym, action) { + if (action == this._action) + this._select(this._next()); + else if (action == this._actionBackward) + this._select(this._previous()); + else if (keysym == Clutter.KEY_Left) + this._select(this._previous()); + else if (keysym == Clutter.KEY_Right) + this._select(this._next()); + else + return Clutter.EVENT_PROPAGATE; + + return Clutter.EVENT_STOP; + } + + _finish() { + super._finish(); + + this._items[this._selectedIndex].activate(true); + } +}); + +var InputSourceSwitcher = GObject.registerClass( +class InputSourceSwitcher extends SwitcherPopup.SwitcherList { + _init(items) { + super._init(true); + + for (let i = 0; i < items.length; i++) + this._addIcon(items[i]); + } + + _addIcon(item) { + let box = new St.BoxLayout({ vertical: true }); + + let bin = new St.Bin({ style_class: 'input-source-switcher-symbol' }); + let symbol = new St.Label({ + text: item.shortName, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + bin.set_child(symbol); + box.add_child(bin); + + let text = new St.Label({ + text: item.displayName, + x_align: Clutter.ActorAlign.CENTER, + }); + box.add_child(text); + + this.addItem(box, text); + } +}); + +var InputSourceSettings = class { + constructor() { + if (this.constructor === InputSourceSettings) + throw new TypeError('Cannot instantiate abstract class %s'.format(this.constructor.name)); + } + + _emitInputSourcesChanged() { + this.emit('input-sources-changed'); + } + + _emitKeyboardOptionsChanged() { + this.emit('keyboard-options-changed'); + } + + _emitPerWindowChanged() { + this.emit('per-window-changed'); + } + + get inputSources() { + return []; + } + + get mruSources() { + return []; + } + + set mruSources(sourcesList) { + // do nothing + } + + get keyboardOptions() { + return []; + } + + get perWindow() { + return false; + } +}; +Signals.addSignalMethods(InputSourceSettings.prototype); + +var InputSourceSystemSettings = class extends InputSourceSettings { + constructor() { + super(); + + this._BUS_NAME = 'org.freedesktop.locale1'; + this._BUS_PATH = '/org/freedesktop/locale1'; + this._BUS_IFACE = 'org.freedesktop.locale1'; + this._BUS_PROPS_IFACE = 'org.freedesktop.DBus.Properties'; + + this._layouts = ''; + this._variants = ''; + this._options = ''; + + this._reload(); + + Gio.DBus.system.signal_subscribe(this._BUS_NAME, + this._BUS_PROPS_IFACE, + 'PropertiesChanged', + this._BUS_PATH, + null, + Gio.DBusSignalFlags.NONE, + this._reload.bind(this)); + } + + async _reload() { + let props; + try { + const result = await Gio.DBus.system.call( + this._BUS_NAME, + this._BUS_PATH, + this._BUS_PROPS_IFACE, + 'GetAll', + new GLib.Variant('(s)', [this._BUS_IFACE]), + null, Gio.DBusCallFlags.NONE, -1, null); + [props] = result.deep_unpack(); + } catch (e) { + log('Could not get properties from %s'.format(this._BUS_NAME)); + return; + } + + const layouts = props['X11Layout'].unpack(); + const variants = props['X11Variant'].unpack(); + const options = props['X11Options'].unpack(); + + if (layouts !== this._layouts || + variants !== this._variants) { + this._layouts = layouts; + this._variants = variants; + this._emitInputSourcesChanged(); + } + if (options !== this._options) { + this._options = options; + this._emitKeyboardOptionsChanged(); + } + } + + get inputSources() { + let sourcesList = []; + let layouts = this._layouts.split(','); + let variants = this._variants.split(','); + + for (let i = 0; i < layouts.length && !!layouts[i]; i++) { + let id = layouts[i]; + if (variants[i]) + id += '+%s'.format(variants[i]); + sourcesList.push({ type: INPUT_SOURCE_TYPE_XKB, id }); + } + return sourcesList; + } + + get keyboardOptions() { + return this._options.split(','); + } +}; + +var InputSourceSessionSettings = class extends InputSourceSettings { + constructor() { + super(); + + this._DESKTOP_INPUT_SOURCES_SCHEMA = 'org.gnome.desktop.input-sources'; + this._KEY_INPUT_SOURCES = 'sources'; + this._KEY_MRU_SOURCES = 'mru-sources'; + this._KEY_KEYBOARD_OPTIONS = 'xkb-options'; + this._KEY_PER_WINDOW = 'per-window'; + + this._settings = new Gio.Settings({ schema_id: this._DESKTOP_INPUT_SOURCES_SCHEMA }); + this._settings.connect('changed::%s'.format(this._KEY_INPUT_SOURCES), this._emitInputSourcesChanged.bind(this)); + this._settings.connect('changed::%s'.format(this._KEY_KEYBOARD_OPTIONS), this._emitKeyboardOptionsChanged.bind(this)); + this._settings.connect('changed::%s'.format(this._KEY_PER_WINDOW), this._emitPerWindowChanged.bind(this)); + } + + _getSourcesList(key) { + let sourcesList = []; + let sources = this._settings.get_value(key); + let nSources = sources.n_children(); + + for (let i = 0; i < nSources; i++) { + let [type, id] = sources.get_child_value(i).deep_unpack(); + sourcesList.push({ type, id }); + } + return sourcesList; + } + + get inputSources() { + return this._getSourcesList(this._KEY_INPUT_SOURCES); + } + + get mruSources() { + return this._getSourcesList(this._KEY_MRU_SOURCES); + } + + set mruSources(sourcesList) { + let sources = GLib.Variant.new('a(ss)', sourcesList); + this._settings.set_value(this._KEY_MRU_SOURCES, sources); + } + + get keyboardOptions() { + return this._settings.get_strv(this._KEY_KEYBOARD_OPTIONS); + } + + get perWindow() { + return this._settings.get_boolean(this._KEY_PER_WINDOW); + } +}; + +var InputSourceManager = class { + constructor() { + // All valid input sources currently in the gsettings + // KEY_INPUT_SOURCES list indexed by their index there + this._inputSources = {}; + // All valid input sources currently in the gsettings + // KEY_INPUT_SOURCES list of type INPUT_SOURCE_TYPE_IBUS + // indexed by the IBus ID + this._ibusSources = {}; + + this._currentSource = null; + + // All valid input sources currently in the gsettings + // KEY_INPUT_SOURCES list ordered by most recently used + this._mruSources = []; + this._mruSourcesBackup = null; + this._keybindingAction = + Main.wm.addKeybinding('switch-input-source', + new Gio.Settings({ schema_id: "org.gnome.desktop.wm.keybindings" }), + Meta.KeyBindingFlags.NONE, + Shell.ActionMode.ALL, + this._switchInputSource.bind(this)); + this._keybindingActionBackward = + Main.wm.addKeybinding('switch-input-source-backward', + new Gio.Settings({ schema_id: "org.gnome.desktop.wm.keybindings" }), + Meta.KeyBindingFlags.IS_REVERSED, + Shell.ActionMode.ALL, + this._switchInputSource.bind(this)); + if (Main.sessionMode.isGreeter) + this._settings = new InputSourceSystemSettings(); + else + this._settings = new InputSourceSessionSettings(); + this._settings.connect('input-sources-changed', this._inputSourcesChanged.bind(this)); + this._settings.connect('keyboard-options-changed', this._keyboardOptionsChanged.bind(this)); + + this._xkbInfo = KeyboardManager.getXkbInfo(); + this._keyboardManager = KeyboardManager.getKeyboardManager(); + + this._ibusReady = false; + this._ibusManager = IBusManager.getIBusManager(); + this._ibusManager.connect('ready', this._ibusReadyCallback.bind(this)); + this._ibusManager.connect('properties-registered', this._ibusPropertiesRegistered.bind(this)); + this._ibusManager.connect('property-updated', this._ibusPropertyUpdated.bind(this)); + this._ibusManager.connect('set-content-type', this._ibusSetContentType.bind(this)); + + global.display.connect('modifiers-accelerator-activated', this._modifiersSwitcher.bind(this)); + + this._sourcesPerWindow = false; + this._focusWindowNotifyId = 0; + this._overviewShowingId = 0; + this._overviewHiddenId = 0; + this._settings.connect('per-window-changed', this._sourcesPerWindowChanged.bind(this)); + this._sourcesPerWindowChanged(); + this._disableIBus = false; + this._reloading = false; + } + + reload() { + this._reloading = true; + this._keyboardManager.setKeyboardOptions(this._settings.keyboardOptions); + this._inputSourcesChanged(); + this._reloading = false; + } + + _ibusReadyCallback(im, ready) { + if (this._ibusReady == ready) + return; + + this._ibusReady = ready; + this._mruSources = []; + this._inputSourcesChanged(); + } + + _modifiersSwitcher() { + let sourceIndexes = Object.keys(this._inputSources); + if (sourceIndexes.length == 0) { + KeyboardManager.releaseKeyboard(); + return true; + } + + let is = this._currentSource; + if (!is) + is = this._inputSources[sourceIndexes[0]]; + + let nextIndex = is.index + 1; + if (nextIndex > sourceIndexes[sourceIndexes.length - 1]) + nextIndex = 0; + + while (!(is = this._inputSources[nextIndex])) + nextIndex += 1; + + is.activate(true); + return true; + } + + _switchInputSource(display, window, binding) { + if (this._mruSources.length < 2) + return; + + // HACK: Fall back on simple input source switching since we + // can't show a popup switcher while a GrabHelper grab is in + // effect without considerable work to consolidate the usage + // of pushModal/popModal and grabHelper. See + // https://bugzilla.gnome.org/show_bug.cgi?id=695143 . + if (Main.actionMode == Shell.ActionMode.POPUP) { + this._modifiersSwitcher(); + return; + } + + let popup = new InputSourcePopup(this._mruSources, this._keybindingAction, this._keybindingActionBackward); + if (!popup.show(binding.is_reversed(), binding.get_name(), binding.get_mask())) + popup.fadeAndDestroy(); + } + + _keyboardOptionsChanged() { + this._keyboardManager.setKeyboardOptions(this._settings.keyboardOptions); + this._keyboardManager.reapply(); + } + + _updateMruSettings() { + // If IBus is not ready we don't have a full picture of all + // the available sources, so don't update the setting + if (!this._ibusReady) + return; + + // If IBus is temporarily disabled, don't update the setting + if (this._disableIBus) + return; + + let sourcesList = []; + for (let i = 0; i < this._mruSources.length; ++i) { + let source = this._mruSources[i]; + sourcesList.push([source.type, source.id]); + } + + this._settings.mruSources = sourcesList; + } + + _currentInputSourceChanged(newSource) { + let oldSource; + [oldSource, this._currentSource] = [this._currentSource, newSource]; + + this.emit('current-source-changed', oldSource); + + for (let i = 1; i < this._mruSources.length; ++i) { + if (this._mruSources[i] == newSource) { + let currentSource = this._mruSources.splice(i, 1); + this._mruSources = currentSource.concat(this._mruSources); + break; + } + } + this._changePerWindowSource(); + } + + activateInputSource(is, interactive) { + // The focus changes during holdKeyboard/releaseKeyboard may trick + // the client into hiding UI containing the currently focused entry. + // So holdKeyboard/releaseKeyboard are not called when + // 'set-content-type' signal is received. + // E.g. Focusing on a password entry in a popup in Xorg Firefox + // will emit 'set-content-type' signal. + // https://gitlab.gnome.org/GNOME/gnome-shell/issues/391 + if (!this._reloading) + KeyboardManager.holdKeyboard(); + this._keyboardManager.apply(is.xkbId); + + // All the "xkb:..." IBus engines simply "echo" back symbols, + // despite their naming implying differently, so we always set + // one in order for XIM applications to work given that we set + // XMODIFIERS=@im=ibus in the first place so that they can + // work without restarting when/if the user adds an IBus input + // source. + let engine; + if (is.type == INPUT_SOURCE_TYPE_IBUS) + engine = is.id; + else + engine = 'xkb:us::eng'; + + if (!this._reloading) + this._ibusManager.setEngine(engine, KeyboardManager.releaseKeyboard); + else + this._ibusManager.setEngine(engine); + this._currentInputSourceChanged(is); + + if (interactive) + this._updateMruSettings(); + } + + _updateMruSources() { + let sourcesList = []; + for (let i in this._inputSources) + sourcesList.push(this._inputSources[i]); + + this._keyboardManager.setUserLayouts(sourcesList.map(x => x.xkbId)); + + if (!this._disableIBus && this._mruSourcesBackup) { + this._mruSources = this._mruSourcesBackup; + this._mruSourcesBackup = null; + } + + // Initialize from settings when we have no MRU sources list + if (this._mruSources.length == 0) { + let mruSettings = this._settings.mruSources; + for (let i = 0; i < mruSettings.length; i++) { + let mruSettingSource = mruSettings[i]; + let mruSource = null; + + for (let j = 0; j < sourcesList.length; j++) { + let source = sourcesList[j]; + if (source.type == mruSettingSource.type && + source.id == mruSettingSource.id) { + mruSource = source; + break; + } + } + + if (mruSource) + this._mruSources.push(mruSource); + } + } + + let mruSources = []; + for (let i = 0; i < this._mruSources.length; i++) { + for (let j = 0; j < sourcesList.length; j++) { + if (this._mruSources[i].type == sourcesList[j].type && + this._mruSources[i].id == sourcesList[j].id) { + mruSources = mruSources.concat(sourcesList.splice(j, 1)); + break; + } + } + } + this._mruSources = mruSources.concat(sourcesList); + } + + _inputSourcesChanged() { + let sources = this._settings.inputSources; + let nSources = sources.length; + + this._currentSource = null; + this._inputSources = {}; + this._ibusSources = {}; + + let infosList = []; + for (let i = 0; i < nSources; i++) { + let displayName; + let shortName; + let type = sources[i].type; + let id = sources[i].id; + let exists = false; + + if (type == INPUT_SOURCE_TYPE_XKB) { + [exists, displayName, shortName] = + this._xkbInfo.get_layout_info(id); + } else if (type == INPUT_SOURCE_TYPE_IBUS) { + if (this._disableIBus) + continue; + let engineDesc = this._ibusManager.getEngineDesc(id); + if (engineDesc) { + let language = IBus.get_language_name(engineDesc.get_language()); + let longName = engineDesc.get_longname(); + let textdomain = engineDesc.get_textdomain(); + if (textdomain != '') + longName = Gettext.dgettext(textdomain, longName); + exists = true; + displayName = '%s (%s)'.format(language, longName); + shortName = this._makeEngineShortName(engineDesc); + } + } + + if (exists) + infosList.push({ type, id, displayName, shortName }); + } + + if (infosList.length == 0) { + let type = INPUT_SOURCE_TYPE_XKB; + let id = KeyboardManager.DEFAULT_LAYOUT; + let [, displayName, shortName] = this._xkbInfo.get_layout_info(id); + infosList.push({ type, id, displayName, shortName }); + } + + let inputSourcesByShortName = {}; + for (let i = 0; i < infosList.length; i++) { + let is = new InputSource(infosList[i].type, + infosList[i].id, + infosList[i].displayName, + infosList[i].shortName, + i); + is.connect('activate', this.activateInputSource.bind(this)); + + if (!(is.shortName in inputSourcesByShortName)) + inputSourcesByShortName[is.shortName] = []; + inputSourcesByShortName[is.shortName].push(is); + + this._inputSources[is.index] = is; + + if (is.type == INPUT_SOURCE_TYPE_IBUS) + this._ibusSources[is.id] = is; + } + + for (let i in this._inputSources) { + let is = this._inputSources[i]; + if (inputSourcesByShortName[is.shortName].length > 1) { + let sub = inputSourcesByShortName[is.shortName].indexOf(is) + 1; + is.shortName += String.fromCharCode(0x2080 + sub); + } + } + + this.emit('sources-changed'); + + this._updateMruSources(); + + if (this._mruSources.length > 0) + this._mruSources[0].activate(false); + + // All ibus engines are preloaded here to reduce the launching time + // when users switch the input sources. + this._ibusManager.preloadEngines(Object.keys(this._ibusSources)); + } + + _makeEngineShortName(engineDesc) { + let symbol = engineDesc.get_symbol(); + if (symbol && symbol[0]) + return symbol; + + let langCode = engineDesc.get_language().split('_', 1)[0]; + if (langCode.length == 2 || langCode.length == 3) + return langCode.toLowerCase(); + + return String.fromCharCode(0x2328); // keyboard glyph + } + + _ibusPropertiesRegistered(im, engineName, props) { + let source = this._ibusSources[engineName]; + if (!source) + return; + + source.properties = props; + + if (source == this._currentSource) + this.emit('current-source-changed', null); + } + + _ibusPropertyUpdated(im, engineName, prop) { + let source = this._ibusSources[engineName]; + if (!source) + return; + + if (this._updateSubProperty(source.properties, prop) && + source == this._currentSource) + this.emit('current-source-changed', null); + } + + _updateSubProperty(props, prop) { + if (!props) + return false; + + let p; + for (let i = 0; (p = props.get(i)) != null; ++i) { + if (p.get_key() == prop.get_key() && p.get_prop_type() == prop.get_prop_type()) { + p.update(prop); + return true; + } else if (p.get_prop_type() == IBus.PropType.MENU) { + if (this._updateSubProperty(p.get_sub_props(), prop)) + return true; + } + } + return false; + } + + _ibusSetContentType(im, purpose, _hints) { + if (purpose == IBus.InputPurpose.PASSWORD) { + if (Object.keys(this._inputSources).length == Object.keys(this._ibusSources).length) + return; + + if (this._disableIBus) + return; + this._disableIBus = true; + this._mruSourcesBackup = this._mruSources.slice(); + } else { + if (!this._disableIBus) + return; + this._disableIBus = false; + } + this.reload(); + } + + _getNewInputSource(current) { + let sourceIndexes = Object.keys(this._inputSources); + if (sourceIndexes.length == 0) + return null; + + if (current) { + for (let i in this._inputSources) { + let is = this._inputSources[i]; + if (is.type == current.type && + is.id == current.id) + return is; + } + } + + return this._inputSources[sourceIndexes[0]]; + } + + _getCurrentWindow() { + if (Main.overview.visible) + return Main.overview; + else + return global.display.focus_window; + } + + _setPerWindowInputSource() { + let window = this._getCurrentWindow(); + if (!window) + return; + + if (!window._inputSources || + window._inputSources !== this._inputSources) { + window._inputSources = this._inputSources; + window._currentSource = this._getNewInputSource(window._currentSource); + } + + if (window._currentSource) + window._currentSource.activate(false); + } + + _sourcesPerWindowChanged() { + this._sourcesPerWindow = this._settings.perWindow; + + if (this._sourcesPerWindow && this._focusWindowNotifyId == 0) { + this._focusWindowNotifyId = global.display.connect('notify::focus-window', + this._setPerWindowInputSource.bind(this)); + this._overviewShowingId = Main.overview.connect('showing', + this._setPerWindowInputSource.bind(this)); + this._overviewHiddenId = Main.overview.connect('hidden', + this._setPerWindowInputSource.bind(this)); + } else if (!this._sourcesPerWindow && this._focusWindowNotifyId != 0) { + global.display.disconnect(this._focusWindowNotifyId); + this._focusWindowNotifyId = 0; + Main.overview.disconnect(this._overviewShowingId); + this._overviewShowingId = 0; + Main.overview.disconnect(this._overviewHiddenId); + this._overviewHiddenId = 0; + + let windows = global.get_window_actors().map(w => w.meta_window); + for (let i = 0; i < windows.length; ++i) { + delete windows[i]._inputSources; + delete windows[i]._currentSource; + } + delete Main.overview._inputSources; + delete Main.overview._currentSource; + } + } + + _changePerWindowSource() { + if (!this._sourcesPerWindow) + return; + + let window = this._getCurrentWindow(); + if (!window) + return; + + window._inputSources = this._inputSources; + window._currentSource = this._currentSource; + } + + get currentSource() { + return this._currentSource; + } + + get inputSources() { + return this._inputSources; + } +}; +Signals.addSignalMethods(InputSourceManager.prototype); + +let _inputSourceManager = null; + +function getInputSourceManager() { + if (_inputSourceManager == null) + _inputSourceManager = new InputSourceManager(); + return _inputSourceManager; +} + +var InputSourceIndicatorContainer = GObject.registerClass( +class InputSourceIndicatorContainer extends St.Widget { + + vfunc_get_preferred_width(forHeight) { + // Here, and in vfunc_get_preferred_height, we need to query + // for the height of all children, but we ignore the results + // for those we don't actually display. + return this.get_children().reduce((maxWidth, child) => { + let width = child.get_preferred_width(forHeight); + return [Math.max(maxWidth[0], width[0]), + Math.max(maxWidth[1], width[1])]; + }, [0, 0]); + } + + vfunc_get_preferred_height(forWidth) { + return this.get_children().reduce((maxHeight, child) => { + let height = child.get_preferred_height(forWidth); + return [Math.max(maxHeight[0], height[0]), + Math.max(maxHeight[1], height[1])]; + }, [0, 0]); + } + + vfunc_allocate(box) { + this.set_allocation(box); + + // translate box to (0, 0) + box.x2 -= box.x1; + box.x1 = 0; + box.y2 -= box.y1; + box.y1 = 0; + + this.get_children().forEach(c => { + c.allocate_align_fill(box, 0.5, 0.5, false, false); + }); + } +}); + +var InputSourceIndicator = GObject.registerClass( +class InputSourceIndicator extends PanelMenu.Button { + _init() { + super._init(0.5, _("Keyboard")); + + this.connect('destroy', this._onDestroy.bind(this)); + + this._menuItems = {}; + this._indicatorLabels = {}; + + this._container = new InputSourceIndicatorContainer(); + + this._hbox = new St.BoxLayout({ style_class: 'panel-status-menu-box' }); + this._hbox.add_child(this._container); + this._hbox.add_child(PopupMenu.arrowIcon(St.Side.BOTTOM)); + + this.add_child(this._hbox); + + this._propSeparator = new PopupMenu.PopupSeparatorMenuItem(); + this.menu.addMenuItem(this._propSeparator); + this._propSection = new PopupMenu.PopupMenuSection(); + this.menu.addMenuItem(this._propSection); + this._propSection.actor.hide(); + + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + this._showLayoutItem = this.menu.addAction(_("Show Keyboard Layout"), this._showLayout.bind(this)); + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + this._sessionUpdated(); + + this._inputSourceManager = getInputSourceManager(); + this._inputSourceManagerSourcesChangedId = + this._inputSourceManager.connect('sources-changed', this._sourcesChanged.bind(this)); + this._inputSourceManagerCurrentSourceChangedId = + this._inputSourceManager.connect('current-source-changed', this._currentSourceChanged.bind(this)); + this._inputSourceManager.reload(); + } + + _onDestroy() { + if (this._inputSourceManager) { + this._inputSourceManager.disconnect(this._inputSourceManagerSourcesChangedId); + this._inputSourceManager.disconnect(this._inputSourceManagerCurrentSourceChangedId); + this._inputSourceManager = null; + } + } + + _sessionUpdated() { + // re-using "allowSettings" for the keyboard layout is a bit shady, + // but at least for now it is used as "allow popping up windows + // from shell menus"; we can always add a separate sessionMode + // option if need arises. + this._showLayoutItem.visible = Main.sessionMode.allowSettings; + } + + _sourcesChanged() { + for (let i in this._menuItems) + this._menuItems[i].destroy(); + for (let i in this._indicatorLabels) + this._indicatorLabels[i].destroy(); + + this._menuItems = {}; + this._indicatorLabels = {}; + + let menuIndex = 0; + for (let i in this._inputSourceManager.inputSources) { + let is = this._inputSourceManager.inputSources[i]; + + let menuItem = new LayoutMenuItem(is.displayName, is.shortName); + menuItem.connect('activate', () => is.activate(true)); + + let indicatorLabel = new St.Label({ text: is.shortName, + visible: false }); + + this._menuItems[i] = menuItem; + this._indicatorLabels[i] = indicatorLabel; + is.connect('changed', () => { + menuItem.indicator.set_text(is.shortName); + indicatorLabel.set_text(is.shortName); + }); + + this.menu.addMenuItem(menuItem, menuIndex++); + this._container.add_actor(indicatorLabel); + } + } + + _currentSourceChanged(manager, oldSource) { + let nVisibleSources = Object.keys(this._inputSourceManager.inputSources).length; + let newSource = this._inputSourceManager.currentSource; + + if (oldSource) { + this._menuItems[oldSource.index].setOrnament(PopupMenu.Ornament.NONE); + this._indicatorLabels[oldSource.index].hide(); + } + + if (!newSource || (nVisibleSources < 2 && !newSource.properties)) { + // This source index might be invalid if we weren't able + // to build a menu item for it, so we hide ourselves since + // we can't fix it here. *shrug* + + // We also hide if we have only one visible source unless + // it's an IBus source with properties. + this.menu.close(); + this.hide(); + return; + } + + this.show(); + + this._buildPropSection(newSource.properties); + + this._menuItems[newSource.index].setOrnament(PopupMenu.Ornament.DOT); + this._indicatorLabels[newSource.index].show(); + } + + _buildPropSection(properties) { + this._propSeparator.hide(); + this._propSection.actor.hide(); + this._propSection.removeAll(); + + this._buildPropSubMenu(this._propSection, properties); + + if (!this._propSection.isEmpty()) { + this._propSection.actor.show(); + this._propSeparator.show(); + } + } + + _buildPropSubMenu(menu, props) { + if (!props) + return; + + let ibusManager = IBusManager.getIBusManager(); + let radioGroup = []; + let p; + for (let i = 0; (p = props.get(i)) != null; ++i) { + let prop = p; + + if (!prop.get_visible()) + continue; + + if (prop.get_key() == 'InputMode') { + let text; + if (prop.get_symbol) + text = prop.get_symbol().get_text(); + else + text = prop.get_label().get_text(); + + let currentSource = this._inputSourceManager.currentSource; + if (currentSource) { + let indicatorLabel = this._indicatorLabels[currentSource.index]; + if (text && text.length > 0 && text.length < 3) + indicatorLabel.set_text(text); + } + } + + let item; + let type = prop.get_prop_type(); + switch (type) { + case IBus.PropType.MENU: + item = new PopupMenu.PopupSubMenuMenuItem(prop.get_label().get_text()); + this._buildPropSubMenu(item.menu, prop.get_sub_props()); + break; + + case IBus.PropType.RADIO: + item = new PopupMenu.PopupMenuItem(prop.get_label().get_text()); + item.prop = prop; + radioGroup.push(item); + item.radioGroup = radioGroup; + item.setOrnament(prop.get_state() == IBus.PropState.CHECKED + ? PopupMenu.Ornament.DOT : PopupMenu.Ornament.NONE); + item.connect('activate', () => { + if (item.prop.get_state() == IBus.PropState.CHECKED) + return; + + let group = item.radioGroup; + for (let j = 0; j < group.length; ++j) { + if (group[j] == item) { + item.setOrnament(PopupMenu.Ornament.DOT); + item.prop.set_state(IBus.PropState.CHECKED); + ibusManager.activateProperty(item.prop.get_key(), + IBus.PropState.CHECKED); + } else { + group[j].setOrnament(PopupMenu.Ornament.NONE); + group[j].prop.set_state(IBus.PropState.UNCHECKED); + ibusManager.activateProperty(group[j].prop.get_key(), + IBus.PropState.UNCHECKED); + } + } + }); + break; + + case IBus.PropType.TOGGLE: + item = new PopupMenu.PopupSwitchMenuItem(prop.get_label().get_text(), prop.get_state() == IBus.PropState.CHECKED); + item.prop = prop; + item.connect('toggled', () => { + if (item.state) { + item.prop.set_state(IBus.PropState.CHECKED); + ibusManager.activateProperty(item.prop.get_key(), + IBus.PropState.CHECKED); + } else { + item.prop.set_state(IBus.PropState.UNCHECKED); + ibusManager.activateProperty(item.prop.get_key(), + IBus.PropState.UNCHECKED); + } + }); + break; + + case IBus.PropType.NORMAL: + item = new PopupMenu.PopupMenuItem(prop.get_label().get_text()); + item.prop = prop; + item.connect('activate', () => { + ibusManager.activateProperty(item.prop.get_key(), + item.prop.get_state()); + }); + break; + + case IBus.PropType.SEPARATOR: + item = new PopupMenu.PopupSeparatorMenuItem(); + break; + + default: + log('IBus property %s has invalid type %d'.format(prop.get_key(), type)); + continue; + } + + item.setSensitive(prop.get_sensitive()); + menu.addMenuItem(item); + } + } + + _showLayout() { + Main.overview.hide(); + + let source = this._inputSourceManager.currentSource; + let xkbLayout = ''; + let xkbVariant = ''; + + if (source.type == INPUT_SOURCE_TYPE_XKB) { + [, , , xkbLayout, xkbVariant] = KeyboardManager.getXkbInfo().get_layout_info(source.id); + } else if (source.type == INPUT_SOURCE_TYPE_IBUS) { + let engineDesc = IBusManager.getIBusManager().getEngineDesc(source.id); + if (engineDesc) { + xkbLayout = engineDesc.get_layout(); + xkbVariant = engineDesc.get_layout_variant(); + } + } + + if (!xkbLayout || xkbLayout.length == 0) + return; + + let description = xkbLayout; + if (xkbVariant.length > 0) + description = '%s\t%s'.format(description, xkbVariant); + + Util.spawn(['gkbd-keyboard-display', '-l', description]); + } +}); diff --git a/js/ui/status/location.js b/js/ui/status/location.js new file mode 100644 index 0000000..4250ed0 --- /dev/null +++ b/js/ui/status/location.js @@ -0,0 +1,387 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const { Clutter, Gio, GLib, GObject, Shell, St } = imports.gi; + +const Dialog = imports.ui.dialog; +const Main = imports.ui.main; +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; +const ModalDialog = imports.ui.modalDialog; +const PermissionStore = imports.misc.permissionStore; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const LOCATION_SCHEMA = 'org.gnome.system.location'; +const MAX_ACCURACY_LEVEL = 'max-accuracy-level'; +const ENABLED = 'enabled'; + +const APP_PERMISSIONS_TABLE = 'gnome'; +const APP_PERMISSIONS_ID = 'geolocation'; + +var GeoclueAccuracyLevel = { + NONE: 0, + COUNTRY: 1, + CITY: 4, + NEIGHBORHOOD: 5, + STREET: 6, + EXACT: 8, +}; + +function accuracyLevelToString(accuracyLevel) { + for (let key in GeoclueAccuracyLevel) { + if (GeoclueAccuracyLevel[key] == accuracyLevel) + return key; + } + + return 'NONE'; +} + +var GeoclueIface = loadInterfaceXML('org.freedesktop.GeoClue2.Manager'); +const GeoclueManager = Gio.DBusProxy.makeProxyWrapper(GeoclueIface); + +var AgentIface = loadInterfaceXML('org.freedesktop.GeoClue2.Agent'); + +var Indicator = GObject.registerClass( +class Indicator extends PanelMenu.SystemIndicator { + _init() { + super._init(); + + this._settings = new Gio.Settings({ schema_id: LOCATION_SCHEMA }); + this._settings.connect('changed::%s'.format(ENABLED), + this._onMaxAccuracyLevelChanged.bind(this)); + this._settings.connect('changed::%s'.format(MAX_ACCURACY_LEVEL), + this._onMaxAccuracyLevelChanged.bind(this)); + + this._indicator = this._addIndicator(); + this._indicator.icon_name = 'find-location-symbolic'; + + this._item = new PopupMenu.PopupSubMenuMenuItem('', true); + this._item.icon.icon_name = 'find-location-symbolic'; + + this._agent = Gio.DBusExportedObject.wrapJSObject(AgentIface, this); + this._agent.export(Gio.DBus.system, '/org/freedesktop/GeoClue2/Agent'); + + this._item.label.text = _("Location Enabled"); + this._onOffAction = this._item.menu.addAction(_("Disable"), this._onOnOffAction.bind(this)); + this._item.menu.addSettingsAction(_('Privacy Settings'), 'gnome-location-panel.desktop'); + + this.menu.addMenuItem(this._item); + + this._watchId = Gio.bus_watch_name(Gio.BusType.SYSTEM, + 'org.freedesktop.GeoClue2', + 0, + this._connectToGeoclue.bind(this), + this._onGeoclueVanished.bind(this)); + Main.sessionMode.connect('updated', this._onSessionUpdated.bind(this)); + this._onSessionUpdated(); + this._onMaxAccuracyLevelChanged(); + this._connectToGeoclue(); + this._connectToPermissionStore(); + } + + get MaxAccuracyLevel() { + return this._getMaxAccuracyLevel(); + } + + AuthorizeAppAsync(params, invocation) { + let [desktopId, reqAccuracyLevel] = params; + + let authorizer = new AppAuthorizer(desktopId, + reqAccuracyLevel, + this._permStoreProxy, + this._getMaxAccuracyLevel()); + + authorizer.authorize(accuracyLevel => { + let ret = accuracyLevel != GeoclueAccuracyLevel.NONE; + invocation.return_value(GLib.Variant.new('(bu)', + [ret, accuracyLevel])); + }); + } + + _syncIndicator() { + if (this._managerProxy == null) { + this._indicator.visible = false; + this._item.visible = false; + return; + } + + this._indicator.visible = this._managerProxy.InUse; + this._item.visible = this._indicator.visible; + this._updateMenuLabels(); + } + + _connectToGeoclue() { + if (this._managerProxy != null || this._connecting) + return false; + + this._connecting = true; + new GeoclueManager(Gio.DBus.system, + 'org.freedesktop.GeoClue2', + '/org/freedesktop/GeoClue2/Manager', + this._onManagerProxyReady.bind(this)); + return true; + } + + _onManagerProxyReady(proxy, error) { + if (error != null) { + log(error.message); + this._connecting = false; + return; + } + + this._managerProxy = proxy; + this._propertiesChangedId = this._managerProxy.connect('g-properties-changed', + this._onGeocluePropsChanged.bind(this)); + + this._syncIndicator(); + + this._managerProxy.AddAgentRemote('gnome-shell', this._onAgentRegistered.bind(this)); + } + + _onAgentRegistered(result, error) { + this._connecting = false; + this._notifyMaxAccuracyLevel(); + + if (error != null) + log(error.message); + } + + _onGeoclueVanished() { + if (this._propertiesChangedId) { + this._managerProxy.disconnect(this._propertiesChangedId); + this._propertiesChangedId = 0; + } + this._managerProxy = null; + + this._syncIndicator(); + } + + _onOnOffAction() { + let enabled = this._settings.get_boolean(ENABLED); + this._settings.set_boolean(ENABLED, !enabled); + } + + _onSessionUpdated() { + let sensitive = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter; + this.menu.setSensitive(sensitive); + } + + _updateMenuLabels() { + if (this._settings.get_boolean(ENABLED)) { + this._item.label.text = this._indicator.visible + ? _("Location In Use") + : _("Location Enabled"); + this._onOffAction.label.text = _("Disable"); + } else { + this._item.label.text = _("Location Disabled"); + this._onOffAction.label.text = _("Enable"); + } + } + + _onMaxAccuracyLevelChanged() { + this._updateMenuLabels(); + + // Gotta ensure geoclue is up and we are registered as agent to it + // before we emit the notify for this property change. + if (!this._connectToGeoclue()) + this._notifyMaxAccuracyLevel(); + } + + _getMaxAccuracyLevel() { + if (this._settings.get_boolean(ENABLED)) { + let level = this._settings.get_string(MAX_ACCURACY_LEVEL); + + return GeoclueAccuracyLevel[level.toUpperCase()] || + GeoclueAccuracyLevel.NONE; + } else { + return GeoclueAccuracyLevel.NONE; + } + } + + _notifyMaxAccuracyLevel() { + let variant = new GLib.Variant('u', this._getMaxAccuracyLevel()); + this._agent.emit_property_changed('MaxAccuracyLevel', variant); + } + + _onGeocluePropsChanged(proxy, properties) { + let unpacked = properties.deep_unpack(); + if ("InUse" in unpacked) + this._syncIndicator(); + } + + _connectToPermissionStore() { + this._permStoreProxy = null; + new PermissionStore.PermissionStore(this._onPermStoreProxyReady.bind(this)); + } + + _onPermStoreProxyReady(proxy, error) { + if (error != null) { + log(error.message); + return; + } + + this._permStoreProxy = proxy; + } +}); + +var AppAuthorizer = class { + constructor(desktopId, reqAccuracyLevel, permStoreProxy, maxAccuracyLevel) { + this.desktopId = desktopId; + this.reqAccuracyLevel = reqAccuracyLevel; + this._permStoreProxy = permStoreProxy; + this._maxAccuracyLevel = maxAccuracyLevel; + this._permissions = {}; + + this._accuracyLevel = GeoclueAccuracyLevel.NONE; + } + + authorize(onAuthDone) { + this._onAuthDone = onAuthDone; + + let appSystem = Shell.AppSystem.get_default(); + this._app = appSystem.lookup_app('%s.desktop'.format(this.desktopId)); + if (this._app == null || this._permStoreProxy == null) { + this._completeAuth(); + + return; + } + + this._permStoreProxy.LookupRemote(APP_PERMISSIONS_TABLE, + APP_PERMISSIONS_ID, + this._onPermLookupDone.bind(this)); + } + + _onPermLookupDone(result, error) { + if (error != null) { + if (error.domain == Gio.DBusError) { + // Likely no xdg-app installed, just authorize the app + this._accuracyLevel = this.reqAccuracyLevel; + this._permStoreProxy = null; + this._completeAuth(); + } else { + // Currently xdg-app throws an error if we lookup for + // unknown ID (which would be the case first time this code + // runs) so we continue with user authorization as normal + // and ID is added to the store if user says "yes". + log(error.message); + this._permissions = {}; + this._userAuthorizeApp(); + } + + return; + } + + [this._permissions] = result; + let permission = this._permissions[this.desktopId]; + + if (permission == null) { + this._userAuthorizeApp(); + } else { + let [levelStr] = permission || ['NONE']; + this._accuracyLevel = GeoclueAccuracyLevel[levelStr] || + GeoclueAccuracyLevel.NONE; + this._completeAuth(); + } + } + + _userAuthorizeApp() { + let name = this._app.get_name(); + let appInfo = this._app.get_app_info(); + let reason = appInfo.get_locale_string("X-Geoclue-Reason"); + + this._showAppAuthDialog(name, reason); + } + + _showAppAuthDialog(name, reason) { + this._dialog = new GeolocationDialog(name, + reason, + this.reqAccuracyLevel); + + let responseId = this._dialog.connect('response', (dialog, level) => { + this._dialog.disconnect(responseId); + this._accuracyLevel = level; + this._completeAuth(); + }); + + this._dialog.open(); + } + + _completeAuth() { + if (this._accuracyLevel != GeoclueAccuracyLevel.NONE) { + this._accuracyLevel = Math.clamp(this._accuracyLevel, + 0, this._maxAccuracyLevel); + } + this._saveToPermissionStore(); + + this._onAuthDone(this._accuracyLevel); + } + + _saveToPermissionStore() { + if (this._permStoreProxy == null) + return; + + let levelStr = accuracyLevelToString(this._accuracyLevel); + let dateStr = Math.round(Date.now() / 1000).toString(); + this._permissions[this.desktopId] = [levelStr, dateStr]; + + let data = GLib.Variant.new('av', {}); + + this._permStoreProxy.SetRemote(APP_PERMISSIONS_TABLE, + true, + APP_PERMISSIONS_ID, + this._permissions, + data, + (result, error) => { + if (error != null) + log(error.message); + }); + } +}; + +var GeolocationDialog = GObject.registerClass({ + Signals: { 'response': { param_types: [GObject.TYPE_UINT] } }, +}, class GeolocationDialog extends ModalDialog.ModalDialog { + _init(name, reason, reqAccuracyLevel) { + super._init({ styleClass: 'geolocation-dialog' }); + this.reqAccuracyLevel = reqAccuracyLevel; + + let content = new Dialog.MessageDialogContent({ + title: _('Allow location access'), + /* Translators: %s is an application name */ + description: _('The app %s wants to access your location').format(name), + }); + + let reasonLabel = new St.Label({ + text: reason, + style_class: 'message-dialog-description', + }); + content.add_child(reasonLabel); + + let infoLabel = new St.Label({ + text: _('Location access can be changed at any time from the privacy settings.'), + style_class: 'message-dialog-description', + }); + content.add_child(infoLabel); + + this.contentLayout.add_child(content); + + let button = this.addButton({ label: _("Deny Access"), + action: this._onDenyClicked.bind(this), + key: Clutter.KEY_Escape }); + this.addButton({ label: _("Grant Access"), + action: this._onGrantClicked.bind(this) }); + + this.setInitialKeyFocus(button); + } + + _onGrantClicked() { + this.emit('response', this.reqAccuracyLevel); + this.close(); + } + + _onDenyClicked() { + this.emit('response', GeoclueAccuracyLevel.NONE); + this.close(); + } +}); diff --git a/js/ui/status/network.js b/js/ui/status/network.js new file mode 100644 index 0000000..377f44e --- /dev/null +++ b/js/ui/status/network.js @@ -0,0 +1,2101 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported NMApplet */ +const { Clutter, Gio, GLib, GObject, NM, St } = imports.gi; +const Signals = imports.signals; + +const Animation = imports.ui.animation; +const Main = imports.ui.main; +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; +const MessageTray = imports.ui.messageTray; +const ModalDialog = imports.ui.modalDialog; +const ModemManager = imports.misc.modemManager; +const Rfkill = imports.ui.status.rfkill; +const Util = imports.misc.util; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +Gio._promisify(Gio.DBusConnection.prototype, 'call', 'call_finish'); +Gio._promisify(NM.Client, 'new_async', 'new_finish'); +Gio._promisify(NM.Client.prototype, + 'check_connectivity_async', 'check_connectivity_finish'); + +const NMConnectionCategory = { + INVALID: 'invalid', + WIRED: 'wired', + WIRELESS: 'wireless', + WWAN: 'wwan', + VPN: 'vpn', +}; + +const NMAccessPointSecurity = { + NONE: 1, + WEP: 2, + WPA_PSK: 3, + WPA2_PSK: 4, + WPA_ENT: 5, + WPA2_ENT: 6, +}; + +var MAX_DEVICE_ITEMS = 4; + +// small optimization, to avoid using [] all the time +const NM80211Mode = NM['80211Mode']; +const NM80211ApFlags = NM['80211ApFlags']; +const NM80211ApSecurityFlags = NM['80211ApSecurityFlags']; + +var PortalHelperResult = { + CANCELLED: 0, + COMPLETED: 1, + RECHECK: 2, +}; + +const PortalHelperIface = loadInterfaceXML('org.gnome.Shell.PortalHelper'); +const PortalHelperProxy = Gio.DBusProxy.makeProxyWrapper(PortalHelperIface); + +function signalToIcon(value) { + if (value > 80) + return 'excellent'; + if (value > 55) + return 'good'; + if (value > 30) + return 'ok'; + if (value > 5) + return 'weak'; + return 'none'; +} + +function ssidToLabel(ssid) { + let label = NM.utils_ssid_to_utf8(ssid.get_data()); + if (!label) + label = _("<unknown>"); + return label; +} + +function ensureActiveConnectionProps(active) { + if (!active._primaryDevice) { + let devices = active.get_devices(); + if (devices.length > 0) { + // This list is guaranteed to have at most one device in it. + let device = devices[0]._delegate; + active._primaryDevice = device; + } + } +} + +function launchSettingsPanel(panel, ...args) { + const param = new GLib.Variant('(sav)', + [panel, args.map(s => new GLib.Variant('s', s))]); + const platformData = { + 'desktop-startup-id': new GLib.Variant('s', + '_TIME%s'.format(global.get_current_time())), + }; + try { + Gio.DBus.session.call( + 'org.gnome.ControlCenter', + '/org/gnome/ControlCenter', + 'org.freedesktop.Application', + 'ActivateAction', + new GLib.Variant('(sava{sv})', + ['launch-panel', [param], platformData]), + null, + Gio.DBusCallFlags.NONE, + -1, + null); + } catch (e) { + log('Failed to launch Settings panel: %s'.format(e.message)); + } +} + +var NMConnectionItem = class { + constructor(section, connection) { + this._section = section; + this._connection = connection; + this._activeConnection = null; + this._activeConnectionChangedId = 0; + + this._buildUI(); + this._sync(); + } + + _buildUI() { + this.labelItem = new PopupMenu.PopupMenuItem(''); + this.labelItem.connect('activate', this._toggle.bind(this)); + + this.radioItem = new PopupMenu.PopupMenuItem(this._connection.get_id(), false); + this.radioItem.connect('activate', this._activate.bind(this)); + } + + destroy() { + this.labelItem.destroy(); + this.radioItem.destroy(); + } + + updateForConnection(connection) { + // connection should always be the same object + // (and object path) as this._connection, but + // this can be false if NetworkManager was restarted + // and picked up connections in a different order + // Just to be safe, we set it here again + + this._connection = connection; + this.radioItem.label.text = connection.get_id(); + this._sync(); + this.emit('name-changed'); + } + + getName() { + return this._connection.get_id(); + } + + isActive() { + if (this._activeConnection == null) + return false; + + return this._activeConnection.state <= NM.ActiveConnectionState.ACTIVATED; + } + + _sync() { + let isActive = this.isActive(); + this.labelItem.label.text = isActive ? _("Turn Off") : this._section.getConnectLabel(); + this.radioItem.setOrnament(isActive ? PopupMenu.Ornament.DOT : PopupMenu.Ornament.NONE); + this.emit('icon-changed'); + } + + _toggle() { + if (this._activeConnection == null) + this._section.activateConnection(this._connection); + else + this._section.deactivateConnection(this._activeConnection); + + this._sync(); + } + + _activate() { + if (this._activeConnection == null) + this._section.activateConnection(this._connection); + + this._sync(); + } + + _connectionStateChanged(_ac, _newstate, _reason) { + this._sync(); + } + + setActiveConnection(activeConnection) { + if (this._activeConnectionChangedId > 0) { + this._activeConnection.disconnect(this._activeConnectionChangedId); + this._activeConnectionChangedId = 0; + } + + this._activeConnection = activeConnection; + + if (this._activeConnection) { + this._activeConnectionChangedId = this._activeConnection.connect('notify::state', + this._connectionStateChanged.bind(this)); + } + + this._sync(); + } +}; +Signals.addSignalMethods(NMConnectionItem.prototype); + +var NMConnectionSection = class NMConnectionSection { + constructor(client) { + if (this.constructor === NMConnectionSection) + throw new TypeError('Cannot instantiate abstract type %s'.format(this.constructor.name)); + + this._client = client; + + this._connectionItems = new Map(); + this._connections = []; + + this._labelSection = new PopupMenu.PopupMenuSection(); + this._radioSection = new PopupMenu.PopupMenuSection(); + + this.item = new PopupMenu.PopupSubMenuMenuItem('', true); + this.item.menu.addMenuItem(this._labelSection); + this.item.menu.addMenuItem(this._radioSection); + + this._notifyConnectivityId = this._client.connect('notify::connectivity', this._iconChanged.bind(this)); + } + + destroy() { + if (this._notifyConnectivityId != 0) { + this._client.disconnect(this._notifyConnectivityId); + this._notifyConnectivityId = 0; + } + + this.item.destroy(); + } + + _iconChanged() { + this._sync(); + this.emit('icon-changed'); + } + + _sync() { + let nItems = this._connectionItems.size; + + this._radioSection.actor.visible = nItems > 1; + this._labelSection.actor.visible = nItems == 1; + + this.item.label.text = this._getStatus(); + this.item.icon.icon_name = this._getMenuIcon(); + } + + _getMenuIcon() { + return this.getIndicatorIcon(); + } + + getConnectLabel() { + return _("Connect"); + } + + _connectionValid(_connection) { + return true; + } + + _connectionSortFunction(one, two) { + return GLib.utf8_collate(one.get_id(), two.get_id()); + } + + _makeConnectionItem(connection) { + return new NMConnectionItem(this, connection); + } + + checkConnection(connection) { + if (!this._connectionValid(connection)) + return; + + // This function is called every time the connection is added or updated. + // In the usual case, we already added this connection and UUID + // didn't change. So we need to check if we already have an item, + // and update it for properties in the connection that changed + // (the only one we care about is the name) + // But it's also possible we didn't know about this connection + // (eg, during coldplug, or because it was updated and suddenly + // it's valid for this device), in which case we add a new item. + + let item = this._connectionItems.get(connection.get_uuid()); + if (item) + this._updateForConnection(item, connection); + else + this._addConnection(connection); + } + + _updateForConnection(item, connection) { + let pos = this._connections.indexOf(connection); + + this._connections.splice(pos, 1); + pos = Util.insertSorted(this._connections, connection, this._connectionSortFunction.bind(this)); + this._labelSection.moveMenuItem(item.labelItem, pos); + this._radioSection.moveMenuItem(item.radioItem, pos); + + item.updateForConnection(connection); + } + + _addConnection(connection) { + let item = this._makeConnectionItem(connection); + if (!item) + return; + + item.connect('icon-changed', () => this._iconChanged()); + item.connect('activation-failed', (o, reason) => { + this.emit('activation-failed', reason); + }); + item.connect('name-changed', this._sync.bind(this)); + + let pos = Util.insertSorted(this._connections, connection, this._connectionSortFunction.bind(this)); + this._labelSection.addMenuItem(item.labelItem, pos); + this._radioSection.addMenuItem(item.radioItem, pos); + this._connectionItems.set(connection.get_uuid(), item); + this._sync(); + } + + removeConnection(connection) { + let uuid = connection.get_uuid(); + let item = this._connectionItems.get(uuid); + if (item == undefined) + return; + + item.destroy(); + this._connectionItems.delete(uuid); + + let pos = this._connections.indexOf(connection); + this._connections.splice(pos, 1); + + this._sync(); + } +}; +Signals.addSignalMethods(NMConnectionSection.prototype); + +var NMConnectionDevice = class NMConnectionDevice extends NMConnectionSection { + constructor(client, device) { + super(client); + + if (this.constructor === NMConnectionDevice) + throw new TypeError('Cannot instantiate abstract type %s'.format(this.constructor.name)); + + this._device = device; + this._description = ''; + + this._autoConnectItem = this.item.menu.addAction(_("Connect"), this._autoConnect.bind(this)); + this._deactivateItem = this._radioSection.addAction(_("Turn Off"), this.deactivateConnection.bind(this)); + + this._stateChangedId = this._device.connect('state-changed', this._deviceStateChanged.bind(this)); + this._activeConnectionChangedId = this._device.connect('notify::active-connection', this._activeConnectionChanged.bind(this)); + } + + _canReachInternet() { + if (this._client.primary_connection != this._device.active_connection) + return true; + + return this._client.connectivity == NM.ConnectivityState.FULL; + } + + _autoConnect() { + let connection = new NM.SimpleConnection(); + this._client.add_and_activate_connection_async(connection, this._device, null, null, null); + } + + destroy() { + if (this._stateChangedId) { + GObject.signal_handler_disconnect(this._device, this._stateChangedId); + this._stateChangedId = 0; + } + if (this._activeConnectionChangedId) { + GObject.signal_handler_disconnect(this._device, this._activeConnectionChangedId); + this._activeConnectionChangedId = 0; + } + + super.destroy(); + } + + _activeConnectionChanged() { + if (this._activeConnection) { + let item = this._connectionItems.get(this._activeConnection.connection.get_uuid()); + item.setActiveConnection(null); + this._activeConnection = null; + } + + this._sync(); + } + + _deviceStateChanged(device, newstate, oldstate, reason) { + if (newstate == oldstate) { + log('device emitted state-changed without actually changing state'); + return; + } + + /* Emit a notification if activation fails, but don't do it + if the reason is no secrets, as that indicates the user + cancelled the agent dialog */ + if (newstate == NM.DeviceState.FAILED && + reason != NM.DeviceStateReason.NO_SECRETS) + this.emit('activation-failed', reason); + + this._sync(); + } + + _connectionValid(connection) { + return this._device.connection_valid(connection); + } + + activateConnection(connection) { + this._client.activate_connection_async(connection, this._device, null, null, null); + } + + deactivateConnection(_activeConnection) { + this._device.disconnect(null); + } + + setDeviceDescription(desc) { + this._description = desc; + this._sync(); + } + + _getDescription() { + return this._description; + } + + _sync() { + let nItems = this._connectionItems.size; + this._autoConnectItem.visible = nItems == 0; + this._deactivateItem.visible = this._device.state > NM.DeviceState.DISCONNECTED; + + if (this._activeConnection == null) { + let activeConnection = this._device.active_connection; + if (activeConnection && activeConnection.connection) { + let item = this._connectionItems.get(activeConnection.connection.get_uuid()); + if (item) { + this._activeConnection = activeConnection; + ensureActiveConnectionProps(this._activeConnection); + item.setActiveConnection(this._activeConnection); + } + } + } + + super._sync(); + } + + _getStatus() { + if (!this._device) + return ''; + + switch (this._device.state) { + case NM.DeviceState.DISCONNECTED: + /* Translators: %s is a network identifier */ + return _("%s Off").format(this._getDescription()); + case NM.DeviceState.ACTIVATED: + /* Translators: %s is a network identifier */ + return _("%s Connected").format(this._getDescription()); + case NM.DeviceState.UNMANAGED: + /* Translators: this is for network devices that are physically present but are not + under NetworkManager's control (and thus cannot be used in the menu); + %s is a network identifier */ + return _("%s Unmanaged").format(this._getDescription()); + case NM.DeviceState.DEACTIVATING: + /* Translators: %s is a network identifier */ + return _("%s Disconnecting").format(this._getDescription()); + case NM.DeviceState.PREPARE: + case NM.DeviceState.CONFIG: + case NM.DeviceState.IP_CONFIG: + case NM.DeviceState.IP_CHECK: + case NM.DeviceState.SECONDARIES: + /* Translators: %s is a network identifier */ + return _("%s Connecting").format(this._getDescription()); + case NM.DeviceState.NEED_AUTH: + /* Translators: this is for network connections that require some kind of key or password; %s is a network identifier */ + return _("%s Requires Authentication").format(this._getDescription()); + case NM.DeviceState.UNAVAILABLE: + // This state is actually a compound of various states (generically unavailable, + // firmware missing), that are exposed by different properties (whose state may + // or may not updated when we receive state-changed). + if (this._device.firmware_missing) { + /* Translators: this is for devices that require some kind of firmware or kernel + module, which is missing; %s is a network identifier */ + return _("Firmware Missing For %s").format(this._getDescription()); + } + /* Translators: this is for a network device that cannot be activated (for example it + is disabled by rfkill, or it has no coverage; %s is a network identifier */ + return _("%s Unavailable").format(this._getDescription()); + case NM.DeviceState.FAILED: + /* Translators: %s is a network identifier */ + return _("%s Connection Failed").format(this._getDescription()); + default: + log('Device state invalid, is %d'.format(this._device.state)); + return 'invalid'; + } + } +}; + +var NMDeviceWired = class extends NMConnectionDevice { + constructor(client, device) { + super(client, device); + + this.item.menu.addSettingsAction(_("Wired Settings"), 'gnome-network-panel.desktop'); + } + + get category() { + return NMConnectionCategory.WIRED; + } + + _hasCarrier() { + if (this._device instanceof NM.DeviceEthernet) + return this._device.carrier; + else + return true; + } + + _sync() { + this.item.visible = this._hasCarrier(); + super._sync(); + } + + getIndicatorIcon() { + if (this._device.active_connection) { + let state = this._device.active_connection.state; + + if (state == NM.ActiveConnectionState.ACTIVATING) { + return 'network-wired-acquiring-symbolic'; + } else if (state == NM.ActiveConnectionState.ACTIVATED) { + if (this._canReachInternet()) + return 'network-wired-symbolic'; + else + return 'network-wired-no-route-symbolic'; + } else { + return 'network-wired-disconnected-symbolic'; + } + } else { + return 'network-wired-disconnected-symbolic'; + } + } +}; + +var NMDeviceModem = class extends NMConnectionDevice { + constructor(client, device) { + super(client, device); + + this.item.menu.addSettingsAction(_("Mobile Broadband Settings"), 'gnome-network-panel.desktop'); + + this._mobileDevice = null; + + let capabilities = device.current_capabilities; + if (device.udi.indexOf('/org/freedesktop/ModemManager1/Modem') == 0) + this._mobileDevice = new ModemManager.BroadbandModem(device.udi, capabilities); + else if (capabilities & NM.DeviceModemCapabilities.GSM_UMTS) + this._mobileDevice = new ModemManager.ModemGsm(device.udi); + else if (capabilities & NM.DeviceModemCapabilities.CDMA_EVDO) + this._mobileDevice = new ModemManager.ModemCdma(device.udi); + else if (capabilities & NM.DeviceModemCapabilities.LTE) + this._mobileDevice = new ModemManager.ModemGsm(device.udi); + + if (this._mobileDevice) { + this._operatorNameId = this._mobileDevice.connect('notify::operator-name', this._sync.bind(this)); + this._signalQualityId = this._mobileDevice.connect('notify::signal-quality', () => { + this._iconChanged(); + }); + } + } + + get category() { + return NMConnectionCategory.WWAN; + } + + _autoConnect() { + launchSettingsPanel('network', 'connect-3g', this._device.get_path()); + } + + destroy() { + if (this._operatorNameId) { + this._mobileDevice.disconnect(this._operatorNameId); + this._operatorNameId = 0; + } + if (this._signalQualityId) { + this._mobileDevice.disconnect(this._signalQualityId); + this._signalQualityId = 0; + } + + super.destroy(); + } + + _getStatus() { + if (!this._client.wwan_hardware_enabled) + /* Translators: %s is a network identifier */ + return _("%s Hardware Disabled").format(this._getDescription()); + else if (!this._client.wwan_enabled) + /* Translators: this is for a network device that cannot be activated + because it's disabled by rfkill (airplane mode); %s is a network identifier */ + return _("%s Disabled").format(this._getDescription()); + else if (this._device.state == NM.DeviceState.ACTIVATED && + this._mobileDevice && this._mobileDevice.operator_name) + return this._mobileDevice.operator_name; + else + return super._getStatus(); + } + + getIndicatorIcon() { + if (this._device.active_connection) { + if (this._device.active_connection.state == NM.ActiveConnectionState.ACTIVATING) + return 'network-cellular-acquiring-symbolic'; + + return this._getSignalIcon(); + } else { + return 'network-cellular-signal-none-symbolic'; + } + } + + _getSignalIcon() { + return 'network-cellular-signal-%s-symbolic'.format( + signalToIcon(this._mobileDevice.signal_quality)); + } +}; + +var NMDeviceBluetooth = class extends NMConnectionDevice { + constructor(client, device) { + super(client, device); + + this.item.menu.addSettingsAction(_("Bluetooth Settings"), 'gnome-network-panel.desktop'); + } + + get category() { + return NMConnectionCategory.WWAN; + } + + _getDescription() { + return this._device.name; + } + + getConnectLabel() { + return _("Connect to Internet"); + } + + getIndicatorIcon() { + if (this._device.active_connection) { + let state = this._device.active_connection.state; + if (state == NM.ActiveConnectionState.ACTIVATING) + return 'network-cellular-acquiring-symbolic'; + else if (state == NM.ActiveConnectionState.ACTIVATED) + return 'network-cellular-connected-symbolic'; + else + return 'network-cellular-signal-none-symbolic'; + } else { + return 'network-cellular-signal-none-symbolic'; + } + } +}; + +var NMWirelessDialogItem = GObject.registerClass({ + Signals: { + 'selected': {}, + }, +}, class NMWirelessDialogItem extends St.BoxLayout { + _init(network) { + this._network = network; + this._ap = network.accessPoints[0]; + + super._init({ style_class: 'nm-dialog-item', + can_focus: true, + reactive: true }); + + let action = new Clutter.ClickAction(); + action.connect('clicked', () => this.grab_key_focus()); + this.add_action(action); + + let title = ssidToLabel(this._ap.get_ssid()); + this._label = new St.Label({ + text: title, + x_expand: true, + }); + + this.label_actor = this._label; + this.add_child(this._label); + + this._selectedIcon = new St.Icon({ style_class: 'nm-dialog-icon', + icon_name: 'object-select-symbolic' }); + this.add(this._selectedIcon); + + this._icons = new St.BoxLayout({ + style_class: 'nm-dialog-icons', + x_align: Clutter.ActorAlign.END, + }); + this.add_child(this._icons); + + this._secureIcon = new St.Icon({ style_class: 'nm-dialog-icon' }); + if (this._ap._secType != NMAccessPointSecurity.NONE) + this._secureIcon.icon_name = 'network-wireless-encrypted-symbolic'; + this._icons.add_actor(this._secureIcon); + + this._signalIcon = new St.Icon({ style_class: 'nm-dialog-icon' }); + this._icons.add_actor(this._signalIcon); + + this._sync(); + } + + vfunc_key_focus_in() { + this.emit('selected'); + } + + _sync() { + this._signalIcon.icon_name = this._getSignalIcon(); + } + + updateBestAP(ap) { + this._ap = ap; + this._sync(); + } + + setActive(isActive) { + this._selectedIcon.opacity = isActive ? 255 : 0; + } + + _getSignalIcon() { + if (this._ap.mode == NM80211Mode.ADHOC) { + return 'network-workgroup-symbolic'; + } else { + return 'network-wireless-signal-%s-symbolic'.format( + signalToIcon(this._ap.strength)); + } + } +}); + +var NMWirelessDialog = GObject.registerClass( +class NMWirelessDialog extends ModalDialog.ModalDialog { + _init(client, device) { + super._init({ styleClass: 'nm-dialog' }); + + this._client = client; + this._device = device; + + this._wirelessEnabledChangedId = this._client.connect('notify::wireless-enabled', + this._syncView.bind(this)); + + this._rfkill = Rfkill.getRfkillManager(); + this._airplaneModeChangedId = this._rfkill.connect('airplane-mode-changed', + this._syncView.bind(this)); + + this._networks = []; + this._buildLayout(); + + let connections = client.get_connections(); + this._connections = connections.filter( + connection => device.connection_valid(connection)); + + this._apAddedId = device.connect('access-point-added', this._accessPointAdded.bind(this)); + this._apRemovedId = device.connect('access-point-removed', this._accessPointRemoved.bind(this)); + this._activeApChangedId = device.connect('notify::active-access-point', this._activeApChanged.bind(this)); + + // accessPointAdded will also create dialog items + let accessPoints = device.get_access_points() || []; + accessPoints.forEach(ap => { + this._accessPointAdded(this._device, ap); + }); + + this._selectedNetwork = null; + this._activeApChanged(); + this._updateSensitivity(); + this._syncView(); + + this._scanTimeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 15, this._onScanTimeout.bind(this)); + GLib.Source.set_name_by_id(this._scanTimeoutId, '[gnome-shell] this._onScanTimeout'); + this._onScanTimeout(); + + let id = Main.sessionMode.connect('updated', () => { + if (Main.sessionMode.allowSettings) + return; + + Main.sessionMode.disconnect(id); + this.close(); + }); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + _onDestroy() { + if (this._apAddedId) { + GObject.Object.prototype.disconnect.call(this._device, this._apAddedId); + this._apAddedId = 0; + } + if (this._apRemovedId) { + GObject.Object.prototype.disconnect.call(this._device, this._apRemovedId); + this._apRemovedId = 0; + } + if (this._activeApChangedId) { + GObject.Object.prototype.disconnect.call(this._device, this._activeApChangedId); + this._activeApChangedId = 0; + } + if (this._wirelessEnabledChangedId) { + this._client.disconnect(this._wirelessEnabledChangedId); + this._wirelessEnabledChangedId = 0; + } + if (this._airplaneModeChangedId) { + this._rfkill.disconnect(this._airplaneModeChangedId); + this._airplaneModeChangedId = 0; + } + + if (this._scanTimeoutId) { + GLib.source_remove(this._scanTimeoutId); + this._scanTimeoutId = 0; + } + } + + _onScanTimeout() { + this._device.request_scan_async(null, null); + return GLib.SOURCE_CONTINUE; + } + + _activeApChanged() { + if (this._activeNetwork) + this._activeNetwork.item.setActive(false); + + this._activeNetwork = null; + if (this._device.active_access_point) { + let idx = this._findNetwork(this._device.active_access_point); + if (idx >= 0) + this._activeNetwork = this._networks[idx]; + } + + if (this._activeNetwork) + this._activeNetwork.item.setActive(true); + this._updateSensitivity(); + } + + _updateSensitivity() { + let connectSensitive = this._client.wireless_enabled && this._selectedNetwork && (this._selectedNetwork != this._activeNetwork); + this._connectButton.reactive = connectSensitive; + this._connectButton.can_focus = connectSensitive; + } + + _syncView() { + if (this._rfkill.airplaneMode) { + this._airplaneBox.show(); + + this._airplaneIcon.icon_name = 'airplane-mode-symbolic'; + this._airplaneHeadline.text = _("Airplane Mode is On"); + this._airplaneText.text = _("Wi-Fi is disabled when airplane mode is on."); + this._airplaneButton.label = _("Turn Off Airplane Mode"); + + this._airplaneButton.visible = !this._rfkill.hwAirplaneMode; + this._airplaneInactive.visible = this._rfkill.hwAirplaneMode; + this._noNetworksBox.hide(); + } else if (!this._client.wireless_enabled) { + this._airplaneBox.show(); + + this._airplaneIcon.icon_name = 'dialog-information-symbolic'; + this._airplaneHeadline.text = _("Wi-Fi is Off"); + this._airplaneText.text = _("Wi-Fi needs to be turned on in order to connect to a network."); + this._airplaneButton.label = _("Turn On Wi-Fi"); + + this._airplaneButton.show(); + this._airplaneInactive.hide(); + this._noNetworksBox.hide(); + } else { + this._airplaneBox.hide(); + + this._noNetworksBox.visible = this._networks.length == 0; + } + + if (this._noNetworksBox.visible) + this._noNetworksSpinner.play(); + else + this._noNetworksSpinner.stop(); + } + + _buildLayout() { + let headline = new St.BoxLayout({ style_class: 'nm-dialog-header-hbox' }); + + let icon = new St.Icon({ style_class: 'nm-dialog-header-icon', + icon_name: 'network-wireless-signal-excellent-symbolic' }); + + let titleBox = new St.BoxLayout({ vertical: true }); + let title = new St.Label({ style_class: 'nm-dialog-header', + text: _("Wi-Fi Networks") }); + let subtitle = new St.Label({ style_class: 'nm-dialog-subheader', + text: _("Select a network") }); + titleBox.add(title); + titleBox.add(subtitle); + + headline.add(icon); + headline.add(titleBox); + + this.contentLayout.style_class = 'nm-dialog-content'; + this.contentLayout.add(headline); + + this._stack = new St.Widget({ + layout_manager: new Clutter.BinLayout(), + y_expand: true, + }); + + this._itemBox = new St.BoxLayout({ vertical: true }); + this._scrollView = new St.ScrollView({ style_class: 'nm-dialog-scroll-view' }); + this._scrollView.set_x_expand(true); + this._scrollView.set_y_expand(true); + this._scrollView.set_policy(St.PolicyType.NEVER, + St.PolicyType.AUTOMATIC); + this._scrollView.add_actor(this._itemBox); + this._stack.add_child(this._scrollView); + + this._noNetworksBox = new St.BoxLayout({ vertical: true, + style_class: 'no-networks-box', + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER }); + + this._noNetworksSpinner = new Animation.Spinner(16); + this._noNetworksBox.add_actor(this._noNetworksSpinner); + this._noNetworksBox.add_actor(new St.Label({ style_class: 'no-networks-label', + text: _("No Networks") })); + this._stack.add_child(this._noNetworksBox); + + this._airplaneBox = new St.BoxLayout({ vertical: true, + style_class: 'nm-dialog-airplane-box', + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER }); + this._airplaneIcon = new St.Icon({ icon_size: 48 }); + this._airplaneHeadline = new St.Label({ style_class: 'nm-dialog-airplane-headline headline' }); + this._airplaneText = new St.Label({ style_class: 'nm-dialog-airplane-text' }); + + let airplaneSubStack = new St.Widget({ layout_manager: new Clutter.BinLayout() }); + this._airplaneButton = new St.Button({ style_class: 'modal-dialog-button button' }); + this._airplaneButton.connect('clicked', () => { + if (this._rfkill.airplaneMode) + this._rfkill.airplaneMode = false; + else + this._client.wireless_enabled = true; + }); + airplaneSubStack.add_actor(this._airplaneButton); + this._airplaneInactive = new St.Label({ style_class: 'nm-dialog-airplane-text', + text: _("Use hardware switch to turn off") }); + airplaneSubStack.add_actor(this._airplaneInactive); + + this._airplaneBox.add_child(this._airplaneIcon); + this._airplaneBox.add_child(this._airplaneHeadline); + this._airplaneBox.add_child(this._airplaneText); + this._airplaneBox.add_child(airplaneSubStack); + this._stack.add_child(this._airplaneBox); + + this.contentLayout.add_child(this._stack); + + this._disconnectButton = this.addButton({ action: () => this.close(), + label: _("Cancel"), + key: Clutter.KEY_Escape }); + this._connectButton = this.addButton({ action: this._connect.bind(this), + label: _("Connect"), + key: Clutter.KEY_Return }); + } + + _connect() { + let network = this._selectedNetwork; + if (network.connections.length > 0) { + let connection = network.connections[0]; + this._client.activate_connection_async(connection, this._device, null, null, null); + } else { + let accessPoints = network.accessPoints; + if ((accessPoints[0]._secType == NMAccessPointSecurity.WPA2_ENT) || + (accessPoints[0]._secType == NMAccessPointSecurity.WPA_ENT)) { + // 802.1x-enabled APs require further configuration, so they're + // handled in gnome-control-center + launchSettingsPanel('wifi', 'connect-8021x-wifi', + this._device.get_path(), accessPoints[0].get_path()); + } else { + let connection = new NM.SimpleConnection(); + this._client.add_and_activate_connection_async(connection, this._device, accessPoints[0].get_path(), null, null); + } + } + + this.close(); + } + + _notifySsidCb(accessPoint) { + if (accessPoint.get_ssid() != null) { + accessPoint.disconnect(accessPoint._notifySsidId); + accessPoint._notifySsidId = 0; + this._accessPointAdded(this._device, accessPoint); + } + } + + _getApSecurityType(accessPoint) { + if (accessPoint._secType) + return accessPoint._secType; + + let flags = accessPoint.flags; + let wpaFlags = accessPoint.wpa_flags; + let rsnFlags = accessPoint.rsn_flags; + let type; + if (rsnFlags != NM80211ApSecurityFlags.NONE) { + /* RSN check first so that WPA+WPA2 APs are treated as RSN/WPA2 */ + if (rsnFlags & NM80211ApSecurityFlags.KEY_MGMT_802_1X) + type = NMAccessPointSecurity.WPA2_ENT; + else if (rsnFlags & NM80211ApSecurityFlags.KEY_MGMT_PSK) + type = NMAccessPointSecurity.WPA2_PSK; + } else if (wpaFlags != NM80211ApSecurityFlags.NONE) { + if (wpaFlags & NM80211ApSecurityFlags.KEY_MGMT_802_1X) + type = NMAccessPointSecurity.WPA_ENT; + else if (wpaFlags & NM80211ApSecurityFlags.KEY_MGMT_PSK) + type = NMAccessPointSecurity.WPA_PSK; + } else { + // eslint-disable-next-line no-lonely-if + if (flags & NM80211ApFlags.PRIVACY) + type = NMAccessPointSecurity.WEP; + else + type = NMAccessPointSecurity.NONE; + } + + // cache the found value to avoid checking flags all the time + accessPoint._secType = type; + return type; + } + + _networkSortFunction(one, two) { + let oneHasConnection = one.connections.length != 0; + let twoHasConnection = two.connections.length != 0; + + // place known connections first + // (-1 = good order, 1 = wrong order) + if (oneHasConnection && !twoHasConnection) + return -1; + else if (!oneHasConnection && twoHasConnection) + return 1; + + let oneAp = one.accessPoints[0] || null; + let twoAp = two.accessPoints[0] || null; + + if (oneAp != null && twoAp == null) + return -1; + else if (oneAp == null && twoAp != null) + return 1; + + let oneStrength = oneAp.strength; + let twoStrength = twoAp.strength; + + // place stronger connections first + if (oneStrength != twoStrength) + return oneStrength < twoStrength ? 1 : -1; + + let oneHasSecurity = one.security != NMAccessPointSecurity.NONE; + let twoHasSecurity = two.security != NMAccessPointSecurity.NONE; + + // place secure connections first + // (we treat WEP/WPA/WPA2 the same as there is no way to + // take them apart from the UI) + if (oneHasSecurity && !twoHasSecurity) + return -1; + else if (!oneHasSecurity && twoHasSecurity) + return 1; + + // sort alphabetically + return GLib.utf8_collate(one.ssidText, two.ssidText); + } + + _networkCompare(network, accessPoint) { + if (!network.ssid.equal(accessPoint.get_ssid())) + return false; + if (network.mode != accessPoint.mode) + return false; + if (network.security != this._getApSecurityType(accessPoint)) + return false; + + return true; + } + + _findExistingNetwork(accessPoint) { + for (let i = 0; i < this._networks.length; i++) { + let network = this._networks[i]; + for (let j = 0; j < network.accessPoints.length; j++) { + if (network.accessPoints[j] == accessPoint) + return { network: i, ap: j }; + } + } + + return null; + } + + _findNetwork(accessPoint) { + if (accessPoint.get_ssid() == null) + return -1; + + for (let i = 0; i < this._networks.length; i++) { + if (this._networkCompare(this._networks[i], accessPoint)) + return i; + } + return -1; + } + + _checkConnections(network, accessPoint) { + this._connections.forEach(connection => { + if (accessPoint.connection_valid(connection) && + !network.connections.includes(connection)) + network.connections.push(connection); + }); + } + + _accessPointAdded(device, accessPoint) { + if (accessPoint.get_ssid() == null) { + // This access point is not visible yet + // Wait for it to get a ssid + accessPoint._notifySsidId = accessPoint.connect('notify::ssid', this._notifySsidCb.bind(this)); + return; + } + + let pos = this._findNetwork(accessPoint); + let network; + + if (pos != -1) { + network = this._networks[pos]; + if (network.accessPoints.includes(accessPoint)) { + log('Access point was already seen, not adding again'); + return; + } + + Util.insertSorted(network.accessPoints, accessPoint, (one, two) => { + return two.strength - one.strength; + }); + network.item.updateBestAP(network.accessPoints[0]); + this._checkConnections(network, accessPoint); + + this._resortItems(); + } else { + network = { + ssid: accessPoint.get_ssid(), + mode: accessPoint.mode, + security: this._getApSecurityType(accessPoint), + connections: [], + item: null, + accessPoints: [accessPoint], + }; + network.ssidText = ssidToLabel(network.ssid); + this._checkConnections(network, accessPoint); + + let newPos = Util.insertSorted(this._networks, network, this._networkSortFunction); + this._createNetworkItem(network); + this._itemBox.insert_child_at_index(network.item, newPos); + } + + this._syncView(); + } + + _accessPointRemoved(device, accessPoint) { + let res = this._findExistingNetwork(accessPoint); + + if (res == null) { + log('Removing an access point that was never added'); + return; + } + + let network = this._networks[res.network]; + network.accessPoints.splice(res.ap, 1); + + if (network.accessPoints.length == 0) { + network.item.destroy(); + this._networks.splice(res.network, 1); + } else { + network.item.updateBestAP(network.accessPoints[0]); + this._resortItems(); + } + + this._syncView(); + } + + _resortItems() { + let adjustment = this._scrollView.vscroll.adjustment; + let scrollValue = adjustment.value; + + this._itemBox.remove_all_children(); + this._networks.forEach(network => { + this._itemBox.add_child(network.item); + }); + + adjustment.value = scrollValue; + } + + _selectNetwork(network) { + if (this._selectedNetwork) + this._selectedNetwork.item.remove_style_pseudo_class('selected'); + + this._selectedNetwork = network; + this._updateSensitivity(); + + if (this._selectedNetwork) + this._selectedNetwork.item.add_style_pseudo_class('selected'); + } + + _createNetworkItem(network) { + network.item = new NMWirelessDialogItem(network); + network.item.setActive(network == this._selectedNetwork); + network.item.connect('selected', () => { + Util.ensureActorVisibleInScrollView(this._scrollView, network.item); + this._selectNetwork(network); + }); + network.item.connect('destroy', () => { + let keyFocus = global.stage.key_focus; + if (keyFocus && keyFocus.contains(network.item)) + this._itemBox.grab_key_focus(); + }); + } +}); + +var NMDeviceWireless = class { + constructor(client, device) { + this._client = client; + this._device = device; + + this._description = ''; + + this.item = new PopupMenu.PopupSubMenuMenuItem('', true); + this.item.menu.addAction(_("Select Network"), this._showDialog.bind(this)); + + this._toggleItem = new PopupMenu.PopupMenuItem(''); + this._toggleItem.connect('activate', this._toggleWifi.bind(this)); + this.item.menu.addMenuItem(this._toggleItem); + + this.item.menu.addSettingsAction(_("Wi-Fi Settings"), 'gnome-wifi-panel.desktop'); + + this._wirelessEnabledChangedId = this._client.connect('notify::wireless-enabled', this._sync.bind(this)); + this._wirelessHwEnabledChangedId = this._client.connect('notify::wireless-hardware-enabled', this._sync.bind(this)); + this._activeApChangedId = this._device.connect('notify::active-access-point', this._activeApChanged.bind(this)); + this._stateChangedId = this._device.connect('state-changed', this._deviceStateChanged.bind(this)); + this._notifyConnectivityId = this._client.connect('notify::connectivity', this._iconChanged.bind(this)); + + this._sync(); + } + + get category() { + return NMConnectionCategory.WIRELESS; + } + + _iconChanged() { + this._sync(); + this.emit('icon-changed'); + } + + destroy() { + if (this._activeApChangedId) { + GObject.signal_handler_disconnect(this._device, this._activeApChangedId); + this._activeApChangedId = 0; + } + if (this._stateChangedId) { + GObject.signal_handler_disconnect(this._device, this._stateChangedId); + this._stateChangedId = 0; + } + if (this._strengthChangedId > 0) { + this._activeAccessPoint.disconnect(this._strengthChangedId); + this._strengthChangedId = 0; + } + if (this._wirelessEnabledChangedId) { + this._client.disconnect(this._wirelessEnabledChangedId); + this._wirelessEnabledChangedId = 0; + } + if (this._wirelessHwEnabledChangedId) { + this._client.disconnect(this._wirelessHwEnabledChangedId); + this._wirelessHwEnabledChangedId = 0; + } + if (this._dialog) { + this._dialog.destroy(); + this._dialog = null; + } + if (this._notifyConnectivityId) { + this._client.disconnect(this._notifyConnectivityId); + this._notifyConnectivityId = 0; + } + + this.item.destroy(); + } + + _deviceStateChanged(device, newstate, oldstate, reason) { + if (newstate == oldstate) { + log('device emitted state-changed without actually changing state'); + return; + } + + /* Emit a notification if activation fails, but don't do it + if the reason is no secrets, as that indicates the user + cancelled the agent dialog */ + if (newstate == NM.DeviceState.FAILED && + reason != NM.DeviceStateReason.NO_SECRETS) + this.emit('activation-failed', reason); + + this._sync(); + } + + _toggleWifi() { + this._client.wireless_enabled = !this._client.wireless_enabled; + } + + _showDialog() { + this._dialog = new NMWirelessDialog(this._client, this._device); + this._dialog.connect('closed', this._dialogClosed.bind(this)); + this._dialog.open(); + } + + _dialogClosed() { + this._dialog = null; + } + + _strengthChanged() { + this._iconChanged(); + } + + _activeApChanged() { + if (this._activeAccessPoint) { + this._activeAccessPoint.disconnect(this._strengthChangedId); + this._strengthChangedId = 0; + } + + this._activeAccessPoint = this._device.active_access_point; + + if (this._activeAccessPoint) { + this._strengthChangedId = this._activeAccessPoint.connect('notify::strength', + this._strengthChanged.bind(this)); + } + + this._sync(); + } + + _sync() { + this._toggleItem.label.text = this._client.wireless_enabled ? _("Turn Off") : _("Turn On"); + this._toggleItem.visible = this._client.wireless_hardware_enabled; + + this.item.icon.icon_name = this._getMenuIcon(); + this.item.label.text = this._getStatus(); + } + + setDeviceDescription(desc) { + this._description = desc; + this._sync(); + } + + _getStatus() { + let ap = this._device.active_access_point; + + if (this._isHotSpotMaster()) + /* Translators: %s is a network identifier */ + return _("%s Hotspot Active").format(this._description); + else if (this._device.state >= NM.DeviceState.PREPARE && + this._device.state < NM.DeviceState.ACTIVATED) + /* Translators: %s is a network identifier */ + return _("%s Connecting").format(this._description); + else if (ap) + return ssidToLabel(ap.get_ssid()); + else if (!this._client.wireless_hardware_enabled) + /* Translators: %s is a network identifier */ + return _("%s Hardware Disabled").format(this._description); + else if (!this._client.wireless_enabled) + /* Translators: %s is a network identifier */ + return _("%s Off").format(this._description); + else if (this._device.state == NM.DeviceState.DISCONNECTED) + /* Translators: %s is a network identifier */ + return _("%s Not Connected").format(this._description); + else + return ''; + } + + _getMenuIcon() { + if (this._device.active_connection) + return this.getIndicatorIcon(); + else + return 'network-wireless-signal-none-symbolic'; + } + + _canReachInternet() { + if (this._client.primary_connection != this._device.active_connection) + return true; + + return this._client.connectivity == NM.ConnectivityState.FULL; + } + + _isHotSpotMaster() { + if (!this._device.active_connection) + return false; + + let connection = this._device.active_connection.connection; + if (!connection) + return false; + + let ip4config = connection.get_setting_ip4_config(); + if (!ip4config) + return false; + + return ip4config.get_method() == NM.SETTING_IP4_CONFIG_METHOD_SHARED; + } + + getIndicatorIcon() { + if (this._device.state < NM.DeviceState.PREPARE) + return 'network-wireless-disconnected-symbolic'; + if (this._device.state < NM.DeviceState.ACTIVATED) + return 'network-wireless-acquiring-symbolic'; + + if (this._isHotSpotMaster()) + return 'network-wireless-hotspot-symbolic'; + + let ap = this._device.active_access_point; + if (!ap) { + if (this._device.mode != NM80211Mode.ADHOC) + log('An active wireless connection, in infrastructure mode, involves no access point?'); + + if (this._canReachInternet()) + return 'network-wireless-connected-symbolic'; + else + return 'network-wireless-no-route-symbolic'; + } + + if (this._canReachInternet()) + return 'network-wireless-signal-%s-symbolic'.format(signalToIcon(ap.strength)); + else + return 'network-wireless-no-route-symbolic'; + } +}; +Signals.addSignalMethods(NMDeviceWireless.prototype); + +var NMVpnConnectionItem = class extends NMConnectionItem { + isActive() { + if (this._activeConnection == null) + return false; + + return this._activeConnection.vpn_state != NM.VpnConnectionState.DISCONNECTED; + } + + _buildUI() { + this.labelItem = new PopupMenu.PopupMenuItem(''); + this.labelItem.connect('activate', this._toggle.bind(this)); + + this.radioItem = new PopupMenu.PopupSwitchMenuItem(this._connection.get_id(), false); + this.radioItem.connect('toggled', this._toggle.bind(this)); + } + + _sync() { + let isActive = this.isActive(); + this.labelItem.label.text = isActive ? _("Turn Off") : this._section.getConnectLabel(); + this.radioItem.setToggleState(isActive); + this.radioItem.setStatus(this._getStatus()); + this.emit('icon-changed'); + } + + _getStatus() { + if (this._activeConnection == null) + return null; + + switch (this._activeConnection.vpn_state) { + case NM.VpnConnectionState.DISCONNECTED: + case NM.VpnConnectionState.ACTIVATED: + return null; + case NM.VpnConnectionState.PREPARE: + case NM.VpnConnectionState.CONNECT: + case NM.VpnConnectionState.IP_CONFIG_GET: + return _("connecting…"); + case NM.VpnConnectionState.NEED_AUTH: + /* Translators: this is for network connections that require some kind of key or password */ + return _("authentication required"); + case NM.VpnConnectionState.FAILED: + return _("connection failed"); + default: + return 'invalid'; + } + } + + _connectionStateChanged(ac, newstate, reason) { + if (newstate == NM.VpnConnectionState.FAILED && + reason != NM.VpnConnectionStateReason.NO_SECRETS) { + // FIXME: if we ever want to show something based on reason, + // we need to convert from NM.VpnConnectionStateReason + // to NM.DeviceStateReason + this.emit('activation-failed', reason); + } + + this.emit('icon-changed'); + super._connectionStateChanged(); + } + + setActiveConnection(activeConnection) { + if (this._activeConnectionChangedId > 0) { + this._activeConnection.disconnect(this._activeConnectionChangedId); + this._activeConnectionChangedId = 0; + } + + this._activeConnection = activeConnection; + + if (this._activeConnection) { + this._activeConnectionChangedId = this._activeConnection.connect('vpn-state-changed', + this._connectionStateChanged.bind(this)); + } + + this._sync(); + } + + getIndicatorIcon() { + if (this._activeConnection) { + if (this._activeConnection.vpn_state < NM.VpnConnectionState.ACTIVATED) + return 'network-vpn-acquiring-symbolic'; + else + return 'network-vpn-symbolic'; + } else { + return ''; + } + } +}; + +var NMVpnSection = class extends NMConnectionSection { + constructor(client) { + super(client); + + this.item.menu.addSettingsAction(_("VPN Settings"), 'gnome-network-panel.desktop'); + + this._sync(); + } + + _sync() { + let nItems = this._connectionItems.size; + this.item.visible = nItems > 0; + + super._sync(); + } + + get category() { + return NMConnectionCategory.VPN; + } + + _getDescription() { + return _("VPN"); + } + + _getStatus() { + let values = this._connectionItems.values(); + for (let item of values) { + if (item.isActive()) + return item.getName(); + } + + return _("VPN Off"); + } + + _getMenuIcon() { + return this.getIndicatorIcon() || 'network-vpn-symbolic'; + } + + activateConnection(connection) { + this._client.activate_connection_async(connection, null, null, null, null); + } + + deactivateConnection(activeConnection) { + this._client.deactivate_connection(activeConnection, null); + } + + setActiveConnections(vpnConnections) { + let connections = this._connectionItems.values(); + for (let item of connections) + item.setActiveConnection(null); + + vpnConnections.forEach(a => { + if (a.connection) { + let item = this._connectionItems.get(a.connection.get_uuid()); + item.setActiveConnection(a); + } + }); + } + + _makeConnectionItem(connection) { + return new NMVpnConnectionItem(this, connection); + } + + getIndicatorIcon() { + let items = this._connectionItems.values(); + for (let item of items) { + let icon = item.getIndicatorIcon(); + if (icon) + return icon; + } + return ''; + } +}; +Signals.addSignalMethods(NMVpnSection.prototype); + +var DeviceCategory = class extends PopupMenu.PopupMenuSection { + constructor(category) { + super(); + + this._category = category; + + this.devices = []; + + this.section = new PopupMenu.PopupMenuSection(); + this.section.box.connect('actor-added', this._sync.bind(this)); + this.section.box.connect('actor-removed', this._sync.bind(this)); + this.addMenuItem(this.section); + + this._summaryItem = new PopupMenu.PopupSubMenuMenuItem('', true); + this._summaryItem.icon.icon_name = this._getSummaryIcon(); + this.addMenuItem(this._summaryItem); + + this._summaryItem.menu.addSettingsAction(_('Network Settings'), + 'gnome-network-panel.desktop'); + this._summaryItem.hide(); + + } + + _sync() { + let nDevices = this.section.box.get_children().reduce( + (prev, child) => prev + (child.visible ? 1 : 0), 0); + this._summaryItem.label.text = this._getSummaryLabel(nDevices); + let shouldSummarize = nDevices > MAX_DEVICE_ITEMS; + this._summaryItem.visible = shouldSummarize; + this.section.actor.visible = !shouldSummarize; + } + + _getSummaryIcon() { + switch (this._category) { + case NMConnectionCategory.WIRED: + return 'network-wired-symbolic'; + case NMConnectionCategory.WIRELESS: + case NMConnectionCategory.WWAN: + return 'network-wireless-symbolic'; + } + return ''; + } + + _getSummaryLabel(nDevices) { + switch (this._category) { + case NMConnectionCategory.WIRED: + return ngettext("%s Wired Connection", + "%s Wired Connections", + nDevices).format(nDevices); + case NMConnectionCategory.WIRELESS: + return ngettext("%s Wi-Fi Connection", + "%s Wi-Fi Connections", + nDevices).format(nDevices); + case NMConnectionCategory.WWAN: + return ngettext("%s Modem Connection", + "%s Modem Connections", + nDevices).format(nDevices); + } + return ''; + } +}; + +var NMApplet = GObject.registerClass( +class Indicator extends PanelMenu.SystemIndicator { + _init() { + super._init(); + + this._primaryIndicator = this._addIndicator(); + this._vpnIndicator = this._addIndicator(); + + // Device types + this._dtypes = { }; + this._dtypes[NM.DeviceType.ETHERNET] = NMDeviceWired; + this._dtypes[NM.DeviceType.WIFI] = NMDeviceWireless; + this._dtypes[NM.DeviceType.MODEM] = NMDeviceModem; + this._dtypes[NM.DeviceType.BT] = NMDeviceBluetooth; + + // Connection types + this._ctypes = { }; + this._ctypes[NM.SETTING_WIRED_SETTING_NAME] = NMConnectionCategory.WIRED; + this._ctypes[NM.SETTING_WIRELESS_SETTING_NAME] = NMConnectionCategory.WIRELESS; + this._ctypes[NM.SETTING_BLUETOOTH_SETTING_NAME] = NMConnectionCategory.WWAN; + this._ctypes[NM.SETTING_CDMA_SETTING_NAME] = NMConnectionCategory.WWAN; + this._ctypes[NM.SETTING_GSM_SETTING_NAME] = NMConnectionCategory.WWAN; + this._ctypes[NM.SETTING_VPN_SETTING_NAME] = NMConnectionCategory.VPN; + + this._getClient(); + } + + async _getClient() { + this._client = await NM.Client.new_async(null); + + this._activeConnections = []; + this._connections = []; + this._connectivityQueue = []; + + this._mainConnection = null; + this._mainConnectionIconChangedId = 0; + this._mainConnectionStateChangedId = 0; + + this._notification = null; + + this._nmDevices = []; + this._devices = { }; + + let categories = [NMConnectionCategory.WIRED, + NMConnectionCategory.WIRELESS, + NMConnectionCategory.WWAN]; + for (let category of categories) { + this._devices[category] = new DeviceCategory(category); + this.menu.addMenuItem(this._devices[category]); + } + + this._vpnSection = new NMVpnSection(this._client); + this._vpnSection.connect('activation-failed', this._onActivationFailed.bind(this)); + this._vpnSection.connect('icon-changed', this._updateIcon.bind(this)); + this.menu.addMenuItem(this._vpnSection.item); + + this._readConnections(); + this._readDevices(); + this._syncNMState(); + this._syncMainConnection(); + this._syncVpnConnections(); + + this._client.connect('notify::nm-running', this._syncNMState.bind(this)); + this._client.connect('notify::networking-enabled', this._syncNMState.bind(this)); + this._client.connect('notify::state', this._syncNMState.bind(this)); + this._client.connect('notify::primary-connection', this._syncMainConnection.bind(this)); + this._client.connect('notify::activating-connection', this._syncMainConnection.bind(this)); + this._client.connect('notify::active-connections', this._syncVpnConnections.bind(this)); + this._client.connect('notify::connectivity', this._syncConnectivity.bind(this)); + this._client.connect('device-added', this._deviceAdded.bind(this)); + this._client.connect('device-removed', this._deviceRemoved.bind(this)); + this._client.connect('connection-added', this._connectionAdded.bind(this)); + this._client.connect('connection-removed', this._connectionRemoved.bind(this)); + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + this._sessionUpdated(); + } + + _sessionUpdated() { + let sensitive = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter; + this.menu.setSensitive(sensitive); + } + + _ensureSource() { + if (!this._source) { + this._source = new MessageTray.Source(_("Network Manager"), + 'network-transmit-receive'); + this._source.policy = new MessageTray.NotificationApplicationPolicy('gnome-network-panel'); + + this._source.connect('destroy', () => (this._source = null)); + Main.messageTray.add(this._source); + } + } + + _readDevices() { + let devices = this._client.get_devices() || []; + for (let i = 0; i < devices.length; ++i) { + try { + this._deviceAdded(this._client, devices[i], true); + } catch (e) { + log('Failed to add device %s: %s'.format(devices[i], e.toString())); + } + } + this._syncDeviceNames(); + } + + _notify(iconName, title, text, urgency) { + if (this._notification) + this._notification.destroy(); + + this._ensureSource(); + + let gicon = new Gio.ThemedIcon({ name: iconName }); + this._notification = new MessageTray.Notification(this._source, title, text, { gicon }); + this._notification.setUrgency(urgency); + this._notification.setTransient(true); + this._notification.connect('destroy', () => { + this._notification = null; + }); + this._source.showNotification(this._notification); + } + + _onActivationFailed(_device, _reason) { + // XXX: nm-applet has no special text depending on reason + // but I'm not sure of this generic message + this._notify('network-error-symbolic', + _("Connection failed"), + _("Activation of network connection failed"), + MessageTray.Urgency.HIGH); + } + + _syncDeviceNames() { + let names = NM.Device.disambiguate_names(this._nmDevices); + for (let i = 0; i < this._nmDevices.length; i++) { + let device = this._nmDevices[i]; + let description = names[i]; + if (device._delegate) + device._delegate.setDeviceDescription(description); + } + } + + _deviceAdded(client, device, skipSyncDeviceNames) { + if (device._delegate) { + // already seen, not adding again + return; + } + + let wrapperClass = this._dtypes[device.get_device_type()]; + if (wrapperClass) { + let wrapper = new wrapperClass(this._client, device); + device._delegate = wrapper; + this._addDeviceWrapper(wrapper); + + this._nmDevices.push(device); + this._deviceChanged(device, skipSyncDeviceNames); + + device.connect('notify::interface', () => { + this._deviceChanged(device, false); + }); + } + } + + _deviceChanged(device, skipSyncDeviceNames) { + let wrapper = device._delegate; + + if (!skipSyncDeviceNames) + this._syncDeviceNames(); + + if (wrapper instanceof NMConnectionSection) { + this._connections.forEach(connection => { + wrapper.checkConnection(connection); + }); + } + } + + _addDeviceWrapper(wrapper) { + wrapper._activationFailedId = wrapper.connect('activation-failed', + this._onActivationFailed.bind(this)); + + let section = this._devices[wrapper.category].section; + section.addMenuItem(wrapper.item); + + let devices = this._devices[wrapper.category].devices; + devices.push(wrapper); + } + + _deviceRemoved(client, device) { + let pos = this._nmDevices.indexOf(device); + if (pos != -1) { + this._nmDevices.splice(pos, 1); + this._syncDeviceNames(); + } + + let wrapper = device._delegate; + if (!wrapper) { + log('Removing a network device that was not added'); + return; + } + + this._removeDeviceWrapper(wrapper); + } + + _removeDeviceWrapper(wrapper) { + wrapper.disconnect(wrapper._activationFailedId); + wrapper.destroy(); + + let devices = this._devices[wrapper.category].devices; + let pos = devices.indexOf(wrapper); + devices.splice(pos, 1); + } + + _getMainConnection() { + let connection; + + connection = this._client.get_primary_connection(); + if (connection) { + ensureActiveConnectionProps(connection); + return connection; + } + + connection = this._client.get_activating_connection(); + if (connection) { + ensureActiveConnectionProps(connection); + return connection; + } + + return null; + } + + _syncMainConnection() { + if (this._mainConnectionIconChangedId > 0) { + this._mainConnection._primaryDevice.disconnect(this._mainConnectionIconChangedId); + this._mainConnectionIconChangedId = 0; + } + + if (this._mainConnectionStateChangedId > 0) { + this._mainConnection.disconnect(this._mainConnectionStateChangedId); + this._mainConnectionStateChangedId = 0; + } + + this._mainConnection = this._getMainConnection(); + + if (this._mainConnection) { + if (this._mainConnection._primaryDevice) + this._mainConnectionIconChangedId = this._mainConnection._primaryDevice.connect('icon-changed', this._updateIcon.bind(this)); + this._mainConnectionStateChangedId = this._mainConnection.connect('notify::state', this._mainConnectionStateChanged.bind(this)); + this._mainConnectionStateChanged(); + } + + this._updateIcon(); + this._syncConnectivity(); + } + + _syncVpnConnections() { + let activeConnections = this._client.get_active_connections() || []; + let vpnConnections = activeConnections.filter( + a => a instanceof NM.VpnConnection); + vpnConnections.forEach(a => { + ensureActiveConnectionProps(a); + }); + this._vpnSection.setActiveConnections(vpnConnections); + + this._updateIcon(); + } + + _mainConnectionStateChanged() { + if (this._mainConnection.state == NM.ActiveConnectionState.ACTIVATED && this._notification) + this._notification.destroy(); + } + + _ignoreConnection(connection) { + let setting = connection.get_setting_connection(); + if (!setting) + return true; + + // Ignore slave connections + if (setting.get_master()) + return true; + + return false; + } + + _addConnection(connection) { + if (this._ignoreConnection(connection)) + return; + if (connection._updatedId) { + // connection was already seen + return; + } + + connection._updatedId = connection.connect('changed', this._updateConnection.bind(this)); + + this._updateConnection(connection); + this._connections.push(connection); + } + + _readConnections() { + let connections = this._client.get_connections(); + connections.forEach(this._addConnection.bind(this)); + } + + _connectionAdded(client, connection) { + this._addConnection(connection); + } + + _connectionRemoved(client, connection) { + let pos = this._connections.indexOf(connection); + if (pos != -1) + this._connections.splice(pos, 1); + + let section = connection._section; + + if (section == NMConnectionCategory.INVALID) + return; + + if (section == NMConnectionCategory.VPN) { + this._vpnSection.removeConnection(connection); + } else { + let devices = this._devices[section].devices; + for (let i = 0; i < devices.length; i++) { + if (devices[i] instanceof NMConnectionSection) + devices[i].removeConnection(connection); + } + } + + connection.disconnect(connection._updatedId); + connection._updatedId = 0; + } + + _updateConnection(connection) { + let connectionSettings = connection.get_setting_by_name(NM.SETTING_CONNECTION_SETTING_NAME); + connection._type = connectionSettings.type; + connection._section = this._ctypes[connection._type] || NMConnectionCategory.INVALID; + + let section = connection._section; + + if (section == NMConnectionCategory.INVALID) + return; + + if (section == NMConnectionCategory.VPN) { + this._vpnSection.checkConnection(connection); + } else { + let devices = this._devices[section].devices; + devices.forEach(wrapper => { + if (wrapper instanceof NMConnectionSection) + wrapper.checkConnection(connection); + }); + } + } + + _syncNMState() { + this.visible = this._client.nm_running; + this.menu.actor.visible = this._client.networking_enabled; + + this._updateIcon(); + this._syncConnectivity(); + } + + _flushConnectivityQueue() { + if (this._portalHelperProxy) { + for (let item of this._connectivityQueue) + this._portalHelperProxy.CloseRemote(item); + } + + this._connectivityQueue = []; + } + + _closeConnectivityCheck(path) { + let index = this._connectivityQueue.indexOf(path); + + if (index >= 0) { + if (this._portalHelperProxy) + this._portalHelperProxy.CloseRemote(path); + + this._connectivityQueue.splice(index, 1); + } + } + + async _portalHelperDone(proxy, emitter, parameters) { + let [path, result] = parameters; + + if (result == PortalHelperResult.CANCELLED) { + // Keep the connection in the queue, so the user is not + // spammed with more logins until we next flush the queue, + // which will happen once he chooses a better connection + // or we get to full connectivity through other means + } else if (result == PortalHelperResult.COMPLETED) { + this._closeConnectivityCheck(path); + } else if (result == PortalHelperResult.RECHECK) { + try { + const state = await this._client.check_connectivity_async(null); + if (state >= NM.ConnectivityState.FULL) + this._closeConnectivityCheck(path); + } catch (e) { } + } else { + log('Invalid result from portal helper: %s'.format(result)); + } + } + + _syncConnectivity() { + if (this._mainConnection == null || + this._mainConnection.state != NM.ActiveConnectionState.ACTIVATED) { + this._flushConnectivityQueue(); + return; + } + + let isPortal = this._client.connectivity == NM.ConnectivityState.PORTAL; + // For testing, allow interpreting any value != FULL as PORTAL, because + // LIMITED (no upstream route after the default gateway) is easy to obtain + // with a tethered phone + // NONE is also possible, with a connection configured to force no default route + // (but in general we should only prompt a portal if we know there is a portal) + if (GLib.getenv('GNOME_SHELL_CONNECTIVITY_TEST') != null) + isPortal = isPortal || this._client.connectivity < NM.ConnectivityState.FULL; + if (!isPortal || Main.sessionMode.isGreeter) + return; + + let path = this._mainConnection.get_path(); + for (let item of this._connectivityQueue) { + if (item == path) + return; + } + + let timestamp = global.get_current_time(); + if (this._portalHelperProxy) { + this._portalHelperProxy.AuthenticateRemote(path, '', timestamp); + } else { + new PortalHelperProxy(Gio.DBus.session, 'org.gnome.Shell.PortalHelper', + '/org/gnome/Shell/PortalHelper', (proxy, error) => { + if (error) { + log('Error launching the portal helper: %s'.format(error)); + return; + } + + this._portalHelperProxy = proxy; + proxy.connectSignal('Done', this._portalHelperDone.bind(this)); + + proxy.AuthenticateRemote(path, '', timestamp); + }); + } + + this._connectivityQueue.push(path); + } + + _updateIcon() { + if (!this._client.networking_enabled) { + this._primaryIndicator.visible = false; + } else { + let dev = null; + if (this._mainConnection) + dev = this._mainConnection._primaryDevice; + + let state = this._client.get_state(); + let connected = state == NM.State.CONNECTED_GLOBAL; + this._primaryIndicator.visible = (dev != null) || connected; + if (dev) { + this._primaryIndicator.icon_name = dev.getIndicatorIcon(); + } else if (connected) { + if (this._client.connectivity == NM.ConnectivityState.FULL) + this._primaryIndicator.icon_name = 'network-wired-symbolic'; + else + this._primaryIndicator.icon_name = 'network-wired-no-route-symbolic'; + } + } + + this._vpnIndicator.icon_name = this._vpnSection.getIndicatorIcon(); + this._vpnIndicator.visible = this._vpnIndicator.icon_name !== null; + } +}); diff --git a/js/ui/status/nightLight.js b/js/ui/status/nightLight.js new file mode 100644 index 0000000..c595c3d --- /dev/null +++ b/js/ui/status/nightLight.js @@ -0,0 +1,70 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const { Gio, GObject } = imports.gi; + +const Main = imports.ui.main; +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const BUS_NAME = 'org.gnome.SettingsDaemon.Color'; +const OBJECT_PATH = '/org/gnome/SettingsDaemon/Color'; + +const ColorInterface = loadInterfaceXML('org.gnome.SettingsDaemon.Color'); +const ColorProxy = Gio.DBusProxy.makeProxyWrapper(ColorInterface); + +var Indicator = GObject.registerClass( +class Indicator extends PanelMenu.SystemIndicator { + _init() { + super._init(); + + this._indicator = this._addIndicator(); + this._indicator.icon_name = 'night-light-symbolic'; + this._proxy = new ColorProxy(Gio.DBus.session, BUS_NAME, OBJECT_PATH, + (proxy, error) => { + if (error) { + log(error.message); + return; + } + this._proxy.connect('g-properties-changed', + this._sync.bind(this)); + this._sync(); + }); + + this._item = new PopupMenu.PopupSubMenuMenuItem("", true); + this._item.icon.icon_name = 'night-light-symbolic'; + this._disableItem = this._item.menu.addAction('', () => { + this._proxy.DisabledUntilTomorrow = !this._proxy.DisabledUntilTomorrow; + }); + this._item.menu.addAction(_("Turn Off"), () => { + let settings = new Gio.Settings({ schema_id: 'org.gnome.settings-daemon.plugins.color' }); + settings.set_boolean('night-light-enabled', false); + }); + this._item.menu.addSettingsAction(_("Display Settings"), 'gnome-display-panel.desktop'); + this.menu.addMenuItem(this._item); + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + this._sessionUpdated(); + this._sync(); + } + + _sessionUpdated() { + let sensitive = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter; + this.menu.setSensitive(sensitive); + } + + _sync() { + let visible = this._proxy.NightLightActive; + let disabled = this._proxy.DisabledUntilTomorrow; + + this._item.label.text = disabled + ? _("Night Light Disabled") + : _("Night Light On"); + this._disableItem.label.text = disabled + ? _("Resume") + : _("Disable Until Tomorrow"); + this._item.visible = this._indicator.visible = visible; + } +}); diff --git a/js/ui/status/power.js b/js/ui/status/power.js new file mode 100644 index 0000000..ca85a98 --- /dev/null +++ b/js/ui/status/power.js @@ -0,0 +1,155 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const { Clutter, Gio, GObject, St, UPowerGlib: UPower } = imports.gi; + +const Main = imports.ui.main; +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const BUS_NAME = 'org.freedesktop.UPower'; +const OBJECT_PATH = '/org/freedesktop/UPower/devices/DisplayDevice'; + +const DisplayDeviceInterface = loadInterfaceXML('org.freedesktop.UPower.Device'); +const PowerManagerProxy = Gio.DBusProxy.makeProxyWrapper(DisplayDeviceInterface); + +const SHOW_BATTERY_PERCENTAGE = 'show-battery-percentage'; + +var Indicator = GObject.registerClass( +class Indicator extends PanelMenu.SystemIndicator { + _init() { + super._init(); + + this._desktopSettings = new Gio.Settings({ + schema_id: 'org.gnome.desktop.interface', + }); + this._desktopSettings.connect( + 'changed::%s'.format(SHOW_BATTERY_PERCENTAGE), this._sync.bind(this)); + + this._indicator = this._addIndicator(); + this._percentageLabel = new St.Label({ + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + this.add_child(this._percentageLabel); + this.add_style_class_name('power-status'); + + this._proxy = new PowerManagerProxy(Gio.DBus.system, BUS_NAME, OBJECT_PATH, + (proxy, error) => { + if (error) { + log(error.message); + } else { + this._proxy.connect('g-properties-changed', + this._sync.bind(this)); + } + this._sync(); + }); + + this._item = new PopupMenu.PopupSubMenuMenuItem('', true); + this._item.menu.addSettingsAction(_('Power Settings'), + 'gnome-power-panel.desktop'); + this.menu.addMenuItem(this._item); + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + this._sessionUpdated(); + } + + _sessionUpdated() { + let sensitive = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter; + this.menu.setSensitive(sensitive); + } + + _getStatus() { + let seconds = 0; + + if (this._proxy.State === UPower.DeviceState.FULLY_CHARGED) + return _('Fully Charged'); + else if (this._proxy.State === UPower.DeviceState.CHARGING) + seconds = this._proxy.TimeToFull; + else if (this._proxy.State === UPower.DeviceState.DISCHARGING) + seconds = this._proxy.TimeToEmpty; + else if (this._proxy.State === UPower.DeviceState.PENDING_CHARGE) + return _('Not Charging'); + // state is PENDING_DISCHARGE + else + return _('Estimating…'); + + let time = Math.round(seconds / 60); + if (time === 0) { + // 0 is reported when UPower does not have enough data + // to estimate battery life + return _('Estimating…'); + } + + let minutes = time % 60; + let hours = Math.floor(time / 60); + + if (this._proxy.State === UPower.DeviceState.DISCHARGING) { + // Translators: this is <hours>:<minutes> Remaining (<percentage>) + return _('%d\u2236%02d Remaining (%d\u2009%%)').format( + hours, minutes, this._proxy.Percentage); + } + + if (this._proxy.State === UPower.DeviceState.CHARGING) { + // Translators: this is <hours>:<minutes> Until Full (<percentage>) + return _('%d\u2236%02d Until Full (%d\u2009%%)').format( + hours, minutes, this._proxy.Percentage); + } + + return null; + } + + _sync() { + // Do we have batteries or a UPS? + let visible = this._proxy.IsPresent; + if (visible) { + this._item.show(); + this._percentageLabel.visible = + this._desktopSettings.get_boolean(SHOW_BATTERY_PERCENTAGE); + } else { + // If there's no battery, then we use the power icon. + this._item.hide(); + this._indicator.icon_name = 'system-shutdown-symbolic'; + this._percentageLabel.hide(); + return; + } + + // The icons + let chargingState = this._proxy.State === UPower.DeviceState.CHARGING + ? '-charging' : ''; + let fillLevel = 10 * Math.floor(this._proxy.Percentage / 10); + const charged = + this._proxy.State === UPower.DeviceState.FULLY_CHARGED || + (this._proxy.State === UPower.DeviceState.CHARGING && fillLevel === 100); + const icon = charged + ? 'battery-level-100-charged-symbolic' + : 'battery-level-%d%s-symbolic'.format(fillLevel, chargingState); + + // Make sure we fall back to fallback-icon-name and not GThemedIcon's + // default fallbacks + let gicon = new Gio.ThemedIcon({ + name: icon, + use_default_fallbacks: false, + }); + + this._indicator.gicon = gicon; + this._item.icon.gicon = gicon; + + let fallbackIcon = this._proxy.IconName; + this._indicator.fallback_icon_name = fallbackIcon; + this._item.icon.fallback_icon_name = fallbackIcon; + + // The icon label + let label; + if (this._proxy.State == UPower.DeviceState.FULLY_CHARGED) + label = _("%d\u2009%%").format(100); + else + label = _("%d\u2009%%").format(this._proxy.Percentage); + this._percentageLabel.text = label; + + // The status label + this._item.label.text = this._getStatus(); + } +}); diff --git a/js/ui/status/remoteAccess.js b/js/ui/status/remoteAccess.js new file mode 100644 index 0000000..21f6581 --- /dev/null +++ b/js/ui/status/remoteAccess.js @@ -0,0 +1,97 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported RemoteAccessApplet */ + +const { GObject, Meta } = imports.gi; + +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; + +var RemoteAccessApplet = GObject.registerClass( +class RemoteAccessApplet extends PanelMenu.SystemIndicator { + _init() { + super._init(); + + let controller = global.backend.get_remote_access_controller(); + + if (!controller) + return; + + this._handles = new Set(); + this._sharedIndicator = null; + this._recordingIndicator = null; + this._menuSection = null; + + controller.connect('new-handle', (o, handle) => { + this._onNewHandle(handle); + }); + } + + _ensureControls() { + if (this._sharedIndicator && this._recordingIndicator) + return; + + this._sharedIndicator = this._addIndicator(); + this._sharedIndicator.icon_name = 'screen-shared-symbolic'; + this._sharedIndicator.add_style_class_name('remote-access-indicator'); + + this._sharedItem = + new PopupMenu.PopupSubMenuMenuItem(_("Screen is Being Shared"), + true); + this._sharedItem.menu.addAction(_("Turn off"), + () => { + for (let handle of this._handles) { + if (!handle.is_recording) + handle.stop(); + } + }); + this._sharedItem.icon.icon_name = 'screen-shared-symbolic'; + this.menu.addMenuItem(this._sharedItem); + + this._recordingIndicator = this._addIndicator(); + this._recordingIndicator.icon_name = 'media-record-symbolic'; + this._recordingIndicator.add_style_class_name('screencast-indicator'); + } + + _isScreenShared() { + return [...this._handles].some(handle => !handle.is_recording); + } + + _isRecording() { + return [...this._handles].some(handle => handle.is_recording); + } + + _sync() { + if (this._isScreenShared()) { + this._sharedIndicator.visible = true; + this._sharedItem.visible = true; + } else { + this._sharedIndicator.visible = false; + this._sharedItem.visible = false; + } + + this._recordingIndicator.visible = this._isRecording(); + } + + _onStopped(handle) { + this._handles.delete(handle); + this._sync(); + } + + _onNewHandle(handle) { + // We can't possibly know about all types of screen sharing on X11, so + // showing these controls on X11 might give a false sense of security. + // Thus, only enable these controls when using Wayland, where we are + // in control of sharing. + // + // We still want to show screen recordings though, to indicate when + // the built in screen recorder is active, no matter the session type. + if (!Meta.is_wayland_compositor() && !handle.is_recording) + return; + + this._handles.add(handle); + handle.connect('stopped', this._onStopped.bind(this)); + + this._ensureControls(); + this._sync(); + } +}); diff --git a/js/ui/status/rfkill.js b/js/ui/status/rfkill.js new file mode 100644 index 0000000..9f8b09d --- /dev/null +++ b/js/ui/status/rfkill.js @@ -0,0 +1,112 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const { Gio, GObject } = imports.gi; +const Signals = imports.signals; + +const Main = imports.ui.main; +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const BUS_NAME = 'org.gnome.SettingsDaemon.Rfkill'; +const OBJECT_PATH = '/org/gnome/SettingsDaemon/Rfkill'; + +const RfkillManagerInterface = loadInterfaceXML('org.gnome.SettingsDaemon.Rfkill'); +const RfkillManagerProxy = Gio.DBusProxy.makeProxyWrapper(RfkillManagerInterface); + +var RfkillManager = class { + constructor() { + this._proxy = new RfkillManagerProxy(Gio.DBus.session, BUS_NAME, OBJECT_PATH, + (proxy, error) => { + if (error) { + log(error.message); + return; + } + this._proxy.connect('g-properties-changed', + this._changed.bind(this)); + this._changed(); + }); + } + + get airplaneMode() { + return this._proxy.AirplaneMode; + } + + set airplaneMode(v) { + this._proxy.AirplaneMode = v; + } + + get hwAirplaneMode() { + return this._proxy.HardwareAirplaneMode; + } + + get shouldShowAirplaneMode() { + return this._proxy.ShouldShowAirplaneMode; + } + + _changed() { + this.emit('airplane-mode-changed'); + } +}; +Signals.addSignalMethods(RfkillManager.prototype); + +var _manager; +function getRfkillManager() { + if (_manager != null) + return _manager; + + _manager = new RfkillManager(); + return _manager; +} + +var Indicator = GObject.registerClass( +class Indicator extends PanelMenu.SystemIndicator { + _init() { + super._init(); + + this._manager = getRfkillManager(); + this._manager.connect('airplane-mode-changed', this._sync.bind(this)); + + this._indicator = this._addIndicator(); + this._indicator.icon_name = 'airplane-mode-symbolic'; + this._indicator.hide(); + + // The menu only appears when airplane mode is on, so just + // statically build it as if it was on, rather than dynamically + // changing the menu contents. + this._item = new PopupMenu.PopupSubMenuMenuItem(_("Airplane Mode On"), true); + this._item.icon.icon_name = 'airplane-mode-symbolic'; + this._offItem = this._item.menu.addAction(_("Turn Off"), () => { + this._manager.airplaneMode = false; + }); + this._item.menu.addSettingsAction(_("Network Settings"), 'gnome-network-panel.desktop'); + this.menu.addMenuItem(this._item); + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + this._sessionUpdated(); + + this._sync(); + } + + _sessionUpdated() { + let sensitive = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter; + this.menu.setSensitive(sensitive); + } + + _sync() { + let airplaneMode = this._manager.airplaneMode; + let hwAirplaneMode = this._manager.hwAirplaneMode; + let showAirplaneMode = this._manager.shouldShowAirplaneMode; + + this._indicator.visible = airplaneMode && showAirplaneMode; + this._item.visible = airplaneMode && showAirplaneMode; + this._offItem.setSensitive(!hwAirplaneMode); + + if (hwAirplaneMode) + this._offItem.label.text = _("Use hardware switch to turn off"); + else + this._offItem.label.text = _("Turn Off"); + } +}); diff --git a/js/ui/status/system.js b/js/ui/status/system.js new file mode 100644 index 0000000..6f71109 --- /dev/null +++ b/js/ui/status/system.js @@ -0,0 +1,178 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const { GObject, Shell, St } = imports.gi; + +const BoxPointer = imports.ui.boxpointer; +const SystemActions = imports.misc.systemActions; +const Main = imports.ui.main; +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; + + +var Indicator = GObject.registerClass( +class Indicator extends PanelMenu.SystemIndicator { + _init() { + super._init(); + + this._systemActions = new SystemActions.getDefault(); + + this._createSubMenu(); + + this._loginScreenItem.connect('notify::visible', + () => this._updateSessionSubMenu()); + this._logoutItem.connect('notify::visible', + () => this._updateSessionSubMenu()); + this._suspendItem.connect('notify::visible', + () => this._updateSessionSubMenu()); + this._powerOffItem.connect('notify::visible', + () => this._updateSessionSubMenu()); + this._restartItem.connect('notify::visible', + () => this._updateSessionSubMenu()); + // Whether shutdown is available or not depends on both lockdown + // settings (disable-log-out) and Polkit policy - the latter doesn't + // notify, so we update the menu item each time the menu opens or + // the lockdown setting changes, which should be close enough. + this.menu.connect('open-state-changed', (menu, open) => { + if (!open) + return; + + this._systemActions.forceUpdate(); + }); + this._updateSessionSubMenu(); + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + this._sessionUpdated(); + } + + _sessionUpdated() { + this._settingsItem.visible = Main.sessionMode.allowSettings; + } + + _updateSessionSubMenu() { + this._sessionSubMenu.visible = + this._loginScreenItem.visible || + this._logoutItem.visible || + this._suspendItem.visible || + this._restartItem.visible || + this._powerOffItem.visible; + } + + _createSubMenu() { + let bindFlags = GObject.BindingFlags.DEFAULT | GObject.BindingFlags.SYNC_CREATE; + let item; + + item = new PopupMenu.PopupImageMenuItem( + this._systemActions.getName('lock-orientation'), + this._systemActions.orientation_lock_icon); + + item.connect('activate', () => { + this.menu.itemActivated(BoxPointer.PopupAnimation.NONE); + this._systemActions.activateLockOrientation(); + }); + this.menu.addMenuItem(item); + this._orientationLockItem = item; + this._systemActions.bind_property('can-lock-orientation', + this._orientationLockItem, 'visible', + bindFlags); + this._systemActions.connect('notify::orientation-lock-icon', () => { + let iconName = this._systemActions.orientation_lock_icon; + let labelText = this._systemActions.getName("lock-orientation"); + + this._orientationLockItem.setIcon(iconName); + this._orientationLockItem.label.text = labelText; + }); + + let app = this._settingsApp = Shell.AppSystem.get_default().lookup_app( + 'gnome-control-center.desktop'); + if (app) { + const [icon] = app.app_info.get_icon().names; + const name = app.app_info.get_name(); + item = new PopupMenu.PopupImageMenuItem(name, icon); + item.connect('activate', () => { + this.menu.itemActivated(BoxPointer.PopupAnimation.NONE); + Main.overview.hide(); + this._settingsApp.activate(); + }); + this.menu.addMenuItem(item); + this._settingsItem = item; + } else { + log('Missing required core component Settings, expect trouble…'); + this._settingsItem = new St.Widget(); + } + + item = new PopupMenu.PopupImageMenuItem(_('Lock'), 'changes-prevent-symbolic'); + item.connect('activate', () => { + this.menu.itemActivated(BoxPointer.PopupAnimation.NONE); + this._systemActions.activateLockScreen(); + }); + this.menu.addMenuItem(item); + this._lockScreenItem = item; + this._systemActions.bind_property('can-lock-screen', + this._lockScreenItem, 'visible', + bindFlags); + + this._sessionSubMenu = new PopupMenu.PopupSubMenuMenuItem( + _('Power Off / Log Out'), true); + this._sessionSubMenu.icon.icon_name = 'system-shutdown-symbolic'; + + item = new PopupMenu.PopupMenuItem(_('Suspend')); + item.connect('activate', () => { + this.menu.itemActivated(BoxPointer.PopupAnimation.NONE); + this._systemActions.activateSuspend(); + }); + this._sessionSubMenu.menu.addMenuItem(item); + this._suspendItem = item; + this._systemActions.bind_property('can-suspend', + this._suspendItem, 'visible', + bindFlags); + + item = new PopupMenu.PopupMenuItem(_('Restart…')); + item.connect('activate', () => { + this.menu.itemActivated(BoxPointer.PopupAnimation.NONE); + this._systemActions.activateRestart(); + }); + this._sessionSubMenu.menu.addMenuItem(item); + this._restartItem = item; + this._systemActions.bind_property('can-restart', + this._restartItem, 'visible', + bindFlags); + + item = new PopupMenu.PopupMenuItem(_('Power Off…')); + item.connect('activate', () => { + this.menu.itemActivated(BoxPointer.PopupAnimation.NONE); + this._systemActions.activatePowerOff(); + }); + this._sessionSubMenu.menu.addMenuItem(item); + this._powerOffItem = item; + this._systemActions.bind_property('can-power-off', + this._powerOffItem, 'visible', + bindFlags); + + this._sessionSubMenu.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + item = new PopupMenu.PopupMenuItem(_('Log Out')); + item.connect('activate', () => { + this.menu.itemActivated(BoxPointer.PopupAnimation.NONE); + this._systemActions.activateLogout(); + }); + this._sessionSubMenu.menu.addMenuItem(item); + this._logoutItem = item; + this._systemActions.bind_property('can-logout', + this._logoutItem, 'visible', + bindFlags); + + item = new PopupMenu.PopupMenuItem(_('Switch User…')); + item.connect('activate', () => { + this.menu.itemActivated(BoxPointer.PopupAnimation.NONE); + this._systemActions.activateSwitchUser(); + }); + this._sessionSubMenu.menu.addMenuItem(item); + this._loginScreenItem = item; + this._systemActions.bind_property('can-switch-user', + this._loginScreenItem, 'visible', + bindFlags); + + this.menu.addMenuItem(this._sessionSubMenu); + } +}); diff --git a/js/ui/status/thunderbolt.js b/js/ui/status/thunderbolt.js new file mode 100644 index 0000000..d98355d --- /dev/null +++ b/js/ui/status/thunderbolt.js @@ -0,0 +1,340 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +// the following is a modified version of bolt/contrib/js/client.js + +const { Gio, GLib, GObject, Polkit, Shell } = imports.gi; +const Signals = imports.signals; + +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; +const PanelMenu = imports.ui.panelMenu; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +/* Keep in sync with data/org.freedesktop.bolt.xml */ + +const BoltClientInterface = loadInterfaceXML('org.freedesktop.bolt1.Manager'); +const BoltDeviceInterface = loadInterfaceXML('org.freedesktop.bolt1.Device'); + +const BoltDeviceProxy = Gio.DBusProxy.makeProxyWrapper(BoltDeviceInterface); + +/* */ + +var Status = { + DISCONNECTED: 'disconnected', + CONNECTING: 'connecting', + CONNECTED: 'connected', + AUTHORIZING: 'authorizing', + AUTH_ERROR: 'auth-error', + AUTHORIZED: 'authorized', +}; + +var Policy = { + DEFAULT: 'default', + MANUAL: 'manual', + AUTO: 'auto', +}; + +var AuthCtrl = { + NONE: 'none', +}; + +var AuthMode = { + DISABLED: 'disabled', + ENABLED: 'enabled', +}; + +const BOLT_DBUS_CLIENT_IFACE = 'org.freedesktop.bolt1.Manager'; +const BOLT_DBUS_NAME = 'org.freedesktop.bolt'; +const BOLT_DBUS_PATH = '/org/freedesktop/bolt'; + +var Client = class { + constructor() { + this._proxy = null; + this.probing = false; + this._getProxy(); + } + + async _getProxy() { + let nodeInfo = Gio.DBusNodeInfo.new_for_xml(BoltClientInterface); + try { + this._proxy = await Gio.DBusProxy.new( + Gio.DBus.system, + Gio.DBusProxyFlags.DO_NOT_AUTO_START, + nodeInfo.lookup_interface(BOLT_DBUS_CLIENT_IFACE), + BOLT_DBUS_NAME, + BOLT_DBUS_PATH, + BOLT_DBUS_CLIENT_IFACE, + null); + } catch (e) { + log('error creating bolt proxy: %s'.format(e.message)); + return; + } + this._propsChangedId = this._proxy.connect('g-properties-changed', this._onPropertiesChanged.bind(this)); + this._deviceAddedId = this._proxy.connectSignal('DeviceAdded', this._onDeviceAdded.bind(this)); + + this.probing = this._proxy.Probing; + if (this.probing) + this.emit('probing-changed', this.probing); + + } + + _onPropertiesChanged(proxy, properties) { + let unpacked = properties.deep_unpack(); + if (!('Probing' in unpacked)) + return; + + this.probing = this._proxy.Probing; + this.emit('probing-changed', this.probing); + } + + _onDeviceAdded(proxy, emitter, params) { + let [path] = params; + let device = new BoltDeviceProxy(Gio.DBus.system, + BOLT_DBUS_NAME, + path); + this.emit('device-added', device); + } + + /* public methods */ + close() { + if (!this._proxy) + return; + + this._proxy.disconnectSignal(this._deviceAddedId); + this._proxy.disconnect(this._propsChangedId); + this._proxy = null; + } + + enrollDevice(id, policy, callback) { + this._proxy.EnrollDeviceRemote(id, policy, AuthCtrl.NONE, (res, error) => { + if (error) { + Gio.DBusError.strip_remote_error(error); + callback(null, error); + return; + } + + let [path] = res; + let device = new BoltDeviceProxy(Gio.DBus.system, + BOLT_DBUS_NAME, + path); + callback(device, null); + }); + } + + get authMode() { + return this._proxy.AuthMode; + } +}; +Signals.addSignalMethods(Client.prototype); + +/* helper class to automatically authorize new devices */ +var AuthRobot = class { + constructor(client) { + this._client = client; + + this._devicesToEnroll = []; + this._enrolling = false; + + this._client.connect('device-added', this._onDeviceAdded.bind(this)); + } + + close() { + this.disconnectAll(); + this._client = null; + } + + /* the "device-added" signal will be emitted by boltd for every + * device that is not currently stored in the database. We are + * only interested in those devices, because all known devices + * will be handled by the user himself */ + _onDeviceAdded(cli, dev) { + if (dev.Status !== Status.CONNECTED) + return; + + /* check if authorization is enabled in the daemon. if not + * we won't even bother authorizing, because we will only + * get an error back. The exact contents of AuthMode might + * change in the future, but must contain AuthMode.ENABLED + * if it is enabled. */ + if (!cli.authMode.split('|').includes(AuthMode.ENABLED)) + return; + + /* check if we should enroll the device */ + let res = [false]; + this.emit('enroll-device', dev, res); + if (res[0] !== true) + return; + + /* ok, we should authorize the device, add it to the back + * of the list */ + this._devicesToEnroll.push(dev); + this._enrollDevices(); + } + + /* The enrollment queue: + * - new devices will be added to the end of the array. + * - an idle callback will be scheduled that will keep + * calling itself as long as there a devices to be + * enrolled. + */ + _enrollDevices() { + if (this._enrolling) + return; + + this._enrolling = true; + GLib.idle_add(GLib.PRIORITY_DEFAULT, + this._enrollDevicesIdle.bind(this)); + } + + _onEnrollDone(device, error) { + if (error) + this.emit('enroll-failed', device, error); + + /* TODO: scan the list of devices to be authorized for children + * of this device and remove them (and their children and + * their children and ....) from the device queue + */ + this._enrolling = this._devicesToEnroll.length > 0; + + if (this._enrolling) { + GLib.idle_add(GLib.PRIORITY_DEFAULT, + this._enrollDevicesIdle.bind(this)); + } + } + + _enrollDevicesIdle() { + let devices = this._devicesToEnroll; + + let dev = devices.shift(); + if (dev === undefined) + return GLib.SOURCE_REMOVE; + + this._client.enrollDevice(dev.Uid, + Policy.DEFAULT, + this._onEnrollDone.bind(this)); + return GLib.SOURCE_REMOVE; + } +}; +Signals.addSignalMethods(AuthRobot.prototype); + +/* eof client.js */ + +var Indicator = GObject.registerClass( +class Indicator extends PanelMenu.SystemIndicator { + _init() { + super._init(); + + this._indicator = this._addIndicator(); + this._indicator.icon_name = 'thunderbolt-symbolic'; + + this._client = new Client(); + this._client.connect('probing-changed', this._onProbing.bind(this)); + + this._robot = new AuthRobot(this._client); + + this._robot.connect('enroll-device', this._onEnrollDevice.bind(this)); + this._robot.connect('enroll-failed', this._onEnrollFailed.bind(this)); + + Main.sessionMode.connect('updated', this._sync.bind(this)); + this._sync(); + + this._source = null; + this._perm = null; + this._createPermission(); + } + + async _createPermission() { + try { + this._perm = await Polkit.Permission.new('org.freedesktop.bolt.enroll', null, null); + } catch (e) { + log('Failed to get PolKit permission: %s'.format(e.toString())); + } + } + + _onDestroy() { + this._robot.close(); + this._client.close(); + } + + _ensureSource() { + if (!this._source) { + this._source = new MessageTray.Source(_("Thunderbolt"), + 'thunderbolt-symbolic'); + this._source.connect('destroy', () => (this._source = null)); + + Main.messageTray.add(this._source); + } + + return this._source; + } + + _notify(title, body) { + if (this._notification) + this._notification.destroy(); + + let source = this._ensureSource(); + + this._notification = new MessageTray.Notification(source, title, body); + this._notification.setUrgency(MessageTray.Urgency.HIGH); + this._notification.connect('destroy', () => { + this._notification = null; + }); + this._notification.connect('activated', () => { + let app = Shell.AppSystem.get_default().lookup_app('gnome-thunderbolt-panel.desktop'); + if (app) + app.activate(); + }); + this._source.showNotification(this._notification); + } + + /* Session callbacks */ + _sync() { + let active = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter; + this._indicator.visible = active && this._client.probing; + } + + /* Bolt.Client callbacks */ + _onProbing(cli, probing) { + if (probing) + this._indicator.icon_name = 'thunderbolt-acquiring-symbolic'; + else + this._indicator.icon_name = 'thunderbolt-symbolic'; + + this._sync(); + } + + /* AuthRobot callbacks */ + _onEnrollDevice(obj, device, policy) { + /* only authorize new devices when in an unlocked user session */ + let unlocked = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter; + /* and if we have the permission to do so, otherwise we trigger a PolKit dialog */ + let allowed = this._perm && this._perm.allowed; + + let auth = unlocked && allowed; + policy[0] = auth; + + log('thunderbolt: [%s] auto enrollment: %s (allowed: %s)'.format( + device.Name, auth ? 'yes' : 'no', allowed ? 'yes' : 'no')); + + if (auth) + return; /* we are done */ + + if (!unlocked) { + const title = _("Unknown Thunderbolt device"); + const body = _("New device has been detected while you were away. Please disconnect and reconnect the device to start using it."); + this._notify(title, body); + } else { + const title = _("Unauthorized Thunderbolt device"); + const body = _("New device has been detected and needs to be authorized by an administrator."); + this._notify(title, body); + } + } + + _onEnrollFailed(obj, device, error) { + const title = _("Thunderbolt authorization error"); + const body = _("Could not authorize the Thunderbolt device: %s").format(error.message); + this._notify(title, body); + } +}); diff --git a/js/ui/status/volume.js b/js/ui/status/volume.js new file mode 100644 index 0000000..7b50658 --- /dev/null +++ b/js/ui/status/volume.js @@ -0,0 +1,430 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const { Clutter, Gio, GLib, GObject, Gvc, St } = imports.gi; +const Signals = imports.signals; + +const Main = imports.ui.main; +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; +const Slider = imports.ui.slider; + +const ALLOW_AMPLIFIED_VOLUME_KEY = 'allow-volume-above-100-percent'; + +// Each Gvc.MixerControl is a connection to PulseAudio, +// so it's better to make it a singleton +let _mixerControl; +function getMixerControl() { + if (_mixerControl) + return _mixerControl; + + _mixerControl = new Gvc.MixerControl({ name: 'GNOME Shell Volume Control' }); + _mixerControl.open(); + + return _mixerControl; +} + +var StreamSlider = class { + constructor(control) { + this._control = control; + + this.item = new PopupMenu.PopupBaseMenuItem({ activate: false }); + + this._inDrag = false; + this._notifyVolumeChangeId = 0; + + this._slider = new Slider.Slider(0); + + this._soundSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.sound' }); + this._soundSettings.connect('changed::%s'.format(ALLOW_AMPLIFIED_VOLUME_KEY), this._amplifySettingsChanged.bind(this)); + this._amplifySettingsChanged(); + + this._sliderChangedId = this._slider.connect('notify::value', + this._sliderChanged.bind(this)); + this._slider.connect('drag-begin', () => (this._inDrag = true)); + this._slider.connect('drag-end', () => { + this._inDrag = false; + this._notifyVolumeChange(); + }); + + this._icon = new St.Icon({ style_class: 'popup-menu-icon' }); + this.item.add(this._icon); + this.item.add_child(this._slider); + this.item.connect('button-press-event', (actor, event) => { + return this._slider.startDragging(event); + }); + this.item.connect('key-press-event', (actor, event) => { + return this._slider.emit('key-press-event', event); + }); + this.item.connect('scroll-event', (actor, event) => { + return this._slider.emit('scroll-event', event); + }); + + this._stream = null; + this._volumeCancellable = null; + this._icons = []; + } + + get stream() { + return this._stream; + } + + set stream(stream) { + if (this._stream) + this._disconnectStream(this._stream); + + this._stream = stream; + + if (this._stream) { + this._connectStream(this._stream); + this._updateVolume(); + } else { + this.emit('stream-updated'); + } + + this._updateVisibility(); + } + + _disconnectStream(stream) { + stream.disconnect(this._mutedChangedId); + this._mutedChangedId = 0; + stream.disconnect(this._volumeChangedId); + this._volumeChangedId = 0; + } + + _connectStream(stream) { + this._mutedChangedId = stream.connect('notify::is-muted', this._updateVolume.bind(this)); + this._volumeChangedId = stream.connect('notify::volume', this._updateVolume.bind(this)); + } + + _shouldBeVisible() { + return this._stream != null; + } + + _updateVisibility() { + let visible = this._shouldBeVisible(); + this.item.visible = visible; + } + + scroll(event) { + return this._slider.scroll(event); + } + + _sliderChanged() { + if (!this._stream) + return; + + let value = this._slider.value; + let volume = value * this._control.get_vol_max_norm(); + let prevMuted = this._stream.is_muted; + let prevVolume = this._stream.volume; + if (volume < 1) { + this._stream.volume = 0; + if (!prevMuted) + this._stream.change_is_muted(true); + } else { + this._stream.volume = volume; + if (prevMuted) + this._stream.change_is_muted(false); + } + this._stream.push_volume(); + + let volumeChanged = this._stream.volume !== prevVolume; + if (volumeChanged && !this._notifyVolumeChangeId && !this._inDrag) { + this._notifyVolumeChangeId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 30, () => { + this._notifyVolumeChange(); + this._notifyVolumeChangeId = 0; + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._notifyVolumeChangeId, + '[gnome-shell] this._notifyVolumeChangeId'); + } + } + + _notifyVolumeChange() { + if (this._volumeCancellable) + this._volumeCancellable.cancel(); + this._volumeCancellable = null; + + if (this._stream.state === Gvc.MixerStreamState.RUNNING) + return; // feedback not necessary while playing + + this._volumeCancellable = new Gio.Cancellable(); + let player = global.display.get_sound_player(); + player.play_from_theme('audio-volume-change', + _("Volume changed"), + this._volumeCancellable); + } + + _changeSlider(value) { + this._slider.block_signal_handler(this._sliderChangedId); + this._slider.value = value; + this._slider.unblock_signal_handler(this._sliderChangedId); + } + + _updateVolume() { + let muted = this._stream.is_muted; + this._changeSlider(muted + ? 0 : this._stream.volume / this._control.get_vol_max_norm()); + this.emit('stream-updated'); + } + + _amplifySettingsChanged() { + this._allowAmplified = this._soundSettings.get_boolean(ALLOW_AMPLIFIED_VOLUME_KEY); + + this._slider.maximum_value = this._allowAmplified + ? this.getMaxLevel() : 1; + + if (this._stream) + this._updateVolume(); + } + + getIcon() { + if (!this._stream) + return null; + + let volume = this._stream.volume; + let n; + if (this._stream.is_muted || volume <= 0) { + n = 0; + } else { + n = Math.ceil(3 * volume / this._control.get_vol_max_norm()); + n = Math.clamp(n, 1, this._icons.length - 1); + } + return this._icons[n]; + } + + getLevel() { + if (!this._stream) + return null; + + return this._stream.volume / this._control.get_vol_max_norm(); + } + + getMaxLevel() { + let maxVolume = this._control.get_vol_max_norm(); + if (this._allowAmplified) + maxVolume = this._control.get_vol_max_amplified(); + + return maxVolume / this._control.get_vol_max_norm(); + } +}; +Signals.addSignalMethods(StreamSlider.prototype); + +var OutputStreamSlider = class extends StreamSlider { + constructor(control) { + super(control); + this._slider.accessible_name = _("Volume"); + this._icons = [ + 'audio-volume-muted-symbolic', + 'audio-volume-low-symbolic', + 'audio-volume-medium-symbolic', + 'audio-volume-high-symbolic', + 'audio-volume-overamplified-symbolic', + ]; + } + + _connectStream(stream) { + super._connectStream(stream); + this._portChangedId = stream.connect('notify::port', this._portChanged.bind(this)); + this._portChanged(); + } + + _findHeadphones(sink) { + // This only works for external headphones (e.g. bluetooth) + if (sink.get_form_factor() == 'headset' || + sink.get_form_factor() == 'headphone') + return true; + + // a bit hackish, but ALSA/PulseAudio have a number + // of different identifiers for headphones, and I could + // not find the complete list + if (sink.get_ports().length > 0) + return sink.get_port().port.includes('headphone'); + + return false; + } + + _disconnectStream(stream) { + super._disconnectStream(stream); + stream.disconnect(this._portChangedId); + this._portChangedId = 0; + } + + _updateSliderIcon() { + this._icon.icon_name = this._hasHeadphones + ? 'audio-headphones-symbolic' + : 'audio-speakers-symbolic'; + } + + _portChanged() { + let hasHeadphones = this._findHeadphones(this._stream); + if (hasHeadphones != this._hasHeadphones) { + this._hasHeadphones = hasHeadphones; + this._updateSliderIcon(); + } + } +}; + +var InputStreamSlider = class extends StreamSlider { + constructor(control) { + super(control); + this._slider.accessible_name = _("Microphone"); + this._control.connect('stream-added', this._maybeShowInput.bind(this)); + this._control.connect('stream-removed', this._maybeShowInput.bind(this)); + this._icon.icon_name = 'audio-input-microphone-symbolic'; + this._icons = [ + 'microphone-sensitivity-muted-symbolic', + 'microphone-sensitivity-low-symbolic', + 'microphone-sensitivity-medium-symbolic', + 'microphone-sensitivity-high-symbolic', + ]; + } + + _connectStream(stream) { + super._connectStream(stream); + this._maybeShowInput(); + } + + _maybeShowInput() { + // only show input widgets if any application is recording audio + let showInput = false; + if (this._stream) { + // skip gnome-volume-control and pavucontrol which appear + // as recording because they show the input level + let skippedApps = [ + 'org.gnome.VolumeControl', + 'org.PulseAudio.pavucontrol', + ]; + + showInput = this._control.get_source_outputs().some(output => { + return !skippedApps.includes(output.get_application_id()); + }); + } + + this._showInput = showInput; + this._updateVisibility(); + } + + _shouldBeVisible() { + return super._shouldBeVisible() && this._showInput; + } +}; + +var VolumeMenu = class extends PopupMenu.PopupMenuSection { + constructor(control) { + super(); + + this.hasHeadphones = false; + + this._control = control; + this._control.connect('state-changed', this._onControlStateChanged.bind(this)); + this._control.connect('default-sink-changed', this._readOutput.bind(this)); + this._control.connect('default-source-changed', this._readInput.bind(this)); + + this._output = new OutputStreamSlider(this._control); + this._output.connect('stream-updated', () => { + this.emit('output-icon-changed'); + }); + this.addMenuItem(this._output.item); + + this._input = new InputStreamSlider(this._control); + this._input.item.connect('notify::visible', () => { + this.emit('input-visible-changed'); + }); + this._input.connect('stream-updated', () => { + this.emit('input-icon-changed'); + }); + this.addMenuItem(this._input.item); + + this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + this._onControlStateChanged(); + } + + scroll(event) { + return this._output.scroll(event); + } + + _onControlStateChanged() { + if (this._control.get_state() == Gvc.MixerControlState.READY) { + this._readInput(); + this._readOutput(); + } else { + this.emit('output-icon-changed'); + } + } + + _readOutput() { + this._output.stream = this._control.get_default_sink(); + } + + _readInput() { + this._input.stream = this._control.get_default_source(); + } + + getOutputIcon() { + return this._output.getIcon(); + } + + getInputIcon() { + return this._input.getIcon(); + } + + getLevel() { + return this._output.getLevel(); + } + + getMaxLevel() { + return this._output.getMaxLevel(); + } + + getInputVisible() { + return this._input.item.visible; + } +}; + +var Indicator = GObject.registerClass( +class Indicator extends PanelMenu.SystemIndicator { + _init() { + super._init(); + + this._primaryIndicator = this._addIndicator(); + this._inputIndicator = this._addIndicator(); + + this._control = getMixerControl(); + this._volumeMenu = new VolumeMenu(this._control); + this._volumeMenu.connect('output-icon-changed', () => { + let icon = this._volumeMenu.getOutputIcon(); + + if (icon != null) + this._primaryIndicator.icon_name = icon; + this._primaryIndicator.visible = icon !== null; + }); + + this._inputIndicator.visible = this._volumeMenu.getInputVisible(); + this._volumeMenu.connect('input-visible-changed', () => { + this._inputIndicator.visible = this._volumeMenu.getInputVisible(); + }); + this._volumeMenu.connect('input-icon-changed', () => { + let icon = this._volumeMenu.getInputIcon(); + + if (icon !== null) + this._inputIndicator.icon_name = icon; + }); + + this.menu.addMenuItem(this._volumeMenu); + } + + vfunc_scroll_event() { + let result = this._volumeMenu.scroll(Clutter.get_current_event()); + if (result == Clutter.EVENT_PROPAGATE || this.menu.actor.mapped) + return result; + + let gicon = new Gio.ThemedIcon({ name: this._volumeMenu.getOutputIcon() }); + let level = this._volumeMenu.getLevel(); + let maxLevel = this._volumeMenu.getMaxLevel(); + Main.osdWindowManager.show(-1, gicon, null, level, maxLevel); + return result; + } +}); diff --git a/js/ui/swipeTracker.js b/js/ui/swipeTracker.js new file mode 100644 index 0000000..fd13fd7 --- /dev/null +++ b/js/ui/swipeTracker.js @@ -0,0 +1,666 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported SwipeTracker */ + +const { Clutter, Gio, GObject, Meta } = imports.gi; + +const Main = imports.ui.main; +const Params = imports.misc.params; + +// FIXME: ideally these values matches physical touchpad size. We can get the +// correct values for gnome-shell specifically, since mutter uses libinput +// directly, but GTK apps cannot get it, so use an arbitrary value so that +// it's consistent with apps. +const TOUCHPAD_BASE_HEIGHT = 300; +const TOUCHPAD_BASE_WIDTH = 400; + +const SCROLL_MULTIPLIER = 10; +const SWIPE_MULTIPLIER = 0.5; + +const MIN_ANIMATION_DURATION = 100; +const MAX_ANIMATION_DURATION = 400; +const VELOCITY_THRESHOLD = 0.4; +// Derivative of easeOutCubic at t=0 +const DURATION_MULTIPLIER = 3; +const ANIMATION_BASE_VELOCITY = 0.002; + +const State = { + NONE: 0, + SCROLLING: 1, +}; + +const TouchpadSwipeGesture = GObject.registerClass({ + Properties: { + 'enabled': GObject.ParamSpec.boolean( + 'enabled', 'enabled', 'enabled', + GObject.ParamFlags.READWRITE, + true), + 'orientation': GObject.ParamSpec.enum( + 'orientation', 'orientation', 'orientation', + GObject.ParamFlags.READWRITE, + Clutter.Orientation, Clutter.Orientation.VERTICAL), + }, + Signals: { + 'begin': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] }, + 'update': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE] }, + 'end': { param_types: [GObject.TYPE_UINT] }, + }, +}, class TouchpadSwipeGesture extends GObject.Object { + _init(allowedModes) { + super._init(); + this._allowedModes = allowedModes; + this._touchpadSettings = new Gio.Settings({ + schema_id: 'org.gnome.desktop.peripherals.touchpad', + }); + this._orientation = Clutter.Orientation.VERTICAL; + this._enabled = true; + + this._stageCaptureEvent = + global.stage.connect('captured-event::touchpad', this._handleEvent.bind(this)); + } + + get enabled() { + return this._enabled; + } + + set enabled(enabled) { + if (this._enabled === enabled) + return; + + this._enabled = enabled; + this.notify('enabled'); + } + + get orientation() { + return this._orientation; + } + + set orientation(orientation) { + if (this._orientation === orientation) + return; + + this._orientation = orientation; + this.notify('orientation'); + } + + _handleEvent(actor, event) { + if (event.type() !== Clutter.EventType.TOUCHPAD_SWIPE) + return Clutter.EVENT_PROPAGATE; + + if (event.get_touchpad_gesture_finger_count() !== 4) + return Clutter.EVENT_PROPAGATE; + + if ((this._allowedModes & Main.actionMode) === 0) + return Clutter.EVENT_PROPAGATE; + + if (!this.enabled) + return Clutter.EVENT_PROPAGATE; + + let time = event.get_time(); + + let [x, y] = event.get_coords(); + let [dx, dy] = event.get_gesture_motion_delta(); + + let delta; + if (this._orientation === Clutter.Orientation.VERTICAL) + delta = dy / TOUCHPAD_BASE_HEIGHT; + else + delta = dx / TOUCHPAD_BASE_WIDTH; + + switch (event.get_gesture_phase()) { + case Clutter.TouchpadGesturePhase.BEGIN: + this.emit('begin', time, x, y); + break; + + case Clutter.TouchpadGesturePhase.UPDATE: + if (this._touchpadSettings.get_boolean('natural-scroll')) + delta = -delta; + + this.emit('update', time, delta * SWIPE_MULTIPLIER); + break; + + case Clutter.TouchpadGesturePhase.END: + case Clutter.TouchpadGesturePhase.CANCEL: + this.emit('end', time); + break; + } + + return Clutter.EVENT_STOP; + } + + destroy() { + if (this._stageCaptureEvent) { + global.stage.disconnect(this._stageCaptureEvent); + delete this._stageCaptureEvent; + } + } +}); + +const TouchSwipeGesture = GObject.registerClass({ + Properties: { + 'distance': GObject.ParamSpec.double( + 'distance', 'distance', 'distance', + GObject.ParamFlags.READWRITE, + 0, Infinity, 0), + 'orientation': GObject.ParamSpec.enum( + 'orientation', 'orientation', 'orientation', + GObject.ParamFlags.READWRITE, + Clutter.Orientation, Clutter.Orientation.VERTICAL), + }, + Signals: { + 'begin': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] }, + 'update': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE] }, + 'end': { param_types: [GObject.TYPE_UINT] }, + 'cancel': { param_types: [GObject.TYPE_UINT] }, + }, +}, class TouchSwipeGesture extends Clutter.GestureAction { + _init(allowedModes, nTouchPoints, thresholdTriggerEdge) { + super._init(); + this.set_n_touch_points(nTouchPoints); + this.set_threshold_trigger_edge(thresholdTriggerEdge); + + this._allowedModes = allowedModes; + this._distance = global.screen_height; + this._orientation = Clutter.Orientation.VERTICAL; + + global.display.connect('grab-op-begin', () => { + this.cancel(); + }); + + this._lastPosition = 0; + } + + get distance() { + return this._distance; + } + + set distance(distance) { + if (this._distance === distance) + return; + + this._distance = distance; + this.notify('distance'); + } + + get orientation() { + return this._orientation; + } + + set orientation(orientation) { + if (this._orientation === orientation) + return; + + this._orientation = orientation; + this.notify('orientation'); + } + + vfunc_gesture_prepare(actor) { + if (!super.vfunc_gesture_prepare(actor)) + return false; + + if ((this._allowedModes & Main.actionMode) === 0) + return false; + + let time = this.get_last_event(0).get_time(); + let [xPress, yPress] = this.get_press_coords(0); + let [x, y] = this.get_motion_coords(0); + + this._lastPosition = + this._orientation === Clutter.Orientation.VERTICAL ? y : x; + + this.emit('begin', time, xPress, yPress); + return true; + } + + vfunc_gesture_progress(_actor) { + let [x, y] = this.get_motion_coords(0); + let pos = this._orientation === Clutter.Orientation.VERTICAL ? y : x; + + let delta = pos - this._lastPosition; + this._lastPosition = pos; + + let time = this.get_last_event(0).get_time(); + + this.emit('update', time, -delta / this._distance); + + return true; + } + + vfunc_gesture_end(_actor) { + let time = this.get_last_event(0).get_time(); + + this.emit('end', time); + } + + vfunc_gesture_cancel(_actor) { + let time = Clutter.get_current_event_time(); + + this.emit('cancel', time); + } +}); + +const ScrollGesture = GObject.registerClass({ + Properties: { + 'enabled': GObject.ParamSpec.boolean( + 'enabled', 'enabled', 'enabled', + GObject.ParamFlags.READWRITE, + true), + 'orientation': GObject.ParamSpec.enum( + 'orientation', 'orientation', 'orientation', + GObject.ParamFlags.READWRITE, + Clutter.Orientation, Clutter.Orientation.VERTICAL), + }, + Signals: { + 'begin': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] }, + 'update': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE] }, + 'end': { param_types: [GObject.TYPE_UINT] }, + }, +}, class ScrollGesture extends GObject.Object { + _init(actor, allowedModes) { + super._init(); + this._allowedModes = allowedModes; + this._began = false; + this._enabled = true; + this._orientation = Clutter.Orientation.VERTICAL; + + actor.connect('scroll-event', this._handleEvent.bind(this)); + } + + get enabled() { + return this._enabled; + } + + set enabled(enabled) { + if (this._enabled === enabled) + return; + + this._enabled = enabled; + this._began = false; + + this.notify('enabled'); + } + + get orientation() { + return this._orientation; + } + + set orientation(orientation) { + if (this._orientation === orientation) + return; + + this._orientation = orientation; + this.notify('orientation'); + } + + canHandleEvent(event) { + if (event.type() !== Clutter.EventType.SCROLL) + return false; + + if (event.get_scroll_source() !== Clutter.ScrollSource.FINGER && + event.get_source_device().get_device_type() !== Clutter.InputDeviceType.TOUCHPAD_DEVICE) + return false; + + if (!this.enabled) + return false; + + if ((this._allowedModes & Main.actionMode) === 0) + return false; + + return true; + } + + _handleEvent(actor, event) { + if (!this.canHandleEvent(event)) + return Clutter.EVENT_PROPAGATE; + + if (event.get_scroll_direction() !== Clutter.ScrollDirection.SMOOTH) + return Clutter.EVENT_PROPAGATE; + + let time = event.get_time(); + let [dx, dy] = event.get_scroll_delta(); + if (dx === 0 && dy === 0) { + this.emit('end', time); + this._began = false; + return Clutter.EVENT_STOP; + } + + if (!this._began) { + let [x, y] = event.get_coords(); + this.emit('begin', time, x, y); + this._began = true; + } + + let delta; + if (this._orientation === Clutter.Orientation.VERTICAL) + delta = dy / TOUCHPAD_BASE_HEIGHT; + else + delta = dx / TOUCHPAD_BASE_WIDTH; + + this.emit('update', time, delta * SCROLL_MULTIPLIER); + + return Clutter.EVENT_STOP; + } +}); + +// USAGE: +// +// To correctly implement the gesture, there must be handlers for the following +// signals: +// +// begin(tracker, monitor) +// The handler should check whether a deceleration animation is currently +// running. If it is, it should stop the animation (without resetting +// progress). Then it should call: +// tracker.confirmSwipe(distance, snapPoints, currentProgress, cancelProgress) +// If it's not called, the swipe would be ignored. +// The parameters are: +// * distance: the page size; +// * snapPoints: an (sorted with ascending order) array of snap points; +// * currentProgress: the current progress; +// * cancelprogress: a non-transient value that would be used if the gesture +// is cancelled. +// If no animation was running, currentProgress and cancelProgress should be +// same. The handler may set 'orientation' property here. +// +// update(tracker, progress) +// The handler should set the progress to the given value. +// +// end(tracker, duration, endProgress) +// The handler should animate the progress to endProgress. If endProgress is +// 0, it should do nothing after the animation, otherwise it should change the +// state, e.g. change the current page or switch workspace. +// NOTE: duration can be 0 in some cases, in this case it should finish +// instantly. + +/** A class for handling swipe gestures */ +var SwipeTracker = GObject.registerClass({ + Properties: { + 'enabled': GObject.ParamSpec.boolean( + 'enabled', 'enabled', 'enabled', + GObject.ParamFlags.READWRITE, + true), + 'orientation': GObject.ParamSpec.enum( + 'orientation', 'orientation', 'orientation', + GObject.ParamFlags.READWRITE, + Clutter.Orientation, Clutter.Orientation.VERTICAL), + 'distance': GObject.ParamSpec.double( + 'distance', 'distance', 'distance', + GObject.ParamFlags.READWRITE, + 0, Infinity, 0), + }, + Signals: { + 'begin': { param_types: [GObject.TYPE_UINT] }, + 'update': { param_types: [GObject.TYPE_DOUBLE] }, + 'end': { param_types: [GObject.TYPE_UINT64, GObject.TYPE_DOUBLE] }, + }, +}, class SwipeTracker extends GObject.Object { + _init(actor, allowedModes, params) { + super._init(); + params = Params.parse(params, { allowDrag: true, allowScroll: true }); + + this._allowedModes = allowedModes; + this._enabled = true; + this._orientation = Clutter.Orientation.VERTICAL; + this._distance = global.screen_height; + + this._reset(); + + this._touchpadGesture = new TouchpadSwipeGesture(allowedModes); + this._touchpadGesture.connect('begin', this._beginGesture.bind(this)); + this._touchpadGesture.connect('update', this._updateGesture.bind(this)); + this._touchpadGesture.connect('end', this._endGesture.bind(this)); + this.bind_property('enabled', this._touchpadGesture, 'enabled', 0); + this.bind_property('orientation', this._touchpadGesture, 'orientation', 0); + + this._touchGesture = new TouchSwipeGesture(allowedModes, 4, + Clutter.GestureTriggerEdge.NONE); + this._touchGesture.connect('begin', this._beginTouchSwipe.bind(this)); + this._touchGesture.connect('update', this._updateGesture.bind(this)); + this._touchGesture.connect('end', this._endGesture.bind(this)); + this._touchGesture.connect('cancel', this._cancelGesture.bind(this)); + this.bind_property('enabled', this._touchGesture, 'enabled', 0); + this.bind_property('orientation', this._touchGesture, 'orientation', 0); + this.bind_property('distance', this._touchGesture, 'distance', 0); + global.stage.add_action(this._touchGesture); + + if (params.allowDrag) { + this._dragGesture = new TouchSwipeGesture(allowedModes, 1, + Clutter.GestureTriggerEdge.AFTER); + this._dragGesture.connect('begin', this._beginGesture.bind(this)); + this._dragGesture.connect('update', this._updateGesture.bind(this)); + this._dragGesture.connect('end', this._endGesture.bind(this)); + this._dragGesture.connect('cancel', this._cancelGesture.bind(this)); + this.bind_property('enabled', this._dragGesture, 'enabled', 0); + this.bind_property('orientation', this._dragGesture, 'orientation', 0); + this.bind_property('distance', this._dragGesture, 'distance', 0); + actor.add_action(this._dragGesture); + } else { + this._dragGesture = null; + } + + if (params.allowScroll) { + this._scrollGesture = new ScrollGesture(actor, allowedModes); + this._scrollGesture.connect('begin', this._beginGesture.bind(this)); + this._scrollGesture.connect('update', this._updateGesture.bind(this)); + this._scrollGesture.connect('end', this._endGesture.bind(this)); + this.bind_property('enabled', this._scrollGesture, 'enabled', 0); + this.bind_property('orientation', this._scrollGesture, 'orientation', 0); + } else { + this._scrollGesture = null; + } + } + + /** + * canHandleScrollEvent: + * @param {Clutter.Event} scrollEvent: an event to check + * @returns {bool} whether the event can be handled by the tracker + * + * This function can be used to combine swipe gesture and mouse + * scrolling. + */ + canHandleScrollEvent(scrollEvent) { + if (!this.enabled || this._scrollGesture === null) + return false; + + return this._scrollGesture.canHandleEvent(scrollEvent); + } + + get enabled() { + return this._enabled; + } + + set enabled(enabled) { + if (this._enabled === enabled) + return; + + this._enabled = enabled; + if (!enabled && this._state === State.SCROLLING) + this._interrupt(); + this.notify('enabled'); + } + + get orientation() { + return this._orientation; + } + + set orientation(orientation) { + if (this._orientation === orientation) + return; + + this._orientation = orientation; + this.notify('orientation'); + } + + get distance() { + return this._distance; + } + + set distance(distance) { + if (this._distance === distance) + return; + + this._distance = distance; + this.notify('distance'); + } + + _reset() { + this._state = State.NONE; + + this._snapPoints = []; + this._initialProgress = 0; + this._cancelProgress = 0; + + this._prevOffset = 0; + this._progress = 0; + + this._prevTime = 0; + this._velocity = 0; + + this._cancelled = false; + } + + _interrupt() { + this.emit('end', 0, this._cancelProgress); + this._reset(); + } + + _beginTouchSwipe(gesture, time, x, y) { + if (this._dragGesture) + this._dragGesture.cancel(); + + this._beginGesture(gesture, time, x, y); + } + + _beginGesture(gesture, time, x, y) { + if (this._state === State.SCROLLING) + return; + + this._prevTime = time; + + let rect = new Meta.Rectangle({ x, y, width: 1, height: 1 }); + let monitor = global.display.get_monitor_index_for_rect(rect); + + this.emit('begin', monitor); + } + + _updateGesture(gesture, time, delta) { + if (this._state !== State.SCROLLING) + return; + + if ((this._allowedModes & Main.actionMode) === 0 || !this.enabled) { + this._interrupt(); + return; + } + + if (this.orientation === Clutter.Orientation.HORIZONTAL && + Clutter.get_default_text_direction() === Clutter.TextDirection.RTL) + delta = -delta; + + this._progress += delta; + + if (time !== this._prevTime) + this._velocity = delta / (time - this._prevTime); + + let firstPoint = this._snapPoints[0]; + let lastPoint = this._snapPoints[this._snapPoints.length - 1]; + this._progress = Math.clamp(this._progress, firstPoint, lastPoint); + this._progress = Math.clamp(this._progress, + this._initialProgress - 1, this._initialProgress + 1); + + this.emit('update', this._progress); + + this._prevTime = time; + } + + _getClosestSnapPoints() { + let upper = this._snapPoints.find(p => p >= this._progress); + let lower = this._snapPoints.slice().reverse().find(p => p <= this._progress); + return [lower, upper]; + } + + _getEndProgress() { + if (this._cancelled) + return this._cancelProgress; + + let [lower, upper] = this._getClosestSnapPoints(); + let middle = (upper + lower) / 2; + + if (this._progress > middle) { + let thresholdMet = this._velocity * this._distance > -VELOCITY_THRESHOLD; + return thresholdMet || this._initialProgress > upper ? upper : lower; + } else { + let thresholdMet = this._velocity * this._distance < VELOCITY_THRESHOLD; + return thresholdMet || this._initialProgress < lower ? lower : upper; + } + } + + _endGesture(_gesture, _time) { + if (this._state !== State.SCROLLING) + return; + + if ((this._allowedModes & Main.actionMode) === 0 || !this.enabled) { + this._interrupt(); + return; + } + + let endProgress = this._getEndProgress(); + + let velocity = ANIMATION_BASE_VELOCITY; + if ((endProgress - this._progress) * this._velocity > 0) + velocity = this._velocity; + + let duration = Math.abs((this._progress - endProgress) / velocity * DURATION_MULTIPLIER); + if (duration > 0) { + duration = Math.clamp(duration, + MIN_ANIMATION_DURATION, MAX_ANIMATION_DURATION); + } + + this.emit('end', duration, endProgress); + this._reset(); + } + + _cancelGesture(gesture, time) { + if (this._state !== State.SCROLLING) + return; + + this._cancelled = true; + this._endGesture(gesture, time); + } + + /** + * confirmSwipe: + * @param {number} distance: swipe distance in pixels + * @param {number[]} snapPoints: + * An array of snap points, sorted in ascending order + * @param {number} currentProgress: initial progress value + * @param {number} cancelProgress: the value to be used on cancelling + * + * Confirms a swipe. User has to call this in 'begin' signal handler, + * otherwise the swipe wouldn't start. If there's an animation running, + * it should be stopped first. + * + * @cancel_progress must always be a snap point, or a value matching + * some other non-transient state. + */ + confirmSwipe(distance, snapPoints, currentProgress, cancelProgress) { + this.distance = distance; + this._snapPoints = snapPoints; + this._initialProgress = currentProgress; + this._progress = currentProgress; + this._cancelProgress = cancelProgress; + + this._velocity = 0; + this._state = State.SCROLLING; + } + + destroy() { + if (this._touchpadGesture) { + this._touchpadGesture.destroy(); + delete this._touchpadGesture; + } + + if (this._touchGesture) { + global.stage.remove_action(this._touchGesture); + delete this._touchGesture; + } + } +}); diff --git a/js/ui/switchMonitor.js b/js/ui/switchMonitor.js new file mode 100644 index 0000000..5ac5825 --- /dev/null +++ b/js/ui/switchMonitor.js @@ -0,0 +1,97 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported SwitchMonitorPopup */ + +const { Clutter, GObject, Meta, St } = imports.gi; + +const SwitcherPopup = imports.ui.switcherPopup; + +var APP_ICON_SIZE = 96; + +var SwitchMonitorPopup = GObject.registerClass( +class SwitchMonitorPopup extends SwitcherPopup.SwitcherPopup { + _init() { + let items = [{ icon: 'view-mirror-symbolic', + /* Translators: this is for display mirroring i.e. cloning. + * Try to keep it under around 15 characters. + */ + label: _('Mirror') }, + { icon: 'video-joined-displays-symbolic', + /* Translators: this is for the desktop spanning displays. + * Try to keep it under around 15 characters. + */ + label: _('Join Displays') }, + { icon: 'video-single-display-symbolic', + /* Translators: this is for using only an external display. + * Try to keep it under around 15 characters. + */ + label: _('External Only') }, + { icon: 'computer-symbolic', + /* Translators: this is for using only the laptop display. + * Try to keep it under around 15 characters. + */ + label: _('Built-in Only') }]; + + super._init(items); + + this._switcherList = new SwitchMonitorSwitcher(items); + } + + show(backward, binding, mask) { + if (!Meta.MonitorManager.get().can_switch_config()) + return false; + + return super.show(backward, binding, mask); + } + + _initialSelection() { + let currentConfig = Meta.MonitorManager.get().get_switch_config(); + let selectConfig = (currentConfig + 1) % Meta.MonitorSwitchConfigType.UNKNOWN; + this._select(selectConfig); + } + + _keyPressHandler(keysym, action) { + if (action == Meta.KeyBindingAction.SWITCH_MONITOR) + this._select(this._next()); + else if (keysym == Clutter.KEY_Left) + this._select(this._previous()); + else if (keysym == Clutter.KEY_Right) + this._select(this._next()); + else + return Clutter.EVENT_PROPAGATE; + + return Clutter.EVENT_STOP; + } + + _finish() { + super._finish(); + + Meta.MonitorManager.get().switch_config(this._selectedIndex); + } +}); + +var SwitchMonitorSwitcher = GObject.registerClass( +class SwitchMonitorSwitcher extends SwitcherPopup.SwitcherList { + _init(items) { + super._init(true); + + for (let i = 0; i < items.length; i++) + this._addIcon(items[i]); + } + + _addIcon(item) { + let box = new St.BoxLayout({ style_class: 'alt-tab-app', + vertical: true }); + + let icon = new St.Icon({ icon_name: item.icon, + icon_size: APP_ICON_SIZE }); + box.add_child(icon); + + let text = new St.Label({ + text: item.label, + x_align: Clutter.ActorAlign.CENTER, + }); + box.add_child(text); + + this.addItem(box, text); + } +}); diff --git a/js/ui/switcherPopup.js b/js/ui/switcherPopup.js new file mode 100644 index 0000000..5cc4ab2 --- /dev/null +++ b/js/ui/switcherPopup.js @@ -0,0 +1,674 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported SwitcherPopup, SwitcherList */ + +const { Clutter, GLib, GObject, Meta, St } = imports.gi; + +const Main = imports.ui.main; + +var POPUP_DELAY_TIMEOUT = 150; // milliseconds + +var POPUP_SCROLL_TIME = 100; // milliseconds +var POPUP_FADE_OUT_TIME = 100; // milliseconds + +var DISABLE_HOVER_TIMEOUT = 500; // milliseconds +var NO_MODS_TIMEOUT = 1500; // milliseconds + +function mod(a, b) { + return (a + b) % b; +} + +function primaryModifier(mask) { + if (mask == 0) + return 0; + + let primary = 1; + while (mask > 1) { + mask >>= 1; + primary <<= 1; + } + return primary; +} + +var SwitcherPopup = GObject.registerClass({ + GTypeFlags: GObject.TypeFlags.ABSTRACT, +}, class SwitcherPopup extends St.Widget { + _init(items) { + super._init({ style_class: 'switcher-popup', + reactive: true, + visible: false }); + + this._switcherList = null; + + this._items = items || []; + this._selectedIndex = 0; + + this.connect('destroy', this._onDestroy.bind(this)); + + Main.uiGroup.add_actor(this); + + this._systemModalOpenedId = + Main.layoutManager.connect('system-modal-opened', () => this.destroy()); + + this._haveModal = false; + this._modifierMask = 0; + + this._motionTimeoutId = 0; + this._initialDelayTimeoutId = 0; + this._noModsTimeoutId = 0; + + this.add_constraint(new Clutter.BindConstraint({ + source: global.stage, + coordinate: Clutter.BindCoordinate.ALL, + })); + + // Initially disable hover so we ignore the enter-event if + // the switcher appears underneath the current pointer location + this._disableHover(); + } + + vfunc_allocate(box) { + this.set_allocation(box); + + let childBox = new Clutter.ActorBox(); + let primary = Main.layoutManager.primaryMonitor; + + let leftPadding = this.get_theme_node().get_padding(St.Side.LEFT); + let rightPadding = this.get_theme_node().get_padding(St.Side.RIGHT); + let hPadding = leftPadding + rightPadding; + + // Allocate the switcherList + // We select a size based on an icon size that does not overflow the screen + let [, childNaturalHeight] = this._switcherList.get_preferred_height(primary.width - hPadding); + let [, childNaturalWidth] = this._switcherList.get_preferred_width(childNaturalHeight); + childBox.x1 = Math.max(primary.x + leftPadding, primary.x + Math.floor((primary.width - childNaturalWidth) / 2)); + childBox.x2 = Math.min(primary.x + primary.width - rightPadding, childBox.x1 + childNaturalWidth); + childBox.y1 = primary.y + Math.floor((primary.height - childNaturalHeight) / 2); + childBox.y2 = childBox.y1 + childNaturalHeight; + this._switcherList.allocate(childBox); + } + + _initialSelection(backward, _binding) { + if (backward) + this._select(this._items.length - 1); + else if (this._items.length == 1) + this._select(0); + else + this._select(1); + } + + show(backward, binding, mask) { + if (this._items.length == 0) + return false; + + if (!Main.pushModal(this)) { + // Probably someone else has a pointer grab, try again with keyboard only + if (!Main.pushModal(this, { options: Meta.ModalOptions.POINTER_ALREADY_GRABBED })) + return false; + } + this._haveModal = true; + this._modifierMask = primaryModifier(mask); + + this.add_actor(this._switcherList); + this._switcherList.connect('item-activated', this._itemActivated.bind(this)); + this._switcherList.connect('item-entered', this._itemEntered.bind(this)); + this._switcherList.connect('item-removed', this._itemRemoved.bind(this)); + + // Need to force an allocation so we can figure out whether we + // need to scroll when selecting + this.opacity = 0; + this.visible = true; + this.get_allocation_box(); + + this._initialSelection(backward, binding); + + // There's a race condition; if the user released Alt before + // we got the grab, then we won't be notified. (See + // https://bugzilla.gnome.org/show_bug.cgi?id=596695 for + // details.) So we check now. (Have to do this after updating + // selection.) + if (this._modifierMask) { + let [x_, y_, mods] = global.get_pointer(); + if (!(mods & this._modifierMask)) { + this._finish(global.get_current_time()); + return true; + } + } else { + this._resetNoModsTimeout(); + } + + // We delay showing the popup so that fast Alt+Tab users aren't + // disturbed by the popup briefly flashing. + this._initialDelayTimeoutId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + POPUP_DELAY_TIMEOUT, + () => { + this._showImmediately(); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._initialDelayTimeoutId, '[gnome-shell] Main.osdWindow.cancel'); + return true; + } + + _showImmediately() { + if (this._initialDelayTimeoutId === 0) + return; + + GLib.source_remove(this._initialDelayTimeoutId); + this._initialDelayTimeoutId = 0; + + Main.osdWindowManager.hideAll(); + this.opacity = 255; + } + + _next() { + return mod(this._selectedIndex + 1, this._items.length); + } + + _previous() { + return mod(this._selectedIndex - 1, this._items.length); + } + + _keyPressHandler(_keysym, _action) { + throw new GObject.NotImplementedError(`_keyPressHandler in ${this.constructor.name}`); + } + + vfunc_key_press_event(keyEvent) { + let keysym = keyEvent.keyval; + let action = global.display.get_keybinding_action( + keyEvent.hardware_keycode, keyEvent.modifier_state); + + this._disableHover(); + + if (this._keyPressHandler(keysym, action) != Clutter.EVENT_PROPAGATE) { + this._showImmediately(); + return Clutter.EVENT_STOP; + } + + // Note: pressing one of the below keys will destroy the popup only if + // that key is not used by the active popup's keyboard shortcut + if (keysym === Clutter.KEY_Escape || keysym === Clutter.KEY_Tab) + this.fadeAndDestroy(); + + // Allow to explicitly select the current item; this is particularly + // useful for no-modifier popups + if (keysym === Clutter.KEY_space || + keysym === Clutter.KEY_Return || + keysym === Clutter.KEY_KP_Enter || + keysym === Clutter.KEY_ISO_Enter) + this._finish(keyEvent.time); + + return Clutter.EVENT_STOP; + } + + vfunc_key_release_event(keyEvent) { + if (this._modifierMask) { + let [x_, y_, mods] = global.get_pointer(); + let state = mods & this._modifierMask; + + if (state == 0) + this._finish(keyEvent.time); + } else { + this._resetNoModsTimeout(); + } + + return Clutter.EVENT_STOP; + } + + vfunc_button_press_event() { + /* We clicked outside */ + this.fadeAndDestroy(); + return Clutter.EVENT_PROPAGATE; + } + + _scrollHandler(direction) { + if (direction == Clutter.ScrollDirection.UP) + this._select(this._previous()); + else if (direction == Clutter.ScrollDirection.DOWN) + this._select(this._next()); + } + + vfunc_scroll_event(scrollEvent) { + this._disableHover(); + + this._scrollHandler(scrollEvent.direction); + return Clutter.EVENT_PROPAGATE; + } + + _itemActivatedHandler(n) { + this._select(n); + } + + _itemActivated(switcher, n) { + this._itemActivatedHandler(n); + this._finish(global.get_current_time()); + } + + _itemEnteredHandler(n) { + this._select(n); + } + + _itemEntered(switcher, n) { + if (!this.mouseActive) + return; + this._itemEnteredHandler(n); + } + + _itemRemovedHandler(n) { + if (this._items.length > 0) { + let newIndex; + + if (n < this._selectedIndex) + newIndex = this._selectedIndex - 1; + else if (n === this._selectedIndex) + newIndex = Math.min(n, this._items.length - 1); + else if (n > this._selectedIndex) + return; // No need to select something new in this case + + this._select(newIndex); + } else { + this.fadeAndDestroy(); + } + } + + _itemRemoved(switcher, n) { + this._itemRemovedHandler(n); + } + + _disableHover() { + this.mouseActive = false; + + if (this._motionTimeoutId != 0) + GLib.source_remove(this._motionTimeoutId); + + this._motionTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, DISABLE_HOVER_TIMEOUT, this._mouseTimedOut.bind(this)); + GLib.Source.set_name_by_id(this._motionTimeoutId, '[gnome-shell] this._mouseTimedOut'); + } + + _mouseTimedOut() { + this._motionTimeoutId = 0; + this.mouseActive = true; + return GLib.SOURCE_REMOVE; + } + + _resetNoModsTimeout() { + if (this._noModsTimeoutId != 0) + GLib.source_remove(this._noModsTimeoutId); + + this._noModsTimeoutId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + NO_MODS_TIMEOUT, + () => { + this._finish(global.display.get_current_time_roundtrip()); + this._noModsTimeoutId = 0; + return GLib.SOURCE_REMOVE; + }); + } + + _popModal() { + if (this._haveModal) { + Main.popModal(this); + this._haveModal = false; + } + } + + fadeAndDestroy() { + this._popModal(); + if (this.opacity > 0) { + this.ease({ + opacity: 0, + duration: POPUP_FADE_OUT_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this.destroy(), + }); + } else { + this.destroy(); + } + } + + _finish(_timestamp) { + this.fadeAndDestroy(); + } + + _onDestroy() { + this._popModal(); + + Main.layoutManager.disconnect(this._systemModalOpenedId); + + if (this._motionTimeoutId != 0) + GLib.source_remove(this._motionTimeoutId); + if (this._initialDelayTimeoutId != 0) + GLib.source_remove(this._initialDelayTimeoutId); + if (this._noModsTimeoutId != 0) + GLib.source_remove(this._noModsTimeoutId); + + // Make sure the SwitcherList is always destroyed, it may not be + // a child of the actor at this point. + if (this._switcherList) + this._switcherList.destroy(); + } + + _select(num) { + this._selectedIndex = num; + this._switcherList.highlight(num); + } +}); + +var SwitcherButton = GObject.registerClass( +class SwitcherButton extends St.Button { + _init(square) { + super._init({ style_class: 'item-box', + reactive: true }); + + this._square = square; + } + + vfunc_get_preferred_width(forHeight) { + if (this._square) + return this.get_preferred_height(-1); + else + return super.vfunc_get_preferred_width(forHeight); + } +}); + +var SwitcherList = GObject.registerClass({ + Signals: { 'item-activated': { param_types: [GObject.TYPE_INT] }, + 'item-entered': { param_types: [GObject.TYPE_INT] }, + 'item-removed': { param_types: [GObject.TYPE_INT] } }, +}, class SwitcherList extends St.Widget { + _init(squareItems) { + super._init({ style_class: 'switcher-list' }); + + this._list = new St.BoxLayout({ style_class: 'switcher-list-item-container', + vertical: false, + x_expand: true, + y_expand: true }); + + let layoutManager = this._list.get_layout_manager(); + + this._list.spacing = 0; + this._list.connect('style-changed', () => { + this._list.spacing = this._list.get_theme_node().get_length('spacing'); + }); + + this._scrollView = new St.ScrollView({ style_class: 'hfade', + enable_mouse_scrolling: false }); + this._scrollView.set_policy(St.PolicyType.NEVER, St.PolicyType.NEVER); + + this._scrollView.add_actor(this._list); + this.add_actor(this._scrollView); + + // Those arrows indicate whether scrolling in one direction is possible + this._leftArrow = new St.DrawingArea({ style_class: 'switcher-arrow', + pseudo_class: 'highlighted' }); + this._leftArrow.connect('repaint', () => { + drawArrow(this._leftArrow, St.Side.LEFT); + }); + this._rightArrow = new St.DrawingArea({ style_class: 'switcher-arrow', + pseudo_class: 'highlighted' }); + this._rightArrow.connect('repaint', () => { + drawArrow(this._rightArrow, St.Side.RIGHT); + }); + + this.add_actor(this._leftArrow); + this.add_actor(this._rightArrow); + + this._items = []; + this._highlighted = -1; + this._squareItems = squareItems; + this._scrollableRight = true; + this._scrollableLeft = false; + + layoutManager.homogeneous = squareItems; + } + + addItem(item, label) { + let bbox = new SwitcherButton(this._squareItems); + + bbox.set_child(item); + this._list.add_actor(bbox); + + bbox.connect('clicked', () => this._onItemClicked(bbox)); + bbox.connect('motion-event', () => this._onItemEnter(bbox)); + + bbox.label_actor = label; + + this._items.push(bbox); + + return bbox; + } + + removeItem(index) { + let item = this._items.splice(index, 1); + item[0].destroy(); + this.emit('item-removed', index); + } + + addAccessibleState(index, state) { + this._items[index].add_accessible_state(state); + } + + removeAccessibleState(index, state) { + this._items[index].remove_accessible_state(state); + } + + _onItemClicked(item) { + this._itemActivated(this._items.indexOf(item)); + } + + _onItemEnter(item) { + // Avoid reentrancy + if (item !== this._items[this._highlighted]) + this._itemEntered(this._items.indexOf(item)); + + return Clutter.EVENT_PROPAGATE; + } + + highlight(index, justOutline) { + if (this._items[this._highlighted]) { + this._items[this._highlighted].remove_style_pseudo_class('outlined'); + this._items[this._highlighted].remove_style_pseudo_class('selected'); + } + + if (this._items[index]) { + if (justOutline) + this._items[index].add_style_pseudo_class('outlined'); + else + this._items[index].add_style_pseudo_class('selected'); + } + + this._highlighted = index; + + let adjustment = this._scrollView.hscroll.adjustment; + let [value] = adjustment.get_values(); + let [absItemX] = this._items[index].get_transformed_position(); + let [result_, posX, posY_] = this.transform_stage_point(absItemX, 0); + let [containerWidth] = this.get_transformed_size(); + if (posX + this._items[index].get_width() > containerWidth) + this._scrollToRight(index); + else if (this._items[index].allocation.x1 - value < 0) + this._scrollToLeft(index); + + } + + _scrollToLeft(index) { + let adjustment = this._scrollView.hscroll.adjustment; + let [value, lower_, upper, stepIncrement_, pageIncrement_, pageSize] = adjustment.get_values(); + + let item = this._items[index]; + + if (item.allocation.x1 < value) + value = Math.max(0, item.allocation.x1); + else if (item.allocation.x2 > value + pageSize) + value = Math.min(upper, item.allocation.x2 - pageSize); + + this._scrollableRight = true; + adjustment.ease(value, { + progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: POPUP_SCROLL_TIME, + onComplete: () => { + if (index === 0) + this._scrollableLeft = false; + this.queue_relayout(); + }, + }); + } + + _scrollToRight(index) { + let adjustment = this._scrollView.hscroll.adjustment; + let [value, lower_, upper, stepIncrement_, pageIncrement_, pageSize] = adjustment.get_values(); + + let item = this._items[index]; + + if (item.allocation.x1 < value) + value = Math.max(0, item.allocation.x1); + else if (item.allocation.x2 > value + pageSize) + value = Math.min(upper, item.allocation.x2 - pageSize); + + this._scrollableLeft = true; + adjustment.ease(value, { + progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: POPUP_SCROLL_TIME, + onComplete: () => { + if (index === this._items.length - 1) + this._scrollableRight = false; + this.queue_relayout(); + }, + }); + } + + _itemActivated(n) { + this.emit('item-activated', n); + } + + _itemEntered(n) { + this.emit('item-entered', n); + } + + _maxChildWidth(forHeight) { + let maxChildMin = 0; + let maxChildNat = 0; + + for (let i = 0; i < this._items.length; i++) { + let [childMin, childNat] = this._items[i].get_preferred_width(forHeight); + maxChildMin = Math.max(childMin, maxChildMin); + maxChildNat = Math.max(childNat, maxChildNat); + + if (this._squareItems) { + [childMin, childNat] = this._items[i].get_preferred_height(-1); + maxChildMin = Math.max(childMin, maxChildMin); + maxChildNat = Math.max(childNat, maxChildNat); + } + } + + return [maxChildMin, maxChildNat]; + } + + vfunc_get_preferred_width(forHeight) { + let themeNode = this.get_theme_node(); + let [maxChildMin] = this._maxChildWidth(forHeight); + let [minListWidth] = this._list.get_preferred_width(forHeight); + + return themeNode.adjust_preferred_width(maxChildMin, minListWidth); + } + + vfunc_get_preferred_height(_forWidth) { + let maxChildMin = 0; + let maxChildNat = 0; + + for (let i = 0; i < this._items.length; i++) { + let [childMin, childNat] = this._items[i].get_preferred_height(-1); + maxChildMin = Math.max(childMin, maxChildMin); + maxChildNat = Math.max(childNat, maxChildNat); + } + + if (this._squareItems) { + let [childMin] = this._maxChildWidth(-1); + maxChildMin = Math.max(childMin, maxChildMin); + maxChildNat = maxChildMin; + } + + let themeNode = this.get_theme_node(); + return themeNode.adjust_preferred_height(maxChildMin, maxChildNat); + } + + vfunc_allocate(box) { + this.set_allocation(box); + + let contentBox = this.get_theme_node().get_content_box(box); + let width = contentBox.x2 - contentBox.x1; + let height = contentBox.y2 - contentBox.y1; + + let leftPadding = this.get_theme_node().get_padding(St.Side.LEFT); + let rightPadding = this.get_theme_node().get_padding(St.Side.RIGHT); + + let [minListWidth] = this._list.get_preferred_width(height); + + let childBox = new Clutter.ActorBox(); + let scrollable = minListWidth > width; + + this._scrollView.allocate(contentBox); + + let arrowWidth = Math.floor(leftPadding / 3); + let arrowHeight = arrowWidth * 2; + childBox.x1 = leftPadding / 2; + childBox.y1 = this.height / 2 - arrowWidth; + childBox.x2 = childBox.x1 + arrowWidth; + childBox.y2 = childBox.y1 + arrowHeight; + this._leftArrow.allocate(childBox); + this._leftArrow.opacity = this._scrollableLeft && scrollable ? 255 : 0; + + arrowWidth = Math.floor(rightPadding / 3); + arrowHeight = arrowWidth * 2; + childBox.x1 = this.width - arrowWidth - rightPadding / 2; + childBox.y1 = this.height / 2 - arrowWidth; + childBox.x2 = childBox.x1 + arrowWidth; + childBox.y2 = childBox.y1 + arrowHeight; + this._rightArrow.allocate(childBox); + this._rightArrow.opacity = this._scrollableRight && scrollable ? 255 : 0; + } +}); + +function drawArrow(area, side) { + let themeNode = area.get_theme_node(); + let borderColor = themeNode.get_border_color(side); + let bodyColor = themeNode.get_foreground_color(); + + let [width, height] = area.get_surface_size(); + let cr = area.get_context(); + + cr.setLineWidth(1.0); + Clutter.cairo_set_source_color(cr, borderColor); + + switch (side) { + case St.Side.TOP: + cr.moveTo(0, height); + cr.lineTo(Math.floor(width * 0.5), 0); + cr.lineTo(width, height); + break; + + case St.Side.BOTTOM: + cr.moveTo(width, 0); + cr.lineTo(Math.floor(width * 0.5), height); + cr.lineTo(0, 0); + break; + + case St.Side.LEFT: + cr.moveTo(width, height); + cr.lineTo(0, Math.floor(height * 0.5)); + cr.lineTo(width, 0); + break; + + case St.Side.RIGHT: + cr.moveTo(0, 0); + cr.lineTo(width, Math.floor(height * 0.5)); + cr.lineTo(0, height); + break; + } + + cr.strokePreserve(); + + Clutter.cairo_set_source_color(cr, bodyColor); + cr.fill(); + cr.$dispose(); +} + diff --git a/js/ui/unlockDialog.js b/js/ui/unlockDialog.js new file mode 100644 index 0000000..4f461f3 --- /dev/null +++ b/js/ui/unlockDialog.js @@ -0,0 +1,901 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported UnlockDialog */ + +const { AccountsService, Atk, Clutter, Gdm, Gio, + GnomeDesktop, GLib, GObject, Meta, Shell, St } = imports.gi; + +const Background = imports.ui.background; +const Layout = imports.ui.layout; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; +const SwipeTracker = imports.ui.swipeTracker; + +const AuthPrompt = imports.gdm.authPrompt; + +// The timeout before going back automatically to the lock screen (in seconds) +const IDLE_TIMEOUT = 2 * 60; + +// The timeout before showing the unlock hint (in seconds) +const HINT_TIMEOUT = 4; + +const CROSSFADE_TIME = 300; +const FADE_OUT_TRANSLATION = 200; +const FADE_OUT_SCALE = 0.3; + +const BLUR_BRIGHTNESS = 0.55; +const BLUR_SIGMA = 60; + +const SUMMARY_ICON_SIZE = 32; + +var NotificationsBox = GObject.registerClass({ + Signals: { 'wake-up-screen': {} }, +}, class NotificationsBox extends St.BoxLayout { + _init() { + super._init({ + vertical: true, + name: 'unlockDialogNotifications', + style_class: 'unlock-dialog-notifications-container', + }); + + this._scrollView = new St.ScrollView({ hscrollbar_policy: St.PolicyType.NEVER }); + this._notificationBox = new St.BoxLayout({ + vertical: true, + style_class: 'unlock-dialog-notifications-container', + }); + this._scrollView.add_actor(this._notificationBox); + + this.add_child(this._scrollView); + + this._sources = new Map(); + Main.messageTray.getSources().forEach(source => { + this._sourceAdded(Main.messageTray, source, true); + }); + this._updateVisibility(); + + this._sourceAddedId = Main.messageTray.connect('source-added', this._sourceAdded.bind(this)); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + _onDestroy() { + if (this._sourceAddedId) { + Main.messageTray.disconnect(this._sourceAddedId); + this._sourceAddedId = 0; + } + + let items = this._sources.entries(); + for (let [source, obj] of items) + this._removeSource(source, obj); + } + + _updateVisibility() { + this._notificationBox.visible = + this._notificationBox.get_children().some(a => a.visible); + + this.visible = this._notificationBox.visible; + } + + _makeNotificationSource(source, box) { + let sourceActor = new MessageTray.SourceActor(source, SUMMARY_ICON_SIZE); + box.add_child(sourceActor); + + let textBox = new St.BoxLayout({ + x_expand: true, + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + box.add_child(textBox); + + let title = new St.Label({ + text: source.title, + style_class: 'unlock-dialog-notification-label', + x_expand: true, + x_align: Clutter.ActorAlign.START, + }); + textBox.add(title); + + let count = source.unseenCount; + let countLabel = new St.Label({ + text: count.toString(), + visible: count > 1, + style_class: 'unlock-dialog-notification-count-text', + }); + textBox.add(countLabel); + + box.visible = count !== 0; + return [title, countLabel]; + } + + _makeNotificationDetailedSource(source, box) { + let sourceActor = new MessageTray.SourceActor(source, SUMMARY_ICON_SIZE); + let sourceBin = new St.Bin({ child: sourceActor }); + box.add(sourceBin); + + let textBox = new St.BoxLayout({ vertical: true }); + box.add_child(textBox); + + let title = new St.Label({ + text: source.title.replace(/\n/g, ' '), + style_class: 'unlock-dialog-notification-label', + }); + textBox.add(title); + + let visible = false; + for (let i = 0; i < source.notifications.length; i++) { + let n = source.notifications[i]; + + if (n.acknowledged) + continue; + + let body = ''; + if (n.bannerBodyText) { + const bodyText = n.bannerBodyText.replace(/\n/g, ' '); + body = n.bannerBodyMarkup + ? bodyText + : GLib.markup_escape_text(bodyText, -1); + } + + let label = new St.Label({ style_class: 'unlock-dialog-notification-count-text' }); + label.clutter_text.set_markup('<b>%s</b> %s'.format(n.title, body)); + textBox.add(label); + + visible = true; + } + + box.visible = visible; + return [title, null]; + } + + _shouldShowDetails(source) { + return source.policy.detailsInLockScreen || + source.narrowestPrivacyScope === MessageTray.PrivacyScope.SYSTEM; + } + + _updateSourceBoxStyle(source, obj, box) { + let hasCriticalNotification = + source.notifications.some(n => n.urgency === MessageTray.Urgency.CRITICAL); + + if (hasCriticalNotification !== obj.hasCriticalNotification) { + obj.hasCriticalNotification = hasCriticalNotification; + + if (hasCriticalNotification) + box.add_style_class_name('critical'); + else + box.remove_style_class_name('critical'); + } + } + + _showSource(source, obj, box) { + if (obj.detailed) + [obj.titleLabel, obj.countLabel] = this._makeNotificationDetailedSource(source, box); + else + [obj.titleLabel, obj.countLabel] = this._makeNotificationSource(source, box); + + box.visible = obj.visible && (source.unseenCount > 0); + + this._updateSourceBoxStyle(source, obj, box); + } + + _sourceAdded(tray, source, initial) { + let obj = { + visible: source.policy.showInLockScreen, + detailed: this._shouldShowDetails(source), + sourceDestroyId: 0, + sourceCountChangedId: 0, + sourceTitleChangedId: 0, + sourceUpdatedId: 0, + sourceBox: null, + titleLabel: null, + countLabel: null, + hasCriticalNotification: false, + }; + + obj.sourceBox = new St.BoxLayout({ + style_class: 'unlock-dialog-notification-source', + x_expand: true, + }); + this._showSource(source, obj, obj.sourceBox); + this._notificationBox.add_child(obj.sourceBox); + + obj.sourceCountChangedId = source.connect('notify::count', () => { + this._countChanged(source, obj); + }); + obj.sourceTitleChangedId = source.connect('notify::title', () => { + this._titleChanged(source, obj); + }); + obj.policyChangedId = source.policy.connect('notify', (policy, pspec) => { + if (pspec.name === 'show-in-lock-screen') + this._visibleChanged(source, obj); + else + this._detailedChanged(source, obj); + }); + obj.sourceDestroyId = source.connect('destroy', () => { + this._onSourceDestroy(source, obj); + }); + + this._sources.set(source, obj); + + if (!initial) { + // block scrollbars while animating, if they're not needed now + let boxHeight = this._notificationBox.height; + if (this._scrollView.height >= boxHeight) + this._scrollView.vscrollbar_policy = St.PolicyType.NEVER; + + let widget = obj.sourceBox; + let [, natHeight] = widget.get_preferred_height(-1); + widget.height = 0; + widget.ease({ + height: natHeight, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: 250, + onComplete: () => { + this._scrollView.vscrollbar_policy = St.PolicyType.AUTOMATIC; + widget.set_height(-1); + }, + }); + + this._updateVisibility(); + if (obj.sourceBox.visible) + this.emit('wake-up-screen'); + } + } + + _titleChanged(source, obj) { + obj.titleLabel.text = source.title; + } + + _countChanged(source, obj) { + // A change in the number of notifications may change whether we show + // details. + let newDetailed = this._shouldShowDetails(source); + let oldDetailed = obj.detailed; + + obj.detailed = newDetailed; + + if (obj.detailed || oldDetailed !== newDetailed) { + // A new notification was pushed, or a previous notification was destroyed. + // Give up, and build the list again. + + obj.sourceBox.destroy_all_children(); + obj.titleLabel = obj.countLabel = null; + this._showSource(source, obj, obj.sourceBox); + } else { + let count = source.unseenCount; + obj.countLabel.text = count.toString(); + obj.countLabel.visible = count > 1; + } + + obj.sourceBox.visible = obj.visible && (source.unseenCount > 0); + + this._updateVisibility(); + if (obj.sourceBox.visible) + this.emit('wake-up-screen'); + } + + _visibleChanged(source, obj) { + if (obj.visible === source.policy.showInLockScreen) + return; + + obj.visible = source.policy.showInLockScreen; + obj.sourceBox.visible = obj.visible && source.unseenCount > 0; + + this._updateVisibility(); + if (obj.sourceBox.visible) + this.emit('wake-up-screen'); + } + + _detailedChanged(source, obj) { + let newDetailed = this._shouldShowDetails(source); + if (obj.detailed === newDetailed) + return; + + obj.detailed = newDetailed; + + obj.sourceBox.destroy_all_children(); + obj.titleLabel = obj.countLabel = null; + this._showSource(source, obj, obj.sourceBox); + } + + _onSourceDestroy(source, obj) { + this._removeSource(source, obj); + this._updateVisibility(); + } + + _removeSource(source, obj) { + obj.sourceBox.destroy(); + obj.sourceBox = obj.titleLabel = obj.countLabel = null; + + source.disconnect(obj.sourceDestroyId); + source.disconnect(obj.sourceCountChangedId); + source.disconnect(obj.sourceTitleChangedId); + source.policy.disconnect(obj.policyChangedId); + + this._sources.delete(source); + } +}); + +var Clock = GObject.registerClass( +class UnlockDialogClock extends St.BoxLayout { + _init() { + super._init({ style_class: 'unlock-dialog-clock', vertical: true }); + + this._time = new St.Label({ + style_class: 'unlock-dialog-clock-time', + x_align: Clutter.ActorAlign.CENTER, + }); + this._date = new St.Label({ + style_class: 'unlock-dialog-clock-date', + x_align: Clutter.ActorAlign.CENTER, + }); + this._hint = new St.Label({ + style_class: 'unlock-dialog-clock-hint', + x_align: Clutter.ActorAlign.CENTER, + opacity: 0, + }); + + this.add_child(this._time); + this.add_child(this._date); + this.add_child(this._hint); + + this._wallClock = new GnomeDesktop.WallClock({ time_only: true }); + this._wallClock.connect('notify::clock', this._updateClock.bind(this)); + + this._seat = Clutter.get_default_backend().get_default_seat(); + this._touchModeChangedId = this._seat.connect('notify::touch-mode', + this._updateHint.bind(this)); + + this._monitorManager = Meta.MonitorManager.get(); + this._powerModeChangedId = this._monitorManager.connect( + 'power-save-mode-changed', () => (this._hint.opacity = 0)); + + this._idleMonitor = Meta.IdleMonitor.get_core(); + this._idleWatchId = this._idleMonitor.add_idle_watch(HINT_TIMEOUT * 1000, () => { + this._hint.ease({ + opacity: 255, + duration: CROSSFADE_TIME, + }); + }); + + this._updateClock(); + this._updateHint(); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + _updateClock() { + this._time.text = this._wallClock.clock; + + let date = new Date(); + /* Translators: This is a time format for a date in + long format */ + let dateFormat = Shell.util_translate_time_string(N_('%A %B %-d')); + this._date.text = date.toLocaleFormat(dateFormat); + } + + _updateHint() { + this._hint.text = this._seat.touch_mode + ? _('Swipe up to unlock') + : _('Click or press a key to unlock'); + } + + _onDestroy() { + this._wallClock.run_dispose(); + + this._seat.disconnect(this._touchModeChangedId); + this._idleMonitor.remove_watch(this._idleWatchId); + this._monitorManager.disconnect(this._powerModeChangedId); + } +}); + +var UnlockDialogLayout = GObject.registerClass( +class UnlockDialogLayout extends Clutter.LayoutManager { + _init(stack, notifications, switchUserButton) { + super._init(); + + this._stack = stack; + this._notifications = notifications; + this._switchUserButton = switchUserButton; + } + + vfunc_get_preferred_width(container, forHeight) { + return this._stack.get_preferred_width(forHeight); + } + + vfunc_get_preferred_height(container, forWidth) { + return this._stack.get_preferred_height(forWidth); + } + + vfunc_allocate(container, box) { + let [width, height] = box.get_size(); + + let tenthOfHeight = height / 10.0; + let thirdOfHeight = height / 3.0; + + let [, , stackWidth, stackHeight] = + this._stack.get_preferred_size(); + + let [, , notificationsWidth, notificationsHeight] = + this._notifications.get_preferred_size(); + + let columnWidth = Math.max(stackWidth, notificationsWidth); + + let columnX1 = Math.floor((width - columnWidth) / 2.0); + let actorBox = new Clutter.ActorBox(); + + // Notifications + let maxNotificationsHeight = Math.min( + notificationsHeight, + height - tenthOfHeight - stackHeight); + + actorBox.x1 = columnX1; + actorBox.y1 = height - maxNotificationsHeight; + actorBox.x2 = columnX1 + columnWidth; + actorBox.y2 = actorBox.y1 + maxNotificationsHeight; + + this._notifications.allocate(actorBox); + + // Authentication Box + let stackY = Math.min( + thirdOfHeight, + height - stackHeight - maxNotificationsHeight); + + actorBox.x1 = columnX1; + actorBox.y1 = stackY; + actorBox.x2 = columnX1 + columnWidth; + actorBox.y2 = stackY + stackHeight; + + this._stack.allocate(actorBox); + + // Switch User button + if (this._switchUserButton.visible) { + let [, , natWidth, natHeight] = + this._switchUserButton.get_preferred_size(); + + const textDirection = this._switchUserButton.get_text_direction(); + if (textDirection === Clutter.TextDirection.RTL) + actorBox.x1 = box.x1 + natWidth; + else + actorBox.x1 = box.x2 - (natWidth * 2); + + actorBox.y1 = box.y2 - (natHeight * 2); + actorBox.x2 = actorBox.x1 + natWidth; + actorBox.y2 = actorBox.y1 + natHeight; + + this._switchUserButton.allocate(actorBox); + } + } +}); + +var UnlockDialog = GObject.registerClass({ + Signals: { + 'failed': {}, + 'wake-up-screen': {}, + }, +}, class UnlockDialog extends St.Widget { + _init(parentActor) { + super._init({ + accessible_role: Atk.Role.WINDOW, + style_class: 'login-dialog', + visible: false, + reactive: true, + }); + + parentActor.add_child(this); + + this._gdmClient = new Gdm.Client(); + + this._adjustment = new St.Adjustment({ + actor: this, + lower: 0, + upper: 2, + page_size: 1, + page_increment: 1, + }); + this._adjustment.connect('notify::value', () => { + this._setTransitionProgress(this._adjustment.value); + }); + + this._swipeTracker = new SwipeTracker.SwipeTracker( + this, Shell.ActionMode.UNLOCK_SCREEN); + this._swipeTracker.connect('begin', this._swipeBegin.bind(this)); + this._swipeTracker.connect('update', this._swipeUpdate.bind(this)); + this._swipeTracker.connect('end', this._swipeEnd.bind(this)); + + this.connect('scroll-event', (o, event) => { + if (this._swipeTracker.canHandleScrollEvent(event)) + return Clutter.EVENT_PROPAGATE; + + let direction = event.get_scroll_direction(); + if (direction === Clutter.ScrollDirection.UP) + this._showClock(); + else if (direction === Clutter.ScrollDirection.DOWN) + this._showPrompt(); + return Clutter.EVENT_STOP; + }); + + this._activePage = null; + + let tapAction = new Clutter.TapAction(); + tapAction.connect('tap', this._showPrompt.bind(this)); + this.add_action(tapAction); + + // Background + this._backgroundGroup = new Clutter.Actor(); + this.add_child(this._backgroundGroup); + + this._bgManagers = []; + + const themeContext = St.ThemeContext.get_for_stage(global.stage); + this._scaleChangedId = themeContext.connect('notify::scale-factor', + () => this._updateBackgroundEffects()); + + this._updateBackgrounds(); + this._monitorsChangedId = + Main.layoutManager.connect('monitors-changed', this._updateBackgrounds.bind(this)); + + this._userManager = AccountsService.UserManager.get_default(); + this._userName = GLib.get_user_name(); + this._user = this._userManager.get_user(this._userName); + + // Authentication & Clock stack + this._stack = new Shell.Stack(); + + this._promptBox = new St.BoxLayout({ vertical: true }); + this._promptBox.set_pivot_point(0.5, 0.5); + this._promptBox.hide(); + this._stack.add_child(this._promptBox); + + this._clock = new Clock(); + this._clock.set_pivot_point(0.5, 0.5); + this._stack.add_child(this._clock); + this._showClock(); + + this.allowCancel = false; + + Main.ctrlAltTabManager.addGroup(this, _('Unlock Window'), 'dialog-password-symbolic'); + + // Notifications + this._notificationsBox = new NotificationsBox(); + this._notificationsBox.connect('wake-up-screen', () => this.emit('wake-up-screen')); + + // Switch User button + this._otherUserButton = new St.Button({ + style_class: 'modal-dialog-button button switch-user-button', + accessible_name: _('Log in as another user'), + reactive: false, + opacity: 0, + x_align: Clutter.ActorAlign.END, + y_align: Clutter.ActorAlign.END, + child: new St.Icon({ icon_name: 'system-users-symbolic' }), + }); + this._otherUserButton.set_pivot_point(0.5, 0.5); + this._otherUserButton.connect('clicked', this._otherUserClicked.bind(this)); + + this._screenSaverSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.screensaver' }); + + this._userSwitchEnabledId = this._screenSaverSettings.connect('changed::user-switch-enabled', + this._updateUserSwitchVisibility.bind(this)); + + this._userLoadedId = this._user.connect('notify::is-loaded', + this._updateUserSwitchVisibility.bind(this)); + + this._updateUserSwitchVisibility(); + + // Main Box + let mainBox = new St.Widget(); + mainBox.add_constraint(new Layout.MonitorConstraint({ primary: true })); + mainBox.add_child(this._stack); + mainBox.add_child(this._notificationsBox); + mainBox.add_child(this._otherUserButton); + mainBox.layout_manager = new UnlockDialogLayout( + this._stack, + this._notificationsBox, + this._otherUserButton); + this.add_child(mainBox); + + this._idleMonitor = Meta.IdleMonitor.get_core(); + this._idleWatchId = this._idleMonitor.add_idle_watch(IDLE_TIMEOUT * 1000, this._escape.bind(this)); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + vfunc_key_press_event(keyEvent) { + if (this._activePage === this._promptBox || + (this._promptBox && this._promptBox.visible)) + return Clutter.EVENT_PROPAGATE; + + const { keyval } = keyEvent; + if (keyval === Clutter.KEY_Shift_L || + keyval === Clutter.KEY_Shift_R || + keyval === Clutter.KEY_Shift_Lock || + keyval === Clutter.KEY_Caps_Lock) + return Clutter.EVENT_PROPAGATE; + + let unichar = keyEvent.unicode_value; + + this._showPrompt(); + + if (GLib.unichar_isgraph(unichar)) + this._authPrompt.addCharacter(unichar); + + return Clutter.EVENT_PROPAGATE; + } + + _createBackground(monitorIndex) { + let monitor = Main.layoutManager.monitors[monitorIndex]; + let widget = new St.Widget({ + style_class: 'screen-shield-background', + x: monitor.x, + y: monitor.y, + width: monitor.width, + height: monitor.height, + effect: new Shell.BlurEffect({ name: 'blur' }), + }); + + let bgManager = new Background.BackgroundManager({ + container: widget, + monitorIndex, + controlPosition: false, + }); + + this._bgManagers.push(bgManager); + + this._backgroundGroup.add_child(widget); + } + + _updateBackgroundEffects() { + const themeContext = St.ThemeContext.get_for_stage(global.stage); + + for (const widget of this._backgroundGroup) { + const effect = widget.get_effect('blur'); + + if (effect) { + effect.set({ + brightness: BLUR_BRIGHTNESS, + sigma: BLUR_SIGMA * themeContext.scale_factor, + }); + } + } + } + + _updateBackgrounds() { + for (let i = 0; i < this._bgManagers.length; i++) + this._bgManagers[i].destroy(); + + this._bgManagers = []; + this._backgroundGroup.destroy_all_children(); + + for (let i = 0; i < Main.layoutManager.monitors.length; i++) + this._createBackground(i); + this._updateBackgroundEffects(); + } + + _ensureAuthPrompt() { + if (this._authPrompt) + return; + + this._authPrompt = new AuthPrompt.AuthPrompt(this._gdmClient, + AuthPrompt.AuthPromptMode.UNLOCK_ONLY); + this._authPrompt.connect('failed', this._fail.bind(this)); + this._authPrompt.connect('cancelled', this._fail.bind(this)); + this._authPrompt.connect('reset', this._onReset.bind(this)); + + this._promptBox.add_child(this._authPrompt); + + this._authPrompt.reset(); + this._authPrompt.updateSensitivity(true); + } + + _maybeDestroyAuthPrompt() { + let focus = global.stage.key_focus; + if (focus === null || + (this._authPrompt && this._authPrompt.contains(focus)) || + (this._otherUserButton && focus === this._otherUserButton)) + this.grab_key_focus(); + + if (this._authPrompt) { + this._authPrompt.destroy(); + this._authPrompt = null; + } + } + + _showClock() { + if (this._activePage === this._clock) + return; + + this._activePage = this._clock; + + this._adjustment.ease(0, { + duration: CROSSFADE_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._maybeDestroyAuthPrompt(), + }); + } + + _showPrompt() { + this._ensureAuthPrompt(); + + if (this._activePage === this._promptBox) + return; + + this._activePage = this._promptBox; + + this._adjustment.ease(1, { + duration: CROSSFADE_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + _setTransitionProgress(progress) { + this._promptBox.visible = progress > 0; + this._clock.visible = progress < 1; + + this._otherUserButton.set({ + reactive: progress > 0, + can_focus: progress > 0, + }); + + const { scaleFactor } = St.ThemeContext.get_for_stage(global.stage); + + this._promptBox.set({ + opacity: 255 * progress, + scale_x: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * progress, + scale_y: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * progress, + translation_y: FADE_OUT_TRANSLATION * (1 - progress) * scaleFactor, + }); + + this._clock.set({ + opacity: 255 * (1 - progress), + scale_x: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * (1 - progress), + scale_y: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * (1 - progress), + translation_y: -FADE_OUT_TRANSLATION * progress * scaleFactor, + }); + + this._otherUserButton.set({ + opacity: 255 * progress, + scale_x: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * progress, + scale_y: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * progress, + }); + } + + _fail() { + this._showClock(); + this.emit('failed'); + } + + _onReset(authPrompt, beginRequest) { + let userName; + if (beginRequest == AuthPrompt.BeginRequestType.PROVIDE_USERNAME) { + this._authPrompt.setUser(this._user); + userName = this._userName; + } else { + userName = null; + } + + this._authPrompt.begin({ userName }); + } + + _escape() { + if (this._authPrompt && this.allowCancel) + this._authPrompt.cancel(); + } + + _swipeBegin(tracker, monitor) { + if (monitor !== Main.layoutManager.primaryIndex) + return; + + this._adjustment.remove_transition('value'); + + this._ensureAuthPrompt(); + + let progress = this._adjustment.value; + tracker.confirmSwipe(this._stack.height, + [0, 1], + progress, + Math.round(progress)); + } + + _swipeUpdate(tracker, progress) { + this._adjustment.value = progress; + } + + _swipeEnd(tracker, duration, endProgress) { + this._activePage = endProgress + ? this._promptBox + : this._clock; + + this._adjustment.ease(endProgress, { + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + duration, + onComplete: () => { + if (this._activePage === this._clock) + this._maybeDestroyAuthPrompt(); + }, + }); + } + + _otherUserClicked() { + Gdm.goto_login_session_sync(null); + + this._authPrompt.cancel(); + } + + _onDestroy() { + this.popModal(); + + if (this._idleWatchId) { + this._idleMonitor.remove_watch(this._idleWatchId); + this._idleWatchId = 0; + } + + if (this._monitorsChangedId) { + Main.layoutManager.disconnect(this._monitorsChangedId); + delete this._monitorsChangedId; + } + + let themeContext = St.ThemeContext.get_for_stage(global.stage); + if (this._scaleChangedId) { + themeContext.disconnect(this._scaleChangedId); + delete this._scaleChangedId; + } + + if (this._gdmClient) { + this._gdmClient = null; + delete this._gdmClient; + } + + if (this._userLoadedId) { + this._user.disconnect(this._userLoadedId); + this._userLoadedId = 0; + } + + if (this._userSwitchEnabledId) { + this._screenSaverSettings.disconnect(this._userSwitchEnabledId); + this._userSwitchEnabledId = 0; + } + } + + _updateUserSwitchVisibility() { + this._otherUserButton.visible = this._userManager.can_switch() && + this._screenSaverSettings.get_boolean('user-switch-enabled'); + } + + cancel() { + if (this._authPrompt) + this._authPrompt.cancel(); + } + + finish(onComplete) { + this._ensureAuthPrompt(); + this._authPrompt.finish(onComplete); + } + + open(timestamp) { + this.show(); + + if (this._isModal) + return true; + + let modalParams = { + timestamp, + actionMode: Shell.ActionMode.UNLOCK_SCREEN, + }; + if (!Main.pushModal(this, modalParams)) + return false; + + this._isModal = true; + + return true; + } + + activate() { + this._showPrompt(); + } + + popModal(timestamp) { + if (this._isModal) { + Main.popModal(this, timestamp); + this._isModal = false; + } + } +}); diff --git a/js/ui/userWidget.js b/js/ui/userWidget.js new file mode 100644 index 0000000..5540ea6 --- /dev/null +++ b/js/ui/userWidget.js @@ -0,0 +1,250 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +// +// A widget showing the user avatar and name +/* exported UserWidget */ + +const { Clutter, GLib, GObject, St } = imports.gi; + +const Params = imports.misc.params; + +var AVATAR_ICON_SIZE = 64; + +// Adapted from gdm/gui/user-switch-applet/applet.c +// +// Copyright (C) 2004-2005 James M. Cape <jcape@ignore-your.tv>. +// Copyright (C) 2008,2009 Red Hat, Inc. + +var Avatar = GObject.registerClass( +class Avatar extends St.Bin { + _init(user, params) { + let themeContext = St.ThemeContext.get_for_stage(global.stage); + params = Params.parse(params, { + styleClass: 'user-icon', + reactive: false, + iconSize: AVATAR_ICON_SIZE, + }); + + super._init({ + style_class: params.styleClass, + reactive: params.reactive, + width: params.iconSize * themeContext.scaleFactor, + height: params.iconSize * themeContext.scaleFactor, + }); + + this._iconSize = params.iconSize; + this._user = user; + + this.bind_property('reactive', this, 'track-hover', + GObject.BindingFlags.SYNC_CREATE); + this.bind_property('reactive', this, 'can-focus', + GObject.BindingFlags.SYNC_CREATE); + + // Monitor the scaling factor to make sure we recreate the avatar when needed. + this._scaleFactorChangeId = + themeContext.connect('notify::scale-factor', this.update.bind(this)); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + vfunc_style_changed() { + super.vfunc_style_changed(); + + let node = this.get_theme_node(); + let [found, iconSize] = node.lookup_length('icon-size', false); + + if (!found) + return; + + let themeContext = St.ThemeContext.get_for_stage(global.stage); + + // node.lookup_length() returns a scaled value, but we + // need unscaled + this._iconSize = iconSize / themeContext.scaleFactor; + this.update(); + } + + _onDestroy() { + if (this._scaleFactorChangeId) { + let themeContext = St.ThemeContext.get_for_stage(global.stage); + themeContext.disconnect(this._scaleFactorChangeId); + delete this._scaleFactorChangeId; + } + } + + setSensitive(sensitive) { + this.reactive = sensitive; + } + + update() { + let iconFile = null; + if (this._user) { + iconFile = this._user.get_icon_file(); + if (iconFile && !GLib.file_test(iconFile, GLib.FileTest.EXISTS)) + iconFile = null; + } + + let { scaleFactor } = St.ThemeContext.get_for_stage(global.stage); + this.set_size( + this._iconSize * scaleFactor, + this._iconSize * scaleFactor); + + if (iconFile) { + this.child = null; + this.style = ` + background-image: url("${iconFile}"); + background-size: cover;`; + } else { + this.style = null; + this.child = new St.Icon({ + icon_name: 'avatar-default-symbolic', + icon_size: this._iconSize, + }); + } + } +}); + +var UserWidgetLabel = GObject.registerClass( +class UserWidgetLabel extends St.Widget { + _init(user) { + super._init({ layout_manager: new Clutter.BinLayout() }); + + this._user = user; + + this._realNameLabel = new St.Label({ style_class: 'user-widget-label', + y_align: Clutter.ActorAlign.CENTER }); + this.add_child(this._realNameLabel); + + this._userNameLabel = new St.Label({ style_class: 'user-widget-label', + y_align: Clutter.ActorAlign.CENTER }); + this.add_child(this._userNameLabel); + + this._currentLabel = null; + + this._userLoadedId = this._user.connect('notify::is-loaded', this._updateUser.bind(this)); + this._userChangedId = this._user.connect('changed', this._updateUser.bind(this)); + this._updateUser(); + + // We can't override the destroy vfunc because that might be called during + // object finalization, and we can't call any JS inside a GC finalize callback, + // so we use a signal, that will be disconnected by GObject the first time + // the actor is destroyed (which is guaranteed to be as part of a normal + // destroy() call from JS, possibly from some ancestor) + this.connect('destroy', this._onDestroy.bind(this)); + } + + _onDestroy() { + if (this._userLoadedId != 0) { + this._user.disconnect(this._userLoadedId); + this._userLoadedId = 0; + } + + if (this._userChangedId != 0) { + this._user.disconnect(this._userChangedId); + this._userChangedId = 0; + } + } + + vfunc_allocate(box) { + this.set_allocation(box); + + let availWidth = box.x2 - box.x1; + let availHeight = box.y2 - box.y1; + + let [, , natRealNameWidth] = this._realNameLabel.get_preferred_size(); + + let childBox = new Clutter.ActorBox(); + + let hiddenLabel; + if (natRealNameWidth <= availWidth) { + this._currentLabel = this._realNameLabel; + hiddenLabel = this._userNameLabel; + } else { + this._currentLabel = this._userNameLabel; + hiddenLabel = this._realNameLabel; + } + this.label_actor = this._currentLabel; + + hiddenLabel.allocate(childBox); + + childBox.set_size(availWidth, availHeight); + + this._currentLabel.allocate(childBox); + } + + vfunc_paint(paintContext) { + this._currentLabel.paint(paintContext); + } + + _updateUser() { + if (this._user.is_loaded) { + this._realNameLabel.text = this._user.get_real_name(); + this._userNameLabel.text = this._user.get_user_name(); + } else { + this._realNameLabel.text = ''; + this._userNameLabel.text = ''; + } + } +}); + +var UserWidget = GObject.registerClass( +class UserWidget extends St.BoxLayout { + _init(user, orientation = Clutter.Orientation.HORIZONTAL) { + // If user is null, that implies a username-based login authorization. + this._user = user; + + let vertical = orientation == Clutter.Orientation.VERTICAL; + let xAlign = vertical ? Clutter.ActorAlign.CENTER : Clutter.ActorAlign.START; + let styleClass = vertical ? 'user-widget vertical' : 'user-widget horizontal'; + + super._init({ + styleClass, + vertical, + xAlign, + }); + + this.connect('destroy', this._onDestroy.bind(this)); + + this._avatar = new Avatar(user); + this._avatar.x_align = Clutter.ActorAlign.CENTER; + this.add_child(this._avatar); + + this._userLoadedId = 0; + this._userChangedId = 0; + if (user) { + this._label = new UserWidgetLabel(user); + this.add_child(this._label); + + this._label.bind_property('label-actor', this, 'label-actor', + GObject.BindingFlags.SYNC_CREATE); + + this._userLoadedId = this._user.connect('notify::is-loaded', this._updateUser.bind(this)); + this._userChangedId = this._user.connect('changed', this._updateUser.bind(this)); + } else { + this._label = new St.Label({ + style_class: 'user-widget-label', + text: 'Empty User', + opacity: 0, + }); + this.add_child(this._label); + + } + + this._updateUser(); + } + + _onDestroy() { + if (this._userLoadedId != 0) { + this._user.disconnect(this._userLoadedId); + this._userLoadedId = 0; + } + + if (this._userChangedId != 0) { + this._user.disconnect(this._userChangedId); + this._userChangedId = 0; + } + } + + _updateUser() { + this._avatar.update(); + } +}); diff --git a/js/ui/viewSelector.js b/js/ui/viewSelector.js new file mode 100644 index 0000000..bfb02a5 --- /dev/null +++ b/js/ui/viewSelector.js @@ -0,0 +1,609 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported ViewSelector */ + +const { Clutter, Gio, GObject, Meta, Shell, St } = imports.gi; +const Signals = imports.signals; + +const AppDisplay = imports.ui.appDisplay; +const Main = imports.ui.main; +const OverviewControls = imports.ui.overviewControls; +const Params = imports.misc.params; +const Search = imports.ui.search; +const ShellEntry = imports.ui.shellEntry; +const WorkspacesView = imports.ui.workspacesView; +const EdgeDragAction = imports.ui.edgeDragAction; +const IconGrid = imports.ui.iconGrid; + +const SHELL_KEYBINDINGS_SCHEMA = 'org.gnome.shell.keybindings'; +var PINCH_GESTURE_THRESHOLD = 0.7; + +var ViewPage = { + WINDOWS: 1, + APPS: 2, + SEARCH: 3, +}; + +var FocusTrap = GObject.registerClass( +class FocusTrap extends St.Widget { + vfunc_navigate_focus(from, direction) { + if (direction == St.DirectionType.TAB_FORWARD || + direction == St.DirectionType.TAB_BACKWARD) + return super.vfunc_navigate_focus(from, direction); + return false; + } +}); + +function getTermsForSearchString(searchString) { + searchString = searchString.replace(/^\s+/g, '').replace(/\s+$/g, ''); + if (searchString == '') + return []; + + let terms = searchString.split(/\s+/); + return terms; +} + +var TouchpadShowOverviewAction = class { + constructor(actor) { + actor.connect('captured-event::touchpad', this._handleEvent.bind(this)); + } + + _handleEvent(actor, event) { + if (event.type() != Clutter.EventType.TOUCHPAD_PINCH) + return Clutter.EVENT_PROPAGATE; + + if (event.get_touchpad_gesture_finger_count() != 3) + return Clutter.EVENT_PROPAGATE; + + if (event.get_gesture_phase() == Clutter.TouchpadGesturePhase.END) + this.emit('activated', event.get_gesture_pinch_scale()); + + return Clutter.EVENT_STOP; + } +}; +Signals.addSignalMethods(TouchpadShowOverviewAction.prototype); + +var ShowOverviewAction = GObject.registerClass({ + Signals: { 'activated': { param_types: [GObject.TYPE_DOUBLE] } }, +}, class ShowOverviewAction extends Clutter.GestureAction { + _init() { + super._init(); + this.set_n_touch_points(3); + + global.display.connect('grab-op-begin', () => { + this.cancel(); + }); + } + + vfunc_gesture_prepare(_actor) { + return Main.actionMode == Shell.ActionMode.NORMAL && + this.get_n_current_points() == this.get_n_touch_points(); + } + + _getBoundingRect(motion) { + let minX, minY, maxX, maxY; + + for (let i = 0; i < this.get_n_current_points(); i++) { + let x, y; + + if (motion == true) + [x, y] = this.get_motion_coords(i); + else + [x, y] = this.get_press_coords(i); + + if (i == 0) { + minX = maxX = x; + minY = maxY = y; + } else { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + + return new Meta.Rectangle({ x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY }); + } + + vfunc_gesture_begin(_actor) { + this._initialRect = this._getBoundingRect(false); + return true; + } + + vfunc_gesture_end(_actor) { + let rect = this._getBoundingRect(true); + let oldArea = this._initialRect.width * this._initialRect.height; + let newArea = rect.width * rect.height; + let areaDiff = newArea / oldArea; + + this.emit('activated', areaDiff); + } +}); + +var ViewSelector = GObject.registerClass({ + Signals: { + 'page-changed': {}, + 'page-empty': {}, + }, +}, class ViewSelector extends Shell.Stack { + _init(searchEntry, workspaceAdjustment, showAppsButton) { + super._init({ + name: 'viewSelector', + x_expand: true, + visible: false, + }); + + this._showAppsButton = showAppsButton; + this._showAppsButton.connect('notify::checked', this._onShowAppsButtonToggled.bind(this)); + + this._activePage = null; + + this._searchActive = false; + + this._entry = searchEntry; + ShellEntry.addContextMenu(this._entry); + + this._text = this._entry.clutter_text; + this._text.connect('text-changed', this._onTextChanged.bind(this)); + this._text.connect('key-press-event', this._onKeyPress.bind(this)); + this._text.connect('key-focus-in', () => { + this._searchResults.highlightDefault(true); + }); + this._text.connect('key-focus-out', () => { + this._searchResults.highlightDefault(false); + }); + this._entry.connect('popup-menu', () => { + if (!this._searchActive) + return; + + this._entry.menu.close(); + this._searchResults.popupMenuDefault(); + }); + this._entry.connect('notify::mapped', this._onMapped.bind(this)); + global.stage.connect('notify::key-focus', this._onStageKeyFocusChanged.bind(this)); + + this._entry.set_primary_icon(new St.Icon({ style_class: 'search-entry-icon', + icon_name: 'edit-find-symbolic' })); + this._clearIcon = new St.Icon({ style_class: 'search-entry-icon', + icon_name: 'edit-clear-symbolic' }); + + this._iconClickedId = 0; + this._capturedEventId = 0; + + this._workspacesDisplay = + new WorkspacesView.WorkspacesDisplay(workspaceAdjustment); + this._workspacesPage = this._addPage(this._workspacesDisplay, + _("Windows"), 'focus-windows-symbolic'); + + this.appDisplay = new AppDisplay.AppDisplay(); + this._appsPage = this._addPage(this.appDisplay, + _("Applications"), 'view-app-grid-symbolic'); + + this._searchResults = new Search.SearchResultsView(); + this._searchPage = this._addPage(this._searchResults, + _("Search"), 'edit-find-symbolic', + { a11yFocus: this._entry }); + + // Since the entry isn't inside the results container we install this + // dummy widget as the last results container child so that we can + // include the entry in the keynav tab path + this._focusTrap = new FocusTrap({ can_focus: true }); + this._focusTrap.connect('key-focus-in', () => { + this._entry.grab_key_focus(); + }); + this._searchResults.add_actor(this._focusTrap); + + global.focus_manager.add_group(this._searchResults); + + this._stageKeyPressId = 0; + Main.overview.connect('showing', () => { + this._stageKeyPressId = global.stage.connect('key-press-event', + this._onStageKeyPress.bind(this)); + }); + Main.overview.connect('hiding', () => { + if (this._stageKeyPressId != 0) { + global.stage.disconnect(this._stageKeyPressId); + this._stageKeyPressId = 0; + } + }); + Main.overview.connect('shown', () => { + // If we were animating from the desktop view to the + // apps page the workspace page was visible, allowing + // the windows to animate, but now we no longer want to + // show it given that we are now on the apps page or + // search page. + if (this._activePage != this._workspacesPage) { + this._workspacesPage.opacity = 0; + this._workspacesPage.hide(); + } + }); + + Main.wm.addKeybinding('toggle-application-view', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._toggleAppsPage.bind(this)); + + Main.wm.addKeybinding('toggle-overview', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + Main.overview.toggle.bind(Main.overview)); + + let side; + if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) + side = St.Side.RIGHT; + else + side = St.Side.LEFT; + let gesture = new EdgeDragAction.EdgeDragAction(side, + Shell.ActionMode.NORMAL); + gesture.connect('activated', () => { + if (Main.overview.visible) + Main.overview.hide(); + else + this.showApps(); + }); + global.stage.add_action(gesture); + + gesture = new ShowOverviewAction(); + gesture.connect('activated', this._pinchGestureActivated.bind(this)); + global.stage.add_action(gesture); + + gesture = new TouchpadShowOverviewAction(global.stage); + gesture.connect('activated', this._pinchGestureActivated.bind(this)); + } + + _pinchGestureActivated(action, scale) { + if (scale < PINCH_GESTURE_THRESHOLD) + Main.overview.show(); + } + + _toggleAppsPage() { + this._showAppsButton.checked = !this._showAppsButton.checked; + Main.overview.show(); + } + + showApps() { + this._showAppsButton.checked = true; + Main.overview.show(); + } + + animateToOverview() { + this.show(); + this.reset(); + this._workspacesDisplay.animateToOverview(this._showAppsButton.checked); + this._activePage = null; + if (this._showAppsButton.checked) + this._showPage(this._appsPage); + else + this._showPage(this._workspacesPage); + + if (!this._workspacesDisplay.activeWorkspaceHasMaximizedWindows()) + Main.overview.fadeOutDesktop(); + } + + animateFromOverview() { + // Make sure workspace page is fully visible to allow + // workspace.js do the animation of the windows + this._workspacesPage.opacity = 255; + + this._workspacesDisplay.animateFromOverview(this._activePage != this._workspacesPage); + + this._showAppsButton.checked = false; + + if (!this._workspacesDisplay.activeWorkspaceHasMaximizedWindows()) + Main.overview.fadeInDesktop(); + } + + vfunc_hide() { + this.reset(); + this._workspacesDisplay.hide(); + + super.vfunc_hide(); + } + + _addPage(actor, name, a11yIcon, params) { + params = Params.parse(params, { a11yFocus: null }); + + let page = new St.Bin({ child: actor }); + + if (params.a11yFocus) { + Main.ctrlAltTabManager.addGroup(params.a11yFocus, name, a11yIcon); + } else { + Main.ctrlAltTabManager.addGroup(actor, name, a11yIcon, { + proxy: this, + focusCallback: () => this._a11yFocusPage(page), + }); + } + page.hide(); + this.add_actor(page); + return page; + } + + _fadePageIn() { + this._activePage.ease({ + opacity: 255, + duration: OverviewControls.SIDE_CONTROLS_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + _fadePageOut(page) { + let oldPage = page; + page.ease({ + opacity: 0, + duration: OverviewControls.SIDE_CONTROLS_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onStopped: () => this._animateIn(oldPage), + }); + } + + _animateIn(oldPage) { + if (oldPage) + oldPage.hide(); + + this.emit('page-empty'); + + this._activePage.show(); + + if (this._activePage == this._appsPage && oldPage == this._workspacesPage) { + // Restore opacity, in case we animated via _fadePageOut + this._activePage.opacity = 255; + this.appDisplay.animate(IconGrid.AnimationDirection.IN); + } else { + this._fadePageIn(); + } + } + + _animateOut(page) { + let oldPage = page; + if (page == this._appsPage && + this._activePage == this._workspacesPage && + !Main.overview.animationInProgress) { + this.appDisplay.animate(IconGrid.AnimationDirection.OUT, () => { + this._animateIn(oldPage); + }); + } else { + this._fadePageOut(page); + } + } + + _showPage(page) { + if (!Main.overview.visible) + return; + + if (page == this._activePage) + return; + + let oldPage = this._activePage; + this._activePage = page; + this.emit('page-changed'); + + if (oldPage) + this._animateOut(oldPage); + else + this._animateIn(); + } + + _a11yFocusPage(page) { + this._showAppsButton.checked = page == this._appsPage; + page.navigate_focus(null, St.DirectionType.TAB_FORWARD, false); + } + + _onShowAppsButtonToggled() { + this._showPage(this._showAppsButton.checked + ? this._appsPage : this._workspacesPage); + } + + _onStageKeyPress(actor, event) { + // Ignore events while anything but the overview has + // pushed a modal (system modals, looking glass, ...) + if (Main.modalCount > 1) + return Clutter.EVENT_PROPAGATE; + + let symbol = event.get_key_symbol(); + + if (symbol === Clutter.KEY_Escape) { + if (this._searchActive) + this.reset(); + else if (this._showAppsButton.checked) + this._showAppsButton.checked = false; + else + Main.overview.hide(); + return Clutter.EVENT_STOP; + } else if (this._shouldTriggerSearch(symbol)) { + this.startSearch(event); + } else if (!this._searchActive && !global.stage.key_focus) { + if (symbol === Clutter.KEY_Tab || symbol === Clutter.KEY_Down) { + this._activePage.navigate_focus(null, St.DirectionType.TAB_FORWARD, false); + return Clutter.EVENT_STOP; + } else if (symbol === Clutter.KEY_ISO_Left_Tab) { + this._activePage.navigate_focus(null, St.DirectionType.TAB_BACKWARD, false); + return Clutter.EVENT_STOP; + } + } + return Clutter.EVENT_PROPAGATE; + } + + _searchCancelled() { + this._showPage(this._showAppsButton.checked + ? this._appsPage + : this._workspacesPage); + + // Leave the entry focused when it doesn't have any text; + // when replacing a selected search term, Clutter emits + // two 'text-changed' signals, one for deleting the previous + // text and one for the new one - the second one is handled + // incorrectly when we remove focus + // (https://bugzilla.gnome.org/show_bug.cgi?id=636341) */ + if (this._text.text != '') + this.reset(); + } + + reset() { + // Don't drop the key focus on Clutter's side if anything but the + // overview has pushed a modal (e.g. system modals when activated using + // the overview). + if (Main.modalCount <= 1) + global.stage.set_key_focus(null); + + this._entry.text = ''; + + this._text.set_cursor_visible(true); + this._text.set_selection(0, 0); + } + + _onStageKeyFocusChanged() { + let focus = global.stage.get_key_focus(); + let appearFocused = this._entry.contains(focus) || + this._searchResults.contains(focus); + + this._text.set_cursor_visible(appearFocused); + + if (appearFocused) + this._entry.add_style_pseudo_class('focus'); + else + this._entry.remove_style_pseudo_class('focus'); + } + + _onMapped() { + if (this._entry.mapped) { + // Enable 'find-as-you-type' + this._capturedEventId = global.stage.connect('captured-event', + this._onCapturedEvent.bind(this)); + this._text.set_cursor_visible(true); + this._text.set_selection(0, 0); + } else { + // Disable 'find-as-you-type' + if (this._capturedEventId > 0) + global.stage.disconnect(this._capturedEventId); + this._capturedEventId = 0; + } + } + + _shouldTriggerSearch(symbol) { + if (symbol === Clutter.KEY_Multi_key) + return true; + + if (symbol === Clutter.KEY_BackSpace && this._searchActive) + return true; + + let unicode = Clutter.keysym_to_unicode(symbol); + if (unicode == 0) + return false; + + if (getTermsForSearchString(String.fromCharCode(unicode)).length > 0) + return true; + + return false; + } + + startSearch(event) { + global.stage.set_key_focus(this._text); + + let synthEvent = event.copy(); + synthEvent.set_source(this._text); + this._text.event(synthEvent, false); + } + + // the entry does not show the hint + _isActivated() { + return this._text.text == this._entry.get_text(); + } + + _onTextChanged() { + let terms = getTermsForSearchString(this._entry.get_text()); + + this._searchActive = terms.length > 0; + this._searchResults.setTerms(terms); + + if (this._searchActive) { + this._showPage(this._searchPage); + + this._entry.set_secondary_icon(this._clearIcon); + + if (this._iconClickedId == 0) { + this._iconClickedId = this._entry.connect('secondary-icon-clicked', + this.reset.bind(this)); + } + } else { + if (this._iconClickedId > 0) { + this._entry.disconnect(this._iconClickedId); + this._iconClickedId = 0; + } + + this._entry.set_secondary_icon(null); + this._searchCancelled(); + } + } + + _onKeyPress(entry, event) { + let symbol = event.get_key_symbol(); + if (symbol === Clutter.KEY_Escape) { + if (this._isActivated()) { + this.reset(); + return Clutter.EVENT_STOP; + } + } else if (this._searchActive) { + let arrowNext, nextDirection; + if (entry.get_text_direction() == Clutter.TextDirection.RTL) { + arrowNext = Clutter.KEY_Left; + nextDirection = St.DirectionType.LEFT; + } else { + arrowNext = Clutter.KEY_Right; + nextDirection = St.DirectionType.RIGHT; + } + + if (symbol === Clutter.KEY_Tab) { + this._searchResults.navigateFocus(St.DirectionType.TAB_FORWARD); + return Clutter.EVENT_STOP; + } else if (symbol === Clutter.KEY_ISO_Left_Tab) { + this._focusTrap.can_focus = false; + this._searchResults.navigateFocus(St.DirectionType.TAB_BACKWARD); + this._focusTrap.can_focus = true; + return Clutter.EVENT_STOP; + } else if (symbol === Clutter.KEY_Down) { + this._searchResults.navigateFocus(St.DirectionType.DOWN); + return Clutter.EVENT_STOP; + } else if (symbol == arrowNext && this._text.position == -1) { + this._searchResults.navigateFocus(nextDirection); + return Clutter.EVENT_STOP; + } else if (symbol === Clutter.KEY_Return || symbol === Clutter.KEY_KP_Enter) { + this._searchResults.activateDefault(); + return Clutter.EVENT_STOP; + } + } + return Clutter.EVENT_PROPAGATE; + } + + _onCapturedEvent(actor, event) { + if (event.type() == Clutter.EventType.BUTTON_PRESS) { + let source = event.get_source(); + if (source != this._text && + this._text.has_key_focus() && + this._text.text == '' && + !this._text.has_preedit() && + !Main.layoutManager.keyboardBox.contains(source)) { + // the user clicked outside after activating the entry, but + // with no search term entered and no keyboard button pressed + // - cancel the search + this.reset(); + } + } + + return Clutter.EVENT_PROPAGATE; + } + + getActivePage() { + if (this._activePage == this._workspacesPage) + return ViewPage.WINDOWS; + else if (this._activePage == this._appsPage) + return ViewPage.APPS; + else + return ViewPage.SEARCH; + } +}); diff --git a/js/ui/windowAttentionHandler.js b/js/ui/windowAttentionHandler.js new file mode 100644 index 0000000..346fad8 --- /dev/null +++ b/js/ui/windowAttentionHandler.js @@ -0,0 +1,106 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported WindowAttentionHandler */ + +const { GObject, Shell } = imports.gi; + +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; + +var WindowAttentionHandler = class { + constructor() { + this._tracker = Shell.WindowTracker.get_default(); + this._windowDemandsAttentionId = global.display.connect('window-demands-attention', + this._onWindowDemandsAttention.bind(this)); + this._windowMarkedUrgentId = global.display.connect('window-marked-urgent', + this._onWindowDemandsAttention.bind(this)); + } + + _getTitleAndBanner(app, window) { + let title = app.get_name(); + let banner = _("“%s” is ready").format(window.get_title()); + return [title, banner]; + } + + _onWindowDemandsAttention(display, window) { + // We don't want to show the notification when the window is already focused, + // because this is rather pointless. + // Some apps (like GIMP) do things like setting the urgency hint on the + // toolbar windows which would result into a notification even though GIMP itself is + // focused. + // We are just ignoring the hint on skip_taskbar windows for now. + // (Which is the same behaviour as with metacity + panel) + + if (!window || window.has_focus() || window.is_skip_taskbar()) + return; + + let app = this._tracker.get_window_app(window); + let source = new WindowAttentionSource(app, window); + Main.messageTray.add(source); + + let [title, banner] = this._getTitleAndBanner(app, window); + + let notification = new MessageTray.Notification(source, title, banner); + notification.connect('activated', () => { + source.open(); + }); + notification.setForFeedback(true); + + source.showNotification(notification); + + source.signalIDs.push(window.connect('notify::title', () => { + [title, banner] = this._getTitleAndBanner(app, window); + notification.update(title, banner); + })); + } +}; + +var WindowAttentionSource = GObject.registerClass( +class WindowAttentionSource extends MessageTray.Source { + _init(app, window) { + this._window = window; + this._app = app; + + super._init(app.get_name()); + + this.signalIDs = []; + this.signalIDs.push(this._window.connect('notify::demands-attention', + this._sync.bind(this))); + this.signalIDs.push(this._window.connect('notify::urgent', + this._sync.bind(this))); + this.signalIDs.push(this._window.connect('focus', + () => this.destroy())); + this.signalIDs.push(this._window.connect('unmanaged', + () => this.destroy())); + } + + _sync() { + if (this._window.demands_attention || this._window.urgent) + return; + this.destroy(); + } + + _createPolicy() { + if (this._app && this._app.get_app_info()) { + let id = this._app.get_id().replace(/\.desktop$/, ''); + return new MessageTray.NotificationApplicationPolicy(id); + } else { + return new MessageTray.NotificationGenericPolicy(); + } + } + + createIcon(size) { + return this._app.create_icon_texture(size); + } + + destroy(params) { + for (let i = 0; i < this.signalIDs.length; i++) + this._window.disconnect(this.signalIDs[i]); + this.signalIDs = []; + + super.destroy(params); + } + + open() { + Main.activateWindow(this._window); + } +}); diff --git a/js/ui/windowManager.js b/js/ui/windowManager.js new file mode 100644 index 0000000..a5f371c --- /dev/null +++ b/js/ui/windowManager.js @@ -0,0 +1,2254 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported WindowManager */ + +const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; + +const AltTab = imports.ui.altTab; +const AppFavorites = imports.ui.appFavorites; +const Dialog = imports.ui.dialog; +const WorkspaceSwitcherPopup = imports.ui.workspaceSwitcherPopup; +const InhibitShortcutsDialog = imports.ui.inhibitShortcutsDialog; +const Main = imports.ui.main; +const ModalDialog = imports.ui.modalDialog; +const WindowMenu = imports.ui.windowMenu; +const PadOsd = imports.ui.padOsd; +const EdgeDragAction = imports.ui.edgeDragAction; +const CloseDialog = imports.ui.closeDialog; +const SwipeTracker = imports.ui.swipeTracker; +const SwitchMonitor = imports.ui.switchMonitor; +const IBusManager = imports.misc.ibusManager; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +var SHELL_KEYBINDINGS_SCHEMA = 'org.gnome.shell.keybindings'; +var MINIMIZE_WINDOW_ANIMATION_TIME = 200; +var SHOW_WINDOW_ANIMATION_TIME = 150; +var DIALOG_SHOW_WINDOW_ANIMATION_TIME = 100; +var DESTROY_WINDOW_ANIMATION_TIME = 150; +var DIALOG_DESTROY_WINDOW_ANIMATION_TIME = 100; +var WINDOW_ANIMATION_TIME = 250; +var DIM_BRIGHTNESS = -0.3; +var DIM_TIME = 500; +var UNDIM_TIME = 250; +var APP_MOTION_THRESHOLD = 30; + +var ONE_SECOND = 1000; // in ms + +const GSD_WACOM_BUS_NAME = 'org.gnome.SettingsDaemon.Wacom'; +const GSD_WACOM_OBJECT_PATH = '/org/gnome/SettingsDaemon/Wacom'; + +const GsdWacomIface = loadInterfaceXML('org.gnome.SettingsDaemon.Wacom'); +const GsdWacomProxy = Gio.DBusProxy.makeProxyWrapper(GsdWacomIface); + +const WINDOW_DIMMER_EFFECT_NAME = "gnome-shell-window-dimmer"; + +Gio._promisify(Shell, + 'util_start_systemd_unit', 'util_start_systemd_unit_finish'); +Gio._promisify(Shell, + 'util_stop_systemd_unit', 'util_stop_systemd_unit_finish'); + +var DisplayChangeDialog = GObject.registerClass( +class DisplayChangeDialog extends ModalDialog.ModalDialog { + _init(wm) { + super._init(); + + this._wm = wm; + + this._countDown = Meta.MonitorManager.get_display_configuration_timeout(); + + // Translators: This string should be shorter than 30 characters + let title = _('Keep these display settings?'); + let description = this._formatCountDown(); + + this._content = new Dialog.MessageDialogContent({ title, description }); + this.contentLayout.add_child(this._content); + + /* Translators: this and the following message should be limited in length, + to avoid ellipsizing the labels. + */ + this._cancelButton = this.addButton({ label: _("Revert Settings"), + action: this._onFailure.bind(this), + key: Clutter.KEY_Escape }); + this._okButton = this.addButton({ label: _("Keep Changes"), + action: this._onSuccess.bind(this), + default: true }); + + this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, ONE_SECOND, this._tick.bind(this)); + GLib.Source.set_name_by_id(this._timeoutId, '[gnome-shell] this._tick'); + } + + close(timestamp) { + if (this._timeoutId > 0) { + GLib.source_remove(this._timeoutId); + this._timeoutId = 0; + } + + super.close(timestamp); + } + + _formatCountDown() { + const fmt = ngettext( + 'Settings changes will revert in %d second', + 'Settings changes will revert in %d seconds', + this._countDown); + return fmt.format(this._countDown); + } + + _tick() { + this._countDown--; + + if (this._countDown == 0) { + /* mutter already takes care of failing at timeout */ + this._timeoutId = 0; + this.close(); + return GLib.SOURCE_REMOVE; + } + + this._content.description = this._formatCountDown(); + return GLib.SOURCE_CONTINUE; + } + + _onFailure() { + this._wm.complete_display_change(false); + this.close(); + } + + _onSuccess() { + this._wm.complete_display_change(true); + this.close(); + } +}); + +var WindowDimmer = GObject.registerClass( +class WindowDimmer extends Clutter.BrightnessContrastEffect { + _init() { + super._init({ + name: WINDOW_DIMMER_EFFECT_NAME, + enabled: false, + }); + this._enabled = true; + } + + _syncEnabled() { + let transitionName = '@effects.%s.brightness'.format(this.name); + let animating = this.actor.get_transition(transitionName) != null; + let dimmed = this.brightness.red != 127; + this.enabled = this._enabled && (animating || dimmed); + } + + setEnabled(enabled) { + this._enabled = enabled; + this._syncEnabled(); + } + + setDimmed(dimmed, animate) { + let val = 127 * (1 + (dimmed ? 1 : 0) * DIM_BRIGHTNESS); + let color = Clutter.Color.new(val, val, val, 255); + + let transitionName = '@effects.%s.brightness'.format(this.name); + this.actor.ease_property(transitionName, color, { + mode: Clutter.AnimationMode.LINEAR, + duration: (dimmed ? DIM_TIME : UNDIM_TIME) * (animate ? 1 : 0), + onComplete: () => this._syncEnabled(), + }); + + this._syncEnabled(); + } +}); + +function getWindowDimmer(actor) { + let enabled = Meta.prefs_get_attach_modal_dialogs(); + let effect = actor.get_effect(WINDOW_DIMMER_EFFECT_NAME); + + if (effect) { + effect.setEnabled(enabled); + } else if (enabled) { + effect = new WindowDimmer(); + actor.add_effect(effect); + } + return effect; +} + +/* + * When the last window closed on a workspace is a dialog or splash + * screen, we assume that it might be an initial window shown before + * the main window of an application, and give the app a grace period + * where it can map another window before we remove the workspace. + */ +var LAST_WINDOW_GRACE_TIME = 1000; + +var WorkspaceTracker = class { + constructor(wm) { + this._wm = wm; + + this._workspaces = []; + this._checkWorkspacesId = 0; + + this._pauseWorkspaceCheck = false; + + let tracker = Shell.WindowTracker.get_default(); + tracker.connect('startup-sequence-changed', this._queueCheckWorkspaces.bind(this)); + + let workspaceManager = global.workspace_manager; + workspaceManager.connect('notify::n-workspaces', + this._nWorkspacesChanged.bind(this)); + workspaceManager.connect('workspaces-reordered', () => { + this._workspaces.sort((a, b) => a.index() - b.index()); + }); + global.window_manager.connect('switch-workspace', + this._queueCheckWorkspaces.bind(this)); + + global.display.connect('window-entered-monitor', + this._windowEnteredMonitor.bind(this)); + global.display.connect('window-left-monitor', + this._windowLeftMonitor.bind(this)); + global.display.connect('restacked', + this._windowsRestacked.bind(this)); + + this._workspaceSettings = new Gio.Settings({ schema_id: 'org.gnome.mutter' }); + this._workspaceSettings.connect('changed::dynamic-workspaces', this._queueCheckWorkspaces.bind(this)); + + this._nWorkspacesChanged(); + } + + blockUpdates() { + this._pauseWorkspaceCheck = true; + } + + unblockUpdates() { + this._pauseWorkspaceCheck = false; + } + + _checkWorkspaces() { + let workspaceManager = global.workspace_manager; + let i; + let emptyWorkspaces = []; + + if (!Meta.prefs_get_dynamic_workspaces()) { + this._checkWorkspacesId = 0; + return false; + } + + // Update workspaces only if Dynamic Workspace Management has not been paused by some other function + if (this._pauseWorkspaceCheck) + return true; + + for (i = 0; i < this._workspaces.length; i++) { + let lastRemoved = this._workspaces[i]._lastRemovedWindow; + if ((lastRemoved && + (lastRemoved.get_window_type() == Meta.WindowType.SPLASHSCREEN || + lastRemoved.get_window_type() == Meta.WindowType.DIALOG || + lastRemoved.get_window_type() == Meta.WindowType.MODAL_DIALOG)) || + this._workspaces[i]._keepAliveId) + emptyWorkspaces[i] = false; + else + emptyWorkspaces[i] = true; + } + + let sequences = Shell.WindowTracker.get_default().get_startup_sequences(); + for (i = 0; i < sequences.length; i++) { + let index = sequences[i].get_workspace(); + if (index >= 0 && index <= workspaceManager.n_workspaces) + emptyWorkspaces[index] = false; + } + + let windows = global.get_window_actors(); + for (i = 0; i < windows.length; i++) { + let actor = windows[i]; + let win = actor.get_meta_window(); + + if (win.is_on_all_workspaces()) + continue; + + let workspaceIndex = win.get_workspace().index(); + emptyWorkspaces[workspaceIndex] = false; + } + + // If we don't have an empty workspace at the end, add one + if (!emptyWorkspaces[emptyWorkspaces.length - 1]) { + workspaceManager.append_new_workspace(false, global.get_current_time()); + emptyWorkspaces.push(true); + } + + let lastIndex = emptyWorkspaces.length - 1; + let lastEmptyIndex = emptyWorkspaces.lastIndexOf(false) + 1; + let activeWorkspaceIndex = workspaceManager.get_active_workspace_index(); + emptyWorkspaces[activeWorkspaceIndex] = false; + + // Delete empty workspaces except for the last one; do it from the end + // to avoid index changes + for (i = lastIndex; i >= 0; i--) { + if (emptyWorkspaces[i] && i != lastEmptyIndex) + workspaceManager.remove_workspace(this._workspaces[i], global.get_current_time()); + } + + this._checkWorkspacesId = 0; + return false; + } + + keepWorkspaceAlive(workspace, duration) { + if (workspace._keepAliveId) + GLib.source_remove(workspace._keepAliveId); + + workspace._keepAliveId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, duration, () => { + workspace._keepAliveId = 0; + this._queueCheckWorkspaces(); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(workspace._keepAliveId, '[gnome-shell] this._queueCheckWorkspaces'); + } + + _windowRemoved(workspace, window) { + workspace._lastRemovedWindow = window; + this._queueCheckWorkspaces(); + let id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, LAST_WINDOW_GRACE_TIME, () => { + if (workspace._lastRemovedWindow == window) { + workspace._lastRemovedWindow = null; + this._queueCheckWorkspaces(); + } + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] this._queueCheckWorkspaces'); + } + + _windowLeftMonitor(metaDisplay, monitorIndex, _metaWin) { + // If the window left the primary monitor, that + // might make that workspace empty + if (monitorIndex == Main.layoutManager.primaryIndex) + this._queueCheckWorkspaces(); + } + + _windowEnteredMonitor(metaDisplay, monitorIndex, _metaWin) { + // If the window entered the primary monitor, that + // might make that workspace non-empty + if (monitorIndex == Main.layoutManager.primaryIndex) + this._queueCheckWorkspaces(); + } + + _windowsRestacked() { + // Figure out where the pointer is in case we lost track of + // it during a grab. (In particular, if a trayicon popup menu + // is dismissed, see if we need to close the message tray.) + global.sync_pointer(); + } + + _queueCheckWorkspaces() { + if (this._checkWorkspacesId == 0) + this._checkWorkspacesId = Meta.later_add(Meta.LaterType.BEFORE_REDRAW, this._checkWorkspaces.bind(this)); + } + + _nWorkspacesChanged() { + let workspaceManager = global.workspace_manager; + let oldNumWorkspaces = this._workspaces.length; + let newNumWorkspaces = workspaceManager.n_workspaces; + + if (oldNumWorkspaces == newNumWorkspaces) + return false; + + if (newNumWorkspaces > oldNumWorkspaces) { + let w; + + // Assume workspaces are only added at the end + for (w = oldNumWorkspaces; w < newNumWorkspaces; w++) + this._workspaces[w] = workspaceManager.get_workspace_by_index(w); + + for (w = oldNumWorkspaces; w < newNumWorkspaces; w++) { + let workspace = this._workspaces[w]; + workspace._windowAddedId = workspace.connect('window-added', this._queueCheckWorkspaces.bind(this)); + workspace._windowRemovedId = workspace.connect('window-removed', this._windowRemoved.bind(this)); + } + + } else { + // Assume workspaces are only removed sequentially + // (e.g. 2,3,4 - not 2,4,7) + let removedIndex; + let removedNum = oldNumWorkspaces - newNumWorkspaces; + for (let w = 0; w < oldNumWorkspaces; w++) { + let workspace = workspaceManager.get_workspace_by_index(w); + if (this._workspaces[w] != workspace) { + removedIndex = w; + break; + } + } + + let lostWorkspaces = this._workspaces.splice(removedIndex, removedNum); + lostWorkspaces.forEach(workspace => { + workspace.disconnect(workspace._windowAddedId); + workspace.disconnect(workspace._windowRemovedId); + }); + } + + this._queueCheckWorkspaces(); + + return false; + } +}; + +var TilePreview = GObject.registerClass( +class TilePreview extends St.Widget { + _init() { + super._init(); + global.window_group.add_actor(this); + + this._reset(); + this._showing = false; + } + + open(window, tileRect, monitorIndex) { + let windowActor = window.get_compositor_private(); + if (!windowActor) + return; + + global.window_group.set_child_below_sibling(this, windowActor); + + if (this._rect && this._rect.equal(tileRect)) + return; + + let changeMonitor = this._monitorIndex == -1 || + this._monitorIndex != monitorIndex; + + this._monitorIndex = monitorIndex; + this._rect = tileRect; + + let monitor = Main.layoutManager.monitors[monitorIndex]; + + this._updateStyle(monitor); + + if (!this._showing || changeMonitor) { + let monitorRect = new Meta.Rectangle({ x: monitor.x, + y: monitor.y, + width: monitor.width, + height: monitor.height }); + let [, rect] = window.get_frame_rect().intersect(monitorRect); + this.set_size(rect.width, rect.height); + this.set_position(rect.x, rect.y); + this.opacity = 0; + } + + this._showing = true; + this.show(); + this.ease({ + x: tileRect.x, + y: tileRect.y, + width: tileRect.width, + height: tileRect.height, + opacity: 255, + duration: WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + close() { + if (!this._showing) + return; + + this._showing = false; + this.ease({ + opacity: 0, + duration: WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._reset(), + }); + } + + _reset() { + this.hide(); + this._rect = null; + this._monitorIndex = -1; + } + + _updateStyle(monitor) { + let styles = ['tile-preview']; + if (this._monitorIndex == Main.layoutManager.primaryIndex) + styles.push('on-primary'); + if (this._rect.x == monitor.x) + styles.push('tile-preview-left'); + if (this._rect.x + this._rect.width == monitor.x + monitor.width) + styles.push('tile-preview-right'); + + this.style_class = styles.join(' '); + } +}); + +var AppSwitchAction = GObject.registerClass({ + Signals: { 'activated': {} }, +}, class AppSwitchAction extends Clutter.GestureAction { + _init() { + super._init(); + this.set_n_touch_points(3); + + global.display.connect('grab-op-begin', () => { + this.cancel(); + }); + } + + vfunc_gesture_prepare(_actor) { + if (Main.actionMode != Shell.ActionMode.NORMAL) { + this.cancel(); + return false; + } + + return this.get_n_current_points() <= 4; + } + + vfunc_gesture_begin(_actor) { + // in milliseconds + const LONG_PRESS_TIMEOUT = 250; + + let nPoints = this.get_n_current_points(); + let event = this.get_last_event(nPoints - 1); + + if (nPoints == 3) { + this._longPressStartTime = event.get_time(); + } else if (nPoints == 4) { + // Check whether the 4th finger press happens after a 3-finger long press, + // this only needs to be checked on the first 4th finger press + if (this._longPressStartTime != null && + event.get_time() < this._longPressStartTime + LONG_PRESS_TIMEOUT) { + this.cancel(); + } else { + this._longPressStartTime = null; + this.emit('activated'); + } + } + + return this.get_n_current_points() <= 4; + } + + vfunc_gesture_progress(_actor) { + + if (this.get_n_current_points() == 3) { + for (let i = 0; i < this.get_n_current_points(); i++) { + let [startX, startY] = this.get_press_coords(i); + let [x, y] = this.get_motion_coords(i); + + if (Math.abs(x - startX) > APP_MOTION_THRESHOLD || + Math.abs(y - startY) > APP_MOTION_THRESHOLD) + return false; + } + + } + + return true; + } +}); + +var ResizePopup = GObject.registerClass( +class ResizePopup extends St.Widget { + _init() { + super._init({ layout_manager: new Clutter.BinLayout() }); + this._label = new St.Label({ style_class: 'resize-popup', + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + x_expand: true, y_expand: true }); + this.add_child(this._label); + Main.uiGroup.add_actor(this); + } + + set(rect, displayW, displayH) { + /* Translators: This represents the size of a window. The first number is + * the width of the window and the second is the height. */ + let text = _("%d × %d").format(displayW, displayH); + this._label.set_text(text); + + this.set_position(rect.x, rect.y); + this.set_size(rect.width, rect.height); + } +}); + +var WindowManager = class { + constructor() { + this._shellwm = global.window_manager; + + this._minimizing = new Set(); + this._unminimizing = new Set(); + this._mapping = new Set(); + this._resizing = new Set(); + this._resizePending = new Set(); + this._destroying = new Set(); + this._movingWindow = null; + + this._dimmedWindows = []; + + this._skippedActors = new Set(); + + this._allowedKeybindings = {}; + + this._isWorkspacePrepended = false; + + this._switchData = null; + this._shellwm.connect('kill-switch-workspace', shellwm => { + if (this._switchData) { + if (this._switchData.inProgress) + this._switchWorkspaceDone(shellwm); + else if (!this._switchData.gestureActivated) + this._finishWorkspaceSwitch(this._switchData); + } + }); + this._shellwm.connect('kill-window-effects', (shellwm, actor) => { + this._minimizeWindowDone(shellwm, actor); + this._mapWindowDone(shellwm, actor); + this._destroyWindowDone(shellwm, actor); + this._sizeChangeWindowDone(shellwm, actor); + }); + + this._shellwm.connect('switch-workspace', this._switchWorkspace.bind(this)); + this._shellwm.connect('show-tile-preview', this._showTilePreview.bind(this)); + this._shellwm.connect('hide-tile-preview', this._hideTilePreview.bind(this)); + this._shellwm.connect('show-window-menu', this._showWindowMenu.bind(this)); + this._shellwm.connect('minimize', this._minimizeWindow.bind(this)); + this._shellwm.connect('unminimize', this._unminimizeWindow.bind(this)); + this._shellwm.connect('size-change', this._sizeChangeWindow.bind(this)); + this._shellwm.connect('size-changed', this._sizeChangedWindow.bind(this)); + this._shellwm.connect('map', this._mapWindow.bind(this)); + this._shellwm.connect('destroy', this._destroyWindow.bind(this)); + this._shellwm.connect('filter-keybinding', this._filterKeybinding.bind(this)); + this._shellwm.connect('confirm-display-change', this._confirmDisplayChange.bind(this)); + this._shellwm.connect('create-close-dialog', this._createCloseDialog.bind(this)); + this._shellwm.connect('create-inhibit-shortcuts-dialog', this._createInhibitShortcutsDialog.bind(this)); + global.display.connect('restacked', this._syncStacking.bind(this)); + + this._workspaceSwitcherPopup = null; + this._tilePreview = null; + + this.allowKeybinding('switch-to-session-1', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-2', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-3', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-4', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-5', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-6', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-7', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-8', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-9', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-10', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-11', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-12', Shell.ActionMode.ALL); + + this.setCustomKeybindingHandler('switch-to-workspace-left', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-right', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-up', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-down', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-last', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-left', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-right', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-up', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-down', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-1', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-2', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-3', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-4', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-5', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-6', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-7', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-8', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-9', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-10', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-11', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-12', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-1', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-2', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-3', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-4', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-5', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-6', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-7', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-8', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-9', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-10', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-11', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-12', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-last', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-applications', + Shell.ActionMode.NORMAL, + this._startSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-group', + Shell.ActionMode.NORMAL, + this._startSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-applications-backward', + Shell.ActionMode.NORMAL, + this._startSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-group-backward', + Shell.ActionMode.NORMAL, + this._startSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-windows', + Shell.ActionMode.NORMAL, + this._startSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-windows-backward', + Shell.ActionMode.NORMAL, + this._startSwitcher.bind(this)); + this.setCustomKeybindingHandler('cycle-windows', + Shell.ActionMode.NORMAL, + this._startSwitcher.bind(this)); + this.setCustomKeybindingHandler('cycle-windows-backward', + Shell.ActionMode.NORMAL, + this._startSwitcher.bind(this)); + this.setCustomKeybindingHandler('cycle-group', + Shell.ActionMode.NORMAL, + this._startSwitcher.bind(this)); + this.setCustomKeybindingHandler('cycle-group-backward', + Shell.ActionMode.NORMAL, + this._startSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-panels', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW | + Shell.ActionMode.LOCK_SCREEN | + Shell.ActionMode.UNLOCK_SCREEN | + Shell.ActionMode.LOGIN_SCREEN, + this._startA11ySwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-panels-backward', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW | + Shell.ActionMode.LOCK_SCREEN | + Shell.ActionMode.UNLOCK_SCREEN | + Shell.ActionMode.LOGIN_SCREEN, + this._startA11ySwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-monitor', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._startSwitcher.bind(this)); + + this.addKeybinding('open-application-menu', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.POPUP, + this._toggleAppMenu.bind(this)); + + this.addKeybinding('toggle-message-tray', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW | + Shell.ActionMode.POPUP, + this._toggleCalendar.bind(this)); + + this.addKeybinding('switch-to-application-1', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._switchToApplication.bind(this)); + + this.addKeybinding('switch-to-application-2', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._switchToApplication.bind(this)); + + this.addKeybinding('switch-to-application-3', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._switchToApplication.bind(this)); + + this.addKeybinding('switch-to-application-4', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._switchToApplication.bind(this)); + + this.addKeybinding('switch-to-application-5', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._switchToApplication.bind(this)); + + this.addKeybinding('switch-to-application-6', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._switchToApplication.bind(this)); + + this.addKeybinding('switch-to-application-7', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._switchToApplication.bind(this)); + + this.addKeybinding('switch-to-application-8', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._switchToApplication.bind(this)); + + this.addKeybinding('switch-to-application-9', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._switchToApplication.bind(this)); + + global.display.connect('show-resize-popup', this._showResizePopup.bind(this)); + global.display.connect('show-pad-osd', this._showPadOsd.bind(this)); + global.display.connect('show-osd', (display, monitorIndex, iconName, label) => { + let icon = Gio.Icon.new_for_string(iconName); + Main.osdWindowManager.show(monitorIndex, icon, label, null); + }); + + this._gsdWacomProxy = new GsdWacomProxy(Gio.DBus.session, GSD_WACOM_BUS_NAME, + GSD_WACOM_OBJECT_PATH, + (proxy, error) => { + if (error) + log(error.message); + }); + + global.display.connect('pad-mode-switch', (display, pad, group, mode) => { + let labels = []; + + // FIXME: Fix num buttons + for (let i = 0; i < 50; i++) { + let str = display.get_pad_action_label(pad, Meta.PadActionType.BUTTON, i); + labels.push(str ? str : ''); + } + + if (this._gsdWacomProxy) { + this._gsdWacomProxy.SetOLEDLabelsRemote(pad.get_device_node(), labels); + this._gsdWacomProxy.SetGroupModeLEDRemote(pad.get_device_node(), group, mode); + } + }); + + global.display.connect('init-xserver', (display, task) => { + IBusManager.getIBusManager().restartDaemon(['--xim']); + + /* Timeout waiting for start job completion after 5 seconds */ + let cancellable = new Gio.Cancellable(); + GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 5, () => { + cancellable.cancel(); + return GLib.SOURCE_REMOVE; + }); + + this._startX11Services(task, cancellable); + + return true; + }); + global.display.connect('x11-display-closing', () => { + if (!Meta.is_wayland_compositor()) + return; + + this._stopX11Services(null); + + IBusManager.getIBusManager().restartDaemon(); + }); + + Main.overview.connect('showing', () => { + for (let i = 0; i < this._dimmedWindows.length; i++) + this._undimWindow(this._dimmedWindows[i]); + + if (this._switchData) { + if (this._switchData.gestureActivated) + this._switchWorkspaceStop(); + this._swipeTracker.enabled = false; + } + }); + Main.overview.connect('hiding', () => { + for (let i = 0; i < this._dimmedWindows.length; i++) + this._dimWindow(this._dimmedWindows[i]); + this._swipeTracker.enabled = true; + }); + + this._windowMenuManager = new WindowMenu.WindowMenuManager(); + + if (Main.sessionMode.hasWorkspaces) + this._workspaceTracker = new WorkspaceTracker(this); + + global.workspace_manager.override_workspace_layout(Meta.DisplayCorner.TOPLEFT, + false, -1, 1); + + let swipeTracker = new SwipeTracker.SwipeTracker(global.stage, + Shell.ActionMode.NORMAL, { allowDrag: false, allowScroll: false }); + swipeTracker.connect('begin', this._switchWorkspaceBegin.bind(this)); + swipeTracker.connect('update', this._switchWorkspaceUpdate.bind(this)); + swipeTracker.connect('end', this._switchWorkspaceEnd.bind(this)); + this._swipeTracker = swipeTracker; + + let appSwitchAction = new AppSwitchAction(); + appSwitchAction.connect('activated', this._switchApp.bind(this)); + global.stage.add_action(appSwitchAction); + + let mode = Shell.ActionMode.ALL & ~Shell.ActionMode.LOCK_SCREEN; + let bottomDragAction = new EdgeDragAction.EdgeDragAction(St.Side.BOTTOM, mode); + bottomDragAction.connect('activated', () => { + Main.keyboard.open(Main.layoutManager.bottomIndex); + }); + Main.layoutManager.connect('keyboard-visible-changed', (manager, visible) => { + bottomDragAction.cancel(); + bottomDragAction.set_enabled(!visible); + }); + global.stage.add_action(bottomDragAction); + + let topDragAction = new EdgeDragAction.EdgeDragAction(St.Side.TOP, mode); + topDragAction.connect('activated', () => { + let currentWindow = global.display.focus_window; + if (currentWindow) + currentWindow.unmake_fullscreen(); + }); + + let updateUnfullscreenGesture = () => { + let currentWindow = global.display.focus_window; + topDragAction.enabled = currentWindow && currentWindow.is_fullscreen(); + }; + + global.display.connect('notify::focus-window', updateUnfullscreenGesture); + global.display.connect('in-fullscreen-changed', updateUnfullscreenGesture); + + global.stage.add_action(topDragAction); + } + + async _startX11Services(task, cancellable) { + try { + await Shell.util_start_systemd_unit( + 'gnome-session-x11-services-ready.target', 'fail', cancellable); + } catch (e) { + // Ignore NOT_SUPPORTED error, which indicates we are not systemd + // managed and gnome-session will have taken care of everything + // already. + // Note that we do log cancellation from here. + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_SUPPORTED)) + log('Error starting X11 services: %s'.format(e.message)); + } finally { + task.return_boolean(true); + } + } + + async _stopX11Services(cancellable) { + try { + await Shell.util_stop_systemd_unit( + 'gnome-session-x11-services.target', 'fail', cancellable); + } catch (e) { + // Ignore NOT_SUPPORTED error, which indicates we are not systemd + // managed and gnome-session will have taken care of everything + // already. + // Note that we do log cancellation from here. + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_SUPPORTED)) + log('Error stopping X11 services: %s'.format(e.message)); + } + } + + _showPadOsd(display, device, settings, imagePath, editionMode, monitorIndex) { + this._currentPadOsd = new PadOsd.PadOsd(device, settings, imagePath, editionMode, monitorIndex); + this._currentPadOsd.connect('closed', () => (this._currentPadOsd = null)); + + return this._currentPadOsd; + } + + _lookupIndex(windows, metaWindow) { + for (let i = 0; i < windows.length; i++) { + if (windows[i].metaWindow == metaWindow) + return i; + } + return -1; + } + + _switchApp() { + let windows = global.get_window_actors().filter(actor => { + let win = actor.metaWindow; + let workspaceManager = global.workspace_manager; + let activeWorkspace = workspaceManager.get_active_workspace(); + return !win.is_override_redirect() && + win.located_on_workspace(activeWorkspace); + }); + + if (windows.length == 0) + return; + + let focusWindow = global.display.focus_window; + let nextWindow; + + if (focusWindow == null) { + nextWindow = windows[0].metaWindow; + } else { + let index = this._lookupIndex(windows, focusWindow) + 1; + + if (index >= windows.length) + index = 0; + + nextWindow = windows[index].metaWindow; + } + + Main.activateWindow(nextWindow); + } + + insertWorkspace(pos) { + let workspaceManager = global.workspace_manager; + + if (!Meta.prefs_get_dynamic_workspaces()) + return; + + workspaceManager.append_new_workspace(false, global.get_current_time()); + + let windows = global.get_window_actors().map(a => a.meta_window); + + // To create a new workspace, we slide all the windows on workspaces + // below us to the next workspace, leaving a blank workspace for us + // to recycle. + windows.forEach(window => { + // If the window is attached to an ancestor, we don't need/want + // to move it + if (window.get_transient_for() != null) + return; + // Same for OR windows + if (window.is_override_redirect()) + return; + // Sticky windows don't need moving, in fact moving would + // unstick them + if (window.on_all_workspaces) + return; + // Windows on workspaces below pos don't need moving + let index = window.get_workspace().index(); + if (index < pos) + return; + window.change_workspace_by_index(index + 1, true); + }); + + // If the new workspace was inserted before the active workspace, + // activate the workspace to which its windows went + let activeIndex = workspaceManager.get_active_workspace_index(); + if (activeIndex >= pos) { + let newWs = workspaceManager.get_workspace_by_index(activeIndex + 1); + this._blockAnimations = true; + newWs.activate(global.get_current_time()); + this._blockAnimations = false; + } + } + + keepWorkspaceAlive(workspace, duration) { + if (!this._workspaceTracker) + return; + + this._workspaceTracker.keepWorkspaceAlive(workspace, duration); + } + + skipNextEffect(actor) { + this._skippedActors.add(actor); + } + + setCustomKeybindingHandler(name, modes, handler) { + if (Meta.keybindings_set_custom_handler(name, handler)) + this.allowKeybinding(name, modes); + } + + addKeybinding(name, settings, flags, modes, handler) { + let action = global.display.add_keybinding(name, settings, flags, handler); + if (action != Meta.KeyBindingAction.NONE) + this.allowKeybinding(name, modes); + return action; + } + + removeKeybinding(name) { + if (global.display.remove_keybinding(name)) + this.allowKeybinding(name, Shell.ActionMode.NONE); + } + + allowKeybinding(name, modes) { + this._allowedKeybindings[name] = modes; + } + + _shouldAnimate() { + return !(Main.overview.visible || + (this._switchData && this._switchData.gestureActivated)); + } + + _shouldAnimateActor(actor, types) { + if (this._skippedActors.delete(actor)) + return false; + + if (!this._shouldAnimate()) + return false; + + if (!actor.get_texture()) + return false; + + let type = actor.meta_window.get_window_type(); + return types.includes(type); + } + + _minimizeWindow(shellwm, actor) { + let types = [Meta.WindowType.NORMAL, + Meta.WindowType.MODAL_DIALOG, + Meta.WindowType.DIALOG]; + if (!this._shouldAnimateActor(actor, types)) { + shellwm.completed_minimize(actor); + return; + } + + actor.set_scale(1.0, 1.0); + + this._minimizing.add(actor); + + if (actor.meta_window.is_monitor_sized()) { + actor.ease({ + opacity: 0, + duration: MINIMIZE_WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onStopped: () => this._minimizeWindowDone(shellwm, actor), + }); + } else { + let xDest, yDest, xScale, yScale; + let [success, geom] = actor.meta_window.get_icon_geometry(); + if (success) { + xDest = geom.x; + yDest = geom.y; + xScale = geom.width / actor.width; + yScale = geom.height / actor.height; + } else { + let monitor = Main.layoutManager.monitors[actor.meta_window.get_monitor()]; + if (!monitor) { + this._minimizeWindowDone(); + return; + } + xDest = monitor.x; + yDest = monitor.y; + if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) + xDest += monitor.width; + xScale = 0; + yScale = 0; + } + + actor.ease({ + scale_x: xScale, + scale_y: yScale, + x: xDest, + y: yDest, + duration: MINIMIZE_WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_IN_EXPO, + onStopped: () => this._minimizeWindowDone(shellwm, actor), + }); + } + } + + _minimizeWindowDone(shellwm, actor) { + if (this._minimizing.delete(actor)) { + actor.remove_all_transitions(); + actor.set_scale(1.0, 1.0); + actor.set_opacity(255); + actor.set_pivot_point(0, 0); + + shellwm.completed_minimize(actor); + } + } + + _unminimizeWindow(shellwm, actor) { + let types = [Meta.WindowType.NORMAL, + Meta.WindowType.MODAL_DIALOG, + Meta.WindowType.DIALOG]; + if (!this._shouldAnimateActor(actor, types)) { + shellwm.completed_unminimize(actor); + return; + } + + this._unminimizing.add(actor); + + if (actor.meta_window.is_monitor_sized()) { + actor.opacity = 0; + actor.set_scale(1.0, 1.0); + actor.ease({ + opacity: 255, + duration: MINIMIZE_WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onStopped: () => this._unminimizeWindowDone(shellwm, actor), + }); + } else { + let [success, geom] = actor.meta_window.get_icon_geometry(); + if (success) { + actor.set_position(geom.x, geom.y); + actor.set_scale(geom.width / actor.width, + geom.height / actor.height); + } else { + let monitor = Main.layoutManager.monitors[actor.meta_window.get_monitor()]; + if (!monitor) { + actor.show(); + this._unminimizeWindowDone(); + return; + } + actor.set_position(monitor.x, monitor.y); + if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) + actor.x += monitor.width; + actor.set_scale(0, 0); + } + + let rect = actor.meta_window.get_frame_rect(); + let [xDest, yDest] = [rect.x, rect.y]; + + actor.show(); + actor.ease({ + scale_x: 1, + scale_y: 1, + x: xDest, + y: yDest, + duration: MINIMIZE_WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_IN_EXPO, + onStopped: () => this._unminimizeWindowDone(shellwm, actor), + }); + } + } + + _unminimizeWindowDone(shellwm, actor) { + if (this._unminimizing.delete(actor)) { + actor.remove_all_transitions(); + actor.set_scale(1.0, 1.0); + actor.set_opacity(255); + actor.set_pivot_point(0, 0); + + shellwm.completed_unminimize(actor); + } + } + + _sizeChangeWindow(shellwm, actor, whichChange, oldFrameRect, _oldBufferRect) { + const types = [Meta.WindowType.NORMAL]; + const shouldAnimate = + this._shouldAnimateActor(actor, types) && + oldFrameRect.width > 0 && + oldFrameRect.height > 0; + + if (shouldAnimate) + this._prepareAnimationInfo(shellwm, actor, oldFrameRect, whichChange); + else + shellwm.completed_size_change(actor); + } + + _prepareAnimationInfo(shellwm, actor, oldFrameRect, _change) { + // Position a clone of the window on top of the old position, + // while actor updates are frozen. + let actorContent = Shell.util_get_content_for_window_actor(actor, oldFrameRect); + let actorClone = new St.Widget({ content: actorContent }); + actorClone.set_offscreen_redirect(Clutter.OffscreenRedirect.ALWAYS); + actorClone.set_position(oldFrameRect.x, oldFrameRect.y); + actorClone.set_size(oldFrameRect.width, oldFrameRect.height); + + actor.freeze(); + + if (this._clearAnimationInfo(actor)) { + log('Old animationInfo removed from actor %s'.format(actor)); + this._shellwm.completed_size_change(actor); + } + + let destroyId = actor.connect('destroy', () => { + this._clearAnimationInfo(actor); + }); + + this._resizePending.add(actor); + actor.__animationInfo = { + clone: actorClone, + oldRect: oldFrameRect, + frozen: true, + destroyId, + }; + } + + _sizeChangedWindow(shellwm, actor) { + if (!actor.__animationInfo) + return; + if (this._resizing.has(actor)) + return; + + let actorClone = actor.__animationInfo.clone; + let targetRect = actor.meta_window.get_frame_rect(); + let sourceRect = actor.__animationInfo.oldRect; + + let scaleX = targetRect.width / sourceRect.width; + let scaleY = targetRect.height / sourceRect.height; + + this._resizePending.delete(actor); + this._resizing.add(actor); + + Main.uiGroup.add_child(actorClone); + + // Now scale and fade out the clone + actorClone.ease({ + x: targetRect.x, + y: targetRect.y, + scale_x: scaleX, + scale_y: scaleY, + opacity: 0, + duration: WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + + actor.translation_x = -targetRect.x + sourceRect.x; + actor.translation_y = -targetRect.y + sourceRect.y; + + // Now set scale the actor to size it as the clone. + actor.scale_x = 1 / scaleX; + actor.scale_y = 1 / scaleY; + + // Scale it to its actual new size + actor.ease({ + scale_x: 1, + scale_y: 1, + translation_x: 0, + translation_y: 0, + duration: WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onStopped: () => this._sizeChangeWindowDone(shellwm, actor), + }); + + // ease didn't animate and cleared the info, we are done + if (!actor.__animationInfo) + return; + + // Now unfreeze actor updates, to get it to the new size. + // It's important that we don't wait until the animation is completed to + // do this, otherwise our scale will be applied to the old texture size. + actor.thaw(); + actor.__animationInfo.frozen = false; + } + + _clearAnimationInfo(actor) { + if (actor.__animationInfo) { + actor.__animationInfo.clone.destroy(); + actor.disconnect(actor.__animationInfo.destroyId); + if (actor.__animationInfo.frozen) + actor.thaw(); + + delete actor.__animationInfo; + return true; + } + return false; + } + + _sizeChangeWindowDone(shellwm, actor) { + if (this._resizing.delete(actor)) { + actor.remove_all_transitions(); + actor.scale_x = 1.0; + actor.scale_y = 1.0; + actor.translation_x = 0; + actor.translation_y = 0; + this._clearAnimationInfo(actor); + this._shellwm.completed_size_change(actor); + } + + if (this._resizePending.delete(actor)) { + this._clearAnimationInfo(actor); + this._shellwm.completed_size_change(actor); + } + } + + _hasAttachedDialogs(window, ignoreWindow) { + var count = 0; + window.foreach_transient(win => { + if (win != ignoreWindow && + win.is_attached_dialog() && + win.get_transient_for() == window) { + count++; + return false; + } + return true; + }); + return count != 0; + } + + _checkDimming(window, ignoreWindow) { + let shouldDim = this._hasAttachedDialogs(window, ignoreWindow); + + if (shouldDim && !window._dimmed) { + window._dimmed = true; + this._dimmedWindows.push(window); + this._dimWindow(window); + } else if (!shouldDim && window._dimmed) { + window._dimmed = false; + this._dimmedWindows = + this._dimmedWindows.filter(win => win != window); + this._undimWindow(window); + } + } + + _dimWindow(window) { + let actor = window.get_compositor_private(); + if (!actor) + return; + let dimmer = getWindowDimmer(actor); + if (!dimmer) + return; + dimmer.setDimmed(true, this._shouldAnimate()); + } + + _undimWindow(window) { + let actor = window.get_compositor_private(); + if (!actor) + return; + let dimmer = getWindowDimmer(actor); + if (!dimmer) + return; + dimmer.setDimmed(false, this._shouldAnimate()); + } + + _mapWindow(shellwm, actor) { + actor._windowType = actor.meta_window.get_window_type(); + actor._notifyWindowTypeSignalId = + actor.meta_window.connect('notify::window-type', () => { + let type = actor.meta_window.get_window_type(); + if (type == actor._windowType) + return; + if (type == Meta.WindowType.MODAL_DIALOG || + actor._windowType == Meta.WindowType.MODAL_DIALOG) { + let parent = actor.get_meta_window().get_transient_for(); + if (parent) + this._checkDimming(parent); + } + + actor._windowType = type; + }); + actor.meta_window.connect('unmanaged', window => { + let parent = window.get_transient_for(); + if (parent) + this._checkDimming(parent); + }); + + if (actor.meta_window.is_attached_dialog()) + this._checkDimming(actor.get_meta_window().get_transient_for()); + + let types = [Meta.WindowType.NORMAL, + Meta.WindowType.DIALOG, + Meta.WindowType.MODAL_DIALOG]; + if (!this._shouldAnimateActor(actor, types)) { + shellwm.completed_map(actor); + return; + } + + switch (actor._windowType) { + case Meta.WindowType.NORMAL: + actor.set_pivot_point(0.5, 1.0); + actor.scale_x = 0.01; + actor.scale_y = 0.05; + actor.opacity = 0; + actor.show(); + this._mapping.add(actor); + + actor.ease({ + opacity: 255, + scale_x: 1, + scale_y: 1, + duration: SHOW_WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_EXPO, + onStopped: () => this._mapWindowDone(shellwm, actor), + }); + break; + case Meta.WindowType.MODAL_DIALOG: + case Meta.WindowType.DIALOG: + actor.set_pivot_point(0.5, 0.5); + actor.scale_y = 0; + actor.opacity = 0; + actor.show(); + this._mapping.add(actor); + + actor.ease({ + opacity: 255, + scale_x: 1, + scale_y: 1, + duration: DIALOG_SHOW_WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onStopped: () => this._mapWindowDone(shellwm, actor), + }); + break; + default: + shellwm.completed_map(actor); + } + } + + _mapWindowDone(shellwm, actor) { + if (this._mapping.delete(actor)) { + actor.remove_all_transitions(); + actor.opacity = 255; + actor.set_pivot_point(0, 0); + actor.scale_y = 1; + actor.scale_x = 1; + actor.translation_y = 0; + actor.translation_x = 0; + shellwm.completed_map(actor); + } + } + + _destroyWindow(shellwm, actor) { + let window = actor.meta_window; + if (actor._notifyWindowTypeSignalId) { + window.disconnect(actor._notifyWindowTypeSignalId); + actor._notifyWindowTypeSignalId = 0; + } + if (window._dimmed) { + this._dimmedWindows = + this._dimmedWindows.filter(win => win != window); + } + + if (window.is_attached_dialog()) + this._checkDimming(window.get_transient_for(), window); + + let types = [Meta.WindowType.NORMAL, + Meta.WindowType.DIALOG, + Meta.WindowType.MODAL_DIALOG]; + if (!this._shouldAnimateActor(actor, types)) { + shellwm.completed_destroy(actor); + return; + } + + switch (actor.meta_window.window_type) { + case Meta.WindowType.NORMAL: + actor.set_pivot_point(0.5, 0.5); + this._destroying.add(actor); + + actor.ease({ + opacity: 0, + scale_x: 0.8, + scale_y: 0.8, + duration: DESTROY_WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onStopped: () => this._destroyWindowDone(shellwm, actor), + }); + break; + case Meta.WindowType.MODAL_DIALOG: + case Meta.WindowType.DIALOG: + actor.set_pivot_point(0.5, 0.5); + this._destroying.add(actor); + + if (window.is_attached_dialog()) { + let parent = window.get_transient_for(); + actor._parentDestroyId = parent.connect('unmanaged', () => { + actor.remove_all_transitions(); + this._destroyWindowDone(shellwm, actor); + }); + } + + actor.ease({ + scale_y: 0, + duration: DIALOG_DESTROY_WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onStopped: () => this._destroyWindowDone(shellwm, actor), + }); + break; + default: + shellwm.completed_destroy(actor); + } + } + + _destroyWindowDone(shellwm, actor) { + if (this._destroying.delete(actor)) { + const parent = actor.get_meta_window()?.get_transient_for(); + if (parent && actor._parentDestroyId) { + parent.disconnect(actor._parentDestroyId); + actor._parentDestroyId = 0; + } + shellwm.completed_destroy(actor); + } + } + + _filterKeybinding(shellwm, binding) { + if (Main.actionMode == Shell.ActionMode.NONE) + return true; + + // There's little sense in implementing a keybinding in mutter and + // not having it work in NORMAL mode; handle this case generically + // so we don't have to explicitly allow all builtin keybindings in + // NORMAL mode. + if (Main.actionMode == Shell.ActionMode.NORMAL && + binding.is_builtin()) + return false; + + return !(this._allowedKeybindings[binding.get_name()] & Main.actionMode); + } + + _syncStacking() { + if (this._switchData == null) + return; + + let windows = global.get_window_actors(); + let lastCurSibling = null; + let lastDirSibling = []; + for (let i = 0; i < windows.length; i++) { + if (windows[i].get_parent() == this._switchData.curGroup) { + this._switchData.curGroup.set_child_above_sibling(windows[i], lastCurSibling); + lastCurSibling = windows[i]; + } else { + for (let dir of Object.values(Meta.MotionDirection)) { + let info = this._switchData.surroundings[dir]; + if (!info || windows[i].get_parent() != info.actor) + continue; + + let sibling = lastDirSibling[dir]; + if (sibling == undefined) + sibling = null; + + info.actor.set_child_above_sibling(windows[i], sibling); + lastDirSibling[dir] = windows[i]; + break; + } + } + } + } + + _getPositionForDirection(direction, fromWs, toWs) { + let xDest = 0, yDest = 0; + + let oldWsIsFullscreen = fromWs.list_windows().some(w => w.is_fullscreen()); + let newWsIsFullscreen = toWs.list_windows().some(w => w.is_fullscreen()); + + // We have to shift windows up or down by the height of the panel to prevent having a + // visible gap between the windows while switching workspaces. Since fullscreen windows + // hide the panel, they don't need to be shifted up or down. + let shiftHeight = Main.panel.height; + + if (direction == Meta.MotionDirection.UP || + direction == Meta.MotionDirection.UP_LEFT || + direction == Meta.MotionDirection.UP_RIGHT) + yDest = -global.screen_height + (oldWsIsFullscreen ? 0 : shiftHeight); + else if (direction == Meta.MotionDirection.DOWN || + direction == Meta.MotionDirection.DOWN_LEFT || + direction == Meta.MotionDirection.DOWN_RIGHT) + yDest = global.screen_height - (newWsIsFullscreen ? 0 : shiftHeight); + + if (direction == Meta.MotionDirection.LEFT || + direction == Meta.MotionDirection.UP_LEFT || + direction == Meta.MotionDirection.DOWN_LEFT) + xDest = -global.screen_width; + else if (direction == Meta.MotionDirection.RIGHT || + direction == Meta.MotionDirection.UP_RIGHT || + direction == Meta.MotionDirection.DOWN_RIGHT) + xDest = global.screen_width; + + return [xDest, yDest]; + } + + _prepareWorkspaceSwitch(from, to, direction) { + if (this._switchData) + return; + + let wgroup = global.window_group; + let windows = global.get_window_actors(); + let switchData = {}; + + this._switchData = switchData; + switchData.curGroup = new Clutter.Actor(); + switchData.movingWindowBin = new Clutter.Actor(); + switchData.windows = []; + switchData.surroundings = {}; + switchData.gestureActivated = false; + switchData.inProgress = false; + + switchData.container = new Clutter.Actor(); + switchData.container.add_actor(switchData.curGroup); + + wgroup.add_actor(switchData.movingWindowBin); + wgroup.add_actor(switchData.container); + + let workspaceManager = global.workspace_manager; + let curWs = workspaceManager.get_workspace_by_index(from); + + for (let dir of Object.values(Meta.MotionDirection)) { + let ws = null; + + if (to < 0) + ws = curWs.get_neighbor(dir); + else if (dir == direction) + ws = workspaceManager.get_workspace_by_index(to); + + if (ws == null || ws == curWs) { + switchData.surroundings[dir] = null; + continue; + } + + let [x, y] = this._getPositionForDirection(dir, curWs, ws); + let info = { + index: ws.index(), + actor: new Clutter.Actor(), + xDest: x, + yDest: y, + }; + switchData.surroundings[dir] = info; + switchData.container.add_actor(info.actor); + switchData.container.set_child_above_sibling(info.actor, null); + + info.actor.set_position(x, y); + } + + wgroup.set_child_above_sibling(switchData.movingWindowBin, null); + + for (let i = 0; i < windows.length; i++) { + let actor = windows[i]; + let window = actor.get_meta_window(); + + if (!window.showing_on_its_workspace()) + continue; + + if (window.is_on_all_workspaces()) + continue; + + let record = { window: actor, + parent: actor.get_parent() }; + + if (this._movingWindow && window == this._movingWindow) { + record.parent.remove_child(actor); + switchData.movingWindow = record; + switchData.windows.push(switchData.movingWindow); + switchData.movingWindowBin.add_child(actor); + } else if (window.get_workspace().index() == from) { + record.parent.remove_child(actor); + switchData.windows.push(record); + switchData.curGroup.add_child(actor); + } else { + let visible = false; + for (let dir of Object.values(Meta.MotionDirection)) { + let info = switchData.surroundings[dir]; + + if (!info || info.index != window.get_workspace().index()) + continue; + + record.parent.remove_child(actor); + switchData.windows.push(record); + info.actor.add_child(actor); + visible = true; + break; + } + + actor.visible = visible; + } + } + + for (let i = 0; i < switchData.windows.length; i++) { + let w = switchData.windows[i]; + + w.windowDestroyId = w.window.connect('destroy', () => { + switchData.windows.splice(switchData.windows.indexOf(w), 1); + }); + } + + Meta.disable_unredirect_for_display(global.display); + } + + _finishWorkspaceSwitch(switchData) { + Meta.enable_unredirect_for_display(global.display); + + this._switchData = null; + + for (let i = 0; i < switchData.windows.length; i++) { + let w = switchData.windows[i]; + + w.window.disconnect(w.windowDestroyId); + w.window.get_parent().remove_child(w.window); + w.parent.add_child(w.window); + + if (!w.window.get_meta_window().get_workspace().active) + w.window.hide(); + } + switchData.container.destroy(); + switchData.movingWindowBin.destroy(); + + this._movingWindow = null; + } + + _switchWorkspace(shellwm, from, to, direction) { + if (!Main.sessionMode.hasWorkspaces || !this._shouldAnimate()) { + shellwm.completed_switch_workspace(); + return; + } + + this._prepareWorkspaceSwitch(from, to, direction); + this._switchData.inProgress = true; + + let workspaceManager = global.workspace_manager; + let fromWs = workspaceManager.get_workspace_by_index(from); + let toWs = workspaceManager.get_workspace_by_index(to); + + let [xDest, yDest] = this._getPositionForDirection(direction, fromWs, toWs); + + /* @direction is the direction that the "camera" moves, so the + * screen contents have to move one screen's worth in the + * opposite direction. + */ + xDest = -xDest; + yDest = -yDest; + + this._switchData.container.ease({ + x: xDest, + y: yDest, + duration: WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + onComplete: () => this._switchWorkspaceDone(shellwm), + }); + } + + _switchWorkspaceDone(shellwm) { + this._finishWorkspaceSwitch(this._switchData); + shellwm.completed_switch_workspace(); + } + + _directionForProgress(progress) { + if (global.workspace_manager.layout_rows === -1) { + return progress > 0 + ? Meta.MotionDirection.DOWN + : Meta.MotionDirection.UP; + } else if (Clutter.get_default_text_direction() === Clutter.TextDirection.RTL) { + return progress > 0 + ? Meta.MotionDirection.LEFT + : Meta.MotionDirection.RIGHT; + } else { + return progress > 0 + ? Meta.MotionDirection.RIGHT + : Meta.MotionDirection.LEFT; + } + } + + _getProgressRange() { + if (!this._switchData) + return [0, 0]; + + let lower = 0; + let upper = 0; + + let horiz = global.workspace_manager.layout_rows !== -1; + let baseDistance; + if (horiz) + baseDistance = global.screen_width; + else + baseDistance = global.screen_height; + + let direction = this._directionForProgress(-1); + let info = this._switchData.surroundings[direction]; + if (info !== null) { + let distance = horiz ? info.xDest : info.yDest; + lower = -Math.abs(distance) / baseDistance; + } + + direction = this._directionForProgress(1); + info = this._switchData.surroundings[direction]; + if (info !== null) { + let distance = horiz ? info.xDest : info.yDest; + upper = Math.abs(distance) / baseDistance; + } + + return [lower, upper]; + } + + _switchWorkspaceBegin(tracker, monitor) { + if (Meta.prefs_get_workspaces_only_on_primary() && + monitor !== Main.layoutManager.primaryIndex) + return; + + let workspaceManager = global.workspace_manager; + let horiz = workspaceManager.layout_rows !== -1; + tracker.orientation = horiz + ? Clutter.Orientation.HORIZONTAL + : Clutter.Orientation.VERTICAL; + + let activeWorkspace = workspaceManager.get_active_workspace(); + + let baseDistance; + if (horiz) + baseDistance = global.screen_width; + else + baseDistance = global.screen_height; + + let progress; + if (this._switchData && this._switchData.gestureActivated) { + this._switchData.container.remove_all_transitions(); + if (!horiz) + progress = -this._switchData.container.y / baseDistance; + else if (Clutter.get_default_text_direction() === Clutter.TextDirection.RTL) + progress = this._switchData.container.x / baseDistance; + else + progress = -this._switchData.container.x / baseDistance; + } else { + this._prepareWorkspaceSwitch(activeWorkspace.index(), -1); + progress = 0; + } + + let points = []; + let [lower, upper] = this._getProgressRange(); + + if (lower !== 0) + points.push(lower); + + points.push(0); + + if (upper !== 0) + points.push(upper); + + tracker.confirmSwipe(baseDistance, points, progress, 0); + } + + _switchWorkspaceUpdate(tracker, progress) { + if (!this._switchData) + return; + + let direction = this._directionForProgress(progress); + let info = this._switchData.surroundings[direction]; + let xPos = 0; + let yPos = 0; + if (info) { + if (global.workspace_manager.layout_rows === -1) + yPos = -Math.round(progress * global.screen_height); + else if (Clutter.get_default_text_direction() === Clutter.TextDirection.RTL) + xPos = Math.round(progress * global.screen_width); + else + xPos = -Math.round(progress * global.screen_width); + } + + this._switchData.container.set_position(xPos, yPos); + } + + _switchWorkspaceEnd(tracker, duration, endProgress) { + if (!this._switchData) + return; + + let workspaceManager = global.workspace_manager; + let activeWorkspace = workspaceManager.get_active_workspace(); + let newWs = activeWorkspace; + let xDest = 0; + let yDest = 0; + if (endProgress !== 0) { + let direction = this._directionForProgress(endProgress); + newWs = activeWorkspace.get_neighbor(direction); + xDest = -this._switchData.surroundings[direction].xDest; + yDest = -this._switchData.surroundings[direction].yDest; + } + + let switchData = this._switchData; + switchData.gestureActivated = true; + + this._switchData.container.ease({ + x: xDest, + y: yDest, + duration, + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + onComplete: () => { + if (!newWs.active) + this.actionMoveWorkspace(newWs); + this._finishWorkspaceSwitch(switchData); + }, + }); + } + + _switchWorkspaceStop() { + this._switchData.container.x = 0; + this._switchData.container.y = 0; + this._finishWorkspaceSwitch(this._switchData); + } + + _showTilePreview(shellwm, window, tileRect, monitorIndex) { + if (!this._tilePreview) + this._tilePreview = new TilePreview(); + this._tilePreview.open(window, tileRect, monitorIndex); + } + + _hideTilePreview() { + if (!this._tilePreview) + return; + this._tilePreview.close(); + } + + _showWindowMenu(shellwm, window, menu, rect) { + this._windowMenuManager.showWindowMenuForWindow(window, menu, rect); + } + + _startSwitcher(display, window, binding) { + let constructor = null; + switch (binding.get_name()) { + case 'switch-applications': + case 'switch-applications-backward': + case 'switch-group': + case 'switch-group-backward': + constructor = AltTab.AppSwitcherPopup; + break; + case 'switch-windows': + case 'switch-windows-backward': + constructor = AltTab.WindowSwitcherPopup; + break; + case 'cycle-windows': + case 'cycle-windows-backward': + constructor = AltTab.WindowCyclerPopup; + break; + case 'cycle-group': + case 'cycle-group-backward': + constructor = AltTab.GroupCyclerPopup; + break; + case 'switch-monitor': + constructor = SwitchMonitor.SwitchMonitorPopup; + break; + } + + if (!constructor) + return; + + /* prevent a corner case where both popups show up at once */ + if (this._workspaceSwitcherPopup != null) + this._workspaceSwitcherPopup.destroy(); + + let tabPopup = new constructor(); + + if (!tabPopup.show(binding.is_reversed(), binding.get_name(), binding.get_mask())) + tabPopup.destroy(); + } + + _startA11ySwitcher(display, window, binding) { + Main.ctrlAltTabManager.popup(binding.is_reversed(), binding.get_name(), binding.get_mask()); + } + + _allowFavoriteShortcuts() { + return Main.sessionMode.hasOverview; + } + + _switchToApplication(display, window, binding) { + if (!this._allowFavoriteShortcuts()) + return; + + let [, , , target] = binding.get_name().split('-'); + let apps = AppFavorites.getAppFavorites().getFavorites(); + let app = apps[target - 1]; + if (app) + app.activate(); + } + + _toggleAppMenu() { + Main.panel.toggleAppMenu(); + } + + _toggleCalendar() { + Main.panel.toggleCalendar(); + } + + _showWorkspaceSwitcher(display, window, binding) { + let workspaceManager = display.get_workspace_manager(); + + if (!Main.sessionMode.hasWorkspaces) + return; + + if (workspaceManager.n_workspaces == 1) + return; + + let [action,,, target] = binding.get_name().split('-'); + let newWs; + let direction; + let vertical = workspaceManager.layout_rows == -1; + let rtl = Clutter.get_default_text_direction() == Clutter.TextDirection.RTL; + + if (action == 'move') { + // "Moving" a window to another workspace doesn't make sense when + // it cannot be unstuck, and is potentially confusing if a new + // workspaces is added at the start/end + if (window.is_always_on_all_workspaces() || + (Meta.prefs_get_workspaces_only_on_primary() && + window.get_monitor() != Main.layoutManager.primaryIndex)) + return; + } + + if (target == 'last') { + if (vertical) + direction = Meta.MotionDirection.DOWN; + else if (rtl) + direction = Meta.MotionDirection.LEFT; + else + direction = Meta.MotionDirection.RIGHT; + newWs = workspaceManager.get_workspace_by_index(workspaceManager.n_workspaces - 1); + } else if (isNaN(target)) { + // Prepend a new workspace dynamically + let prependTarget; + if (vertical) + prependTarget = 'up'; + else if (rtl) + prependTarget = 'right'; + else + prependTarget = 'left'; + if (workspaceManager.get_active_workspace_index() === 0 && + action === 'move' && target === prependTarget && + this._isWorkspacePrepended === false) { + this.insertWorkspace(0); + this._isWorkspacePrepended = true; + } + + direction = Meta.MotionDirection[target.toUpperCase()]; + newWs = workspaceManager.get_active_workspace().get_neighbor(direction); + } else if ((target > 0) && (target <= workspaceManager.n_workspaces)) { + target--; + newWs = workspaceManager.get_workspace_by_index(target); + + if (workspaceManager.get_active_workspace().index() > target) { + if (vertical) + direction = Meta.MotionDirection.UP; + else if (rtl) + direction = Meta.MotionDirection.RIGHT; + else + direction = Meta.MotionDirection.LEFT; + } else { + if (vertical) // eslint-disable-line no-lonely-if + direction = Meta.MotionDirection.DOWN; + else if (rtl) + direction = Meta.MotionDirection.LEFT; + else + direction = Meta.MotionDirection.RIGHT; + } + } + + if (workspaceManager.layout_rows == -1 && + direction != Meta.MotionDirection.UP && + direction != Meta.MotionDirection.DOWN) + return; + + if (workspaceManager.layout_columns == -1 && + direction != Meta.MotionDirection.LEFT && + direction != Meta.MotionDirection.RIGHT) + return; + + if (action == 'switch') + this.actionMoveWorkspace(newWs); + else + this.actionMoveWindow(window, newWs); + + if (!Main.overview.visible) { + if (this._workspaceSwitcherPopup == null) { + this._workspaceTracker.blockUpdates(); + this._workspaceSwitcherPopup = new WorkspaceSwitcherPopup.WorkspaceSwitcherPopup(); + this._workspaceSwitcherPopup.connect('destroy', () => { + this._workspaceTracker.unblockUpdates(); + this._workspaceSwitcherPopup = null; + this._isWorkspacePrepended = false; + }); + } + this._workspaceSwitcherPopup.display(direction, newWs.index()); + } + } + + actionMoveWorkspace(workspace) { + if (!Main.sessionMode.hasWorkspaces) + return; + + if (!workspace.active) + workspace.activate(global.get_current_time()); + } + + actionMoveWindow(window, workspace) { + if (!Main.sessionMode.hasWorkspaces) + return; + + if (!workspace.active) { + // This won't have any effect for "always sticky" windows + // (like desktop windows or docks) + + this._movingWindow = window; + window.change_workspace(workspace); + + global.display.clear_mouse_mode(); + workspace.activate_with_focus(window, global.get_current_time()); + } + } + + _confirmDisplayChange() { + let dialog = new DisplayChangeDialog(this._shellwm); + dialog.open(); + } + + _createCloseDialog(shellwm, window) { + return new CloseDialog.CloseDialog(window); + } + + _createInhibitShortcutsDialog(shellwm, window) { + return new InhibitShortcutsDialog.InhibitShortcutsDialog(window); + } + + _showResizePopup(display, show, rect, displayW, displayH) { + if (show) { + if (!this._resizePopup) + this._resizePopup = new ResizePopup(); + + this._resizePopup.set(rect, displayW, displayH); + } else { + if (!this._resizePopup) + return; + + this._resizePopup.destroy(); + this._resizePopup = null; + } + } +}; diff --git a/js/ui/windowMenu.js b/js/ui/windowMenu.js new file mode 100644 index 0000000..bb6a8df --- /dev/null +++ b/js/ui/windowMenu.js @@ -0,0 +1,238 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -* +/* exported WindowMenuManager */ + +const { GLib, Meta, St } = imports.gi; + +const BoxPointer = imports.ui.boxpointer; +const Main = imports.ui.main; +const PopupMenu = imports.ui.popupMenu; + +var WindowMenu = class extends PopupMenu.PopupMenu { + constructor(window, sourceActor) { + super(sourceActor, 0, St.Side.TOP); + + this.actor.add_style_class_name('window-menu'); + + Main.layoutManager.uiGroup.add_actor(this.actor); + this.actor.hide(); + + this._buildMenu(window); + } + + _buildMenu(window) { + let type = window.get_window_type(); + + let item; + + item = this.addAction(_("Minimize"), () => { + window.minimize(); + }); + if (!window.can_minimize()) + item.setSensitive(false); + + if (window.get_maximized()) { + item = this.addAction(_("Unmaximize"), () => { + window.unmaximize(Meta.MaximizeFlags.BOTH); + }); + } else { + item = this.addAction(_("Maximize"), () => { + window.maximize(Meta.MaximizeFlags.BOTH); + }); + } + if (!window.can_maximize()) + item.setSensitive(false); + + item = this.addAction(_("Move"), event => { + this._grabAction(window, Meta.GrabOp.KEYBOARD_MOVING, event.get_time()); + }); + if (!window.allows_move()) + item.setSensitive(false); + + item = this.addAction(_("Resize"), event => { + this._grabAction(window, Meta.GrabOp.KEYBOARD_RESIZING_UNKNOWN, event.get_time()); + }); + if (!window.allows_resize()) + item.setSensitive(false); + + if (!window.titlebar_is_onscreen() && type != Meta.WindowType.DOCK && type != Meta.WindowType.DESKTOP) { + this.addAction(_("Move Titlebar Onscreen"), () => { + window.shove_titlebar_onscreen(); + }); + } + + item = this.addAction(_("Always on Top"), () => { + if (window.is_above()) + window.unmake_above(); + else + window.make_above(); + }); + if (window.is_above()) + item.setOrnament(PopupMenu.Ornament.CHECK); + if (window.get_maximized() == Meta.MaximizeFlags.BOTH || + type == Meta.WindowType.DOCK || + type == Meta.WindowType.DESKTOP || + type == Meta.WindowType.SPLASHSCREEN) + item.setSensitive(false); + + if (Main.sessionMode.hasWorkspaces && + (!Meta.prefs_get_workspaces_only_on_primary() || + window.is_on_primary_monitor())) { + let isSticky = window.is_on_all_workspaces(); + + item = this.addAction(_("Always on Visible Workspace"), () => { + if (isSticky) + window.unstick(); + else + window.stick(); + }); + if (isSticky) + item.setOrnament(PopupMenu.Ornament.CHECK); + if (window.is_always_on_all_workspaces()) + item.setSensitive(false); + + if (!isSticky) { + let workspace = window.get_workspace(); + if (workspace != workspace.get_neighbor(Meta.MotionDirection.LEFT)) { + this.addAction(_("Move to Workspace Left"), () => { + let dir = Meta.MotionDirection.LEFT; + window.change_workspace(workspace.get_neighbor(dir)); + }); + } + if (workspace != workspace.get_neighbor(Meta.MotionDirection.RIGHT)) { + this.addAction(_("Move to Workspace Right"), () => { + let dir = Meta.MotionDirection.RIGHT; + window.change_workspace(workspace.get_neighbor(dir)); + }); + } + if (workspace != workspace.get_neighbor(Meta.MotionDirection.UP)) { + this.addAction(_("Move to Workspace Up"), () => { + let dir = Meta.MotionDirection.UP; + window.change_workspace(workspace.get_neighbor(dir)); + }); + } + if (workspace != workspace.get_neighbor(Meta.MotionDirection.DOWN)) { + this.addAction(_("Move to Workspace Down"), () => { + let dir = Meta.MotionDirection.DOWN; + window.change_workspace(workspace.get_neighbor(dir)); + }); + } + } + } + + let display = global.display; + let nMonitors = display.get_n_monitors(); + let monitorIndex = window.get_monitor(); + if (nMonitors > 1 && monitorIndex >= 0) { + this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + let dir = Meta.DisplayDirection.UP; + let upMonitorIndex = + display.get_monitor_neighbor_index(monitorIndex, dir); + if (upMonitorIndex != -1) { + this.addAction(_("Move to Monitor Up"), () => { + window.move_to_monitor(upMonitorIndex); + }); + } + + dir = Meta.DisplayDirection.DOWN; + let downMonitorIndex = + display.get_monitor_neighbor_index(monitorIndex, dir); + if (downMonitorIndex != -1) { + this.addAction(_("Move to Monitor Down"), () => { + window.move_to_monitor(downMonitorIndex); + }); + } + + dir = Meta.DisplayDirection.LEFT; + let leftMonitorIndex = + display.get_monitor_neighbor_index(monitorIndex, dir); + if (leftMonitorIndex != -1) { + this.addAction(_("Move to Monitor Left"), () => { + window.move_to_monitor(leftMonitorIndex); + }); + } + + dir = Meta.DisplayDirection.RIGHT; + let rightMonitorIndex = + display.get_monitor_neighbor_index(monitorIndex, dir); + if (rightMonitorIndex != -1) { + this.addAction(_("Move to Monitor Right"), () => { + window.move_to_monitor(rightMonitorIndex); + }); + } + } + + this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + item = this.addAction(_("Close"), event => { + window.delete(event.get_time()); + }); + if (!window.can_close()) + item.setSensitive(false); + } + + _grabAction(window, grabOp, time) { + if (global.display.get_grab_op() == Meta.GrabOp.NONE) { + window.begin_grab_op(grabOp, true, time); + return; + } + + let waitId = 0; + let id = global.display.connect('grab-op-end', display => { + display.disconnect(id); + GLib.source_remove(waitId); + + window.begin_grab_op(grabOp, true, time); + }); + + waitId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, () => { + global.display.disconnect(id); + return GLib.SOURCE_REMOVE; + }); + } +}; + +var WindowMenuManager = class { + constructor() { + this._manager = new PopupMenu.PopupMenuManager(Main.layoutManager.dummyCursor); + + this._sourceActor = new St.Widget({ reactive: true, visible: false }); + this._sourceActor.connect('button-press-event', () => { + this._manager.activeMenu.toggle(); + }); + Main.uiGroup.add_actor(this._sourceActor); + } + + showWindowMenuForWindow(window, type, rect) { + if (!Main.sessionMode.hasWmMenus) + return; + + if (type != Meta.WindowMenuType.WM) + throw new Error('Unsupported window menu type'); + let menu = new WindowMenu(window, this._sourceActor); + + this._manager.addMenu(menu); + + menu.connect('activate', () => { + window.check_alive(global.get_current_time()); + }); + let destroyId = window.connect('unmanaged', () => { + menu.close(); + }); + + this._sourceActor.set_size(Math.max(1, rect.width), Math.max(1, rect.height)); + this._sourceActor.set_position(rect.x, rect.y); + this._sourceActor.show(); + + menu.open(BoxPointer.PopupAnimation.FADE); + menu.actor.navigate_focus(null, St.DirectionType.TAB_FORWARD, false); + menu.connect('open-state-changed', (menu_, isOpen) => { + if (isOpen) + return; + + this._sourceActor.hide(); + menu.destroy(); + window.disconnect(destroyId); + }); + } +}; diff --git a/js/ui/windowPreview.js b/js/ui/windowPreview.js new file mode 100644 index 0000000..d379703 --- /dev/null +++ b/js/ui/windowPreview.js @@ -0,0 +1,773 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported WindowPreview */ + +const { Atk, Clutter, GLib, GObject, + Graphene, Meta, Pango, Shell, St } = imports.gi; + +const DND = imports.ui.dnd; + +var WINDOW_DND_SIZE = 256; + +var WINDOW_OVERLAY_IDLE_HIDE_TIMEOUT = 750; +var WINDOW_OVERLAY_FADE_TIME = 200; + +var DRAGGING_WINDOW_OPACITY = 100; + +var WindowPreviewLayout = GObject.registerClass({ + Properties: { + 'bounding-box': GObject.ParamSpec.boxed( + 'bounding-box', 'Bounding box', 'Bounding box', + GObject.ParamFlags.READABLE, + Clutter.ActorBox.$gtype), + }, +}, class WindowPreviewLayout extends Clutter.LayoutManager { + _init() { + super._init(); + + this._container = null; + this._boundingBox = new Clutter.ActorBox(); + this._windows = new Map(); + } + + _layoutChanged() { + let frameRect; + + for (const windowInfo of this._windows.values()) { + const frame = windowInfo.metaWindow.get_frame_rect(); + frameRect = frameRect ? frameRect.union(frame) : frame; + } + + if (!frameRect) + frameRect = new Meta.Rectangle(); + + const oldBox = this._boundingBox.copy(); + this._boundingBox.set_origin(frameRect.x, frameRect.y); + this._boundingBox.set_size(frameRect.width, frameRect.height); + + if (!this._boundingBox.equal(oldBox)) + this.notify('bounding-box'); + + // Always call layout_changed(), a size or position change of an + // attached dialog might not affect the boundingBox + this.layout_changed(); + } + + vfunc_set_container(container) { + this._container = container; + } + + vfunc_get_preferred_height(_container, _forWidth) { + return [0, this._boundingBox.get_height()]; + } + + vfunc_get_preferred_width(_container, _forHeight) { + return [0, this._boundingBox.get_width()]; + } + + vfunc_allocate(container, box) { + // If the scale isn't 1, we weren't allocated our preferred size + // and have to scale the children allocations accordingly. + const scaleX = this._boundingBox.get_width() > 0 + ? box.get_width() / this._boundingBox.get_width() + : 1; + const scaleY = this._boundingBox.get_height() > 0 + ? box.get_height() / this._boundingBox.get_height() + : 1; + + const childBox = new Clutter.ActorBox(); + + for (const child of container) { + if (!child.visible) + continue; + + const windowInfo = this._windows.get(child); + if (windowInfo) { + const bufferRect = windowInfo.metaWindow.get_buffer_rect(); + childBox.set_origin( + bufferRect.x - this._boundingBox.x1, + bufferRect.y - this._boundingBox.y1); + + const [, , natWidth, natHeight] = child.get_preferred_size(); + childBox.set_size(natWidth, natHeight); + + childBox.x1 *= scaleX; + childBox.x2 *= scaleX; + childBox.y1 *= scaleY; + childBox.y2 *= scaleY; + + child.allocate(childBox); + } else { + child.allocate_preferred_size(0, 0); + } + } + } + + /** + * addWindow: + * @param {Meta.Window} window: the MetaWindow instance + * + * Creates a ClutterActor drawing the texture of @window and adds it + * to the container. If @window is already part of the preview, this + * function will do nothing. + * + * @returns {Clutter.Actor} The newly created actor drawing @window + */ + addWindow(window) { + const index = [...this._windows.values()].findIndex(info => + info.metaWindow === window); + + if (index !== -1) + return null; + + const windowActor = window.get_compositor_private(); + const actor = new Clutter.Clone({ source: windowActor }); + + this._windows.set(actor, { + metaWindow: window, + windowActor, + sizeChangedId: window.connect('size-changed', () => + this._layoutChanged()), + positionChangedId: window.connect('position-changed', () => + this._layoutChanged()), + windowActorDestroyId: windowActor.connect('destroy', () => + actor.destroy()), + destroyId: actor.connect('destroy', () => + this.removeWindow(window)), + }); + + this._container.add_child(actor); + + this._layoutChanged(); + + return actor; + } + + /** + * removeWindow: + * @param {Meta.Window} window: the window to remove from the preview + * + * Removes a MetaWindow @window from the preview which has been added + * previously using addWindow(). If @window is not part of preview, + * this function will do nothing. + */ + removeWindow(window) { + const entry = [...this._windows].find( + ([, i]) => i.metaWindow === window); + + if (!entry) + return; + + const [actor, windowInfo] = entry; + + windowInfo.metaWindow.disconnect(windowInfo.sizeChangedId); + windowInfo.metaWindow.disconnect(windowInfo.positionChangedId); + windowInfo.windowActor.disconnect(windowInfo.windowActorDestroyId); + actor.disconnect(windowInfo.destroyId); + + this._windows.delete(actor); + this._container.remove_child(actor); + + this._layoutChanged(); + } + + /** + * getWindows: + * + * Gets an array of all MetaWindows that were added to the layout + * using addWindow(), ordered by the insertion order. + * + * @returns {Array} An array including all windows + */ + getWindows() { + return [...this._windows.values()].map(i => i.metaWindow); + } + + // eslint-disable-next-line camelcase + get bounding_box() { + return this._boundingBox; + } +}); + +var WindowPreview = GObject.registerClass({ + Properties: { + 'overlay-enabled': GObject.ParamSpec.boolean( + 'overlay-enabled', 'overlay-enabled', 'overlay-enabled', + GObject.ParamFlags.READWRITE, + true), + }, + Signals: { + 'drag-begin': {}, + 'drag-cancelled': {}, + 'drag-end': {}, + 'selected': { param_types: [GObject.TYPE_UINT] }, + 'show-chrome': {}, + 'size-changed': {}, + }, +}, class WindowPreview extends St.Widget { + _init(metaWindow, workspace) { + this.metaWindow = metaWindow; + this.metaWindow._delegate = this; + this._windowActor = metaWindow.get_compositor_private(); + this._workspace = workspace; + + super._init({ + reactive: true, + can_focus: true, + accessible_role: Atk.Role.PUSH_BUTTON, + offscreen_redirect: Clutter.OffscreenRedirect.AUTOMATIC_FOR_OPACITY, + }); + + this._windowContainer = new Clutter.Actor(); + // gjs currently can't handle setting an actors layout manager during + // the initialization of the actor if that layout manager keeps track + // of its container, so set the layout manager after creating the + // container + this._windowContainer.layout_manager = new WindowPreviewLayout(); + this.add_child(this._windowContainer); + + this._addWindow(metaWindow); + + this._delegate = this; + + this._stackAbove = null; + + this._windowContainer.layout_manager.connect( + 'notify::bounding-box', layout => { + // A bounding box of 0x0 means all windows were removed + if (layout.bounding_box.get_area() > 0) + this.emit('size-changed'); + }); + + this._windowDestroyId = + this._windowActor.connect('destroy', () => this.destroy()); + + this._updateAttachedDialogs(); + + let clickAction = new Clutter.ClickAction(); + clickAction.connect('clicked', () => this._activate()); + clickAction.connect('long-press', this._onLongPress.bind(this)); + this.add_action(clickAction); + this.connect('destroy', this._onDestroy.bind(this)); + + this._draggable = DND.makeDraggable(this, + { restoreOnSuccess: true, + manualMode: true, + dragActorMaxSize: WINDOW_DND_SIZE, + dragActorOpacity: DRAGGING_WINDOW_OPACITY }); + this._draggable.connect('drag-begin', this._onDragBegin.bind(this)); + this._draggable.connect('drag-cancelled', this._onDragCancelled.bind(this)); + this._draggable.connect('drag-end', this._onDragEnd.bind(this)); + this.inDrag = false; + + this._selected = false; + this._overlayEnabled = true; + this._closeRequested = false; + this._idleHideOverlayId = 0; + + this._border = new St.Widget({ + visible: false, + style_class: 'window-clone-border', + }); + this._borderConstraint = new Clutter.BindConstraint({ + source: this._windowContainer, + coordinate: Clutter.BindCoordinate.SIZE, + }); + this._border.add_constraint(this._borderConstraint); + this._border.add_constraint(new Clutter.AlignConstraint({ + source: this._windowContainer, + align_axis: Clutter.AlignAxis.BOTH, + factor: 0.5, + })); + this._borderCenter = new Clutter.Actor(); + this._border.bind_property('visible', this._borderCenter, 'visible', + GObject.BindingFlags.SYNC_CREATE); + this._borderCenterConstraint = new Clutter.BindConstraint({ + source: this._windowContainer, + coordinate: Clutter.BindCoordinate.SIZE, + }); + this._borderCenter.add_constraint(this._borderCenterConstraint); + this._borderCenter.add_constraint(new Clutter.AlignConstraint({ + source: this._windowContainer, + align_axis: Clutter.AlignAxis.BOTH, + factor: 0.5, + })); + this._border.connect('style-changed', + this._onBorderStyleChanged.bind(this)); + + this._title = new St.Label({ + visible: false, + style_class: 'window-caption', + text: this._getCaption(), + reactive: true, + }); + this._title.add_constraint(new Clutter.BindConstraint({ + source: this._borderCenter, + coordinate: Clutter.BindCoordinate.POSITION, + })); + this._title.add_constraint(new Clutter.AlignConstraint({ + source: this._borderCenter, + align_axis: Clutter.AlignAxis.X_AXIS, + factor: 0.5, + })); + this._title.add_constraint(new Clutter.AlignConstraint({ + source: this._borderCenter, + align_axis: Clutter.AlignAxis.Y_AXIS, + pivot_point: new Graphene.Point({ x: -1, y: 0.5 }), + factor: 1, + })); + this._title.clutter_text.ellipsize = Pango.EllipsizeMode.END; + this.label_actor = this._title; + this._updateCaptionId = this.metaWindow.connect('notify::title', () => { + this._title.text = this._getCaption(); + }); + + const layout = Meta.prefs_get_button_layout(); + this._closeButtonSide = + layout.left_buttons.includes(Meta.ButtonFunction.CLOSE) + ? St.Side.LEFT : St.Side.RIGHT; + + this._closeButton = new St.Button({ + visible: false, + style_class: 'window-close', + child: new St.Icon({ icon_name: 'window-close-symbolic' }), + }); + this._closeButton.add_constraint(new Clutter.BindConstraint({ + source: this._borderCenter, + coordinate: Clutter.BindCoordinate.POSITION, + })); + this._closeButton.add_constraint(new Clutter.AlignConstraint({ + source: this._borderCenter, + align_axis: Clutter.AlignAxis.X_AXIS, + pivot_point: new Graphene.Point({ x: 0.5, y: -1 }), + factor: this._closeButtonSide === St.Side.LEFT ? 0 : 1, + })); + this._closeButton.add_constraint(new Clutter.AlignConstraint({ + source: this._borderCenter, + align_axis: Clutter.AlignAxis.Y_AXIS, + pivot_point: new Graphene.Point({ x: -1, y: 0.5 }), + factor: 0, + })); + this._closeButton.connect('clicked', () => this._deleteAll()); + + this.add_child(this._borderCenter); + this.add_child(this._border); + this.add_child(this._title); + this.add_child(this._closeButton); + + this.connect('notify::realized', () => { + if (!this.realized) + return; + + this._border.ensure_style(); + this._title.ensure_style(); + }); + } + + vfunc_get_preferred_width(forHeight) { + const themeNode = this.get_theme_node(); + + // Only include window previews in size request, not chrome + const [minWidth, natWidth] = + this._windowContainer.get_preferred_width( + themeNode.adjust_for_height(forHeight)); + + return themeNode.adjust_preferred_width(minWidth, natWidth); + } + + vfunc_get_preferred_height(forWidth) { + const themeNode = this.get_theme_node(); + const [minHeight, natHeight] = + this._windowContainer.get_preferred_height( + themeNode.adjust_for_width(forWidth)); + + return themeNode.adjust_preferred_height(minHeight, natHeight); + } + + vfunc_allocate(box) { + this.set_allocation(box); + + for (const child of this) + child.allocate_available_size(0, 0, box.get_width(), box.get_height()); + } + + _onBorderStyleChanged() { + let borderNode = this._border.get_theme_node(); + this._borderSize = borderNode.get_border_width(St.Side.TOP); + + // Increase the size of the border actor so the border outlines + // the bounding box + this._borderConstraint.offset = this._borderSize * 2; + this._borderCenterConstraint.offset = this._borderSize; + } + + _windowCanClose() { + return this.metaWindow.can_close() && + !this._hasAttachedDialogs(); + } + + _getCaption() { + if (this.metaWindow.title) + return this.metaWindow.title; + + let tracker = Shell.WindowTracker.get_default(); + let app = tracker.get_window_app(this.metaWindow); + return app.get_name(); + } + + chromeHeights() { + const [, closeButtonHeight] = this._closeButton.get_preferred_height(-1); + const [, titleHeight] = this._title.get_preferred_height(-1); + + const topOversize = (this._borderSize / 2) + (closeButtonHeight / 2); + const bottomOversize = Math.max( + this._borderSize, + (titleHeight / 2) + (this._borderSize / 2)); + + return [topOversize, bottomOversize]; + } + + chromeWidths() { + const [, closeButtonWidth] = this._closeButton.get_preferred_width(-1); + + const leftOversize = this._closeButtonSide === St.Side.LEFT + ? (this._borderSize / 2) + (closeButtonWidth / 2) + : this._borderSize; + const rightOversize = this._closeButtonSide === St.Side.LEFT + ? this._borderSize + : (this._borderSize / 2) + (closeButtonWidth / 2); + + return [leftOversize, rightOversize]; + } + + showOverlay(animate) { + if (!this._overlayEnabled) + return; + + const ongoingTransition = this._border.get_transition('opacity'); + + // Don't do anything if we're fully visible already + if (this._border.visible && !ongoingTransition) + return; + + // If we're supposed to animate and an animation in our direction + // is already happening, let that one continue + if (animate && + ongoingTransition && + ongoingTransition.get_interval().peek_final_value() === 255) + return; + + const toShow = this._windowCanClose() + ? [this._border, this._title, this._closeButton] + : [this._border, this._title]; + + toShow.forEach(a => { + a.opacity = 0; + a.show(); + a.ease({ + opacity: 255, + duration: animate ? WINDOW_OVERLAY_FADE_TIME : 0, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + }); + + this.emit('show-chrome'); + } + + hideOverlay(animate) { + const ongoingTransition = this._border.get_transition('opacity'); + + // Don't do anything if we're fully hidden already + if (!this._border.visible && !ongoingTransition) + return; + + // If we're supposed to animate and an animation in our direction + // is already happening, let that one continue + if (animate && + ongoingTransition && + ongoingTransition.get_interval().peek_final_value() === 0) + return; + + [this._border, this._title, this._closeButton].forEach(a => { + a.opacity = 255; + a.ease({ + opacity: 0, + duration: animate ? WINDOW_OVERLAY_FADE_TIME : 0, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => a.hide(), + }); + }); + } + + _addWindow(metaWindow) { + const clone = this._windowContainer.layout_manager.addWindow(metaWindow); + if (!clone) + return; + + // We expect this to be used for all interaction rather than + // the ClutterClone; as the former is reactive and the latter + // is not, this just works for most cases. However, for DND all + // actors are picked, so DND operations would operate on the clone. + // To avoid this, we hide it from pick. + Shell.util_set_hidden_from_pick(clone, true); + } + + vfunc_has_overlaps() { + return this._hasAttachedDialogs(); + } + + _deleteAll() { + const windows = this._windowContainer.layout_manager.getWindows(); + + // Delete all windows, starting from the bottom-most (most-modal) one + for (const window of windows.reverse()) + window.delete(global.get_current_time()); + + this._closeRequested = true; + } + + addDialog(win) { + let parent = win.get_transient_for(); + while (parent.is_attached_dialog()) + parent = parent.get_transient_for(); + + // Display dialog if it is attached to our metaWindow + if (win.is_attached_dialog() && parent == this.metaWindow) + this._addWindow(win); + + // The dialog popped up after the user tried to close the window, + // assume it's a close confirmation and leave the overview + if (this._closeRequested) + this._activate(); + } + + _hasAttachedDialogs() { + return this._windowContainer.layout_manager.getWindows().length > 1; + } + + _updateAttachedDialogs() { + let iter = win => { + let actor = win.get_compositor_private(); + + if (!actor) + return false; + if (!win.is_attached_dialog()) + return false; + + this._addWindow(win); + win.foreach_transient(iter); + return true; + }; + this.metaWindow.foreach_transient(iter); + } + + get boundingBox() { + const box = this._windowContainer.layout_manager.bounding_box; + + return { + x: box.x1, + y: box.y1, + width: box.get_width(), + height: box.get_height(), + }; + } + + get windowCenter() { + const box = this._windowContainer.layout_manager.bounding_box; + + return new Graphene.Point({ + x: box.get_x() + box.get_width() / 2, + y: box.get_y() + box.get_height() / 2, + }); + } + + // eslint-disable-next-line camelcase + get overlay_enabled() { + return this._overlayEnabled; + } + + // eslint-disable-next-line camelcase + set overlay_enabled(enabled) { + if (this._overlayEnabled === enabled) + return; + + this._overlayEnabled = enabled; + this.notify('overlay-enabled'); + + if (!enabled) + this.hideOverlay(false); + else if (this['has-pointer'] || global.stage.key_focus === this) + this.showOverlay(true); + } + + // Find the actor just below us, respecting reparenting done by DND code + _getActualStackAbove() { + if (this._stackAbove == null) + return null; + + if (this.inDrag) { + if (this._stackAbove._delegate) + return this._stackAbove._delegate._getActualStackAbove(); + else + return null; + } else { + return this._stackAbove; + } + } + + setStackAbove(actor) { + this._stackAbove = actor; + if (this.inDrag) + // We'll fix up the stack after the drag + return; + + let parent = this.get_parent(); + let actualAbove = this._getActualStackAbove(); + if (actualAbove == null) + parent.set_child_below_sibling(this, null); + else + parent.set_child_above_sibling(this, actualAbove); + } + + _onDestroy() { + this._windowActor.disconnect(this._windowDestroyId); + + this.metaWindow._delegate = null; + this._delegate = null; + + this.metaWindow.disconnect(this._updateCaptionId); + + if (this._longPressLater) { + Meta.later_remove(this._longPressLater); + delete this._longPressLater; + } + + if (this._idleHideOverlayId > 0) { + GLib.source_remove(this._idleHideOverlayId); + this._idleHideOverlayId = 0; + } + + if (this.inDrag) { + this.emit('drag-end'); + this.inDrag = false; + } + } + + _activate() { + this._selected = true; + this.emit('selected', global.get_current_time()); + } + + vfunc_enter_event(crossingEvent) { + this.showOverlay(true); + return super.vfunc_enter_event(crossingEvent); + } + + vfunc_leave_event(crossingEvent) { + if (this._idleHideOverlayId > 0) + GLib.source_remove(this._idleHideOverlayId); + + this._idleHideOverlayId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + WINDOW_OVERLAY_IDLE_HIDE_TIMEOUT, () => { + if (this._closeButton['has-pointer'] || + this._title['has-pointer']) + return GLib.SOURCE_CONTINUE; + + if (!this['has-pointer']) + this.hideOverlay(true); + + this._idleHideOverlayId = 0; + return GLib.SOURCE_REMOVE; + }); + + GLib.Source.set_name_by_id(this._idleHideOverlayId, '[gnome-shell] this._idleHideOverlayId'); + + return super.vfunc_leave_event(crossingEvent); + } + + vfunc_key_focus_in() { + super.vfunc_key_focus_in(); + this.showOverlay(true); + } + + vfunc_key_focus_out() { + super.vfunc_key_focus_out(); + this.hideOverlay(true); + } + + vfunc_key_press_event(keyEvent) { + let symbol = keyEvent.keyval; + let isEnter = symbol == Clutter.KEY_Return || symbol == Clutter.KEY_KP_Enter; + if (isEnter) { + this._activate(); + return true; + } + + return super.vfunc_key_press_event(keyEvent); + } + + _onLongPress(action, actor, state) { + // Take advantage of the Clutter policy to consider + // a long-press canceled when the pointer movement + // exceeds dnd-drag-threshold to manually start the drag + if (state == Clutter.LongPressState.CANCEL) { + let event = Clutter.get_current_event(); + this._dragTouchSequence = event.get_event_sequence(); + + if (this._longPressLater) + return true; + + // A click cancels a long-press before any click handler is + // run - make sure to not start a drag in that case + this._longPressLater = Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + delete this._longPressLater; + if (this._selected) + return; + let [x, y] = action.get_coords(); + action.release(); + this._draggable.startDrag(x, y, global.get_current_time(), this._dragTouchSequence, event.get_device()); + }); + } else { + this.showOverlay(true); + } + return true; + } + + _onDragBegin(_draggable, _time) { + this.inDrag = true; + this.hideOverlay(false); + this.emit('drag-begin'); + } + + handleDragOver(source, actor, x, y, time) { + return this._workspace.handleDragOver(source, actor, x, y, time); + } + + acceptDrop(source, actor, x, y, time) { + return this._workspace.acceptDrop(source, actor, x, y, time); + } + + _onDragCancelled(_draggable, _time) { + this.emit('drag-cancelled'); + } + + _onDragEnd(_draggable, _time, _snapback) { + this.inDrag = false; + + // We may not have a parent if DnD completed successfully, in + // which case our clone will shortly be destroyed and replaced + // with a new one on the target workspace. + let parent = this.get_parent(); + if (parent !== null) { + if (this._stackAbove == null) + parent.set_child_below_sibling(this, null); + else + parent.set_child_above_sibling(this, this._stackAbove); + } + + if (this['has-pointer']) + this.showOverlay(true); + + this.emit('drag-end'); + } +}); diff --git a/js/ui/workspace.js b/js/ui/workspace.js new file mode 100644 index 0000000..6cd6a98 --- /dev/null +++ b/js/ui/workspace.js @@ -0,0 +1,1353 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Workspace */ + +const { Clutter, GLib, GObject, St } = imports.gi; + +const DND = imports.ui.dnd; +const Main = imports.ui.main; +const Overview = imports.ui.overview; +const { WindowPreview } = imports.ui.windowPreview; + +var WINDOW_PREVIEW_MAXIMUM_SCALE = 1.0; + +var WINDOW_REPOSITIONING_DELAY = 750; + +// When calculating a layout, we calculate the scale of windows and the percent +// of the available area the new layout uses. If the values for the new layout, +// when weighted with the values as below, are worse than the previous layout's, +// we stop looking for a new layout and use the previous layout. +// Otherwise, we keep looking for a new layout. +var LAYOUT_SCALE_WEIGHT = 1; +var LAYOUT_SPACE_WEIGHT = 0.1; + +var WINDOW_ANIMATION_MAX_NUMBER_BLENDING = 3; + +function _interpolate(start, end, step) { + return start + (end - start) * step; +} + +// Window Thumbnail Layout Algorithm +// ================================= +// +// General overview +// ---------------- +// +// The window thumbnail layout algorithm calculates some optimal layout +// by computing layouts with some number of rows, calculating how good +// each layout is, and stopping iterating when it finds one that is worse +// than the previous layout. A layout consists of which windows are in +// which rows, row sizes and other general state tracking that would make +// calculating window positions from this information fairly easy. +// +// After a layout is computed that's considered the best layout, we +// compute the layout scale to fit it in the area, and then compute +// slots (sizes and positions) for each thumbnail. +// +// Layout generation +// ----------------- +// +// Layout generation is naive and simple: we simply add windows to a row +// until we've added too many windows to a row, and then make a new row, +// until we have our required N rows. The potential issue with this strategy +// is that we may have too many windows at the bottom in some pathological +// cases, which tends to make the thumbnails have the shape of a pile of +// sand with a peak, with one window at the top. +// +// Scaling factors +// --------------- +// +// Thumbnail position is mostly straightforward -- the main issue is +// computing an optimal scale for each window that fits the constraints, +// and doesn't make the thumbnail too small to see. There are two factors +// involved in thumbnail scale to make sure that these two goals are met: +// the window scale (calculated by _computeWindowScale) and the layout +// scale (calculated by computeSizeAndScale). +// +// The calculation logic becomes slightly more complicated because row +// and column spacing are not scaled, they're constant, so we can't +// simply generate a bunch of window positions and then scale it. In +// practice, it's not too bad -- we can simply try to fit the layout +// in the input area minus whatever spacing we have, and then add +// it back afterwards. +// +// The window scale is constant for the window's size regardless of the +// input area or the layout scale or rows or anything else, and right +// now just enlarges the window if it's too small. The fact that this +// factor is stable makes it easy to calculate, so there's no sense +// in not applying it in most calculations. +// +// The layout scale depends on the input area, the rows, etc, but is the +// same for the entire layout, rather than being per-window. After +// generating the rows of windows, we basically do some basic math to +// fit the full, unscaled layout to the input area, as described above. +// +// With these two factors combined, the final scale of each thumbnail is +// simply windowScale * layoutScale... almost. +// +// There's one additional constraint: the thumbnail scale must never be +// larger than WINDOW_PREVIEW_MAXIMUM_SCALE, which means that the inequality: +// +// windowScale * layoutScale <= WINDOW_PREVIEW_MAXIMUM_SCALE +// +// must always be true. This is for each individual window -- while we +// could adjust layoutScale to make the largest thumbnail smaller than +// WINDOW_PREVIEW_MAXIMUM_SCALE, it would shrink windows which are already +// under the inequality. To solve this, we simply cheat: we simply keep +// each window's "cell" area to be the same, but we shrink the thumbnail +// and center it horizontally, and align it to the bottom vertically. + +var LayoutStrategy = class { + constructor(monitor, rowSpacing, columnSpacing) { + if (this.constructor === LayoutStrategy) + throw new TypeError(`Cannot instantiate abstract type ${this.constructor.name}`); + + this._monitor = monitor; + this._rowSpacing = rowSpacing; + this._columnSpacing = columnSpacing; + } + + _newRow() { + // Row properties: + // + // * x, y are the position of row, relative to area + // + // * width, height are the scaled versions of fullWidth, fullHeight + // + // * width also has the spacing in between windows. It's not in + // fullWidth, as the spacing is constant, whereas fullWidth is + // meant to be scaled + // + // * neither height/fullHeight have any sort of spacing or padding + return { x: 0, y: 0, + width: 0, height: 0, + fullWidth: 0, fullHeight: 0, + windows: [] }; + } + + // Computes and returns an individual scaling factor for @window, + // to be applied in addition to the overall layout scale. + _computeWindowScale(window) { + // Since we align windows next to each other, the height of the + // thumbnails is much more important to preserve than the width of + // them, so two windows with equal height, but maybe differering + // widths line up. + let ratio = window.boundingBox.height / this._monitor.height; + + // The purpose of this manipulation here is to prevent windows + // from getting too small. For something like a calculator window, + // we need to bump up the size just a bit to make sure it looks + // good. We'll use a multiplier of 1.5 for this. + + // Map from [0, 1] to [1.5, 1] + return _interpolate(1.5, 1, ratio); + } + + // Compute the size of each row, by assigning to the properties + // row.width, row.height, row.fullWidth, row.fullHeight, and + // (optionally) for each row in @layout.rows. This method is + // intended to be called by subclasses. + _computeRowSizes(_layout) { + throw new GObject.NotImplementedError(`_computeRowSizes in ${this.constructor.name}`); + } + + // Compute strategy-specific window slots for each window in + // @windows, given the @layout. The strategy may also use @layout + // as strategy-specific storage. + // + // This must calculate: + // * maxColumns - The maximum number of columns used by the layout. + // * gridWidth - The total width used by the grid, unscaled, unspaced. + // * gridHeight - The totial height used by the grid, unscaled, unspaced. + // * rows - A list of rows, which should be instantiated by _newRow. + computeLayout(_windows, _layout) { + throw new GObject.NotImplementedError(`computeLayout in ${this.constructor.name}`); + } + + // Given @layout, compute the overall scale and space of the layout. + // The scale is the individual, non-fancy scale of each window, and + // the space is the percentage of the available area eventually + // used by the layout. + + // This method does not return anything, but instead installs + // the properties "scale" and "space" on @layout directly. + // + // Make sure to call this methods before calling computeWindowSlots(), + // as it depends on the scale property installed in @layout here. + computeScaleAndSpace(layout) { + let area = layout.area; + + let hspacing = (layout.maxColumns - 1) * this._columnSpacing; + let vspacing = (layout.numRows - 1) * this._rowSpacing; + + let spacedWidth = area.width - hspacing; + let spacedHeight = area.height - vspacing; + + let horizontalScale = spacedWidth / layout.gridWidth; + let verticalScale = spacedHeight / layout.gridHeight; + + // Thumbnails should be less than 70% of the original size + let scale = Math.min( + horizontalScale, verticalScale, WINDOW_PREVIEW_MAXIMUM_SCALE); + + let scaledLayoutWidth = layout.gridWidth * scale + hspacing; + let scaledLayoutHeight = layout.gridHeight * scale + vspacing; + let space = (scaledLayoutWidth * scaledLayoutHeight) / (area.width * area.height); + + layout.scale = scale; + layout.space = space; + } + + computeWindowSlots(layout, area) { + this._computeRowSizes(layout); + + let { rows, scale } = layout; + + let slots = []; + + // Do this in three parts. + let heightWithoutSpacing = 0; + for (let i = 0; i < rows.length; i++) { + let row = rows[i]; + heightWithoutSpacing += row.height; + } + + let verticalSpacing = (rows.length - 1) * this._rowSpacing; + let additionalVerticalScale = Math.min(1, (area.height - verticalSpacing) / heightWithoutSpacing); + + // keep track how much smaller the grid becomes due to scaling + // so it can be centered again + let compensation = 0; + let y = 0; + + for (let i = 0; i < rows.length; i++) { + let row = rows[i]; + + // If this window layout row doesn't fit in the actual + // geometry, then apply an additional scale to it. + let horizontalSpacing = (row.windows.length - 1) * this._columnSpacing; + let widthWithoutSpacing = row.width - horizontalSpacing; + let additionalHorizontalScale = Math.min(1, (area.width - horizontalSpacing) / widthWithoutSpacing); + + if (additionalHorizontalScale < additionalVerticalScale) { + row.additionalScale = additionalHorizontalScale; + // Only consider the scaling in addition to the vertical scaling for centering. + compensation += (additionalVerticalScale - additionalHorizontalScale) * row.height; + } else { + row.additionalScale = additionalVerticalScale; + // No compensation when scaling vertically since centering based on a too large + // height would undo what vertical scaling is trying to achieve. + } + + row.x = area.x + (Math.max(area.width - (widthWithoutSpacing * row.additionalScale + horizontalSpacing), 0) / 2); + row.y = area.y + (Math.max(area.height - (heightWithoutSpacing + verticalSpacing), 0) / 2) + y; + y += row.height * row.additionalScale + this._rowSpacing; + } + + compensation /= 2; + + for (let i = 0; i < rows.length; i++) { + let row = rows[i]; + let x = row.x; + for (let j = 0; j < row.windows.length; j++) { + let window = row.windows[j]; + + let s = scale * this._computeWindowScale(window) * row.additionalScale; + let cellWidth = window.boundingBox.width * s; + let cellHeight = window.boundingBox.height * s; + + s = Math.min(s, WINDOW_PREVIEW_MAXIMUM_SCALE); + let cloneWidth = window.boundingBox.width * s; + const cloneHeight = window.boundingBox.height * s; + + let cloneX = x + (cellWidth - cloneWidth) / 2; + let cloneY = row.y + row.height * row.additionalScale - cellHeight + compensation; + + // Align with the pixel grid to prevent blurry windows at scale = 1 + cloneX = Math.floor(cloneX); + cloneY = Math.floor(cloneY); + + slots.push([cloneX, cloneY, cloneWidth, cloneHeight, window]); + x += cellWidth + this._columnSpacing; + } + } + return slots; + } +}; + +var UnalignedLayoutStrategy = class extends LayoutStrategy { + _computeRowSizes(layout) { + let { rows, scale } = layout; + for (let i = 0; i < rows.length; i++) { + let row = rows[i]; + row.width = row.fullWidth * scale + (row.windows.length - 1) * this._columnSpacing; + row.height = row.fullHeight * scale; + } + } + + _keepSameRow(row, window, width, idealRowWidth) { + if (row.fullWidth + width <= idealRowWidth) + return true; + + let oldRatio = row.fullWidth / idealRowWidth; + let newRatio = (row.fullWidth + width) / idealRowWidth; + + if (Math.abs(1 - newRatio) < Math.abs(1 - oldRatio)) + return true; + + return false; + } + + _sortRow(row) { + // Sort windows horizontally to minimize travel distance. + // This affects in what order the windows end up in a row. + row.windows.sort((a, b) => a.windowCenter.x - b.windowCenter.x); + } + + computeLayout(windows, layout) { + let numRows = layout.numRows; + + let rows = []; + let totalWidth = 0; + for (let i = 0; i < windows.length; i++) { + let window = windows[i]; + let s = this._computeWindowScale(window); + totalWidth += window.boundingBox.width * s; + } + + let idealRowWidth = totalWidth / numRows; + + // Sort windows vertically to minimize travel distance. + // This affects what rows the windows get placed in. + let sortedWindows = windows.slice(); + sortedWindows.sort((a, b) => a.windowCenter.y - b.windowCenter.y); + + let windowIdx = 0; + for (let i = 0; i < numRows; i++) { + let row = this._newRow(); + rows.push(row); + + for (; windowIdx < sortedWindows.length; windowIdx++) { + let window = sortedWindows[windowIdx]; + let s = this._computeWindowScale(window); + let width = window.boundingBox.width * s; + let height = window.boundingBox.height * s; + row.fullHeight = Math.max(row.fullHeight, height); + + // either new width is < idealWidth or new width is nearer from idealWidth then oldWidth + if (this._keepSameRow(row, window, width, idealRowWidth) || (i == numRows - 1)) { + row.windows.push(window); + row.fullWidth += width; + } else { + break; + } + } + } + + let gridHeight = 0; + let maxRow; + for (let i = 0; i < numRows; i++) { + let row = rows[i]; + this._sortRow(row); + + if (!maxRow || row.fullWidth > maxRow.fullWidth) + maxRow = row; + gridHeight += row.fullHeight; + } + + layout.rows = rows; + layout.maxColumns = maxRow.windows.length; + layout.gridWidth = maxRow.fullWidth; + layout.gridHeight = gridHeight; + } +}; + +function animateAllocation(actor, box) { + if (actor.allocation.equal(box) || + actor.allocation.get_width() === 0 || + actor.allocation.get_height() === 0) { + actor.allocate(box); + return null; + } + + actor.save_easing_state(); + actor.set_easing_mode(Clutter.AnimationMode.EASE_OUT_QUAD); + actor.set_easing_duration(200); + + actor.allocate(box); + + actor.restore_easing_state(); + + return actor.get_transition('allocation'); +} + +var WorkspaceLayout = GObject.registerClass({ + Properties: { + 'spacing': GObject.ParamSpec.double( + 'spacing', 'Spacing', 'Spacing', + GObject.ParamFlags.READWRITE, + 0, Infinity, 20), + 'layout-frozen': GObject.ParamSpec.boolean( + 'layout-frozen', 'Layout frozen', 'Layout frozen', + GObject.ParamFlags.READWRITE, + false), + }, +}, class WorkspaceLayout extends Clutter.LayoutManager { + _init(metaWorkspace, monitorIndex) { + super._init(); + + this._spacing = 20; + this._layoutFrozen = false; + + this._monitorIndex = monitorIndex; + this._workarea = metaWorkspace + ? metaWorkspace.get_work_area_for_monitor(this._monitorIndex) + : Main.layoutManager.getWorkAreaForMonitor(this._monitorIndex); + + this._container = null; + this._windows = new Map(); + this._sortedWindows = []; + this._lastBox = null; + this._windowSlots = []; + this._layout = null; + + this._stateAdjustment = new St.Adjustment({ + value: 0, + lower: 0, + upper: 1, + }); + + this._stateAdjustment.connect('notify::value', () => { + [...this._windows.keys()].forEach( + preview => this._syncOverlay(preview)); + this.layout_changed(); + }); + } + + _isBetterLayout(oldLayout, newLayout) { + if (oldLayout.scale === undefined) + return true; + + let spacePower = (newLayout.space - oldLayout.space) * LAYOUT_SPACE_WEIGHT; + let scalePower = (newLayout.scale - oldLayout.scale) * LAYOUT_SCALE_WEIGHT; + + if (newLayout.scale > oldLayout.scale && newLayout.space > oldLayout.space) { + // Win win -- better scale and better space + return true; + } else if (newLayout.scale > oldLayout.scale && newLayout.space <= oldLayout.space) { + // Keep new layout only if scale gain outweighs aspect space loss + return scalePower > spacePower; + } else if (newLayout.scale <= oldLayout.scale && newLayout.space > oldLayout.space) { + // Keep new layout only if aspect space gain outweighs scale loss + return spacePower > scalePower; + } else { + // Lose -- worse scale and space + return false; + } + } + + _adjustSpacingAndPadding(rowSpacing, colSpacing, containerBox) { + if (this._sortedWindows.length === 0) + return [colSpacing, rowSpacing, containerBox]; + + // All of the overlays have the same chrome sizes, + // so just pick the first one. + const window = this._sortedWindows[0]; + + const [topOversize, bottomOversize] = window.chromeHeights(); + const [leftOversize, rightOversize] = window.chromeWidths(); + + if (rowSpacing) + rowSpacing += Math.max(topOversize, bottomOversize); + if (colSpacing) + colSpacing += Math.max(leftOversize, rightOversize); + + if (containerBox) { + containerBox.x1 += leftOversize; + containerBox.x2 -= rightOversize; + containerBox.y1 += topOversize; + containerBox.y2 -= bottomOversize; + } + + return [rowSpacing, colSpacing, containerBox]; + } + + _createBestLayout(area) { + const [rowSpacing, colSpacing] = + this._adjustSpacingAndPadding(this._spacing, this._spacing, null); + + // We look for the largest scale that allows us to fit the + // largest row/tallest column on the workspace. + const strategy = new UnalignedLayoutStrategy( + Main.layoutManager.monitors[this._monitorIndex], + rowSpacing, + colSpacing); + + let lastLayout = {}; + + for (let numRows = 1; ; numRows++) { + let numColumns = Math.ceil(this._sortedWindows.length / numRows); + + // If adding a new row does not change column count just stop + // (for instance: 9 windows, with 3 rows -> 3 columns, 4 rows -> + // 3 columns as well => just use 3 rows then) + if (numColumns === lastLayout.numColumns) + break; + + let layout = { area, strategy, numRows, numColumns }; + strategy.computeLayout(this._sortedWindows, layout); + strategy.computeScaleAndSpace(layout); + + if (!this._isBetterLayout(lastLayout, layout)) + break; + + lastLayout = layout; + } + + return lastLayout; + } + + _getWindowSlots(containerBox) { + [, , containerBox] = + this._adjustSpacingAndPadding(null, null, containerBox); + + const availArea = { + x: parseInt(containerBox.x1), + y: parseInt(containerBox.y1), + width: parseInt(containerBox.get_width()), + height: parseInt(containerBox.get_height()), + }; + + return this._layout.strategy.computeWindowSlots(this._layout, availArea); + } + + _getAdjustedWorkarea(container) { + const workarea = this._workarea.copy(); + + if (container instanceof St.Widget) { + const themeNode = container.get_theme_node(); + workarea.width -= themeNode.get_horizontal_padding(); + workarea.height -= themeNode.get_vertical_padding(); + } + + return workarea; + } + + vfunc_set_container(container) { + this._container = container; + this._stateAdjustment.actor = container; + } + + vfunc_get_preferred_width(container, forHeight) { + const workarea = this._getAdjustedWorkarea(container); + if (forHeight === -1) + return [0, workarea.width]; + + const workAreaAspectRatio = workarea.width / workarea.height; + const widthPreservingAspectRatio = forHeight * workAreaAspectRatio; + + return [0, widthPreservingAspectRatio]; + } + + vfunc_get_preferred_height(container, forWidth) { + const workarea = this._getAdjustedWorkarea(container); + if (forWidth === -1) + return [0, workarea.height]; + + const workAreaAspectRatio = workarea.width / workarea.height; + const heightPreservingAspectRatio = forWidth / workAreaAspectRatio; + + return [0, heightPreservingAspectRatio]; + } + + vfunc_allocate(container, box) { + const containerBox = container.allocation; + const containerAllocationChanged = + this._lastBox === null || !this._lastBox.equal(containerBox); + this._lastBox = containerBox.copy(); + + // If the containers size changed, we can no longer keep around + // the old windowSlots, so we must unfreeze the layout. + // + // However, if the overview animation is in progress, don't unfreeze + // the layout. This is needed to prevent windows "snapping" to their + // new positions during the overview closing animation when the + // allocation subtly expands every frame. + if (this._layoutFrozen && containerAllocationChanged && !Main.overview.animationInProgress) { + this._layoutFrozen = false; + this.notify('layout-frozen'); + } + + let layoutChanged = false; + if (!this._layoutFrozen) { + if (this._layout === null) { + this._layout = this._createBestLayout(this._workarea); + layoutChanged = true; + } + + if (layoutChanged || containerAllocationChanged) + this._windowSlots = this._getWindowSlots(box.copy()); + } + + const allocationScale = containerBox.get_width() / this._workarea.width; + + const workspaceBox = new Clutter.ActorBox(); + const layoutBox = new Clutter.ActorBox(); + let childBox = new Clutter.ActorBox(); + + for (const child of container) { + if (!child.visible) + continue; + + // The fifth element in the slot array is the WindowPreview + const index = this._windowSlots.findIndex(s => s[4] === child); + if (index === -1) { + log('Couldn\'t find child %s in window slots'.format(child)); + child.allocate(childBox); + continue; + } + + const [x, y, width, height] = this._windowSlots[index]; + const windowInfo = this._windows.get(child); + + if (windowInfo.metaWindow.showing_on_its_workspace()) { + workspaceBox.x1 = child.boundingBox.x - this._workarea.x; + workspaceBox.x2 = workspaceBox.x1 + child.boundingBox.width; + workspaceBox.y1 = child.boundingBox.y - this._workarea.y; + workspaceBox.y2 = workspaceBox.y1 + child.boundingBox.height; + } else { + workspaceBox.set_origin(this._workarea.x, this._workarea.y); + workspaceBox.set_size(0, 0); + + child.opacity = this._stateAdjustment.value * 255; + } + + workspaceBox.scale(allocationScale); + // don't allow the scaled floating size to drop below + // the target layout size + workspaceBox.set_size( + Math.max(workspaceBox.get_width(), width), + Math.max(workspaceBox.get_height(), height)); + + layoutBox.x1 = x; + layoutBox.x2 = layoutBox.x1 + width; + layoutBox.y1 = y; + layoutBox.y2 = layoutBox.y1 + height; + + childBox = workspaceBox.interpolate(layoutBox, + this._stateAdjustment.value); + + if (windowInfo.currentTransition) { + windowInfo.currentTransition.get_interval().set_final(childBox); + + // The timeline of the transition might not have been updated + // before this allocation cycle, so make sure the child + // still updates needs_allocation to FALSE. + // Unfortunately, this relies on the fast paths in + // clutter_actor_allocate(), otherwise we'd start a new + // transition on the child, replacing the current one. + child.allocate(child.allocation); + continue; + } + + // We want layout changes (ie. larger changes to the layout like + // reshuffling the window order) to be animated, but small changes + // like changes to the container size to happen immediately (for + // example if the container height is being animated, we want to + // avoid animating the children allocations to make sure they + // don't "lag behind" the other animation). + if (layoutChanged && !Main.overview.animationInProgress) { + const transition = animateAllocation(child, childBox); + if (transition) { + windowInfo.currentTransition = transition; + windowInfo.currentTransition.connect('stopped', () => { + windowInfo.currentTransition = null; + }); + } + } else { + child.allocate(childBox); + } + } + } + + _syncOverlay(preview) { + preview.overlay_enabled = this._stateAdjustment.value === 1; + } + + /** + * addWindow: + * @param {WindowPreview} window: the window to add + * @param {Meta.Window} metaWindow: the MetaWindow of the window + * + * Adds @window to the workspace, it will be shown immediately if + * the layout isn't frozen using the layout-frozen property. + * + * If @window is already part of the workspace, nothing will happen. + */ + addWindow(window, metaWindow) { + if (this._windows.has(window)) + return; + + this._windows.set(window, { + metaWindow, + sizeChangedId: metaWindow.connect('size-changed', () => { + this._layout = null; + this.layout_changed(); + }), + destroyId: window.connect('destroy', () => + this.removeWindow(window)), + currentTransition: null, + }); + + this._sortedWindows.push(window); + this._sortedWindows.sort((a, b) => { + const winA = this._windows.get(a).metaWindow; + const winB = this._windows.get(b).metaWindow; + + return winA.get_stable_sequence() - winB.get_stable_sequence(); + }); + + this._syncOverlay(window); + this._container.add_child(window); + + this._layout = null; + this.layout_changed(); + } + + /** + * removeWindow: + * @param {WindowPreview} window: the window to remove + * + * Removes @window from the workspace if @window is a part of the + * workspace. If the layout-frozen property is set to true, the + * window will still be visible until the property is set to false. + */ + removeWindow(window) { + const windowInfo = this._windows.get(window); + if (!windowInfo) + return; + + windowInfo.metaWindow.disconnect(windowInfo.sizeChangedId); + window.disconnect(windowInfo.destroyId); + if (windowInfo.currentTransition) + window.remove_transition('allocation'); + + this._windows.delete(window); + this._sortedWindows.splice(this._sortedWindows.indexOf(window), 1); + + // The layout might be frozen and we might not update the windowSlots + // on the next allocation, so remove the slot now already + const index = this._windowSlots.findIndex(s => s[4] === window); + if (index !== -1) + this._windowSlots.splice(index, 1); + + // The window might have been reparented by DND + if (window.get_parent() === this._container) + this._container.remove_child(window); + + this._layout = null; + this.layout_changed(); + } + + syncStacking(stackIndices) { + const windows = [...this._windows.keys()]; + windows.sort((a, b) => { + const seqA = this._windows.get(a).metaWindow.get_stable_sequence(); + const seqB = this._windows.get(b).metaWindow.get_stable_sequence(); + + return stackIndices[seqA] - stackIndices[seqB]; + }); + + let lastWindow = null; + for (const window of windows) { + window.setStackAbove(lastWindow); + lastWindow = window; + } + + this._layout = null; + this.layout_changed(); + } + + /** + * getFocusChain: + * + * Gets the focus chain of the workspace. This function will return + * an empty array if the floating window layout is used. + * + * @returns {Array} an array of {Clutter.Actor}s + */ + getFocusChain() { + if (this._stateAdjustment.value === 0) + return []; + + // The fifth element in the slot array is the WindowPreview + return this._windowSlots.map(s => s[4]); + } + + /** + * An StAdjustment for controlling and transitioning between + * the alignment of windows using the layout strategy and the + * floating window layout. + * + * A value of 0 of the adjustment completely uses the floating + * window layout while a value of 1 completely aligns windows using + * the layout strategy. + * + * @type {St.Adjustment} + */ + get stateAdjustment() { + return this._stateAdjustment; + } + + get spacing() { + return this._spacing; + } + + set spacing(s) { + if (this._spacing === s) + return; + + this._spacing = s; + + this._layout = null; + this.notify('spacing'); + this.layout_changed(); + } + + // eslint-disable-next-line camelcase + get layout_frozen() { + return this._layoutFrozen; + } + + // eslint-disable-next-line camelcase + set layout_frozen(f) { + if (this._layoutFrozen === f) + return; + + this._layoutFrozen = f; + + this.notify('layout-frozen'); + if (!this._layoutFrozen) + this.layout_changed(); + } +}); + +/** + * @metaWorkspace: a #Meta.Workspace, or null + */ +var Workspace = GObject.registerClass( +class Workspace extends St.Widget { + _init(metaWorkspace, monitorIndex) { + super._init({ + style_class: 'window-picker', + layout_manager: new WorkspaceLayout(metaWorkspace, monitorIndex), + }); + + this.metaWorkspace = metaWorkspace; + + this.monitorIndex = monitorIndex; + this._monitor = Main.layoutManager.monitors[this.monitorIndex]; + + if (monitorIndex != Main.layoutManager.primaryIndex) + this.add_style_class_name('external-monitor'); + + this.connect('style-changed', this._onStyleChanged.bind(this)); + this.connect('destroy', this._onDestroy.bind(this)); + + const windows = global.get_window_actors().map(a => a.meta_window) + .filter(this._isMyWindow, this); + + // Create clones for windows that should be + // visible in the Overview + this._windows = []; + for (let i = 0; i < windows.length; i++) { + if (this._isOverviewWindow(windows[i])) + this._addWindowClone(windows[i]); + } + + // Track window changes + if (this.metaWorkspace) { + this._windowAddedId = this.metaWorkspace.connect('window-added', + this._windowAdded.bind(this)); + this._windowRemovedId = this.metaWorkspace.connect('window-removed', + this._windowRemoved.bind(this)); + } + this._windowEnteredMonitorId = global.display.connect('window-entered-monitor', + this._windowEnteredMonitor.bind(this)); + this._windowLeftMonitorId = global.display.connect('window-left-monitor', + this._windowLeftMonitor.bind(this)); + this._layoutFrozenId = 0; + + // DND requires this to be set + this._delegate = this; + } + + vfunc_get_focus_chain() { + return this.layout_manager.getFocusChain(); + } + + _lookupIndex(metaWindow) { + return this._windows.findIndex(w => w.metaWindow == metaWindow); + } + + containsMetaWindow(metaWindow) { + return this._lookupIndex(metaWindow) >= 0; + } + + isEmpty() { + return this._windows.length == 0; + } + + syncStacking(stackIndices) { + this.layout_manager.syncStacking(stackIndices); + } + + _doRemoveWindow(metaWin) { + let clone = this._removeWindowClone(metaWin); + + if (!clone) + return; + + clone.destroy(); + + // We need to reposition the windows; to avoid shuffling windows + // around while the user is interacting with the workspace, we delay + // the positioning until the pointer remains still for at least 750 ms + // or is moved outside the workspace + this.layout_manager.layout_frozen = true; + + if (this._layoutFrozenId > 0) { + GLib.source_remove(this._layoutFrozenId); + this._layoutFrozenId = 0; + } + + let [oldX, oldY] = global.get_pointer(); + + this._layoutFrozenId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + WINDOW_REPOSITIONING_DELAY, + () => { + const [newX, newY] = global.get_pointer(); + const pointerHasMoved = oldX !== newX || oldY !== newY; + const actorUnderPointer = global.stage.get_actor_at_pos( + Clutter.PickMode.REACTIVE, newX, newY); + + if ((pointerHasMoved && this.contains(actorUnderPointer)) || + this._windows.some(w => w.contains(actorUnderPointer))) { + oldX = newX; + oldY = newY; + return GLib.SOURCE_CONTINUE; + } + + this.layout_manager.layout_frozen = false; + this._layoutFrozenId = 0; + return GLib.SOURCE_REMOVE; + }); + + GLib.Source.set_name_by_id(this._layoutFrozenId, + '[gnome-shell] this._layoutFrozenId'); + } + + _doAddWindow(metaWin) { + let win = metaWin.get_compositor_private(); + + if (!win) { + // Newly-created windows are added to a workspace before + // the compositor finds out about them... + let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + if (metaWin.get_compositor_private() && + metaWin.get_workspace() == this.metaWorkspace) + this._doAddWindow(metaWin); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] this._doAddWindow'); + return; + } + + // We might have the window in our list already if it was on all workspaces and + // now was moved to this workspace + if (this._lookupIndex(metaWin) != -1) + return; + + if (!this._isMyWindow(metaWin)) + return; + + if (!this._isOverviewWindow(metaWin)) { + if (metaWin.get_transient_for() == null) + return; + + // Let the top-most ancestor handle all transients + let parent = metaWin.find_root_ancestor(); + let clone = this._windows.find(c => c.metaWindow == parent); + + // If no clone was found, the parent hasn't been created yet + // and will take care of the dialog when added + if (clone) + clone.addDialog(metaWin); + + return; + } + + const clone = this._addWindowClone(metaWin); + + clone.set_pivot_point(0.5, 0.5); + clone.scale_x = 0; + clone.scale_y = 0; + clone.ease({ + scale_x: 1, + scale_y: 1, + duration: 250, + onStopped: () => clone.set_pivot_point(0, 0), + }); + + if (this._layoutFrozenId > 0) { + // If a window was closed before, unfreeze the layout to ensure + // the new window is immediately shown + this.layout_manager.layout_frozen = false; + + GLib.source_remove(this._layoutFrozenId); + this._layoutFrozenId = 0; + } + } + + _windowAdded(metaWorkspace, metaWin) { + this._doAddWindow(metaWin); + } + + _windowRemoved(metaWorkspace, metaWin) { + this._doRemoveWindow(metaWin); + } + + _windowEnteredMonitor(metaDisplay, monitorIndex, metaWin) { + if (monitorIndex == this.monitorIndex) + this._doAddWindow(metaWin); + } + + _windowLeftMonitor(metaDisplay, monitorIndex, metaWin) { + if (monitorIndex == this.monitorIndex) + this._doRemoveWindow(metaWin); + } + + // check for maximized windows on the workspace + hasMaximizedWindows() { + for (let i = 0; i < this._windows.length; i++) { + let metaWindow = this._windows[i].metaWindow; + if (metaWindow.showing_on_its_workspace() && + metaWindow.maximized_horizontally && + metaWindow.maximized_vertically) + return true; + } + return false; + } + + fadeToOverview() { + // We don't want to reposition windows while animating in this way. + this.layout_manager.layout_frozen = true; + this._overviewShownId = Main.overview.connect('shown', this._doneShowingOverview.bind(this)); + if (this._windows.length == 0) + return; + + if (this.metaWorkspace !== null && !this.metaWorkspace.active) + return; + + this.layout_manager.stateAdjustment.value = 0; + + // Special case maximized windows, since it doesn't make sense + // to animate windows below in the stack + let topMaximizedWindow; + // It is ok to treat the case where there is no maximized + // window as if the bottom-most window was maximized given that + // it won't affect the result of the animation + for (topMaximizedWindow = this._windows.length - 1; topMaximizedWindow > 0; topMaximizedWindow--) { + let metaWindow = this._windows[topMaximizedWindow].metaWindow; + if (metaWindow.maximized_horizontally && metaWindow.maximized_vertically) + break; + } + + let nTimeSlots = Math.min(WINDOW_ANIMATION_MAX_NUMBER_BLENDING + 1, this._windows.length - topMaximizedWindow); + let windowBaseTime = Overview.ANIMATION_TIME / nTimeSlots; + + let topIndex = this._windows.length - 1; + for (let i = 0; i < this._windows.length; i++) { + if (i < topMaximizedWindow) { + // below top-most maximized window, don't animate + this._windows[i].hideOverlay(false); + this._windows[i].opacity = 0; + } else { + let fromTop = topIndex - i; + let time; + if (fromTop < nTimeSlots) // animate top-most windows gradually + time = windowBaseTime * (nTimeSlots - fromTop); + else + time = windowBaseTime; + + this._windows[i].opacity = 255; + this._fadeWindow(i, time, 0); + } + } + } + + fadeFromOverview() { + this.layout_manager.layout_frozen = true; + this._overviewHiddenId = Main.overview.connect('hidden', this._doneLeavingOverview.bind(this)); + if (this._windows.length == 0) + return; + + for (let i = 0; i < this._windows.length; i++) + this._windows[i].remove_all_transitions(); + + if (this._layoutFrozenId > 0) { + GLib.source_remove(this._layoutFrozenId); + this._layoutFrozenId = 0; + } + + if (this.metaWorkspace !== null && !this.metaWorkspace.active) + return; + + this.layout_manager.stateAdjustment.value = 0; + + // Special case maximized windows, since it doesn't make sense + // to animate windows below in the stack + let topMaximizedWindow; + // It is ok to treat the case where there is no maximized + // window as if the bottom-most window was maximized given that + // it won't affect the result of the animation + for (topMaximizedWindow = this._windows.length - 1; topMaximizedWindow > 0; topMaximizedWindow--) { + let metaWindow = this._windows[topMaximizedWindow].metaWindow; + if (metaWindow.maximized_horizontally && metaWindow.maximized_vertically) + break; + } + + let nTimeSlots = Math.min(WINDOW_ANIMATION_MAX_NUMBER_BLENDING + 1, this._windows.length - topMaximizedWindow); + let windowBaseTime = Overview.ANIMATION_TIME / nTimeSlots; + + let topIndex = this._windows.length - 1; + for (let i = 0; i < this._windows.length; i++) { + if (i < topMaximizedWindow) { + // below top-most maximized window, don't animate + this._windows[i].hideOverlay(false); + this._windows[i].opacity = 0; + } else { + let fromTop = topIndex - i; + let time; + if (fromTop < nTimeSlots) // animate top-most windows gradually + time = windowBaseTime * (fromTop + 1); + else + time = windowBaseTime * nTimeSlots; + + this._windows[i].opacity = 0; + this._fadeWindow(i, time, 255); + } + } + } + + _fadeWindow(index, duration, opacity) { + let clone = this._windows[index]; + clone.hideOverlay(false); + + if (clone.metaWindow.showing_on_its_workspace()) { + clone.ease({ + opacity, + duration, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } else { + // The window is hidden + clone.opacity = 0; + } + } + + zoomToOverview() { + const animate = + this.metaWorkspace === null || this.metaWorkspace.active; + + const adj = this.layout_manager.stateAdjustment; + adj.ease(1, { + duration: animate ? Overview.ANIMATION_TIME : 0, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + zoomFromOverview() { + for (let i = 0; i < this._windows.length; i++) + this._windows[i].remove_all_transitions(); + + if (this._layoutFrozenId > 0) { + GLib.source_remove(this._layoutFrozenId); + this._layoutFrozenId = 0; + } + + this.layout_manager.layout_frozen = true; + this._overviewHiddenId = Main.overview.connect('hidden', this._doneLeavingOverview.bind(this)); + + if (this.metaWorkspace !== null && !this.metaWorkspace.active) + return; + + this.layout_manager.stateAdjustment.ease(0, { + duration: Overview.ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + _onDestroy() { + if (this._overviewHiddenId) { + Main.overview.disconnect(this._overviewHiddenId); + this._overviewHiddenId = 0; + } + + if (this._overviewShownId) { + Main.overview.disconnect(this._overviewShownId); + this._overviewShownId = 0; + } + + if (this.metaWorkspace) { + this.metaWorkspace.disconnect(this._windowAddedId); + this.metaWorkspace.disconnect(this._windowRemovedId); + } + global.display.disconnect(this._windowEnteredMonitorId); + global.display.disconnect(this._windowLeftMonitorId); + + if (this._layoutFrozenId > 0) { + GLib.source_remove(this._layoutFrozenId); + this._layoutFrozenId = 0; + } + + this._windows = []; + } + + _doneLeavingOverview() { + this.layout_manager.layout_frozen = false; + this.layout_manager.stateAdjustment.value = 0; + this._windows.forEach(w => (w.opacity = 255)); + } + + _doneShowingOverview() { + this.layout_manager.layout_frozen = false; + this.layout_manager.stateAdjustment.value = 1; + this._windows.forEach(w => (w.opacity = 255)); + } + + _isMyWindow(window) { + const isOnWorkspace = this.metaWorkspace === null || + window.located_on_workspace(this.metaWorkspace); + const isOnMonitor = window.get_monitor() === this.monitorIndex; + + return isOnWorkspace && isOnMonitor; + } + + _isOverviewWindow(window) { + return !window.skip_taskbar; + } + + // Create a clone of a (non-desktop) window and add it to the window list + _addWindowClone(metaWindow) { + let clone = new WindowPreview(metaWindow, this); + + clone.connect('selected', + this._onCloneSelected.bind(this)); + clone.connect('drag-begin', () => { + Main.overview.beginWindowDrag(metaWindow); + }); + clone.connect('drag-cancelled', () => { + Main.overview.cancelledWindowDrag(metaWindow); + }); + clone.connect('drag-end', () => { + Main.overview.endWindowDrag(metaWindow); + }); + clone.connect('show-chrome', () => { + let focus = global.stage.key_focus; + if (focus == null || this.contains(focus)) + clone.grab_key_focus(); + + this._windows.forEach(c => { + if (c !== clone) + c.hideOverlay(true); + }); + }); + clone.connect('destroy', () => { + this._doRemoveWindow(metaWindow); + }); + + this.layout_manager.addWindow(clone, metaWindow); + + if (this._windows.length == 0) + clone.setStackAbove(null); + else + clone.setStackAbove(this._windows[this._windows.length - 1]); + + this._windows.push(clone); + + return clone; + } + + _removeWindowClone(metaWin) { + // find the position of the window in our list + let index = this._lookupIndex(metaWin); + + if (index == -1) + return null; + + this.layout_manager.removeWindow(this._windows[index]); + + return this._windows.splice(index, 1).pop(); + } + + _onStyleChanged() { + const themeNode = this.get_theme_node(); + this.layout_manager.spacing = themeNode.get_length('spacing'); + } + + _onCloneSelected(clone, time) { + let wsIndex; + if (this.metaWorkspace) + wsIndex = this.metaWorkspace.index(); + Main.activateWindow(clone.metaWindow, time, wsIndex); + } + + // Draggable target interface + handleDragOver(source, _actor, _x, _y, _time) { + if (source.metaWindow && !this._isMyWindow(source.metaWindow)) + return DND.DragMotionResult.MOVE_DROP; + if (source.app && source.app.can_open_new_window()) + return DND.DragMotionResult.COPY_DROP; + if (!source.app && source.shellWorkspaceLaunch) + return DND.DragMotionResult.COPY_DROP; + + return DND.DragMotionResult.CONTINUE; + } + + acceptDrop(source, actor, x, y, time) { + let workspaceManager = global.workspace_manager; + let workspaceIndex = this.metaWorkspace + ? this.metaWorkspace.index() + : workspaceManager.get_active_workspace_index(); + + if (source.metaWindow) { + const window = source.metaWindow; + if (this._isMyWindow(window)) + return false; + + // We need to move the window before changing the workspace, because + // the move itself could cause a workspace change if the window enters + // the primary monitor + if (window.get_monitor() != this.monitorIndex) + window.move_to_monitor(this.monitorIndex); + + window.change_workspace_by_index(workspaceIndex, false); + return true; + } else if (source.app && source.app.can_open_new_window()) { + if (source.animateLaunchAtPos) + source.animateLaunchAtPos(actor.x, actor.y); + + source.app.open_new_window(workspaceIndex); + return true; + } else if (!source.app && source.shellWorkspaceLaunch) { + // While unused in our own drag sources, shellWorkspaceLaunch allows + // extensions to define custom actions for their drag sources. + source.shellWorkspaceLaunch({ workspace: workspaceIndex, + timestamp: time }); + return true; + } + + return false; + } +}); diff --git a/js/ui/workspaceSwitcherPopup.js b/js/ui/workspaceSwitcherPopup.js new file mode 100644 index 0000000..4613e96 --- /dev/null +++ b/js/ui/workspaceSwitcherPopup.js @@ -0,0 +1,229 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported WorkspaceSwitcherPopup */ + +const { Clutter, GLib, GObject, Meta, St } = imports.gi; + +const Main = imports.ui.main; + +var ANIMATION_TIME = 100; +var DISPLAY_TIMEOUT = 600; + +var WorkspaceSwitcherPopupList = GObject.registerClass( +class WorkspaceSwitcherPopupList extends St.Widget { + _init() { + super._init({ + style_class: 'workspace-switcher', + offscreen_redirect: Clutter.OffscreenRedirect.ALWAYS, + }); + + this._itemSpacing = 0; + this._childHeight = 0; + this._childWidth = 0; + this._orientation = global.workspace_manager.layout_rows == -1 + ? Clutter.Orientation.VERTICAL + : Clutter.Orientation.HORIZONTAL; + + this.connect('style-changed', () => { + this._itemSpacing = this.get_theme_node().get_length('spacing'); + }); + } + + _getPreferredSizeForOrientation(_forSize) { + let workArea = Main.layoutManager.getWorkAreaForMonitor(Main.layoutManager.primaryIndex); + let themeNode = this.get_theme_node(); + + let availSize; + if (this._orientation == Clutter.Orientation.HORIZONTAL) + availSize = workArea.width - themeNode.get_horizontal_padding(); + else + availSize = workArea.height - themeNode.get_vertical_padding(); + + let size = 0; + for (let child of this.get_children()) { + let [, childNaturalHeight] = child.get_preferred_height(-1); + let height = childNaturalHeight * workArea.width / workArea.height; + + if (this._orientation == Clutter.Orientation.HORIZONTAL) + size += height * workArea.width / workArea.height; + else + size += height; + } + + let workspaceManager = global.workspace_manager; + let spacing = this._itemSpacing * (workspaceManager.n_workspaces - 1); + size += spacing; + size = Math.min(size, availSize); + + if (this._orientation == Clutter.Orientation.HORIZONTAL) { + this._childWidth = (size - spacing) / workspaceManager.n_workspaces; + return themeNode.adjust_preferred_width(size, size); + } else { + this._childHeight = (size - spacing) / workspaceManager.n_workspaces; + return themeNode.adjust_preferred_height(size, size); + } + } + + _getSizeForOppositeOrientation() { + let workArea = Main.layoutManager.getWorkAreaForMonitor(Main.layoutManager.primaryIndex); + + if (this._orientation == Clutter.Orientation.HORIZONTAL) { + this._childHeight = Math.round(this._childWidth * workArea.height / workArea.width); + return [this._childHeight, this._childHeight]; + } else { + this._childWidth = Math.round(this._childHeight * workArea.width / workArea.height); + return [this._childWidth, this._childWidth]; + } + } + + vfunc_get_preferred_height(forWidth) { + if (this._orientation == Clutter.Orientation.HORIZONTAL) + return this._getSizeForOppositeOrientation(); + else + return this._getPreferredSizeForOrientation(forWidth); + } + + vfunc_get_preferred_width(forHeight) { + if (this._orientation == Clutter.Orientation.HORIZONTAL) + return this._getPreferredSizeForOrientation(forHeight); + else + return this._getSizeForOppositeOrientation(); + } + + vfunc_allocate(box) { + this.set_allocation(box); + + let themeNode = this.get_theme_node(); + box = themeNode.get_content_box(box); + + let childBox = new Clutter.ActorBox(); + + let rtl = this.text_direction == Clutter.TextDirection.RTL; + let x = rtl ? box.x2 - this._childWidth : box.x1; + let y = box.y1; + for (let child of this.get_children()) { + childBox.x1 = Math.round(x); + childBox.x2 = Math.round(x + this._childWidth); + childBox.y1 = Math.round(y); + childBox.y2 = Math.round(y + this._childHeight); + + if (this._orientation == Clutter.Orientation.HORIZONTAL) { + if (rtl) + x -= this._childWidth + this._itemSpacing; + else + x += this._childWidth + this._itemSpacing; + } else { + y += this._childHeight + this._itemSpacing; + } + child.allocate(childBox); + } + } +}); + +var WorkspaceSwitcherPopup = GObject.registerClass( +class WorkspaceSwitcherPopup extends St.Widget { + _init() { + super._init({ x: 0, + y: 0, + width: global.screen_width, + height: global.screen_height, + style_class: 'workspace-switcher-group' }); + + Main.uiGroup.add_actor(this); + + this._timeoutId = 0; + + this._container = new St.BoxLayout({ style_class: 'workspace-switcher-container' }); + this.add_child(this._container); + + this._list = new WorkspaceSwitcherPopupList(); + this._container.add_child(this._list); + + this._redisplay(); + + this.hide(); + + let workspaceManager = global.workspace_manager; + this._workspaceManagerSignals = []; + this._workspaceManagerSignals.push(workspaceManager.connect('workspace-added', + this._redisplay.bind(this))); + this._workspaceManagerSignals.push(workspaceManager.connect('workspace-removed', + this._redisplay.bind(this))); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + _redisplay() { + let workspaceManager = global.workspace_manager; + + this._list.destroy_all_children(); + + for (let i = 0; i < workspaceManager.n_workspaces; i++) { + let indicator = null; + + if (i == this._activeWorkspaceIndex && this._direction == Meta.MotionDirection.UP) + indicator = new St.Bin({ style_class: 'ws-switcher-active-up' }); + else if (i == this._activeWorkspaceIndex && this._direction == Meta.MotionDirection.DOWN) + indicator = new St.Bin({ style_class: 'ws-switcher-active-down' }); + else if (i == this._activeWorkspaceIndex && this._direction == Meta.MotionDirection.LEFT) + indicator = new St.Bin({ style_class: 'ws-switcher-active-left' }); + else if (i == this._activeWorkspaceIndex && this._direction == Meta.MotionDirection.RIGHT) + indicator = new St.Bin({ style_class: 'ws-switcher-active-right' }); + else + indicator = new St.Bin({ style_class: 'ws-switcher-box' }); + + this._list.add_actor(indicator); + + } + + let workArea = Main.layoutManager.getWorkAreaForMonitor(Main.layoutManager.primaryIndex); + let [, containerNatHeight] = this._container.get_preferred_height(global.screen_width); + let [, containerNatWidth] = this._container.get_preferred_width(containerNatHeight); + this._container.x = workArea.x + Math.floor((workArea.width - containerNatWidth) / 2); + this._container.y = workArea.y + Math.floor((workArea.height - containerNatHeight) / 2); + } + + _show() { + this._container.ease({ + opacity: 255, + duration: ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + this.show(); + } + + display(direction, activeWorkspaceIndex) { + this._direction = direction; + this._activeWorkspaceIndex = activeWorkspaceIndex; + + this._redisplay(); + if (this._timeoutId != 0) + GLib.source_remove(this._timeoutId); + this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, DISPLAY_TIMEOUT, this._onTimeout.bind(this)); + GLib.Source.set_name_by_id(this._timeoutId, '[gnome-shell] this._onTimeout'); + this._show(); + } + + _onTimeout() { + GLib.source_remove(this._timeoutId); + this._timeoutId = 0; + this._container.ease({ + opacity: 0.0, + duration: ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this.destroy(), + }); + return GLib.SOURCE_REMOVE; + } + + _onDestroy() { + if (this._timeoutId) + GLib.source_remove(this._timeoutId); + this._timeoutId = 0; + + let workspaceManager = global.workspace_manager; + for (let i = 0; i < this._workspaceManagerSignals.length; i++) + workspaceManager.disconnect(this._workspaceManagerSignals[i]); + + this._workspaceManagerSignals = []; + } +}); diff --git a/js/ui/workspaceThumbnail.js b/js/ui/workspaceThumbnail.js new file mode 100644 index 0000000..9d7a863 --- /dev/null +++ b/js/ui/workspaceThumbnail.js @@ -0,0 +1,1362 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported WorkspaceThumbnail, ThumbnailsBox */ + +const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; + +const Background = imports.ui.background; +const DND = imports.ui.dnd; +const Main = imports.ui.main; +const Workspace = imports.ui.workspace; + +// The maximum size of a thumbnail is 1/10 the width and height of the screen +let MAX_THUMBNAIL_SCALE = 1 / 10.; + +var RESCALE_ANIMATION_TIME = 200; +var SLIDE_ANIMATION_TIME = 200; + +// When we create workspaces by dragging, we add a "cut" into the top and +// bottom of each workspace so that the user doesn't have to hit the +// placeholder exactly. +var WORKSPACE_CUT_SIZE = 10; + +var WORKSPACE_KEEP_ALIVE_TIME = 100; + +var MUTTER_SCHEMA = 'org.gnome.mutter'; + +/* A layout manager that requests size only for primary_actor, but then allocates + all using a fixed layout */ +var PrimaryActorLayout = GObject.registerClass( +class PrimaryActorLayout extends Clutter.FixedLayout { + _init(primaryActor) { + super._init(); + + this.primaryActor = primaryActor; + } + + vfunc_get_preferred_width(container, forHeight) { + return this.primaryActor.get_preferred_width(forHeight); + } + + vfunc_get_preferred_height(container, forWidth) { + return this.primaryActor.get_preferred_height(forWidth); + } +}); + +var WindowClone = GObject.registerClass({ + Signals: { + 'drag-begin': {}, + 'drag-cancelled': {}, + 'drag-end': {}, + 'selected': { param_types: [GObject.TYPE_UINT] }, + }, +}, class WindowClone extends Clutter.Actor { + _init(realWindow) { + let clone = new Clutter.Clone({ source: realWindow }); + super._init({ + layout_manager: new PrimaryActorLayout(clone), + reactive: true, + }); + this._delegate = this; + + this.add_child(clone); + this.realWindow = realWindow; + this.metaWindow = realWindow.meta_window; + + clone._updateId = this.realWindow.connect('notify::position', + this._onPositionChanged.bind(this)); + clone._destroyId = this.realWindow.connect('destroy', () => { + // First destroy the clone and then destroy everything + // This will ensure that we never see it in the _disconnectSignals loop + clone.destroy(); + this.destroy(); + }); + this._onPositionChanged(); + + this.connect('destroy', this._onDestroy.bind(this)); + + this._draggable = DND.makeDraggable(this, + { restoreOnSuccess: true, + dragActorMaxSize: Workspace.WINDOW_DND_SIZE, + dragActorOpacity: Workspace.DRAGGING_WINDOW_OPACITY }); + this._draggable.connect('drag-begin', this._onDragBegin.bind(this)); + this._draggable.connect('drag-cancelled', this._onDragCancelled.bind(this)); + this._draggable.connect('drag-end', this._onDragEnd.bind(this)); + this.inDrag = false; + + let iter = win => { + let actor = win.get_compositor_private(); + + if (!actor) + return false; + if (!win.is_attached_dialog()) + return false; + + this._doAddAttachedDialog(win, actor); + win.foreach_transient(iter); + + return true; + }; + this.metaWindow.foreach_transient(iter); + } + + // Find the actor just below us, respecting reparenting done + // by DND code + getActualStackAbove() { + if (this._stackAbove == null) + return null; + + if (this.inDrag) { + if (this._stackAbove._delegate) + return this._stackAbove._delegate.getActualStackAbove(); + else + return null; + } else { + return this._stackAbove; + } + } + + setStackAbove(actor) { + this._stackAbove = actor; + + // Don't apply the new stacking now, it will be applied + // when dragging ends and window are stacked again + if (actor.inDrag) + return; + + let parent = this.get_parent(); + let actualAbove = this.getActualStackAbove(); + if (actualAbove == null) + parent.set_child_below_sibling(this, null); + else + parent.set_child_above_sibling(this, actualAbove); + } + + addAttachedDialog(win) { + this._doAddAttachedDialog(win, win.get_compositor_private()); + } + + _doAddAttachedDialog(metaDialog, realDialog) { + let clone = new Clutter.Clone({ source: realDialog }); + this._updateDialogPosition(realDialog, clone); + + clone._updateId = realDialog.connect('notify::position', dialog => { + this._updateDialogPosition(dialog, clone); + }); + clone._destroyId = realDialog.connect('destroy', () => { + clone.destroy(); + }); + this.add_child(clone); + } + + _updateDialogPosition(realDialog, cloneDialog) { + let metaDialog = realDialog.meta_window; + let dialogRect = metaDialog.get_frame_rect(); + let rect = this.metaWindow.get_frame_rect(); + + cloneDialog.set_position(dialogRect.x - rect.x, dialogRect.y - rect.y); + } + + _onPositionChanged() { + this.set_position(this.realWindow.x, this.realWindow.y); + } + + _disconnectSignals() { + this.get_children().forEach(child => { + let realWindow = child.source; + + realWindow.disconnect(child._updateId); + realWindow.disconnect(child._destroyId); + }); + } + + _onDestroy() { + this._disconnectSignals(); + + this._delegate = null; + + if (this.inDrag) { + this.emit('drag-end'); + this.inDrag = false; + } + } + + vfunc_button_press_event() { + return Clutter.EVENT_STOP; + } + + vfunc_button_release_event(buttonEvent) { + this.emit('selected', buttonEvent.time); + + return Clutter.EVENT_STOP; + } + + vfunc_touch_event(touchEvent) { + if (touchEvent.type != Clutter.EventType.TOUCH_END || + !global.display.is_pointer_emulating_sequence(touchEvent.sequence)) + return Clutter.EVENT_PROPAGATE; + + this.emit('selected', touchEvent.time); + return Clutter.EVENT_STOP; + } + + _onDragBegin(_draggable, _time) { + this.inDrag = true; + this.emit('drag-begin'); + } + + _onDragCancelled(_draggable, _time) { + this.emit('drag-cancelled'); + } + + _onDragEnd(_draggable, _time, _snapback) { + this.inDrag = false; + + // We may not have a parent if DnD completed successfully, in + // which case our clone will shortly be destroyed and replaced + // with a new one on the target workspace. + let parent = this.get_parent(); + if (parent !== null) { + if (this._stackAbove == null) + parent.set_child_below_sibling(this, null); + else + parent.set_child_above_sibling(this, this._stackAbove); + } + + + this.emit('drag-end'); + } +}); + + +var ThumbnailState = { + NEW: 0, + ANIMATING_IN: 1, + NORMAL: 2, + REMOVING: 3, + ANIMATING_OUT: 4, + ANIMATED_OUT: 5, + COLLAPSING: 6, + DESTROYED: 7, +}; + +/** + * @metaWorkspace: a #Meta.Workspace + */ +var WorkspaceThumbnail = GObject.registerClass({ + Properties: { + 'collapse-fraction': GObject.ParamSpec.double( + 'collapse-fraction', 'collapse-fraction', 'collapse-fraction', + GObject.ParamFlags.READWRITE, + 0, 1, 0), + 'slide-position': GObject.ParamSpec.double( + 'slide-position', 'slide-position', 'slide-position', + GObject.ParamFlags.READWRITE, + 0, 1, 0), + }, +}, class WorkspaceThumbnail extends St.Widget { + _init(metaWorkspace) { + super._init({ + clip_to_allocation: true, + style_class: 'workspace-thumbnail', + }); + this._delegate = this; + + this.metaWorkspace = metaWorkspace; + this.monitorIndex = Main.layoutManager.primaryIndex; + + this._removed = false; + + this._contents = new Clutter.Actor(); + this.add_child(this._contents); + + this.connect('destroy', this._onDestroy.bind(this)); + + this._createBackground(); + + let workArea = Main.layoutManager.getWorkAreaForMonitor(this.monitorIndex); + this.setPorthole(workArea.x, workArea.y, workArea.width, workArea.height); + + let windows = global.get_window_actors().filter(actor => { + let win = actor.meta_window; + return win.located_on_workspace(metaWorkspace); + }); + + // Create clones for windows that should be visible in the Overview + this._windows = []; + this._allWindows = []; + this._minimizedChangedIds = []; + for (let i = 0; i < windows.length; i++) { + let minimizedChangedId = + windows[i].meta_window.connect('notify::minimized', + this._updateMinimized.bind(this)); + this._allWindows.push(windows[i].meta_window); + this._minimizedChangedIds.push(minimizedChangedId); + + if (this._isMyWindow(windows[i]) && this._isOverviewWindow(windows[i])) + this._addWindowClone(windows[i]); + } + + // Track window changes + this._windowAddedId = this.metaWorkspace.connect('window-added', + this._windowAdded.bind(this)); + this._windowRemovedId = this.metaWorkspace.connect('window-removed', + this._windowRemoved.bind(this)); + this._windowEnteredMonitorId = global.display.connect('window-entered-monitor', + this._windowEnteredMonitor.bind(this)); + this._windowLeftMonitorId = global.display.connect('window-left-monitor', + this._windowLeftMonitor.bind(this)); + + this.state = ThumbnailState.NORMAL; + this._slidePosition = 0; // Fully slid in + this._collapseFraction = 0; // Not collapsed + } + + _createBackground() { + this._bgManager = new Background.BackgroundManager({ monitorIndex: Main.layoutManager.primaryIndex, + container: this._contents, + vignette: false }); + } + + setPorthole(x, y, width, height) { + this.set_size(width, height); + this._contents.set_position(-x, -y); + } + + _lookupIndex(metaWindow) { + return this._windows.findIndex(w => w.metaWindow == metaWindow); + } + + syncStacking(stackIndices) { + this._windows.sort((a, b) => { + let indexA = stackIndices[a.metaWindow.get_stable_sequence()]; + let indexB = stackIndices[b.metaWindow.get_stable_sequence()]; + return indexA - indexB; + }); + + for (let i = 0; i < this._windows.length; i++) { + let clone = this._windows[i]; + if (i == 0) { + clone.setStackAbove(this._bgManager.backgroundActor); + } else { + let previousClone = this._windows[i - 1]; + clone.setStackAbove(previousClone); + } + } + } + + // eslint-disable-next-line camelcase + set slide_position(slidePosition) { + if (this._slidePosition == slidePosition) + return; + this._slidePosition = slidePosition; + this.notify('slide-position'); + this.queue_relayout(); + } + + // eslint-disable-next-line camelcase + get slide_position() { + return this._slidePosition; + } + + // eslint-disable-next-line camelcase + set collapse_fraction(collapseFraction) { + if (this._collapseFraction == collapseFraction) + return; + this._collapseFraction = collapseFraction; + this.notify('collapse-fraction'); + this.queue_relayout(); + } + + // eslint-disable-next-line camelcase + get collapse_fraction() { + return this._collapseFraction; + } + + _doRemoveWindow(metaWin) { + let clone = this._removeWindowClone(metaWin); + if (clone) + clone.destroy(); + } + + _doAddWindow(metaWin) { + if (this._removed) + return; + + let win = metaWin.get_compositor_private(); + + if (!win) { + // Newly-created windows are added to a workspace before + // the compositor finds out about them... + let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + if (!this._removed && + metaWin.get_compositor_private() && + metaWin.get_workspace() == this.metaWorkspace) + this._doAddWindow(metaWin); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] this._doAddWindow'); + return; + } + + if (!this._allWindows.includes(metaWin)) { + let minimizedChangedId = metaWin.connect('notify::minimized', + this._updateMinimized.bind(this)); + this._allWindows.push(metaWin); + this._minimizedChangedIds.push(minimizedChangedId); + } + + // We might have the window in our list already if it was on all workspaces and + // now was moved to this workspace + if (this._lookupIndex(metaWin) != -1) + return; + + if (!this._isMyWindow(win)) + return; + + if (this._isOverviewWindow(win)) { + this._addWindowClone(win); + } else if (metaWin.is_attached_dialog()) { + let parent = metaWin.get_transient_for(); + while (parent.is_attached_dialog()) + parent = parent.get_transient_for(); + + let idx = this._lookupIndex(parent); + if (idx < 0) { + // parent was not created yet, it will take care + // of the dialog when created + return; + } + + let clone = this._windows[idx]; + clone.addAttachedDialog(metaWin); + } + } + + _windowAdded(metaWorkspace, metaWin) { + this._doAddWindow(metaWin); + } + + _windowRemoved(metaWorkspace, metaWin) { + let index = this._allWindows.indexOf(metaWin); + if (index != -1) { + metaWin.disconnect(this._minimizedChangedIds[index]); + this._allWindows.splice(index, 1); + this._minimizedChangedIds.splice(index, 1); + } + + this._doRemoveWindow(metaWin); + } + + _windowEnteredMonitor(metaDisplay, monitorIndex, metaWin) { + if (monitorIndex == this.monitorIndex) + this._doAddWindow(metaWin); + } + + _windowLeftMonitor(metaDisplay, monitorIndex, metaWin) { + if (monitorIndex == this.monitorIndex) + this._doRemoveWindow(metaWin); + } + + _updateMinimized(metaWin) { + if (metaWin.minimized) + this._doRemoveWindow(metaWin); + else + this._doAddWindow(metaWin); + } + + workspaceRemoved() { + if (this._removed) + return; + + this._removed = true; + + this.metaWorkspace.disconnect(this._windowAddedId); + this.metaWorkspace.disconnect(this._windowRemovedId); + global.display.disconnect(this._windowEnteredMonitorId); + global.display.disconnect(this._windowLeftMonitorId); + + for (let i = 0; i < this._allWindows.length; i++) + this._allWindows[i].disconnect(this._minimizedChangedIds[i]); + } + + _onDestroy() { + this.workspaceRemoved(); + + if (this._bgManager) { + this._bgManager.destroy(); + this._bgManager = null; + } + + this._windows = []; + } + + // Tests if @actor belongs to this workspace and monitor + _isMyWindow(actor) { + let win = actor.meta_window; + return win.located_on_workspace(this.metaWorkspace) && + (win.get_monitor() == this.monitorIndex); + } + + // Tests if @win should be shown in the Overview + _isOverviewWindow(win) { + return !win.get_meta_window().skip_taskbar && + win.get_meta_window().showing_on_its_workspace(); + } + + // Create a clone of a (non-desktop) window and add it to the window list + _addWindowClone(win) { + let clone = new WindowClone(win); + + clone.connect('selected', (o, time) => { + this.activate(time); + }); + clone.connect('drag-begin', () => { + Main.overview.beginWindowDrag(clone.metaWindow); + }); + clone.connect('drag-cancelled', () => { + Main.overview.cancelledWindowDrag(clone.metaWindow); + }); + clone.connect('drag-end', () => { + Main.overview.endWindowDrag(clone.metaWindow); + }); + clone.connect('destroy', () => { + this._removeWindowClone(clone.metaWindow); + }); + this._contents.add_actor(clone); + + if (this._windows.length == 0) + clone.setStackAbove(this._bgManager.backgroundActor); + else + clone.setStackAbove(this._windows[this._windows.length - 1]); + + this._windows.push(clone); + + return clone; + } + + _removeWindowClone(metaWin) { + // find the position of the window in our list + let index = this._lookupIndex(metaWin); + + if (index == -1) + return null; + + return this._windows.splice(index, 1).pop(); + } + + activate(time) { + if (this.state > ThumbnailState.NORMAL) + return; + + // a click on the already current workspace should go back to the main view + if (this.metaWorkspace.active) + Main.overview.hide(); + else + this.metaWorkspace.activate(time); + } + + // Draggable target interface used only by ThumbnailsBox + handleDragOverInternal(source, actor, time) { + if (source == Main.xdndHandler) { + this.metaWorkspace.activate(time); + return DND.DragMotionResult.CONTINUE; + } + + if (this.state > ThumbnailState.NORMAL) + return DND.DragMotionResult.CONTINUE; + + if (source.metaWindow && + !this._isMyWindow(source.metaWindow.get_compositor_private())) + return DND.DragMotionResult.MOVE_DROP; + if (source.app && source.app.can_open_new_window()) + return DND.DragMotionResult.COPY_DROP; + if (!source.app && source.shellWorkspaceLaunch) + return DND.DragMotionResult.COPY_DROP; + + return DND.DragMotionResult.CONTINUE; + } + + acceptDropInternal(source, actor, time) { + if (this.state > ThumbnailState.NORMAL) + return false; + + if (source.metaWindow) { + let win = source.metaWindow.get_compositor_private(); + if (this._isMyWindow(win)) + return false; + + let metaWindow = win.get_meta_window(); + + // We need to move the window before changing the workspace, because + // the move itself could cause a workspace change if the window enters + // the primary monitor + if (metaWindow.get_monitor() != this.monitorIndex) + metaWindow.move_to_monitor(this.monitorIndex); + + metaWindow.change_workspace_by_index(this.metaWorkspace.index(), false); + return true; + } else if (source.app && source.app.can_open_new_window()) { + if (source.animateLaunchAtPos) + source.animateLaunchAtPos(actor.x, actor.y); + + source.app.open_new_window(this.metaWorkspace.index()); + return true; + } else if (!source.app && source.shellWorkspaceLaunch) { + // While unused in our own drag sources, shellWorkspaceLaunch allows + // extensions to define custom actions for their drag sources. + source.shellWorkspaceLaunch({ workspace: this.metaWorkspace.index(), + timestamp: time }); + return true; + } + + return false; + } +}); + + +var ThumbnailsBox = GObject.registerClass({ + Properties: { + 'indicator-y': GObject.ParamSpec.double( + 'indicator-y', 'indicator-y', 'indicator-y', + GObject.ParamFlags.READWRITE, + 0, Infinity, 0), + 'scale': GObject.ParamSpec.double( + 'scale', 'scale', 'scale', + GObject.ParamFlags.READWRITE, + 0, Infinity, 0), + }, +}, class ThumbnailsBox extends St.Widget { + _init(scrollAdjustment) { + super._init({ reactive: true, + style_class: 'workspace-thumbnails', + request_mode: Clutter.RequestMode.WIDTH_FOR_HEIGHT }); + + this._delegate = this; + + let indicator = new St.Bin({ style_class: 'workspace-thumbnail-indicator' }); + + // We don't want the indicator to affect drag-and-drop + Shell.util_set_hidden_from_pick(indicator, true); + + this._indicator = indicator; + this.add_actor(indicator); + + // The porthole is the part of the screen we're showing in the thumbnails + this._porthole = { width: global.stage.width, height: global.stage.height, + x: global.stage.x, y: global.stage.y }; + + this._dropWorkspace = -1; + this._dropPlaceholderPos = -1; + this._dropPlaceholder = new St.Bin({ style_class: 'placeholder' }); + this.add_actor(this._dropPlaceholder); + this._spliceIndex = -1; + + this._targetScale = 0; + this._scale = 0; + this._pendingScaleUpdate = false; + this._stateUpdateQueued = false; + this._animatingIndicator = false; + + this._stateCounts = {}; + for (let key in ThumbnailState) + this._stateCounts[ThumbnailState[key]] = 0; + + this._thumbnails = []; + + Main.overview.connect('showing', + this._createThumbnails.bind(this)); + Main.overview.connect('hidden', + this._destroyThumbnails.bind(this)); + + Main.overview.connect('item-drag-begin', + this._onDragBegin.bind(this)); + Main.overview.connect('item-drag-end', + this._onDragEnd.bind(this)); + Main.overview.connect('item-drag-cancelled', + this._onDragCancelled.bind(this)); + Main.overview.connect('window-drag-begin', + this._onDragBegin.bind(this)); + Main.overview.connect('window-drag-end', + this._onDragEnd.bind(this)); + Main.overview.connect('window-drag-cancelled', + this._onDragCancelled.bind(this)); + + this._settings = new Gio.Settings({ schema_id: MUTTER_SCHEMA }); + this._settings.connect('changed::dynamic-workspaces', + this._updateSwitcherVisibility.bind(this)); + + Main.layoutManager.connect('monitors-changed', () => { + this._destroyThumbnails(); + if (Main.overview.visible) + this._createThumbnails(); + }); + + global.display.connect('workareas-changed', + this._updatePorthole.bind(this)); + + this._switchWorkspaceNotifyId = 0; + this._nWorkspacesNotifyId = 0; + this._syncStackingId = 0; + this._workareasChangedId = 0; + + this._scrollAdjustment = scrollAdjustment; + + this._scrollAdjustment.connect('notify::value', adj => { + let workspaceManager = global.workspace_manager; + let activeIndex = workspaceManager.get_active_workspace_index(); + + this._animatingIndicator = adj.value !== activeIndex; + + if (!this._animatingIndicator) + this._queueUpdateStates(); + + this.queue_relayout(); + }); + } + + _updateSwitcherVisibility() { + let workspaceManager = global.workspace_manager; + + this.visible = + this._settings.get_boolean('dynamic-workspaces') || + workspaceManager.n_workspaces > 1; + } + + _activateThumbnailAtPoint(stageX, stageY, time) { + let [r_, x_, y] = this.transform_stage_point(stageX, stageY); + + let thumbnail = this._thumbnails.find(t => { + let [, h] = t.get_transformed_size(); + return y >= t.y && y <= t.y + h; + }); + if (thumbnail) + thumbnail.activate(time); + } + + vfunc_button_release_event(buttonEvent) { + let { x, y } = buttonEvent; + this._activateThumbnailAtPoint(x, y, buttonEvent.time); + return Clutter.EVENT_STOP; + } + + vfunc_touch_event(touchEvent) { + if (touchEvent.type == Clutter.EventType.TOUCH_END && + global.display.is_pointer_emulating_sequence(touchEvent.sequence)) { + let { x, y } = touchEvent; + this._activateThumbnailAtPoint(x, y, touchEvent.time); + } + + return Clutter.EVENT_STOP; + } + + _onDragBegin() { + this._dragCancelled = false; + this._dragMonitor = { + dragMotion: this._onDragMotion.bind(this), + }; + DND.addDragMonitor(this._dragMonitor); + } + + _onDragEnd() { + if (this._dragCancelled) + return; + + this._endDrag(); + } + + _onDragCancelled() { + this._dragCancelled = true; + this._endDrag(); + } + + _endDrag() { + this._clearDragPlaceholder(); + DND.removeDragMonitor(this._dragMonitor); + } + + _onDragMotion(dragEvent) { + if (!this.contains(dragEvent.targetActor)) + this._onLeave(); + return DND.DragMotionResult.CONTINUE; + } + + _onLeave() { + this._clearDragPlaceholder(); + } + + _clearDragPlaceholder() { + if (this._dropPlaceholderPos == -1) + return; + + this._dropPlaceholderPos = -1; + this.queue_relayout(); + } + + // Draggable target interface + handleDragOver(source, actor, x, y, time) { + if (!source.metaWindow && + (!source.app || !source.app.can_open_new_window()) && + (source.app || !source.shellWorkspaceLaunch) && + source != Main.xdndHandler) + return DND.DragMotionResult.CONTINUE; + + let canCreateWorkspaces = Meta.prefs_get_dynamic_workspaces(); + let spacing = this.get_theme_node().get_length('spacing'); + + this._dropWorkspace = -1; + let placeholderPos = -1; + let targetBase; + if (this._dropPlaceholderPos == 0) + targetBase = this._dropPlaceholder.y; + else + targetBase = this._thumbnails[0].y; + let targetTop = targetBase - spacing - WORKSPACE_CUT_SIZE; + let length = this._thumbnails.length; + for (let i = 0; i < length; i++) { + // Allow the reorder target to have a 10px "cut" into + // each side of the thumbnail, to make dragging onto the + // placeholder easier + let [, h] = this._thumbnails[i].get_transformed_size(); + let targetBottom = targetBase + WORKSPACE_CUT_SIZE; + let nextTargetBase = targetBase + h + spacing; + let nextTargetTop = nextTargetBase - spacing - (i == length - 1 ? 0 : WORKSPACE_CUT_SIZE); + + // Expand the target to include the placeholder, if it exists. + if (i == this._dropPlaceholderPos) + targetBottom += this._dropPlaceholder.get_height(); + + if (y > targetTop && y <= targetBottom && source != Main.xdndHandler && canCreateWorkspaces) { + placeholderPos = i; + break; + } else if (y > targetBottom && y <= nextTargetTop) { + this._dropWorkspace = i; + break; + } + + targetBase = nextTargetBase; + targetTop = nextTargetTop; + } + + if (this._dropPlaceholderPos != placeholderPos) { + this._dropPlaceholderPos = placeholderPos; + this.queue_relayout(); + } + + if (this._dropWorkspace != -1) + return this._thumbnails[this._dropWorkspace].handleDragOverInternal(source, actor, time); + else if (this._dropPlaceholderPos != -1) + return source.metaWindow ? DND.DragMotionResult.MOVE_DROP : DND.DragMotionResult.COPY_DROP; + else + return DND.DragMotionResult.CONTINUE; + } + + acceptDrop(source, actor, x, y, time) { + if (this._dropWorkspace != -1) { + return this._thumbnails[this._dropWorkspace].acceptDropInternal(source, actor, time); + } else if (this._dropPlaceholderPos != -1) { + if (!source.metaWindow && + (!source.app || !source.app.can_open_new_window()) && + (source.app || !source.shellWorkspaceLaunch)) + return false; + + let isWindow = !!source.metaWindow; + + let newWorkspaceIndex; + [newWorkspaceIndex, this._dropPlaceholderPos] = [this._dropPlaceholderPos, -1]; + this._spliceIndex = newWorkspaceIndex; + + Main.wm.insertWorkspace(newWorkspaceIndex); + + if (isWindow) { + // Move the window to our monitor first if necessary. + let thumbMonitor = this._thumbnails[newWorkspaceIndex].monitorIndex; + if (source.metaWindow.get_monitor() != thumbMonitor) + source.metaWindow.move_to_monitor(thumbMonitor); + source.metaWindow.change_workspace_by_index(newWorkspaceIndex, true); + } else if (source.app && source.app.can_open_new_window()) { + if (source.animateLaunchAtPos) + source.animateLaunchAtPos(actor.x, actor.y); + + source.app.open_new_window(newWorkspaceIndex); + } else if (!source.app && source.shellWorkspaceLaunch) { + // While unused in our own drag sources, shellWorkspaceLaunch allows + // extensions to define custom actions for their drag sources. + source.shellWorkspaceLaunch({ workspace: newWorkspaceIndex, + timestamp: time }); + } + + if (source.app || (!source.app && source.shellWorkspaceLaunch)) { + // This new workspace will be automatically removed if the application fails + // to open its first window within some time, as tracked by Shell.WindowTracker. + // Here, we only add a very brief timeout to avoid the _immediate_ removal of the + // workspace while we wait for the startup sequence to load. + let workspaceManager = global.workspace_manager; + Main.wm.keepWorkspaceAlive(workspaceManager.get_workspace_by_index(newWorkspaceIndex), + WORKSPACE_KEEP_ALIVE_TIME); + } + + // Start the animation on the workspace (which is actually + // an old one which just became empty) + let thumbnail = this._thumbnails[newWorkspaceIndex]; + this._setThumbnailState(thumbnail, ThumbnailState.NEW); + thumbnail.slide_position = 1; + + this._queueUpdateStates(); + + return true; + } else { + return false; + } + } + + _createThumbnails() { + let workspaceManager = global.workspace_manager; + + this._nWorkspacesNotifyId = + workspaceManager.connect('notify::n-workspaces', + this._workspacesChanged.bind(this)); + this._workspacesReorderedId = + workspaceManager.connect('workspaces-reordered', () => { + this._thumbnails.sort((a, b) => { + return a.metaWorkspace.index() - b.metaWorkspace.index(); + }); + this.queue_relayout(); + }); + this._syncStackingId = + Main.overview.connect('windows-restacked', + this._syncStacking.bind(this)); + + this._targetScale = 0; + this._scale = 0; + this._pendingScaleUpdate = false; + this._stateUpdateQueued = false; + + this._stateCounts = {}; + for (let key in ThumbnailState) + this._stateCounts[ThumbnailState[key]] = 0; + + this.addThumbnails(0, workspaceManager.n_workspaces); + + this._updateSwitcherVisibility(); + } + + _destroyThumbnails() { + if (this._thumbnails.length == 0) + return; + + if (this._nWorkspacesNotifyId > 0) { + let workspaceManager = global.workspace_manager; + workspaceManager.disconnect(this._nWorkspacesNotifyId); + this._nWorkspacesNotifyId = 0; + } + if (this._workspacesReorderedId > 0) { + let workspaceManager = global.workspace_manager; + workspaceManager.disconnect(this._workspacesReorderedId); + this._workspacesReorderedId = 0; + } + + if (this._syncStackingId > 0) { + Main.overview.disconnect(this._syncStackingId); + this._syncStackingId = 0; + } + + for (let w = 0; w < this._thumbnails.length; w++) + this._thumbnails[w].destroy(); + this._thumbnails = []; + } + + _workspacesChanged() { + let validThumbnails = + this._thumbnails.filter(t => t.state <= ThumbnailState.NORMAL); + let workspaceManager = global.workspace_manager; + let oldNumWorkspaces = validThumbnails.length; + let newNumWorkspaces = workspaceManager.n_workspaces; + + if (newNumWorkspaces > oldNumWorkspaces) { + this.addThumbnails(oldNumWorkspaces, newNumWorkspaces - oldNumWorkspaces); + } else { + let removedIndex; + let removedNum = oldNumWorkspaces - newNumWorkspaces; + for (let w = 0; w < oldNumWorkspaces; w++) { + let metaWorkspace = workspaceManager.get_workspace_by_index(w); + if (this._thumbnails[w].metaWorkspace != metaWorkspace) { + removedIndex = w; + break; + } + } + + this.removeThumbnails(removedIndex, removedNum); + } + + this._updateSwitcherVisibility(); + } + + addThumbnails(start, count) { + let workspaceManager = global.workspace_manager; + + for (let k = start; k < start + count; k++) { + let metaWorkspace = workspaceManager.get_workspace_by_index(k); + let thumbnail = new WorkspaceThumbnail(metaWorkspace); + thumbnail.setPorthole(this._porthole.x, this._porthole.y, + this._porthole.width, this._porthole.height); + this._thumbnails.push(thumbnail); + this.add_actor(thumbnail); + + if (start > 0 && this._spliceIndex == -1) { + // not the initial fill, and not splicing via DND + thumbnail.state = ThumbnailState.NEW; + thumbnail.slide_position = 1; // start slid out + this._haveNewThumbnails = true; + } else { + thumbnail.state = ThumbnailState.NORMAL; + } + + this._stateCounts[thumbnail.state]++; + } + + this._queueUpdateStates(); + + // The thumbnails indicator actually needs to be on top of the thumbnails + this.set_child_above_sibling(this._indicator, null); + + // Clear the splice index, we got the message + this._spliceIndex = -1; + } + + removeThumbnails(start, count) { + let currentPos = 0; + for (let k = 0; k < this._thumbnails.length; k++) { + let thumbnail = this._thumbnails[k]; + + if (thumbnail.state > ThumbnailState.NORMAL) + continue; + + if (currentPos >= start && currentPos < start + count) { + thumbnail.workspaceRemoved(); + this._setThumbnailState(thumbnail, ThumbnailState.REMOVING); + } + + currentPos++; + } + + this._queueUpdateStates(); + } + + _syncStacking(overview, stackIndices) { + for (let i = 0; i < this._thumbnails.length; i++) + this._thumbnails[i].syncStacking(stackIndices); + } + + set scale(scale) { + if (this._scale == scale) + return; + + this._scale = scale; + this.notify('scale'); + this.queue_relayout(); + } + + get scale() { + return this._scale; + } + + _setThumbnailState(thumbnail, state) { + this._stateCounts[thumbnail.state]--; + thumbnail.state = state; + this._stateCounts[thumbnail.state]++; + } + + _iterateStateThumbnails(state, callback) { + if (this._stateCounts[state] == 0) + return; + + for (let i = 0; i < this._thumbnails.length; i++) { + if (this._thumbnails[i].state == state) + callback.call(this, this._thumbnails[i]); + } + } + + _updateStates() { + this._stateUpdateQueued = false; + + // If we are animating the indicator, wait + if (this._animatingIndicator) + return; + + // Then slide out any thumbnails that have been destroyed + this._iterateStateThumbnails(ThumbnailState.REMOVING, thumbnail => { + this._setThumbnailState(thumbnail, ThumbnailState.ANIMATING_OUT); + + thumbnail.ease_property('slide-position', 1, { + duration: SLIDE_ANIMATION_TIME, + mode: Clutter.AnimationMode.LINEAR, + onComplete: () => { + this._setThumbnailState(thumbnail, ThumbnailState.ANIMATED_OUT); + this._queueUpdateStates(); + }, + }); + }); + + // As long as things are sliding out, don't proceed + if (this._stateCounts[ThumbnailState.ANIMATING_OUT] > 0) + return; + + // Once that's complete, we can start scaling to the new size and collapse any removed thumbnails + this._iterateStateThumbnails(ThumbnailState.ANIMATED_OUT, thumbnail => { + this._setThumbnailState(thumbnail, ThumbnailState.COLLAPSING); + thumbnail.ease_property('collapse-fraction', 1, { + duration: RESCALE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._stateCounts[thumbnail.state]--; + thumbnail.state = ThumbnailState.DESTROYED; + + let index = this._thumbnails.indexOf(thumbnail); + this._thumbnails.splice(index, 1); + thumbnail.destroy(); + + this._queueUpdateStates(); + }, + }); + }); + + if (this._pendingScaleUpdate) { + this.ease_property('scale', this._targetScale, { + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: RESCALE_ANIMATION_TIME, + onComplete: () => this._queueUpdateStates(), + }); + this._pendingScaleUpdate = false; + } + + // Wait until that's done + if (this._scale != this._targetScale || this._stateCounts[ThumbnailState.COLLAPSING] > 0) + return; + + // And then slide in any new thumbnails + this._iterateStateThumbnails(ThumbnailState.NEW, thumbnail => { + this._setThumbnailState(thumbnail, ThumbnailState.ANIMATING_IN); + thumbnail.ease_property('slide-position', 0, { + duration: SLIDE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._setThumbnailState(thumbnail, ThumbnailState.NORMAL); + }, + }); + }); + } + + _queueUpdateStates() { + if (this._stateUpdateQueued) + return; + + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, + this._updateStates.bind(this)); + + this._stateUpdateQueued = true; + } + + vfunc_get_preferred_height(_forWidth) { + // Note that for getPreferredWidth/Height we cheat a bit and skip propagating + // the size request to our children because we know how big they are and know + // that the actors aren't depending on the virtual functions being called. + let workspaceManager = global.workspace_manager; + let themeNode = this.get_theme_node(); + + let spacing = themeNode.get_length('spacing'); + let nWorkspaces = workspaceManager.n_workspaces; + let totalSpacing = (nWorkspaces - 1) * spacing; + + let naturalHeight = totalSpacing + nWorkspaces * this._porthole.height * MAX_THUMBNAIL_SCALE; + + return themeNode.adjust_preferred_height(totalSpacing, naturalHeight); + } + + vfunc_get_preferred_width(forHeight) { + let workspaceManager = global.workspace_manager; + let themeNode = this.get_theme_node(); + + forHeight = themeNode.adjust_for_height(forHeight); + + let spacing = themeNode.get_length('spacing'); + let nWorkspaces = workspaceManager.n_workspaces; + let totalSpacing = (nWorkspaces - 1) * spacing; + + let avail = forHeight - totalSpacing; + + let scale = (avail / nWorkspaces) / this._porthole.height; + scale = Math.min(scale, MAX_THUMBNAIL_SCALE); + + let width = Math.round(this._porthole.width * scale); + + return themeNode.adjust_preferred_width(width, width); + } + + _updatePorthole() { + if (!Main.layoutManager.primaryMonitor) { + this._porthole = { width: global.stage.width, height: global.stage.height, + x: global.stage.x, y: global.stage.y }; + } else { + this._porthole = Main.layoutManager.getWorkAreaForMonitor(Main.layoutManager.primaryIndex); + } + + this.queue_relayout(); + } + + vfunc_allocate(box) { + this.set_allocation(box); + + let rtl = Clutter.get_default_text_direction() == Clutter.TextDirection.RTL; + + if (this._thumbnails.length == 0) // not visible + return; + + let workspaceManager = global.workspace_manager; + let themeNode = this.get_theme_node(); + + box = themeNode.get_content_box(box); + + let portholeWidth = this._porthole.width; + let portholeHeight = this._porthole.height; + let spacing = themeNode.get_length('spacing'); + + // Compute the scale we'll need once everything is updated + let nWorkspaces = workspaceManager.n_workspaces; + let totalSpacing = (nWorkspaces - 1) * spacing; + let avail = (box.y2 - box.y1) - totalSpacing; + + let newScale = (avail / nWorkspaces) / portholeHeight; + newScale = Math.min(newScale, MAX_THUMBNAIL_SCALE); + + if (newScale != this._targetScale) { + if (this._targetScale > 0) { + // We don't do the tween immediately because we need to observe the ordering + // in queueUpdateStates - if workspaces have been removed we need to slide them + // out as the first thing. + this._targetScale = newScale; + this._pendingScaleUpdate = true; + } else { + this._targetScale = this._scale = newScale; + } + + this._queueUpdateStates(); + } + + let thumbnailHeight = portholeHeight * this._scale; + let thumbnailWidth = Math.round(portholeWidth * this._scale); + let roundedHScale = thumbnailWidth / portholeWidth; + + let slideOffset; // X offset when thumbnail is fully slid offscreen + if (rtl) + slideOffset = -(thumbnailWidth + themeNode.get_padding(St.Side.LEFT)); + else + slideOffset = thumbnailWidth + themeNode.get_padding(St.Side.RIGHT); + + let indicatorValue = this._scrollAdjustment.value; + let indicatorUpperWs = Math.ceil(indicatorValue); + let indicatorLowerWs = Math.floor(indicatorValue); + + let indicatorLowerY1 = 0; + let indicatorLowerY2 = 0; + let indicatorUpperY1 = 0; + let indicatorUpperY2 = 0; + + let indicatorThemeNode = this._indicator.get_theme_node(); + let indicatorTopFullBorder = indicatorThemeNode.get_padding(St.Side.TOP) + indicatorThemeNode.get_border_width(St.Side.TOP); + let indicatorBottomFullBorder = indicatorThemeNode.get_padding(St.Side.BOTTOM) + indicatorThemeNode.get_border_width(St.Side.BOTTOM); + let indicatorLeftFullBorder = indicatorThemeNode.get_padding(St.Side.LEFT) + indicatorThemeNode.get_border_width(St.Side.LEFT); + let indicatorRightFullBorder = indicatorThemeNode.get_padding(St.Side.RIGHT) + indicatorThemeNode.get_border_width(St.Side.RIGHT); + + let y = box.y1; + + if (this._dropPlaceholderPos == -1) { + this._dropPlaceholder.allocate_preferred_size( + ...this._dropPlaceholder.get_position()); + + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + this._dropPlaceholder.hide(); + }); + } + + let childBox = new Clutter.ActorBox(); + + for (let i = 0; i < this._thumbnails.length; i++) { + let thumbnail = this._thumbnails[i]; + + if (i > 0) + y += spacing - Math.round(thumbnail.collapse_fraction * spacing); + + let x1, x2; + if (rtl) { + x1 = box.x1 + slideOffset * thumbnail.slide_position; + x2 = x1 + thumbnailWidth; + } else { + x1 = box.x2 - thumbnailWidth + slideOffset * thumbnail.slide_position; + x2 = x1 + thumbnailWidth; + } + + if (i == this._dropPlaceholderPos) { + let [, placeholderHeight] = this._dropPlaceholder.get_preferred_height(-1); + childBox.x1 = x1; + childBox.x2 = x2; + childBox.y1 = Math.round(y); + childBox.y2 = Math.round(y + placeholderHeight); + this._dropPlaceholder.allocate(childBox); + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + this._dropPlaceholder.show(); + }); + y += placeholderHeight + spacing; + } + + // We might end up with thumbnailHeight being something like 99.33 + // pixels. To make this work and not end up with a gap at the bottom, + // we need some thumbnails to be 99 pixels and some 100 pixels height; + // we compute an actual scale separately for each thumbnail. + let y1 = Math.round(y); + let y2 = Math.round(y + thumbnailHeight); + let roundedVScale = (y2 - y1) / portholeHeight; + + if (i === indicatorUpperWs) { + indicatorUpperY1 = y1; + indicatorUpperY2 = y2; + } + if (i === indicatorLowerWs) { + indicatorLowerY1 = y1; + indicatorLowerY2 = y2; + } + + // Allocating a scaled actor is funny - x1/y1 correspond to the origin + // of the actor, but x2/y2 are increased by the *unscaled* size. + childBox.x1 = x1; + childBox.x2 = x1 + portholeWidth; + childBox.y1 = y1; + childBox.y2 = y1 + portholeHeight; + + thumbnail.set_scale(roundedHScale, roundedVScale); + thumbnail.allocate(childBox); + + // We round the collapsing portion so that we don't get thumbnails resizing + // during an animation due to differences in rounded, but leave the uncollapsed + // portion unrounded so that non-animating we end up with the right total + y += thumbnailHeight - Math.round(thumbnailHeight * thumbnail.collapse_fraction); + } + + if (rtl) { + childBox.x1 = box.x1; + childBox.x2 = box.x1 + thumbnailWidth; + } else { + childBox.x1 = box.x2 - thumbnailWidth; + childBox.x2 = box.x2; + } + let indicatorY1 = indicatorLowerY1 + + (indicatorUpperY1 - indicatorLowerY1) * (indicatorValue % 1); + let indicatorY2 = indicatorLowerY2 + + (indicatorUpperY2 - indicatorLowerY2) * (indicatorValue % 1); + + childBox.x1 -= indicatorLeftFullBorder; + childBox.x2 += indicatorRightFullBorder; + childBox.y1 = indicatorY1 - indicatorTopFullBorder; + childBox.y2 = indicatorY2 + indicatorBottomFullBorder; + this._indicator.allocate(childBox); + } +}); diff --git a/js/ui/workspacesView.js b/js/ui/workspacesView.js new file mode 100644 index 0000000..eba43ad --- /dev/null +++ b/js/ui/workspacesView.js @@ -0,0 +1,805 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported WorkspacesView, WorkspacesDisplay */ + +const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; + +const Main = imports.ui.main; +const SwipeTracker = imports.ui.swipeTracker; +const Workspace = imports.ui.workspace; + +var { ANIMATION_TIME } = imports.ui.overview; +var WORKSPACE_SWITCH_TIME = 250; +var SCROLL_TIMEOUT_TIME = 150; + +var AnimationType = { + ZOOM: 0, + FADE: 1, +}; + +const MUTTER_SCHEMA = 'org.gnome.mutter'; + +var WorkspacesViewBase = GObject.registerClass({ + GTypeFlags: GObject.TypeFlags.ABSTRACT, +}, class WorkspacesViewBase extends St.Widget { + _init(monitorIndex) { + const { x, y, width, height } = + Main.layoutManager.getWorkAreaForMonitor(monitorIndex); + + super._init({ + style_class: 'workspaces-view', + x, y, width, height, + }); + this.connect('destroy', this._onDestroy.bind(this)); + global.focus_manager.add_group(this); + + this._monitorIndex = monitorIndex; + + this._inDrag = false; + this._windowDragBeginId = Main.overview.connect('window-drag-begin', this._dragBegin.bind(this)); + this._windowDragEndId = Main.overview.connect('window-drag-end', this._dragEnd.bind(this)); + } + + _onDestroy() { + this._dragEnd(); + + if (this._windowDragBeginId > 0) { + Main.overview.disconnect(this._windowDragBeginId); + this._windowDragBeginId = 0; + } + if (this._windowDragEndId > 0) { + Main.overview.disconnect(this._windowDragEndId); + this._windowDragEndId = 0; + } + } + + _dragBegin() { + this._inDrag = true; + } + + _dragEnd() { + this._inDrag = false; + } + + vfunc_allocate(box) { + this.set_allocation(box); + + for (const child of this) + child.allocate_available_size(0, 0, box.get_width(), box.get_height()); + } +}); + +var WorkspacesView = GObject.registerClass( +class WorkspacesView extends WorkspacesViewBase { + _init(monitorIndex, scrollAdjustment) { + let workspaceManager = global.workspace_manager; + + super._init(monitorIndex); + this.clip_to_allocation = true; + + this._animating = false; // tweening + this._gestureActive = false; // touch(pad) gestures + + this._scrollAdjustment = scrollAdjustment; + this._onScrollId = this._scrollAdjustment.connect('notify::value', + this._onScrollAdjustmentChanged.bind(this)); + + this._workspaces = []; + this._updateWorkspaces(); + this._updateWorkspacesId = + workspaceManager.connect('notify::n-workspaces', + this._updateWorkspaces.bind(this)); + this._reorderWorkspacesId = + workspaceManager.connect('workspaces-reordered', () => { + this._workspaces.sort((a, b) => { + return a.metaWorkspace.index() - b.metaWorkspace.index(); + }); + this._workspaces.forEach( + (ws, i) => this.set_child_at_index(ws, i)); + }); + + this._switchWorkspaceNotifyId = + global.window_manager.connect('switch-workspace', + this._activeWorkspaceChanged.bind(this)); + } + + vfunc_allocate(box) { + this.set_allocation(box); + + if (this.get_n_children() === 0) + return; + + const { workspaceManager } = global; + const { nWorkspaces } = workspaceManager; + + const vertical = workspaceManager.layout_rows === -1; + const rtl = this.text_direction === Clutter.TextDirection.RTL; + + this._workspaces.forEach((child, index) => { + if (rtl && !vertical) + index = nWorkspaces - index - 1; + + const x = vertical ? 0 : index * this.width; + const y = vertical ? index * this.height : 0; + + child.allocate_available_size(x, y, box.get_width(), box.get_height()); + }); + + this._updateScrollPosition(); + } + + getActiveWorkspace() { + let workspaceManager = global.workspace_manager; + let active = workspaceManager.get_active_workspace_index(); + return this._workspaces[active]; + } + + animateToOverview(animationType) { + for (let w = 0; w < this._workspaces.length; w++) { + if (animationType == AnimationType.ZOOM) + this._workspaces[w].zoomToOverview(); + else + this._workspaces[w].fadeToOverview(); + } + this._updateScrollPosition(); + this._updateVisibility(); + } + + animateFromOverview(animationType) { + for (let w = 0; w < this._workspaces.length; w++) { + if (animationType == AnimationType.ZOOM) + this._workspaces[w].zoomFromOverview(); + else + this._workspaces[w].fadeFromOverview(); + } + } + + syncStacking(stackIndices) { + for (let i = 0; i < this._workspaces.length; i++) + this._workspaces[i].syncStacking(stackIndices); + } + + _scrollToActive() { + const { workspaceManager } = global; + const active = workspaceManager.get_active_workspace_index(); + + this._animating = true; + this._updateVisibility(); + + this._scrollAdjustment.remove_transition('value'); + this._scrollAdjustment.ease(active, { + duration: WORKSPACE_SWITCH_TIME, + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + onComplete: () => { + this._animating = false; + this._updateVisibility(); + }, + }); + } + + _updateVisibility() { + let workspaceManager = global.workspace_manager; + let active = workspaceManager.get_active_workspace_index(); + + for (let w = 0; w < this._workspaces.length; w++) { + let workspace = this._workspaces[w]; + + if (this._animating || this._gestureActive) + workspace.show(); + else if (this._inDrag) + workspace.visible = Math.abs(w - active) <= 1; + else + workspace.visible = w == active; + } + } + + _updateWorkspaces() { + let workspaceManager = global.workspace_manager; + let newNumWorkspaces = workspaceManager.n_workspaces; + + for (let j = 0; j < newNumWorkspaces; j++) { + let metaWorkspace = workspaceManager.get_workspace_by_index(j); + let workspace; + + if (j >= this._workspaces.length) { /* added */ + workspace = new Workspace.Workspace(metaWorkspace, this._monitorIndex); + this.add_actor(workspace); + this._workspaces[j] = workspace; + } else { + workspace = this._workspaces[j]; + + if (workspace.metaWorkspace != metaWorkspace) { /* removed */ + workspace.destroy(); + this._workspaces.splice(j, 1); + } /* else kept */ + } + } + + this._updateScrollPosition(); + } + + _activeWorkspaceChanged(_wm, _from, _to, _direction) { + if (this._scrolling) + return; + + this._scrollToActive(); + } + + _onDestroy() { + super._onDestroy(); + + this._scrollAdjustment.disconnect(this._onScrollId); + global.window_manager.disconnect(this._switchWorkspaceNotifyId); + let workspaceManager = global.workspace_manager; + workspaceManager.disconnect(this._updateWorkspacesId); + workspaceManager.disconnect(this._reorderWorkspacesId); + } + + startTouchGesture() { + this._gestureActive = true; + + this._updateVisibility(); + } + + endTouchGesture() { + this._gestureActive = false; + + // Make sure title captions etc are shown as necessary + this._scrollToActive(); + this._updateVisibility(); + } + + // sync the workspaces' positions to the value of the scroll adjustment + // and change the active workspace if appropriate + _onScrollAdjustmentChanged() { + if (!this.has_allocation()) + return; + + const adj = this._scrollAdjustment; + const allowSwitch = + adj.get_transition('value') === null && !this._gestureActive; + + let workspaceManager = global.workspace_manager; + let active = workspaceManager.get_active_workspace_index(); + let current = Math.round(adj.value); + + if (allowSwitch && active !== current) { + if (!this._workspaces[current]) { + // The current workspace was destroyed. This could happen + // when you are on the last empty workspace, and consolidate + // windows using the thumbnail bar. + // In that case, the intended behavior is to stay on the empty + // workspace, which is the last one, so pick it. + current = this._workspaces.length - 1; + } + + let metaWorkspace = this._workspaces[current].metaWorkspace; + metaWorkspace.activate(global.get_current_time()); + } + + this._updateScrollPosition(); + } + + _updateScrollPosition() { + if (!this.has_allocation()) + return; + + const adj = this._scrollAdjustment; + + if (adj.upper == 1) + return; + + const workspaceManager = global.workspace_manager; + const vertical = workspaceManager.layout_rows === -1; + const rtl = this.text_direction === Clutter.TextDirection.RTL; + const progress = vertical || !rtl + ? adj.value : adj.upper - adj.value - 1; + + for (const ws of this._workspaces) { + if (vertical) + ws.translation_y = -progress * this.height; + else + ws.translation_x = -progress * this.width; + } + } +}); + +var ExtraWorkspaceView = GObject.registerClass( +class ExtraWorkspaceView extends WorkspacesViewBase { + _init(monitorIndex) { + super._init(monitorIndex); + this._workspace = new Workspace.Workspace(null, monitorIndex); + this.add_actor(this._workspace); + } + + getActiveWorkspace() { + return this._workspace; + } + + animateToOverview(animationType) { + if (animationType == AnimationType.ZOOM) + this._workspace.zoomToOverview(); + else + this._workspace.fadeToOverview(); + } + + animateFromOverview(animationType) { + if (animationType == AnimationType.ZOOM) + this._workspace.zoomFromOverview(); + else + this._workspace.fadeFromOverview(); + } + + syncStacking(stackIndices) { + this._workspace.syncStacking(stackIndices); + } + + startTouchGesture() { + } + + endTouchGesture() { + } +}); + +var WorkspacesDisplay = GObject.registerClass( +class WorkspacesDisplay extends St.Widget { + _init(scrollAdjustment) { + super._init({ + visible: false, + clip_to_allocation: true, + }); + this.connect('notify::allocation', this._updateWorkspacesActualGeometry.bind(this)); + + Main.overview.connect('relayout', + () => this._updateWorkspacesActualGeometry()); + + let workspaceManager = global.workspace_manager; + this._scrollAdjustment = scrollAdjustment; + + this._switchWorkspaceId = + global.window_manager.connect('switch-workspace', + this._activeWorkspaceChanged.bind(this)); + + this._reorderWorkspacesdId = + workspaceManager.connect('workspaces-reordered', + this._workspacesReordered.bind(this)); + + let clickAction = new Clutter.ClickAction(); + clickAction.connect('clicked', action => { + // Only switch to the workspace when there's no application + // windows open. The problem is that it's too easy to miss + // an app window and get the wrong one focused. + let event = Clutter.get_current_event(); + let index = this._getMonitorIndexForEvent(event); + if ((action.get_button() == 1 || action.get_button() == 0) && + this._workspacesViews[index].getActiveWorkspace().isEmpty()) + Main.overview.hide(); + }); + Main.overview.addAction(clickAction); + this.bind_property('mapped', clickAction, 'enabled', GObject.BindingFlags.SYNC_CREATE); + this._clickAction = clickAction; + + this._swipeTracker = new SwipeTracker.SwipeTracker( + Main.layoutManager.overviewGroup, Shell.ActionMode.OVERVIEW); + this._swipeTracker.connect('begin', this._switchWorkspaceBegin.bind(this)); + this._swipeTracker.connect('update', this._switchWorkspaceUpdate.bind(this)); + this._swipeTracker.connect('end', this._switchWorkspaceEnd.bind(this)); + this.connect('notify::mapped', this._updateSwipeTracker.bind(this)); + + this._windowDragBeginId = + Main.overview.connect('window-drag-begin', + this._windowDragBegin.bind(this)); + this._windowDragEndId = + Main.overview.connect('window-drag-end', + this._windowDragEnd.bind(this)); + this._overviewShownId = Main.overview.connect('shown', () => { + this._inWindowFade = false; + this._syncWorkspacesActualGeometry(); + }); + + this._primaryIndex = Main.layoutManager.primaryIndex; + this._workspacesViews = []; + + this._settings = new Gio.Settings({ schema_id: MUTTER_SCHEMA }); + this._settings.connect('changed::workspaces-only-on-primary', + this._workspacesOnlyOnPrimaryChanged.bind(this)); + this._workspacesOnlyOnPrimaryChanged(); + + this._notifyOpacityId = 0; + this._restackedNotifyId = 0; + this._scrollEventId = 0; + this._keyPressEventId = 0; + this._scrollTimeoutId = 0; + this._syncActualGeometryLater = 0; + + this._actualGeometry = null; + this._inWindowDrag = false; + this._inWindowFade = false; + + this._gestureActive = false; // touch(pad) gestures + this._canScroll = true; // limiting scrolling speed + + this.connect('destroy', this._onDestroy.bind(this)); + } + + _onDestroy() { + if (this._notifyOpacityId) { + let parent = this.get_parent(); + if (parent) + parent.disconnect(this._notifyOpacityId); + this._notifyOpacityId = 0; + } + + if (this._parentSetLater) { + Meta.later_remove(this._parentSetLater); + this._parentSetLater = 0; + } + + if (this._syncActualGeometryLater) { + Meta.later_remove(this._syncActualGeometryLater); + this._syncActualGeometryLater = 0; + } + + if (this._scrollTimeoutId !== 0) { + GLib.source_remove(this._scrollTimeoutId); + this._scrollTimeoutId = 0; + } + + global.window_manager.disconnect(this._switchWorkspaceId); + global.workspace_manager.disconnect(this._reorderWorkspacesdId); + Main.overview.disconnect(this._windowDragBeginId); + Main.overview.disconnect(this._windowDragEndId); + Main.overview.disconnect(this._overviewShownId); + } + + _windowDragBegin() { + this._inWindowDrag = true; + this._updateSwipeTracker(); + } + + _windowDragEnd() { + this._inWindowDrag = false; + this._updateSwipeTracker(); + } + + _updateSwipeTracker() { + this._swipeTracker.enabled = this.mapped && !this._inWindowDrag; + } + + _workspacesReordered() { + let workspaceManager = global.workspace_manager; + + this._scrollAdjustment.value = + workspaceManager.get_active_workspace_index(); + } + + _activeWorkspaceChanged(_wm, _from, to, _direction) { + if (this._gestureActive) + return; + + this._scrollAdjustment.ease(to, { + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + duration: WORKSPACE_SWITCH_TIME, + }); + } + + _directionForProgress(progress) { + if (global.workspace_manager.layout_rows === -1) { + return progress > 0 + ? Meta.MotionDirection.DOWN + : Meta.MotionDirection.UP; + } else if (this.text_direction === Clutter.TextDirection.RTL) { + return progress > 0 + ? Meta.MotionDirection.LEFT + : Meta.MotionDirection.RIGHT; + } else { + return progress > 0 + ? Meta.MotionDirection.RIGHT + : Meta.MotionDirection.LEFT; + } + } + + _switchWorkspaceBegin(tracker, monitor) { + if (this._workspacesOnlyOnPrimary && monitor !== this._primaryIndex) + return; + + let workspaceManager = global.workspace_manager; + let adjustment = this._scrollAdjustment; + if (this._gestureActive) + adjustment.remove_transition('value'); + + tracker.orientation = workspaceManager.layout_rows !== -1 + ? Clutter.Orientation.HORIZONTAL + : Clutter.Orientation.VERTICAL; + + for (let i = 0; i < this._workspacesViews.length; i++) + this._workspacesViews[i].startTouchGesture(); + + let distance = global.workspace_manager.layout_rows === -1 + ? this.height : this.width; + + let progress = adjustment.value / adjustment.page_size; + let points = Array.from( + { length: workspaceManager.n_workspaces }, (v, i) => i); + + tracker.confirmSwipe(distance, points, progress, Math.round(progress)); + + this._gestureActive = true; + } + + _switchWorkspaceUpdate(tracker, progress) { + let adjustment = this._scrollAdjustment; + adjustment.value = progress * adjustment.page_size; + } + + _switchWorkspaceEnd(tracker, duration, endProgress) { + this._clickAction.release(); + + let workspaceManager = global.workspace_manager; + let newWs = workspaceManager.get_workspace_by_index(endProgress); + + this._scrollAdjustment.ease(endProgress, { + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + duration, + onComplete: () => { + if (!newWs.active) + newWs.activate(global.get_current_time()); + this._endTouchGesture(); + }, + }); + } + + _endTouchGesture() { + for (let i = 0; i < this._workspacesViews.length; i++) + this._workspacesViews[i].endTouchGesture(); + this._gestureActive = false; + } + + vfunc_navigate_focus(from, direction) { + return this._getPrimaryView().navigate_focus(from, direction, false); + } + + animateToOverview(fadeOnPrimary) { + this.show(); + this._updateWorkspacesViews(); + + for (let i = 0; i < this._workspacesViews.length; i++) { + let animationType; + if (fadeOnPrimary && i == this._primaryIndex) + animationType = AnimationType.FADE; + else + animationType = AnimationType.ZOOM; + this._workspacesViews[i].animateToOverview(animationType); + } + + this._inWindowFade = fadeOnPrimary; + + if (this._actualGeometry && !fadeOnPrimary) + this._syncWorkspacesActualGeometry(); + + this._restackedNotifyId = + Main.overview.connect('windows-restacked', + this._onRestacked.bind(this)); + if (this._scrollEventId == 0) + this._scrollEventId = Main.overview.connect('scroll-event', this._onScrollEvent.bind(this)); + + if (this._keyPressEventId == 0) + this._keyPressEventId = global.stage.connect('key-press-event', this._onKeyPressEvent.bind(this)); + } + + animateFromOverview(fadeOnPrimary) { + for (let i = 0; i < this._workspacesViews.length; i++) { + let animationType; + if (fadeOnPrimary && i == this._primaryIndex) + animationType = AnimationType.FADE; + else + animationType = AnimationType.ZOOM; + this._workspacesViews[i].animateFromOverview(animationType); + } + + this._inWindowFade = fadeOnPrimary; + + const { primaryIndex } = Main.layoutManager; + const { x, y, width, height } = + Main.layoutManager.getWorkAreaForMonitor(primaryIndex); + this._getPrimaryView().ease({ + x, y, width, height, + duration: fadeOnPrimary ? 0 : ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + vfunc_hide() { + if (this._restackedNotifyId > 0) { + Main.overview.disconnect(this._restackedNotifyId); + this._restackedNotifyId = 0; + } + if (this._scrollEventId > 0) { + Main.overview.disconnect(this._scrollEventId); + this._scrollEventId = 0; + } + if (this._keyPressEventId > 0) { + global.stage.disconnect(this._keyPressEventId); + this._keyPressEventId = 0; + } + for (let i = 0; i < this._workspacesViews.length; i++) + this._workspacesViews[i].destroy(); + this._workspacesViews = []; + + super.vfunc_hide(); + } + + _workspacesOnlyOnPrimaryChanged() { + this._workspacesOnlyOnPrimary = this._settings.get_boolean('workspaces-only-on-primary'); + + if (!Main.overview.visible) + return; + + this._updateWorkspacesViews(); + this._syncWorkspacesActualGeometry(); + } + + _updateWorkspacesViews() { + for (let i = 0; i < this._workspacesViews.length; i++) + this._workspacesViews[i].destroy(); + + this._primaryIndex = Main.layoutManager.primaryIndex; + this._workspacesViews = []; + let monitors = Main.layoutManager.monitors; + for (let i = 0; i < monitors.length; i++) { + let view; + if (this._workspacesOnlyOnPrimary && i != this._primaryIndex) + view = new ExtraWorkspaceView(i); + else + view = new WorkspacesView(i, this._scrollAdjustment); + + this._workspacesViews.push(view); + Main.layoutManager.overviewGroup.add_actor(view); + } + } + + _getMonitorIndexForEvent(event) { + let [x, y] = event.get_coords(); + let rect = new Meta.Rectangle({ x, y, width: 1, height: 1 }); + return global.display.get_monitor_index_for_rect(rect); + } + + _getPrimaryView() { + if (!this._workspacesViews.length) + return null; + return this._workspacesViews[this._primaryIndex]; + } + + activeWorkspaceHasMaximizedWindows() { + return this._getPrimaryView().getActiveWorkspace().hasMaximizedWindows(); + } + + vfunc_parent_set(oldParent) { + if (oldParent && this._notifyOpacityId) + oldParent.disconnect(this._notifyOpacityId); + this._notifyOpacityId = 0; + + if (this._parentSetLater) + return; + + this._parentSetLater = Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + this._parentSetLater = 0; + let newParent = this.get_parent(); + if (!newParent) + return; + + // This is kinda hackish - we want the primary view to + // appear as parent of this, though in reality it + // is added directly to Main.layoutManager.overviewGroup + this._notifyOpacityId = newParent.connect('notify::opacity', () => { + let opacity = this.get_parent().opacity; + let primaryView = this._getPrimaryView(); + if (!primaryView) + return; + primaryView.opacity = opacity; + primaryView.visible = opacity != 0; + }); + }); + } + + _updateWorkspacesActualGeometry() { + const [x, y] = this.get_transformed_position(); + const width = this.allocation.get_width(); + const height = this.allocation.get_height(); + + this._actualGeometry = { x, y, width, height }; + + if (this._syncActualGeometryLater > 0) + return; + + this._syncActualGeometryLater = + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + this._syncWorkspacesActualGeometry(); + + this._syncActualGeometryLater = 0; + return GLib.SOURCE_REMOVE; + }); + } + + _syncWorkspacesActualGeometry() { + const primaryView = this._getPrimaryView(); + if (!primaryView || this._inWindowFade) + return; + + primaryView.ease({ + ...this._actualGeometry, + duration: Main.overview.animationInProgress ? ANIMATION_TIME : 0, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + _onRestacked(overview, stackIndices) { + for (let i = 0; i < this._workspacesViews.length; i++) + this._workspacesViews[i].syncStacking(stackIndices); + } + + _onScrollEvent(actor, event) { + if (this._swipeTracker.canHandleScrollEvent(event)) + return Clutter.EVENT_PROPAGATE; + + if (!this.mapped) + return Clutter.EVENT_PROPAGATE; + + if (this._workspacesOnlyOnPrimary && + this._getMonitorIndexForEvent(event) != this._primaryIndex) + return Clutter.EVENT_PROPAGATE; + + if (!this._canScroll) + return Clutter.EVENT_PROPAGATE; + + let workspaceManager = global.workspace_manager; + let activeWs = workspaceManager.get_active_workspace(); + let ws; + switch (event.get_scroll_direction()) { + case Clutter.ScrollDirection.UP: + ws = activeWs.get_neighbor(Meta.MotionDirection.UP); + break; + case Clutter.ScrollDirection.DOWN: + ws = activeWs.get_neighbor(Meta.MotionDirection.DOWN); + break; + case Clutter.ScrollDirection.LEFT: + ws = activeWs.get_neighbor(Meta.MotionDirection.LEFT); + break; + case Clutter.ScrollDirection.RIGHT: + ws = activeWs.get_neighbor(Meta.MotionDirection.RIGHT); + break; + default: + return Clutter.EVENT_PROPAGATE; + } + Main.wm.actionMoveWorkspace(ws); + + this._canScroll = false; + this._scrollTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, + SCROLL_TIMEOUT_TIME, () => { + this._canScroll = true; + this._scrollTimeoutId = 0; + return GLib.SOURCE_REMOVE; + }); + + return Clutter.EVENT_STOP; + } + + _onKeyPressEvent(actor, event) { + if (!this.mapped) + return Clutter.EVENT_PROPAGATE; + let workspaceManager = global.workspace_manager; + let activeWs = workspaceManager.get_active_workspace(); + let ws; + switch (event.get_key_symbol()) { + case Clutter.KEY_Page_Up: + ws = activeWs.get_neighbor(Meta.MotionDirection.UP); + break; + case Clutter.KEY_Page_Down: + ws = activeWs.get_neighbor(Meta.MotionDirection.DOWN); + break; + default: + return Clutter.EVENT_PROPAGATE; + } + Main.wm.actionMoveWorkspace(ws); + return Clutter.EVENT_STOP; + } +}); diff --git a/js/ui/xdndHandler.js b/js/ui/xdndHandler.js new file mode 100644 index 0000000..13d012d --- /dev/null +++ b/js/ui/xdndHandler.js @@ -0,0 +1,118 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +const { Clutter } = imports.gi; +const Signals = imports.signals; + +const DND = imports.ui.dnd; +const Main = imports.ui.main; + +var XdndHandler = class { + constructor() { + // Used to display a clone of the cursor window when the + // window group is hidden (like it happens in the overview) + this._cursorWindowClone = null; + + // Used as a drag actor in case we don't have a cursor window clone + this._dummy = new Clutter.Actor({ width: 1, height: 1, opacity: 0 }); + Main.uiGroup.add_actor(this._dummy); + this._dummy.hide(); + + var dnd = global.backend.get_dnd(); + dnd.connect('dnd-enter', this._onEnter.bind(this)); + dnd.connect('dnd-position-change', this._onPositionChanged.bind(this)); + dnd.connect('dnd-leave', this._onLeave.bind(this)); + + this._windowGroupVisibilityHandlerId = 0; + } + + // Called when the user cancels the drag (i.e release the button) + _onLeave() { + if (this._windowGroupVisibilityHandlerId != 0) { + global.window_group.disconnect(this._windowGroupVisibilityHandlerId); + this._windowGroupVisibilityHandlerId = 0; + } + if (this._cursorWindowClone) { + this._cursorWindowClone.destroy(); + this._cursorWindowClone = null; + } + + this.emit('drag-end'); + } + + _onEnter() { + this._windowGroupVisibilityHandlerId = + global.window_group.connect('notify::visible', + this._onWindowGroupVisibilityChanged.bind(this)); + + this.emit('drag-begin', global.get_current_time()); + } + + _onWindowGroupVisibilityChanged() { + if (!global.window_group.visible) { + if (this._cursorWindowClone) + return; + + let windows = global.get_window_actors(); + let cursorWindow = windows[windows.length - 1]; + + // FIXME: more reliable way? + if (!cursorWindow.get_meta_window().is_override_redirect()) + return; + + let constraintPosition = new Clutter.BindConstraint({ coordinate: Clutter.BindCoordinate.POSITION, + source: cursorWindow }); + + this._cursorWindowClone = new Clutter.Clone({ source: cursorWindow }); + Main.uiGroup.add_actor(this._cursorWindowClone); + + // Make sure that the clone has the same position as the source + this._cursorWindowClone.add_constraint(constraintPosition); + } else { + if (!this._cursorWindowClone) + return; + + this._cursorWindowClone.destroy(); + this._cursorWindowClone = null; + } + } + + _onPositionChanged(obj, x, y) { + let pickedActor = global.stage.get_actor_at_pos(Clutter.PickMode.REACTIVE, x, y); + + // Make sure that the cursor window is on top + if (this._cursorWindowClone) + Main.uiGroup.set_child_above_sibling(this._cursorWindowClone, null); + + let dragEvent = { + x, + y, + dragActor: this._cursorWindowClone ? this._cursorWindowClone : this._dummy, + source: this, + targetActor: pickedActor, + }; + + for (let i = 0; i < DND.dragMonitors.length; i++) { + let motionFunc = DND.dragMonitors[i].dragMotion; + if (motionFunc) { + let result = motionFunc(dragEvent); + if (result != DND.DragMotionResult.CONTINUE) + return; + } + } + + while (pickedActor) { + if (pickedActor._delegate && pickedActor._delegate.handleDragOver) { + let [r_, targX, targY] = pickedActor.transform_stage_point(x, y); + let result = pickedActor._delegate.handleDragOver(this, + dragEvent.dragActor, + targX, + targY, + global.get_current_time()); + if (result != DND.DragMotionResult.CONTINUE) + return; + } + pickedActor = pickedActor.get_parent(); + } + } +}; +Signals.addSignalMethods(XdndHandler.prototype); |