diff options
Diffstat (limited to 'js/ui')
107 files changed, 62809 insertions, 0 deletions
diff --git a/js/ui/accessDialog.js b/js/ui/accessDialog.js new file mode 100644 index 0000000..8788e47 --- /dev/null +++ b/js/ui/accessDialog.js @@ -0,0 +1,160 @@ +/* exported AccessDialogDBus */ +const { Clutter, Gio, GLib, GObject, Pango, Shell, St } = imports.gi; + +const CheckBox = imports.ui.checkBox; +const Dialog = imports.ui.dialog; +const ModalDialog = imports.ui.modalDialog; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const RequestIface = loadInterfaceXML('org.freedesktop.impl.portal.Request'); +const AccessIface = loadInterfaceXML('org.freedesktop.impl.portal.Access'); + +var DialogResponse = { + OK: 0, + CANCEL: 1, + CLOSED: 2, +}; + +var AccessDialog = GObject.registerClass( +class AccessDialog extends ModalDialog.ModalDialog { + _init(invocation, handle, title, description, body, options) { + super._init({ styleClass: 'access-dialog' }); + + this._invocation = invocation; + this._handle = handle; + + this._requestExported = false; + this._request = Gio.DBusExportedObject.wrapJSObject(RequestIface, this); + + for (let option in options) + options[option] = options[option].deepUnpack(); + + this._buildLayout(title, description, body, options); + } + + _buildLayout(title, description, body, options) { + // No support for non-modal system dialogs, so ignore the option + // let modal = options['modal'] || true; + let denyLabel = options['deny_label'] || _("Deny Access"); + let grantLabel = options['grant_label'] || _("Grant Access"); + let choices = options['choices'] || []; + + let content = new Dialog.MessageDialogContent({ title, description }); + this.contentLayout.add_actor(content); + + this._choices = new Map(); + + for (let i = 0; i < choices.length; i++) { + let [id, name, opts, selected] = choices[i]; + if (opts.length > 0) + continue; // radio buttons, not implemented + + let check = new CheckBox.CheckBox(); + check.getLabelActor().text = name; + check.checked = selected == "true"; + content.add_child(check); + + this._choices.set(id, check); + } + + let bodyLabel = new St.Label({ + text: body, + x_align: Clutter.ActorAlign.CENTER, + }); + bodyLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + bodyLabel.clutter_text.line_wrap = true; + content.add_child(bodyLabel); + + this.addButton({ + label: denyLabel, + action: () => this._sendResponse(DialogResponse.CANCEL), + key: Clutter.KEY_Escape, + }); + this.addButton({ + label: grantLabel, + action: () => this._sendResponse(DialogResponse.OK), + }); + } + + open() { + if (!super.open()) + return false; + + let connection = this._invocation.get_connection(); + this._requestExported = this._request.export(connection, this._handle); + return true; + } + + CloseAsync(invocation, _params) { + if (this._invocation.get_sender() != invocation.get_sender()) { + invocation.return_error_literal(Gio.DBusError, + Gio.DBusError.ACCESS_DENIED, + ''); + return; + } + + this._sendResponse(DialogResponse.CLOSED); + } + + _sendResponse(response) { + if (this._requestExported) + this._request.unexport(); + this._requestExported = false; + + let results = {}; + if (response == DialogResponse.OK) { + for (let [id, check] of this._choices) { + let checked = check.checked ? 'true' : 'false'; + results[id] = new GLib.Variant('s', checked); + } + } + + // Delay actual response until the end of the close animation (if any) + this.connect('closed', () => { + this._invocation.return_value(new GLib.Variant('(ua{sv})', + [response, results])); + }); + this.close(); + } +}); + +var AccessDialogDBus = class { + constructor() { + this._accessDialog = null; + + this._windowTracker = Shell.WindowTracker.get_default(); + + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(AccessIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/freedesktop/portal/desktop'); + + Gio.DBus.session.own_name('org.gnome.Shell.Portal', Gio.BusNameOwnerFlags.REPLACE, null, null); + } + + AccessDialogAsync(params, invocation) { + if (this._accessDialog) { + invocation.return_error_literal(Gio.DBusError, + Gio.DBusError.LIMITS_EXCEEDED, + 'Already showing a system access dialog'); + return; + } + + let [handle, appId, parentWindow_, title, description, body, options] = params; + // We probably want to use parentWindow and global.display.focus_window + // for this check in the future + if (appId && `${appId}.desktop` !== this._windowTracker.focus_app.id) { + invocation.return_error_literal(Gio.DBusError, + Gio.DBusError.ACCESS_DENIED, + 'Only the focused app is allowed to show a system access dialog'); + return; + } + + let dialog = new AccessDialog( + invocation, handle, title, description, body, options); + dialog.open(); + + dialog.connect('closed', () => (this._accessDialog = null)); + + this._accessDialog = dialog; + } +}; diff --git a/js/ui/altTab.js b/js/ui/altTab.js new file mode 100644 index 0000000..a3daebc --- /dev/null +++ b/js/ui/altTab.js @@ -0,0 +1,1134 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported AppSwitcherPopup, GroupCyclerPopup, WindowSwitcherPopup, + WindowCyclerPopup */ + +const { Atk, Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; + +const Main = imports.ui.main; +const SwitcherPopup = imports.ui.switcherPopup; + +var APP_ICON_HOVER_TIMEOUT = 200; // milliseconds + +var THUMBNAIL_DEFAULT_SIZE = 256; +var THUMBNAIL_POPUP_TIME = 500; // milliseconds +var THUMBNAIL_FADE_TIME = 100; // milliseconds + +var WINDOW_PREVIEW_SIZE = 128; +var APP_ICON_SIZE = 96; +var APP_ICON_SIZE_SMALL = 48; + +const baseIconSizes = [96, 64, 48, 32, 22]; + +var AppIconMode = { + THUMBNAIL_ONLY: 1, + APP_ICON_ONLY: 2, + BOTH: 3, +}; + +function _createWindowClone(window, size) { + let [width, height] = window.get_size(); + let scale = Math.min(1.0, size / width, size / height); + return new Clutter.Clone({ + source: window, + width: width * scale, + height: height * scale, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + // usual hack for the usual bug in ClutterBinLayout... + x_expand: true, + y_expand: true, + }); +} + +function getWindows(workspace) { + // We ignore skip-taskbar windows in switchers, but if they are attached + // to their parent, their position in the MRU list may be more appropriate + // than the parent; so start with the complete list ... + let windows = global.display.get_tab_list(Meta.TabList.NORMAL_ALL, + workspace); + // ... map windows to their parent where appropriate ... + return windows.map(w => { + return w.is_attached_dialog() ? w.get_transient_for() : w; + // ... and filter out skip-taskbar windows and duplicates + }).filter((w, i, a) => !w.skip_taskbar && a.indexOf(w) == i); +} + +var AppSwitcherPopup = GObject.registerClass( +class AppSwitcherPopup extends SwitcherPopup.SwitcherPopup { + _init() { + super._init(); + + this._thumbnails = null; + this._thumbnailTimeoutId = 0; + this._currentWindow = -1; + + this.thumbnailsVisible = false; + + let apps = Shell.AppSystem.get_default().get_running(); + + this._switcherList = new AppSwitcher(apps, this); + this._items = this._switcherList.icons; + } + + vfunc_allocate(box) { + super.vfunc_allocate(box); + + // Allocate the thumbnails + // We try to avoid overflowing the screen so we base the resulting size on + // those calculations + if (this._thumbnails) { + let childBox = this._switcherList.get_allocation_box(); + let primary = Main.layoutManager.primaryMonitor; + + let leftPadding = this.get_theme_node().get_padding(St.Side.LEFT); + let rightPadding = this.get_theme_node().get_padding(St.Side.RIGHT); + let bottomPadding = this.get_theme_node().get_padding(St.Side.BOTTOM); + let hPadding = leftPadding + rightPadding; + + let icon = this._items[this._selectedIndex]; + let [posX] = icon.get_transformed_position(); + let thumbnailCenter = posX + icon.width / 2; + let [, childNaturalWidth] = this._thumbnails.get_preferred_width(-1); + childBox.x1 = Math.max(primary.x + leftPadding, Math.floor(thumbnailCenter - childNaturalWidth / 2)); + if (childBox.x1 + childNaturalWidth > primary.x + primary.width - hPadding) { + let offset = childBox.x1 + childNaturalWidth - primary.width + hPadding; + childBox.x1 = Math.max(primary.x + leftPadding, childBox.x1 - offset - hPadding); + } + + let spacing = this.get_theme_node().get_length('spacing'); + + childBox.x2 = childBox.x1 + childNaturalWidth; + if (childBox.x2 > primary.x + primary.width - rightPadding) + childBox.x2 = primary.x + primary.width - rightPadding; + childBox.y1 = this._switcherList.allocation.y2 + spacing; + this._thumbnails.addClones(primary.y + primary.height - bottomPadding - childBox.y1); + let [, childNaturalHeight] = this._thumbnails.get_preferred_height(-1); + childBox.y2 = childBox.y1 + childNaturalHeight; + this._thumbnails.allocate(childBox); + } + } + + _initialSelection(backward, binding) { + if (binding == 'switch-group') { + if (backward) + this._select(0, this._items[0].cachedWindows.length - 1); + else if (this._items[0].cachedWindows.length > 1) + this._select(0, 1); + else + this._select(0, 0); + } else if (binding == 'switch-group-backward') { + this._select(0, this._items[0].cachedWindows.length - 1); + } else if (binding == 'switch-applications-backward') { + this._select(this._items.length - 1); + } else if (this._items.length == 1) { + this._select(0); + } else if (backward) { + this._select(this._items.length - 1); + } else { + this._select(1); + } + } + + _nextWindow() { + // We actually want the second window if we're in the unset state + if (this._currentWindow == -1) + this._currentWindow = 0; + return SwitcherPopup.mod(this._currentWindow + 1, + this._items[this._selectedIndex].cachedWindows.length); + } + + _previousWindow() { + // Also assume second window here + if (this._currentWindow == -1) + this._currentWindow = 1; + return SwitcherPopup.mod(this._currentWindow - 1, + this._items[this._selectedIndex].cachedWindows.length); + } + + _closeAppWindow(appIndex, windowIndex) { + let appIcon = this._items[appIndex]; + if (!appIcon) + return; + + let window = appIcon.cachedWindows[windowIndex]; + if (!window) + return; + + window.delete(global.get_current_time()); + } + + _quitApplication(appIndex) { + let appIcon = this._items[appIndex]; + if (!appIcon) + return; + + appIcon.app.request_quit(); + } + + _keyPressHandler(keysym, action) { + const rtl = Clutter.get_default_text_direction() === Clutter.TextDirection.RTL; + if (action == Meta.KeyBindingAction.SWITCH_GROUP) { + if (!this._thumbnailsFocused) + this._select(this._selectedIndex, 0); + else + this._select(this._selectedIndex, this._nextWindow()); + } else if (action == Meta.KeyBindingAction.SWITCH_GROUP_BACKWARD) { + this._select(this._selectedIndex, this._previousWindow()); + } else if (action == Meta.KeyBindingAction.SWITCH_APPLICATIONS) { + this._select(this._next()); + } else if (action == Meta.KeyBindingAction.SWITCH_APPLICATIONS_BACKWARD) { + this._select(this._previous()); + } else if (keysym == Clutter.KEY_q || keysym === Clutter.KEY_Q) { + this._quitApplication(this._selectedIndex); + } else if (this._thumbnailsFocused) { + if (keysym === Clutter.KEY_Left) + this._select(this._selectedIndex, rtl ? this._nextWindow() : this._previousWindow()); + else if (keysym === Clutter.KEY_Right) + this._select(this._selectedIndex, rtl ? this._previousWindow() : this._nextWindow()); + else if (keysym === Clutter.KEY_Up) + this._select(this._selectedIndex, null, true); + else if (keysym === Clutter.KEY_w || keysym === Clutter.KEY_W || keysym === Clutter.KEY_F4) + this._closeAppWindow(this._selectedIndex, this._currentWindow); + else + return Clutter.EVENT_PROPAGATE; + } else if (keysym == Clutter.KEY_Left) { + this._select(rtl ? this._next() : this._previous()); + } else if (keysym == Clutter.KEY_Right) { + this._select(rtl ? this._previous() : this._next()); + } else if (keysym == Clutter.KEY_Down) { + this._select(this._selectedIndex, 0); + } else { + return Clutter.EVENT_PROPAGATE; + } + + return Clutter.EVENT_STOP; + } + + _scrollHandler(direction) { + if (direction == Clutter.ScrollDirection.UP) { + if (this._thumbnailsFocused) { + if (this._currentWindow == 0 || this._currentWindow == -1) + this._select(this._previous()); + else + this._select(this._selectedIndex, this._previousWindow()); + } else { + let nwindows = this._items[this._selectedIndex].cachedWindows.length; + if (nwindows > 1) + this._select(this._selectedIndex, nwindows - 1); + else + this._select(this._previous()); + } + } else if (direction == Clutter.ScrollDirection.DOWN) { + if (this._thumbnailsFocused) { + if (this._currentWindow == this._items[this._selectedIndex].cachedWindows.length - 1) + this._select(this._next()); + else + this._select(this._selectedIndex, this._nextWindow()); + } else { + let nwindows = this._items[this._selectedIndex].cachedWindows.length; + if (nwindows > 1) + this._select(this._selectedIndex, 0); + else + this._select(this._next()); + } + } + } + + _itemActivatedHandler(n) { + // If the user clicks on the selected app, activate the + // selected window; otherwise (eg, they click on an app while + // !mouseActive) activate the clicked-on app. + if (n == this._selectedIndex && this._currentWindow >= 0) + this._select(n, this._currentWindow); + else + this._select(n); + } + + _windowActivated(thumbnailSwitcher, n) { + let appIcon = this._items[this._selectedIndex]; + Main.activateWindow(appIcon.cachedWindows[n]); + this.fadeAndDestroy(); + } + + _windowEntered(thumbnailSwitcher, n) { + if (!this.mouseActive) + return; + + this._select(this._selectedIndex, n); + } + + _windowRemoved(thumbnailSwitcher, n) { + let appIcon = this._items[this._selectedIndex]; + if (!appIcon) + return; + + if (appIcon.cachedWindows.length > 0) { + let newIndex = Math.min(n, appIcon.cachedWindows.length - 1); + this._select(this._selectedIndex, newIndex); + } + } + + _finish(timestamp) { + let appIcon = this._items[this._selectedIndex]; + if (this._currentWindow < 0) + appIcon.app.activate_window(appIcon.cachedWindows[0], timestamp); + else if (appIcon.cachedWindows[this._currentWindow]) + Main.activateWindow(appIcon.cachedWindows[this._currentWindow], timestamp); + + super._finish(timestamp); + } + + _onDestroy() { + if (this._thumbnailTimeoutId != 0) + GLib.source_remove(this._thumbnailTimeoutId); + + super._onDestroy(); + } + + /** + * _select: + * @param {number} app: index of the app to select + * @param {number=} window: index of which of @app's windows to select + * @param {bool} forceAppFocus: optional flag, see below + * + * Selects the indicated @app, and optional @window, and sets + * this._thumbnailsFocused appropriately to indicate whether the + * arrow keys should act on the app list or the thumbnail list. + * + * If @app is specified and @window is unspecified or %null, then + * the app is highlighted (ie, given a light background), and the + * current thumbnail list, if any, is destroyed. If @app has + * multiple windows, and @forceAppFocus is not %true, then a + * timeout is started to open a thumbnail list. + * + * If @app and @window are specified (and @forceAppFocus is not), + * then @app will be outlined, a thumbnail list will be created + * and focused (if it hasn't been already), and the @window'th + * window in it will be highlighted. + * + * If @app and @window are specified and @forceAppFocus is %true, + * then @app will be highlighted, and @window outlined, and the + * app list will have the keyboard focus. + */ + _select(app, window, forceAppFocus) { + if (app != this._selectedIndex || window == null) { + if (this._thumbnails) + this._destroyThumbnails(); + } + + if (this._thumbnailTimeoutId != 0) { + GLib.source_remove(this._thumbnailTimeoutId); + this._thumbnailTimeoutId = 0; + } + + this._thumbnailsFocused = (window != null) && !forceAppFocus; + + this._selectedIndex = app; + this._currentWindow = window ? window : -1; + this._switcherList.highlight(app, this._thumbnailsFocused); + + if (window != null) { + if (!this._thumbnails) + this._createThumbnails(); + this._currentWindow = window; + this._thumbnails.highlight(window, forceAppFocus); + } else if (this._items[this._selectedIndex].cachedWindows.length > 1 && + !forceAppFocus) { + this._thumbnailTimeoutId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + THUMBNAIL_POPUP_TIME, + this._timeoutPopupThumbnails.bind(this)); + GLib.Source.set_name_by_id(this._thumbnailTimeoutId, '[gnome-shell] this._timeoutPopupThumbnails'); + } + } + + _timeoutPopupThumbnails() { + if (!this._thumbnails) + this._createThumbnails(); + this._thumbnailTimeoutId = 0; + this._thumbnailsFocused = false; + return GLib.SOURCE_REMOVE; + } + + _destroyThumbnails() { + let thumbnailsActor = this._thumbnails; + this._thumbnails.ease({ + opacity: 0, + duration: THUMBNAIL_FADE_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + thumbnailsActor.destroy(); + this.thumbnailsVisible = false; + }, + }); + this._thumbnails = null; + this._switcherList.removeAccessibleState(this._selectedIndex, Atk.StateType.EXPANDED); + } + + _createThumbnails() { + this._thumbnails = new ThumbnailSwitcher(this._items[this._selectedIndex].cachedWindows); + this._thumbnails.connect('item-activated', this._windowActivated.bind(this)); + this._thumbnails.connect('item-entered', this._windowEntered.bind(this)); + this._thumbnails.connect('item-removed', this._windowRemoved.bind(this)); + this._thumbnails.connect('destroy', () => { + this._thumbnails = null; + this._thumbnailsFocused = false; + }); + + this.add_actor(this._thumbnails); + + // Need to force an allocation so we can figure out whether we + // need to scroll when selecting + this._thumbnails.get_allocation_box(); + + this._thumbnails.opacity = 0; + this._thumbnails.ease({ + opacity: 255, + duration: THUMBNAIL_FADE_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this.thumbnailsVisible = true; + }, + }); + + this._switcherList.addAccessibleState(this._selectedIndex, Atk.StateType.EXPANDED); + } +}); + +var CyclerHighlight = GObject.registerClass( +class CyclerHighlight extends St.Widget { + _init() { + super._init({ layout_manager: new Clutter.BinLayout() }); + this._window = null; + + this._clone = new Clutter.Clone(); + this.add_actor(this._clone); + + this._highlight = new St.Widget({ style_class: 'cycler-highlight' }); + this.add_actor(this._highlight); + + let coordinate = Clutter.BindCoordinate.ALL; + let constraint = new Clutter.BindConstraint({ coordinate }); + this._clone.bind_property('source', constraint, 'source', 0); + + this.add_constraint(constraint); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + set window(w) { + if (this._window == w) + return; + + this._window?.disconnectObject(this); + + this._window = w; + + if (this._clone.source) + this._clone.source.sync_visibility(); + + const windowActor = this._window?.get_compositor_private() ?? null; + + if (windowActor) + windowActor.hide(); + + this._clone.source = windowActor; + + if (this._window) { + this._onSizeChanged(); + this._window.connectObject('size-changed', + this._onSizeChanged.bind(this), this); + } else { + this._highlight.set_size(0, 0); + this._highlight.hide(); + } + } + + _onSizeChanged() { + const bufferRect = this._window.get_buffer_rect(); + const rect = this._window.get_frame_rect(); + this._highlight.set_size(rect.width, rect.height); + this._highlight.set_position( + rect.x - bufferRect.x, + rect.y - bufferRect.y); + this._highlight.show(); + } + + _onDestroy() { + this.window = null; + } +}); + +// We don't show an actual popup, so just provide what SwitcherPopup +// expects instead of inheriting from SwitcherList +var CyclerList = GObject.registerClass({ + Signals: { + 'item-activated': { param_types: [GObject.TYPE_INT] }, + 'item-entered': { param_types: [GObject.TYPE_INT] }, + 'item-removed': { param_types: [GObject.TYPE_INT] }, + 'item-highlighted': { param_types: [GObject.TYPE_INT] }, + }, +}, class CyclerList extends St.Widget { + highlight(index, _justOutline) { + this.emit('item-highlighted', index); + } +}); + +var CyclerPopup = GObject.registerClass({ + GTypeFlags: GObject.TypeFlags.ABSTRACT, +}, class CyclerPopup extends SwitcherPopup.SwitcherPopup { + _init() { + super._init(); + + this._items = this._getWindows(); + + this._highlight = new CyclerHighlight(); + global.window_group.add_actor(this._highlight); + + this._switcherList = new CyclerList(); + this._switcherList.connect('item-highlighted', (list, index) => { + this._highlightItem(index); + }); + } + + _highlightItem(index, _justOutline) { + this._highlight.window = this._items[index]; + global.window_group.set_child_above_sibling(this._highlight, null); + } + + _finish() { + let window = this._items[this._selectedIndex]; + let ws = window.get_workspace(); + let workspaceManager = global.workspace_manager; + let activeWs = workspaceManager.get_active_workspace(); + + if (window.minimized) { + Main.wm.skipNextEffect(window.get_compositor_private()); + window.unminimize(); + } + + if (activeWs == ws) { + Main.activateWindow(window); + } else { + // If the selected window is on a different workspace, we don't + // want it to disappear, then slide in with the workspace; instead, + // always activate it on the active workspace ... + activeWs.activate_with_focus(window, global.get_current_time()); + + // ... then slide it over to the original workspace if necessary + Main.wm.actionMoveWindow(window, ws); + } + + super._finish(); + } + + _onDestroy() { + this._highlight.destroy(); + + super._onDestroy(); + } +}); + + +var GroupCyclerPopup = GObject.registerClass( +class GroupCyclerPopup extends CyclerPopup { + _init() { + this._settings = new Gio.Settings({ schema_id: 'org.gnome.shell.app-switcher' }); + super._init(); + } + + _getWindows() { + let app = Shell.WindowTracker.get_default().focus_app; + let appWindows = app?.get_windows() ?? []; + + if (this._settings.get_boolean('current-workspace-only')) { + const workspaceManager = global.workspace_manager; + const workspace = workspaceManager.get_active_workspace(); + appWindows = appWindows.filter( + window => window.located_on_workspace(workspace)); + } + + return appWindows; + } + + _keyPressHandler(keysym, action) { + if (action == Meta.KeyBindingAction.CYCLE_GROUP) + this._select(this._next()); + else if (action == Meta.KeyBindingAction.CYCLE_GROUP_BACKWARD) + this._select(this._previous()); + else + return Clutter.EVENT_PROPAGATE; + + return Clutter.EVENT_STOP; + } +}); + +var WindowSwitcherPopup = GObject.registerClass( +class WindowSwitcherPopup extends SwitcherPopup.SwitcherPopup { + _init() { + super._init(); + this._settings = new Gio.Settings({ schema_id: 'org.gnome.shell.window-switcher' }); + + let windows = this._getWindowList(); + + let mode = this._settings.get_enum('app-icon-mode'); + this._switcherList = new WindowSwitcher(windows, mode); + this._items = this._switcherList.icons; + } + + _getWindowList() { + let workspace = null; + + if (this._settings.get_boolean('current-workspace-only')) { + let workspaceManager = global.workspace_manager; + + workspace = workspaceManager.get_active_workspace(); + } + + return getWindows(workspace); + } + + _closeWindow(windowIndex) { + let windowIcon = this._items[windowIndex]; + if (!windowIcon) + return; + + windowIcon.window.delete(global.get_current_time()); + } + + _keyPressHandler(keysym, action) { + const rtl = Clutter.get_default_text_direction() === Clutter.TextDirection.RTL; + if (action == Meta.KeyBindingAction.SWITCH_WINDOWS) + this._select(this._next()); + else if (action == Meta.KeyBindingAction.SWITCH_WINDOWS_BACKWARD) + this._select(this._previous()); + else if (keysym == Clutter.KEY_Left) + this._select(rtl ? this._next() : this._previous()); + else if (keysym == Clutter.KEY_Right) + this._select(rtl ? this._previous() : this._next()); + else if (keysym === Clutter.KEY_w || keysym === Clutter.KEY_W || keysym === Clutter.KEY_F4) + this._closeWindow(this._selectedIndex); + else + return Clutter.EVENT_PROPAGATE; + + return Clutter.EVENT_STOP; + } + + _finish() { + Main.activateWindow(this._items[this._selectedIndex].window); + + super._finish(); + } +}); + +var WindowCyclerPopup = GObject.registerClass( +class WindowCyclerPopup extends CyclerPopup { + _init() { + this._settings = new Gio.Settings({ schema_id: 'org.gnome.shell.window-switcher' }); + super._init(); + } + + _getWindows() { + let workspace = null; + + if (this._settings.get_boolean('current-workspace-only')) { + let workspaceManager = global.workspace_manager; + + workspace = workspaceManager.get_active_workspace(); + } + + return getWindows(workspace); + } + + _keyPressHandler(keysym, action) { + if (action == Meta.KeyBindingAction.CYCLE_WINDOWS) + this._select(this._next()); + else if (action == Meta.KeyBindingAction.CYCLE_WINDOWS_BACKWARD) + this._select(this._previous()); + else + return Clutter.EVENT_PROPAGATE; + + return Clutter.EVENT_STOP; + } +}); + +var AppIcon = GObject.registerClass( +class AppIcon extends St.BoxLayout { + _init(app) { + super._init({ + style_class: 'alt-tab-app', + vertical: true, + }); + + this.app = app; + this.icon = null; + this._iconBin = new St.Bin(); + + this.add_child(this._iconBin); + this.label = new St.Label({ + text: this.app.get_name(), + x_align: Clutter.ActorAlign.CENTER, + }); + this.add_child(this.label); + } + + // eslint-disable-next-line camelcase + set_size(size) { + this.icon = this.app.create_icon_texture(size); + this._iconBin.child = this.icon; + } +}); + +var AppSwitcher = GObject.registerClass( +class AppSwitcher extends SwitcherPopup.SwitcherList { + _init(apps, altTabPopup) { + super._init(true); + + this.icons = []; + this._arrows = []; + + let windowTracker = Shell.WindowTracker.get_default(); + let settings = new Gio.Settings({ schema_id: 'org.gnome.shell.app-switcher' }); + + let workspace = null; + if (settings.get_boolean('current-workspace-only')) { + let workspaceManager = global.workspace_manager; + + workspace = workspaceManager.get_active_workspace(); + } + + let allWindows = getWindows(workspace); + + // Construct the AppIcons, add to the popup + for (let i = 0; i < apps.length; i++) { + let appIcon = new AppIcon(apps[i]); + // Cache the window list now; we don't handle dynamic changes here, + // and we don't want to be continually retrieving it + appIcon.cachedWindows = allWindows.filter( + w => windowTracker.get_window_app(w) === appIcon.app); + if (appIcon.cachedWindows.length > 0) + this._addIcon(appIcon); + } + + this._altTabPopup = altTabPopup; + this._delayedHighlighted = -1; + this._mouseTimeOutId = 0; + + this.connect('destroy', this._onDestroy.bind(this)); + } + + _onDestroy() { + if (this._mouseTimeOutId != 0) + GLib.source_remove(this._mouseTimeOutId); + + this.icons.forEach( + icon => icon.app.disconnectObject(this)); + } + + _setIconSize() { + let j = 0; + while (this._items.length > 1 && this._items[j].style_class != 'item-box') + j++; + + let themeNode = this._items[j].get_theme_node(); + this._list.ensure_style(); + + let iconPadding = themeNode.get_horizontal_padding(); + let iconBorder = themeNode.get_border_width(St.Side.LEFT) + themeNode.get_border_width(St.Side.RIGHT); + let [, labelNaturalHeight] = this.icons[j].label.get_preferred_height(-1); + let iconSpacing = labelNaturalHeight + iconPadding + iconBorder; + let totalSpacing = this._list.spacing * (this._items.length - 1); + + // We just assume the whole screen here due to weirdness happening with the passed width + let primary = Main.layoutManager.primaryMonitor; + let parentPadding = this.get_parent().get_theme_node().get_horizontal_padding(); + let availWidth = primary.width - parentPadding - this.get_theme_node().get_horizontal_padding(); + + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + let iconSizes = baseIconSizes.map(s => s * scaleFactor); + let iconSize = baseIconSizes[0]; + + if (this._items.length > 1) { + for (let i = 0; i < baseIconSizes.length; i++) { + iconSize = baseIconSizes[i]; + let height = iconSizes[i] + iconSpacing; + let w = height * this._items.length + totalSpacing; + if (w <= availWidth) + break; + } + } + + this._iconSize = iconSize; + + for (let i = 0; i < this.icons.length; i++) { + if (this.icons[i].icon != null) + break; + this.icons[i].set_size(iconSize); + } + } + + vfunc_get_preferred_height(forWidth) { + if (!this._iconSize) + this._setIconSize(); + + return super.vfunc_get_preferred_height(forWidth); + } + + vfunc_allocate(box) { + // Allocate the main list items + super.vfunc_allocate(box); + + let contentBox = this.get_theme_node().get_content_box(box); + + let arrowHeight = Math.floor(this.get_theme_node().get_padding(St.Side.BOTTOM) / 3); + let arrowWidth = arrowHeight * 2; + + // Now allocate each arrow underneath its item + let childBox = new Clutter.ActorBox(); + for (let i = 0; i < this._items.length; i++) { + let itemBox = this._items[i].allocation; + childBox.x1 = contentBox.x1 + Math.floor(itemBox.x1 + (itemBox.x2 - itemBox.x1 - arrowWidth) / 2); + childBox.x2 = childBox.x1 + arrowWidth; + childBox.y1 = contentBox.y1 + itemBox.y2 + arrowHeight; + childBox.y2 = childBox.y1 + arrowHeight; + this._arrows[i].allocate(childBox); + } + } + + // We override SwitcherList's _onItemMotion method to delay + // activation when the thumbnail list is open + _onItemMotion(item) { + if (item === this._items[this._highlighted] || + item === this._items[this._delayedHighlighted]) + return Clutter.EVENT_PROPAGATE; + + const index = this._items.indexOf(item); + + if (this._mouseTimeOutId !== 0) { + GLib.source_remove(this._mouseTimeOutId); + this._delayedHighlighted = -1; + this._mouseTimeOutId = 0; + } + + if (this._altTabPopup.thumbnailsVisible) { + this._delayedHighlighted = index; + this._mouseTimeOutId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + APP_ICON_HOVER_TIMEOUT, + () => { + this._enterItem(index); + this._delayedHighlighted = -1; + this._mouseTimeOutId = 0; + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._mouseTimeOutId, '[gnome-shell] this._enterItem'); + } else { + this._itemEntered(index); + } + + return Clutter.EVENT_PROPAGATE; + } + + _enterItem(index) { + let [x, y] = global.get_pointer(); + let pickedActor = global.stage.get_actor_at_pos(Clutter.PickMode.ALL, x, y); + if (this._items[index].contains(pickedActor)) + this._itemEntered(index); + } + + // We override SwitcherList's highlight() method to also deal with + // the AppSwitcher->ThumbnailSwitcher arrows. Apps with only 1 window + // will hide their arrows by default, but show them when their + // thumbnails are visible (ie, when the app icon is supposed to be + // in justOutline mode). Apps with multiple windows will normally + // show a dim arrow, but show a bright arrow when they are + // highlighted. + highlight(n, justOutline) { + if (this.icons[this._highlighted]) { + if (this.icons[this._highlighted].cachedWindows.length === 1) + this._arrows[this._highlighted].hide(); + else + this._arrows[this._highlighted].remove_style_pseudo_class('highlighted'); + } + + super.highlight(n, justOutline); + + if (this._highlighted !== -1) { + if (justOutline && this.icons[this._highlighted].cachedWindows.length === 1) + this._arrows[this._highlighted].show(); + else + this._arrows[this._highlighted].add_style_pseudo_class('highlighted'); + } + } + + _addIcon(appIcon) { + this.icons.push(appIcon); + let item = this.addItem(appIcon, appIcon.label); + + appIcon.app.connectObject('notify::state', app => { + if (app.state != Shell.AppState.RUNNING) + this._removeIcon(app); + }, this); + + let arrow = new St.DrawingArea({ style_class: 'switcher-arrow' }); + arrow.connect('repaint', () => SwitcherPopup.drawArrow(arrow, St.Side.BOTTOM)); + this.add_actor(arrow); + this._arrows.push(arrow); + + if (appIcon.cachedWindows.length == 1) + arrow.hide(); + else + item.add_accessible_state(Atk.StateType.EXPANDABLE); + } + + _removeIcon(app) { + let index = this.icons.findIndex(icon => { + return icon.app == app; + }); + if (index === -1) + return; + + this._arrows[index].destroy(); + this._arrows.splice(index, 1); + + this.icons.splice(index, 1); + this.removeItem(index); + } +}); + +var ThumbnailSwitcher = GObject.registerClass( +class ThumbnailSwitcher extends SwitcherPopup.SwitcherList { + _init(windows) { + super._init(false); + + this._labels = []; + this._thumbnailBins = []; + this._clones = []; + this._windows = windows; + + for (let i = 0; i < windows.length; i++) { + const box = new St.BoxLayout({ + style_class: 'thumbnail-box', + vertical: true, + }); + + let bin = new St.Bin({ style_class: 'thumbnail' }); + + box.add_actor(bin); + this._thumbnailBins.push(bin); + + const title = windows[i].get_title(); + const name = new St.Label({ + text: title, + // St.Label doesn't support text-align + x_align: Clutter.ActorAlign.CENTER, + }); + this._labels.push(name); + box.add_actor(name); + + this.addItem(box, name); + } + + this.connect('destroy', this._onDestroy.bind(this)); + } + + addClones(availHeight) { + if (!this._thumbnailBins.length) + return; + let totalPadding = this._items[0].get_theme_node().get_horizontal_padding() + this._items[0].get_theme_node().get_vertical_padding(); + totalPadding += this.get_theme_node().get_horizontal_padding() + this.get_theme_node().get_vertical_padding(); + let [, labelNaturalHeight] = this._labels[0].get_preferred_height(-1); + let spacing = this._items[0].child.get_theme_node().get_length('spacing'); + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + let thumbnailSize = THUMBNAIL_DEFAULT_SIZE * scaleFactor; + + availHeight = Math.min(availHeight - labelNaturalHeight - totalPadding - spacing, thumbnailSize); + let binHeight = availHeight + this._items[0].get_theme_node().get_vertical_padding() + this.get_theme_node().get_vertical_padding() - spacing; + binHeight = Math.min(thumbnailSize, binHeight); + + for (let i = 0; i < this._thumbnailBins.length; i++) { + let mutterWindow = this._windows[i].get_compositor_private(); + if (!mutterWindow) + continue; + + let clone = _createWindowClone(mutterWindow, thumbnailSize); + this._thumbnailBins[i].set_height(binHeight); + this._thumbnailBins[i].add_actor(clone); + + mutterWindow.connectObject('destroy', + source => this._removeThumbnail(source, clone), this); + this._clones.push(clone); + } + + // Make sure we only do this once + this._thumbnailBins = []; + } + + _removeThumbnail(source, clone) { + let index = this._clones.indexOf(clone); + if (index === -1) + return; + + this._clones.splice(index, 1); + this._windows.splice(index, 1); + this._labels.splice(index, 1); + this.removeItem(index); + + if (this._clones.length > 0) + this.highlight(SwitcherPopup.mod(index, this._clones.length)); + else + this.destroy(); + } + + _onDestroy() { + this._clones.forEach( + clone => clone?.source.disconnectObject(this)); + } +}); + +var WindowIcon = GObject.registerClass( +class WindowIcon extends St.BoxLayout { + _init(window, mode) { + super._init({ + style_class: 'alt-tab-app', + vertical: true, + }); + + this.window = window; + + this._icon = new St.Widget({ layout_manager: new Clutter.BinLayout() }); + + this.add_child(this._icon); + this.label = new St.Label({ text: window.get_title() }); + + let tracker = Shell.WindowTracker.get_default(); + this.app = tracker.get_window_app(window); + + let mutterWindow = this.window.get_compositor_private(); + let size; + + this._icon.destroy_all_children(); + + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + + switch (mode) { + case AppIconMode.THUMBNAIL_ONLY: + size = WINDOW_PREVIEW_SIZE; + this._icon.add_actor(_createWindowClone(mutterWindow, size * scaleFactor)); + break; + + case AppIconMode.BOTH: + size = WINDOW_PREVIEW_SIZE; + this._icon.add_actor(_createWindowClone(mutterWindow, size * scaleFactor)); + + if (this.app) { + this._icon.add_actor(this._createAppIcon(this.app, + APP_ICON_SIZE_SMALL)); + } + break; + + case AppIconMode.APP_ICON_ONLY: + size = APP_ICON_SIZE; + this._icon.add_actor(this._createAppIcon(this.app, size)); + } + + this._icon.set_size(size * scaleFactor, size * scaleFactor); + } + + _createAppIcon(app, size) { + let appIcon = app + ? app.create_icon_texture(size) + : new St.Icon({ icon_name: 'icon-missing', icon_size: size }); + appIcon.x_expand = appIcon.y_expand = true; + appIcon.x_align = appIcon.y_align = Clutter.ActorAlign.END; + + return appIcon; + } +}); + +var WindowSwitcher = GObject.registerClass( +class WindowSwitcher extends SwitcherPopup.SwitcherList { + _init(windows, mode) { + super._init(true); + + this._label = new St.Label({ + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + this.add_actor(this._label); + + this.windows = windows; + this.icons = []; + + for (let i = 0; i < windows.length; i++) { + let win = windows[i]; + let icon = new WindowIcon(win, mode); + + this.addItem(icon, icon.label); + this.icons.push(icon); + + icon.window.connectObject('unmanaged', + window => this._removeWindow(window), this); + } + + this.connect('destroy', this._onDestroy.bind(this)); + } + + _onDestroy() { + this.icons.forEach( + icon => icon.window.disconnectObject(this)); + } + + vfunc_get_preferred_height(forWidth) { + let [minHeight, natHeight] = super.vfunc_get_preferred_height(forWidth); + + let spacing = this.get_theme_node().get_padding(St.Side.BOTTOM); + let [labelMin, labelNat] = this._label.get_preferred_height(-1); + + minHeight += labelMin + spacing; + natHeight += labelNat + spacing; + + return [minHeight, natHeight]; + } + + vfunc_allocate(box) { + let themeNode = this.get_theme_node(); + let contentBox = themeNode.get_content_box(box); + const labelHeight = this._label.height; + const totalLabelHeight = + labelHeight + themeNode.get_padding(St.Side.BOTTOM); + + box.y2 -= totalLabelHeight; + super.vfunc_allocate(box); + + // Hooking up the parent vfunc will call this.set_allocation() with + // the height without the label height, so call it again with the + // correct size here. + box.y2 += totalLabelHeight; + this.set_allocation(box); + + const childBox = new Clutter.ActorBox(); + childBox.x1 = contentBox.x1; + childBox.x2 = contentBox.x2; + childBox.y2 = contentBox.y2; + childBox.y1 = childBox.y2 - labelHeight; + this._label.allocate(childBox); + } + + highlight(index, justOutline) { + super.highlight(index, justOutline); + + this._label.set_text(index == -1 ? '' : this.icons[index].label.text); + } + + _removeWindow(window) { + let index = this.icons.findIndex(icon => { + return icon.window == window; + }); + if (index === -1) + return; + + this.icons.splice(index, 1); + this.removeItem(index); + } +}); diff --git a/js/ui/animation.js b/js/ui/animation.js new file mode 100644 index 0000000..c2ed248 --- /dev/null +++ b/js/ui/animation.js @@ -0,0 +1,196 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Animation, AnimatedIcon, Spinner */ + +const { Clutter, GLib, GObject, Gio, St } = imports.gi; + +const Params = imports.misc.params; + +var ANIMATED_ICON_UPDATE_TIMEOUT = 16; +var SPINNER_ANIMATION_TIME = 300; +var SPINNER_ANIMATION_DELAY = 1000; + +var Animation = GObject.registerClass( +class Animation extends St.Bin { + _init(file, width, height, speed) { + const themeContext = St.ThemeContext.get_for_stage(global.stage); + + super._init({ + style: `width: ${width}px; height: ${height}px;`, + }); + + this.connect('destroy', this._onDestroy.bind(this)); + this.connect('resource-scale-changed', + this._loadFile.bind(this, file, width, height)); + + themeContext.connectObject('notify::scale-factor', + () => { + this._loadFile(file, width, height); + this.set_size(width * themeContext.scale_factor, height * themeContext.scale_factor); + }, this); + + this._speed = speed; + + this._isLoaded = false; + this._isPlaying = false; + this._timeoutId = 0; + this._frame = 0; + + this._loadFile(file, width, height); + } + + play() { + if (this._isLoaded && this._timeoutId == 0) { + if (this._frame == 0) + this._showFrame(0); + + this._timeoutId = GLib.timeout_add(GLib.PRIORITY_LOW, this._speed, this._update.bind(this)); + GLib.Source.set_name_by_id(this._timeoutId, '[gnome-shell] this._update'); + } + + this._isPlaying = true; + } + + stop() { + if (this._timeoutId > 0) { + GLib.source_remove(this._timeoutId); + this._timeoutId = 0; + } + + this._isPlaying = false; + } + + _loadFile(file, width, height) { + const resourceScale = this.get_resource_scale(); + let wasPlaying = this._isPlaying; + + if (this._isPlaying) + this.stop(); + + this._isLoaded = false; + this.destroy_all_children(); + + let textureCache = St.TextureCache.get_default(); + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + this._animations = textureCache.load_sliced_image(file, width, height, + scaleFactor, resourceScale, + this._animationsLoaded.bind(this)); + this._animations.set({ + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + this.set_child(this._animations); + + if (wasPlaying) + this.play(); + } + + _showFrame(frame) { + let oldFrameActor = this._animations.get_child_at_index(this._frame); + if (oldFrameActor) + oldFrameActor.hide(); + + this._frame = frame % this._animations.get_n_children(); + + let newFrameActor = this._animations.get_child_at_index(this._frame); + if (newFrameActor) + newFrameActor.show(); + } + + _update() { + this._showFrame(this._frame + 1); + return GLib.SOURCE_CONTINUE; + } + + _syncAnimationSize() { + if (!this._isLoaded) + return; + + let [width, height] = this.get_size(); + + for (let i = 0; i < this._animations.get_n_children(); ++i) + this._animations.get_child_at_index(i).set_size(width, height); + } + + _animationsLoaded() { + this._isLoaded = this._animations.get_n_children() > 0; + + this._syncAnimationSize(); + + if (this._isPlaying) + this.play(); + } + + _onDestroy() { + this.stop(); + } +}); + +var AnimatedIcon = GObject.registerClass( +class AnimatedIcon extends Animation { + _init(file, size) { + super._init(file, size, size, ANIMATED_ICON_UPDATE_TIMEOUT); + } +}); + +var Spinner = GObject.registerClass( +class Spinner extends AnimatedIcon { + _init(size, params) { + params = Params.parse(params, { + animate: false, + hideOnStop: false, + }); + let file = Gio.File.new_for_uri('resource:///org/gnome/shell/theme/process-working.svg'); + super._init(file, size); + + this.opacity = 0; + this._animate = params.animate; + this._hideOnStop = params.hideOnStop; + this.visible = !this._hideOnStop; + } + + _onDestroy() { + this._animate = false; + super._onDestroy(); + } + + play() { + this.remove_all_transitions(); + this.show(); + + if (this._animate) { + super.play(); + this.ease({ + opacity: 255, + delay: SPINNER_ANIMATION_DELAY, + duration: SPINNER_ANIMATION_TIME, + mode: Clutter.AnimationMode.LINEAR, + }); + } else { + this.opacity = 255; + super.play(); + } + } + + stop() { + this.remove_all_transitions(); + + if (this._animate) { + this.ease({ + opacity: 0, + duration: SPINNER_ANIMATION_TIME, + mode: Clutter.AnimationMode.LINEAR, + onComplete: () => { + super.stop(); + if (this._hideOnStop) + this.hide(); + }, + }); + } else { + this.opacity = 0; + super.stop(); + + if (this._hideOnStop) + this.hide(); + } + } +}); diff --git a/js/ui/appDisplay.js b/js/ui/appDisplay.js new file mode 100644 index 0000000..61fd0bc --- /dev/null +++ b/js/ui/appDisplay.js @@ -0,0 +1,3273 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported AppDisplay, AppSearchProvider */ + +const { + Clutter, Gio, GLib, GObject, Graphene, Pango, Shell, St, +} = imports.gi; + +const AppFavorites = imports.ui.appFavorites; +const { AppMenu } = imports.ui.appMenu; +const BoxPointer = imports.ui.boxpointer; +const DND = imports.ui.dnd; +const GrabHelper = imports.ui.grabHelper; +const IconGrid = imports.ui.iconGrid; +const Layout = imports.ui.layout; +const Main = imports.ui.main; +const PageIndicators = imports.ui.pageIndicators; +const ParentalControlsManager = imports.misc.parentalControlsManager; +const PopupMenu = imports.ui.popupMenu; +const Search = imports.ui.search; +const SwipeTracker = imports.ui.swipeTracker; +const Params = imports.misc.params; +const SystemActions = imports.misc.systemActions; + +var MENU_POPUP_TIMEOUT = 600; +var POPDOWN_DIALOG_TIMEOUT = 500; + +var FOLDER_SUBICON_FRACTION = .4; + +var VIEWS_SWITCH_TIME = 400; +var VIEWS_SWITCH_ANIMATION_DELAY = 100; + +var SCROLL_TIMEOUT_TIME = 150; + +var APP_ICON_SCALE_IN_TIME = 500; +var APP_ICON_SCALE_IN_DELAY = 700; + +var APP_ICON_TITLE_EXPAND_TIME = 200; +var APP_ICON_TITLE_COLLAPSE_TIME = 100; + +const FOLDER_DIALOG_ANIMATION_TIME = 200; + +const PAGE_PREVIEW_ANIMATION_TIME = 150; +const PAGE_INDICATOR_FADE_TIME = 200; +const PAGE_PREVIEW_RATIO = 0.20; + +const OVERSHOOT_THRESHOLD = 20; +const OVERSHOOT_TIMEOUT = 1000; + +const DELAYED_MOVE_TIMEOUT = 200; + +const DIALOG_SHADE_NORMAL = Clutter.Color.from_pixel(0x000000cc); +const DIALOG_SHADE_HIGHLIGHT = Clutter.Color.from_pixel(0x00000055); + +const DEFAULT_FOLDERS = { + 'Utilities': { + name: 'X-GNOME-Utilities.directory', + categories: ['X-GNOME-Utilities'], + apps: [ + 'gnome-abrt.desktop', + 'gnome-system-log.desktop', + 'nm-connection-editor.desktop', + 'org.gnome.baobab.desktop', + 'org.gnome.Connections.desktop', + 'org.gnome.DejaDup.desktop', + 'org.gnome.Dictionary.desktop', + 'org.gnome.DiskUtility.desktop', + 'org.gnome.eog.desktop', + 'org.gnome.Evince.desktop', + 'org.gnome.FileRoller.desktop', + 'org.gnome.fonts.desktop', + 'org.gnome.seahorse.Application.desktop', + 'org.gnome.tweaks.desktop', + 'org.gnome.Usage.desktop', + 'vinagre.desktop', + ], + }, + 'YaST': { + name: 'suse-yast.directory', + categories: ['X-SuSE-YaST'], + }, +}; + +function _getCategories(info) { + let categoriesStr = info.get_categories(); + if (!categoriesStr) + return []; + return categoriesStr.split(';'); +} + +function _listsIntersect(a, b) { + for (let itemA of a) { + if (b.includes(itemA)) + return true; + } + return false; +} + +function _getFolderName(folder) { + let name = folder.get_string('name'); + + if (folder.get_boolean('translate')) { + let translated = Shell.util_get_translated_folder_name(name); + if (translated !== null) + return translated; + } + + return name; +} + +function _getViewFromIcon(icon) { + for (let parent = icon.get_parent(); parent; parent = parent.get_parent()) { + if (parent instanceof BaseAppView) + return parent; + } + return null; +} + +function _findBestFolderName(apps) { + let appInfos = apps.map(app => app.get_app_info()); + + let categoryCounter = {}; + let commonCategories = []; + + appInfos.reduce((categories, appInfo) => { + for (let category of _getCategories(appInfo)) { + if (!(category in categoryCounter)) + categoryCounter[category] = 0; + + categoryCounter[category] += 1; + + // If a category is present in all apps, its counter will + // reach appInfos.length + if (category.length > 0 && + categoryCounter[category] == appInfos.length) + categories.push(category); + } + return categories; + }, commonCategories); + + for (let category of commonCategories) { + const directory = `${category}.directory`; + const translated = Shell.util_get_translated_folder_name(directory); + if (translated !== null) + return translated; + } + + return null; +} + +const AppGrid = GObject.registerClass({ + Properties: { + 'indicators-padding': GObject.ParamSpec.boxed('indicators-padding', + 'Indicators padding', 'Indicators padding', + GObject.ParamFlags.READWRITE, + Clutter.Margin.$gtype), + }, +}, class AppGrid extends IconGrid.IconGrid { + _init(layoutParams) { + super._init(layoutParams); + + this._indicatorsPadding = new Clutter.Margin(); + } + + _updatePadding() { + const node = this.get_theme_node(); + const {rowSpacing, columnSpacing} = this.layoutManager; + + const padding = this._indicatorsPadding.copy(); + padding.left += rowSpacing; + padding.right += rowSpacing; + padding.top += columnSpacing; + padding.bottom += columnSpacing; + ['top', 'right', 'bottom', 'left'].forEach(side => { + padding[side] += node.get_length(`page-padding-${side}`); + }); + + this.layoutManager.pagePadding = padding; + } + + vfunc_style_changed() { + super.vfunc_style_changed(); + this._updatePadding(); + } + + get indicatorsPadding() { + return this._indicatorsPadding; + } + + set indicatorsPadding(v) { + if (this._indicatorsPadding === v) + return; + + this._indicatorsPadding = v ? v : new Clutter.Margin(); + this._updatePadding(); + } +}); + +const BaseAppViewGridLayout = GObject.registerClass( +class BaseAppViewGridLayout extends Clutter.BinLayout { + _init(grid, scrollView, nextPageIndicator, nextPageArrow, + previousPageIndicator, previousPageArrow) { + if (!(grid instanceof AppGrid)) + throw new Error('Grid must be an AppGrid subclass'); + + super._init(); + + this._grid = grid; + this._scrollView = scrollView; + this._previousPageIndicator = previousPageIndicator; + this._previousPageArrow = previousPageArrow; + this._nextPageIndicator = nextPageIndicator; + this._nextPageArrow = nextPageArrow; + + grid.connect('pages-changed', () => this._syncPageIndicatorsVisibility()); + + this._pageIndicatorsAdjustment = new St.Adjustment({ + lower: 0, + upper: 1, + }); + this._pageIndicatorsAdjustment.connect( + 'notify::value', () => this._syncPageIndicators()); + + this._showIndicators = false; + this._currentPage = 0; + this._pageWidth = 0; + } + + _getIndicatorsWidth(box) { + const [width, height] = box.get_size(); + const arrows = [ + this._nextPageArrow, + this._previousPageArrow, + ]; + + const minArrowsWidth = arrows.reduce( + (previousWidth, accessory) => { + const [min] = accessory.get_preferred_width(height); + return Math.max(previousWidth, min); + }, 0); + + const idealIndicatorWidth = (width * PAGE_PREVIEW_RATIO) / 2; + + return Math.max(idealIndicatorWidth, minArrowsWidth); + } + + _syncPageIndicatorsVisibility(animate = true) { + const previousIndicatorsVisible = + this._currentPage > 0 && this._showIndicators; + + if (previousIndicatorsVisible) + this._previousPageIndicator.show(); + + this._previousPageIndicator.ease({ + opacity: previousIndicatorsVisible ? 255 : 0, + duration: animate ? PAGE_INDICATOR_FADE_TIME : 0, + onComplete: () => { + if (!previousIndicatorsVisible) + this._previousPageIndicator.hide(); + }, + }); + + const previousArrowVisible = + this._currentPage > 0 && !previousIndicatorsVisible; + + if (previousArrowVisible) + this._previousPageArrow.show(); + + this._previousPageArrow.ease({ + opacity: previousArrowVisible ? 255 : 0, + duration: animate ? PAGE_INDICATOR_FADE_TIME : 0, + onComplete: () => { + if (!previousArrowVisible) + this._previousPageArrow.hide(); + }, + }); + + // Always show the next page indicator to allow dropping + // icons into new pages + const {allowIncompletePages, nPages} = this._grid.layoutManager; + const nextIndicatorsVisible = this._showIndicators && + (allowIncompletePages ? true : this._currentPage < nPages - 1); + + if (nextIndicatorsVisible) + this._nextPageIndicator.show(); + + this._nextPageIndicator.ease({ + opacity: nextIndicatorsVisible ? 255 : 0, + duration: animate ? PAGE_INDICATOR_FADE_TIME : 0, + onComplete: () => { + if (!nextIndicatorsVisible) + this._nextPageIndicator.hide(); + }, + }); + + const nextArrowVisible = + this._currentPage < nPages - 1 && + !nextIndicatorsVisible; + + if (nextArrowVisible) + this._nextPageArrow.show(); + + this._nextPageArrow.ease({ + opacity: nextArrowVisible ? 255 : 0, + duration: animate ? PAGE_INDICATOR_FADE_TIME : 0, + onComplete: () => { + if (!nextArrowVisible) + this._nextPageArrow.hide(); + }, + }); + } + + _getEndIcon(icons) { + const {columnsPerPage} = this._grid.layoutManager; + const index = Math.min(icons.length, columnsPerPage); + return icons[Math.max(index - 1, 0)]; + } + + _translatePreviousPageIcons(value, ltr) { + if (this._currentPage === 0) + return; + + const previousPage = this._currentPage - 1; + const icons = this._grid.getItemsAtPage(previousPage).filter(i => i.visible); + if (icons.length === 0) + return; + + const {left, right} = this._grid.indicatorsPadding; + const {columnSpacing} = this._grid.layoutManager; + const endIcon = this._getEndIcon(icons); + let iconOffset; + + if (ltr) { + const currentPageOffset = this._pageWidth * this._currentPage; + iconOffset = currentPageOffset - endIcon.allocation.x2 + left - columnSpacing; + } else { + const rtlPage = this._grid.nPages - previousPage - 1; + const pageOffset = this._pageWidth * rtlPage; + iconOffset = pageOffset - endIcon.allocation.x1 - right + columnSpacing; + } + + for (const icon of icons) + icon.translationX = iconOffset * value; + } + + _translateNextPageIcons(value, ltr) { + if (this._currentPage >= this._grid.nPages - 1) + return; + + const nextPage = this._currentPage + 1; + const icons = this._grid.getItemsAtPage(nextPage).filter(i => i.visible); + if (icons.length === 0) + return; + + const {left, right} = this._grid.indicatorsPadding; + const {columnSpacing} = this._grid.layoutManager; + let iconOffset; + + if (ltr) { + const pageOffset = this._pageWidth * nextPage; + iconOffset = pageOffset - icons[0].allocation.x1 - right + columnSpacing; + } else { + const rtlPage = this._grid.nPages - this._currentPage - 1; + const currentPageOffset = this._pageWidth * rtlPage; + iconOffset = currentPageOffset - icons[0].allocation.x2 + left - columnSpacing; + } + + for (const icon of icons) + icon.translationX = iconOffset * value; + } + + _syncPageIndicators() { + if (!this._container) + return; + + const {value} = this._pageIndicatorsAdjustment; + + const ltr = this._container.get_text_direction() !== Clutter.TextDirection.RTL; + const {left, right} = this._grid.indicatorsPadding; + const leftIndicatorOffset = -left * (1 - value); + const rightIndicatorOffset = right * (1 - value); + + this._previousPageIndicator.translationX = + ltr ? leftIndicatorOffset : rightIndicatorOffset; + this._nextPageIndicator.translationX = + ltr ? rightIndicatorOffset : leftIndicatorOffset; + + const leftArrowOffset = -left * value; + const rightArrowOffset = right * value; + + this._previousPageArrow.translationX = + ltr ? leftArrowOffset : rightArrowOffset; + this._nextPageArrow.translationX = + ltr ? rightArrowOffset : leftArrowOffset; + + // Page icons + this._translatePreviousPageIcons(value, ltr); + this._translateNextPageIcons(value, ltr); + + if (this._grid.nPages > 0) { + this._grid.getItemsAtPage(this._currentPage).forEach(icon => { + icon.translationX = 0; + }); + } + } + + vfunc_set_container(container) { + this._container = container; + this._pageIndicatorsAdjustment.actor = container; + this._syncPageIndicators(); + } + + vfunc_allocate(container, box) { + const ltr = container.get_text_direction() !== Clutter.TextDirection.RTL; + const indicatorsWidth = this._getIndicatorsWidth(box); + + this._grid.indicatorsPadding = new Clutter.Margin({ + left: indicatorsWidth, + right: indicatorsWidth, + }); + + this._scrollView.allocate(box); + + const leftBox = box.copy(); + leftBox.x2 = leftBox.x1 + indicatorsWidth; + + const rightBox = box.copy(); + rightBox.x1 = rightBox.x2 - indicatorsWidth; + + this._previousPageIndicator.allocate(ltr ? leftBox : rightBox); + this._previousPageArrow.allocate_align_fill(ltr ? leftBox : rightBox, + 0.5, 0.5, false, false); + this._nextPageIndicator.allocate(ltr ? rightBox : leftBox); + this._nextPageArrow.allocate_align_fill(ltr ? rightBox : leftBox, + 0.5, 0.5, false, false); + + this._pageWidth = box.get_width(); + } + + goToPage(page, animate = true) { + if (this._currentPage === page) + return; + + this._currentPage = page; + this._syncPageIndicatorsVisibility(animate); + this._syncPageIndicators(); + } + + showPageIndicators() { + if (this._showIndicators) + return; + + this._pageIndicatorsAdjustment.ease(1, { + duration: PAGE_PREVIEW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + }); + + this._grid.clipToView = false; + this._showIndicators = true; + this._syncPageIndicatorsVisibility(); + } + + hidePageIndicators() { + if (!this._showIndicators) + return; + + this._pageIndicatorsAdjustment.ease(0, { + duration: PAGE_PREVIEW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + onComplete: () => { + this._grid.clipToView = true; + }, + }); + + this._showIndicators = false; + this._syncPageIndicatorsVisibility(); + } +}); + +var BaseAppView = GObject.registerClass({ + GTypeFlags: GObject.TypeFlags.ABSTRACT, + Properties: { + 'gesture-modes': GObject.ParamSpec.flags( + 'gesture-modes', 'gesture-modes', 'gesture-modes', + GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, + Shell.ActionMode, Shell.ActionMode.OVERVIEW), + }, + Signals: { + 'view-loaded': {}, + }, +}, class BaseAppView extends St.Widget { + _init(params = {}) { + super._init(params); + + this._grid = this._createGrid(); + this._grid._delegate = this; + // Standard hack for ClutterBinLayout + this._grid.x_expand = true; + this._grid.connect('pages-changed', () => { + this.goToPage(this._grid.currentPage); + this._pageIndicators.setNPages(this._grid.nPages); + this._pageIndicators.setCurrentPosition(this._grid.currentPage); + }); + + // Scroll View + this._scrollView = new St.ScrollView({ + style_class: 'apps-scroll-view', + clip_to_allocation: true, + x_expand: true, + y_expand: true, + reactive: true, + enable_mouse_scrolling: false, + }); + this._scrollView.set_policy(St.PolicyType.EXTERNAL, St.PolicyType.NEVER); + + this._canScroll = true; // limiting scrolling speed + this._scrollTimeoutId = 0; + this._scrollView.connect('scroll-event', this._onScroll.bind(this)); + + this._scrollView.add_actor(this._grid); + + const scroll = this._scrollView.hscroll; + this._adjustment = scroll.adjustment; + this._adjustment.connect('notify::value', adj => { + const value = adj.value / adj.page_size; + this._pageIndicators.setCurrentPosition(value); + }); + + // Page Indicators + this._pageIndicators = + new PageIndicators.PageIndicators(Clutter.Orientation.HORIZONTAL); + + this._pageIndicators.y_expand = false; + this._pageIndicators.connect('page-activated', + (indicators, pageIndex) => { + this.goToPage(pageIndex); + }); + this._pageIndicators.connect('scroll-event', (actor, event) => { + this._scrollView.event(event, false); + }); + + // Navigation indicators + this._nextPageIndicator = new St.Widget({ + style_class: 'page-navigation-hint next', + opacity: 0, + visible: false, + reactive: true, + x_expand: true, + y_expand: true, + x_align: Clutter.ActorAlign.FILL, + y_align: Clutter.ActorAlign.FILL, + }); + + this._prevPageIndicator = new St.Widget({ + style_class: 'page-navigation-hint previous', + opacity: 0, + visible: false, + reactive: true, + x_expand: true, + y_expand: true, + x_align: Clutter.ActorAlign.FILL, + y_align: Clutter.ActorAlign.FILL, + }); + + // Next/prev page arrows + const rtl = this.get_text_direction() === Clutter.TextDirection.RTL; + this._nextPageArrow = new St.Button({ + style_class: 'page-navigation-arrow', + icon_name: rtl + ? 'carousel-arrow-previous-symbolic' + : 'carousel-arrow-next-symbolic', + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + this._nextPageArrow.connect('clicked', + () => this.goToPage(this._grid.currentPage + 1)); + + this._prevPageArrow = new St.Button({ + style_class: 'page-navigation-arrow', + icon_name: rtl + ? 'carousel-arrow-next-symbolic' + : 'carousel-arrow-previous-symbolic', + opacity: 0, + visible: false, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + this._prevPageArrow.connect('clicked', + () => this.goToPage(this._grid.currentPage - 1)); + + const scrollContainer = new St.Widget({ + clip_to_allocation: true, + y_expand: true, + }); + scrollContainer.add_child(this._scrollView); + scrollContainer.add_child(this._prevPageIndicator); + scrollContainer.add_child(this._nextPageIndicator); + scrollContainer.add_child(this._nextPageArrow); + scrollContainer.add_child(this._prevPageArrow); + scrollContainer.layoutManager = new BaseAppViewGridLayout( + this._grid, + this._scrollView, + this._nextPageIndicator, + this._nextPageArrow, + this._prevPageIndicator, + this._prevPageArrow); + this._appGridLayout = scrollContainer.layoutManager; + scrollContainer._delegate = this; + + this._box = new St.BoxLayout({ + vertical: true, + x_expand: true, + y_expand: true, + }); + this._box.add_child(scrollContainer); + this._box.add_child(this._pageIndicators); + + // Swipe + this._swipeTracker = new SwipeTracker.SwipeTracker(this._scrollView, + Clutter.Orientation.HORIZONTAL, this.gestureModes); + this._swipeTracker.orientation = Clutter.Orientation.HORIZONTAL; + this._swipeTracker.connect('begin', this._swipeBegin.bind(this)); + this._swipeTracker.connect('update', this._swipeUpdate.bind(this)); + this._swipeTracker.connect('end', this._swipeEnd.bind(this)); + + this._orientation = Clutter.Orientation.HORIZONTAL; + + this._items = new Map(); + this._orderedItems = []; + + // Filter the apps through the user’s parental controls. + this._parentalControlsManager = ParentalControlsManager.getDefault(); + this._parentalControlsManager.connectObject('app-filter-changed', + () => this._redisplay(), this); + + // Don't duplicate favorites + this._appFavorites = AppFavorites.getAppFavorites(); + this._appFavorites.connectObject('changed', + () => this._redisplay(), this); + + // Drag n' Drop + this._overshootTimeoutId = 0; + this._delayedMoveData = null; + + this._dragBeginId = 0; + this._dragEndId = 0; + this._dragCancelledId = 0; + + this.connect('destroy', this._onDestroy.bind(this)); + + this._previewedPages = new Map(); + } + + _onDestroy() { + if (this._swipeTracker) { + this._swipeTracker.destroy(); + delete this._swipeTracker; + } + + this._removeDelayedMove(); + this._disconnectDnD(); + } + + _createGrid() { + return new AppGrid({allow_incomplete_pages: true}); + } + + _onScroll(actor, event) { + if (this._swipeTracker.canHandleScrollEvent(event)) + return Clutter.EVENT_PROPAGATE; + + if (!this._canScroll) + return Clutter.EVENT_STOP; + + const rtl = this.get_text_direction() === Clutter.TextDirection.RTL; + const vertical = this._orientation === Clutter.Orientation.VERTICAL; + + let nextPage = this._grid.currentPage; + switch (event.get_scroll_direction()) { + case Clutter.ScrollDirection.UP: + nextPage -= 1; + break; + + case Clutter.ScrollDirection.DOWN: + nextPage += 1; + break; + + case Clutter.ScrollDirection.LEFT: + if (vertical) + return Clutter.EVENT_STOP; + nextPage += rtl ? 1 : -1; + break; + + case Clutter.ScrollDirection.RIGHT: + if (vertical) + return Clutter.EVENT_STOP; + nextPage += rtl ? -1 : 1; + break; + + default: + return Clutter.EVENT_STOP; + } + + this.goToPage(nextPage); + + this._canScroll = false; + this._scrollTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, + SCROLL_TIMEOUT_TIME, () => { + this._canScroll = true; + this._scrollTimeoutId = 0; + return GLib.SOURCE_REMOVE; + }); + + return Clutter.EVENT_STOP; + } + + _swipeBegin(tracker, monitor) { + if (monitor !== Main.layoutManager.primaryIndex) + return; + + if (this._dragFocus) { + this._dragFocus.cancelActions(); + this._dragFocus = null; + } + + const adjustment = this._adjustment; + adjustment.remove_transition('value'); + + const progress = adjustment.value / adjustment.page_size; + const points = Array.from({ length: this._grid.nPages }, (v, i) => i); + const size = tracker.orientation === Clutter.Orientation.VERTICAL + ? this._grid.allocation.get_height() : this._grid.allocation.get_width(); + + tracker.confirmSwipe(size, points, progress, Math.round(progress)); + } + + _swipeUpdate(tracker, progress) { + const adjustment = this._adjustment; + adjustment.value = progress * adjustment.page_size; + } + + _swipeEnd(tracker, duration, endProgress) { + const adjustment = this._adjustment; + const value = endProgress * adjustment.page_size; + + adjustment.ease(value, { + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + duration, + onComplete: () => this.goToPage(endProgress, false), + }); + } + + _connectDnD() { + this._dragBeginId = + Main.overview.connect('item-drag-begin', this._onDragBegin.bind(this)); + this._dragEndId = + Main.overview.connect('item-drag-end', this._onDragEnd.bind(this)); + this._dragCancelledId = + Main.overview.connect('item-drag-cancelled', this._onDragCancelled.bind(this)); + } + + _disconnectDnD() { + if (this._dragBeginId > 0) { + Main.overview.disconnect(this._dragBeginId); + this._dragBeginId = 0; + } + + if (this._dragEndId > 0) { + Main.overview.disconnect(this._dragEndId); + this._dragEndId = 0; + } + + if (this._dragCancelledId > 0) { + Main.overview.disconnect(this._dragCancelledId); + this._dragCancelledId = 0; + } + + if (this._dragMonitor) { + DND.removeDragMonitor(this._dragMonitor); + this._dragMonitor = null; + } + } + + _maybeMoveItem(dragEvent) { + const [success, x, y] = + this._grid.transform_stage_point(dragEvent.x, dragEvent.y); + + if (!success) + return; + + const { source } = dragEvent; + const [page, position, dragLocation] = + this._getDropTarget(x, y, source); + const item = position !== -1 + ? this._grid.getItemAt(page, position) : null; + + + // Dragging over invalid parts of the grid cancels the timeout + if (item === source || + dragLocation === IconGrid.DragLocation.INVALID || + dragLocation === IconGrid.DragLocation.ON_ICON) { + this._removeDelayedMove(); + return; + } + + if (!this._delayedMoveData || + this._delayedMoveData.page !== page || + this._delayedMoveData.position !== position) { + // Update the item with a small delay + this._removeDelayedMove(); + this._delayedMoveData = { + page, + position, + source, + destroyId: source.connect('destroy', () => this._removeDelayedMove()), + timeoutId: GLib.timeout_add(GLib.PRIORITY_DEFAULT, + DELAYED_MOVE_TIMEOUT, () => { + this._moveItem(source, page, position); + this._delayedMoveData.timeoutId = 0; + this._removeDelayedMove(); + return GLib.SOURCE_REMOVE; + }), + }; + } + } + + _removeDelayedMove() { + if (!this._delayedMoveData) + return; + + const { source, destroyId, timeoutId } = this._delayedMoveData; + + if (timeoutId > 0) + GLib.source_remove(timeoutId); + + if (destroyId > 0) + source.disconnect(destroyId); + + this._delayedMoveData = null; + } + + _resetOvershoot() { + if (this._overshootTimeoutId) + GLib.source_remove(this._overshootTimeoutId); + this._overshootTimeoutId = 0; + } + + _dragWithinOvershootRegion(dragEvent) { + const rtl = this.get_text_direction() === Clutter.TextDirection.RTL; + const {x, y, targetActor: indicator} = dragEvent; + const [indicatorX, indicatorY] = indicator.get_transformed_position(); + const [indicatorWidth, indicatorHeight] = indicator.get_transformed_size(); + + let overshootX = indicatorX; + if (indicator === this._nextPageIndicator || rtl) + overshootX += indicatorWidth - OVERSHOOT_THRESHOLD; + + const overshootBox = new Clutter.ActorBox(); + overshootBox.set_origin(overshootX, indicatorY); + overshootBox.set_size(OVERSHOOT_THRESHOLD, indicatorHeight); + + return overshootBox.contains(x, y); + } + + _handleDragOvershoot(dragEvent) { + // Already animating + if (this._adjustment.get_transition('value') !== null) + return; + + const {targetActor} = dragEvent; + + if (targetActor !== this._prevPageIndicator && + targetActor !== this._nextPageIndicator) { + this._resetOvershoot(); + return; + } + + if (this._overshootTimeoutId > 0) + return; + + let targetPage; + if (dragEvent.targetActor === this._prevPageIndicator) + targetPage = this._grid.currentPage - 1; + else + targetPage = this._grid.currentPage + 1; + + if (targetPage < 0 || targetPage >= this._grid.nPages) + return; // don't go beyond first/last page + + // If dragging over the drag overshoot threshold region, immediately + // switch pages + if (this._dragWithinOvershootRegion(dragEvent)) { + this._resetOvershoot(); + this.goToPage(targetPage); + } + + this._overshootTimeoutId = + GLib.timeout_add(GLib.PRIORITY_DEFAULT, OVERSHOOT_TIMEOUT, () => { + this._resetOvershoot(); + this.goToPage(targetPage); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._overshootTimeoutId, + '[gnome-shell] this._overshootTimeoutId'); + } + + _onDragBegin() { + this._dragMonitor = { + dragMotion: this._onDragMotion.bind(this), + dragDrop: this._onDragDrop.bind(this), + }; + DND.addDragMonitor(this._dragMonitor); + this._appGridLayout.showPageIndicators(); + this._dragFocus = null; + this._swipeTracker.enabled = false; + } + + _onDragMotion(dragEvent) { + if (!(dragEvent.source instanceof AppViewItem)) + return DND.DragMotionResult.CONTINUE; + + const appIcon = dragEvent.source; + + // Handle the drag overshoot. When dragging to above the + // icon grid, move to the page above; when dragging below, + // move to the page below. + if (appIcon instanceof AppViewItem) + this._handleDragOvershoot(dragEvent); + + this._maybeMoveItem(dragEvent); + + return DND.DragMotionResult.CONTINUE; + } + + _onDragDrop(dropEvent) { + // Because acceptDrop() does not receive the target actor, store it + // here and use this value in the acceptDrop() implementation below. + this._dropTarget = dropEvent.targetActor; + return DND.DragMotionResult.CONTINUE; + } + + _onDragEnd() { + if (this._dragMonitor) { + DND.removeDragMonitor(this._dragMonitor); + this._dragMonitor = null; + } + + this._resetOvershoot(); + this._appGridLayout.hidePageIndicators(); + this._swipeTracker.enabled = true; + } + + _onDragCancelled() { + // At this point, the positions aren't stored yet, thus _redisplay() + // will move all items to their original positions + this._redisplay(); + this._appGridLayout.hidePageIndicators(); + this._swipeTracker.enabled = true; + } + + _canAccept(source) { + return source instanceof AppViewItem; + } + + handleDragOver(source) { + if (!this._canAccept(source)) + return DND.DragMotionResult.NO_DROP; + + return DND.DragMotionResult.MOVE_DROP; + } + + acceptDrop(source) { + const dropTarget = this._dropTarget; + delete this._dropTarget; + + if (!this._canAccept(source)) + return false; + + if (dropTarget === this._prevPageIndicator || + dropTarget === this._nextPageIndicator) { + const increment = dropTarget === this._prevPageIndicator ? -1 : 1; + const { currentPage, nPages } = this._grid; + const page = Math.min(currentPage + increment, nPages); + const position = page < nPages ? -1 : 0; + + this._moveItem(source, page, position); + this.goToPage(page); + } else if (this._delayedMoveData) { + // Dropped before the icon was moved + const { page, position } = this._delayedMoveData; + + this._moveItem(source, page, position); + this._removeDelayedMove(); + } + + return true; + } + + _findBestPageToAppend(startPage = 1) { + for (let i = startPage; i < this._grid.nPages; i++) { + const pageItems = + this._grid.getItemsAtPage(i).filter(c => c.visible); + + if (pageItems.length < this._grid.itemsPerPage) + return i; + } + + return -1; + } + + _getLinearPosition(page, position) { + let itemIndex = 0; + + if (this._grid.nPages > 0) { + const realPage = page === -1 ? this._grid.nPages - 1 : page; + + itemIndex = position === -1 + ? this._grid.getItemsAtPage(realPage).filter(c => c.visible).length - 1 + : position; + + for (let i = 0; i < realPage; i++) { + const pageItems = this._grid.getItemsAtPage(i).filter(c => c.visible); + itemIndex += pageItems.length; + } + } + + return itemIndex; + } + + _addItem(item, page, position) { + // Append icons to the first page with empty slot, starting from + // the second page + if (this._grid.nPages > 1 && page === -1 && position === -1) + page = this._findBestPageToAppend(); + + const itemIndex = this._getLinearPosition(page, position); + + this._orderedItems.splice(itemIndex, 0, item); + this._items.set(item.id, item); + this._grid.addItem(item, page, position); + } + + _removeItem(item) { + const iconIndex = this._orderedItems.indexOf(item); + + this._orderedItems.splice(iconIndex, 1); + this._items.delete(item.id); + this._grid.removeItem(item); + } + + _getItemPosition(item) { + const { itemsPerPage } = this._grid; + + let iconIndex = this._orderedItems.indexOf(item); + if (iconIndex === -1) + iconIndex = this._orderedItems.length - 1; + + const page = Math.floor(iconIndex / itemsPerPage); + const position = iconIndex % itemsPerPage; + + return [page, position]; + } + + _redisplay() { + let oldApps = this._orderedItems.slice(); + let oldAppIds = oldApps.map(icon => icon.id); + + let newApps = this._loadApps().sort(this._compareItems.bind(this)); + let newAppIds = newApps.map(icon => icon.id); + + let addedApps = newApps.filter(icon => !oldAppIds.includes(icon.id)); + let removedApps = oldApps.filter(icon => !newAppIds.includes(icon.id)); + + // Remove old app icons + removedApps.forEach(icon => { + this._removeItem(icon); + icon.destroy(); + }); + + // Add new app icons, or move existing ones + newApps.forEach(icon => { + const [page, position] = this._getItemPosition(icon); + if (addedApps.includes(icon)) + this._addItem(icon, page, position); + else if (page !== -1 && position !== -1) + this._moveItem(icon, page, position); + }); + + this.emit('view-loaded'); + } + + getAllItems() { + return this._orderedItems; + } + + _compareItems(a, b) { + return a.name.localeCompare(b.name); + } + + _selectAppInternal(id) { + if (this._items.has(id)) + this._items.get(id).navigate_focus(null, St.DirectionType.TAB_FORWARD, false); + else + log(`No such application ${id}`); + } + + selectApp(id) { + if (this._items.has(id)) { + let item = this._items.get(id); + + if (item.mapped) { + this._selectAppInternal(id); + } else { + // Need to wait until the view is mapped + let signalId = item.connect('notify::mapped', actor => { + if (actor.mapped) { + actor.disconnect(signalId); + this._selectAppInternal(id); + } + }); + } + } else { + // Need to wait until the view is built + let signalId = this.connect('view-loaded', () => { + this.disconnect(signalId); + this.selectApp(id); + }); + } + } + + _getDropTarget(x, y, source) { + const { currentPage } = this._grid; + + let [item, dragLocation] = this._grid.getDropTarget(x, y); + + const [sourcePage, sourcePosition] = this._grid.getItemPosition(source); + const targetPage = currentPage; + let targetPosition = item + ? this._grid.getItemPosition(item)[1] : -1; + + // In case we're hovering over the edge of an item but the + // reflow will happen in the opposite direction (the drag + // can't "naturally push the item away"), we instead set the + // drop target to the adjacent item that can be pushed away + // in the reflow-direction. + // + // We must avoid doing that if we're hovering over the first + // or last column though, in that case there is no adjacent + // icon we could push away. + if (dragLocation === IconGrid.DragLocation.START_EDGE && + targetPosition > sourcePosition && + targetPage === sourcePage) { + const nColumns = this._grid.layout_manager.columns_per_page; + const targetColumn = targetPosition % nColumns; + + if (targetColumn > 0) { + targetPosition -= 1; + dragLocation = IconGrid.DragLocation.END_EDGE; + } + } else if (dragLocation === IconGrid.DragLocation.END_EDGE && + (targetPosition < sourcePosition || + targetPage !== sourcePage)) { + const nColumns = this._grid.layout_manager.columns_per_page; + const targetColumn = targetPosition % nColumns; + + if (targetColumn < nColumns - 1) { + targetPosition += 1; + dragLocation = IconGrid.DragLocation.START_EDGE; + } + } + + // Append to the page if dragging over empty area + if (dragLocation === IconGrid.DragLocation.EMPTY_SPACE) { + const pageItems = + this._grid.getItemsAtPage(currentPage).filter(c => c.visible); + + targetPosition = pageItems.length; + } + + return [targetPage, targetPosition, dragLocation]; + } + + _moveItem(item, newPage, newPosition) { + const [page, position] = this._grid.getItemPosition(item); + if (page === newPage && position === newPosition) + return; + + // Update the _orderedItems array + let index = this._orderedItems.indexOf(item); + this._orderedItems.splice(index, 1); + + index = this._getLinearPosition(newPage, newPosition); + this._orderedItems.splice(index, 0, item); + + this._grid.moveItem(item, newPage, newPosition); + } + + vfunc_map() { + this._swipeTracker.enabled = true; + this._connectDnD(); + super.vfunc_map(); + } + + vfunc_unmap() { + if (this._swipeTracker) + this._swipeTracker.enabled = false; + this._disconnectDnD(); + super.vfunc_unmap(); + } + + animateSwitch(animationDirection) { + this.remove_all_transitions(); + this._grid.remove_all_transitions(); + + let params = { + duration: VIEWS_SWITCH_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }; + if (animationDirection == IconGrid.AnimationDirection.IN) { + this.show(); + params.opacity = 255; + params.delay = VIEWS_SWITCH_ANIMATION_DELAY; + } else { + params.opacity = 0; + params.delay = 0; + params.onComplete = () => this.hide(); + } + + this._grid.ease(params); + } + + goToPage(pageNumber, animate = true) { + pageNumber = Math.clamp(pageNumber, 0, Math.max(this._grid.nPages - 1, 0)); + + if (this._grid.currentPage === pageNumber) + return; + + this._appGridLayout.goToPage(pageNumber, animate); + this._grid.goToPage(pageNumber, animate); + } + + updateDragFocus(dragFocus) { + this._dragFocus = dragFocus; + } +}); + +var PageManager = GObject.registerClass({ + Signals: { 'layout-changed': {} }, +}, class PageManager extends GObject.Object { + _init() { + super._init(); + + this._updatingPages = false; + this._loadPages(); + + global.settings.connect('changed::app-picker-layout', + this._loadPages.bind(this)); + } + + _loadPages() { + const layout = global.settings.get_value('app-picker-layout'); + this._pages = layout.recursiveUnpack(); + if (!this._updatingPages) + this.emit('layout-changed'); + } + + getAppPosition(appId) { + let position = -1; + let page = -1; + + for (let pageIndex = 0; pageIndex < this._pages.length; pageIndex++) { + const pageData = this._pages[pageIndex]; + + if (appId in pageData) { + page = pageIndex; + position = pageData[appId].position; + break; + } + } + + return [page, position]; + } + + set pages(p) { + const packedPages = []; + + // Pack the icon properties as a GVariant + for (const page of p) { + const pageData = {}; + for (const [appId, properties] of Object.entries(page)) + pageData[appId] = new GLib.Variant('a{sv}', properties); + packedPages.push(pageData); + } + + this._updatingPages = true; + + const variant = new GLib.Variant('aa{sv}', packedPages); + global.settings.set_value('app-picker-layout', variant); + + this._updatingPages = false; + } + + get pages() { + return this._pages; + } +}); + +var AppDisplay = GObject.registerClass( +class AppDisplay extends BaseAppView { + _init() { + super._init({ + layout_manager: new Clutter.BinLayout(), + x_expand: true, + y_expand: true, + }); + + this._pageManager = new PageManager(); + this._pageManager.connect('layout-changed', () => this._redisplay()); + + this.add_child(this._box); + + this._folderIcons = []; + + this._currentDialog = null; + this._displayingDialog = false; + + this._placeholder = null; + + this._overviewHiddenId = 0; + this._redisplayWorkId = Main.initializeDeferredWork(this, () => { + this._redisplay(); + if (this._overviewHiddenId === 0) + this._overviewHiddenId = Main.overview.connect('hidden', () => this.goToPage(0)); + }); + + Shell.AppSystem.get_default().connect('installed-changed', () => { + Main.queueDeferredWork(this._redisplayWorkId); + }); + this._folderSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.app-folders' }); + this._ensureDefaultFolders(); + this._folderSettings.connect('changed::folder-children', () => { + Main.queueDeferredWork(this._redisplayWorkId); + }); + } + + _onDestroy() { + super._onDestroy(); + + if (this._scrollTimeoutId !== 0) { + GLib.source_remove(this._scrollTimeoutId); + this._scrollTimeoutId = 0; + } + } + + vfunc_map() { + this._keyPressEventId = + global.stage.connect('key-press-event', + this._onKeyPressEvent.bind(this)); + super.vfunc_map(); + } + + vfunc_unmap() { + if (this._keyPressEventId) { + global.stage.disconnect(this._keyPressEventId); + this._keyPressEventId = 0; + } + super.vfunc_unmap(); + } + + _redisplay() { + this._folderIcons.forEach(icon => { + icon.view._redisplay(); + }); + + super._redisplay(); + } + + _savePages() { + const pages = []; + + for (let i = 0; i < this._grid.nPages; i++) { + const pageItems = + this._grid.getItemsAtPage(i).filter(c => c.visible); + const pageData = {}; + + pageItems.forEach((item, index) => { + pageData[item.id] = { + position: GLib.Variant.new_int32(index), + }; + }); + pages.push(pageData); + } + + this._pageManager.pages = pages; + } + + _ensureDefaultFolders() { + if (this._folderSettings.get_strv('folder-children').length > 0) + return; + + const folders = Object.keys(DEFAULT_FOLDERS); + this._folderSettings.set_strv('folder-children', folders); + + const { path } = this._folderSettings; + for (const folder of folders) { + const { name, categories, apps } = DEFAULT_FOLDERS[folder]; + const child = new Gio.Settings({ + schema_id: 'org.gnome.desktop.app-folders.folder', + path: `${path}folders/${folder}/`, + }); + child.set_string('name', name); + child.set_boolean('translate', true); + child.set_strv('categories', categories); + if (apps) + child.set_strv('apps', apps); + } + } + + _ensurePlaceholder(source) { + if (this._placeholder) + return; + + const appSys = Shell.AppSystem.get_default(); + const app = appSys.lookup_app(source.id); + + const isDraggable = + global.settings.is_writable('favorite-apps') || + global.settings.is_writable('app-picker-layout'); + + this._placeholder = new AppIcon(app, { isDraggable }); + this._placeholder.connect('notify::pressed', icon => { + if (icon.pressed) + this.updateDragFocus(icon); + }); + this._placeholder.scaleAndFade(); + this._redisplay(); + } + + _removePlaceholder() { + if (this._placeholder) { + this._placeholder.undoScaleAndFade(); + this._placeholder = null; + this._redisplay(); + } + } + + getAppInfos() { + return this._appInfoList; + } + + _getItemPosition(item) { + if (item === this._placeholder) { + let [page, position] = this._grid.getItemPosition(item); + + if (page === -1) + page = this._findBestPageToAppend(this._grid.currentPage); + + return [page, position]; + } + + return this._pageManager.getAppPosition(item.id); + } + + _compareItems(a, b) { + const [aPage, aPosition] = this._getItemPosition(a); + const [bPage, bPosition] = this._getItemPosition(b); + + if (aPage === -1 && bPage === -1) + return a.name.localeCompare(b.name); + else if (aPage === -1) + return 1; + else if (bPage === -1) + return -1; + + if (aPage !== bPage) + return aPage - bPage; + + return aPosition - bPosition; + } + + _loadApps() { + let appIcons = []; + this._appInfoList = Shell.AppSystem.get_default().get_installed().filter(appInfo => { + try { + appInfo.get_id(); // catch invalid file encodings + } catch (e) { + return false; + } + return !this._appFavorites.isFavorite(appInfo.get_id()) && + this._parentalControlsManager.shouldShowApp(appInfo); + }); + + let apps = this._appInfoList.map(app => app.get_id()); + + let appSys = Shell.AppSystem.get_default(); + + const appsInsideFolders = new Set(); + this._folderIcons = []; + + let folders = this._folderSettings.get_strv('folder-children'); + folders.forEach(id => { + let path = `${this._folderSettings.path}folders/${id}/`; + let icon = this._items.get(id); + if (!icon) { + icon = new FolderIcon(id, path, this); + icon.connect('apps-changed', () => { + this._redisplay(); + this._savePages(); + }); + icon.connect('notify::pressed', () => { + if (icon.pressed) + this.updateDragFocus(icon); + }); + } + + // Don't try to display empty folders + if (!icon.visible) { + icon.destroy(); + return; + } + + appIcons.push(icon); + this._folderIcons.push(icon); + + icon.getAppIds().forEach(appId => appsInsideFolders.add(appId)); + }); + + // Allow dragging of the icon only if the Dash would accept a drop to + // change favorite-apps. There are no other possible drop targets from + // the app picker, so there's no other need for a drag to start, + // at least on single-monitor setups. + // This also disables drag-to-launch on multi-monitor setups, + // but we hope that is not used much. + const isDraggable = + global.settings.is_writable('favorite-apps') || + global.settings.is_writable('app-picker-layout'); + + apps.forEach(appId => { + if (appsInsideFolders.has(appId)) + return; + + let icon = this._items.get(appId); + if (!icon) { + let app = appSys.lookup_app(appId); + + icon = new AppIcon(app, { isDraggable }); + icon.connect('notify::pressed', () => { + if (icon.pressed) + this.updateDragFocus(icon); + }); + } + + appIcons.push(icon); + }); + + // At last, if there's a placeholder available, add it + if (this._placeholder) + appIcons.push(this._placeholder); + + return appIcons; + } + + animateSwitch(animationDirection) { + super.animateSwitch(animationDirection); + + if (this._currentDialog && this._displayingDialog && + animationDirection == IconGrid.AnimationDirection.OUT) { + this._currentDialog.ease({ + opacity: 0, + duration: VIEWS_SWITCH_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => (this.opacity = 255), + }); + } + } + + goToPage(pageNumber, animate = true) { + pageNumber = Math.clamp(pageNumber, 0, Math.max(this._grid.nPages - 1, 0)); + + if (this._grid.currentPage === pageNumber && + this._displayingDialog && + this._currentDialog) + return; + if (this._displayingDialog && this._currentDialog) + this._currentDialog.popdown(); + + super.goToPage(pageNumber, animate); + } + + _onScroll(actor, event) { + if (this._displayingDialog || !this._scrollView.reactive) + return Clutter.EVENT_STOP; + + return super._onScroll(actor, event); + } + + _onKeyPressEvent(actor, event) { + if (this._displayingDialog) + return Clutter.EVENT_STOP; + + if (event.get_key_symbol() === Clutter.KEY_Page_Up) { + this.goToPage(this._grid.currentPage - 1); + return Clutter.EVENT_STOP; + } else if (event.get_key_symbol() === Clutter.KEY_Page_Down) { + this.goToPage(this._grid.currentPage + 1); + return Clutter.EVENT_STOP; + } else if (event.get_key_symbol() === Clutter.KEY_Home) { + this.goToPage(0); + return Clutter.EVENT_STOP; + } else if (event.get_key_symbol() === Clutter.KEY_End) { + this.goToPage(this._grid.nPages - 1); + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_PROPAGATE; + } + + addFolderDialog(dialog) { + Main.layoutManager.overviewGroup.add_child(dialog); + dialog.connect('open-state-changed', (o, isOpen) => { + this._currentDialog?.disconnectObject(this); + + this._currentDialog = null; + + if (isOpen) { + this._currentDialog = dialog; + this._currentDialog.connectObject('destroy', + () => (this._currentDialog = null), this); + } + this._displayingDialog = isOpen; + }); + } + + _maybeMoveItem(dragEvent) { + const clonedEvent = { + ...dragEvent, + source: this._placeholder ? this._placeholder : dragEvent.source, + }; + + super._maybeMoveItem(clonedEvent); + } + + _onDragBegin(overview, source) { + super._onDragBegin(overview, source); + + // When dragging from a folder dialog, the dragged app icon doesn't + // exist in AppDisplay. We work around that by adding a placeholder + // icon that is either destroyed on cancel, or becomes the effective + // new icon when dropped. + if (_getViewFromIcon(source) instanceof FolderView || + this._appFavorites.isFavorite(source.id)) + this._ensurePlaceholder(source); + } + + _onDragMotion(dragEvent) { + if (this._currentDialog) + return DND.DragMotionResult.CONTINUE; + + return super._onDragMotion(dragEvent); + } + + _onDragEnd() { + super._onDragEnd(); + this._removePlaceholder(); + this._savePages(); + } + + _onDragCancelled(overview, source) { + const view = _getViewFromIcon(source); + + if (view instanceof FolderView) + return; + + super._onDragCancelled(overview, source); + } + + acceptDrop(source) { + if (!super.acceptDrop(source)) + return false; + + this._savePages(); + + let view = _getViewFromIcon(source); + if (view instanceof FolderView) + view.removeApp(source.app); + + if (this._currentDialog) + this._currentDialog.popdown(); + + if (this._appFavorites.isFavorite(source.id)) + this._appFavorites.removeFavorite(source.id); + + return true; + } + + createFolder(apps) { + let newFolderId = GLib.uuid_string_random(); + + let folders = this._folderSettings.get_strv('folder-children'); + folders.push(newFolderId); + this._folderSettings.set_strv('folder-children', folders); + + // Create the new folder + let newFolderPath = this._folderSettings.path.concat('folders/', newFolderId, '/'); + let newFolderSettings; + try { + newFolderSettings = new Gio.Settings({ + schema_id: 'org.gnome.desktop.app-folders.folder', + path: newFolderPath, + }); + } catch (e) { + log('Error creating new folder'); + return false; + } + + // The hovered AppIcon always passes its own id as the first + // one, and this is where we want the folder to be created + let [folderPage, folderPosition] = + this._grid.getItemPosition(this._items.get(apps[0])); + + // Adjust the final position + folderPosition -= apps.reduce((counter, appId) => { + const [page, position] = + this._grid.getItemPosition(this._items.get(appId)); + if (page === folderPage && position < folderPosition) + counter++; + return counter; + }, 0); + + let appItems = apps.map(id => this._items.get(id).app); + let folderName = _findBestFolderName(appItems); + if (!folderName) + folderName = _("Unnamed Folder"); + + newFolderSettings.delay(); + newFolderSettings.set_string('name', folderName); + newFolderSettings.set_strv('apps', apps); + newFolderSettings.apply(); + + this._redisplay(); + + // Move the folder to where the icon target icon was + const folderItem = this._items.get(newFolderId); + this._moveItem(folderItem, folderPage, folderPosition); + this._savePages(); + + return true; + } +}); + +var AppSearchProvider = class AppSearchProvider { + constructor() { + this._appSys = Shell.AppSystem.get_default(); + this.id = 'applications'; + this.isRemoteProvider = false; + this.canLaunchSearch = false; + + this._systemActions = new SystemActions.getDefault(); + + this._parentalControlsManager = ParentalControlsManager.getDefault(); + } + + getResultMetas(apps) { + const { scaleFactor } = St.ThemeContext.get_for_stage(global.stage); + let metas = []; + for (let id of apps) { + if (id.endsWith('.desktop')) { + let app = this._appSys.lookup_app(id); + + metas.push({ + id: app.get_id(), + name: app.get_name(), + createIcon: size => app.create_icon_texture(size), + }); + } else { + let name = this._systemActions.getName(id); + let iconName = this._systemActions.getIconName(id); + + const createIcon = size => new St.Icon({ + icon_name: iconName, + width: size * scaleFactor, + height: size * scaleFactor, + style_class: 'system-action-icon', + }); + + metas.push({ id, name, createIcon }); + } + } + + return new Promise(resolve => resolve(metas)); + } + + filterResults(results, maxNumber) { + return results.slice(0, maxNumber); + } + + getInitialResultSet(terms, cancellable) { + // Defer until the parental controls manager is initialised, so the + // results can be filtered correctly. + if (!this._parentalControlsManager.initialized) { + return new Promise(resolve => { + let initializedId = this._parentalControlsManager.connect('app-filter-changed', async () => { + if (this._parentalControlsManager.initialized) { + this._parentalControlsManager.disconnect(initializedId); + resolve(await this.getInitialResultSet(terms, cancellable)); + } + }); + }); + } + + let query = terms.join(' '); + let groups = Shell.AppSystem.search(query); + let usage = Shell.AppUsage.get_default(); + let results = []; + + groups.forEach(group => { + group = group.filter(appID => { + const app = this._appSys.lookup_app(appID); + return app && this._parentalControlsManager.shouldShowApp(app.app_info); + }); + results = results.concat(group.sort( + (a, b) => usage.compare(a, b))); + }); + + results = results.concat(this._systemActions.getMatchingActions(terms)); + return new Promise(resolve => resolve(results)); + } + + getSubsearchResultSet(previousResults, terms, cancellable) { + return this.getInitialResultSet(terms, cancellable); + } + + createResultObject(resultMeta) { + if (resultMeta.id.endsWith('.desktop')) { + return new AppIcon(this._appSys.lookup_app(resultMeta['id']), { + expandTitleOnHover: false, + }); + } else { + return new SystemActionIcon(this, resultMeta); + } + } +}; + +var AppViewItem = GObject.registerClass( +class AppViewItem extends St.Button { + _init(params = {}, isDraggable = true, expandTitleOnHover = true) { + super._init({ + pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }), + reactive: true, + button_mask: St.ButtonMask.ONE | St.ButtonMask.TWO, + can_focus: true, + ...params, + }); + + this._delegate = this; + + if (isDraggable) { + this._draggable = DND.makeDraggable(this, { timeoutThreshold: 200 }); + + this._draggable.connect('drag-begin', this._onDragBegin.bind(this)); + this._draggable.connect('drag-cancelled', this._onDragCancelled.bind(this)); + this._draggable.connect('drag-end', this._onDragEnd.bind(this)); + } + + this._otherIconIsHovering = false; + this._expandTitleOnHover = expandTitleOnHover; + + if (expandTitleOnHover) + this.connect('notify::hover', this._onHover.bind(this)); + this.connect('destroy', this._onDestroy.bind(this)); + } + + _onDestroy() { + if (this._dragMonitor) { + DND.removeDragMonitor(this._dragMonitor); + this._dragMonitor = null; + } + + if (this._draggable) { + if (this._dragging) + Main.overview.endItemDrag(this); + this._draggable = null; + } + } + + _updateMultiline() { + if (!this._expandTitleOnHover || !this.icon.label) + return; + + const { label } = this.icon; + const { clutterText } = label; + const layout = clutterText.get_layout(); + if (!layout.is_wrapped() && !layout.is_ellipsized()) + return; + + label.remove_transition('allocation'); + + const id = label.connect('notify::allocation', () => { + label.restore_easing_state(); + label.disconnect(id); + }); + + const expand = this._forcedHighlight || this.hover || this.has_key_focus(); + label.save_easing_state(); + label.set_easing_duration(expand + ? APP_ICON_TITLE_EXPAND_TIME + : APP_ICON_TITLE_COLLAPSE_TIME); + clutterText.set({ + line_wrap: expand, + line_wrap_mode: expand ? Pango.WrapMode.WORD_CHAR : Pango.WrapMode.NONE, + ellipsize: expand ? Pango.EllipsizeMode.NONE : Pango.EllipsizeMode.END, + }); + } + + _onHover() { + this._updateMultiline(); + } + + _onDragBegin() { + this._dragging = true; + this.scaleAndFade(); + Main.overview.beginItemDrag(this); + } + + _onDragCancelled() { + this._dragging = false; + Main.overview.cancelledItemDrag(this); + } + + _onDragEnd() { + this._dragging = false; + this.undoScaleAndFade(); + Main.overview.endItemDrag(this); + } + + scaleIn() { + this.scale_x = 0; + this.scale_y = 0; + + this.ease({ + scale_x: 1, + scale_y: 1, + duration: APP_ICON_SCALE_IN_TIME, + delay: APP_ICON_SCALE_IN_DELAY, + mode: Clutter.AnimationMode.EASE_OUT_QUINT, + }); + } + + scaleAndFade() { + this.reactive = false; + this.ease({ + scale_x: 0.5, + scale_y: 0.5, + opacity: 0, + }); + } + + undoScaleAndFade() { + this.reactive = true; + this.ease({ + scale_x: 1.0, + scale_y: 1.0, + opacity: 255, + }); + } + + _canAccept(source) { + return source !== this; + } + + _setHoveringByDnd(hovering) { + if (this._otherIconIsHovering === hovering) + return; + + this._otherIconIsHovering = hovering; + + if (hovering) { + this._dragMonitor = { + dragMotion: this._onDragMotion.bind(this), + }; + DND.addDragMonitor(this._dragMonitor); + } else { + DND.removeDragMonitor(this._dragMonitor); + } + } + + _onDragMotion(dragEvent) { + if (!this.contains(dragEvent.targetActor)) + this._setHoveringByDnd(false); + + return DND.DragMotionResult.CONTINUE; + } + + _withinLeeways(x) { + return x < IconGrid.LEFT_DIVIDER_LEEWAY || + x > this.width - IconGrid.RIGHT_DIVIDER_LEEWAY; + } + + vfunc_key_focus_in() { + this._updateMultiline(); + super.vfunc_key_focus_in(); + } + + vfunc_key_focus_out() { + this._updateMultiline(); + super.vfunc_key_focus_out(); + } + + handleDragOver(source, _actor, x) { + if (source === this) + return DND.DragMotionResult.NO_DROP; + + if (!this._canAccept(source)) + return DND.DragMotionResult.CONTINUE; + + if (this._withinLeeways(x)) { + this._setHoveringByDnd(false); + return DND.DragMotionResult.CONTINUE; + } + + this._setHoveringByDnd(true); + + return DND.DragMotionResult.MOVE_DROP; + } + + acceptDrop(source, _actor, x) { + this._setHoveringByDnd(false); + + if (!this._canAccept(source)) + return false; + + if (this._withinLeeways(x)) + return false; + + return true; + } + + cancelActions() { + if (this._draggable) + this._draggable.fakeRelease(); + this.fake_release(); + } + + get id() { + return this._id; + } + + get name() { + return this._name; + } + + setForcedHighlight(highlighted) { + this._forcedHighlight = highlighted; + this.set({ + track_hover: !highlighted, + hover: highlighted, + }); + } +}); + +var FolderGrid = GObject.registerClass( +class FolderGrid extends AppGrid { + _init() { + super._init({ + allow_incomplete_pages: false, + columns_per_page: 3, + rows_per_page: 3, + page_halign: Clutter.ActorAlign.CENTER, + page_valign: Clutter.ActorAlign.CENTER, + }); + + this.setGridModes([ + { + rows: 3, + columns: 3, + }, + ]); + } +}); + +var FolderView = GObject.registerClass( +class FolderView extends BaseAppView { + _init(folder, id, parentView) { + super._init({ + layout_manager: new Clutter.BinLayout(), + x_expand: true, + y_expand: true, + gesture_modes: Shell.ActionMode.POPUP, + }); + + // If it not expand, the parent doesn't take into account its preferred_width when allocating + // the second time it allocates, so we apply the "Standard hack for ClutterBinLayout" + this._grid.x_expand = true; + this._id = id; + this._folder = folder; + this._parentView = parentView; + this._grid._delegate = this; + + this.add_child(this._box); + + let action = new Clutter.PanAction({ interpolate: true }); + action.connect('pan', this._onPan.bind(this)); + this._scrollView.add_action(action); + + this._deletingFolder = false; + this._appIds = []; + this._redisplay(); + } + + _createGrid() { + return new FolderGrid(); + } + + _getFolderApps() { + const appIds = []; + const excludedApps = this._folder.get_strv('excluded-apps'); + const appSys = Shell.AppSystem.get_default(); + const addAppId = appId => { + if (excludedApps.includes(appId)) + return; + + if (this._appFavorites.isFavorite(appId)) + return; + + const app = appSys.lookup_app(appId); + if (!app) + return; + + if (!this._parentalControlsManager.shouldShowApp(app.get_app_info())) + return; + + if (appIds.indexOf(appId) !== -1) + return; + + appIds.push(appId); + }; + + const folderApps = this._folder.get_strv('apps'); + folderApps.forEach(addAppId); + + const folderCategories = this._folder.get_strv('categories'); + const appInfos = this._parentView.getAppInfos(); + appInfos.forEach(appInfo => { + let appCategories = _getCategories(appInfo); + if (!_listsIntersect(folderCategories, appCategories)) + return; + + addAppId(appInfo.get_id()); + }); + + return appIds; + } + + _getItemPosition(item) { + const appIndex = this._appIds.indexOf(item.id); + + if (appIndex === -1) + return [-1, -1]; + + const { itemsPerPage } = this._grid; + return [Math.floor(appIndex / itemsPerPage), appIndex % itemsPerPage]; + } + + _compareItems(a, b) { + const aPosition = this._appIds.indexOf(a.id); + const bPosition = this._appIds.indexOf(b.id); + + if (aPosition === -1 && bPosition === -1) + return a.name.localeCompare(b.name); + else if (aPosition === -1) + return 1; + else if (bPosition === -1) + return -1; + + return aPosition - bPosition; + } + + createFolderIcon(size) { + const layout = new Clutter.GridLayout({ + row_homogeneous: true, + column_homogeneous: true, + }); + let icon = new St.Widget({ + layout_manager: layout, + x_align: Clutter.ActorAlign.CENTER, + style: `width: ${size}px; height: ${size}px;`, + }); + + let subSize = Math.floor(FOLDER_SUBICON_FRACTION * size); + + let numItems = this._orderedItems.length; + let rtl = icon.get_text_direction() == Clutter.TextDirection.RTL; + for (let i = 0; i < 4; i++) { + const style = `width: ${subSize}px; height: ${subSize}px;`; + let bin = new St.Bin({ style }); + if (i < numItems) + bin.child = this._orderedItems[i].app.create_icon_texture(subSize); + layout.attach(bin, rtl ? (i + 1) % 2 : i % 2, Math.floor(i / 2), 1, 1); + } + + return icon; + } + + _onPan(action) { + let [dist_, dx_, dy] = action.get_motion_delta(0); + let adjustment = this._scrollView.vscroll.adjustment; + adjustment.value -= (dy / this._scrollView.height) * adjustment.page_size; + return false; + } + + _loadApps() { + let apps = []; + let appSys = Shell.AppSystem.get_default(); + + this._appIds.forEach(appId => { + const app = appSys.lookup_app(appId); + + let icon = this._items.get(appId); + if (!icon) + icon = new AppIcon(app); + + apps.push(icon); + }); + + return apps; + } + + _redisplay() { + // Keep the app ids list cached + this._appIds = this._getFolderApps(); + + super._redisplay(); + } + + acceptDrop(source) { + if (!super.acceptDrop(source)) + return false; + + const folderApps = this._orderedItems.map(item => item.id); + this._folder.set_strv('apps', folderApps); + + return true; + } + + addApp(app) { + let folderApps = this._folder.get_strv('apps'); + folderApps.push(app.id); + + this._folder.set_strv('apps', folderApps); + + // Also remove from 'excluded-apps' if the app id is listed + // there. This is only possible on categories-based folders. + let excludedApps = this._folder.get_strv('excluded-apps'); + let index = excludedApps.indexOf(app.id); + if (index >= 0) { + excludedApps.splice(index, 1); + this._folder.set_strv('excluded-apps', excludedApps); + } + } + + removeApp(app) { + let folderApps = this._folder.get_strv('apps'); + let index = folderApps.indexOf(app.id); + if (index >= 0) + folderApps.splice(index, 1); + + // Remove the folder if this is the last app icon; otherwise, + // just remove the icon + if (folderApps.length == 0) { + this._deletingFolder = true; + + // Resetting all keys deletes the relocatable schema + let keys = this._folder.settings_schema.list_keys(); + for (const key of keys) + this._folder.reset(key); + + let settings = new Gio.Settings({ schema_id: 'org.gnome.desktop.app-folders' }); + let folders = settings.get_strv('folder-children'); + folders.splice(folders.indexOf(this._id), 1); + settings.set_strv('folder-children', folders); + + this._deletingFolder = false; + } else { + // If this is a categories-based folder, also add it to + // the list of excluded apps + const categories = this._folder.get_strv('categories'); + if (categories.length > 0) { + const excludedApps = this._folder.get_strv('excluded-apps'); + excludedApps.push(app.id); + this._folder.set_strv('excluded-apps', excludedApps); + } + + this._folder.set_strv('apps', folderApps); + } + } + + get deletingFolder() { + return this._deletingFolder; + } +}); + +var FolderIcon = GObject.registerClass({ + Signals: { + 'apps-changed': {}, + }, +}, class FolderIcon extends AppViewItem { + _init(id, path, parentView) { + super._init({ + style_class: 'app-well-app app-folder', + button_mask: St.ButtonMask.ONE, + toggle_mode: true, + can_focus: true, + }, global.settings.is_writable('app-picker-layout')); + this._id = id; + this._name = ''; + this._parentView = parentView; + + this._folder = new Gio.Settings({ + schema_id: 'org.gnome.desktop.app-folders.folder', + path, + }); + + this.icon = new IconGrid.BaseIcon('', { + createIcon: this._createIcon.bind(this), + setSizeManually: true, + }); + this.set_child(this.icon); + this.label_actor = this.icon.label; + + this.view = new FolderView(this._folder, id, parentView); + + this._folder.connectObject( + 'changed', this._sync.bind(this), this); + this._sync(); + } + + _onDestroy() { + super._onDestroy(); + + if (this._dialog) + this._dialog.destroy(); + else + this.view.destroy(); + } + + vfunc_clicked() { + this.open(); + } + + vfunc_unmap() { + if (this._dialog) + this._dialog.popdown(); + + super.vfunc_unmap(); + } + + open() { + this._ensureFolderDialog(); + this.view._scrollView.vscroll.adjustment.value = 0; + this._dialog.popup(); + } + + getAppIds() { + return this.view.getAllItems().map(item => item.id); + } + + _setHoveringByDnd(hovering) { + if (this._otherIconIsHovering == hovering) + return; + + super._setHoveringByDnd(hovering); + + if (hovering) + this.add_style_pseudo_class('drop'); + else + this.remove_style_pseudo_class('drop'); + } + + _onDragMotion(dragEvent) { + if (!this._canAccept(dragEvent.source)) + this._setHoveringByDnd(false); + + return super._onDragMotion(dragEvent); + } + + getDragActor() { + const iconParams = { + createIcon: this._createIcon.bind(this), + showLabel: this.icon.label !== null, + setSizeManually: false, + }; + + const icon = new IconGrid.BaseIcon(this.name, iconParams); + icon.style_class = this.style_class; + + return icon; + } + + getDragActorSource() { + return this; + } + + _canAccept(source) { + if (!(source instanceof AppIcon)) + return false; + + let view = _getViewFromIcon(source); + if (!view || !(view instanceof AppDisplay)) + return false; + + if (this._folder.get_strv('apps').includes(source.id)) + return false; + + return true; + } + + acceptDrop(source) { + const accepted = super.acceptDrop(source); + + if (!accepted) + return false; + + this.view.addApp(source.app); + + return true; + } + + _updateName() { + let name = _getFolderName(this._folder); + if (this.name == name) + return; + + this._name = name; + this.icon.label.text = this.name; + } + + _sync() { + if (this.view.deletingFolder) + return; + + this.emit('apps-changed'); + this._updateName(); + this.visible = this.view.getAllItems().length > 0; + this.icon.update(); + } + + _createIcon(iconSize) { + return this.view.createFolderIcon(iconSize, this); + } + + _ensureFolderDialog() { + if (this._dialog) + return; + if (!this._dialog) { + this._dialog = new AppFolderDialog(this, this._folder, + this._parentView); + this._parentView.addFolderDialog(this._dialog); + this._dialog.connect('open-state-changed', (popup, isOpen) => { + const duration = FOLDER_DIALOG_ANIMATION_TIME / 2; + const mode = isOpen + ? Clutter.AnimationMode.EASE_OUT_QUAD + : Clutter.AnimationMode.EASE_IN_QUAD; + + this.ease({ + opacity: isOpen ? 0 : 255, + duration, + mode, + delay: isOpen ? 0 : FOLDER_DIALOG_ANIMATION_TIME - duration, + }); + + if (!isOpen) + this.checked = false; + }); + } + } +}); + +var AppFolderDialog = GObject.registerClass({ + Signals: { + 'open-state-changed': { param_types: [GObject.TYPE_BOOLEAN] }, + }, +}, class AppFolderDialog extends St.Bin { + _init(source, folder, appDisplay) { + super._init({ + visible: false, + x_expand: true, + y_expand: true, + reactive: true, + }); + + this.add_constraint(new Layout.MonitorConstraint({ primary: true })); + + const clickAction = new Clutter.ClickAction(); + clickAction.connect('clicked', () => { + const [x, y] = clickAction.get_coords(); + const actor = + global.stage.get_actor_at_pos(Clutter.PickMode.ALL, x, y); + + if (actor === this) + this.popdown(); + }); + this.add_action(clickAction); + + this._source = source; + this._folder = folder; + this._view = source.view; + this._appDisplay = appDisplay; + this._delegate = this; + + this._isOpen = false; + + this._viewBox = new St.BoxLayout({ + style_class: 'app-folder-dialog', + x_expand: true, + y_expand: true, + x_align: Clutter.ActorAlign.FILL, + y_align: Clutter.ActorAlign.FILL, + vertical: true, + }); + + this.child = new St.Bin({ + style_class: 'app-folder-dialog-container', + child: this._viewBox, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + + this._addFolderNameEntry(); + this._viewBox.add_child(this._view); + + global.focus_manager.add_group(this); + + this._grabHelper = new GrabHelper.GrabHelper(this, { + actionMode: Shell.ActionMode.POPUP, + }); + this.connect('destroy', this._onDestroy.bind(this)); + + this._dragMonitor = null; + this._sourceMappedId = 0; + this._popdownTimeoutId = 0; + this._needsZoomAndFade = false; + + this._popdownCallbacks = []; + } + + _addFolderNameEntry() { + this._entryBox = new St.BoxLayout({ + style_class: 'folder-name-container', + }); + this._viewBox.add_child(this._entryBox); + + // Empty actor to center the title + let ghostButton = new Clutter.Actor(); + this._entryBox.add_child(ghostButton); + + let stack = new Shell.Stack({ + x_expand: true, + x_align: Clutter.ActorAlign.CENTER, + }); + this._entryBox.add_child(stack); + + // Folder name label + this._folderNameLabel = new St.Label({ + style_class: 'folder-name-label', + x_expand: true, + y_expand: true, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + + stack.add_child(this._folderNameLabel); + + // Folder name entry + this._entry = new St.Entry({ + style_class: 'folder-name-entry', + opacity: 0, + reactive: false, + }); + this._entry.clutter_text.set({ + x_expand: true, + x_align: Clutter.ActorAlign.CENTER, + }); + + this._entry.clutter_text.connect('activate', () => { + this._showFolderLabel(); + }); + + stack.add_child(this._entry); + + // Edit button + this._editButton = new St.Button({ + style_class: 'edit-folder-button', + button_mask: St.ButtonMask.ONE, + toggle_mode: true, + reactive: true, + can_focus: true, + x_align: Clutter.ActorAlign.END, + y_align: Clutter.ActorAlign.CENTER, + icon_name: 'document-edit-symbolic', + }); + + this._editButton.connect('notify::checked', () => { + if (this._editButton.checked) + this._showFolderEntry(); + else + this._showFolderLabel(); + }); + + this._entryBox.add_child(this._editButton); + + ghostButton.add_constraint(new Clutter.BindConstraint({ + source: this._editButton, + coordinate: Clutter.BindCoordinate.SIZE, + })); + + this._folder.connect('changed::name', () => this._syncFolderName()); + this._syncFolderName(); + } + + _syncFolderName() { + let newName = _getFolderName(this._folder); + + this._folderNameLabel.text = newName; + this._entry.text = newName; + } + + _switchActor(from, to) { + to.reactive = true; + to.ease({ + opacity: 255, + duration: 300, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + + from.ease({ + opacity: 0, + duration: 300, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + from.reactive = false; + }, + }); + } + + _showFolderLabel() { + if (this._editButton.checked) + this._editButton.checked = false; + + this._maybeUpdateFolderName(); + this._switchActor(this._entry, this._folderNameLabel); + } + + _showFolderEntry() { + this._switchActor(this._folderNameLabel, this._entry); + + this._entry.clutter_text.set_selection(0, -1); + this._entry.clutter_text.grab_key_focus(); + } + + _maybeUpdateFolderName() { + let folderName = _getFolderName(this._folder); + let newFolderName = this._entry.text.trim(); + + if (newFolderName.length === 0 || newFolderName === folderName) + return; + + this._folder.set_string('name', newFolderName); + this._folder.set_boolean('translate', false); + } + + _zoomAndFadeIn() { + let [sourceX, sourceY] = + this._source.get_transformed_position(); + let [dialogX, dialogY] = + this.child.get_transformed_position(); + + this.child.set({ + translation_x: sourceX - dialogX, + translation_y: sourceY - dialogY, + scale_x: this._source.width / this.child.width, + scale_y: this._source.height / this.child.height, + opacity: 0, + }); + + this.ease({ + background_color: DIALOG_SHADE_NORMAL, + duration: FOLDER_DIALOG_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + this.child.ease({ + translation_x: 0, + translation_y: 0, + scale_x: 1, + scale_y: 1, + opacity: 255, + duration: FOLDER_DIALOG_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + + this._needsZoomAndFade = false; + + if (this._sourceMappedId === 0) { + this._sourceMappedId = this._source.connect( + 'notify::mapped', this._zoomAndFadeOut.bind(this)); + } + } + + _zoomAndFadeOut() { + if (!this._isOpen) + return; + + if (!this._source.mapped) { + this.hide(); + return; + } + + let [sourceX, sourceY] = + this._source.get_transformed_position(); + let [dialogX, dialogY] = + this.child.get_transformed_position(); + + this.ease({ + background_color: Clutter.Color.from_pixel(0x00000000), + duration: FOLDER_DIALOG_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + + this.child.ease({ + translation_x: sourceX - dialogX, + translation_y: sourceY - dialogY, + scale_x: this._source.width / this.child.width, + scale_y: this._source.height / this.child.height, + opacity: 0, + duration: FOLDER_DIALOG_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this.child.set({ + translation_x: 0, + translation_y: 0, + scale_x: 1, + scale_y: 1, + opacity: 255, + }); + this.hide(); + + this._popdownCallbacks.forEach(func => func()); + this._popdownCallbacks = []; + }, + }); + + this._needsZoomAndFade = false; + } + + _removeDragMonitor() { + if (!this._dragMonitor) + return; + + DND.removeDragMonitor(this._dragMonitor); + this._dragMonitor = null; + } + + _removePopdownTimeout() { + if (this._popdownTimeoutId === 0) + return; + + GLib.source_remove(this._popdownTimeoutId); + this._popdownTimeoutId = 0; + } + + _onDestroy() { + if (this._isOpen) { + this._isOpen = false; + this._grabHelper.ungrab({ actor: this }); + this._grabHelper = null; + } + + if (this._sourceMappedId) { + this._source.disconnect(this._sourceMappedId); + this._sourceMappedId = 0; + } + + this._removePopdownTimeout(); + this._removeDragMonitor(); + } + + vfunc_allocate(box) { + super.vfunc_allocate(box); + + // We can only start zooming after receiving an allocation + if (this._needsZoomAndFade) + this._zoomAndFadeIn(); + } + + vfunc_key_press_event(keyEvent) { + if (global.stage.get_key_focus() != this) + return Clutter.EVENT_PROPAGATE; + + // Since we need to only grab focus on one item child when the user + // actually press a key we don't use navigate_focus when opening + // the popup. + // Instead of that, grab the focus on the AppFolderPopup actor + // and actually moves the focus to a child only when the user + // actually press a key. + // It should work with just grab_key_focus on the AppFolderPopup + // actor, but since the arrow keys are not wrapping_around the focus + // is not grabbed by a child when the widget that has the current focus + // is the same that is requesting focus, so to make it works with arrow + // keys we need to connect to the key-press-event and navigate_focus + // when that happens using TAB_FORWARD or TAB_BACKWARD instead of arrow + // keys + + // Use TAB_FORWARD for down key and right key + // and TAB_BACKWARD for up key and left key on ltr + // languages + let direction; + let isLtr = Clutter.get_default_text_direction() == Clutter.TextDirection.LTR; + switch (keyEvent.keyval) { + case Clutter.KEY_Down: + direction = St.DirectionType.TAB_FORWARD; + break; + case Clutter.KEY_Right: + direction = isLtr + ? St.DirectionType.TAB_FORWARD + : St.DirectionType.TAB_BACKWARD; + break; + case Clutter.KEY_Up: + direction = St.DirectionType.TAB_BACKWARD; + break; + case Clutter.KEY_Left: + direction = isLtr + ? St.DirectionType.TAB_BACKWARD + : St.DirectionType.TAB_FORWARD; + break; + default: + return Clutter.EVENT_PROPAGATE; + } + return this.navigate_focus(null, direction, false); + } + + _setLighterBackground(lighter) { + const backgroundColor = lighter + ? DIALOG_SHADE_HIGHLIGHT + : DIALOG_SHADE_NORMAL; + + this.ease({ + backgroundColor, + duration: FOLDER_DIALOG_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + _withinDialog(x, y) { + const childExtents = this.child.get_transformed_extents(); + return childExtents.contains_point(new Graphene.Point({ x, y })); + } + + _setupDragMonitor() { + if (this._dragMonitor) + return; + + this._dragMonitor = { + dragMotion: dragEvent => { + const withinDialog = + this._withinDialog(dragEvent.x, dragEvent.y); + + this._setLighterBackground(!withinDialog); + + if (withinDialog) { + this._removePopdownTimeout(); + this._removeDragMonitor(); + } + return DND.DragMotionResult.CONTINUE; + }, + }; + DND.addDragMonitor(this._dragMonitor); + } + + _setupPopdownTimeout() { + if (this._popdownTimeoutId > 0) + return; + + this._popdownTimeoutId = + GLib.timeout_add(GLib.PRIORITY_DEFAULT, POPDOWN_DIALOG_TIMEOUT, () => { + this._popdownTimeoutId = 0; + this.popdown(); + return GLib.SOURCE_REMOVE; + }); + } + + handleDragOver(source, actor, x, y) { + if (this._withinDialog(x, y)) { + this._setLighterBackground(false); + this._removePopdownTimeout(); + this._removeDragMonitor(); + } else { + this._setupPopdownTimeout(); + this._setupDragMonitor(); + } + + return DND.DragMotionResult.MOVE_DROP; + } + + acceptDrop(source) { + const appId = source.id; + + this.popdown(() => { + this._view.removeApp(source); + this._appDisplay.selectApp(appId); + }); + + return true; + } + + toggle() { + if (this._isOpen) + this.popdown(); + else + this.popup(); + } + + popup() { + if (this._isOpen) + return; + + this._isOpen = this._grabHelper.grab({ + actor: this, + onUngrab: () => this.popdown(), + }); + + if (!this._isOpen) + return; + + this.get_parent().set_child_above_sibling(this, null); + + this._needsZoomAndFade = true; + this.show(); + + this.emit('open-state-changed', true); + } + + popdown(callback) { + // Either call the callback right away, or wait for the zoom out + // animation to finish + if (callback) { + if (this.visible) + this._popdownCallbacks.push(callback); + else + callback(); + } + + if (!this._isOpen) + return; + + this._zoomAndFadeOut(); + this._showFolderLabel(); + + this._isOpen = false; + this._grabHelper.ungrab({ actor: this }); + this.emit('open-state-changed', false); + } +}); + +var AppIcon = GObject.registerClass({ + Signals: { + 'menu-state-changed': { param_types: [GObject.TYPE_BOOLEAN] }, + 'sync-tooltip': {}, + }, +}, class AppIcon extends AppViewItem { + _init(app, iconParams = {}) { + // Get the isDraggable property without passing it on to the BaseIcon: + const appIconParams = Params.parse(iconParams, { isDraggable: true }, true); + const isDraggable = appIconParams['isDraggable']; + delete iconParams['isDraggable']; + const expandTitleOnHover = appIconParams['expandTitleOnHover']; + delete iconParams['expandTitleOnHover']; + + super._init({ style_class: 'app-well-app' }, isDraggable, expandTitleOnHover); + + this.app = app; + this._id = app.get_id(); + this._name = app.get_name(); + + this._iconContainer = new St.Widget({ + layout_manager: new Clutter.BinLayout(), + x_expand: true, + y_expand: true, + }); + + this.set_child(this._iconContainer); + + this._folderPreviewId = 0; + + iconParams['createIcon'] = this._createIcon.bind(this); + iconParams['setSizeManually'] = true; + this.icon = new IconGrid.BaseIcon(app.get_name(), iconParams); + this._iconContainer.add_child(this.icon); + + this._dot = new St.Widget({ + style_class: 'app-well-app-running-dot', + layout_manager: new Clutter.BinLayout(), + x_expand: true, + y_expand: true, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.END, + }); + this._iconContainer.add_child(this._dot); + + this.label_actor = this.icon.label; + + this.connect('popup-menu', this._onKeyboardPopupMenu.bind(this)); + + this._menu = null; + this._menuManager = new PopupMenu.PopupMenuManager(this); + + this._menuTimeoutId = 0; + this.app.connectObject('notify::state', + () => this._updateRunningStyle(), this); + this._updateRunningStyle(); + } + + _onDestroy() { + super._onDestroy(); + + if (this._folderPreviewId > 0) { + GLib.source_remove(this._folderPreviewId); + this._folderPreviewId = 0; + } + + this._removeMenuTimeout(); + } + + _onDragBegin() { + if (this._menu) + this._menu.close(true); + this._removeMenuTimeout(); + super._onDragBegin(); + } + + _createIcon(iconSize) { + return this.app.create_icon_texture(iconSize); + } + + _removeMenuTimeout() { + if (this._menuTimeoutId > 0) { + GLib.source_remove(this._menuTimeoutId); + this._menuTimeoutId = 0; + } + } + + _updateRunningStyle() { + if (this.app.state != Shell.AppState.STOPPED) + this._dot.show(); + else + this._dot.hide(); + } + + _setPopupTimeout() { + this._removeMenuTimeout(); + this._menuTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, MENU_POPUP_TIMEOUT, () => { + this._menuTimeoutId = 0; + this.popupMenu(); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._menuTimeoutId, '[gnome-shell] this.popupMenu'); + } + + vfunc_leave_event(crossingEvent) { + const ret = super.vfunc_leave_event(crossingEvent); + + this.fake_release(); + this._removeMenuTimeout(); + return ret; + } + + vfunc_button_press_event(buttonEvent) { + const ret = super.vfunc_button_press_event(buttonEvent); + if (buttonEvent.button == 1) { + this._setPopupTimeout(); + } else if (buttonEvent.button == 3) { + this.popupMenu(); + return Clutter.EVENT_STOP; + } + return ret; + } + + vfunc_touch_event(touchEvent) { + const ret = super.vfunc_touch_event(touchEvent); + if (touchEvent.type == Clutter.EventType.TOUCH_BEGIN) + this._setPopupTimeout(); + + return ret; + } + + vfunc_clicked(button) { + this._removeMenuTimeout(); + this.activate(button); + } + + _onKeyboardPopupMenu() { + this.popupMenu(); + this._menu.actor.navigate_focus(null, St.DirectionType.TAB_FORWARD, false); + } + + getId() { + return this.app.get_id(); + } + + popupMenu(side = St.Side.LEFT) { + this.setForcedHighlight(true); + this._removeMenuTimeout(); + this.fake_release(); + + if (!this._menu) { + this._menu = new AppMenu(this, side, { + favoritesSection: true, + showSingleWindows: true, + }); + this._menu.setApp(this.app); + this._menu.connect('open-state-changed', (menu, isPoppedUp) => { + if (!isPoppedUp) + this._onMenuPoppedDown(); + }); + Main.overview.connectObject('hiding', + () => this._menu.close(), this); + + Main.uiGroup.add_actor(this._menu.actor); + this._menuManager.addMenu(this._menu); + } + + this.emit('menu-state-changed', true); + + this._menu.open(BoxPointer.PopupAnimation.FULL); + this._menuManager.ignoreRelease(); + this.emit('sync-tooltip'); + + return false; + } + + _onMenuPoppedDown() { + this.setForcedHighlight(false); + this.emit('menu-state-changed', false); + } + + activate(button) { + let event = Clutter.get_current_event(); + let modifiers = event ? event.get_state() : 0; + let isMiddleButton = button && button == Clutter.BUTTON_MIDDLE; + let isCtrlPressed = (modifiers & Clutter.ModifierType.CONTROL_MASK) != 0; + let openNewWindow = this.app.can_open_new_window() && + this.app.state == Shell.AppState.RUNNING && + (isCtrlPressed || isMiddleButton); + + if (this.app.state == Shell.AppState.STOPPED || openNewWindow) + this.animateLaunch(); + + if (openNewWindow) + this.app.open_new_window(-1); + else + this.app.activate(); + + Main.overview.hide(); + } + + animateLaunch() { + this.icon.animateZoomOut(); + } + + animateLaunchAtPos(x, y) { + this.icon.animateZoomOutAtPos(x, y); + } + + shellWorkspaceLaunch(params) { + let { stack } = new Error(); + log(`shellWorkspaceLaunch is deprecated, use app.open_new_window() instead\n${stack}`); + + params = Params.parse(params, { + workspace: -1, + timestamp: 0, + }); + + this.app.open_new_window(params.workspace); + } + + getDragActor() { + return this.app.create_icon_texture(Main.overview.dash.iconSize); + } + + // Returns the original actor that should align with the actor + // we show as the item is being dragged. + getDragActorSource() { + return this.icon.icon; + } + + shouldShowTooltip() { + return this.hover && (!this._menu || !this._menu.isOpen); + } + + _showFolderPreview() { + this.icon.label.opacity = 0; + this.icon.icon.ease({ + scale_x: FOLDER_SUBICON_FRACTION, + scale_y: FOLDER_SUBICON_FRACTION, + }); + } + + _hideFolderPreview() { + this.icon.label.opacity = 255; + this.icon.icon.ease({ + scale_x: 1.0, + scale_y: 1.0, + }); + } + + _canAccept(source) { + let view = _getViewFromIcon(source); + + return source != this && + (source instanceof this.constructor) && + (view instanceof AppDisplay); + } + + _setHoveringByDnd(hovering) { + if (this._otherIconIsHovering == hovering) + return; + + super._setHoveringByDnd(hovering); + + if (hovering) { + if (this._folderPreviewId > 0) + return; + + this._folderPreviewId = + GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => { + this.add_style_pseudo_class('drop'); + this._showFolderPreview(); + this._folderPreviewId = 0; + return GLib.SOURCE_REMOVE; + }); + } else { + if (this._folderPreviewId > 0) { + GLib.source_remove(this._folderPreviewId); + this._folderPreviewId = 0; + } + this._hideFolderPreview(); + this.remove_style_pseudo_class('drop'); + } + } + + acceptDrop(source, actor, x) { + const accepted = super.acceptDrop(source, actor, x); + if (!accepted) + return false; + + let view = _getViewFromIcon(this); + let apps = [this.id, source.id]; + + return view?.createFolder(apps); + } + + cancelActions() { + if (this._menu) + this._menu.close(true); + this._removeMenuTimeout(); + super.cancelActions(); + } +}); + +var SystemActionIcon = GObject.registerClass( +class SystemActionIcon extends Search.GridSearchResult { + activate() { + SystemActions.getDefault().activateAction(this.metaInfo['id']); + Main.overview.hide(); + } +}); diff --git a/js/ui/appFavorites.js b/js/ui/appFavorites.js new file mode 100644 index 0000000..d8a3018 --- /dev/null +++ b/js/ui/appFavorites.js @@ -0,0 +1,212 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported getAppFavorites */ + +const Shell = imports.gi.Shell; +const ParentalControlsManager = imports.misc.parentalControlsManager; +const Signals = imports.misc.signals; + +const Main = imports.ui.main; + +// In alphabetical order +const RENAMED_DESKTOP_IDS = { + 'baobab.desktop': 'org.gnome.baobab.desktop', + 'cheese.desktop': 'org.gnome.Cheese.desktop', + 'dconf-editor.desktop': 'ca.desrt.dconf-editor.desktop', + 'empathy.desktop': 'org.gnome.Empathy.desktop', + 'eog.desktop': 'org.gnome.eog.desktop', + 'epiphany.desktop': 'org.gnome.Epiphany.desktop', + 'evolution.desktop': 'org.gnome.Evolution.desktop', + 'file-roller.desktop': 'org.gnome.FileRoller.desktop', + 'five-or-more.desktop': 'org.gnome.five-or-more.desktop', + 'four-in-a-row.desktop': 'org.gnome.Four-in-a-row.desktop', + 'gcalctool.desktop': 'org.gnome.Calculator.desktop', + 'geary.desktop': 'org.gnome.Geary.desktop', + 'gedit.desktop': 'org.gnome.gedit.desktop', + 'glchess.desktop': 'org.gnome.Chess.desktop', + 'glines.desktop': 'org.gnome.five-or-more.desktop', + 'gnect.desktop': 'org.gnome.Four-in-a-row.desktop', + 'gnibbles.desktop': 'org.gnome.Nibbles.desktop', + 'gnobots2.desktop': 'org.gnome.Robots.desktop', + 'gnome-boxes.desktop': 'org.gnome.Boxes.desktop', + 'gnome-calculator.desktop': 'org.gnome.Calculator.desktop', + 'gnome-chess.desktop': 'org.gnome.Chess.desktop', + 'gnome-clocks.desktop': 'org.gnome.clocks.desktop', + 'gnome-contacts.desktop': 'org.gnome.Contacts.desktop', + 'gnome-documents.desktop': 'org.gnome.Documents.desktop', + 'gnome-font-viewer.desktop': 'org.gnome.font-viewer.desktop', + 'gnome-klotski.desktop': 'org.gnome.Klotski.desktop', + 'gnome-nibbles.desktop': 'org.gnome.Nibbles.desktop', + 'gnome-mahjongg.desktop': 'org.gnome.Mahjongg.desktop', + 'gnome-mines.desktop': 'org.gnome.Mines.desktop', + 'gnome-music.desktop': 'org.gnome.Music.desktop', + 'gnome-photos.desktop': 'org.gnome.Photos.desktop', + 'gnome-robots.desktop': 'org.gnome.Robots.desktop', + 'gnome-screenshot.desktop': 'org.gnome.Screenshot.desktop', + 'gnome-software.desktop': 'org.gnome.Software.desktop', + 'gnome-terminal.desktop': 'org.gnome.Terminal.desktop', + 'gnome-tetravex.desktop': 'org.gnome.Tetravex.desktop', + 'gnome-tweaks.desktop': 'org.gnome.tweaks.desktop', + 'gnome-weather.desktop': 'org.gnome.Weather.desktop', + 'gnomine.desktop': 'org.gnome.Mines.desktop', + 'gnotravex.desktop': 'org.gnome.Tetravex.desktop', + 'gnotski.desktop': 'org.gnome.Klotski.desktop', + 'gtali.desktop': 'org.gnome.Tali.desktop', + 'iagno.desktop': 'org.gnome.Reversi.desktop', + 'nautilus.desktop': 'org.gnome.Nautilus.desktop', + 'org.gnome.gnome-2048.desktop': 'org.gnome.TwentyFortyEight.desktop', + 'org.gnome.taquin.desktop': 'org.gnome.Taquin.desktop', + 'org.gnome.Weather.Application.desktop': 'org.gnome.Weather.desktop', + 'polari.desktop': 'org.gnome.Polari.desktop', + 'seahorse.desktop': 'org.gnome.seahorse.Application.desktop', + 'shotwell.desktop': 'org.gnome.Shotwell.desktop', + 'tali.desktop': 'org.gnome.Tali.desktop', + 'totem.desktop': 'org.gnome.Totem.desktop', + 'evince.desktop': 'org.gnome.Evince.desktop', +}; + +class AppFavorites extends Signals.EventEmitter { + constructor() { + super(); + + // Filter the apps through the user’s parental controls. + this._parentalControlsManager = ParentalControlsManager.getDefault(); + this._parentalControlsManager.connect('app-filter-changed', () => { + this.reload(); + this.emit('changed'); + }); + + this.FAVORITE_APPS_KEY = 'favorite-apps'; + this._favorites = {}; + global.settings.connect(`changed::${this.FAVORITE_APPS_KEY}`, this._onFavsChanged.bind(this)); + this.reload(); + } + + _onFavsChanged() { + this.reload(); + this.emit('changed'); + } + + reload() { + let ids = global.settings.get_strv(this.FAVORITE_APPS_KEY); + let appSys = Shell.AppSystem.get_default(); + + // Map old desktop file names to the current ones + let updated = false; + ids = ids.map(id => { + let newId = RENAMED_DESKTOP_IDS[id]; + if (newId !== undefined && + appSys.lookup_app(newId) != null) { + updated = true; + return newId; + } + return id; + }); + // ... and write back the updated desktop file names + if (updated) + global.settings.set_strv(this.FAVORITE_APPS_KEY, ids); + + let apps = ids.map(id => appSys.lookup_app(id)) + .filter(app => app !== null && this._parentalControlsManager.shouldShowApp(app.app_info)); + this._favorites = {}; + for (let i = 0; i < apps.length; i++) { + let app = apps[i]; + this._favorites[app.get_id()] = app; + } + } + + _getIds() { + let ret = []; + for (let id in this._favorites) + ret.push(id); + return ret; + } + + getFavoriteMap() { + return this._favorites; + } + + getFavorites() { + let ret = []; + for (let id in this._favorites) + ret.push(this._favorites[id]); + return ret; + } + + isFavorite(appId) { + return appId in this._favorites; + } + + _addFavorite(appId, pos) { + if (appId in this._favorites) + return false; + + let app = Shell.AppSystem.get_default().lookup_app(appId); + + if (!app) + return false; + + if (!this._parentalControlsManager.shouldShowApp(app.app_info)) + return false; + + let ids = this._getIds(); + if (pos == -1) + ids.push(appId); + else + ids.splice(pos, 0, appId); + global.settings.set_strv(this.FAVORITE_APPS_KEY, ids); + return true; + } + + addFavoriteAtPos(appId, pos) { + if (!this._addFavorite(appId, pos)) + return; + + let app = Shell.AppSystem.get_default().lookup_app(appId); + + let msg = _('%s has been pinned to the dash.').format(app.get_name()); + Main.overview.setMessage(msg, { + forFeedback: true, + undoCallback: () => this._removeFavorite(appId), + }); + } + + addFavorite(appId) { + this.addFavoriteAtPos(appId, -1); + } + + moveFavoriteToPos(appId, pos) { + this._removeFavorite(appId); + this._addFavorite(appId, pos); + } + + _removeFavorite(appId) { + if (!(appId in this._favorites)) + return false; + + let ids = this._getIds().filter(id => id != appId); + global.settings.set_strv(this.FAVORITE_APPS_KEY, ids); + return true; + } + + removeFavorite(appId) { + let ids = this._getIds(); + let pos = ids.indexOf(appId); + + let app = this._favorites[appId]; + if (!this._removeFavorite(appId)) + return; + + let msg = _('%s has been unpinned from the dash.').format(app.get_name()); + Main.overview.setMessage(msg, { + forFeedback: true, + undoCallback: () => this._addFavorite(appId, pos), + }); + } +} + +var appFavoritesInstance = null; +function getAppFavorites() { + if (appFavoritesInstance == null) + appFavoritesInstance = new AppFavorites(); + return appFavoritesInstance; +} diff --git a/js/ui/appMenu.js b/js/ui/appMenu.js new file mode 100644 index 0000000..010fdb3 --- /dev/null +++ b/js/ui/appMenu.js @@ -0,0 +1,287 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported AppMenu */ +const { Clutter, Gio, GLib, Meta, Shell, St } = imports.gi; + +const AppFavorites = imports.ui.appFavorites; +const Main = imports.ui.main; +const ParentalControlsManager = imports.misc.parentalControlsManager; +const PopupMenu = imports.ui.popupMenu; + +var AppMenu = class AppMenu extends PopupMenu.PopupMenu { + /** + * @param {Clutter.Actor} sourceActor - actor the menu is attached to + * @param {St.Side} side - arrow side + * @param {object} params - options + * @param {bool} params.favoritesSection - show items to add/remove favorite + * @param {bool} params.showSingleWindow - show window section for a single window + */ + constructor(sourceActor, side = St.Side.TOP, params = {}) { + if (Clutter.get_default_text_direction() === Clutter.TextDirection.RTL) { + if (side === St.Side.LEFT) + side = St.Side.RIGHT; + else if (side === St.Side.RIGHT) + side = St.Side.LEFT; + } + + super(sourceActor, 0.5, side); + + this.actor.add_style_class_name('app-menu'); + + const { + favoritesSection = false, + showSingleWindows = false, + } = params; + + this._app = null; + this._appSystem = Shell.AppSystem.get_default(); + this._parentalControlsManager = ParentalControlsManager.getDefault(); + this._appFavorites = AppFavorites.getAppFavorites(); + this._enableFavorites = favoritesSection; + this._showSingleWindows = showSingleWindows; + + this._windowsChangedId = 0; + this._updateWindowsLaterId = 0; + + /* Translators: This is the heading of a list of open windows */ + this._openWindowsHeader = new PopupMenu.PopupSeparatorMenuItem(_('Open Windows')); + this.addMenuItem(this._openWindowsHeader); + + this._windowSection = new PopupMenu.PopupMenuSection(); + this.addMenuItem(this._windowSection); + + this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + this._newWindowItem = this.addAction(_('New Window'), () => { + this._animateLaunch(); + this._app.open_new_window(-1); + Main.overview.hide(); + }); + + this._actionSection = new PopupMenu.PopupMenuSection(); + this.addMenuItem(this._actionSection); + + this._onGpuMenuItem = this.addAction('', () => { + this._animateLaunch(); + this._app.launch(0, -1, this._getNonDefaultLaunchGpu()); + Main.overview.hide(); + }); + + this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + this._toggleFavoriteItem = this.addAction('', () => { + const appId = this._app.get_id(); + if (this._appFavorites.isFavorite(appId)) + this._appFavorites.removeFavorite(appId); + else + this._appFavorites.addFavorite(appId); + }); + + this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + this._detailsItem = this.addAction(_('Show Details'), async () => { + const id = this._app.get_id(); + const args = GLib.Variant.new('(ss)', [id, '']); + const bus = await Gio.DBus.get(Gio.BusType.SESSION, null); + bus.call( + 'org.gnome.Software', + '/org/gnome/Software', + 'org.gtk.Actions', 'Activate', + new GLib.Variant('(sava{sv})', ['details', [args], null]), + null, 0, -1, null); + Main.overview.hide(); + }); + + this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + this._quitItem = + this.addAction(_('Quit'), () => this._app.request_quit()); + + this._appSystem.connectObject( + 'installed-changed', () => this._updateDetailsVisibility(), + 'app-state-changed', this._onAppStateChanged.bind(this), + this.actor); + + this._parentalControlsManager.connectObject( + 'app-filter-changed', () => this._updateFavoriteItem(), this.actor); + + this._appFavorites.connectObject( + 'changed', () => this._updateFavoriteItem(), this.actor); + + global.settings.connectObject( + 'writable-changed::favorite-apps', () => this._updateFavoriteItem(), + this.actor); + + global.connectObject( + 'notify::switcheroo-control', () => this._updateGpuItem(), + this.actor); + + this._updateQuitItem(); + this._updateFavoriteItem(); + this._updateGpuItem(); + this._updateDetailsVisibility(); + } + + _onAppStateChanged(sys, app) { + if (this._app !== app) + return; + + this._updateQuitItem(); + this._updateNewWindowItem(); + this._updateGpuItem(); + } + + _updateQuitItem() { + this._quitItem.visible = this._app?.state === Shell.AppState.RUNNING; + } + + _updateNewWindowItem() { + const actions = this._app?.appInfo?.list_actions() ?? []; + this._newWindowItem.visible = + this._app?.can_open_new_window() && !actions.includes('new-window'); + } + + _updateFavoriteItem() { + const appInfo = this._app?.app_info; + const canFavorite = appInfo && + this._enableFavorites && + global.settings.is_writable('favorite-apps') && + this._parentalControlsManager.shouldShowApp(appInfo); + + this._toggleFavoriteItem.visible = canFavorite; + + if (!canFavorite) + return; + + const { id } = this._app; + this._toggleFavoriteItem.label.text = this._appFavorites.isFavorite(id) + ? _('Unpin') + : _('Pin to Dash'); + } + + _updateGpuItem() { + const proxy = global.get_switcheroo_control(); + const hasDualGpu = proxy?.get_cached_property('HasDualGpu')?.unpack(); + + const showItem = + this._app?.state === Shell.AppState.STOPPED && hasDualGpu; + + this._onGpuMenuItem.visible = showItem; + + if (!showItem) + return; + + const launchGpu = this._getNonDefaultLaunchGpu(); + this._onGpuMenuItem.label.text = launchGpu === Shell.AppLaunchGpu.DEFAULT + ? _('Launch using Integrated Graphics Card') + : _('Launch using Discrete Graphics Card'); + } + + _updateDetailsVisibility() { + const sw = this._appSystem.lookup_app('org.gnome.Software.desktop'); + this._detailsItem.visible = sw !== null; + } + + _animateLaunch() { + if (this.sourceActor.animateLaunch) + this.sourceActor.animateLaunch(); + } + + _getNonDefaultLaunchGpu() { + return this._app.appInfo.get_boolean('PrefersNonDefaultGPU') + ? Shell.AppLaunchGpu.DEFAULT + : Shell.AppLaunchGpu.DISCRETE; + } + + /** */ + destroy() { + super.destroy(); + + this.setApp(null); + } + + /** + * @returns {bool} - true if the menu is empty + */ + isEmpty() { + if (!this._app) + return true; + return super.isEmpty(); + } + + /** + * @param {Shell.App} app - the app the menu represents + */ + setApp(app) { + if (this._app === app) + return; + + this._app?.disconnectObject(this); + + this._app = app; + + this._app?.connectObject('windows-changed', + () => this._queueUpdateWindowsSection(), this); + + this._updateWindowsSection(); + + const appInfo = app?.app_info; + const actions = appInfo?.list_actions() ?? []; + + this._actionSection.removeAll(); + actions.forEach(action => { + const label = appInfo.get_action_name(action); + this._actionSection.addAction(label, event => { + if (action === 'new-window') + this._animateLaunch(); + + this._app.launch_action(action, event.get_time(), -1); + Main.overview.hide(); + }); + }); + + this._updateQuitItem(); + this._updateNewWindowItem(); + this._updateFavoriteItem(); + this._updateGpuItem(); + } + + _queueUpdateWindowsSection() { + if (this._updateWindowsLaterId) + return; + + this._updateWindowsLaterId = Meta.later_add( + Meta.LaterType.BEFORE_REDRAW, () => { + this._updateWindowsSection(); + return GLib.SOURCE_REMOVE; + }); + } + + _updateWindowsSection() { + if (this._updateWindowsLaterId) + Meta.later_remove(this._updateWindowsLaterId); + this._updateWindowsLaterId = 0; + + this._windowSection.removeAll(); + this._openWindowsHeader.hide(); + + if (!this._app) + return; + + const minWindows = this._showSingleWindows ? 1 : 2; + const windows = this._app.get_windows().filter(w => !w.skip_taskbar); + if (windows.length < minWindows) + return; + + this._openWindowsHeader.show(); + + windows.forEach(window => { + const title = window.title || this._app.get_name(); + const item = this._windowSection.addAction(title, event => { + Main.activateWindow(window, event.get_time()); + }); + window.connectObject('notify::title', () => { + item.label.text = window.title || this._app.get_name(); + }, item); + }); + } +}; diff --git a/js/ui/audioDeviceSelection.js b/js/ui/audioDeviceSelection.js new file mode 100644 index 0000000..e284772 --- /dev/null +++ b/js/ui/audioDeviceSelection.js @@ -0,0 +1,207 @@ +/* exported AudioDeviceSelectionDBus */ +const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; + +const Dialog = imports.ui.dialog; +const Main = imports.ui.main; +const ModalDialog = imports.ui.modalDialog; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +var AudioDevice = { + HEADPHONES: 1 << 0, + HEADSET: 1 << 1, + MICROPHONE: 1 << 2, +}; + +const AudioDeviceSelectionIface = loadInterfaceXML('org.gnome.Shell.AudioDeviceSelection'); + +var AudioDeviceSelectionDialog = GObject.registerClass({ + Signals: { 'device-selected': { param_types: [GObject.TYPE_UINT] } }, +}, class AudioDeviceSelectionDialog extends ModalDialog.ModalDialog { + _init(devices) { + super._init({ styleClass: 'audio-device-selection-dialog' }); + + this._deviceItems = {}; + + this._buildLayout(); + + if (devices & AudioDevice.HEADPHONES) + this._addDevice(AudioDevice.HEADPHONES); + if (devices & AudioDevice.HEADSET) + this._addDevice(AudioDevice.HEADSET); + if (devices & AudioDevice.MICROPHONE) + this._addDevice(AudioDevice.MICROPHONE); + + if (this._selectionBox.get_n_children() < 2) + throw new Error('Too few devices for a selection'); + } + + _buildLayout() { + let content = new Dialog.MessageDialogContent({ + title: _('Select Audio Device'), + }); + + this._selectionBox = new St.BoxLayout({ + style_class: 'audio-selection-box', + x_align: Clutter.ActorAlign.CENTER, + x_expand: true, + }); + content.add_child(this._selectionBox); + + this.contentLayout.add_child(content); + + if (Main.sessionMode.allowSettings) { + this.addButton({ + action: this._openSettings.bind(this), + label: _('Sound Settings'), + }); + } + this.addButton({ + action: () => this.close(), + label: _('Cancel'), + key: Clutter.KEY_Escape, + }); + } + + _getDeviceLabel(device) { + switch (device) { + case AudioDevice.HEADPHONES: + return _("Headphones"); + case AudioDevice.HEADSET: + return _("Headset"); + case AudioDevice.MICROPHONE: + return _("Microphone"); + default: + return null; + } + } + + _getDeviceIcon(device) { + switch (device) { + case AudioDevice.HEADPHONES: + return 'audio-headphones-symbolic'; + case AudioDevice.HEADSET: + return 'audio-headset-symbolic'; + case AudioDevice.MICROPHONE: + return 'audio-input-microphone-symbolic'; + default: + return null; + } + } + + _addDevice(device) { + const box = new St.BoxLayout({ + style_class: 'audio-selection-device-box', + vertical: true, + }); + box.connect('notify::height', () => { + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + box.width = box.height; + return GLib.SOURCE_REMOVE; + }); + }); + + const icon = new St.Icon({ + style_class: 'audio-selection-device-icon', + icon_name: this._getDeviceIcon(device), + }); + box.add(icon); + + const label = new St.Label({ + style_class: 'audio-selection-device-label', + text: this._getDeviceLabel(device), + x_align: Clutter.ActorAlign.CENTER, + }); + box.add(label); + + const button = new St.Button({ + style_class: 'audio-selection-device', + can_focus: true, + child: box, + }); + this._selectionBox.add(button); + + button.connect('clicked', () => { + this.emit('device-selected', device); + this.close(); + Main.overview.hide(); + }); + } + + _openSettings() { + let desktopFile = 'gnome-sound-panel.desktop'; + let app = Shell.AppSystem.get_default().lookup_app(desktopFile); + + if (!app) { + log(`Settings panel for desktop file ${desktopFile} could not be loaded!`); + return; + } + + this.close(); + Main.overview.hide(); + app.activate(); + } +}); + +var AudioDeviceSelectionDBus = class AudioDeviceSelectionDBus { + constructor() { + this._audioSelectionDialog = null; + + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(AudioDeviceSelectionIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell/AudioDeviceSelection'); + + Gio.DBus.session.own_name('org.gnome.Shell.AudioDeviceSelection', Gio.BusNameOwnerFlags.REPLACE, null, null); + } + + _onDialogClosed() { + this._audioSelectionDialog = null; + } + + _onDeviceSelected(dialog, device) { + let connection = this._dbusImpl.get_connection(); + let info = this._dbusImpl.get_info(); + const deviceName = Object.keys(AudioDevice) + .filter(dev => AudioDevice[dev] === device)[0].toLowerCase(); + connection.emit_signal(this._audioSelectionDialog._sender, + this._dbusImpl.get_object_path(), + info ? info.name : null, + 'DeviceSelected', + GLib.Variant.new('(s)', [deviceName])); + } + + OpenAsync(params, invocation) { + if (this._audioSelectionDialog) { + invocation.return_value(null); + return; + } + + let [deviceNames] = params; + let devices = 0; + deviceNames.forEach(n => (devices |= AudioDevice[n.toUpperCase()])); + + let dialog; + try { + dialog = new AudioDeviceSelectionDialog(devices); + } catch (e) { + invocation.return_value(null); + return; + } + dialog._sender = invocation.get_sender(); + + dialog.connect('closed', this._onDialogClosed.bind(this)); + dialog.connect('device-selected', + this._onDeviceSelected.bind(this)); + dialog.open(); + + this._audioSelectionDialog = dialog; + invocation.return_value(null); + } + + CloseAsync(params, invocation) { + if (this._audioSelectionDialog && + this._audioSelectionDialog._sender == invocation.get_sender()) + this._audioSelectionDialog.close(); + + invocation.return_value(null); + } +}; diff --git a/js/ui/background.js b/js/ui/background.js new file mode 100644 index 0000000..829ffb4 --- /dev/null +++ b/js/ui/background.js @@ -0,0 +1,842 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported SystemBackground, BackgroundManager */ + +// READ THIS FIRST +// Background handling is a maze of objects, both objects in this file, and +// also objects inside Mutter. They all have a role. +// +// BackgroundManager +// The only object that other parts of GNOME Shell deal with; a +// BackgroundManager creates background actors and adds them to +// the specified container. When the background is changed by the +// user it will fade out the old actor and fade in the new actor. +// (This is separate from the fading for an animated background, +// since using two actors is quite inefficient.) +// +// MetaBackgroundImage +// An object represented an image file that will be used for drawing +// the background. MetaBackgroundImage objects asynchronously load, +// so they are first created in an unloaded state, then later emit +// a ::loaded signal when the Cogl object becomes available. +// +// MetaBackgroundImageCache +// A cache from filename to MetaBackgroundImage. +// +// BackgroundSource +// An object that is created for each GSettings schema (separate +// settings schemas are used for the lock screen and main background), +// and holds a reference to shared Background objects. +// +// MetaBackground +// Holds the specification of a background - a background color +// or gradient and one or two images blended together. +// +// Background +// JS delegate object that Connects a MetaBackground to the GSettings +// schema for the background. +// +// Animation +// A helper object that handles loading a XML-based animation; it is a +// wrapper for GnomeDesktop.BGSlideShow +// +// MetaBackgroundActor +// An actor that draws the background for a single monitor +// +// BackgroundCache +// A cache of Settings schema => BackgroundSource and of a single Animation. +// Also used to share file monitors. +// +// A static image, background color or gradient is relatively straightforward. The +// calling code creates a separate BackgroundManager for each monitor. Since they +// are created for the same GSettings schema, they will use the same BackgroundSource +// object, which provides a single Background and correspondingly a single +// MetaBackground object. +// +// BackgroundManager BackgroundManager +// | \ / | +// | BackgroundSource | looked up in BackgroundCache +// | | | +// | Background | +// | | | +// MetaBackgroundActor | MetaBackgroundActor +// \ | / +// `------- MetaBackground ------' +// | +// MetaBackgroundImage looked up in MetaBackgroundImageCache +// +// The animated case is tricker because the animation XML file can specify different +// files for different monitor resolutions and aspect ratios. For this reason, +// the BackgroundSource provides different Background share a single Animation object, +// which tracks the animation, but use different MetaBackground objects. In the +// common case, the different MetaBackground objects will be created for the +// same filename and look up the *same* MetaBackgroundImage object, so there is +// little wasted memory: +// +// BackgroundManager BackgroundManager +// | \ / | +// | BackgroundSource | looked up in BackgroundCache +// | / \ | +// | Background Background | +// | | \ / | | +// | | Animation | | looked up in BackgroundCache +// MetaBackgroundA|tor Me|aBackgroundActor +// \ | | / +// MetaBackground MetaBackground +// \ / +// MetaBackgroundImage looked up in MetaBackgroundImageCache +// MetaBackgroundImage +// +// But the case of different filenames and different background images +// is possible as well: +// .... +// MetaBackground MetaBackground +// | | +// MetaBackgroundImage MetaBackgroundImage +// MetaBackgroundImage MetaBackgroundImage + +const { Clutter, GDesktopEnums, Gio, GLib, GObject, GnomeDesktop, Meta } = imports.gi; +const Signals = imports.misc.signals; + +const LoginManager = imports.misc.loginManager; +const Main = imports.ui.main; +const Params = imports.misc.params; + +Gio._promisify(Gio.File.prototype, 'query_info_async'); + +var DEFAULT_BACKGROUND_COLOR = Clutter.Color.from_pixel(0x2e3436ff); + +const BACKGROUND_SCHEMA = 'org.gnome.desktop.background'; +const PRIMARY_COLOR_KEY = 'primary-color'; +const SECONDARY_COLOR_KEY = 'secondary-color'; +const COLOR_SHADING_TYPE_KEY = 'color-shading-type'; +const BACKGROUND_STYLE_KEY = 'picture-options'; +const PICTURE_URI_KEY = 'picture-uri'; +const PICTURE_URI_DARK_KEY = 'picture-uri-dark'; + +const INTERFACE_SCHEMA = 'org.gnome.desktop.interface'; +const COLOR_SCHEME_KEY = 'color-scheme'; + +var FADE_ANIMATION_TIME = 1000; + +// These parameters affect how often we redraw. +// The first is how different (percent crossfaded) the slide show +// has to look before redrawing and the second is the minimum +// frequency (in seconds) we're willing to wake up +var ANIMATION_OPACITY_STEP_INCREMENT = 4.0; +var ANIMATION_MIN_WAKEUP_INTERVAL = 1.0; + +let _backgroundCache = null; + +function _fileEqual0(file1, file2) { + if (file1 == file2) + return true; + + if (!file1 || !file2) + return false; + + return file1.equal(file2); +} + +var BackgroundCache = class BackgroundCache extends Signals.EventEmitter { + constructor() { + super(); + + this._fileMonitors = {}; + this._backgroundSources = {}; + this._animations = {}; + } + + monitorFile(file) { + let key = file.hash(); + if (this._fileMonitors[key]) + return; + + let monitor = file.monitor(Gio.FileMonitorFlags.NONE, null); + monitor.connect('changed', + (obj, theFile, otherFile, eventType) => { + // Ignore CHANGED and CREATED events, since in both cases + // we'll get a CHANGES_DONE_HINT event when done. + if (eventType != Gio.FileMonitorEvent.CHANGED && + eventType != Gio.FileMonitorEvent.CREATED) + this.emit('file-changed', file); + }); + + this._fileMonitors[key] = monitor; + } + + getAnimation(params) { + params = Params.parse(params, { + file: null, + settingsSchema: null, + onLoaded: null, + }); + + let animation = this._animations[params.settingsSchema]; + if (animation && _fileEqual0(animation.file, params.file)) { + if (params.onLoaded) { + let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + params.onLoaded(this._animations[params.settingsSchema]); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] params.onLoaded'); + } + return; + } + + animation = new Animation({ file: params.file }); + + animation.load_async(null, () => { + this._animations[params.settingsSchema] = animation; + + if (params.onLoaded) { + let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + params.onLoaded(this._animations[params.settingsSchema]); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] params.onLoaded'); + } + }); + } + + getBackgroundSource(layoutManager, settingsSchema) { + // The layoutManager is always the same one; we pass in it since + // Main.layoutManager may not be set yet + + if (!(settingsSchema in this._backgroundSources)) { + this._backgroundSources[settingsSchema] = new BackgroundSource(layoutManager, settingsSchema); + this._backgroundSources[settingsSchema]._useCount = 1; + } else { + this._backgroundSources[settingsSchema]._useCount++; + } + + return this._backgroundSources[settingsSchema]; + } + + releaseBackgroundSource(settingsSchema) { + if (settingsSchema in this._backgroundSources) { + let source = this._backgroundSources[settingsSchema]; + source._useCount--; + if (source._useCount == 0) { + delete this._backgroundSources[settingsSchema]; + source.destroy(); + } + } + } +}; + +function getBackgroundCache() { + if (!_backgroundCache) + _backgroundCache = new BackgroundCache(); + return _backgroundCache; +} + +var Background = GObject.registerClass({ + Signals: { 'loaded': {}, 'bg-changed': {} }, +}, class Background extends Meta.Background { + _init(params) { + params = Params.parse(params, { + monitorIndex: 0, + layoutManager: Main.layoutManager, + settings: null, + file: null, + style: null, + }); + + super._init({ meta_display: global.display }); + + this._settings = params.settings; + this._file = params.file; + this._style = params.style; + this._monitorIndex = params.monitorIndex; + this._layoutManager = params.layoutManager; + this._fileWatches = {}; + this._cancellable = new Gio.Cancellable(); + this.isLoaded = false; + + this._interfaceSettings = new Gio.Settings({ schema_id: INTERFACE_SCHEMA }); + + this._clock = new GnomeDesktop.WallClock(); + this._clock.connectObject('notify::timezone', + () => { + if (this._animation) + this._loadAnimation(this._animation.file); + }, this); + + let loginManager = LoginManager.getLoginManager(); + loginManager.connectObject('prepare-for-sleep', + (lm, aboutToSuspend) => { + if (aboutToSuspend) + return; + this._refreshAnimation(); + }, this); + + this._settings.connectObject('changed', + this._emitChangedSignal.bind(this), this); + + this._interfaceSettings.connectObject(`changed::${COLOR_SCHEME_KEY}`, + this._emitChangedSignal.bind(this), this); + + this._load(); + } + + destroy() { + this._cancellable.cancel(); + this._removeAnimationTimeout(); + + let i; + let keys = Object.keys(this._fileWatches); + for (i = 0; i < keys.length; i++) + this._cache.disconnect(this._fileWatches[keys[i]]); + + this._fileWatches = null; + + this._clock.disconnectObject(this); + this._clock = null; + + LoginManager.getLoginManager().disconnectObject(this); + this._settings.disconnectObject(this); + this._interfaceSettings.disconnectObject(this); + + if (this._changedIdleId) { + GLib.source_remove(this._changedIdleId); + this._changedIdleId = 0; + } + } + + _emitChangedSignal() { + if (this._changedIdleId) + return; + + this._changedIdleId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this._changedIdleId = 0; + this.emit('bg-changed'); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._changedIdleId, + '[gnome-shell] Background._emitChangedSignal'); + } + + updateResolution() { + if (this._animation) + this._refreshAnimation(); + } + + _refreshAnimation() { + if (!this._animation) + return; + + this._removeAnimationTimeout(); + this._updateAnimation(); + } + + _setLoaded() { + if (this.isLoaded) + return; + + this.isLoaded = true; + if (this._cancellable?.is_cancelled()) + return; + + let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this.emit('loaded'); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] Background._setLoaded Idle'); + } + + _loadPattern() { + let colorString, res_, color, secondColor; + + colorString = this._settings.get_string(PRIMARY_COLOR_KEY); + [res_, color] = Clutter.Color.from_string(colorString); + colorString = this._settings.get_string(SECONDARY_COLOR_KEY); + [res_, secondColor] = Clutter.Color.from_string(colorString); + + let shadingType = this._settings.get_enum(COLOR_SHADING_TYPE_KEY); + + if (shadingType == GDesktopEnums.BackgroundShading.SOLID) + this.set_color(color); + else + this.set_gradient(shadingType, color, secondColor); + } + + _watchFile(file) { + let key = file.hash(); + if (this._fileWatches[key]) + return; + + this._cache.monitorFile(file); + let signalId = this._cache.connect('file-changed', + (cache, changedFile) => { + if (changedFile.equal(file)) { + let imageCache = Meta.BackgroundImageCache.get_default(); + imageCache.purge(changedFile); + this._emitChangedSignal(); + } + }); + this._fileWatches[key] = signalId; + } + + _removeAnimationTimeout() { + if (this._updateAnimationTimeoutId) { + GLib.source_remove(this._updateAnimationTimeoutId); + this._updateAnimationTimeoutId = 0; + } + } + + _updateAnimation() { + this._updateAnimationTimeoutId = 0; + + this._animation.update(this._layoutManager.monitors[this._monitorIndex]); + let files = this._animation.keyFrameFiles; + + let finish = () => { + this._setLoaded(); + if (files.length > 1) { + this.set_blend(files[0], files[1], + this._animation.transitionProgress, + this._style); + } else if (files.length > 0) { + this.set_file(files[0], this._style); + } else { + this.set_file(null, this._style); + } + this._queueUpdateAnimation(); + }; + + let cache = Meta.BackgroundImageCache.get_default(); + let numPendingImages = files.length; + for (let i = 0; i < files.length; i++) { + this._watchFile(files[i]); + let image = cache.load(files[i]); + if (image.is_loaded()) { + numPendingImages--; + if (numPendingImages == 0) + finish(); + } else { + // eslint-disable-next-line no-loop-func + let id = image.connect('loaded', () => { + image.disconnect(id); + numPendingImages--; + if (numPendingImages == 0) + finish(); + }); + } + } + } + + _queueUpdateAnimation() { + if (this._updateAnimationTimeoutId != 0) + return; + + if (!this._cancellable || this._cancellable.is_cancelled()) + return; + + if (!this._animation.transitionDuration) + return; + + let nSteps = 255 / ANIMATION_OPACITY_STEP_INCREMENT; + let timePerStep = (this._animation.transitionDuration * 1000) / nSteps; + + let interval = Math.max(ANIMATION_MIN_WAKEUP_INTERVAL * 1000, + timePerStep); + + if (interval > GLib.MAXUINT32) + return; + + this._updateAnimationTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, + interval, + () => { + this._updateAnimationTimeoutId = 0; + this._updateAnimation(); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._updateAnimationTimeoutId, '[gnome-shell] this._updateAnimation'); + } + + _loadAnimation(file) { + this._cache.getAnimation({ + file, + settingsSchema: this._settings.schema_id, + onLoaded: animation => { + this._animation = animation; + + if (!this._animation || this._cancellable.is_cancelled()) { + this._setLoaded(); + return; + } + + this._updateAnimation(); + this._watchFile(file); + }, + }); + } + + _loadImage(file) { + this.set_file(file, this._style); + this._watchFile(file); + + let cache = Meta.BackgroundImageCache.get_default(); + let image = cache.load(file); + if (image.is_loaded()) { + this._setLoaded(); + } else { + let id = image.connect('loaded', () => { + this._setLoaded(); + image.disconnect(id); + }); + } + } + + async _loadFile(file) { + let info; + try { + info = await file.query_info_async( + Gio.FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE, + Gio.FileQueryInfoFlags.NONE, + 0, + this._cancellable); + } catch (e) { + this._setLoaded(); + return; + } + + const contentType = info.get_content_type(); + if (contentType === 'application/xml') + this._loadAnimation(file); + else + this._loadImage(file); + } + + _load() { + this._cache = getBackgroundCache(); + + this._loadPattern(); + + if (!this._file) { + this._setLoaded(); + return; + } + + this._loadFile(this._file); + } +}); + +let _systemBackground; + +var SystemBackground = GObject.registerClass({ + Signals: { 'loaded': {} }, +}, class SystemBackground extends Meta.BackgroundActor { + _init() { + if (_systemBackground == null) { + _systemBackground = new Meta.Background({ meta_display: global.display }); + _systemBackground.set_color(DEFAULT_BACKGROUND_COLOR); + } + + super._init({ + meta_display: global.display, + monitor: 0, + }); + this.content.background = _systemBackground; + + let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this.emit('loaded'); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] SystemBackground.loaded'); + } +}); + +var BackgroundSource = class BackgroundSource { + constructor(layoutManager, settingsSchema) { + // Allow override the background image setting for performance testing + this._layoutManager = layoutManager; + this._overrideImage = GLib.getenv('SHELL_BACKGROUND_IMAGE'); + this._settings = new Gio.Settings({ schema_id: settingsSchema }); + this._backgrounds = []; + + let monitorManager = Meta.MonitorManager.get(); + this._monitorsChangedId = + monitorManager.connect('monitors-changed', + this._onMonitorsChanged.bind(this)); + + this._interfaceSettings = new Gio.Settings({ schema_id: INTERFACE_SCHEMA }); + } + + _onMonitorsChanged() { + for (let monitorIndex in this._backgrounds) { + let background = this._backgrounds[monitorIndex]; + + if (monitorIndex < this._layoutManager.monitors.length) { + background.updateResolution(); + } else { + background.disconnect(background._changedId); + background.destroy(); + delete this._backgrounds[monitorIndex]; + } + } + } + + getBackground(monitorIndex) { + let file = null; + let style; + + // We don't watch changes to settings here, + // instead we rely on Background to watch those + // and emit 'bg-changed' at the right time + + if (this._overrideImage != null) { + file = Gio.File.new_for_path(this._overrideImage); + style = GDesktopEnums.BackgroundStyle.ZOOM; // Hardcode + } else { + style = this._settings.get_enum(BACKGROUND_STYLE_KEY); + if (style != GDesktopEnums.BackgroundStyle.NONE) { + const colorScheme = this._interfaceSettings.get_enum('color-scheme'); + const uri = this._settings.get_string( + colorScheme === GDesktopEnums.ColorScheme.PREFER_DARK + ? PICTURE_URI_DARK_KEY + : PICTURE_URI_KEY); + + file = Gio.File.new_for_commandline_arg(uri); + } + } + + // Animated backgrounds are (potentially) per-monitor, since + // they can have variants that depend on the aspect ratio and + // size of the monitor; for other backgrounds we can use the + // same background object for all monitors. + if (file == null || !file.get_basename().endsWith('.xml')) + monitorIndex = 0; + + if (!(monitorIndex in this._backgrounds)) { + let background = new Background({ + monitorIndex, + layoutManager: this._layoutManager, + settings: this._settings, + file, + style, + }); + + background._changedId = background.connect('bg-changed', () => { + background.disconnect(background._changedId); + background.destroy(); + delete this._backgrounds[monitorIndex]; + }); + + this._backgrounds[monitorIndex] = background; + } + + return this._backgrounds[monitorIndex]; + } + + destroy() { + let monitorManager = Meta.MonitorManager.get(); + monitorManager.disconnect(this._monitorsChangedId); + + for (let monitorIndex in this._backgrounds) { + let background = this._backgrounds[monitorIndex]; + background.disconnect(background._changedId); + background.destroy(); + } + + this._backgrounds = null; + } +}; + +var Animation = GObject.registerClass( +class Animation extends GnomeDesktop.BGSlideShow { + _init(params) { + super._init(params); + + this.keyFrameFiles = []; + this.transitionProgress = 0.0; + this.transitionDuration = 0.0; + this.loaded = false; + } + + // eslint-disable-next-line camelcase + load_async(cancellable, callback) { + super.load_async(cancellable, () => { + this.loaded = true; + + callback?.(); + }); + } + + update(monitor) { + this.keyFrameFiles = []; + + if (this.get_num_slides() < 1) + return; + + let [progress, duration, isFixed_, filename1, filename2] = + this.get_current_slide(monitor.width, monitor.height); + + this.transitionDuration = duration; + this.transitionProgress = progress; + + if (filename1) + this.keyFrameFiles.push(Gio.File.new_for_path(filename1)); + + if (filename2) + this.keyFrameFiles.push(Gio.File.new_for_path(filename2)); + } +}); + +var BackgroundManager = class BackgroundManager extends Signals.EventEmitter { + constructor(params) { + super(); + params = Params.parse(params, { + container: null, + layoutManager: Main.layoutManager, + monitorIndex: null, + vignette: false, + controlPosition: true, + settingsSchema: BACKGROUND_SCHEMA, + useContentSize: true, + }); + + let cache = getBackgroundCache(); + this._settingsSchema = params.settingsSchema; + this._backgroundSource = cache.getBackgroundSource(params.layoutManager, params.settingsSchema); + + this._container = params.container; + this._layoutManager = params.layoutManager; + this._vignette = params.vignette; + this._monitorIndex = params.monitorIndex; + this._controlPosition = params.controlPosition; + this._useContentSize = params.useContentSize; + + this.backgroundActor = this._createBackgroundActor(); + this._newBackgroundActor = null; + } + + destroy() { + let cache = getBackgroundCache(); + cache.releaseBackgroundSource(this._settingsSchema); + this._backgroundSource = null; + + if (this._newBackgroundActor) { + this._newBackgroundActor.destroy(); + this._newBackgroundActor = null; + } + + if (this.backgroundActor) { + this.backgroundActor.destroy(); + this.backgroundActor = null; + } + } + + _swapBackgroundActor() { + let oldBackgroundActor = this.backgroundActor; + this.backgroundActor = this._newBackgroundActor; + this._newBackgroundActor = null; + this.emit('changed'); + + if (Main.layoutManager.screenTransition.visible) { + oldBackgroundActor.destroy(); + return; + } + + oldBackgroundActor.ease({ + opacity: 0, + duration: FADE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => oldBackgroundActor.destroy(), + }); + } + + _updateBackgroundActor() { + if (this._newBackgroundActor) { + /* Skip displaying existing background queued for load */ + this._newBackgroundActor.destroy(); + this._newBackgroundActor = null; + } + + let newBackgroundActor = this._createBackgroundActor(); + + const oldContent = this.backgroundActor.content; + const newContent = newBackgroundActor.content; + + newContent.vignette_sharpness = oldContent.vignette_sharpness; + newContent.brightness = oldContent.brightness; + + newBackgroundActor.visible = this.backgroundActor.visible; + + this._newBackgroundActor = newBackgroundActor; + + const { background } = newBackgroundActor.content; + + if (background.isLoaded) { + this._swapBackgroundActor(); + } else { + newBackgroundActor.loadedSignalId = background.connect('loaded', + () => { + background.disconnect(newBackgroundActor.loadedSignalId); + newBackgroundActor.loadedSignalId = 0; + + this._swapBackgroundActor(); + }); + } + } + + _createBackgroundActor() { + let background = this._backgroundSource.getBackground(this._monitorIndex); + let backgroundActor = new Meta.BackgroundActor({ + meta_display: global.display, + monitor: this._monitorIndex, + request_mode: this._useContentSize + ? Clutter.RequestMode.CONTENT_SIZE + : Clutter.RequestMode.HEIGHT_FOR_WIDTH, + x_expand: !this._useContentSize, + y_expand: !this._useContentSize, + }); + backgroundActor.content.set({ + background, + vignette: this._vignette, + vignette_sharpness: 0.5, + brightness: 0.5, + }); + + this._container.add_child(backgroundActor); + + if (this._controlPosition) { + let monitor = this._layoutManager.monitors[this._monitorIndex]; + backgroundActor.set_position(monitor.x, monitor.y); + this._container.set_child_below_sibling(backgroundActor, null); + } + + let changeSignalId = background.connect('bg-changed', () => { + background.disconnect(changeSignalId); + changeSignalId = null; + this._updateBackgroundActor(); + }); + + let loadedSignalId; + if (background.isLoaded) { + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this.emit('loaded'); + return GLib.SOURCE_REMOVE; + }); + } else { + loadedSignalId = background.connect('loaded', () => { + background.disconnect(loadedSignalId); + loadedSignalId = null; + this.emit('loaded'); + }); + } + + backgroundActor.connect('destroy', () => { + if (changeSignalId) + background.disconnect(changeSignalId); + + if (loadedSignalId) + background.disconnect(loadedSignalId); + + if (backgroundActor.loadedSignalId) + background.disconnect(backgroundActor.loadedSignalId); + }); + + return backgroundActor; + } +}; diff --git a/js/ui/backgroundMenu.js b/js/ui/backgroundMenu.js new file mode 100644 index 0000000..4c7372a --- /dev/null +++ b/js/ui/backgroundMenu.js @@ -0,0 +1,67 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported addBackgroundMenu */ + +const { Clutter, St } = imports.gi; + +const BoxPointer = imports.ui.boxpointer; +const Main = imports.ui.main; +const PopupMenu = imports.ui.popupMenu; + +var BackgroundMenu = class BackgroundMenu extends PopupMenu.PopupMenu { + constructor(layoutManager) { + super(layoutManager.dummyCursor, 0, St.Side.TOP); + + this.addSettingsAction(_("Change Background…"), 'gnome-background-panel.desktop'); + this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + this.addSettingsAction(_("Display Settings"), 'gnome-display-panel.desktop'); + this.addSettingsAction(_('Settings'), 'org.gnome.Settings.desktop'); + + this.actor.add_style_class_name('background-menu'); + + layoutManager.uiGroup.add_actor(this.actor); + this.actor.hide(); + } +}; + +function addBackgroundMenu(actor, layoutManager) { + actor.reactive = true; + actor._backgroundMenu = new BackgroundMenu(layoutManager); + actor._backgroundManager = new PopupMenu.PopupMenuManager(actor); + actor._backgroundManager.addMenu(actor._backgroundMenu); + + function openMenu(x, y) { + Main.layoutManager.setDummyCursorGeometry(x, y, 0, 0); + actor._backgroundMenu.open(BoxPointer.PopupAnimation.FULL); + } + + let clickAction = new Clutter.ClickAction(); + clickAction.connect('long-press', (action, theActor, state) => { + if (state == Clutter.LongPressState.QUERY) { + return (action.get_button() == 0 || + action.get_button() == 1) && + !actor._backgroundMenu.isOpen; + } + if (state == Clutter.LongPressState.ACTIVATE) { + let [x, y] = action.get_coords(); + openMenu(x, y); + actor._backgroundManager.ignoreRelease(); + } + return true; + }); + clickAction.connect('clicked', action => { + if (action.get_button() == 3) { + let [x, y] = action.get_coords(); + openMenu(x, y); + } + }); + actor.add_action(clickAction); + + global.display.connectObject('grab-op-begin', + () => clickAction.release(), actor); + + actor.connect('destroy', () => { + actor._backgroundMenu.destroy(); + actor._backgroundMenu = null; + actor._backgroundManager = null; + }); +} diff --git a/js/ui/barLevel.js b/js/ui/barLevel.js new file mode 100644 index 0000000..da5b34a --- /dev/null +++ b/js/ui/barLevel.js @@ -0,0 +1,262 @@ +/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */ +/* exported BarLevel */ + +const { Atk, Clutter, GObject, St } = imports.gi; + +var BarLevel = GObject.registerClass({ + Properties: { + 'value': GObject.ParamSpec.double( + 'value', 'value', 'value', + GObject.ParamFlags.READWRITE, + 0, 2, 0), + 'maximum-value': GObject.ParamSpec.double( + 'maximum-value', 'maximum-value', 'maximum-value', + GObject.ParamFlags.READWRITE, + 1, 2, 1), + 'overdrive-start': GObject.ParamSpec.double( + 'overdrive-start', 'overdrive-start', 'overdrive-start', + GObject.ParamFlags.READWRITE, + 1, 2, 1), + }, +}, class BarLevel extends St.DrawingArea { + _init(params) { + this._maxValue = 1; + this._value = 0; + this._overdriveStart = 1; + this._barLevelWidth = 0; + + let defaultParams = { + style_class: 'barlevel', + accessible_role: Atk.Role.LEVEL_BAR, + }; + super._init(Object.assign(defaultParams, params)); + this.connect('notify::allocation', () => { + this._barLevelWidth = this.allocation.get_width(); + }); + + this._customAccessible = St.GenericAccessible.new_for_actor(this); + this.set_accessible(this._customAccessible); + + this._customAccessible.connect('get-current-value', this._getCurrentValue.bind(this)); + this._customAccessible.connect('get-minimum-value', this._getMinimumValue.bind(this)); + this._customAccessible.connect('get-maximum-value', this._getMaximumValue.bind(this)); + this._customAccessible.connect('set-current-value', this._setCurrentValue.bind(this)); + + this.connect('notify::value', this._valueChanged.bind(this)); + } + + get value() { + return this._value; + } + + set value(value) { + value = Math.max(Math.min(value, this._maxValue), 0); + + if (this._value == value) + return; + + this._value = value; + this.notify('value'); + this.queue_repaint(); + } + + get maximumValue() { + return this._maxValue; + } + + set maximumValue(value) { + value = Math.max(value, 1); + + if (this._maxValue == value) + return; + + this._maxValue = value; + this._overdriveStart = Math.min(this._overdriveStart, this._maxValue); + this.notify('maximum-value'); + this.queue_repaint(); + } + + get overdriveStart() { + return this._overdriveStart; + } + + set overdriveStart(value) { + if (this._overdriveStart == value) + return; + + if (value > this._maxValue) { + throw new Error(`Tried to set overdrive value to ${value}, ` + + `which is a number greater than the maximum allowed value ${this._maxValue}`); + } + + this._overdriveStart = value; + this.notify('overdrive-start'); + this.queue_repaint(); + } + + vfunc_repaint() { + let cr = this.get_context(); + let themeNode = this.get_theme_node(); + let [width, height] = this.get_surface_size(); + const rtl = this.get_text_direction() === Clutter.TextDirection.RTL; + + let barLevelHeight = themeNode.get_length('-barlevel-height'); + let barLevelBorderRadius = Math.min(width, barLevelHeight) / 2; + let fgColor = themeNode.get_foreground_color(); + + let barLevelColor = themeNode.get_color('-barlevel-background-color'); + let barLevelActiveColor = themeNode.get_color('-barlevel-active-background-color'); + let barLevelOverdriveColor = themeNode.get_color('-barlevel-overdrive-color'); + + let barLevelBorderWidth = Math.min(themeNode.get_length('-barlevel-border-width'), 1); + let [hasBorderColor, barLevelBorderColor] = + themeNode.lookup_color('-barlevel-border-color', false); + if (!hasBorderColor) + barLevelBorderColor = barLevelColor; + let [hasActiveBorderColor, barLevelActiveBorderColor] = + themeNode.lookup_color('-barlevel-active-border-color', false); + if (!hasActiveBorderColor) + barLevelActiveBorderColor = barLevelActiveColor; + let [hasOverdriveBorderColor, barLevelOverdriveBorderColor] = + themeNode.lookup_color('-barlevel-overdrive-border-color', false); + if (!hasOverdriveBorderColor) + barLevelOverdriveBorderColor = barLevelOverdriveColor; + + const TAU = Math.PI * 2; + + let endX = 0; + if (this._maxValue > 0) { + let progress = this._value / this._maxValue; + if (rtl) + progress = 1 - progress; + endX = barLevelBorderRadius + (width - 2 * barLevelBorderRadius) * progress; + } + + let overdriveRatio = this._overdriveStart / this._maxValue; + if (rtl) + overdriveRatio = 1 - overdriveRatio; + let overdriveSeparatorX = barLevelBorderRadius + (width - 2 * barLevelBorderRadius) * overdriveRatio; + + let overdriveActive = this._overdriveStart !== this._maxValue; + let overdriveSeparatorWidth = 0; + if (overdriveActive) + overdriveSeparatorWidth = themeNode.get_length('-barlevel-overdrive-separator-width'); + + let xcArcStart = barLevelBorderRadius + barLevelBorderWidth; + let xcArcEnd = width - xcArcStart; + if (rtl) + [xcArcStart, xcArcEnd] = [xcArcEnd, xcArcStart]; + + /* background bar */ + if (!rtl) + cr.arc(xcArcEnd, height / 2, barLevelBorderRadius, TAU * (3 / 4), TAU * (1 / 4)); + else + cr.arcNegative(xcArcEnd, height / 2, barLevelBorderRadius, TAU * (3 / 4), TAU * (1 / 4)); + cr.lineTo(endX, (height + barLevelHeight) / 2); + cr.lineTo(endX, (height - barLevelHeight) / 2); + cr.lineTo(xcArcEnd, (height - barLevelHeight) / 2); + Clutter.cairo_set_source_color(cr, barLevelColor); + cr.fillPreserve(); + Clutter.cairo_set_source_color(cr, barLevelBorderColor); + cr.setLineWidth(barLevelBorderWidth); + cr.stroke(); + + /* normal progress bar */ + let x = 0; + if (!rtl) { + x = Math.min(endX, overdriveSeparatorX - overdriveSeparatorWidth / 2); + cr.arc(xcArcStart, height / 2, barLevelBorderRadius, TAU * (1 / 4), TAU * (3 / 4)); + } else { + x = Math.max(endX, overdriveSeparatorX + overdriveSeparatorWidth / 2); + cr.arcNegative(xcArcStart, height / 2, barLevelBorderRadius, TAU * (1 / 4), TAU * (3 / 4)); + } + cr.lineTo(x, (height - barLevelHeight) / 2); + cr.lineTo(x, (height + barLevelHeight) / 2); + cr.lineTo(xcArcStart, (height + barLevelHeight) / 2); + if (this._value > 0) + Clutter.cairo_set_source_color(cr, barLevelActiveColor); + cr.fillPreserve(); + Clutter.cairo_set_source_color(cr, barLevelActiveBorderColor); + cr.setLineWidth(barLevelBorderWidth); + cr.stroke(); + + /* overdrive progress barLevel */ + if (!rtl) + x = Math.min(endX, overdriveSeparatorX) + overdriveSeparatorWidth / 2; + else + x = Math.max(endX, overdriveSeparatorX) - overdriveSeparatorWidth / 2; + if (this._value > this._overdriveStart) { + cr.moveTo(x, (height - barLevelHeight) / 2); + cr.lineTo(endX, (height - barLevelHeight) / 2); + cr.lineTo(endX, (height + barLevelHeight) / 2); + cr.lineTo(x, (height + barLevelHeight) / 2); + cr.lineTo(x, (height - barLevelHeight) / 2); + Clutter.cairo_set_source_color(cr, barLevelOverdriveColor); + cr.fillPreserve(); + Clutter.cairo_set_source_color(cr, barLevelOverdriveBorderColor); + cr.setLineWidth(barLevelBorderWidth); + cr.stroke(); + } + + /* end progress bar arc */ + if (this._value > 0) { + if (this._value <= this._overdriveStart) + Clutter.cairo_set_source_color(cr, barLevelActiveColor); + else + Clutter.cairo_set_source_color(cr, barLevelOverdriveColor); + if (!rtl) { + cr.arc(endX, height / 2, barLevelBorderRadius, TAU * (3 / 4), TAU * (1 / 4)); + cr.lineTo(Math.floor(endX), (height + barLevelHeight) / 2); + cr.lineTo(Math.floor(endX), (height - barLevelHeight) / 2); + } else { + cr.arcNegative(endX, height / 2, barLevelBorderRadius, TAU * (3 / 4), TAU * (1 / 4)); + cr.lineTo(Math.ceil(endX), (height + barLevelHeight) / 2); + cr.lineTo(Math.ceil(endX), (height - barLevelHeight) / 2); + } + cr.lineTo(endX, (height - barLevelHeight) / 2); + cr.fillPreserve(); + cr.setLineWidth(barLevelBorderWidth); + cr.stroke(); + } + + /* draw overdrive separator */ + if (overdriveActive) { + cr.moveTo(overdriveSeparatorX - overdriveSeparatorWidth / 2, (height - barLevelHeight) / 2); + cr.lineTo(overdriveSeparatorX + overdriveSeparatorWidth / 2, (height - barLevelHeight) / 2); + cr.lineTo(overdriveSeparatorX + overdriveSeparatorWidth / 2, (height + barLevelHeight) / 2); + cr.lineTo(overdriveSeparatorX - overdriveSeparatorWidth / 2, (height + barLevelHeight) / 2); + cr.lineTo(overdriveSeparatorX - overdriveSeparatorWidth / 2, (height - barLevelHeight) / 2); + if (this._value <= this._overdriveStart) + Clutter.cairo_set_source_color(cr, fgColor); + else + Clutter.cairo_set_source_color(cr, barLevelColor); + cr.fill(); + } + + cr.$dispose(); + } + + _getCurrentValue() { + return this._value; + } + + _getOverdriveStart() { + return this._overdriveStart; + } + + _getMinimumValue() { + return 0; + } + + _getMaximumValue() { + return this._maxValue; + } + + _setCurrentValue(_actor, value) { + this._value = value; + } + + _valueChanged() { + this._customAccessible.notify("accessible-value"); + } +}); diff --git a/js/ui/boxpointer.js b/js/ui/boxpointer.js new file mode 100644 index 0000000..3987d62 --- /dev/null +++ b/js/ui/boxpointer.js @@ -0,0 +1,654 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported BoxPointer */ + +const { Clutter, GObject, Meta, St } = imports.gi; + +const Main = imports.ui.main; + +var PopupAnimation = { + NONE: 0, + SLIDE: 1 << 0, + FADE: 1 << 1, + FULL: ~0, +}; + +var POPUP_ANIMATION_TIME = 150; + +/** + * BoxPointer: + * @side: side to draw the arrow on + * @binProperties: Properties to set on contained bin + * + * An actor which displays a triangle "arrow" pointing to a given + * side. The .bin property is a container in which content can be + * placed. The arrow position may be controlled via + * setArrowOrigin(). The arrow side might be temporarily flipped + * depending on the box size and source position to keep the box + * totally inside the monitor workarea if possible. + * + */ +var BoxPointer = GObject.registerClass({ + Signals: { 'arrow-side-changed': {} }, +}, class BoxPointer extends St.Widget { + _init(arrowSide, binProperties) { + super._init(); + + this.set_offscreen_redirect(Clutter.OffscreenRedirect.ALWAYS); + + this._arrowSide = arrowSide; + this._userArrowSide = arrowSide; + this._arrowOrigin = 0; + this._arrowActor = null; + this.bin = new St.Bin(binProperties); + this.add_actor(this.bin); + this._border = new St.DrawingArea(); + this._border.connect('repaint', this._drawBorder.bind(this)); + this.add_actor(this._border); + this.set_child_above_sibling(this.bin, this._border); + this._sourceAlignment = 0.5; + this._muteKeys = true; + this._muteInput = true; + + this.connect('notify::visible', () => { + if (this.visible) + Meta.disable_unredirect_for_display(global.display); + else + Meta.enable_unredirect_for_display(global.display); + }); + } + + vfunc_captured_event(event) { + if (event.type() === Clutter.EventType.ENTER || + event.type() === Clutter.EventType.LEAVE) + return Clutter.EVENT_PROPAGATE; + + let mute = event.type() === Clutter.EventType.KEY_PRESS || + event.type() === Clutter.EventType.KEY_RELEASE + ? this._muteKeys : this._muteInput; + + if (mute) + return Clutter.EVENT_STOP; + + return Clutter.EVENT_PROPAGATE; + } + + get arrowSide() { + return this._arrowSide; + } + + open(animate, onComplete) { + let themeNode = this.get_theme_node(); + let rise = themeNode.get_length('-arrow-rise'); + let animationTime = animate & PopupAnimation.FULL ? POPUP_ANIMATION_TIME : 0; + + if (animate & PopupAnimation.FADE) + this.opacity = 0; + else + this.opacity = 255; + + this._muteKeys = false; + this.show(); + + if (animate & PopupAnimation.SLIDE) { + switch (this._arrowSide) { + case St.Side.TOP: + this.translation_y = -rise; + break; + case St.Side.BOTTOM: + this.translation_y = rise; + break; + case St.Side.LEFT: + this.translation_x = -rise; + break; + case St.Side.RIGHT: + this.translation_x = rise; + break; + } + } + + this.ease({ + opacity: 255, + translation_x: 0, + translation_y: 0, + duration: animationTime, + mode: Clutter.AnimationMode.LINEAR, + onComplete: () => { + this._muteInput = false; + if (onComplete) + onComplete(); + }, + }); + } + + close(animate, onComplete) { + if (!this.visible) + return; + + let translationX = 0; + let translationY = 0; + let themeNode = this.get_theme_node(); + let rise = themeNode.get_length('-arrow-rise'); + let fade = animate & PopupAnimation.FADE; + let animationTime = animate & PopupAnimation.FULL ? POPUP_ANIMATION_TIME : 0; + + if (animate & PopupAnimation.SLIDE) { + switch (this._arrowSide) { + case St.Side.TOP: + translationY = rise; + break; + case St.Side.BOTTOM: + translationY = -rise; + break; + case St.Side.LEFT: + translationX = rise; + break; + case St.Side.RIGHT: + translationX = -rise; + break; + } + } + + this._muteInput = true; + this._muteKeys = true; + + this.remove_all_transitions(); + this.ease({ + opacity: fade ? 0 : 255, + translation_x: translationX, + translation_y: translationY, + duration: animationTime, + mode: Clutter.AnimationMode.LINEAR, + onComplete: () => { + this.hide(); + this.opacity = 0; + this.translation_x = 0; + this.translation_y = 0; + if (onComplete) + onComplete(); + }, + }); + } + + _adjustAllocationForArrow(isWidth, minSize, natSize) { + let themeNode = this.get_theme_node(); + let borderWidth = themeNode.get_length('-arrow-border-width'); + minSize += borderWidth * 2; + natSize += borderWidth * 2; + if ((!isWidth && (this._arrowSide == St.Side.TOP || this._arrowSide == St.Side.BOTTOM)) || + (isWidth && (this._arrowSide == St.Side.LEFT || this._arrowSide == St.Side.RIGHT))) { + let rise = themeNode.get_length('-arrow-rise'); + minSize += rise; + natSize += rise; + } + + return [minSize, natSize]; + } + + vfunc_get_preferred_width(forHeight) { + let themeNode = this.get_theme_node(); + forHeight = themeNode.adjust_for_height(forHeight); + + let width = this.bin.get_preferred_width(forHeight); + width = this._adjustAllocationForArrow(true, ...width); + + return themeNode.adjust_preferred_width(...width); + } + + vfunc_get_preferred_height(forWidth) { + let themeNode = this.get_theme_node(); + let borderWidth = themeNode.get_length('-arrow-border-width'); + forWidth = themeNode.adjust_for_width(forWidth); + + let height = this.bin.get_preferred_height(forWidth - 2 * borderWidth); + height = this._adjustAllocationForArrow(false, ...height); + + return themeNode.adjust_preferred_height(...height); + } + + vfunc_allocate(box) { + if (this._sourceActor && this._sourceActor.mapped) { + this._reposition(box); + this._updateFlip(box); + } + + this.set_allocation(box); + + let themeNode = this.get_theme_node(); + let borderWidth = themeNode.get_length('-arrow-border-width'); + let rise = themeNode.get_length('-arrow-rise'); + let childBox = new Clutter.ActorBox(); + let [availWidth, availHeight] = themeNode.get_content_box(box).get_size(); + + childBox.x1 = 0; + childBox.y1 = 0; + childBox.x2 = availWidth; + childBox.y2 = availHeight; + this._border.allocate(childBox); + + childBox.x1 = borderWidth; + childBox.y1 = borderWidth; + childBox.x2 = availWidth - borderWidth; + childBox.y2 = availHeight - borderWidth; + switch (this._arrowSide) { + case St.Side.TOP: + childBox.y1 += rise; + break; + case St.Side.BOTTOM: + childBox.y2 -= rise; + break; + case St.Side.LEFT: + childBox.x1 += rise; + break; + case St.Side.RIGHT: + childBox.x2 -= rise; + break; + } + this.bin.allocate(childBox); + } + + _drawBorder(area) { + let themeNode = this.get_theme_node(); + + if (this._arrowActor) { + let [sourceX, sourceY] = this._arrowActor.get_transformed_position(); + let [sourceWidth, sourceHeight] = this._arrowActor.get_transformed_size(); + let [absX, absY] = this.get_transformed_position(); + + if (this._arrowSide == St.Side.TOP || + this._arrowSide == St.Side.BOTTOM) + this._arrowOrigin = sourceX - absX + sourceWidth / 2; + else + this._arrowOrigin = sourceY - absY + sourceHeight / 2; + } + + let borderWidth = themeNode.get_length('-arrow-border-width'); + let base = themeNode.get_length('-arrow-base'); + let rise = themeNode.get_length('-arrow-rise'); + let borderRadius = themeNode.get_length('-arrow-border-radius'); + + let halfBorder = borderWidth / 2; + let halfBase = Math.floor(base / 2); + + let [width, height] = area.get_surface_size(); + let [boxWidth, boxHeight] = [width, height]; + if (this._arrowSide == St.Side.TOP || this._arrowSide == St.Side.BOTTOM) + boxHeight -= rise; + else + boxWidth -= rise; + + let cr = area.get_context(); + + // Translate so that box goes from 0,0 to boxWidth,boxHeight, + // with the arrow poking out of that + if (this._arrowSide == St.Side.TOP) + cr.translate(0, rise); + else if (this._arrowSide == St.Side.LEFT) + cr.translate(rise, 0); + + let [x1, y1] = [halfBorder, halfBorder]; + let [x2, y2] = [boxWidth - halfBorder, boxHeight - halfBorder]; + + let skipTopLeft = false; + let skipTopRight = false; + let skipBottomLeft = false; + let skipBottomRight = false; + + if (rise) { + switch (this._arrowSide) { + case St.Side.TOP: + if (this._arrowOrigin == x1) + skipTopLeft = true; + else if (this._arrowOrigin == x2) + skipTopRight = true; + break; + + case St.Side.RIGHT: + if (this._arrowOrigin == y1) + skipTopRight = true; + else if (this._arrowOrigin == y2) + skipBottomRight = true; + break; + + case St.Side.BOTTOM: + if (this._arrowOrigin == x1) + skipBottomLeft = true; + else if (this._arrowOrigin == x2) + skipBottomRight = true; + break; + + case St.Side.LEFT: + if (this._arrowOrigin == y1) + skipTopLeft = true; + else if (this._arrowOrigin == y2) + skipBottomLeft = true; + break; + } + } + + cr.moveTo(x1 + borderRadius, y1); + if (this._arrowSide == St.Side.TOP && rise) { + if (skipTopLeft) { + cr.moveTo(x1, y2 - borderRadius); + cr.lineTo(x1, y1 - rise); + cr.lineTo(x1 + halfBase, y1); + } else if (skipTopRight) { + cr.lineTo(x2 - halfBase, y1); + cr.lineTo(x2, y1 - rise); + cr.lineTo(x2, y1 + borderRadius); + } else { + cr.lineTo(this._arrowOrigin - halfBase, y1); + cr.lineTo(this._arrowOrigin, y1 - rise); + cr.lineTo(this._arrowOrigin + halfBase, y1); + } + } + + if (!skipTopRight) { + cr.lineTo(x2 - borderRadius, y1); + cr.arc(x2 - borderRadius, y1 + borderRadius, borderRadius, + 3 * Math.PI / 2, Math.PI * 2); + } + + if (this._arrowSide == St.Side.RIGHT && rise) { + if (skipTopRight) { + cr.lineTo(x2 + rise, y1); + cr.lineTo(x2 + rise, y1 + halfBase); + } else if (skipBottomRight) { + cr.lineTo(x2, y2 - halfBase); + cr.lineTo(x2 + rise, y2); + cr.lineTo(x2 - borderRadius, y2); + } else { + cr.lineTo(x2, this._arrowOrigin - halfBase); + cr.lineTo(x2 + rise, this._arrowOrigin); + cr.lineTo(x2, this._arrowOrigin + halfBase); + } + } + + if (!skipBottomRight) { + cr.lineTo(x2, y2 - borderRadius); + cr.arc(x2 - borderRadius, y2 - borderRadius, borderRadius, + 0, Math.PI / 2); + } + + if (this._arrowSide == St.Side.BOTTOM && rise) { + if (skipBottomLeft) { + cr.lineTo(x1 + halfBase, y2); + cr.lineTo(x1, y2 + rise); + cr.lineTo(x1, y2 - borderRadius); + } else if (skipBottomRight) { + cr.lineTo(x2, y2 + rise); + cr.lineTo(x2 - halfBase, y2); + } else { + cr.lineTo(this._arrowOrigin + halfBase, y2); + cr.lineTo(this._arrowOrigin, y2 + rise); + cr.lineTo(this._arrowOrigin - halfBase, y2); + } + } + + if (!skipBottomLeft) { + cr.lineTo(x1 + borderRadius, y2); + cr.arc(x1 + borderRadius, y2 - borderRadius, borderRadius, + Math.PI / 2, Math.PI); + } + + if (this._arrowSide == St.Side.LEFT && rise) { + if (skipTopLeft) { + cr.lineTo(x1, y1 + halfBase); + cr.lineTo(x1 - rise, y1); + cr.lineTo(x1 + borderRadius, y1); + } else if (skipBottomLeft) { + cr.lineTo(x1 - rise, y2); + cr.lineTo(x1 - rise, y2 - halfBase); + } else { + cr.lineTo(x1, this._arrowOrigin + halfBase); + cr.lineTo(x1 - rise, this._arrowOrigin); + cr.lineTo(x1, this._arrowOrigin - halfBase); + } + } + + if (!skipTopLeft) { + cr.lineTo(x1, y1 + borderRadius); + cr.arc(x1 + borderRadius, y1 + borderRadius, borderRadius, + Math.PI, 3 * Math.PI / 2); + } + + const [hasColor, bgColor] = + themeNode.lookup_color('-arrow-background-color', false); + if (hasColor) { + Clutter.cairo_set_source_color(cr, bgColor); + cr.fillPreserve(); + } + + if (borderWidth > 0) { + let borderColor = themeNode.get_color('-arrow-border-color'); + Clutter.cairo_set_source_color(cr, borderColor); + cr.setLineWidth(borderWidth); + cr.stroke(); + } + + cr.$dispose(); + } + + setPosition(sourceActor, alignment) { + if (!this._sourceActor || sourceActor != this._sourceActor) { + this._sourceActor?.disconnectObject(this); + + this._sourceActor = sourceActor; + + this._sourceActor?.connectObject('destroy', + () => (this._sourceActor = null), this); + } + + this._arrowAlignment = alignment; + + this.queue_relayout(); + } + + setSourceAlignment(alignment) { + this._sourceAlignment = alignment; + + if (!this._sourceActor) + return; + + this.setPosition(this._sourceActor, this._arrowAlignment); + } + + _reposition(allocationBox) { + let sourceActor = this._sourceActor; + let alignment = this._arrowAlignment; + let monitorIndex = Main.layoutManager.findIndexForActor(sourceActor); + + this._sourceExtents = sourceActor.get_transformed_extents(); + this._workArea = Main.layoutManager.getWorkAreaForMonitor(monitorIndex); + + // Position correctly relative to the sourceActor + const sourceAllocation = sourceActor.get_allocation_box(); + const sourceContentBox = sourceActor instanceof St.Widget + ? sourceActor.get_theme_node().get_content_box(sourceAllocation) + : new Clutter.ActorBox({ + x2: sourceAllocation.get_width(), + y2: sourceAllocation.get_height(), + }); + let sourceTopLeft = this._sourceExtents.get_top_left(); + let sourceBottomRight = this._sourceExtents.get_bottom_right(); + let sourceCenterX = sourceTopLeft.x + sourceContentBox.x1 + (sourceContentBox.x2 - sourceContentBox.x1) * this._sourceAlignment; + let sourceCenterY = sourceTopLeft.y + sourceContentBox.y1 + (sourceContentBox.y2 - sourceContentBox.y1) * this._sourceAlignment; + let [, , natWidth, natHeight] = this.get_preferred_size(); + + // We also want to keep it onscreen, and separated from the + // edge by the same distance as the main part of the box is + // separated from its sourceActor + let workarea = this._workArea; + let themeNode = this.get_theme_node(); + let borderWidth = themeNode.get_length('-arrow-border-width'); + let arrowBase = themeNode.get_length('-arrow-base'); + let borderRadius = themeNode.get_length('-arrow-border-radius'); + let margin = 4 * borderRadius + borderWidth + arrowBase; + + let gap = themeNode.get_length('-boxpointer-gap'); + let padding = themeNode.get_length('-arrow-rise'); + + let resX, resY; + + switch (this._arrowSide) { + case St.Side.TOP: + resY = sourceBottomRight.y + gap; + break; + case St.Side.BOTTOM: + resY = sourceTopLeft.y - natHeight - gap; + break; + case St.Side.LEFT: + resX = sourceBottomRight.x + gap; + break; + case St.Side.RIGHT: + resX = sourceTopLeft.x - natWidth - gap; + break; + } + + // Now align and position the pointing axis, making sure it fits on + // screen. If the arrowOrigin is so close to the edge that the arrow + // will not be isosceles, we try to compensate as follows: + // - We skip the rounded corner and settle for a right angled arrow + // as shown below. See _drawBorder for further details. + // |\_____ + // | + // | + // - If the arrow was going to be acute angled, we move the position + // of the box to maintain the arrow's accuracy. + + let arrowOrigin; + let halfBase = Math.floor(arrowBase / 2); + let halfBorder = borderWidth / 2; + let halfMargin = margin / 2; + let [x1, y1] = [halfBorder, halfBorder]; + let [x2, y2] = [natWidth - halfBorder, natHeight - halfBorder]; + + switch (this._arrowSide) { + case St.Side.TOP: + case St.Side.BOTTOM: + resX = sourceCenterX - (halfMargin + (natWidth - margin) * alignment); + + resX = Math.max(resX, workarea.x + padding); + resX = Math.min(resX, workarea.x + workarea.width - (padding + natWidth)); + + arrowOrigin = sourceCenterX - resX; + if (arrowOrigin <= (x1 + (borderRadius + halfBase))) { + if (arrowOrigin > x1) + resX += arrowOrigin - x1; + arrowOrigin = x1; + } else if (arrowOrigin >= (x2 - (borderRadius + halfBase))) { + if (arrowOrigin < x2) + resX -= x2 - arrowOrigin; + arrowOrigin = x2; + } + break; + + case St.Side.LEFT: + case St.Side.RIGHT: + resY = sourceCenterY - (halfMargin + (natHeight - margin) * alignment); + + resY = Math.max(resY, workarea.y + padding); + resY = Math.min(resY, workarea.y + workarea.height - (padding + natHeight)); + + arrowOrigin = sourceCenterY - resY; + if (arrowOrigin <= (y1 + (borderRadius + halfBase))) { + if (arrowOrigin > y1) + resY += arrowOrigin - y1; + arrowOrigin = y1; + } else if (arrowOrigin >= (y2 - (borderRadius + halfBase))) { + if (arrowOrigin < y2) + resY -= y2 - arrowOrigin; + arrowOrigin = y2; + } + break; + } + + this.setArrowOrigin(arrowOrigin); + + let parent = this.get_parent(); + let success, x, y; + while (!success) { + [success, x, y] = parent.transform_stage_point(resX, resY); + parent = parent.get_parent(); + } + + // Actually set the position + allocationBox.set_origin(Math.floor(x), Math.floor(y)); + } + + // @origin: Coordinate specifying middle of the arrow, along + // the Y axis for St.Side.LEFT, St.Side.RIGHT from the top and X axis from + // the left for St.Side.TOP and St.Side.BOTTOM. + setArrowOrigin(origin) { + if (this._arrowOrigin != origin) { + this._arrowOrigin = origin; + this._border.queue_repaint(); + } + } + + // @actor: an actor relative to which the arrow is positioned. + // Differently from setPosition, this will not move the boxpointer itself, + // on the arrow + setArrowActor(actor) { + if (this._arrowActor != actor) { + this._arrowActor = actor; + this._border.queue_repaint(); + } + } + + _calculateArrowSide(arrowSide) { + let sourceTopLeft = this._sourceExtents.get_top_left(); + let sourceBottomRight = this._sourceExtents.get_bottom_right(); + let [, , boxWidth, boxHeight] = this.get_preferred_size(); + let workarea = this._workArea; + + switch (arrowSide) { + case St.Side.TOP: + if (sourceBottomRight.y + boxHeight > workarea.y + workarea.height && + boxHeight < sourceTopLeft.y - workarea.y) + return St.Side.BOTTOM; + break; + case St.Side.BOTTOM: + if (sourceTopLeft.y - boxHeight < workarea.y && + boxHeight < workarea.y + workarea.height - sourceBottomRight.y) + return St.Side.TOP; + break; + case St.Side.LEFT: + if (sourceBottomRight.x + boxWidth > workarea.x + workarea.width && + boxWidth < sourceTopLeft.x - workarea.x) + return St.Side.RIGHT; + break; + case St.Side.RIGHT: + if (sourceTopLeft.x - boxWidth < workarea.x && + boxWidth < workarea.x + workarea.width - sourceBottomRight.x) + return St.Side.LEFT; + break; + } + + return arrowSide; + } + + _updateFlip(allocationBox) { + let arrowSide = this._calculateArrowSide(this._userArrowSide); + if (this._arrowSide != arrowSide) { + this._arrowSide = arrowSide; + this._reposition(allocationBox); + + this.emit('arrow-side-changed'); + } + } + + updateArrowSide(side) { + this._arrowSide = side; + this._border.queue_repaint(); + + this.emit('arrow-side-changed'); + } + + getPadding(side) { + return this.bin.get_theme_node().get_padding(side); + } + + getArrowHeight() { + return this.get_theme_node().get_length('-arrow-rise'); + } +}); diff --git a/js/ui/calendar.js b/js/ui/calendar.js new file mode 100644 index 0000000..9851536 --- /dev/null +++ b/js/ui/calendar.js @@ -0,0 +1,1031 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Calendar, CalendarMessageList, DBusEventSource */ + +const { Clutter, Gio, GLib, GObject, Shell, St } = imports.gi; + +const Main = imports.ui.main; +const MessageList = imports.ui.messageList; +const MessageTray = imports.ui.messageTray; +const Mpris = imports.ui.mpris; +const PopupMenu = imports.ui.popupMenu; +const Util = imports.misc.util; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +var SHOW_WEEKDATE_KEY = 'show-weekdate'; + +var MESSAGE_ICON_SIZE = -1; // pick up from CSS + +var NC_ = (context, str) => `${context}\u0004${str}`; + +function sameYear(dateA, dateB) { + return dateA.getYear() == dateB.getYear(); +} + +function sameMonth(dateA, dateB) { + return sameYear(dateA, dateB) && (dateA.getMonth() == dateB.getMonth()); +} + +function sameDay(dateA, dateB) { + return sameMonth(dateA, dateB) && (dateA.getDate() == dateB.getDate()); +} + +function _isWorkDay(date) { + /* Translators: Enter 0-6 (Sunday-Saturday) for non-work days. Examples: "0" (Sunday) "6" (Saturday) "06" (Sunday and Saturday). */ + let days = C_('calendar-no-work', "06"); + return !days.includes(date.getDay().toString()); +} + +function _getBeginningOfDay(date) { + let ret = new Date(date.getTime()); + ret.setHours(0); + ret.setMinutes(0); + ret.setSeconds(0); + ret.setMilliseconds(0); + return ret; +} + +function _getEndOfDay(date) { + const ret = _getBeginningOfDay(date); + ret.setDate(ret.getDate() + 1); + return ret; +} + +function _getCalendarDayAbbreviation(dayNumber) { + let abbreviations = [ + /* Translators: Calendar grid abbreviation for Sunday. + * + * NOTE: These grid abbreviations are always shown together + * and in order, e.g. "S M T W T F S". + */ + NC_("grid sunday", "S"), + /* Translators: Calendar grid abbreviation for Monday */ + NC_("grid monday", "M"), + /* Translators: Calendar grid abbreviation for Tuesday */ + NC_("grid tuesday", "T"), + /* Translators: Calendar grid abbreviation for Wednesday */ + NC_("grid wednesday", "W"), + /* Translators: Calendar grid abbreviation for Thursday */ + NC_("grid thursday", "T"), + /* Translators: Calendar grid abbreviation for Friday */ + NC_("grid friday", "F"), + /* Translators: Calendar grid abbreviation for Saturday */ + NC_("grid saturday", "S"), + ]; + return Shell.util_translate_time_string(abbreviations[dayNumber]); +} + +// Abstraction for an appointment/event in a calendar + +var CalendarEvent = class CalendarEvent { + constructor(id, date, end, summary) { + this.id = id; + this.date = date; + this.end = end; + this.summary = summary; + } +}; + +// Interface for appointments/events - e.g. the contents of a calendar +// + +var EventSourceBase = GObject.registerClass({ + GTypeFlags: GObject.TypeFlags.ABSTRACT, + Properties: { + 'has-calendars': GObject.ParamSpec.boolean( + 'has-calendars', 'has-calendars', 'has-calendars', + GObject.ParamFlags.READABLE, + false), + 'is-loading': GObject.ParamSpec.boolean( + 'is-loading', 'is-loading', 'is-loading', + GObject.ParamFlags.READABLE, + false), + }, + Signals: { 'changed': {} }, +}, class EventSourceBase extends GObject.Object { + get isLoading() { + throw new GObject.NotImplementedError(`isLoading in ${this.constructor.name}`); + } + + get hasCalendars() { + throw new GObject.NotImplementedError(`hasCalendars in ${this.constructor.name}`); + } + + destroy() { + } + + requestRange(_begin, _end) { + throw new GObject.NotImplementedError(`requestRange in ${this.constructor.name}`); + } + + getEvents(_begin, _end) { + throw new GObject.NotImplementedError(`getEvents in ${this.constructor.name}`); + } + + hasEvents(_day) { + throw new GObject.NotImplementedError(`hasEvents in ${this.constructor.name}`); + } +}); + +var EmptyEventSource = GObject.registerClass( +class EmptyEventSource extends EventSourceBase { + get isLoading() { + return false; + } + + get hasCalendars() { + return false; + } + + requestRange(_begin, _end) { + } + + getEvents(_begin, _end) { + let result = []; + return result; + } + + hasEvents(_day) { + return false; + } +}); + +const CalendarServerIface = loadInterfaceXML('org.gnome.Shell.CalendarServer'); + +const CalendarServerInfo = Gio.DBusInterfaceInfo.new_for_xml(CalendarServerIface); + +function CalendarServer() { + return new Gio.DBusProxy({ + g_connection: Gio.DBus.session, + g_interface_name: CalendarServerInfo.name, + g_interface_info: CalendarServerInfo, + g_name: 'org.gnome.Shell.CalendarServer', + g_object_path: '/org/gnome/Shell/CalendarServer', + }); +} + +function _datesEqual(a, b) { + if (a < b) + return false; + else if (a > b) + return false; + return true; +} + +/** + * Checks whether an event overlaps a given interval + * + * @param {Date} e0 Beginning of the event + * @param {Date} e1 End of the event + * @param {Date} i0 Beginning of the interval + * @param {Date} i1 End of the interval + * @returns {boolean} Whether there was an overlap + */ +function _eventOverlapsInterval(e0, e1, i0, i1) { + // This also ensures zero-length events are included + if (e0 >= i0 && e1 < i1) + return true; + + if (e1 <= i0) + return false; + if (i1 <= e0) + return false; + + return true; +} + +// an implementation that reads data from a session bus service +var DBusEventSource = GObject.registerClass( +class DBusEventSource extends EventSourceBase { + _init() { + super._init(); + this._resetCache(); + this._isLoading = false; + + this._initialized = false; + this._dbusProxy = new CalendarServer(); + this._initProxy(); + } + + async _initProxy() { + let loaded = false; + + try { + await this._dbusProxy.init_async(GLib.PRIORITY_DEFAULT, null); + loaded = true; + } catch (e) { + // Ignore timeouts and install signals as normal, because with high + // probability the service will appear later on, and we will get a + // NameOwnerChanged which will finish loading + // + // (But still _initialized to false, because the proxy does not know + // about the HasCalendars property and would cause an exception trying + // to read it) + if (!e.matches(Gio.DBusError, Gio.DBusError.TIMED_OUT)) { + log(`Error loading calendars: ${e.message}`); + return; + } + } + + this._dbusProxy.connectSignal('EventsAddedOrUpdated', + this._onEventsAddedOrUpdated.bind(this)); + this._dbusProxy.connectSignal('EventsRemoved', + this._onEventsRemoved.bind(this)); + this._dbusProxy.connectSignal('ClientDisappeared', + this._onClientDisappeared.bind(this)); + + this._dbusProxy.connect('notify::g-name-owner', () => { + if (this._dbusProxy.g_name_owner) + this._onNameAppeared(); + else + this._onNameVanished(); + }); + + this._dbusProxy.connect('g-properties-changed', () => { + this.notify('has-calendars'); + }); + + this._initialized = loaded; + if (loaded) { + this.notify('has-calendars'); + this._onNameAppeared(); + } + } + + destroy() { + this._dbusProxy.run_dispose(); + } + + get hasCalendars() { + if (this._initialized) + return this._dbusProxy.HasCalendars; + else + return false; + } + + get isLoading() { + return this._isLoading; + } + + _resetCache() { + this._events = new Map(); + this._lastRequestBegin = null; + this._lastRequestEnd = null; + } + + _removeMatching(uidPrefix) { + let changed = false; + for (const id of this._events.keys()) { + if (id.startsWith(uidPrefix)) + changed = this._events.delete(id) || changed; + } + return changed; + } + + _onNameAppeared() { + this._initialized = true; + this._resetCache(); + this._loadEvents(true); + } + + _onNameVanished() { + this._resetCache(); + this.emit('changed'); + } + + _onEventsAddedOrUpdated(dbusProxy, nameOwner, argArray) { + const [appointments = []] = argArray; + let changed = false; + const handledRemovals = new Set(); + + for (let n = 0; n < appointments.length; n++) { + const [id, summary, startTime, endTime] = appointments[n]; + const date = new Date(startTime * 1000); + const end = new Date(endTime * 1000); + let event = new CalendarEvent(id, date, end, summary); + /* It's a recurring event */ + if (!id.endsWith('\n')) { + const parentId = id.substr(0, id.lastIndexOf('\n') + 1); + if (!handledRemovals.has(parentId)) { + handledRemovals.add(parentId); + this._removeMatching(parentId); + } + } + this._events.set(event.id, event); + + changed = true; + } + + if (changed) + this.emit('changed'); + } + + _onEventsRemoved(dbusProxy, nameOwner, argArray) { + const [ids = []] = argArray; + + let changed = false; + for (const id of ids) + changed = this._removeMatching(id) || changed; + + if (changed) + this.emit('changed'); + } + + _onClientDisappeared(dbusProxy, nameOwner, argArray) { + let [sourceUid = ''] = argArray; + sourceUid += '\n'; + + if (this._removeMatching(sourceUid)) + this.emit('changed'); + } + + _loadEvents(forceReload) { + // Ignore while loading + if (!this._initialized) + return; + + if (this._curRequestBegin && this._curRequestEnd) { + if (forceReload) { + this._events.clear(); + this.emit('changed'); + } + this._dbusProxy.SetTimeRangeAsync( + this._curRequestBegin.getTime() / 1000, + this._curRequestEnd.getTime() / 1000, + forceReload, + Gio.DBusCallFlags.NONE).catch(logError); + } + } + + requestRange(begin, end) { + if (!(_datesEqual(begin, this._lastRequestBegin) && _datesEqual(end, this._lastRequestEnd))) { + this._lastRequestBegin = begin; + this._lastRequestEnd = end; + this._curRequestBegin = begin; + this._curRequestEnd = end; + this._loadEvents(true); + } + } + + *_getFilteredEvents(begin, end) { + for (const event of this._events.values()) { + if (_eventOverlapsInterval(event.date, event.end, begin, end)) + yield event; + } + } + + getEvents(begin, end) { + let result = [...this._getFilteredEvents(begin, end)]; + + result.sort((event1, event2) => { + // sort events by end time on ending day + let d1 = event1.date < begin && event1.end <= end ? event1.end : event1.date; + let d2 = event2.date < begin && event2.end <= end ? event2.end : event2.date; + return d1.getTime() - d2.getTime(); + }); + return result; + } + + hasEvents(day) { + let dayBegin = _getBeginningOfDay(day); + let dayEnd = _getEndOfDay(day); + + const { done } = this._getFilteredEvents(dayBegin, dayEnd).next(); + return !done; + } +}); + +var Calendar = GObject.registerClass({ + Signals: { 'selected-date-changed': { param_types: [GLib.DateTime.$gtype] } }, +}, class Calendar extends St.Widget { + _init() { + this._weekStart = Shell.util_get_week_start(); + this._settings = new Gio.Settings({ schema_id: 'org.gnome.desktop.calendar' }); + + this._settings.connect(`changed::${SHOW_WEEKDATE_KEY}`, this._onSettingsChange.bind(this)); + this._useWeekdate = this._settings.get_boolean(SHOW_WEEKDATE_KEY); + + /** + * Translators: The header displaying just the month name + * standalone, when this is a month of the current year. + * "%OB" is the new format specifier introduced in glibc 2.27, + * in most cases you should not change it. + */ + this._headerFormatWithoutYear = _('%OB'); + /** + * Translators: The header displaying the month name and the year + * number, when this is a month of a different year. You can + * reorder the format specifiers or add other modifications + * according to the requirements of your language. + * "%OB" is the new format specifier introduced in glibc 2.27, + * in most cases you should not use the old "%B" here unless you + * absolutely know what you are doing. + */ + this._headerFormat = _('%OB %Y'); + + // Start off with the current date + this._selectedDate = new Date(); + + this._shouldDateGrabFocus = false; + + super._init({ + style_class: 'calendar', + layout_manager: new Clutter.GridLayout(), + reactive: true, + }); + + this._buildHeader(); + } + + setEventSource(eventSource) { + if (!(eventSource instanceof EventSourceBase)) + throw new Error('Event source is not valid type'); + + this._eventSource = eventSource; + this._eventSource.connect('changed', () => { + this._rebuildCalendar(); + this._update(); + }); + this._rebuildCalendar(); + this._update(); + } + + // Sets the calendar to show a specific date + setDate(date) { + if (sameDay(date, this._selectedDate)) + return; + + this._selectedDate = date; + this._update(); + + let datetime = GLib.DateTime.new_from_unix_local( + this._selectedDate.getTime() / 1000); + this.emit('selected-date-changed', datetime); + } + + updateTimeZone() { + // The calendar need to be rebuilt after a time zone update because + // the date might have changed. + this._rebuildCalendar(); + this._update(); + } + + _buildHeader() { + let layout = this.layout_manager; + let offsetCols = this._useWeekdate ? 1 : 0; + this.destroy_all_children(); + + // Top line of the calendar '<| September 2009 |>' + this._topBox = new St.BoxLayout({ style_class: 'calendar-month-header' }); + layout.attach(this._topBox, 0, 0, offsetCols + 7, 1); + + this._backButton = new St.Button({ + style_class: 'calendar-change-month-back pager-button', + icon_name: 'pan-start-symbolic', + accessible_name: _('Previous month'), + can_focus: true, + }); + this._topBox.add(this._backButton); + this._backButton.connect('clicked', this._onPrevMonthButtonClicked.bind(this)); + + this._monthLabel = new St.Label({ + style_class: 'calendar-month-label', + can_focus: true, + x_align: Clutter.ActorAlign.CENTER, + x_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + this._topBox.add_child(this._monthLabel); + + this._forwardButton = new St.Button({ + style_class: 'calendar-change-month-forward pager-button', + icon_name: 'pan-end-symbolic', + accessible_name: _('Next month'), + can_focus: true, + }); + this._topBox.add(this._forwardButton); + this._forwardButton.connect('clicked', this._onNextMonthButtonClicked.bind(this)); + + // Add weekday labels... + // + // We need to figure out the abbreviated localized names for the days of the week; + // we do this by just getting the next 7 days starting from right now and then putting + // them in the right cell in the table. It doesn't matter if we add them in order + let iter = new Date(this._selectedDate); + iter.setSeconds(0); // Leap second protection. Hah! + iter.setHours(12); + for (let i = 0; i < 7; i++) { + // Could use iter.toLocaleFormat('%a') but that normally gives three characters + // and we want, ideally, a single character for e.g. S M T W T F S + let customDayAbbrev = _getCalendarDayAbbreviation(iter.getDay()); + let label = new St.Label({ + style_class: 'calendar-day-base calendar-day-heading', + text: customDayAbbrev, + can_focus: true, + }); + label.accessible_name = iter.toLocaleFormat('%A'); + let col; + if (this.get_text_direction() == Clutter.TextDirection.RTL) + col = 6 - (7 + iter.getDay() - this._weekStart) % 7; + else + col = offsetCols + (7 + iter.getDay() - this._weekStart) % 7; + layout.attach(label, col, 1, 1, 1); + iter.setDate(iter.getDate() + 1); + } + + // All the children after this are days, and get removed when we update the calendar + this._firstDayIndex = this.get_n_children(); + } + + vfunc_scroll_event(scrollEvent) { + switch (scrollEvent.direction) { + case Clutter.ScrollDirection.UP: + case Clutter.ScrollDirection.LEFT: + this._onPrevMonthButtonClicked(); + break; + case Clutter.ScrollDirection.DOWN: + case Clutter.ScrollDirection.RIGHT: + this._onNextMonthButtonClicked(); + break; + } + return Clutter.EVENT_PROPAGATE; + } + + _onPrevMonthButtonClicked() { + let newDate = new Date(this._selectedDate); + let oldMonth = newDate.getMonth(); + if (oldMonth == 0) { + newDate.setMonth(11); + newDate.setFullYear(newDate.getFullYear() - 1); + if (newDate.getMonth() != 11) { + let day = 32 - new Date(newDate.getFullYear() - 1, 11, 32).getDate(); + newDate = new Date(newDate.getFullYear() - 1, 11, day); + } + } else { + newDate.setMonth(oldMonth - 1); + if (newDate.getMonth() != oldMonth - 1) { + let day = 32 - new Date(newDate.getFullYear(), oldMonth - 1, 32).getDate(); + newDate = new Date(newDate.getFullYear(), oldMonth - 1, day); + } + } + + this._backButton.grab_key_focus(); + + this.setDate(newDate); + } + + _onNextMonthButtonClicked() { + let newDate = new Date(this._selectedDate); + let oldMonth = newDate.getMonth(); + if (oldMonth == 11) { + newDate.setMonth(0); + newDate.setFullYear(newDate.getFullYear() + 1); + if (newDate.getMonth() != 0) { + let day = 32 - new Date(newDate.getFullYear() + 1, 0, 32).getDate(); + newDate = new Date(newDate.getFullYear() + 1, 0, day); + } + } else { + newDate.setMonth(oldMonth + 1); + if (newDate.getMonth() != oldMonth + 1) { + let day = 32 - new Date(newDate.getFullYear(), oldMonth + 1, 32).getDate(); + newDate = new Date(newDate.getFullYear(), oldMonth + 1, day); + } + } + + this._forwardButton.grab_key_focus(); + + this.setDate(newDate); + } + + _onSettingsChange() { + this._useWeekdate = this._settings.get_boolean(SHOW_WEEKDATE_KEY); + this._buildHeader(); + this._rebuildCalendar(); + this._update(); + } + + _rebuildCalendar() { + let now = new Date(); + + // Remove everything but the topBox and the weekday labels + let children = this.get_children(); + for (let i = this._firstDayIndex; i < children.length; i++) + children[i].destroy(); + + this._buttons = []; + + // Start at the beginning of the week before the start of the month + // + // We want to show always 6 weeks (to keep the calendar menu at the same + // height if there are no events), so we pad it according to the following + // policy: + // + // 1 - If a month has 6 weeks, we place no padding (example: Dec 2012) + // 2 - If a month has 5 weeks and it starts on week start, we pad one week + // before it (example: Apr 2012) + // 3 - If a month has 5 weeks and it starts on any other day, we pad one week + // after it (example: Nov 2012) + // 4 - If a month has 4 weeks, we pad one week before and one after it + // (example: Feb 2010) + // + // Actually computing the number of weeks is complex, but we know that the + // problematic categories (2 and 4) always start on week start, and that + // all months at the end have 6 weeks. + let beginDate = new Date( + this._selectedDate.getFullYear(), this._selectedDate.getMonth(), 1); + + this._calendarBegin = new Date(beginDate); + this._markedAsToday = now; + + let daysToWeekStart = (7 + beginDate.getDay() - this._weekStart) % 7; + let startsOnWeekStart = daysToWeekStart == 0; + let weekPadding = startsOnWeekStart ? 7 : 0; + + beginDate.setDate(beginDate.getDate() - (weekPadding + daysToWeekStart)); + + let layout = this.layout_manager; + let iter = new Date(beginDate); + let row = 2; + // nRows here means 6 weeks + one header + one navbar + let nRows = 8; + while (row < nRows) { + let button = new St.Button({ + // xgettext:no-javascript-format + label: iter.toLocaleFormat(C_('date day number format', '%d')), + can_focus: true, + }); + let rtl = button.get_text_direction() == Clutter.TextDirection.RTL; + + if (this._eventSource instanceof EmptyEventSource) + button.reactive = false; + + button._date = new Date(iter); + button.connect('clicked', () => { + this._shouldDateGrabFocus = true; + this.setDate(button._date); + this._shouldDateGrabFocus = false; + }); + + let hasEvents = this._eventSource.hasEvents(iter); + let styleClass = 'calendar-day-base calendar-day'; + + if (_isWorkDay(iter)) + styleClass += ' calendar-work-day'; + else + styleClass += ' calendar-nonwork-day'; + + // Hack used in lieu of border-collapse - see gnome-shell.css + if (row == 2) + styleClass = `calendar-day-top ${styleClass}`; + + let leftMost = rtl + ? iter.getDay() == (this._weekStart + 6) % 7 + : iter.getDay() == this._weekStart; + if (leftMost) + styleClass = `calendar-day-left ${styleClass}`; + + if (sameDay(now, iter)) + styleClass += ' calendar-today'; + else if (iter.getMonth() != this._selectedDate.getMonth()) + styleClass += ' calendar-other-month-day'; + + if (hasEvents) + styleClass += ' calendar-day-with-events'; + + button.style_class = styleClass; + + let offsetCols = this._useWeekdate ? 1 : 0; + let col; + if (rtl) + col = 6 - (7 + iter.getDay() - this._weekStart) % 7; + else + col = offsetCols + (7 + iter.getDay() - this._weekStart) % 7; + layout.attach(button, col, row, 1, 1); + + this._buttons.push(button); + + if (this._useWeekdate && iter.getDay() == 4) { + const label = new St.Label({ + text: iter.toLocaleFormat('%V'), + style_class: 'calendar-week-number', + can_focus: true, + }); + let weekFormat = Shell.util_translate_time_string(N_("Week %V")); + label.clutter_text.y_align = Clutter.ActorAlign.CENTER; + label.accessible_name = iter.toLocaleFormat(weekFormat); + layout.attach(label, rtl ? 7 : 0, row, 1, 1); + } + + iter.setDate(iter.getDate() + 1); + + if (iter.getDay() == this._weekStart) + row++; + } + + // Signal to the event source that we are interested in events + // only from this date range + this._eventSource.requestRange(beginDate, iter); + } + + _update() { + let now = new Date(); + + if (sameYear(this._selectedDate, now)) + this._monthLabel.text = this._selectedDate.toLocaleFormat(this._headerFormatWithoutYear); + else + this._monthLabel.text = this._selectedDate.toLocaleFormat(this._headerFormat); + + if (!this._calendarBegin || !sameMonth(this._selectedDate, this._calendarBegin) || !sameDay(now, this._markedAsToday)) + this._rebuildCalendar(); + + this._buttons.forEach(button => { + if (sameDay(button._date, this._selectedDate)) { + button.add_style_pseudo_class('selected'); + if (this._shouldDateGrabFocus) + button.grab_key_focus(); + } else { + button.remove_style_pseudo_class('selected'); + } + }); + } +}); + +var NotificationMessage = GObject.registerClass( +class NotificationMessage extends MessageList.Message { + _init(notification) { + super._init(notification.title, notification.bannerBodyText); + this.setUseBodyMarkup(notification.bannerBodyMarkup); + + this.notification = notification; + + this.setIcon(this._getIcon()); + + this.connect('close', () => { + this._closed = true; + if (this.notification) + this.notification.destroy(MessageTray.NotificationDestroyedReason.DISMISSED); + }); + notification.connectObject( + 'updated', this._onUpdated.bind(this), + 'destroy', () => { + this.notification = null; + if (!this._closed) + this.close(); + }, this); + } + + _getIcon() { + if (this.notification.gicon) { + return new St.Icon({ + gicon: this.notification.gicon, + icon_size: MESSAGE_ICON_SIZE, + }); + } else { + return this.notification.source.createIcon(MESSAGE_ICON_SIZE); + } + } + + _onUpdated(n, _clear) { + this.setIcon(this._getIcon()); + this.setTitle(n.title); + this.setBody(n.bannerBodyText); + this.setUseBodyMarkup(n.bannerBodyMarkup); + } + + vfunc_clicked() { + this.notification.activate(); + } + + canClose() { + return true; + } +}); + +var TimeLabel = GObject.registerClass( +class NotificationTimeLabel extends St.Label { + _init(datetime) { + super._init({ + style_class: 'event-time', + x_align: Clutter.ActorAlign.START, + y_align: Clutter.ActorAlign.END, + }); + this._datetime = datetime; + } + + vfunc_map() { + this.text = Util.formatTimeSpan(this._datetime); + super.vfunc_map(); + } +}); + +var NotificationSection = GObject.registerClass( +class NotificationSection extends MessageList.MessageListSection { + _init() { + super._init(); + + this._nUrgent = 0; + + Main.messageTray.connect('source-added', this._sourceAdded.bind(this)); + Main.messageTray.getSources().forEach(source => { + this._sourceAdded(Main.messageTray, source); + }); + } + + get allowed() { + return Main.sessionMode.hasNotifications && + !Main.sessionMode.isGreeter; + } + + _sourceAdded(tray, source) { + source.connectObject('notification-added', + this._onNotificationAdded.bind(this), this); + } + + _onNotificationAdded(source, notification) { + let message = new NotificationMessage(notification); + message.setSecondaryActor(new TimeLabel(notification.datetime)); + + let isUrgent = notification.urgency == MessageTray.Urgency.CRITICAL; + + notification.connectObject( + 'destroy', () => { + if (isUrgent) + this._nUrgent--; + }, + 'updated', () => { + message.setSecondaryActor(new TimeLabel(notification.datetime)); + this.moveMessage(message, isUrgent ? 0 : this._nUrgent, this.mapped); + }, this); + + if (isUrgent) { + // Keep track of urgent notifications to keep them on top + this._nUrgent++; + } else if (this.mapped) { + // Only acknowledge non-urgent notifications in case it + // has important actions that are inaccessible when not + // shown as banner + notification.acknowledged = true; + } + + let index = isUrgent ? 0 : this._nUrgent; + this.addMessageAtIndex(message, index, this.mapped); + } + + vfunc_map() { + this._messages.forEach(message => { + if (message.notification.urgency != MessageTray.Urgency.CRITICAL) + message.notification.acknowledged = true; + }); + super.vfunc_map(); + } +}); + +var Placeholder = GObject.registerClass( +class Placeholder extends St.BoxLayout { + _init() { + super._init({ style_class: 'message-list-placeholder', vertical: true }); + this._date = new Date(); + + this._icon = new St.Icon({ icon_name: 'no-notifications-symbolic' }); + this.add_actor(this._icon); + + this._label = new St.Label({ text: _('No Notifications') }); + this.add_actor(this._label); + } +}); + +const DoNotDisturbSwitch = GObject.registerClass( +class DoNotDisturbSwitch extends PopupMenu.Switch { + _init() { + this._settings = new Gio.Settings({ + schema_id: 'org.gnome.desktop.notifications', + }); + + super._init(this._settings.get_boolean('show-banners')); + + this._settings.bind('show-banners', + this, 'state', + Gio.SettingsBindFlags.INVERT_BOOLEAN); + + this.connect('destroy', () => { + Gio.Settings.unbind(this, 'state'); + this._settings = null; + }); + } +}); + +var CalendarMessageList = GObject.registerClass( +class CalendarMessageList extends St.Widget { + _init() { + super._init({ + style_class: 'message-list', + layout_manager: new Clutter.BinLayout(), + x_expand: true, + y_expand: true, + }); + + this._placeholder = new Placeholder(); + this.add_actor(this._placeholder); + + let box = new St.BoxLayout({ + vertical: true, + x_expand: true, + y_expand: true, + }); + this.add_actor(box); + + this._scrollView = new St.ScrollView({ + style_class: 'vfade', + overlay_scrollbars: true, + x_expand: true, y_expand: true, + }); + this._scrollView.set_policy(St.PolicyType.NEVER, St.PolicyType.AUTOMATIC); + box.add_actor(this._scrollView); + + let hbox = new St.BoxLayout({ style_class: 'message-list-controls' }); + box.add_child(hbox); + + const dndLabel = new St.Label({ + text: _('Do Not Disturb'), + y_align: Clutter.ActorAlign.CENTER, + }); + hbox.add_child(dndLabel); + + this._dndSwitch = new DoNotDisturbSwitch(); + this._dndButton = new St.Button({ + style_class: 'dnd-button', + can_focus: true, + toggle_mode: true, + child: this._dndSwitch, + label_actor: dndLabel, + y_align: Clutter.ActorAlign.CENTER, + }); + this._dndSwitch.bind_property('state', + this._dndButton, 'checked', + GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE); + hbox.add_child(this._dndButton); + + this._clearButton = new St.Button({ + style_class: 'message-list-clear-button button', + label: _('Clear'), + can_focus: true, + x_expand: true, + x_align: Clutter.ActorAlign.END, + }); + this._clearButton.connect('clicked', () => { + this._sectionList.get_children().forEach(s => s.clear()); + }); + hbox.add_actor(this._clearButton); + + this._placeholder.bind_property('visible', + this._clearButton, 'visible', + GObject.BindingFlags.INVERT_BOOLEAN); + + this._sectionList = new St.BoxLayout({ + style_class: 'message-list-sections', + vertical: true, + x_expand: true, + y_expand: true, + y_align: Clutter.ActorAlign.START, + }); + this._sectionList.connectObject( + 'actor-added', this._sync.bind(this), + 'actor-removed', this._sync.bind(this), + this); + this._scrollView.add_actor(this._sectionList); + + this._mediaSection = new Mpris.MediaSection(); + this._addSection(this._mediaSection); + + this._notificationSection = new NotificationSection(); + this._addSection(this._notificationSection); + + Main.sessionMode.connect('updated', this._sync.bind(this)); + } + + _addSection(section) { + section.connectObject( + 'notify::visible', this._sync.bind(this), + 'notify::empty', this._sync.bind(this), + 'notify::can-clear', this._sync.bind(this), + 'destroy', () => this._sectionList.remove_actor(section), + 'message-focused', (_s, messageActor) => { + Util.ensureActorVisibleInScrollView(this._scrollView, messageActor); + }, this); + this._sectionList.add_actor(section); + } + + _sync() { + let sections = this._sectionList.get_children(); + let visible = sections.some(s => s.allowed); + this.visible = visible; + if (!visible) + return; + + let empty = sections.every(s => s.empty || !s.visible); + this._placeholder.visible = empty; + + let canClear = sections.some(s => s.canClear && s.visible); + this._clearButton.reactive = canClear; + } +}); diff --git a/js/ui/checkBox.js b/js/ui/checkBox.js new file mode 100644 index 0000000..d64bd0d --- /dev/null +++ b/js/ui/checkBox.js @@ -0,0 +1,40 @@ +/* exported CheckBox */ +const { Atk, Clutter, GObject, Pango, St } = imports.gi; + +var CheckBox = GObject.registerClass( +class CheckBox extends St.Button { + _init(label) { + let container = new St.BoxLayout({ + x_expand: true, + y_expand: true, + }); + super._init({ + style_class: 'check-box', + child: container, + button_mask: St.ButtonMask.ONE, + toggle_mode: true, + can_focus: true, + }); + this.set_accessible_role(Atk.Role.CHECK_BOX); + + this._box = new St.Bin({ y_align: Clutter.ActorAlign.START }); + container.add_actor(this._box); + + this._label = new St.Label({ y_align: Clutter.ActorAlign.CENTER }); + this._label.clutter_text.set_line_wrap(true); + this._label.clutter_text.set_ellipsize(Pango.EllipsizeMode.NONE); + this.set_label_actor(this._label); + container.add_actor(this._label); + + if (label) + this.setLabel(label); + } + + setLabel(label) { + this._label.set_text(label); + } + + getLabelActor() { + return this._label; + } +}); diff --git a/js/ui/closeDialog.js b/js/ui/closeDialog.js new file mode 100644 index 0000000..f5ddecd --- /dev/null +++ b/js/ui/closeDialog.js @@ -0,0 +1,207 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported CloseDialog */ + +const { Clutter, GLib, GObject, Meta, Shell, St } = imports.gi; + +const Dialog = imports.ui.dialog; +const Main = imports.ui.main; + +var FROZEN_WINDOW_BRIGHTNESS = -0.3; +var DIALOG_TRANSITION_TIME = 150; +var ALIVE_TIMEOUT = 5000; + +var CloseDialog = GObject.registerClass({ + Implements: [Meta.CloseDialog], + Properties: { + 'window': GObject.ParamSpec.override('window', Meta.CloseDialog), + }, +}, class CloseDialog extends GObject.Object { + _init(window) { + super._init(); + this._window = window; + this._dialog = null; + this._tracked = undefined; + this._timeoutId = 0; + } + + get window() { + return this._window; + } + + set window(window) { + this._window = window; + } + + _createDialogContent() { + let tracker = Shell.WindowTracker.get_default(); + let windowApp = tracker.get_window_app(this._window); + + /* Translators: %s is an application name */ + let title = _("“%s” is not responding.").format(windowApp.get_name()); + let description = _('You may choose to wait a short while for it to ' + + 'continue or force the application to quit entirely.'); + return new Dialog.MessageDialogContent({ title, description }); + } + + _updateScale() { + // Since this is a child of MetaWindowActor (which, for Wayland clients, + // applies the geometry scale factor to its children itself, see + // meta_window_actor_set_geometry_scale()), make sure we don't apply + // the factor twice in the end. + if (this._window.get_client_type() !== Meta.WindowClientType.WAYLAND) + return; + + let { scaleFactor } = St.ThemeContext.get_for_stage(global.stage); + this._dialog.set_scale(1 / scaleFactor, 1 / scaleFactor); + } + + _initDialog() { + if (this._dialog) + return; + + let windowActor = this._window.get_compositor_private(); + this._dialog = new Dialog.Dialog(windowActor, 'close-dialog'); + this._dialog.width = windowActor.width; + this._dialog.height = windowActor.height; + + this._dialog.contentLayout.add_child(this._createDialogContent()); + this._dialog.addButton({ + label: _('Force Quit'), + action: this._onClose.bind(this), + default: true, + }); + this._dialog.addButton({ + label: _('Wait'), + action: this._onWait.bind(this), + key: Clutter.KEY_Escape, + }); + + global.focus_manager.add_group(this._dialog); + + let themeContext = St.ThemeContext.get_for_stage(global.stage); + themeContext.connect('notify::scale-factor', this._updateScale.bind(this)); + + this._updateScale(); + } + + _addWindowEffect() { + // We set the effect on the surface actor, so the dialog itself + // (which is a child of the MetaWindowActor) does not get the + // effect applied itself. + let windowActor = this._window.get_compositor_private(); + let surfaceActor = windowActor.get_first_child(); + let effect = new Clutter.BrightnessContrastEffect(); + effect.set_brightness(FROZEN_WINDOW_BRIGHTNESS); + surfaceActor.add_effect_with_name("gnome-shell-frozen-window", effect); + } + + _removeWindowEffect() { + let windowActor = this._window.get_compositor_private(); + let surfaceActor = windowActor.get_first_child(); + surfaceActor.remove_effect_by_name("gnome-shell-frozen-window"); + } + + _onWait() { + this.response(Meta.CloseDialogResponse.WAIT); + } + + _onClose() { + this.response(Meta.CloseDialogResponse.FORCE_CLOSE); + } + + _onFocusChanged() { + if (Meta.is_wayland_compositor()) + return; + + let focusWindow = global.display.focus_window; + let keyFocus = global.stage.key_focus; + + let shouldTrack; + if (focusWindow != null) + shouldTrack = focusWindow == this._window; + else + shouldTrack = keyFocus && this._dialog.contains(keyFocus); + + if (this._tracked === shouldTrack) + return; + + if (shouldTrack) { + Main.layoutManager.trackChrome(this._dialog, + { affectsInputRegion: true }); + } else { + Main.layoutManager.untrackChrome(this._dialog); + } + + // The buttons are broken when they aren't added to the input region, + // so disable them properly in that case + this._dialog.buttonLayout.get_children().forEach(b => { + b.reactive = shouldTrack; + }); + + this._tracked = shouldTrack; + } + + vfunc_show() { + if (this._dialog != null) + return; + + Meta.disable_unredirect_for_display(global.display); + + this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, ALIVE_TIMEOUT, + () => { + this._window.check_alive(global.display.get_current_time_roundtrip()); + return GLib.SOURCE_CONTINUE; + }); + + global.display.connectObject( + 'notify::focus-window', this._onFocusChanged.bind(this), this); + + global.stage.connectObject( + 'notify::key-focus', this._onFocusChanged.bind(this), this); + + this._addWindowEffect(); + this._initDialog(); + + this._dialog._dialog.scale_y = 0; + this._dialog._dialog.set_pivot_point(0.5, 0.5); + + this._dialog._dialog.ease({ + scale_y: 1, + mode: Clutter.AnimationMode.LINEAR, + duration: DIALOG_TRANSITION_TIME, + onComplete: this._onFocusChanged.bind(this), + }); + } + + vfunc_hide() { + if (this._dialog == null) + return; + + Meta.enable_unredirect_for_display(global.display); + + GLib.source_remove(this._timeoutId); + this._timeoutId = 0; + + global.display.disconnectObject(this); + global.stage.disconnectObject(this); + + this._dialog._dialog.remove_all_transitions(); + + let dialog = this._dialog; + this._dialog = null; + this._removeWindowEffect(); + + dialog.makeInactive(); + dialog._dialog.ease({ + scale_y: 0, + mode: Clutter.AnimationMode.LINEAR, + duration: DIALOG_TRANSITION_TIME, + onComplete: () => dialog.destroy(), + }); + } + + vfunc_focus() { + if (this._dialog) + this._dialog.grab_key_focus(); + } +}); diff --git a/js/ui/components/__init__.js b/js/ui/components/__init__.js new file mode 100644 index 0000000..7430013 --- /dev/null +++ b/js/ui/components/__init__.js @@ -0,0 +1,58 @@ +/* exported ComponentManager */ +const Main = imports.ui.main; + +var ComponentManager = class { + constructor() { + this._allComponents = {}; + this._enabledComponents = []; + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + this._sessionUpdated(); + } + + _sessionUpdated() { + let newEnabledComponents = Main.sessionMode.components; + + newEnabledComponents + .filter(name => !this._enabledComponents.includes(name)) + .forEach(name => this._enableComponent(name)); + + this._enabledComponents + .filter(name => !newEnabledComponents.includes(name)) + .forEach(name => this._disableComponent(name)); + + this._enabledComponents = newEnabledComponents; + } + + _importComponent(name) { + let module = imports.ui.components[name]; + return module.Component; + } + + _ensureComponent(name) { + let component = this._allComponents[name]; + if (component) + return component; + + if (Main.sessionMode.isLocked) + return null; + + let constructor = this._importComponent(name); + component = new constructor(); + this._allComponents[name] = component; + return component; + } + + _enableComponent(name) { + let component = this._ensureComponent(name); + if (component) + component.enable(); + } + + _disableComponent(name) { + let component = this._allComponents[name]; + if (component == null) + return; + component.disable(); + } +}; diff --git a/js/ui/components/automountManager.js b/js/ui/components/automountManager.js new file mode 100644 index 0000000..4c0c223 --- /dev/null +++ b/js/ui/components/automountManager.js @@ -0,0 +1,256 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Component */ + +const { Gio, GLib } = imports.gi; +const Params = imports.misc.params; + +const GnomeSession = imports.misc.gnomeSession; +const Main = imports.ui.main; +const ShellMountOperation = imports.ui.shellMountOperation; + +var GNOME_SESSION_AUTOMOUNT_INHIBIT = 16; + +// GSettings keys +const SETTINGS_SCHEMA = 'org.gnome.desktop.media-handling'; +const SETTING_ENABLE_AUTOMOUNT = 'automount'; + +var AUTORUN_EXPIRE_TIMEOUT_SECS = 10; + +var AutomountManager = class { + constructor() { + this._settings = new Gio.Settings({ schema_id: SETTINGS_SCHEMA }); + this._activeOperations = new Map(); + this._session = new GnomeSession.SessionManager(); + this._session.connectSignal('InhibitorAdded', + this._InhibitorsChanged.bind(this)); + this._session.connectSignal('InhibitorRemoved', + this._InhibitorsChanged.bind(this)); + this._inhibited = false; + + this._volumeMonitor = Gio.VolumeMonitor.get(); + } + + enable() { + this._volumeMonitor.connectObject( + 'volume-added', this._onVolumeAdded.bind(this), + 'volume-removed', this._onVolumeRemoved.bind(this), + 'drive-connected', this._onDriveConnected.bind(this), + 'drive-disconnected', this._onDriveDisconnected.bind(this), + 'drive-eject-button', this._onDriveEjectButton.bind(this), this); + + this._mountAllId = GLib.idle_add(GLib.PRIORITY_DEFAULT, this._startupMountAll.bind(this)); + GLib.Source.set_name_by_id(this._mountAllId, '[gnome-shell] this._startupMountAll'); + } + + disable() { + this._volumeMonitor.disconnectObject(this); + + if (this._mountAllId > 0) { + GLib.source_remove(this._mountAllId); + this._mountAllId = 0; + } + } + + async _InhibitorsChanged(_object, _senderName, [_inhibitor]) { + try { + const [inhibited] = + await this._session.IsInhibitedAsync(GNOME_SESSION_AUTOMOUNT_INHIBIT); + this._inhibited = inhibited; + } catch (e) {} + } + + _startupMountAll() { + let volumes = this._volumeMonitor.get_volumes(); + volumes.forEach(volume => { + this._checkAndMountVolume(volume, { + checkSession: false, + useMountOp: false, + allowAutorun: false, + }); + }); + + this._mountAllId = 0; + return GLib.SOURCE_REMOVE; + } + + _onDriveConnected() { + // if we're not in the current ConsoleKit session, + // or screensaver is active, don't play sounds + if (!this._session.SessionIsActive) + return; + + let player = global.display.get_sound_player(); + player.play_from_theme('device-added-media', + _("External drive connected"), + null); + } + + _onDriveDisconnected() { + // if we're not in the current ConsoleKit session, + // or screensaver is active, don't play sounds + if (!this._session.SessionIsActive) + return; + + let player = global.display.get_sound_player(); + player.play_from_theme('device-removed-media', + _("External drive disconnected"), + null); + } + + _onDriveEjectButton(monitor, drive) { + // TODO: this code path is not tested, as the GVfs volume monitor + // doesn't emit this signal just yet. + if (!this._session.SessionIsActive) + return; + + // we force stop/eject in this case, so we don't have to pass a + // mount operation object + if (drive.can_stop()) { + drive.stop(Gio.MountUnmountFlags.FORCE, null, null, + (o, res) => { + try { + drive.stop_finish(res); + } catch (e) { + log(`Unable to stop the drive after drive-eject-button ${e.toString()}`); + } + }); + } else if (drive.can_eject()) { + drive.eject_with_operation(Gio.MountUnmountFlags.FORCE, null, null, + (o, res) => { + try { + drive.eject_with_operation_finish(res); + } catch (e) { + log(`Unable to eject the drive after drive-eject-button ${e.toString()}`); + } + }); + } + } + + _onVolumeAdded(monitor, volume) { + this._checkAndMountVolume(volume); + } + + _checkAndMountVolume(volume, params) { + params = Params.parse(params, { + checkSession: true, + useMountOp: true, + allowAutorun: true, + }); + + if (params.checkSession) { + // if we're not in the current ConsoleKit session, + // don't attempt automount + if (!this._session.SessionIsActive) + return; + } + + if (this._inhibited) + return; + + // Volume is already mounted, don't bother. + if (volume.get_mount()) + return; + + if (!this._settings.get_boolean(SETTING_ENABLE_AUTOMOUNT) || + !volume.should_automount() || + !volume.can_mount()) { + // allow the autorun to run anyway; this can happen if the + // mount gets added programmatically later, even if + // should_automount() or can_mount() are false, like for + // blank optical media. + this._allowAutorun(volume); + this._allowAutorunExpire(volume); + + return; + } + + if (params.useMountOp) { + let operation = new ShellMountOperation.ShellMountOperation(volume); + this._mountVolume(volume, operation, params.allowAutorun); + } else { + this._mountVolume(volume, null, params.allowAutorun); + } + } + + _mountVolume(volume, operation, allowAutorun) { + if (allowAutorun) + this._allowAutorun(volume); + + const mountOp = operation?.mountOp ?? null; + this._activeOperations.set(volume, operation); + + volume.mount(0, mountOp, null, + this._onVolumeMounted.bind(this)); + } + + _onVolumeMounted(volume, res) { + this._allowAutorunExpire(volume); + + try { + volume.mount_finish(res); + this._closeOperation(volume); + } catch (e) { + // FIXME: we will always get G_IO_ERROR_FAILED from the gvfs udisks + // backend, see https://bugs.freedesktop.org/show_bug.cgi?id=51271 + // To reask the password if the user input was empty or wrong, we + // will check for corresponding error messages. However, these + // error strings are not unique for the cases in the comments below. + if (e.message.includes('No key available with this passphrase') || // cryptsetup + e.message.includes('No key available to unlock device') || // udisks (no password) + // libblockdev wrong password opening LUKS device + e.message.includes('Failed to activate device: Incorrect passphrase') || + // cryptsetup returns EINVAL in many cases, including wrong TCRYPT password/parameters + e.message.includes('Failed to load device\'s parameters: Invalid argument')) { + this._reaskPassword(volume); + } else { + if (e.message.includes('Compiled against a version of libcryptsetup that does not support the VeraCrypt PIM setting')) { + Main.notifyError(_("Unable to unlock volume"), + _("The installed udisks version does not support the PIM setting")); + } + + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED_HANDLED)) + log(`Unable to mount volume ${volume.get_name()}: ${e.toString()}`); + this._closeOperation(volume); + } + } + } + + _onVolumeRemoved(monitor, volume) { + if (volume._allowAutorunExpireId && volume._allowAutorunExpireId > 0) { + GLib.source_remove(volume._allowAutorunExpireId); + delete volume._allowAutorunExpireId; + } + } + + _reaskPassword(volume) { + let prevOperation = this._activeOperations.get(volume); + const existingDialog = prevOperation?.borrowDialog(); + let operation = + new ShellMountOperation.ShellMountOperation(volume, + { existingDialog }); + this._mountVolume(volume, operation); + } + + _closeOperation(volume) { + let operation = this._activeOperations.get(volume); + if (!operation) + return; + operation.close(); + this._activeOperations.delete(volume); + } + + _allowAutorun(volume) { + volume.allowAutorun = true; + } + + _allowAutorunExpire(volume) { + let id = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, AUTORUN_EXPIRE_TIMEOUT_SECS, () => { + volume.allowAutorun = false; + delete volume._allowAutorunExpireId; + return GLib.SOURCE_REMOVE; + }); + volume._allowAutorunExpireId = id; + GLib.Source.set_name_by_id(id, '[gnome-shell] volume.allowAutorun'); + } +}; +var Component = AutomountManager; diff --git a/js/ui/components/autorunManager.js b/js/ui/components/autorunManager.js new file mode 100644 index 0000000..d94be39 --- /dev/null +++ b/js/ui/components/autorunManager.js @@ -0,0 +1,345 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Component */ + +const { Clutter, Gio, GObject, St } = imports.gi; + +const GnomeSession = imports.misc.gnomeSession; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; + +Gio._promisify(Gio.Mount.prototype, 'guess_content_type'); + +const { loadInterfaceXML } = imports.misc.fileUtils; + +// GSettings keys +const SETTINGS_SCHEMA = 'org.gnome.desktop.media-handling'; +const SETTING_DISABLE_AUTORUN = 'autorun-never'; +const SETTING_START_APP = 'autorun-x-content-start-app'; +const SETTING_IGNORE = 'autorun-x-content-ignore'; +const SETTING_OPEN_FOLDER = 'autorun-x-content-open-folder'; + +var AutorunSetting = { + RUN: 0, + IGNORE: 1, + FILES: 2, + ASK: 3, +}; + +// misc utils +function shouldAutorunMount(mount) { + let root = mount.get_root(); + let volume = mount.get_volume(); + + if (!volume || !volume.allowAutorun) + return false; + + if (root.is_native() && isMountRootHidden(root)) + return false; + + return true; +} + +function isMountRootHidden(root) { + let path = root.get_path(); + + // skip any mounts in hidden directory hierarchies + return path.includes('/.'); +} + +function isMountNonLocal(mount) { + // If the mount doesn't have an associated volume, that means it's + // an uninteresting filesystem. Most devices that we care about will + // have a mount, like media players and USB sticks. + let volume = mount.get_volume(); + if (volume == null) + return true; + + return volume.get_identifier("class") == "network"; +} + +function startAppForMount(app, mount) { + let files = []; + let root = mount.get_root(); + let retval = false; + + files.push(root); + + try { + retval = app.launch(files, + global.create_app_launch_context(0, -1)); + } catch (e) { + log(`Unable to launch the application ${app.get_name()}: ${e}`); + } + + return retval; +} + +const HotplugSnifferIface = loadInterfaceXML('org.gnome.Shell.HotplugSniffer'); +const HotplugSnifferProxy = Gio.DBusProxy.makeProxyWrapper(HotplugSnifferIface); +function HotplugSniffer() { + return new HotplugSnifferProxy(Gio.DBus.session, + 'org.gnome.Shell.HotplugSniffer', + '/org/gnome/Shell/HotplugSniffer'); +} + +var ContentTypeDiscoverer = class { + constructor() { + this._settings = new Gio.Settings({ schema_id: SETTINGS_SCHEMA }); + } + + async guessContentTypes(mount) { + let autorunEnabled = !this._settings.get_boolean(SETTING_DISABLE_AUTORUN); + let shouldScan = autorunEnabled && !isMountNonLocal(mount); + + let contentTypes = []; + if (shouldScan) { + try { + contentTypes = await mount.guess_content_type(false, null); + } catch (e) { + log(`Unable to guess content types on added mount ${mount.get_name()}: ${e}`); + } + + if (contentTypes.length === 0) { + const root = mount.get_root(); + const hotplugSniffer = new HotplugSniffer(); + [contentTypes] = await hotplugSniffer.SniffURIAsync(root.get_uri()); + } + } + + // we're not interested in win32 software content types here + contentTypes = contentTypes.filter( + type => type !== 'x-content/win32-software'); + + const apps = []; + contentTypes.forEach(type => { + const app = Gio.app_info_get_default_for_type(type, false); + + if (app) + apps.push(app); + }); + + if (apps.length === 0) + apps.push(Gio.app_info_get_default_for_type('inode/directory', false)); + + return [apps, contentTypes]; + } +}; + +var AutorunManager = class { + constructor() { + this._session = new GnomeSession.SessionManager(); + this._volumeMonitor = Gio.VolumeMonitor.get(); + + this._dispatcher = new AutorunDispatcher(this); + } + + enable() { + this._volumeMonitor.connectObject( + 'mount-added', this._onMountAdded.bind(this), + 'mount-removed', this._onMountRemoved.bind(this), this); + } + + disable() { + this._volumeMonitor.disconnectObject(this); + } + + async _onMountAdded(monitor, mount) { + // don't do anything if our session is not the currently + // active one + if (!this._session.SessionIsActive) + return; + + const discoverer = new ContentTypeDiscoverer(); + const [apps, contentTypes] = await discoverer.guessContentTypes(mount); + this._dispatcher.addMount(mount, apps, contentTypes); + } + + _onMountRemoved(monitor, mount) { + this._dispatcher.removeMount(mount); + } +}; + +var AutorunDispatcher = class { + constructor(manager) { + this._manager = manager; + this._sources = []; + this._settings = new Gio.Settings({ schema_id: SETTINGS_SCHEMA }); + } + + _getAutorunSettingForType(contentType) { + let runApp = this._settings.get_strv(SETTING_START_APP); + if (runApp.includes(contentType)) + return AutorunSetting.RUN; + + let ignore = this._settings.get_strv(SETTING_IGNORE); + if (ignore.includes(contentType)) + return AutorunSetting.IGNORE; + + let openFiles = this._settings.get_strv(SETTING_OPEN_FOLDER); + if (openFiles.includes(contentType)) + return AutorunSetting.FILES; + + return AutorunSetting.ASK; + } + + _getSourceForMount(mount) { + let filtered = this._sources.filter(source => source.mount == mount); + + // we always make sure not to add two sources for the same + // mount in addMount(), so it's safe to assume filtered.length + // is always either 1 or 0. + if (filtered.length == 1) + return filtered[0]; + + return null; + } + + _addSource(mount, apps) { + // if we already have a source showing for this + // mount, return + if (this._getSourceForMount(mount)) + return; + + // add a new source + this._sources.push(new AutorunSource(this._manager, mount, apps)); + } + + addMount(mount, apps, contentTypes) { + // if autorun is disabled globally, return + if (this._settings.get_boolean(SETTING_DISABLE_AUTORUN)) + return; + + // if the mount doesn't want to be autorun, return + if (!shouldAutorunMount(mount)) + return; + + let setting; + if (contentTypes.length > 0) + setting = this._getAutorunSettingForType(contentTypes[0]); + else + setting = AutorunSetting.ASK; + + // check at the settings for the first content type + // to see whether we should ask + if (setting == AutorunSetting.IGNORE) + return; // return right away + + let success = false; + let app = null; + + if (setting == AutorunSetting.RUN) + app = Gio.app_info_get_default_for_type(contentTypes[0], false); + else if (setting == AutorunSetting.FILES) + app = Gio.app_info_get_default_for_type('inode/directory', false); + + if (app) + success = startAppForMount(app, mount); + + // we fallback here also in case the settings did not specify 'ask', + // but we failed launching the default app or the default file manager + if (!success) + this._addSource(mount, apps); + } + + removeMount(mount) { + let source = this._getSourceForMount(mount); + + // if we aren't tracking this mount, don't do anything + if (!source) + return; + + // destroy the notification source + source.destroy(); + } +}; + +var AutorunSource = GObject.registerClass( +class AutorunSource extends MessageTray.Source { + _init(manager, mount, apps) { + super._init(mount.get_name()); + + this._manager = manager; + this.mount = mount; + this.apps = apps; + + this._notification = new AutorunNotification(this._manager, this); + + // add ourselves as a source, and popup the notification + Main.messageTray.add(this); + this.showNotification(this._notification); + } + + getIcon() { + return this.mount.get_icon(); + } + + _createPolicy() { + return new MessageTray.NotificationApplicationPolicy('org.gnome.Nautilus'); + } +}); + +var AutorunNotification = GObject.registerClass( +class AutorunNotification extends MessageTray.Notification { + _init(manager, source) { + super._init(source, source.title); + + this._manager = manager; + this._mount = source.mount; + } + + createBanner() { + let banner = new MessageTray.NotificationBanner(this); + + this.source.apps.forEach(app => { + let actor = this._buttonForApp(app); + + if (actor) + banner.addButton(actor); + }); + + return banner; + } + + _buttonForApp(app) { + let box = new St.BoxLayout({ + x_expand: true, + x_align: Clutter.ActorAlign.START, + }); + const icon = new St.Icon({ + gicon: app.get_icon(), + style_class: 'hotplug-notification-item-icon', + }); + box.add(icon); + + let label = new St.Bin({ + child: new St.Label({ + text: _("Open with %s").format(app.get_name()), + y_align: Clutter.ActorAlign.CENTER, + }), + }); + box.add(label); + + const button = new St.Button({ + child: box, + x_expand: true, + button_mask: St.ButtonMask.ONE, + style_class: 'hotplug-notification-item button', + }); + + button.connect('clicked', () => { + startAppForMount(app, this._mount); + this.destroy(); + }); + + return button; + } + + activate() { + super.activate(); + + let app = Gio.app_info_get_default_for_type('inode/directory', false); + startAppForMount(app, this._mount); + } +}); + +var Component = AutorunManager; diff --git a/js/ui/components/keyring.js b/js/ui/components/keyring.js new file mode 100644 index 0000000..cd7a81e --- /dev/null +++ b/js/ui/components/keyring.js @@ -0,0 +1,229 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Component */ + +const { Clutter, Gcr, Gio, GObject, Pango, Shell, St } = imports.gi; + +const Dialog = imports.ui.dialog; +const ModalDialog = imports.ui.modalDialog; +const ShellEntry = imports.ui.shellEntry; +const CheckBox = imports.ui.checkBox; +const Util = imports.misc.util; + +var KeyringDialog = GObject.registerClass( +class KeyringDialog extends ModalDialog.ModalDialog { + _init() { + super._init({ styleClass: 'prompt-dialog' }); + + this.prompt = new Shell.KeyringPrompt(); + this.prompt.connect('show-password', this._onShowPassword.bind(this)); + this.prompt.connect('show-confirm', this._onShowConfirm.bind(this)); + this.prompt.connect('prompt-close', this._onHidePrompt.bind(this)); + + let content = new Dialog.MessageDialogContent(); + + this.prompt.bind_property('message', + content, 'title', GObject.BindingFlags.SYNC_CREATE); + this.prompt.bind_property('description', + content, 'description', GObject.BindingFlags.SYNC_CREATE); + + let passwordBox = new St.BoxLayout({ + style_class: 'prompt-dialog-password-layout', + vertical: true, + }); + + this._passwordEntry = new St.PasswordEntry({ + style_class: 'prompt-dialog-password-entry', + can_focus: true, + x_align: Clutter.ActorAlign.CENTER, + }); + ShellEntry.addContextMenu(this._passwordEntry); + this._passwordEntry.clutter_text.connect('activate', this._onPasswordActivate.bind(this)); + this.prompt.bind_property('password-visible', + this._passwordEntry, 'visible', GObject.BindingFlags.SYNC_CREATE); + passwordBox.add_child(this._passwordEntry); + + this._confirmEntry = new St.PasswordEntry({ + style_class: 'prompt-dialog-password-entry', + can_focus: true, + x_align: Clutter.ActorAlign.CENTER, + }); + ShellEntry.addContextMenu(this._confirmEntry); + this._confirmEntry.clutter_text.connect('activate', this._onConfirmActivate.bind(this)); + this.prompt.bind_property('confirm-visible', + this._confirmEntry, 'visible', GObject.BindingFlags.SYNC_CREATE); + passwordBox.add_child(this._confirmEntry); + + this.prompt.set_password_actor(this._passwordEntry.clutter_text); + this.prompt.set_confirm_actor(this._confirmEntry.clutter_text); + + let warningBox = new St.BoxLayout({ vertical: true }); + + let capsLockWarning = new ShellEntry.CapsLockWarning(); + let syncCapsLockWarningVisibility = () => { + capsLockWarning.visible = + this.prompt.password_visible || this.prompt.confirm_visible; + }; + this.prompt.connect('notify::password-visible', syncCapsLockWarningVisibility); + this.prompt.connect('notify::confirm-visible', syncCapsLockWarningVisibility); + warningBox.add_child(capsLockWarning); + + let warning = new St.Label({ style_class: 'prompt-dialog-error-label' }); + warning.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + warning.clutter_text.line_wrap = true; + this.prompt.bind_property('warning', + warning, 'text', GObject.BindingFlags.SYNC_CREATE); + this.prompt.connect('notify::warning-visible', () => { + warning.opacity = this.prompt.warning_visible ? 255 : 0; + }); + this.prompt.connect('notify::warning', () => { + if (this._passwordEntry && this.prompt.warning !== '') + Util.wiggle(this._passwordEntry); + }); + warningBox.add_child(warning); + + passwordBox.add_child(warningBox); + content.add_child(passwordBox); + + this._choice = new CheckBox.CheckBox(); + this.prompt.bind_property('choice-label', this._choice.getLabelActor(), + 'text', GObject.BindingFlags.SYNC_CREATE); + this.prompt.bind_property('choice-chosen', this._choice, + 'checked', GObject.BindingFlags.SYNC_CREATE | GObject.BindingFlags.BIDIRECTIONAL); + this.prompt.bind_property('choice-visible', this._choice, + 'visible', GObject.BindingFlags.SYNC_CREATE); + content.add_child(this._choice); + + this.contentLayout.add_child(content); + + this._cancelButton = this.addButton({ + label: '', + action: this._onCancelButton.bind(this), + key: Clutter.KEY_Escape, + }); + this._continueButton = this.addButton({ + label: '', + action: this._onContinueButton.bind(this), + default: true, + }); + + this.prompt.bind_property('cancel-label', this._cancelButton, 'label', GObject.BindingFlags.SYNC_CREATE); + this.prompt.bind_property('continue-label', this._continueButton, 'label', GObject.BindingFlags.SYNC_CREATE); + } + + _updateSensitivity(sensitive) { + if (this._passwordEntry) + this._passwordEntry.reactive = sensitive; + + if (this._confirmEntry) + this._confirmEntry.reactive = sensitive; + + this._continueButton.can_focus = sensitive; + this._continueButton.reactive = sensitive; + } + + _ensureOpen() { + // NOTE: ModalDialog.open() is safe to call if the dialog is + // already open - it just returns true without side-effects + if (this.open()) + return true; + + // The above fail if e.g. unable to get input grab + // + // In an ideal world this wouldn't happen (because the + // Shell is in complete control of the session) but that's + // just not how things work right now. + + log('keyringPrompt: Failed to show modal dialog.' + + ' Dismissing prompt request'); + this.prompt.cancel(); + return false; + } + + _onShowPassword() { + this._ensureOpen(); + this._updateSensitivity(true); + this._passwordEntry.text = ''; + this._passwordEntry.grab_key_focus(); + } + + _onShowConfirm() { + this._ensureOpen(); + this._updateSensitivity(true); + this._confirmEntry.text = ''; + this._continueButton.grab_key_focus(); + } + + _onHidePrompt() { + this.close(); + } + + _onPasswordActivate() { + if (this.prompt.confirm_visible) + this._confirmEntry.grab_key_focus(); + else + this._onContinueButton(); + } + + _onConfirmActivate() { + this._onContinueButton(); + } + + _onContinueButton() { + this._updateSensitivity(false); + this.prompt.complete(); + } + + _onCancelButton() { + this.prompt.cancel(); + } +}); + +var KeyringDummyDialog = class { + constructor() { + this.prompt = new Shell.KeyringPrompt(); + this.prompt.connect('show-password', this._cancelPrompt.bind(this)); + this.prompt.connect('show-confirm', this._cancelPrompt.bind(this)); + } + + _cancelPrompt() { + this.prompt.cancel(); + } +}; + +var KeyringPrompter = GObject.registerClass( +class KeyringPrompter extends Gcr.SystemPrompter { + _init() { + super._init(); + this.connect('new-prompt', () => { + let dialog = this._enabled + ? new KeyringDialog() + : new KeyringDummyDialog(); + this._currentPrompt = dialog.prompt; + return this._currentPrompt; + }); + this._dbusId = null; + this._registered = false; + this._enabled = false; + this._currentPrompt = null; + } + + enable() { + if (!this._registered) { + this.register(Gio.DBus.session); + this._dbusId = Gio.DBus.session.own_name('org.gnome.keyring.SystemPrompter', + Gio.BusNameOwnerFlags.ALLOW_REPLACEMENT, null, null); + this._registered = true; + } + this._enabled = true; + } + + disable() { + this._enabled = false; + + if (this.prompting) + this._currentPrompt.cancel(); + this._currentPrompt = null; + } +}); + +var Component = KeyringPrompter; diff --git a/js/ui/components/networkAgent.js b/js/ui/components/networkAgent.js new file mode 100644 index 0000000..ba02f88 --- /dev/null +++ b/js/ui/components/networkAgent.js @@ -0,0 +1,877 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Component */ + +const { Clutter, Gio, GLib, GObject, NM, Pango, Shell, St } = imports.gi; +const Signals = imports.misc.signals; + +const Dialog = imports.ui.dialog; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; +const ModalDialog = imports.ui.modalDialog; +const ShellEntry = imports.ui.shellEntry; + +Gio._promisify(Shell.NetworkAgent.prototype, 'init_async'); +Gio._promisify(Shell.NetworkAgent.prototype, 'search_vpn_plugin'); + +const VPN_UI_GROUP = 'VPN Plugin UI'; + +var NetworkSecretDialog = GObject.registerClass( +class NetworkSecretDialog extends ModalDialog.ModalDialog { + _init(agent, requestId, connection, settingName, hints, flags, contentOverride) { + super._init({ styleClass: 'prompt-dialog' }); + + this._agent = agent; + this._requestId = requestId; + this._connection = connection; + this._settingName = settingName; + this._hints = hints; + + if (contentOverride) + this._content = contentOverride; + else + this._content = this._getContent(); + + let contentBox = new Dialog.MessageDialogContent({ + title: this._content.title, + description: this._content.message, + }); + + let initialFocusSet = false; + for (let i = 0; i < this._content.secrets.length; i++) { + let secret = this._content.secrets[i]; + let reactive = secret.key != null; + + let entryParams = { + style_class: 'prompt-dialog-password-entry', + hint_text: secret.label, + text: secret.value, + can_focus: reactive, + reactive, + x_align: Clutter.ActorAlign.CENTER, + }; + if (secret.password) + secret.entry = new St.PasswordEntry(entryParams); + else + secret.entry = new St.Entry(entryParams); + ShellEntry.addContextMenu(secret.entry); + contentBox.add_child(secret.entry); + + if (secret.validate) + secret.valid = secret.validate(secret); + else // no special validation, just ensure it's not empty + secret.valid = secret.value.length > 0; + + if (reactive) { + if (!initialFocusSet) { + this.setInitialKeyFocus(secret.entry); + initialFocusSet = true; + } + + secret.entry.clutter_text.connect('activate', this._onOk.bind(this)); + secret.entry.clutter_text.connect('text-changed', () => { + secret.value = secret.entry.get_text(); + if (secret.validate) + secret.valid = secret.validate(secret); + else + secret.valid = secret.value.length > 0; + this._updateOkButton(); + }); + } else { + secret.valid = true; + } + } + + if (this._content.secrets.some(s => s.password)) { + let capsLockWarning = new ShellEntry.CapsLockWarning(); + contentBox.add_child(capsLockWarning); + } + + if (flags & NM.SecretAgentGetSecretsFlags.WPS_PBC_ACTIVE) { + let descriptionLabel = new St.Label({ + text: _('Alternatively you can connect by pushing the “WPS” button on your router.'), + style_class: 'message-dialog-description', + }); + descriptionLabel.clutter_text.line_wrap = true; + descriptionLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + + contentBox.add_child(descriptionLabel); + } + + this.contentLayout.add_child(contentBox); + + this._okButton = { + label: _("Connect"), + action: this._onOk.bind(this), + default: true, + }; + + this.setButtons([{ + label: _("Cancel"), + action: this.cancel.bind(this), + key: Clutter.KEY_Escape, + }, this._okButton]); + + this._updateOkButton(); + } + + _updateOkButton() { + let valid = true; + for (let i = 0; i < this._content.secrets.length; i++) { + let secret = this._content.secrets[i]; + valid &&= secret.valid; + } + + this._okButton.button.reactive = valid; + this._okButton.button.can_focus = valid; + } + + _onOk() { + let valid = true; + for (let i = 0; i < this._content.secrets.length; i++) { + let secret = this._content.secrets[i]; + valid &&= secret.valid; + if (secret.key !== null) { + if (this._settingName === 'vpn') + this._agent.add_vpn_secret(this._requestId, secret.key, secret.value); + else + this._agent.set_password(this._requestId, secret.key, secret.value); + } + } + + if (valid) { + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.CONFIRMED); + this.close(global.get_current_time()); + } + // do nothing if not valid + } + + cancel() { + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.USER_CANCELED); + this.close(global.get_current_time()); + } + + _validateWpaPsk(secret) { + let value = secret.value; + if (value.length == 64) { + // must be composed of hexadecimal digits only + for (let i = 0; i < 64; i++) { + if (!((value[i] >= 'a' && value[i] <= 'f') || + (value[i] >= 'A' && value[i] <= 'F') || + (value[i] >= '0' && value[i] <= '9'))) + return false; + } + return true; + } + + return value.length >= 8 && value.length <= 63; + } + + _validateStaticWep(secret) { + let value = secret.value; + if (secret.wep_key_type == NM.WepKeyType.KEY) { + if (value.length == 10 || value.length == 26) { + for (let i = 0; i < value.length; i++) { + if (!((value[i] >= 'a' && value[i] <= 'f') || + (value[i] >= 'A' && value[i] <= 'F') || + (value[i] >= '0' && value[i] <= '9'))) + return false; + } + } else if (value.length == 5 || value.length == 13) { + for (let i = 0; i < value.length; i++) { + if (!((value[i] >= 'a' && value[i] <= 'z') || + (value[i] >= 'A' && value[i] <= 'Z'))) + return false; + } + } else { + return false; + } + } else if (secret.wep_key_type == NM.WepKeyType.PASSPHRASE) { + if (value.length < 0 || value.length > 64) + return false; + } + return true; + } + + _getWirelessSecrets(secrets, _wirelessSetting) { + let wirelessSecuritySetting = this._connection.get_setting_wireless_security(); + + if (this._settingName == '802-1x') { + this._get8021xSecrets(secrets); + return; + } + + switch (wirelessSecuritySetting.key_mgmt) { + // First the easy ones + case 'wpa-none': + case 'wpa-psk': + case 'sae': + secrets.push({ + label: _('Password'), + key: 'psk', + value: wirelessSecuritySetting.psk || '', + validate: this._validateWpaPsk, + password: true, + }); + break; + case 'none': // static WEP + secrets.push({ + label: _('Key'), + key: `wep-key${wirelessSecuritySetting.wep_tx_keyidx}`, + value: wirelessSecuritySetting.get_wep_key(wirelessSecuritySetting.wep_tx_keyidx) || '', + wep_key_type: wirelessSecuritySetting.wep_key_type, + validate: this._validateStaticWep, + password: true, + }); + break; + case 'ieee8021x': + if (wirelessSecuritySetting.auth_alg == 'leap') { // Cisco LEAP + secrets.push({ + label: _('Password'), + key: 'leap-password', + value: wirelessSecuritySetting.leap_password || '', + password: true, + }); + } else { // Dynamic (IEEE 802.1x) WEP + this._get8021xSecrets(secrets); + } + break; + case 'wpa-eap': + this._get8021xSecrets(secrets); + break; + default: + log(`Invalid wireless key management: ${wirelessSecuritySetting.key_mgmt}`); + } + } + + _get8021xSecrets(secrets) { + let ieee8021xSetting = this._connection.get_setting_802_1x(); + + /* If hints were given we know exactly what we need to ask */ + if (this._settingName == "802-1x" && this._hints.length) { + if (this._hints.includes('identity')) { + secrets.push({ + label: _('Username'), + key: 'identity', + value: ieee8021xSetting.identity || '', + password: false, + }); + } + if (this._hints.includes('password')) { + secrets.push({ + label: _('Password'), + key: 'password', + value: ieee8021xSetting.password || '', + password: true, + }); + } + if (this._hints.includes('private-key-password')) { + secrets.push({ + label: _('Private key password'), + key: 'private-key-password', + value: ieee8021xSetting.private_key_password || '', + password: true, + }); + } + return; + } + + switch (ieee8021xSetting.get_eap_method(0)) { + case 'md5': + case 'leap': + case 'ttls': + case 'peap': + case 'fast': + // TTLS and PEAP are actually much more complicated, but this complication + // is not visible here since we only care about phase2 authentication + // (and don't even care of which one) + secrets.push({ + label: _('Username'), + key: null, + value: ieee8021xSetting.identity || '', + password: false, + }); + secrets.push({ + label: _('Password'), + key: 'password', + value: ieee8021xSetting.password || '', + password: true, + }); + break; + case 'tls': + secrets.push({ + label: _('Identity'), + key: null, + value: ieee8021xSetting.identity || '', + password: false, + }); + secrets.push({ + label: _('Private key password'), + key: 'private-key-password', + value: ieee8021xSetting.private_key_password || '', + password: true, + }); + break; + default: + log(`Invalid EAP/IEEE802.1x method: ${ieee8021xSetting.get_eap_method(0)}`); + } + } + + _getPPPoESecrets(secrets) { + let pppoeSetting = this._connection.get_setting_pppoe(); + secrets.push({ + label: _('Username'), + key: 'username', + value: pppoeSetting.username || '', + password: false, + }); + secrets.push({ + label: _('Service'), key: 'service', + value: pppoeSetting.service || '', + password: false, + }); + secrets.push({ + label: _('Password'), key: 'password', + value: pppoeSetting.password || '', + password: true, + }); + } + + _getMobileSecrets(secrets, connectionType) { + let setting; + if (connectionType == 'bluetooth') + setting = this._connection.get_setting_cdma() || this._connection.get_setting_gsm(); + else + setting = this._connection.get_setting_by_name(connectionType); + secrets.push({ + label: _('Password'), + key: 'password', + value: setting.value || '', + password: true, + }); + } + + _getContent() { + let connectionSetting = this._connection.get_setting_connection(); + let connectionType = connectionSetting.get_connection_type(); + let wirelessSetting; + let ssid; + + let content = { }; + content.secrets = []; + + switch (connectionType) { + case '802-11-wireless': + wirelessSetting = this._connection.get_setting_wireless(); + ssid = NM.utils_ssid_to_utf8(wirelessSetting.get_ssid().get_data()); + content.title = _('Authentication required'); + content.message = _("Passwords or encryption keys are required to access the wireless network “%s”.").format(ssid); + this._getWirelessSecrets(content.secrets, wirelessSetting); + break; + case '802-3-ethernet': + content.title = _("Wired 802.1X authentication"); + content.message = null; + content.secrets.push({ + label: _('Network name'), + key: null, + value: connectionSetting.get_id(), + password: false, + }); + this._get8021xSecrets(content.secrets); + break; + case 'pppoe': + content.title = _("DSL authentication"); + content.message = null; + this._getPPPoESecrets(content.secrets); + break; + case 'gsm': + if (this._hints.includes('pin')) { + let gsmSetting = this._connection.get_setting_gsm(); + content.title = _("PIN code required"); + content.message = _("PIN code is needed for the mobile broadband device"); + content.secrets.push({ + label: _('PIN'), + key: 'pin', + value: gsmSetting.pin || '', + password: true, + }); + break; + } + // fall through + case 'cdma': + case 'bluetooth': + content.title = _('Authentication required'); + content.message = _("A password is required to connect to “%s”.").format(connectionSetting.get_id()); + this._getMobileSecrets(content.secrets, connectionType); + break; + default: + log(`Invalid connection type: ${connectionType}`); + } + + return content; + } +}); + +var VPNRequestHandler = class extends Signals.EventEmitter { + constructor(agent, requestId, authHelper, serviceType, connection, hints, flags) { + super(); + + this._agent = agent; + this._requestId = requestId; + this._connection = connection; + this._flags = flags; + this._pluginOutBuffer = []; + this._title = null; + this._description = null; + this._content = []; + this._shellDialog = null; + + let connectionSetting = connection.get_setting_connection(); + + const argv = [ + authHelper.fileName, + '-u', connectionSetting.uuid, + '-n', connectionSetting.id, + '-s', serviceType, + ]; + if (authHelper.externalUIMode) + argv.push('--external-ui-mode'); + if (flags & NM.SecretAgentGetSecretsFlags.ALLOW_INTERACTION) + argv.push('-i'); + if (flags & NM.SecretAgentGetSecretsFlags.REQUEST_NEW) + argv.push('-r'); + if (authHelper.supportsHints) { + for (let i = 0; i < hints.length; i++) { + argv.push('-t'); + argv.push(hints[i]); + } + } + + this._newStylePlugin = authHelper.externalUIMode; + + try { + let [success_, pid, stdin, stdout, stderr] = + GLib.spawn_async_with_pipes( + null, /* pwd */ + argv, + null, /* envp */ + GLib.SpawnFlags.DO_NOT_REAP_CHILD, + () => { + try { + global.context.restore_rlimit_nofile(); + } catch (err) { + } + }); + + this._childPid = pid; + this._stdin = new Gio.UnixOutputStream({ fd: stdin, close_fd: true }); + this._stdout = new Gio.UnixInputStream({ fd: stdout, close_fd: true }); + GLib.close(stderr); + this._dataStdout = new Gio.DataInputStream({ base_stream: this._stdout }); + + if (this._newStylePlugin) + this._readStdoutNewStyle(); + else + this._readStdoutOldStyle(); + + this._childWatch = GLib.child_watch_add(GLib.PRIORITY_DEFAULT, pid, + this._vpnChildFinished.bind(this)); + + this._writeConnection(); + } catch (e) { + logError(e, 'error while spawning VPN auth helper'); + + this._agent.respond(requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + } + } + + cancel(respond) { + if (respond) + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.USER_CANCELED); + + if (this._newStylePlugin && this._shellDialog) { + this._shellDialog.close(global.get_current_time()); + this._shellDialog.destroy(); + } else { + try { + this._stdin.write('QUIT\n\n', null); + } catch (e) { /* ignore broken pipe errors */ } + } + + this.destroy(); + } + + destroy() { + if (this._destroyed) + return; + + this.emit('destroy'); + if (this._childWatch) + GLib.source_remove(this._childWatch); + + this._stdin.close(null); + // Stdout is closed when we finish reading from it + + this._destroyed = true; + } + + _vpnChildFinished(pid, status, _requestObj) { + this._childWatch = 0; + if (this._newStylePlugin) { + // For new style plugin, all work is done in the async reading functions + // Just reap the process here + return; + } + + let [exited, exitStatus] = Shell.util_wifexited(status); + + if (exited) { + if (exitStatus != 0) + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.USER_CANCELED); + else + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.CONFIRMED); + } else { + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + } + + this.destroy(); + } + + _vpnChildProcessLineOldStyle(line) { + if (this._previousLine != undefined) { + // Two consecutive newlines mean that the child should be closed + // (the actual newlines are eaten by Gio.DataInputStream) + // Send a termination message + if (line == '' && this._previousLine == '') { + try { + this._stdin.write('QUIT\n\n', null); + } catch (e) { /* ignore broken pipe errors */ } + } else { + this._agent.add_vpn_secret(this._requestId, this._previousLine, line); + this._previousLine = undefined; + } + } else { + this._previousLine = line; + } + } + + async _readStdoutOldStyle() { + const [line, len_] = + await this._dataStdout.read_line_async(GLib.PRIORITY_DEFAULT, null); + + if (line === null) { + // end of file + this._stdout.close(null); + return; + } + + const decoder = new TextDecoder(); + this._vpnChildProcessLineOldStyle(decoder.decode(line)); + + // try to read more! + this._readStdoutOldStyle(); + } + + async _readStdoutNewStyle() { + const cnt = + await this._dataStdout.fill_async(-1, GLib.PRIORITY_DEFAULT, null); + + if (cnt === 0) { + // end of file + this._showNewStyleDialog(); + + this._stdout.close(null); + return; + } + + // Try to read more + this._dataStdout.set_buffer_size(2 * this._dataStdout.get_buffer_size()); + this._readStdoutNewStyle(); + } + + _showNewStyleDialog() { + let keyfile = new GLib.KeyFile(); + let data; + let contentOverride; + + try { + data = new GLib.Bytes(this._dataStdout.peek_buffer()); + keyfile.load_from_bytes(data, GLib.KeyFileFlags.NONE); + + if (keyfile.get_integer(VPN_UI_GROUP, 'Version') != 2) + throw new Error('Invalid plugin keyfile version, is %d'); + + contentOverride = { + title: keyfile.get_string(VPN_UI_GROUP, 'Title'), + message: keyfile.get_string(VPN_UI_GROUP, 'Description'), + secrets: [], + }; + + let [groups, len_] = keyfile.get_groups(); + for (let i = 0; i < groups.length; i++) { + if (groups[i] == VPN_UI_GROUP) + continue; + + let value = keyfile.get_string(groups[i], 'Value'); + let shouldAsk = keyfile.get_boolean(groups[i], 'ShouldAsk'); + + if (shouldAsk) { + contentOverride.secrets.push({ + label: keyfile.get_string(groups[i], 'Label'), + key: groups[i], + value, + password: keyfile.get_boolean(groups[i], 'IsSecret'), + }); + } else { + if (!value.length) // Ignore empty secrets + continue; + + this._agent.add_vpn_secret(this._requestId, groups[i], value); + } + } + } catch (e) { + // No output is a valid case it means "both secrets are stored" + if (data.length > 0) { + logError(e, 'error while reading VPN plugin output keyfile'); + + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + this.destroy(); + return; + } + } + + if (contentOverride && contentOverride.secrets.length) { + // Only show the dialog if we actually have something to ask + this._shellDialog = new NetworkSecretDialog(this._agent, this._requestId, this._connection, 'vpn', [], this._flags, contentOverride); + this._shellDialog.open(global.get_current_time()); + } else { + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.CONFIRMED); + this.destroy(); + } + } + + _writeConnection() { + let vpnSetting = this._connection.get_setting_vpn(); + + try { + vpnSetting.foreach_data_item((key, value) => { + this._stdin.write(`DATA_KEY=${key}\n`, null); + this._stdin.write(`DATA_VAL=${value || ''}\n\n`, null); + }); + vpnSetting.foreach_secret((key, value) => { + this._stdin.write(`SECRET_KEY=${key}\n`, null); + this._stdin.write(`SECRET_VAL=${value || ''}\n\n`, null); + }); + this._stdin.write('DONE\n\n', null); + } catch (e) { + logError(e, 'internal error while writing connection to helper'); + + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + this.destroy(); + } + } +}; + +var NetworkAgent = class { + constructor() { + this._native = new Shell.NetworkAgent({ + identifier: 'org.gnome.Shell.NetworkAgent', + capabilities: NM.SecretAgentCapabilities.VPN_HINTS, + auto_register: false, + }); + + this._dialogs = { }; + this._vpnRequests = { }; + this._notifications = { }; + + this._native.connect('new-request', this._newRequest.bind(this)); + this._native.connect('cancel-request', this._cancelRequest.bind(this)); + + this._initialized = false; + this._initNative(); + } + + async _initNative() { + try { + await this._native.init_async(GLib.PRIORITY_DEFAULT, null); + this._initialized = true; + } catch (e) { + this._native = null; + logError(e, 'error initializing the NetworkManager Agent'); + } + } + + enable() { + if (!this._native) + return; + + this._native.auto_register = true; + if (this._initialized && !this._native.registered) + this._native.register_async(null, null); + } + + disable() { + let requestId; + + for (requestId in this._dialogs) + this._dialogs[requestId].cancel(); + this._dialogs = { }; + + for (requestId in this._vpnRequests) + this._vpnRequests[requestId].cancel(true); + this._vpnRequests = { }; + + for (requestId in this._notifications) + this._notifications[requestId].destroy(); + this._notifications = { }; + + if (!this._native) + return; + + this._native.auto_register = false; + if (this._initialized && this._native.registered) + this._native.unregister_async(null, null); + } + + _showNotification(requestId, connection, settingName, hints, flags) { + let source = new MessageTray.Source(_("Network Manager"), 'network-transmit-receive'); + source.policy = new MessageTray.NotificationApplicationPolicy('gnome-network-panel'); + + let title, body; + + let connectionSetting = connection.get_setting_connection(); + let connectionType = connectionSetting.get_connection_type(); + switch (connectionType) { + case '802-11-wireless': { + let wirelessSetting = connection.get_setting_wireless(); + let ssid = NM.utils_ssid_to_utf8(wirelessSetting.get_ssid().get_data()); + title = _('Authentication required'); + body = _("Passwords or encryption keys are required to access the wireless network “%s”.").format(ssid); + break; + } + case '802-3-ethernet': + title = _("Wired 802.1X authentication"); + body = _('A password is required to connect to “%s”.').format(connection.get_id()); + break; + case 'pppoe': + title = _("DSL authentication"); + body = _('A password is required to connect to “%s”.').format(connection.get_id()); + break; + case 'gsm': + if (hints.includes('pin')) { + title = _("PIN code required"); + body = _("PIN code is needed for the mobile broadband device"); + break; + } + // fall through + case 'cdma': + case 'bluetooth': + title = _('Authentication required'); + body = _("A password is required to connect to “%s”.").format(connectionSetting.get_id()); + break; + case 'vpn': + title = _("VPN password"); + body = _("A password is required to connect to “%s”.").format(connectionSetting.get_id()); + break; + default: + log(`Invalid connection type: ${connectionType}`); + this._native.respond(requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + return; + } + + let notification = new MessageTray.Notification(source, title, body); + + notification.connect('activated', () => { + notification.answered = true; + this._handleRequest(requestId, connection, settingName, hints, flags); + }); + + this._notifications[requestId] = notification; + notification.connect('destroy', () => { + if (!notification.answered) + this._native.respond(requestId, Shell.NetworkAgentResponse.USER_CANCELED); + delete this._notifications[requestId]; + }); + + Main.messageTray.add(source); + source.showNotification(notification); + } + + _newRequest(agent, requestId, connection, settingName, hints, flags) { + if (!(flags & NM.SecretAgentGetSecretsFlags.USER_REQUESTED)) + this._showNotification(requestId, connection, settingName, hints, flags); + else + this._handleRequest(requestId, connection, settingName, hints, flags); + } + + _handleRequest(requestId, connection, settingName, hints, flags) { + if (settingName == 'vpn') { + this._vpnRequest(requestId, connection, hints, flags); + return; + } + + let dialog = new NetworkSecretDialog(this._native, requestId, connection, settingName, hints, flags); + dialog.connect('destroy', () => { + delete this._dialogs[requestId]; + }); + this._dialogs[requestId] = dialog; + dialog.open(global.get_current_time()); + } + + _cancelRequest(agent, requestId) { + if (this._dialogs[requestId]) { + this._dialogs[requestId].close(global.get_current_time()); + this._dialogs[requestId].destroy(); + delete this._dialogs[requestId]; + } else if (this._vpnRequests[requestId]) { + this._vpnRequests[requestId].cancel(false); + delete this._vpnRequests[requestId]; + } + } + + async _vpnRequest(requestId, connection, hints, flags) { + let vpnSetting = connection.get_setting_vpn(); + let serviceType = vpnSetting.service_type; + + let binary = await this._findAuthBinary(serviceType); + if (!binary) { + log('Invalid VPN service type (cannot find authentication binary)'); + + /* cancel the auth process */ + this._native.respond(requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + return; + } + + let vpnRequest = new VPNRequestHandler(this._native, requestId, binary, serviceType, connection, hints, flags); + vpnRequest.connect('destroy', () => { + delete this._vpnRequests[requestId]; + }); + this._vpnRequests[requestId] = vpnRequest; + } + + async _findAuthBinary(serviceType) { + let plugin; + + try { + plugin = await this._native.search_vpn_plugin(serviceType); + } catch (e) { + logError(e); + return null; + } + + const fileName = plugin.get_auth_dialog(); + if (!GLib.file_test(fileName, GLib.FileTest.IS_EXECUTABLE)) { + log(`VPN plugin at ${fileName} is not executable`); + return null; + } + + const prop = plugin.lookup_property('GNOME', 'supports-external-ui-mode'); + const trimmedProp = prop?.trim().toLowerCase() ?? ''; + + return { + fileName, + supportsHints: plugin.supports_hints(), + externalUIMode: ['true', 'yes', 'on', '1'].includes(trimmedProp), + }; + } +}; +var Component = NetworkAgent; diff --git a/js/ui/components/polkitAgent.js b/js/ui/components/polkitAgent.js new file mode 100644 index 0000000..1da02e5 --- /dev/null +++ b/js/ui/components/polkitAgent.js @@ -0,0 +1,471 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Component */ + +const { + AccountsService, Clutter, GLib, GObject, + Pango, PolkitAgent, Polkit, Shell, St, +} = imports.gi; + +const Dialog = imports.ui.dialog; +const Main = imports.ui.main; +const ModalDialog = imports.ui.modalDialog; +const ShellEntry = imports.ui.shellEntry; +const UserWidget = imports.ui.userWidget; +const Util = imports.misc.util; + +const DialogMode = { + AUTH: 0, + CONFIRM: 1, +}; + +const DIALOG_ICON_SIZE = 64; + +const DELAYED_RESET_TIMEOUT = 200; + +var AuthenticationDialog = GObject.registerClass({ + Signals: { 'done': { param_types: [GObject.TYPE_BOOLEAN] } }, +}, class AuthenticationDialog extends ModalDialog.ModalDialog { + _init(actionId, description, cookie, userNames) { + super._init({ styleClass: 'prompt-dialog' }); + + this.actionId = actionId; + this.message = description; + this.userNames = userNames; + + Main.sessionMode.connectObject('updated', () => { + this.visible = !Main.sessionMode.isLocked; + }, this); + + this.connect('closed', this._onDialogClosed.bind(this)); + + let title = _("Authentication Required"); + + let headerContent = new Dialog.MessageDialogContent({ title, description }); + this.contentLayout.add_child(headerContent); + + let bodyContent = new Dialog.MessageDialogContent(); + + if (userNames.length > 1) { + log(`polkitAuthenticationAgent: Received ${userNames.length} ` + + 'identities that can be used for authentication. Only ' + + 'considering one.'); + } + + let userName = GLib.get_user_name(); + if (!userNames.includes(userName)) + userName = 'root'; + if (!userNames.includes(userName)) + userName = userNames[0]; + + this._user = AccountsService.UserManager.get_default().get_user(userName); + + let userBox = new St.BoxLayout({ + style_class: 'polkit-dialog-user-layout', + vertical: true, + }); + bodyContent.add_child(userBox); + + this._userAvatar = new UserWidget.Avatar(this._user, { + iconSize: DIALOG_ICON_SIZE, + }); + this._userAvatar.x_align = Clutter.ActorAlign.CENTER; + userBox.add_child(this._userAvatar); + + this._userLabel = new St.Label({ + style_class: userName === 'root' + ? 'polkit-dialog-user-root-label' + : 'polkit-dialog-user-label', + }); + + if (userName === 'root') + this._userLabel.text = _('Administrator'); + + userBox.add_child(this._userLabel); + + let passwordBox = new St.BoxLayout({ + style_class: 'prompt-dialog-password-layout', + vertical: true, + }); + + this._passwordEntry = new St.PasswordEntry({ + style_class: 'prompt-dialog-password-entry', + text: "", + can_focus: true, + visible: false, + x_align: Clutter.ActorAlign.CENTER, + }); + ShellEntry.addContextMenu(this._passwordEntry); + this._passwordEntry.clutter_text.connect('activate', this._onEntryActivate.bind(this)); + this._passwordEntry.bind_property('reactive', + this._passwordEntry.clutter_text, 'editable', + GObject.BindingFlags.SYNC_CREATE); + passwordBox.add_child(this._passwordEntry); + + let warningBox = new St.BoxLayout({ vertical: true }); + + let capsLockWarning = new ShellEntry.CapsLockWarning(); + this._passwordEntry.bind_property('visible', + capsLockWarning, 'visible', + GObject.BindingFlags.SYNC_CREATE); + warningBox.add_child(capsLockWarning); + + this._errorMessageLabel = new St.Label({ + style_class: 'prompt-dialog-error-label', + visible: false, + }); + this._errorMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._errorMessageLabel.clutter_text.line_wrap = true; + warningBox.add_child(this._errorMessageLabel); + + this._infoMessageLabel = new St.Label({ + style_class: 'prompt-dialog-info-label', + visible: false, + }); + this._infoMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._infoMessageLabel.clutter_text.line_wrap = true; + warningBox.add_child(this._infoMessageLabel); + + /* text is intentionally non-blank otherwise the height is not the same as for + * infoMessage and errorMessageLabel - but it is still invisible because + * gnome-shell.css sets the color to be transparent + */ + this._nullMessageLabel = new St.Label({ style_class: 'prompt-dialog-null-label' }); + this._nullMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._nullMessageLabel.clutter_text.line_wrap = true; + warningBox.add_child(this._nullMessageLabel); + + passwordBox.add_child(warningBox); + bodyContent.add_child(passwordBox); + + this._cancelButton = this.addButton({ + label: _('Cancel'), + action: this.cancel.bind(this), + key: Clutter.KEY_Escape, + }); + this._okButton = this.addButton({ + label: _('Authenticate'), + action: this._onAuthenticateButtonPressed.bind(this), + reactive: false, + }); + this._okButton.bind_property('reactive', + this._okButton, 'can-focus', + GObject.BindingFlags.SYNC_CREATE); + + this._passwordEntry.clutter_text.connect('text-changed', text => { + this._okButton.reactive = text.get_text().length > 0; + }); + + this.contentLayout.add_child(bodyContent); + + this._doneEmitted = false; + + this._mode = -1; + + this._identityToAuth = Polkit.UnixUser.new_for_name(userName); + this._cookie = cookie; + + this._user.connectObject( + 'notify::is-loaded', this._onUserChanged.bind(this), + 'changed', this._onUserChanged.bind(this), this); + this._onUserChanged(); + } + + _initiateSession() { + this._destroySession(DELAYED_RESET_TIMEOUT); + + this._session = new PolkitAgent.Session({ + identity: this._identityToAuth, + cookie: this._cookie, + }); + this._session.connectObject( + 'completed', this._onSessionCompleted.bind(this), + 'request', this._onSessionRequest.bind(this), + 'show-error', this._onSessionShowError.bind(this), + 'show-info', this._onSessionShowInfo.bind(this), this); + this._session.initiate(); + } + + _ensureOpen() { + // NOTE: ModalDialog.open() is safe to call if the dialog is + // already open - it just returns true without side-effects + if (!this.open(global.get_current_time())) { + // This can fail if e.g. unable to get input grab + // + // In an ideal world this wouldn't happen (because the + // Shell is in complete control of the session) but that's + // just not how things work right now. + // + // One way to make this happen is by running 'sleep 3; + // pkexec bash' and then opening a popup menu. + // + // We could add retrying if this turns out to be a problem + + log('polkitAuthenticationAgent: Failed to show modal dialog. ' + + `Dismissing authentication request for action-id ${this.actionId} ` + + `cookie ${this._cookie}`); + this._emitDone(true); + } + } + + _emitDone(dismissed) { + if (!this._doneEmitted) { + this._doneEmitted = true; + this.emit('done', dismissed); + } + } + + _onEntryActivate() { + let response = this._passwordEntry.get_text(); + if (response.length === 0) + return; + + this._passwordEntry.reactive = false; + this._okButton.reactive = false; + + this._session.response(response); + // When the user responds, dismiss already shown info and + // error texts (if any) + this._errorMessageLabel.hide(); + this._infoMessageLabel.hide(); + this._nullMessageLabel.show(); + } + + _onAuthenticateButtonPressed() { + if (this._mode === DialogMode.CONFIRM) + this._initiateSession(); + else + this._onEntryActivate(); + } + + _onSessionCompleted(session, gainedAuthorization) { + if (this._completed || this._doneEmitted) + return; + + this._completed = true; + + /* Yay, all done */ + if (gainedAuthorization) { + this._emitDone(false); + } else { + /* Unless we are showing an existing error message from the PAM + * module (the PAM module could be reporting the authentication + * error providing authentication-method specific information), + * show "Sorry, that didn't work. Please try again." + */ + if (!this._errorMessageLabel.visible) { + /* Translators: "that didn't work" refers to the fact that the + * requested authentication was not gained; this can happen + * because of an authentication error (like invalid password), + * for instance. */ + this._errorMessageLabel.set_text(_("Sorry, that didn’t work. Please try again.")); + this._errorMessageLabel.show(); + this._infoMessageLabel.hide(); + this._nullMessageLabel.hide(); + + Util.wiggle(this._passwordEntry); + } + + /* Try and authenticate again */ + this._initiateSession(); + } + } + + _onSessionRequest(session, request, echoOn) { + if (this._sessionRequestTimeoutId) { + GLib.source_remove(this._sessionRequestTimeoutId); + this._sessionRequestTimeoutId = 0; + } + + // Hack: The request string comes directly from PAM, if it's "Password:" + // we replace it with our own to allow localization, if it's something + // else we remove the last colon and any trailing or leading spaces. + if (request === 'Password:' || request === 'Password: ') + this._passwordEntry.hint_text = _('Password'); + else + this._passwordEntry.hint_text = request.replace(/: *$/, '').trim(); + + this._passwordEntry.password_visible = echoOn; + + this._passwordEntry.show(); + this._passwordEntry.set_text(''); + this._passwordEntry.reactive = true; + this._okButton.reactive = false; + + this._ensureOpen(); + this._passwordEntry.grab_key_focus(); + } + + _onSessionShowError(session, text) { + this._passwordEntry.set_text(''); + this._errorMessageLabel.set_text(text); + this._errorMessageLabel.show(); + this._infoMessageLabel.hide(); + this._nullMessageLabel.hide(); + this._ensureOpen(); + } + + _onSessionShowInfo(session, text) { + this._passwordEntry.set_text(''); + this._infoMessageLabel.set_text(text); + this._infoMessageLabel.show(); + this._errorMessageLabel.hide(); + this._nullMessageLabel.hide(); + this._ensureOpen(); + } + + _destroySession(delay = 0) { + this._session?.disconnectObject(this); + + if (!this._completed) + this._session?.cancel(); + + this._completed = false; + this._session = null; + + if (this._sessionRequestTimeoutId) { + GLib.source_remove(this._sessionRequestTimeoutId); + this._sessionRequestTimeoutId = 0; + } + + let resetDialog = () => { + this._sessionRequestTimeoutId = 0; + + if (this.state != ModalDialog.State.OPENED) + return GLib.SOURCE_REMOVE; + + this._passwordEntry.hide(); + this._cancelButton.grab_key_focus(); + this._okButton.reactive = false; + + return GLib.SOURCE_REMOVE; + }; + + if (delay) { + this._sessionRequestTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, resetDialog); + GLib.Source.set_name_by_id(this._sessionRequestTimeoutId, '[gnome-shell] this._sessionRequestTimeoutId'); + } else { + resetDialog(); + } + } + + _onUserChanged() { + if (!this._user.is_loaded) + return; + + let userName = this._user.get_user_name(); + let realName = this._user.get_real_name(); + + if (userName !== 'root') + this._userLabel.set_text(realName); + + this._userAvatar.update(); + + if (this._user.get_password_mode() === AccountsService.UserPasswordMode.NONE) { + if (this._mode === DialogMode.CONFIRM) + return; + + this._mode = DialogMode.CONFIRM; + this._destroySession(); + + this._okButton.reactive = true; + + /* We normally open the dialog when we get a "request" signal, but + * since in this case initiating a session would perform the + * authentication, only open the dialog and initiate the session + * when the user confirmed. */ + this._ensureOpen(); + } else { + if (this._mode === DialogMode.AUTH) + return; + + this._mode = DialogMode.AUTH; + this._initiateSession(); + } + } + + close(timestamp) { + // Ensure cleanup if the dialog was never shown + if (this.state === ModalDialog.State.CLOSED) + this._onDialogClosed(); + super.close(timestamp); + } + + cancel() { + this._emitDone(true); + } + + _onDialogClosed() { + Main.sessionMode.disconnectObject(this); + + if (this._sessionRequestTimeoutId) + GLib.source_remove(this._sessionRequestTimeoutId); + this._sessionRequestTimeoutId = 0; + + this._user?.disconnectObject(this); + this._user = null; + + this._destroySession(); + } +}); + +var AuthenticationAgent = GObject.registerClass( +class AuthenticationAgent extends Shell.PolkitAuthenticationAgent { + _init() { + super._init(); + + this._currentDialog = null; + this.connect('initiate', this._onInitiate.bind(this)); + this.connect('cancel', this._onCancel.bind(this)); + this._sessionUpdatedId = 0; + } + + enable() { + try { + this.register(); + } catch (e) { + log('Failed to register AuthenticationAgent'); + } + } + + disable() { + try { + this.unregister(); + } catch (e) { + log('Failed to unregister AuthenticationAgent'); + } + } + + _onInitiate(nativeAgent, actionId, message, iconName, cookie, userNames) { + // Don't pop up a dialog while locked + if (Main.sessionMode.isLocked) { + Main.sessionMode.connectObject('updated', () => { + Main.sessionMode.disconnectObject(this); + + this._onInitiate(nativeAgent, actionId, message, iconName, cookie, userNames); + }, this); + return; + } + + this._currentDialog = new AuthenticationDialog(actionId, message, cookie, userNames); + this._currentDialog.connect('done', this._onDialogDone.bind(this)); + } + + _onCancel(_nativeAgent) { + this._completeRequest(false); + } + + _onDialogDone(_dialog, dismissed) { + this._completeRequest(dismissed); + } + + _completeRequest(dismissed) { + this._currentDialog.close(); + this._currentDialog = null; + + Main.sessionMode.disconnectObject(this); + + this.complete(dismissed); + } +}); + +var Component = AuthenticationAgent; diff --git a/js/ui/components/telepathyClient.js b/js/ui/components/telepathyClient.js new file mode 100644 index 0000000..d317822 --- /dev/null +++ b/js/ui/components/telepathyClient.js @@ -0,0 +1,1019 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Component */ + +const { Clutter, Gio, GLib, GObject, St } = imports.gi; + +var Tpl = null; +var Tp = null; +try { + ({ TelepathyGLib: Tp, TelepathyLogger: Tpl } = imports.gi); + + Gio._promisify(Tp.Channel.prototype, 'close_async'); + Gio._promisify(Tp.TextChannel.prototype, 'send_message_async'); + Gio._promisify(Tp.ChannelDispatchOperation.prototype, 'claim_with_async'); + Gio._promisify(Tpl.LogManager.prototype, 'get_filtered_events_async'); +} catch (e) { + log('Telepathy is not available, chat integration will be disabled.'); +} + +const History = imports.misc.history; +const Main = imports.ui.main; +const MessageList = imports.ui.messageList; +const MessageTray = imports.ui.messageTray; +const Params = imports.misc.params; +const Util = imports.misc.util; + +const HAVE_TP = Tp != null && Tpl != null; + +// See Notification.appendMessage +var SCROLLBACK_IMMEDIATE_TIME = 3 * 60; // 3 minutes +var SCROLLBACK_RECENT_TIME = 15 * 60; // 15 minutes +var SCROLLBACK_RECENT_LENGTH = 20; +var SCROLLBACK_IDLE_LENGTH = 5; + +// See Source._displayPendingMessages +var SCROLLBACK_HISTORY_LINES = 10; + +// See Notification._onEntryChanged +var COMPOSING_STOP_TIMEOUT = 5; + +var CHAT_EXPAND_LINES = 12; + +var NotificationDirection = { + SENT: 'chat-sent', + RECEIVED: 'chat-received', +}; + +const ChatMessage = HAVE_TP ? GObject.registerClass({ + Properties: { + 'message-type': GObject.ParamSpec.int( + 'message-type', 'message-type', 'message-type', + GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, + Math.min(...Object.values(Tp.ChannelTextMessageType)), + Math.max(...Object.values(Tp.ChannelTextMessageType)), + Tp.ChannelTextMessageType.NORMAL), + 'text': GObject.ParamSpec.string( + 'text', 'text', 'text', + GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, + null), + 'sender': GObject.ParamSpec.string( + 'sender', 'sender', 'sender', + GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, + null), + 'timestamp': GObject.ParamSpec.int64( + 'timestamp', 'timestamp', 'timestamp', + GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, + 0, Number.MAX_SAFE_INTEGER, 0), + 'direction': GObject.ParamSpec.string( + 'direction', 'direction', 'direction', + GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, + null), + }, +}, class ChatMessageClass extends GObject.Object { + static newFromTpMessage(tpMessage, direction) { + return new ChatMessage({ + 'message-type': tpMessage.get_message_type(), + 'text': tpMessage.to_text()[0], + 'sender': tpMessage.sender.alias, + 'timestamp': direction === NotificationDirection.RECEIVED + ? tpMessage.get_received_timestamp() : tpMessage.get_sent_timestamp(), + direction, + }); + } + + static newFromTplTextEvent(tplTextEvent) { + let direction = + tplTextEvent.get_sender().get_entity_type() === Tpl.EntityType.SELF + ? NotificationDirection.SENT : NotificationDirection.RECEIVED; + + return new ChatMessage({ + 'message-type': tplTextEvent.get_message_type(), + 'text': tplTextEvent.get_message(), + 'sender': tplTextEvent.get_sender().get_alias(), + 'timestamp': tplTextEvent.get_timestamp(), + direction, + }); + } +}) : null; + + +var TelepathyComponent = class { + constructor() { + this._client = null; + + if (!HAVE_TP) + return; // Telepathy isn't available + + this._client = new TelepathyClient(); + } + + enable() { + if (!this._client) + return; + + try { + this._client.register(); + } catch (e) { + throw new Error(`Could not register Telepathy client. Error: ${e}`); + } + + if (!this._client.account_manager.is_prepared(Tp.AccountManager.get_feature_quark_core())) + this._client.account_manager.prepare_async(null, null); + } + + disable() { + if (!this._client) + return; + + this._client.unregister(); + } +}; + +var TelepathyClient = HAVE_TP ? GObject.registerClass( +class TelepathyClient extends Tp.BaseClient { + _init() { + // channel path -> ChatSource + this._chatSources = {}; + this._chatState = Tp.ChannelChatState.ACTIVE; + + // account path -> AccountNotification + this._accountNotifications = {}; + + // Define features we want + this._accountManager = Tp.AccountManager.dup(); + let factory = this._accountManager.get_factory(); + factory.add_account_features([Tp.Account.get_feature_quark_connection()]); + factory.add_connection_features([Tp.Connection.get_feature_quark_contact_list()]); + factory.add_channel_features([Tp.Channel.get_feature_quark_contacts()]); + factory.add_contact_features([ + Tp.ContactFeature.ALIAS, + Tp.ContactFeature.AVATAR_DATA, + Tp.ContactFeature.PRESENCE, + Tp.ContactFeature.SUBSCRIPTION_STATES, + ]); + + // Set up a SimpleObserver, which will call _observeChannels whenever a + // channel matching its filters is detected. + // The second argument, recover, means _observeChannels will be run + // for any existing channel as well. + super._init({ + name: 'GnomeShell', + account_manager: this._accountManager, + uniquify_name: true, + }); + + // We only care about single-user text-based chats + let filter = {}; + filter[Tp.PROP_CHANNEL_CHANNEL_TYPE] = Tp.IFACE_CHANNEL_TYPE_TEXT; + filter[Tp.PROP_CHANNEL_TARGET_HANDLE_TYPE] = Tp.HandleType.CONTACT; + + this.set_observer_recover(true); + this.add_observer_filter(filter); + this.add_approver_filter(filter); + this.add_handler_filter(filter); + + // Allow other clients (such as Empathy) to preempt our channels if + // needed + this.set_delegated_channels_callback( + this._delegatedChannelsCb.bind(this)); + } + + vfunc_observe_channels(...args) { + let [account, conn, channels, dispatchOp_, requests_, context] = args; + let len = channels.length; + for (let i = 0; i < len; i++) { + let channel = channels[i]; + let [targetHandle_, targetHandleType] = channel.get_handle(); + + if (channel.get_invalidated()) + continue; + + /* Only observe contact text channels */ + if (!(channel instanceof Tp.TextChannel) || + targetHandleType != Tp.HandleType.CONTACT) + continue; + + this._createChatSource(account, conn, channel, channel.get_target_contact()); + } + + context.accept(); + } + + _createChatSource(account, conn, channel, contact) { + if (this._chatSources[channel.get_object_path()]) + return; + + let source = new ChatSource(account, conn, channel, contact, this); + + this._chatSources[channel.get_object_path()] = source; + source.connect('destroy', () => { + delete this._chatSources[channel.get_object_path()]; + }); + } + + vfunc_handle_channels(...args) { + let [account, conn, channels, requests_, userActionTime_, context] = args; + this._handlingChannels(account, conn, channels, true); + context.accept(); + } + + _handlingChannels(account, conn, channels, notify) { + let len = channels.length; + for (let i = 0; i < len; i++) { + let channel = channels[i]; + + // We can only handle text channel, so close any other channel + if (!(channel instanceof Tp.TextChannel)) { + channel.close_async(); + continue; + } + + if (channel.get_invalidated()) + continue; + + // 'notify' will be true when coming from an actual HandleChannels + // call, and not when from a successful Claim call. The point is + // we don't want to notify for a channel we just claimed which + // has no new messages (for example, a new channel which only has + // a delivery notification). We rely on _displayPendingMessages() + // and _messageReceived() to notify for new messages. + + // But we should still notify from HandleChannels because the + // Telepathy spec states that handlers must foreground channels + // in HandleChannels calls which are already being handled. + + if (notify && this.is_handling_channel(channel)) { + // We are already handling the channel, display the source + let source = this._chatSources[channel.get_object_path()]; + if (source) + source.showNotification(); + } + } + } + + vfunc_add_dispatch_operation(...args) { + let [account, conn, channels, dispatchOp, context] = args; + let channel = channels[0]; + let chanType = channel.get_channel_type(); + + if (channel.get_invalidated()) { + context.fail(new Tp.Error({ + code: Tp.Error.INVALID_ARGUMENT, + message: 'Channel is invalidated', + })); + return; + } + + if (chanType == Tp.IFACE_CHANNEL_TYPE_TEXT) { + this._approveTextChannel(account, conn, channel, dispatchOp, context); + } else { + context.fail(new Tp.Error({ + code: Tp.Error.INVALID_ARGUMENT, + message: 'Unsupported channel type', + })); + } + } + + async _approveTextChannel(account, conn, channel, dispatchOp, context) { + let [targetHandle_, targetHandleType] = channel.get_handle(); + + if (targetHandleType != Tp.HandleType.CONTACT) { + context.fail(new Tp.Error({ + code: Tp.Error.INVALID_ARGUMENT, + message: 'Unsupported handle type', + })); + return; + } + + context.accept(); + + // Approve private text channels right away as we are going to handle it + try { + await dispatchOp.claim_with_async(this); + this._handlingChannels(account, conn, [channel], false); + } catch (err) { + log(`Failed to claim channel: ${err}`); + } + } + + _delegatedChannelsCb(_client, _channels) { + // Nothing to do as we don't make a distinction between observed and + // handled channels. + } +}) : null; + +var ChatSource = HAVE_TP ? GObject.registerClass( +class ChatSource extends MessageTray.Source { + _init(account, conn, channel, contact, client) { + this._account = account; + this._contact = contact; + this._client = client; + + super._init(contact.get_alias()); + + this.isChat = true; + this._pendingMessages = []; + + this._conn = conn; + this._channel = channel; + + this._notifyTimeoutId = 0; + + this._presence = contact.get_presence_type(); + + this._channel.connectObject( + 'invalidated', this._channelClosed.bind(this), + 'message-sent', this._messageSent.bind(this), + 'message-received', this._messageReceived.bind(this), + 'pending-message-removed', this._pendingRemoved.bind(this), this); + + this._contact.connectObject( + 'notify::alias', this._updateAlias.bind(this), + 'notify::avatar-file', this._updateAvatarIcon.bind(this), + 'presence-changed', this._presenceChanged.bind(this), this); + + // Add ourselves as a source. + Main.messageTray.add(this); + + this._getLogMessages(); + } + + _ensureNotification() { + if (this._notification) + return; + + this._notification = new ChatNotification(this); + this._notification.connectObject( + 'activated', this.open.bind(this), + 'destroy', () => (this._notification = null), + 'updated', () => { + if (this._banner && this._banner.expanded) + this._ackMessages(); + }, this); + this.pushNotification(this._notification); + } + + _createPolicy() { + if (this._account.protocol_name == 'irc') + return new MessageTray.NotificationApplicationPolicy('org.gnome.Polari'); + return new MessageTray.NotificationApplicationPolicy('empathy'); + } + + createBanner() { + this._banner = new ChatNotificationBanner(this._notification); + + // We ack messages when the user expands the new notification + this._banner.connectObject( + 'expanded', this._ackMessages.bind(this), + 'destroy', () => (this._banner = null), this); + + return this._banner; + } + + _updateAlias() { + let oldAlias = this.title; + let newAlias = this._contact.get_alias(); + + if (oldAlias == newAlias) + return; + + this.setTitle(newAlias); + if (this._notification) + this._notification.appendAliasChange(oldAlias, newAlias); + } + + getIcon() { + let file = this._contact.get_avatar_file(); + if (file) + return new Gio.FileIcon({ file }); + else + return new Gio.ThemedIcon({ name: 'avatar-default' }); + } + + getSecondaryIcon() { + let iconName; + let presenceType = this._contact.get_presence_type(); + + switch (presenceType) { + case Tp.ConnectionPresenceType.AVAILABLE: + iconName = 'user-available'; + break; + case Tp.ConnectionPresenceType.BUSY: + iconName = 'user-busy'; + break; + case Tp.ConnectionPresenceType.OFFLINE: + iconName = 'user-offline'; + break; + case Tp.ConnectionPresenceType.HIDDEN: + iconName = 'user-invisible'; + break; + case Tp.ConnectionPresenceType.AWAY: + iconName = 'user-away'; + break; + case Tp.ConnectionPresenceType.EXTENDED_AWAY: + iconName = 'user-idle'; + break; + default: + iconName = 'user-offline'; + } + return new Gio.ThemedIcon({ name: iconName }); + } + + _updateAvatarIcon() { + this.iconUpdated(); + if (this._notification) { + this._notification.update(this._notification.title, + this._notification.bannerBodyText, + { gicon: this.getIcon() }); + } + } + + open() { + Main.overview.hide(); + Main.panel.closeCalendar(); + + if (this._client.is_handling_channel(this._channel)) { + // We are handling the channel, try to pass it to Empathy or Polari + // (depending on the channel type) + // We don't check if either app is available - mission control will + // fallback to something else if activation fails + + let target; + if (this._channel.connection.protocol_name == 'irc') + target = 'org.freedesktop.Telepathy.Client.Polari'; + else + target = 'org.freedesktop.Telepathy.Client.Empathy.Chat'; + this._client.delegate_channels_async([this._channel], global.get_current_time(), target, null); + } else { + // We are not the handler, just ask to present the channel + let dbus = Tp.DBusDaemon.dup(); + let cd = Tp.ChannelDispatcher.new(dbus); + + cd.present_channel_async(this._channel, global.get_current_time(), null); + } + } + + async _getLogMessages() { + let logManager = Tpl.LogManager.dup_singleton(); + let entity = Tpl.Entity.new_from_tp_contact(this._contact, Tpl.EntityType.CONTACT); + + const [events] = await logManager.get_filtered_events_async( + this._account, entity, + Tpl.EventTypeMask.TEXT, SCROLLBACK_HISTORY_LINES, + null); + + let logMessages = events.map(e => ChatMessage.newFromTplTextEvent(e)); + this._ensureNotification(); + + let pendingTpMessages = this._channel.get_pending_messages(); + let pendingMessages = []; + + for (let i = 0; i < pendingTpMessages.length; i++) { + let message = pendingTpMessages[i]; + + if (message.get_message_type() == Tp.ChannelTextMessageType.DELIVERY_REPORT) + continue; + + pendingMessages.push(ChatMessage.newFromTpMessage(message, + NotificationDirection.RECEIVED)); + + this._pendingMessages.push(message); + } + + this.countUpdated(); + + let showTimestamp = false; + + for (let i = 0; i < logMessages.length; i++) { + let logMessage = logMessages[i]; + let isPending = false; + + // Skip any log messages that are also in pendingMessages + for (let j = 0; j < pendingMessages.length; j++) { + let pending = pendingMessages[j]; + if (logMessage.timestamp == pending.timestamp && logMessage.text == pending.text) { + isPending = true; + break; + } + } + + if (!isPending) { + showTimestamp = true; + this._notification.appendMessage(logMessage, true, ['chat-log-message']); + } + } + + if (showTimestamp) + this._notification.appendTimestamp(); + + for (let i = 0; i < pendingMessages.length; i++) + this._notification.appendMessage(pendingMessages[i], true); + + if (pendingMessages.length > 0) + this.showNotification(); + } + + destroy(reason) { + if (this._client.is_handling_channel(this._channel)) { + this._ackMessages(); + // The chat box has been destroyed so it can't + // handle the channel any more. + this._channel.close_async(); + } else { + // Don't indicate any unread messages when the notification + // that represents them has been destroyed. + this._pendingMessages = []; + this.countUpdated(); + } + + // Keep source alive while the channel is open + if (reason != MessageTray.NotificationDestroyedReason.SOURCE_CLOSED) + return; + + if (this._destroyed) + return; + + this._destroyed = true; + this._channel.disconnectObject(this); + this._contact.disconnectObject(this); + + super.destroy(reason); + } + + _channelClosed() { + this.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); + } + + /* All messages are new messages for Telepathy sources */ + get count() { + return this._pendingMessages.length; + } + + get unseenCount() { + return this.count; + } + + get countVisible() { + return this.count > 0; + } + + _messageReceived(channel, message) { + if (message.get_message_type() == Tp.ChannelTextMessageType.DELIVERY_REPORT) + return; + + this._ensureNotification(); + this._pendingMessages.push(message); + this.countUpdated(); + + message = ChatMessage.newFromTpMessage(message, + NotificationDirection.RECEIVED); + this._notification.appendMessage(message); + + // Wait a bit before notifying for the received message, a handler + // could ack it in the meantime. + if (this._notifyTimeoutId != 0) + GLib.source_remove(this._notifyTimeoutId); + this._notifyTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, + this._notifyTimeout.bind(this)); + GLib.Source.set_name_by_id(this._notifyTimeoutId, '[gnome-shell] this._notifyTimeout'); + } + + _notifyTimeout() { + if (this._pendingMessages.length != 0) + this.showNotification(); + + this._notifyTimeoutId = 0; + + return GLib.SOURCE_REMOVE; + } + + // This is called for both messages we send from + // our client and other clients as well. + _messageSent(channel, message, _flags, _token) { + this._ensureNotification(); + message = ChatMessage.newFromTpMessage(message, + NotificationDirection.SENT); + this._notification.appendMessage(message); + } + + showNotification() { + super.showNotification(this._notification); + } + + respond(text) { + let type; + if (text.slice(0, 4) == '/me ') { + type = Tp.ChannelTextMessageType.ACTION; + text = text.slice(4); + } else { + type = Tp.ChannelTextMessageType.NORMAL; + } + + let msg = Tp.ClientMessage.new_text(type, text); + this._channel.send_message_async(msg, 0); + } + + setChatState(state) { + // We don't want to send COMPOSING every time a letter is typed into + // the entry. We send the state only when it changes. Telepathy/Empathy + // might change it behind our back if the user is using both + // gnome-shell's entry and the Empathy conversation window. We could + // keep track of it with the ChatStateChanged signal but it is good + // enough right now. + if (state != this._chatState) { + this._chatState = state; + this._channel.set_chat_state_async(state, null); + } + } + + _presenceChanged(_contact, _presence, _status, _message) { + if (this._notification) { + this._notification.update(this._notification.title, + this._notification.bannerBodyText, + { secondaryGIcon: this.getSecondaryIcon() }); + } + } + + _pendingRemoved(channel, message) { + let idx = this._pendingMessages.indexOf(message); + + if (idx >= 0) { + this._pendingMessages.splice(idx, 1); + this.countUpdated(); + } + + if (this._pendingMessages.length == 0 && + this._banner && !this._banner.expanded) + this._banner.hide(); + } + + _ackMessages() { + // Don't clear our messages here, tp-glib will send a + // 'pending-message-removed' for each one. + this._channel.ack_all_pending_messages_async(null); + } +}) : null; + +const ChatNotificationMessage = HAVE_TP ? GObject.registerClass( +class ChatNotificationMessage extends GObject.Object { + _init(props = {}) { + super._init(); + this.set(props); + } +}) : null; + +var ChatNotification = HAVE_TP ? GObject.registerClass({ + Signals: { + 'message-removed': { param_types: [ChatNotificationMessage.$gtype] }, + 'message-added': { param_types: [ChatNotificationMessage.$gtype] }, + 'timestamp-changed': { param_types: [ChatNotificationMessage.$gtype] }, + }, +}, class ChatNotification extends MessageTray.Notification { + _init(source) { + super._init(source, source.title, null, + { secondaryGIcon: source.getSecondaryIcon() }); + this.setUrgency(MessageTray.Urgency.HIGH); + this.setResident(true); + + this.messages = []; + this._timestampTimeoutId = 0; + } + + destroy(reason) { + if (this._timestampTimeoutId) + GLib.source_remove(this._timestampTimeoutId); + this._timestampTimeoutId = 0; + super.destroy(reason); + } + + /** + * appendMessage: + * @param {Object} message: An object with the properties + * {string} message.text: the body of the message, + * {Tp.ChannelTextMessageType} message.messageType: the type + * {string} message.sender: the name of the sender, + * {number} message.timestamp: the time the message was sent + * {NotificationDirection} message.direction: a #NotificationDirection + * + * @param {bool} noTimestamp: Whether to add a timestamp. If %true, + * no timestamp will be added, regardless of the difference since + * the last timestamp + */ + appendMessage(message, noTimestamp) { + let messageBody = GLib.markup_escape_text(message.text, -1); + let styles = [message.direction]; + + if (message.messageType == Tp.ChannelTextMessageType.ACTION) { + let senderAlias = GLib.markup_escape_text(message.sender, -1); + messageBody = `<i>${senderAlias}</i> ${messageBody}`; + styles.push('chat-action'); + } + + if (message.direction == NotificationDirection.RECEIVED) { + this.update(this.source.title, messageBody, { + datetime: GLib.DateTime.new_from_unix_local(message.timestamp), + bannerMarkup: true, + }); + } + + let group = message.direction == NotificationDirection.RECEIVED + ? 'received' : 'sent'; + + this._append({ + body: messageBody, + group, + styles, + timestamp: message.timestamp, + noTimestamp, + }); + } + + _filterMessages() { + if (this.messages.length < 1) + return; + + let lastMessageTime = this.messages[0].timestamp; + let currentTime = Date.now() / 1000; + + // Keep the scrollback from growing too long. If the most + // recent message (before the one we just added) is within + // SCROLLBACK_RECENT_TIME, we will keep + // SCROLLBACK_RECENT_LENGTH previous messages. Otherwise + // we'll keep SCROLLBACK_IDLE_LENGTH messages. + + let maxLength = lastMessageTime < currentTime - SCROLLBACK_RECENT_TIME + ? SCROLLBACK_IDLE_LENGTH : SCROLLBACK_RECENT_LENGTH; + + let filteredHistory = this.messages.filter(item => item.realMessage); + if (filteredHistory.length > maxLength) { + let lastMessageToKeep = filteredHistory[maxLength]; + let expired = this.messages.splice(this.messages.indexOf(lastMessageToKeep)); + for (let i = 0; i < expired.length; i++) + this.emit('message-removed', expired[i]); + } + } + + /** + * _append: + * @param {Object} props: An object with the properties: + * {string} props.body: The text of the message. + * {string} props.group: The group of the message, one of: + * 'received', 'sent', 'meta'. + * {string[]} props.styles: Style class names for the message to have. + * {number} props.timestamp: The timestamp of the message. + * {bool} props.noTimestamp: suppress timestamp signal? + */ + _append(props) { + let currentTime = Date.now() / 1000; + props = Params.parse(props, { + body: null, + group: null, + styles: [], + timestamp: currentTime, + noTimestamp: false, + }); + const { noTimestamp } = props; + delete props.noTimestamp; + + // Reset the old message timeout + if (this._timestampTimeoutId) + GLib.source_remove(this._timestampTimeoutId); + this._timestampTimeoutId = 0; + + let message = new ChatNotificationMessage({ + realMessage: props.group !== 'meta', + showTimestamp: false, + ...props, + }); + + this.messages.unshift(message); + this.emit('message-added', message); + + if (!noTimestamp) { + let timestamp = props.timestamp; + if (timestamp < currentTime - SCROLLBACK_IMMEDIATE_TIME) { + this.appendTimestamp(); + } else { + // Schedule a new timestamp in SCROLLBACK_IMMEDIATE_TIME + // from the timestamp of the message. + this._timestampTimeoutId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, + SCROLLBACK_IMMEDIATE_TIME - (currentTime - timestamp), + this.appendTimestamp.bind(this)); + GLib.Source.set_name_by_id(this._timestampTimeoutId, '[gnome-shell] this.appendTimestamp'); + } + } + + this._filterMessages(); + } + + appendTimestamp() { + this._timestampTimeoutId = 0; + + this.messages[0].showTimestamp = true; + this.emit('timestamp-changed', this.messages[0]); + + this._filterMessages(); + + return GLib.SOURCE_REMOVE; + } + + appendAliasChange(oldAlias, newAlias) { + oldAlias = GLib.markup_escape_text(oldAlias, -1); + newAlias = GLib.markup_escape_text(newAlias, -1); + + /* Translators: this is the other person changing their old IM name to their new + IM name. */ + const message = `<i>${ + _('%s is now known as %s').format(oldAlias, newAlias)}</i>`; + + this._append({ + body: message, + group: 'meta', + styles: ['chat-meta-message'], + }); + + this._filterMessages(); + } +}) : null; + +var ChatLineBox = GObject.registerClass( +class ChatLineBox extends St.BoxLayout { + vfunc_get_preferred_height(forWidth) { + let [, natHeight] = super.vfunc_get_preferred_height(forWidth); + return [natHeight, natHeight]; + } +}); + +var ChatNotificationBanner = GObject.registerClass( +class ChatNotificationBanner extends MessageTray.NotificationBanner { + _init(notification) { + super._init(notification); + + this._responseEntry = new St.Entry({ + style_class: 'chat-response', + x_expand: true, + can_focus: true, + }); + this._responseEntry.clutter_text.connect('activate', this._onEntryActivated.bind(this)); + this._responseEntry.clutter_text.connect('text-changed', this._onEntryChanged.bind(this)); + this.setActionArea(this._responseEntry); + + this._responseEntry.clutter_text.connect('key-focus-in', () => { + this.focused = true; + }); + this._responseEntry.clutter_text.connect('key-focus-out', () => { + this.focused = false; + this.emit('unfocused'); + }); + + this._scrollArea = new St.ScrollView({ + style_class: 'chat-scrollview vfade', + vscrollbar_policy: St.PolicyType.AUTOMATIC, + hscrollbar_policy: St.PolicyType.NEVER, + visible: this.expanded, + }); + this._contentArea = new St.BoxLayout({ + style_class: 'chat-body', + vertical: true, + }); + this._scrollArea.add_actor(this._contentArea); + + this.setExpandedBody(this._scrollArea); + this.setExpandedLines(CHAT_EXPAND_LINES); + + this._lastGroup = null; + + // Keep track of the bottom position for the current adjustment and + // force a scroll to the bottom if things change while we were at the + // bottom + this._oldMaxScrollValue = this._scrollArea.vscroll.adjustment.value; + this._scrollArea.vscroll.adjustment.connect('changed', adjustment => { + if (adjustment.value == this._oldMaxScrollValue) + this.scrollTo(St.Side.BOTTOM); + this._oldMaxScrollValue = Math.max(adjustment.lower, adjustment.upper - adjustment.page_size); + }); + + this._inputHistory = new History.HistoryManager({ entry: this._responseEntry.clutter_text }); + + this._composingTimeoutId = 0; + + this._messageActors = new Map(); + + this.notification.connectObject( + 'timestamp-changed', (n, message) => this._updateTimestamp(message), + 'message-added', (n, message) => this._addMessage(message), + 'message-removed', (n, message) => { + let actor = this._messageActors.get(message); + if (this._messageActors.delete(message)) + actor.destroy(); + }, this); + + for (let i = this.notification.messages.length - 1; i >= 0; i--) + this._addMessage(this.notification.messages[i]); + } + + scrollTo(side) { + let adjustment = this._scrollArea.vscroll.adjustment; + if (side == St.Side.TOP) + adjustment.value = adjustment.lower; + else if (side == St.Side.BOTTOM) + adjustment.value = adjustment.upper; + } + + hide() { + this.emit('done-displaying'); + } + + _addMessage(message) { + let body = new MessageList.URLHighlighter(message.body, true, true); + + let styles = message.styles; + for (let i = 0; i < styles.length; i++) + body.add_style_class_name(styles[i]); + + let group = message.group; + if (group != this._lastGroup) { + this._lastGroup = group; + body.add_style_class_name('chat-new-group'); + } + + let lineBox = new ChatLineBox(); + lineBox.add(body); + this._contentArea.add_actor(lineBox); + this._messageActors.set(message, lineBox); + + this._updateTimestamp(message); + } + + _updateTimestamp(message) { + let actor = this._messageActors.get(message); + if (!actor) + return; + + while (actor.get_n_children() > 1) + actor.get_child_at_index(1).destroy(); + + if (message.showTimestamp) { + let lastMessageTime = message.timestamp; + let lastMessageDate = new Date(lastMessageTime * 1000); + + let timeLabel = Util.createTimeLabel(lastMessageDate); + timeLabel.style_class = 'chat-meta-message'; + timeLabel.x_expand = timeLabel.y_expand = true; + timeLabel.x_align = timeLabel.y_align = Clutter.ActorAlign.END; + + actor.add_actor(timeLabel); + } + } + + _onEntryActivated() { + let text = this._responseEntry.get_text(); + if (text == '') + return; + + this._inputHistory.addItem(text); + + // Telepathy sends out the Sent signal for us. + // see Source._messageSent + this._responseEntry.set_text(''); + this.notification.source.respond(text); + } + + _composingStopTimeout() { + this._composingTimeoutId = 0; + + this.notification.source.setChatState(Tp.ChannelChatState.PAUSED); + + return GLib.SOURCE_REMOVE; + } + + _onEntryChanged() { + let text = this._responseEntry.get_text(); + + // If we're typing, we want to send COMPOSING. + // If we empty the entry, we want to send ACTIVE. + // If we've stopped typing for COMPOSING_STOP_TIMEOUT + // seconds, we want to send PAUSED. + + // Remove composing timeout. + if (this._composingTimeoutId > 0) { + GLib.source_remove(this._composingTimeoutId); + this._composingTimeoutId = 0; + } + + if (text != '') { + this.notification.source.setChatState(Tp.ChannelChatState.COMPOSING); + + this._composingTimeoutId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, + COMPOSING_STOP_TIMEOUT, + this._composingStopTimeout.bind(this)); + GLib.Source.set_name_by_id(this._composingTimeoutId, '[gnome-shell] this._composingStopTimeout'); + } else { + this.notification.source.setChatState(Tp.ChannelChatState.ACTIVE); + } + } +}); + +var Component = TelepathyComponent; diff --git a/js/ui/ctrlAltTab.js b/js/ui/ctrlAltTab.js new file mode 100644 index 0000000..421fecf --- /dev/null +++ b/js/ui/ctrlAltTab.js @@ -0,0 +1,203 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported CtrlAltTabManager */ + +const { Clutter, GObject, Meta, Shell, St } = imports.gi; + +const Main = imports.ui.main; +const SwitcherPopup = imports.ui.switcherPopup; +const Params = imports.misc.params; + +var POPUP_APPICON_SIZE = 96; + +var SortGroup = { + TOP: 0, + MIDDLE: 1, + BOTTOM: 2, +}; + +var CtrlAltTabManager = class CtrlAltTabManager { + constructor() { + this._items = []; + this.addGroup(global.window_group, + _('Windows'), + 'focus-windows-symbolic', { + sortGroup: SortGroup.TOP, + focusCallback: this._focusWindows.bind(this), + }); + } + + addGroup(root, name, icon, params) { + const item = Params.parse(params, { + sortGroup: SortGroup.MIDDLE, + proxy: root, + focusCallback: null, + }); + + item.root = root; + item.name = name; + item.iconName = icon; + + this._items.push(item); + root.connect('destroy', () => this.removeGroup(root)); + if (root instanceof St.Widget) + global.focus_manager.add_group(root); + } + + removeGroup(root) { + if (root instanceof St.Widget) + global.focus_manager.remove_group(root); + for (let i = 0; i < this._items.length; i++) { + if (this._items[i].root == root) { + this._items.splice(i, 1); + return; + } + } + } + + focusGroup(item, timestamp) { + if (item.focusCallback) + item.focusCallback(timestamp); + else + item.root.navigate_focus(null, St.DirectionType.TAB_FORWARD, false); + } + + // Sort the items into a consistent order; panel first, tray last, + // and everything else in between, sorted by X coordinate, so that + // they will have the same left-to-right ordering in the + // Ctrl-Alt-Tab dialog as they do onscreen. + _sortItems(a, b) { + if (a.sortGroup != b.sortGroup) + return a.sortGroup - b.sortGroup; + + let [ax] = a.proxy.get_transformed_position(); + let [bx] = b.proxy.get_transformed_position(); + + return ax - bx; + } + + popup(backward, binding, mask) { + // Start with the set of focus groups that are currently mapped + let items = this._items.filter(item => item.proxy.mapped); + + // And add the windows metacity would show in its Ctrl-Alt-Tab list + if (Main.sessionMode.hasWindows && !Main.overview.visible) { + let display = global.display; + let workspaceManager = global.workspace_manager; + let activeWorkspace = workspaceManager.get_active_workspace(); + let windows = display.get_tab_list(Meta.TabList.DOCKS, + activeWorkspace); + let windowTracker = Shell.WindowTracker.get_default(); + let textureCache = St.TextureCache.get_default(); + for (let i = 0; i < windows.length; i++) { + let icon = null; + let iconName = null; + if (windows[i].get_window_type() == Meta.WindowType.DESKTOP) { + iconName = 'video-display-symbolic'; + } else { + let app = windowTracker.get_window_app(windows[i]); + if (app) { + icon = app.create_icon_texture(POPUP_APPICON_SIZE); + } else { + icon = new St.Icon({ + gicon: textureCache.bind_cairo_surface_property(windows[i], 'icon'), + icon_size: POPUP_APPICON_SIZE, + }); + } + } + + items.push({ + name: windows[i].title, + proxy: windows[i].get_compositor_private(), + focusCallback: timestamp => { + Main.activateWindow(windows[i], timestamp); + }, + iconActor: icon, + iconName, + sortGroup: SortGroup.MIDDLE, + }); + } + } + + if (!items.length) + return; + + items.sort(this._sortItems.bind(this)); + + if (!this._popup) { + this._popup = new CtrlAltTabPopup(items); + this._popup.show(backward, binding, mask); + + this._popup.connect('destroy', + () => { + this._popup = null; + }); + } + } + + _focusWindows(timestamp) { + global.display.focus_default_window(timestamp); + } +}; + +var CtrlAltTabPopup = GObject.registerClass( +class CtrlAltTabPopup extends SwitcherPopup.SwitcherPopup { + _init(items) { + super._init(items); + + this._switcherList = new CtrlAltTabSwitcher(this._items); + } + + _keyPressHandler(keysym, action) { + if (action == Meta.KeyBindingAction.SWITCH_PANELS) + this._select(this._next()); + else if (action == Meta.KeyBindingAction.SWITCH_PANELS_BACKWARD) + this._select(this._previous()); + else if (keysym == Clutter.KEY_Left) + this._select(this._previous()); + else if (keysym == Clutter.KEY_Right) + this._select(this._next()); + else + return Clutter.EVENT_PROPAGATE; + + return Clutter.EVENT_STOP; + } + + _finish(time) { + super._finish(time); + Main.ctrlAltTabManager.focusGroup(this._items[this._selectedIndex], time); + } +}); + +var CtrlAltTabSwitcher = GObject.registerClass( +class CtrlAltTabSwitcher extends SwitcherPopup.SwitcherList { + _init(items) { + super._init(true); + + for (let i = 0; i < items.length; i++) + this._addIcon(items[i]); + } + + _addIcon(item) { + const box = new St.BoxLayout({ + style_class: 'alt-tab-app', + vertical: true, + }); + + let icon = item.iconActor; + if (!icon) { + icon = new St.Icon({ + icon_name: item.iconName, + icon_size: POPUP_APPICON_SIZE, + }); + } + box.add_child(icon); + + let text = new St.Label({ + text: item.name, + x_align: Clutter.ActorAlign.CENTER, + }); + box.add_child(text); + + this.addItem(box, text); + } +}); diff --git a/js/ui/dash.js b/js/ui/dash.js new file mode 100644 index 0000000..165f8ea --- /dev/null +++ b/js/ui/dash.js @@ -0,0 +1,992 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Dash */ + +const { Clutter, GLib, GObject, Graphene, Meta, Shell, St } = imports.gi; + +const AppDisplay = imports.ui.appDisplay; +const AppFavorites = imports.ui.appFavorites; +const DND = imports.ui.dnd; +const IconGrid = imports.ui.iconGrid; +const Main = imports.ui.main; +const Overview = imports.ui.overview; + +var DASH_ANIMATION_TIME = 200; +var DASH_ITEM_LABEL_SHOW_TIME = 150; +var DASH_ITEM_LABEL_HIDE_TIME = 100; +var DASH_ITEM_HOVER_TIMEOUT = 300; + +function getAppFromSource(source) { + if (source instanceof AppDisplay.AppIcon) + return source.app; + else + return null; +} + +var DashIcon = GObject.registerClass( +class DashIcon extends AppDisplay.AppIcon { + _init(app) { + super._init(app, { + setSizeManually: true, + showLabel: false, + }); + } + + popupMenu() { + super.popupMenu(St.Side.BOTTOM); + } + + // Disable scale-n-fade methods used during DND by parent + scaleAndFade() { + } + + undoScaleAndFade() { + } + + handleDragOver() { + return DND.DragMotionResult.CONTINUE; + } + + acceptDrop() { + return false; + } +}); + +// A container like StBin, but taking the child's scale into account +// when requesting a size +var DashItemContainer = GObject.registerClass( +class DashItemContainer extends St.Widget { + _init() { + super._init({ + style_class: 'dash-item-container', + pivot_point: new Graphene.Point({ x: .5, y: .5 }), + layout_manager: new Clutter.BinLayout(), + scale_x: 0, + scale_y: 0, + opacity: 0, + x_expand: true, + x_align: Clutter.ActorAlign.CENTER, + }); + + this._labelText = ""; + this.label = new St.Label({ style_class: 'dash-label' }); + this.label.hide(); + Main.layoutManager.addChrome(this.label); + this.label.connectObject('destroy', () => (this.label = null), this); + this.label_actor = this.label; + + this.child = null; + this.animatingOut = false; + + this.connect('notify::scale-x', () => this.queue_relayout()); + this.connect('notify::scale-y', () => this.queue_relayout()); + + this.connect('destroy', () => { + if (this.child != null) + this.child.destroy(); + this.label?.destroy(); + }); + } + + vfunc_get_preferred_height(forWidth) { + let themeNode = this.get_theme_node(); + forWidth = themeNode.adjust_for_width(forWidth); + let [minHeight, natHeight] = super.vfunc_get_preferred_height(forWidth); + return themeNode.adjust_preferred_height(minHeight * this.scale_y, + natHeight * this.scale_y); + } + + vfunc_get_preferred_width(forHeight) { + let themeNode = this.get_theme_node(); + forHeight = themeNode.adjust_for_height(forHeight); + let [minWidth, natWidth] = super.vfunc_get_preferred_width(forHeight); + return themeNode.adjust_preferred_width(minWidth * this.scale_x, + natWidth * this.scale_x); + } + + showLabel() { + if (!this._labelText) + return; + + this.label.set_text(this._labelText); + this.label.opacity = 0; + this.label.show(); + + let [stageX, stageY] = this.get_transformed_position(); + + const itemWidth = this.allocation.get_width(); + + const labelWidth = this.label.get_width(); + const xOffset = Math.floor((itemWidth - labelWidth) / 2); + const x = Math.clamp(stageX + xOffset, 0, global.stage.width - labelWidth); + + let node = this.label.get_theme_node(); + const yOffset = node.get_length('-y-offset'); + + const y = stageY - this.label.height - yOffset; + + this.label.set_position(x, y); + this.label.ease({ + opacity: 255, + duration: DASH_ITEM_LABEL_SHOW_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + setLabelText(text) { + this._labelText = text; + this.child.accessible_name = text; + } + + hideLabel() { + this.label.ease({ + opacity: 0, + duration: DASH_ITEM_LABEL_HIDE_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this.label.hide(), + }); + } + + setChild(actor) { + if (this.child == actor) + return; + + this.destroy_all_children(); + + this.child = actor; + this.child.y_expand = true; + this.add_actor(this.child); + } + + show(animate) { + if (this.child == null) + return; + + let time = animate ? DASH_ANIMATION_TIME : 0; + this.ease({ + scale_x: 1, + scale_y: 1, + opacity: 255, + duration: time, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + animateOutAndDestroy() { + this.label.hide(); + + if (this.child == null) { + this.destroy(); + return; + } + + this.animatingOut = true; + this.ease({ + scale_x: 0, + scale_y: 0, + opacity: 0, + duration: DASH_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this.destroy(), + }); + } +}); + +var ShowAppsIcon = GObject.registerClass( +class ShowAppsIcon extends DashItemContainer { + _init() { + super._init(); + + this.toggleButton = new St.Button({ + style_class: 'show-apps', + track_hover: true, + can_focus: true, + toggle_mode: true, + }); + this._iconActor = null; + this.icon = new IconGrid.BaseIcon(_('Show Applications'), { + setSizeManually: true, + showLabel: false, + createIcon: this._createIcon.bind(this), + }); + this.icon.y_align = Clutter.ActorAlign.CENTER; + + this.toggleButton.add_actor(this.icon); + this.toggleButton._delegate = this; + + this.setChild(this.toggleButton); + this.setDragApp(null); + } + + _createIcon(size) { + this._iconActor = new St.Icon({ + icon_name: 'view-app-grid-symbolic', + icon_size: size, + style_class: 'show-apps-icon', + track_hover: true, + }); + return this._iconActor; + } + + _canRemoveApp(app) { + if (app == null) + return false; + + if (!global.settings.is_writable('favorite-apps')) + return false; + + let id = app.get_id(); + let isFavorite = AppFavorites.getAppFavorites().isFavorite(id); + return isFavorite; + } + + setDragApp(app) { + let canRemove = this._canRemoveApp(app); + + this.toggleButton.set_hover(canRemove); + if (this._iconActor) + this._iconActor.set_hover(canRemove); + + if (canRemove) + this.setLabelText(_('Unpin')); + else + this.setLabelText(_("Show Applications")); + } + + handleDragOver(source, _actor, _x, _y, _time) { + if (!this._canRemoveApp(getAppFromSource(source))) + return DND.DragMotionResult.NO_DROP; + + return DND.DragMotionResult.MOVE_DROP; + } + + acceptDrop(source, _actor, _x, _y, _time) { + let app = getAppFromSource(source); + if (!this._canRemoveApp(app)) + return false; + + let id = app.get_id(); + + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + AppFavorites.getAppFavorites().removeFavorite(id); + return false; + }); + + return true; + } +}); + +var DragPlaceholderItem = GObject.registerClass( +class DragPlaceholderItem extends DashItemContainer { + _init() { + super._init(); + this.setChild(new St.Bin({ style_class: 'placeholder' })); + } +}); + +var EmptyDropTargetItem = GObject.registerClass( +class EmptyDropTargetItem extends DashItemContainer { + _init() { + super._init(); + this.setChild(new St.Bin({ style_class: 'empty-dash-drop-target' })); + } +}); + +const DashIconsLayout = GObject.registerClass( +class DashIconsLayout extends Clutter.BoxLayout { + _init() { + super._init({ + orientation: Clutter.Orientation.HORIZONTAL, + }); + } + + vfunc_get_preferred_width(container, forHeight) { + const [, natWidth] = super.vfunc_get_preferred_width(container, forHeight); + return [0, natWidth]; + } +}); + +const baseIconSizes = [16, 22, 24, 32, 48, 64]; + +var Dash = GObject.registerClass({ + Signals: { 'icon-size-changed': {} }, +}, class Dash extends St.Widget { + _init() { + this._maxWidth = -1; + this._maxHeight = -1; + this.iconSize = 64; + this._shownInitially = false; + + this._separator = null; + this._dragPlaceholder = null; + this._dragPlaceholderPos = -1; + this._animatingPlaceholdersCount = 0; + this._showLabelTimeoutId = 0; + this._resetHoverTimeoutId = 0; + this._labelShowing = false; + + super._init({ + name: 'dash', + offscreen_redirect: Clutter.OffscreenRedirect.ALWAYS, + layout_manager: new Clutter.BinLayout(), + }); + + this._dashContainer = new St.BoxLayout({ + x_align: Clutter.ActorAlign.CENTER, + y_expand: true, + }); + + this._box = new St.Widget({ + clip_to_allocation: true, + layout_manager: new DashIconsLayout(), + y_expand: true, + }); + this._box._delegate = this; + + this._dashContainer.add_child(this._box); + + this._showAppsIcon = new ShowAppsIcon(); + this._showAppsIcon.show(false); + this._showAppsIcon.icon.setIconSize(this.iconSize); + this._hookUpLabel(this._showAppsIcon); + this._dashContainer.add_child(this._showAppsIcon); + + this.showAppsButton = this._showAppsIcon.toggleButton; + + this._background = new St.Widget({ + style_class: 'dash-background', + }); + + const sizerBox = new Clutter.Actor(); + sizerBox.add_constraint(new Clutter.BindConstraint({ + source: this._showAppsIcon.icon, + coordinate: Clutter.BindCoordinate.HEIGHT, + })); + sizerBox.add_constraint(new Clutter.BindConstraint({ + source: this._dashContainer, + coordinate: Clutter.BindCoordinate.WIDTH, + })); + this._background.add_child(sizerBox); + + this.add_child(this._background); + this.add_child(this._dashContainer); + + this._workId = Main.initializeDeferredWork(this._box, this._redisplay.bind(this)); + + this._appSystem = Shell.AppSystem.get_default(); + + this._appSystem.connect('installed-changed', () => { + AppFavorites.getAppFavorites().reload(); + this._queueRedisplay(); + }); + AppFavorites.getAppFavorites().connect('changed', this._queueRedisplay.bind(this)); + this._appSystem.connect('app-state-changed', this._queueRedisplay.bind(this)); + + Main.overview.connect('item-drag-begin', + this._onItemDragBegin.bind(this)); + Main.overview.connect('item-drag-end', + this._onItemDragEnd.bind(this)); + Main.overview.connect('item-drag-cancelled', + this._onItemDragCancelled.bind(this)); + Main.overview.connect('window-drag-begin', + this._onWindowDragBegin.bind(this)); + Main.overview.connect('window-drag-cancelled', + this._onWindowDragEnd.bind(this)); + Main.overview.connect('window-drag-end', + this._onWindowDragEnd.bind(this)); + + // Translators: this is the name of the dock/favorites area on + // the left of the overview + Main.ctrlAltTabManager.addGroup(this, _("Dash"), 'user-bookmarks-symbolic'); + } + + _onItemDragBegin() { + this._dragCancelled = false; + this._dragMonitor = { + dragMotion: this._onItemDragMotion.bind(this), + }; + DND.addDragMonitor(this._dragMonitor); + + if (this._box.get_n_children() == 0) { + this._emptyDropTarget = new EmptyDropTargetItem(); + this._box.insert_child_at_index(this._emptyDropTarget, 0); + this._emptyDropTarget.show(true); + } + } + + _onItemDragCancelled() { + this._dragCancelled = true; + this._endItemDrag(); + } + + _onItemDragEnd() { + if (this._dragCancelled) + return; + + this._endItemDrag(); + } + + _endItemDrag() { + this._clearDragPlaceholder(); + this._clearEmptyDropTarget(); + this._showAppsIcon.setDragApp(null); + DND.removeDragMonitor(this._dragMonitor); + } + + _onItemDragMotion(dragEvent) { + let app = getAppFromSource(dragEvent.source); + if (app == null) + return DND.DragMotionResult.CONTINUE; + + let showAppsHovered = + this._showAppsIcon.contains(dragEvent.targetActor); + + if (!this._box.contains(dragEvent.targetActor) || showAppsHovered) + this._clearDragPlaceholder(); + + if (showAppsHovered) + this._showAppsIcon.setDragApp(app); + else + this._showAppsIcon.setDragApp(null); + + return DND.DragMotionResult.CONTINUE; + } + + _onWindowDragBegin() { + this.ease({ + opacity: 128, + duration: Overview.ANIMATION_TIME / 2, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + _onWindowDragEnd() { + this.ease({ + opacity: 255, + duration: Overview.ANIMATION_TIME / 2, + mode: Clutter.AnimationMode.EASE_IN_QUAD, + }); + } + + _appIdListToHash(apps) { + let ids = {}; + for (let i = 0; i < apps.length; i++) + ids[apps[i].get_id()] = apps[i]; + return ids; + } + + _queueRedisplay() { + Main.queueDeferredWork(this._workId); + } + + _hookUpLabel(item, appIcon) { + item.child.connect('notify::hover', () => { + this._syncLabel(item, appIcon); + }); + + item.child.connect('clicked', () => { + this._labelShowing = false; + item.hideLabel(); + }); + + Main.overview.connectObject('hiding', () => { + this._labelShowing = false; + item.hideLabel(); + }, item.child); + + if (appIcon) { + appIcon.connect('sync-tooltip', () => { + this._syncLabel(item, appIcon); + }); + } + } + + _createAppItem(app) { + let appIcon = new DashIcon(app); + + appIcon.connect('menu-state-changed', + (o, opened) => { + this._itemMenuStateChanged(item, opened); + }); + + let item = new DashItemContainer(); + item.setChild(appIcon); + + // Override default AppIcon label_actor, now the + // accessible_name is set at DashItemContainer.setLabelText + appIcon.label_actor = null; + item.setLabelText(app.get_name()); + + appIcon.icon.setIconSize(this.iconSize); + this._hookUpLabel(item, appIcon); + + return item; + } + + _itemMenuStateChanged(item, opened) { + // When the menu closes, it calls sync_hover, which means + // that the notify::hover handler does everything we need to. + if (opened) { + if (this._showLabelTimeoutId > 0) { + GLib.source_remove(this._showLabelTimeoutId); + this._showLabelTimeoutId = 0; + } + + item.hideLabel(); + } + } + + _syncLabel(item, appIcon) { + let shouldShow = appIcon ? appIcon.shouldShowTooltip() : item.child.get_hover(); + + if (shouldShow) { + if (this._showLabelTimeoutId == 0) { + let timeout = this._labelShowing ? 0 : DASH_ITEM_HOVER_TIMEOUT; + this._showLabelTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, timeout, + () => { + this._labelShowing = true; + item.showLabel(); + this._showLabelTimeoutId = 0; + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._showLabelTimeoutId, '[gnome-shell] item.showLabel'); + if (this._resetHoverTimeoutId > 0) { + GLib.source_remove(this._resetHoverTimeoutId); + this._resetHoverTimeoutId = 0; + } + } + } else { + if (this._showLabelTimeoutId > 0) + GLib.source_remove(this._showLabelTimeoutId); + this._showLabelTimeoutId = 0; + item.hideLabel(); + if (this._labelShowing) { + this._resetHoverTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, DASH_ITEM_HOVER_TIMEOUT, + () => { + this._labelShowing = false; + this._resetHoverTimeoutId = 0; + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._resetHoverTimeoutId, '[gnome-shell] this._labelShowing'); + } + } + } + + _adjustIconSize() { + // For the icon size, we only consider children which are "proper" + // icons (i.e. ignoring drag placeholders) and which are not + // animating out (which means they will be destroyed at the end of + // the animation) + let iconChildren = this._box.get_children().filter(actor => { + return actor.child && + actor.child._delegate && + actor.child._delegate.icon && + !actor.animatingOut; + }); + + iconChildren.push(this._showAppsIcon); + + if (this._maxWidth === -1 || this._maxHeight === -1) + return; + + const themeNode = this.get_theme_node(); + const maxAllocation = new Clutter.ActorBox({ + x1: 0, + y1: 0, + x2: this._maxWidth, + y2: 42, /* whatever */ + }); + let maxContent = themeNode.get_content_box(maxAllocation); + let availWidth = maxContent.x2 - maxContent.x1; + let spacing = themeNode.get_length('spacing'); + + let firstButton = iconChildren[0].child; + let firstIcon = firstButton._delegate.icon; + + // Enforce valid spacings during the size request + firstIcon.icon.ensure_style(); + const [, , iconWidth, iconHeight] = firstIcon.icon.get_preferred_size(); + const [, , buttonWidth, buttonHeight] = firstButton.get_preferred_size(); + + // Subtract icon padding and box spacing from the available width + availWidth -= iconChildren.length * (buttonWidth - iconWidth) + + (iconChildren.length - 1) * spacing; + + let availHeight = this._maxHeight; + availHeight -= this.margin_top + this.margin_bottom; + availHeight -= this._background.get_theme_node().get_vertical_padding(); + availHeight -= themeNode.get_vertical_padding(); + availHeight -= buttonHeight - iconHeight; + + const maxIconSize = Math.min(availWidth / iconChildren.length, availHeight); + + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + let iconSizes = baseIconSizes.map(s => s * scaleFactor); + + let newIconSize = baseIconSizes[0]; + for (let i = 0; i < iconSizes.length; i++) { + if (iconSizes[i] <= maxIconSize) + newIconSize = baseIconSizes[i]; + } + + if (newIconSize == this.iconSize) + return; + + let oldIconSize = this.iconSize; + this.iconSize = newIconSize; + this.emit('icon-size-changed'); + + let scale = oldIconSize / newIconSize; + for (let i = 0; i < iconChildren.length; i++) { + let icon = iconChildren[i].child._delegate.icon; + + // Set the new size immediately, to keep the icons' sizes + // in sync with this.iconSize + icon.setIconSize(this.iconSize); + + // Don't animate the icon size change when the overview + // is transitioning, not visible or when initially filling + // the dash + if (!Main.overview.visible || Main.overview.animationInProgress || + !this._shownInitially) + continue; + + let [targetWidth, targetHeight] = icon.icon.get_size(); + + // Scale the icon's texture to the previous size and + // tween to the new size + icon.icon.set_size(icon.icon.width * scale, + icon.icon.height * scale); + + icon.icon.ease({ + width: targetWidth, + height: targetHeight, + duration: DASH_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + if (this._separator) { + this._separator.ease({ + height: this.iconSize, + duration: DASH_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + } + + _redisplay() { + let favorites = AppFavorites.getAppFavorites().getFavoriteMap(); + + let running = this._appSystem.get_running(); + + let children = this._box.get_children().filter(actor => { + return actor.child && + actor.child._delegate && + actor.child._delegate.app; + }); + // Apps currently in the dash + let oldApps = children.map(actor => actor.child._delegate.app); + // Apps supposed to be in the dash + let newApps = []; + + for (let id in favorites) + newApps.push(favorites[id]); + + for (let i = 0; i < running.length; i++) { + let app = running[i]; + if (app.get_id() in favorites) + continue; + newApps.push(app); + } + + // Figure out the actual changes to the list of items; we iterate + // over both the list of items currently in the dash and the list + // of items expected there, and collect additions and removals. + // Moves are both an addition and a removal, where the order of + // the operations depends on whether we encounter the position + // where the item has been added first or the one from where it + // was removed. + // There is an assumption that only one item is moved at a given + // time; when moving several items at once, everything will still + // end up at the right position, but there might be additional + // additions/removals (e.g. it might remove all the launchers + // and add them back in the new order even if a smaller set of + // additions and removals is possible). + // If above assumptions turns out to be a problem, we might need + // to use a more sophisticated algorithm, e.g. Longest Common + // Subsequence as used by diff. + let addedItems = []; + let removedActors = []; + + let newIndex = 0; + let oldIndex = 0; + while (newIndex < newApps.length || oldIndex < oldApps.length) { + let oldApp = oldApps.length > oldIndex ? oldApps[oldIndex] : null; + let newApp = newApps.length > newIndex ? newApps[newIndex] : null; + + // No change at oldIndex/newIndex + if (oldApp == newApp) { + oldIndex++; + newIndex++; + continue; + } + + // App removed at oldIndex + if (oldApp && !newApps.includes(oldApp)) { + removedActors.push(children[oldIndex]); + oldIndex++; + continue; + } + + // App added at newIndex + if (newApp && !oldApps.includes(newApp)) { + addedItems.push({ + app: newApp, + item: this._createAppItem(newApp), + pos: newIndex, + }); + newIndex++; + continue; + } + + // App moved + let nextApp = newApps.length > newIndex + 1 + ? newApps[newIndex + 1] : null; + let insertHere = nextApp && nextApp == oldApp; + let alreadyRemoved = removedActors.reduce((result, actor) => { + let removedApp = actor.child._delegate.app; + return result || removedApp == newApp; + }, false); + + if (insertHere || alreadyRemoved) { + let newItem = this._createAppItem(newApp); + addedItems.push({ + app: newApp, + item: newItem, + pos: newIndex + removedActors.length, + }); + newIndex++; + } else { + removedActors.push(children[oldIndex]); + oldIndex++; + } + } + + for (let i = 0; i < addedItems.length; i++) { + this._box.insert_child_at_index(addedItems[i].item, + addedItems[i].pos); + } + + for (let i = 0; i < removedActors.length; i++) { + let item = removedActors[i]; + + // Don't animate item removal when the overview is transitioning + // or hidden + if (Main.overview.visible && !Main.overview.animationInProgress) + item.animateOutAndDestroy(); + else + item.destroy(); + } + + this._adjustIconSize(); + + // Skip animations on first run when adding the initial set + // of items, to avoid all items zooming in at once + + let animate = this._shownInitially && Main.overview.visible && + !Main.overview.animationInProgress; + + if (!this._shownInitially) + this._shownInitially = true; + + for (let i = 0; i < addedItems.length; i++) + addedItems[i].item.show(animate); + + // Update separator + const nFavorites = Object.keys(favorites).length; + const nIcons = children.length + addedItems.length - removedActors.length; + if (nFavorites > 0 && nFavorites < nIcons) { + if (!this._separator) { + this._separator = new St.Widget({ + style_class: 'dash-separator', + y_align: Clutter.ActorAlign.CENTER, + height: this.iconSize, + }); + this._box.add_child(this._separator); + } + let pos = nFavorites + this._animatingPlaceholdersCount; + if (this._dragPlaceholder) + pos++; + this._box.set_child_at_index(this._separator, pos); + } else if (this._separator) { + this._separator.destroy(); + this._separator = null; + } + + // Workaround for https://bugzilla.gnome.org/show_bug.cgi?id=692744 + // Without it, StBoxLayout may use a stale size cache + this._box.queue_relayout(); + } + + _clearDragPlaceholder() { + if (this._dragPlaceholder) { + this._animatingPlaceholdersCount++; + this._dragPlaceholder.connect('destroy', () => { + this._animatingPlaceholdersCount--; + }); + this._dragPlaceholder.animateOutAndDestroy(); + this._dragPlaceholder = null; + } + this._dragPlaceholderPos = -1; + } + + _clearEmptyDropTarget() { + if (this._emptyDropTarget) { + this._emptyDropTarget.animateOutAndDestroy(); + this._emptyDropTarget = null; + } + } + + handleDragOver(source, actor, x, _y, _time) { + let app = getAppFromSource(source); + + // Don't allow favoriting of transient apps + if (app == null || app.is_window_backed()) + return DND.DragMotionResult.NO_DROP; + + if (!global.settings.is_writable('favorite-apps')) + return DND.DragMotionResult.NO_DROP; + + let favorites = AppFavorites.getAppFavorites().getFavorites(); + let numFavorites = favorites.length; + + let favPos = favorites.indexOf(app); + + let children = this._box.get_children(); + let numChildren = children.length; + let boxWidth = this._box.width; + + // Keep the placeholder out of the index calculation; assuming that + // the remove target has the same size as "normal" items, we don't + // need to do the same adjustment there. + if (this._dragPlaceholder) { + boxWidth -= this._dragPlaceholder.width; + numChildren--; + } + + // Same with the separator + if (this._separator) { + boxWidth -= this._separator.width; + numChildren--; + } + + let pos; + if (this._emptyDropTarget) + pos = 0; // always insert at the start when dash is empty + else if (this.text_direction === Clutter.TextDirection.RTL) + pos = numChildren - Math.floor(x * numChildren / boxWidth); + else + pos = Math.floor(x * numChildren / boxWidth); + + // Put the placeholder after the last favorite if we are not + // in the favorites zone + if (pos > numFavorites) + pos = numFavorites; + + if (pos !== this._dragPlaceholderPos && this._animatingPlaceholdersCount === 0) { + this._dragPlaceholderPos = pos; + + // Don't allow positioning before or after self + if (favPos != -1 && (pos == favPos || pos == favPos + 1)) { + this._clearDragPlaceholder(); + return DND.DragMotionResult.CONTINUE; + } + + // If the placeholder already exists, we just move + // it, but if we are adding it, expand its size in + // an animation + let fadeIn; + if (this._dragPlaceholder) { + this._dragPlaceholder.destroy(); + fadeIn = false; + } else { + fadeIn = true; + } + + this._dragPlaceholder = new DragPlaceholderItem(); + this._dragPlaceholder.child.set_width(this.iconSize); + this._dragPlaceholder.child.set_height(this.iconSize / 2); + this._box.insert_child_at_index(this._dragPlaceholder, + this._dragPlaceholderPos); + this._dragPlaceholder.show(fadeIn); + } + + if (!this._dragPlaceholder) + return DND.DragMotionResult.NO_DROP; + + let srcIsFavorite = favPos != -1; + + if (srcIsFavorite) + return DND.DragMotionResult.MOVE_DROP; + + return DND.DragMotionResult.COPY_DROP; + } + + // Draggable target interface + acceptDrop(source, _actor, _x, _y, _time) { + let app = getAppFromSource(source); + + // Don't allow favoriting of transient apps + if (app == null || app.is_window_backed()) + return false; + + if (!global.settings.is_writable('favorite-apps')) + return false; + + let id = app.get_id(); + + let favorites = AppFavorites.getAppFavorites().getFavoriteMap(); + + let srcIsFavorite = id in favorites; + + let favPos = 0; + let children = this._box.get_children(); + for (let i = 0; i < this._dragPlaceholderPos; i++) { + if (this._dragPlaceholder && + children[i] == this._dragPlaceholder) + continue; + + let childId = children[i].child._delegate.app.get_id(); + if (childId == id) + continue; + if (childId in favorites) + favPos++; + } + + // No drag placeholder means we don't want to favorite the app + // and we are dragging it to its original position + if (!this._dragPlaceholder) + return true; + + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + let appFavorites = AppFavorites.getAppFavorites(); + if (srcIsFavorite) + appFavorites.moveFavoriteToPos(id, favPos); + else + appFavorites.addFavoriteAtPos(id, favPos); + return false; + }); + + return true; + } + + setMaxSize(maxWidth, maxHeight) { + if (this._maxWidth === maxWidth && + this._maxHeight === maxHeight) + return; + + this._maxWidth = maxWidth; + this._maxHeight = maxHeight; + this._queueRedisplay(); + } +}); diff --git a/js/ui/dateMenu.js b/js/ui/dateMenu.js new file mode 100644 index 0000000..2c44f0c --- /dev/null +++ b/js/ui/dateMenu.js @@ -0,0 +1,980 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported DateMenuButton */ + +const { + Clutter, Gio, GLib, GnomeDesktop, + GObject, GWeather, Pango, Shell, St, +} = imports.gi; + +const Util = imports.misc.util; +const Main = imports.ui.main; +const PanelMenu = imports.ui.panelMenu; +const Calendar = imports.ui.calendar; +const Weather = imports.misc.weather; +const System = imports.system; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const NC_ = (context, str) => `${context}\u0004${str}`; +const T_ = Shell.util_translate_time_string; + +const MAX_FORECASTS = 5; +const EN_CHAR = '\u2013'; + +const ClocksIntegrationIface = loadInterfaceXML('org.gnome.Shell.ClocksIntegration'); +const ClocksProxy = Gio.DBusProxy.makeProxyWrapper(ClocksIntegrationIface); + +function _isToday(date) { + let now = new Date(); + return now.getYear() == date.getYear() && + now.getMonth() == date.getMonth() && + now.getDate() == date.getDate(); +} + +function _gDateTimeToDate(datetime) { + return new Date(datetime.to_unix() * 1000 + datetime.get_microsecond() / 1000); +} + +var TodayButton = GObject.registerClass( +class TodayButton extends St.Button { + _init(calendar) { + // Having the ability to go to the current date if the user is already + // on the current date can be confusing. So don't make the button reactive + // until the selected date changes. + super._init({ + style_class: 'datemenu-today-button', + x_expand: true, + can_focus: true, + reactive: false, + }); + + let hbox = new St.BoxLayout({ vertical: true }); + this.add_actor(hbox); + + this._dayLabel = new St.Label({ + style_class: 'day-label', + x_align: Clutter.ActorAlign.START, + }); + hbox.add_actor(this._dayLabel); + + this._dateLabel = new St.Label({ style_class: 'date-label' }); + hbox.add_actor(this._dateLabel); + + this._calendar = calendar; + this._calendar.connect('selected-date-changed', (_calendar, datetime) => { + // Make the button reactive only if the selected date is not the + // current date. + this.reactive = !_isToday(_gDateTimeToDate(datetime)); + }); + } + + vfunc_clicked() { + this._calendar.setDate(new Date(), false); + } + + setDate(date) { + this._dayLabel.set_text(date.toLocaleFormat('%A')); + + /* Translators: This is the date format to use when the calendar popup is + * shown - it is shown just below the time in the top bar (e.g., + * "Tue 9:29 AM"). The string itself should become a full date, e.g., + * "February 17 2015". + */ + let dateFormat = Shell.util_translate_time_string(N_("%B %-d %Y")); + this._dateLabel.set_text(date.toLocaleFormat(dateFormat)); + + /* Translators: This is the accessible name of the date button shown + * below the time in the shell; it should combine the weekday and the + * date, e.g. "Tuesday February 17 2015". + */ + dateFormat = Shell.util_translate_time_string(N_("%A %B %e %Y")); + this.accessible_name = date.toLocaleFormat(dateFormat); + } +}); + +var EventsSection = GObject.registerClass( +class EventsSection extends St.Button { + _init() { + super._init({ + style_class: 'events-button', + can_focus: true, + x_expand: true, + child: new St.BoxLayout({ + style_class: 'events-box', + vertical: true, + x_expand: true, + }), + }); + + this._startDate = null; + this._endDate = null; + + this._eventSource = null; + this._calendarApp = null; + + this._title = new St.Label({ + style_class: 'events-title', + }); + this.child.add_child(this._title); + + this._eventsList = new St.BoxLayout({ + style_class: 'events-list', + vertical: true, + x_expand: true, + }); + this.child.add_child(this._eventsList); + + this._appSys = Shell.AppSystem.get_default(); + this._appSys.connect('installed-changed', + this._appInstalledChanged.bind(this)); + this._appInstalledChanged(); + } + + setDate(date) { + this._startDate = + new Date(date.getFullYear(), date.getMonth(), date.getDate()); + this._endDate = + new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1); + + this._updateTitle(); + this._reloadEvents(); + } + + setEventSource(eventSource) { + if (!(eventSource instanceof Calendar.EventSourceBase)) + throw new Error('Event source is not valid type'); + + this._eventSource = eventSource; + this._eventSource.connect('changed', this._reloadEvents.bind(this)); + this._eventSource.connect('notify::has-calendars', + this._sync.bind(this)); + this._sync(); + } + + _updateTitle() { + /* Translators: Shown on calendar heading when selected day occurs on current year */ + const sameYearFormat = T_(NC_('calendar heading', '%B %-d')); + + /* Translators: Shown on calendar heading when selected day occurs on different year */ + const otherYearFormat = T_(NC_('calendar heading', '%B %-d %Y')); + + const timeSpanDay = GLib.TIME_SPAN_DAY / 1000; + const now = new Date(); + + if (this._startDate <= now && now < this._endDate) + this._title.text = _('Today'); + else if (this._endDate <= now && now - this._endDate < timeSpanDay) + this._title.text = _('Yesterday'); + else if (this._startDate > now && this._startDate - now <= timeSpanDay) + this._title.text = _('Tomorrow'); + else if (this._startDate.getFullYear() === now.getFullYear()) + this._title.text = this._startDate.toLocaleFormat(sameYearFormat); + else + this._title.text = this._startDate.toLocaleFormat(otherYearFormat); + } + + _isAtMidnight(eventTime) { + return eventTime.getHours() === 0 && eventTime.getMinutes() === 0 && eventTime.getSeconds() === 0; + } + + _formatEventTime(event) { + const eventStart = event.date; + let eventEnd = event.end; + + const allDay = + eventStart.getTime() === this._startDate.getTime() && eventEnd.getTime() === this._endDate.getTime(); + + const startsBeforeToday = eventStart < this._startDate; + const endsAfterToday = eventEnd > this._endDate; + + const startTimeOnly = Util.formatTime(eventStart, { timeOnly: true }); + const endTimeOnly = Util.formatTime(eventEnd, { timeOnly: true }); + + const rtl = Clutter.get_default_text_direction() === Clutter.TextDirection.RTL; + + let title; + if (allDay) { + /* Translators: Shown in calendar event list for all day events + * Keep it short, best if you can use less then 10 characters + */ + title = C_('event list time', 'All Day'); + } else if (startsBeforeToday || endsAfterToday) { + const now = new Date(); + const thisYear = now.getFullYear(); + + const startsAtMidnight = this._isAtMidnight(eventStart); + const endsAtMidnight = this._isAtMidnight(eventEnd); + + const startYear = eventStart.getFullYear(); + + if (endsAtMidnight) { + eventEnd = new Date(eventEnd); + eventEnd.setDate(eventEnd.getDate() - 1); + } + + const endYear = eventEnd.getFullYear(); + + let format; + if (startYear === thisYear && thisYear === endYear) + /* Translators: Shown in calendar event list as the start/end of events + * that only show day and month + */ + format = T_(N_('%m/%d')); + else + format = '%x'; + + const startDateOnly = eventStart.toLocaleFormat(format); + const endDateOnly = eventEnd.toLocaleFormat(format); + + if (startsAtMidnight && endsAtMidnight) + title = `${rtl ? endDateOnly : startDateOnly} ${EN_CHAR} ${rtl ? startDateOnly : endDateOnly}`; + else if (rtl) + title = `${endTimeOnly} ${endDateOnly} ${EN_CHAR} ${startTimeOnly} ${startDateOnly}`; + else + title = `${startDateOnly} ${startTimeOnly} ${EN_CHAR} ${endDateOnly} ${endTimeOnly}`; + } else if (eventStart === eventEnd) { + title = startTimeOnly; + } else { + title = `${rtl ? endTimeOnly : startTimeOnly} ${EN_CHAR} ${rtl ? startTimeOnly : endTimeOnly}`; + } + + return title; + } + + _reloadEvents() { + if (this._eventSource.isLoading || this._reloading) + return; + + this._reloading = true; + + [...this._eventsList].forEach(c => c.destroy()); + + const events = + this._eventSource.getEvents(this._startDate, this._endDate); + + for (let event of events) { + const box = new St.BoxLayout({ + style_class: 'event-box', + vertical: true, + }); + box.add(new St.Label({ + text: event.summary, + style_class: 'event-summary', + })); + box.add(new St.Label({ + text: this._formatEventTime(event), + style_class: 'event-time', + })); + this._eventsList.add_child(box); + } + + if (this._eventsList.get_n_children() === 0) { + const placeholder = new St.Label({ + text: _('No Events'), + style_class: 'event-placeholder', + }); + this._eventsList.add_child(placeholder); + } + + this._reloading = false; + this._sync(); + } + + vfunc_clicked() { + Main.overview.hide(); + Main.panel.closeCalendar(); + + let appInfo = this._calendarApp; + if (appInfo.get_id() === 'org.gnome.Evolution.desktop') { + const app = this._appSys.lookup_app('evolution-calendar.desktop'); + if (app) + appInfo = app.app_info; + } + appInfo.launch([], global.create_app_launch_context(0, -1)); + } + + _appInstalledChanged() { + const apps = Gio.AppInfo.get_recommended_for_type('text/calendar'); + if (apps && (apps.length > 0)) { + const app = Gio.AppInfo.get_default_for_type('text/calendar', false); + const defaultInRecommended = apps.some(a => a.equal(app)); + this._calendarApp = defaultInRecommended ? app : apps[0]; + } else { + this._calendarApp = null; + } + + return this._sync(); + } + + _sync() { + this.visible = this._eventSource && this._eventSource.hasCalendars; + this.reactive = this._calendarApp !== null; + } +}); + +var WorldClocksSection = GObject.registerClass( +class WorldClocksSection extends St.Button { + _init() { + super._init({ + style_class: 'world-clocks-button', + can_focus: true, + x_expand: true, + }); + this._clock = new GnomeDesktop.WallClock(); + this._clockNotifyId = 0; + this._tzNotifyId = 0; + + this._locations = []; + + let layout = new Clutter.GridLayout({ orientation: Clutter.Orientation.VERTICAL }); + this._grid = new St.Widget({ + style_class: 'world-clocks-grid', + x_expand: true, + layout_manager: layout, + }); + layout.hookup_style(this._grid); + + this.child = this._grid; + + this._clocksApp = null; + this._clocksProxy = new ClocksProxy( + Gio.DBus.session, + 'org.gnome.clocks', + '/org/gnome/clocks', + this._onProxyReady.bind(this), + null /* cancellable */, + Gio.DBusProxyFlags.DO_NOT_AUTO_START | Gio.DBusProxyFlags.GET_INVALIDATED_PROPERTIES); + + this._settings = new Gio.Settings({ + schema_id: 'org.gnome.shell.world-clocks', + }); + this._settings.connect('changed', this._clocksChanged.bind(this)); + this._clocksChanged(); + + this._appSystem = Shell.AppSystem.get_default(); + this._appSystem.connect('installed-changed', + this._sync.bind(this)); + this._sync(); + } + + vfunc_clicked() { + if (this._clocksApp) + this._clocksApp.activate(); + + Main.overview.hide(); + Main.panel.closeCalendar(); + } + + _sync() { + this._clocksApp = this._appSystem.lookup_app('org.gnome.clocks.desktop'); + this.visible = this._clocksApp != null; + } + + _clocksChanged() { + this._grid.destroy_all_children(); + this._locations = []; + + let world = GWeather.Location.get_world(); + let clocks = this._settings.get_value('locations').deepUnpack(); + for (let i = 0; i < clocks.length; i++) { + let l = world.deserialize(clocks[i]); + if (l && l.get_timezone() != null) + this._locations.push({ location: l }); + } + + const unixtime = GLib.DateTime.new_now_local().to_unix(); + this._locations.sort((a, b) => { + const tzA = a.location.get_timezone(); + const tzB = b.location.get_timezone(); + const intA = tzA.find_interval(GLib.TimeType.STANDARD, unixtime); + const intB = tzB.find_interval(GLib.TimeType.STANDARD, unixtime); + return tzA.get_offset(intA) - tzB.get_offset(intB); + }); + + let layout = this._grid.layout_manager; + let title = this._locations.length == 0 + ? _("Add world clocks…") + : _("World Clocks"); + const header = new St.Label({ + style_class: 'world-clocks-header', + x_align: Clutter.ActorAlign.START, + text: title, + }); + if (this._grid.text_direction === Clutter.TextDirection.RTL) + layout.attach(header, 2, 0, 1, 1); + else + layout.attach(header, 0, 0, 2, 1); + this.label_actor = header; + + for (let i = 0; i < this._locations.length; i++) { + let l = this._locations[i].location; + + let name = l.get_city_name() || l.get_name(); + const label = new St.Label({ + style_class: 'world-clocks-city', + text: name, + x_align: Clutter.ActorAlign.START, + y_align: Clutter.ActorAlign.CENTER, + x_expand: true, + }); + + let time = new St.Label({ style_class: 'world-clocks-time' }); + + const tz = new St.Label({ + style_class: 'world-clocks-timezone', + x_align: Clutter.ActorAlign.END, + y_align: Clutter.ActorAlign.CENTER, + }); + + time.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + tz.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + + if (this._grid.text_direction == Clutter.TextDirection.RTL) { + layout.attach(tz, 0, i + 1, 1, 1); + layout.attach(time, 1, i + 1, 1, 1); + layout.attach(label, 2, i + 1, 1, 1); + } else { + layout.attach(label, 0, i + 1, 1, 1); + layout.attach(time, 1, i + 1, 1, 1); + layout.attach(tz, 2, i + 1, 1, 1); + } + + this._locations[i].timeLabel = time; + this._locations[i].tzLabel = tz; + } + + if (this._grid.get_n_children() > 1) { + if (!this._clockNotifyId) { + this._clockNotifyId = + this._clock.connect('notify::clock', this._updateTimeLabels.bind(this)); + } + if (!this._tzNotifyId) { + this._tzNotifyId = + this._clock.connect('notify::timezone', this._updateTimezoneLabels.bind(this)); + } + this._updateTimeLabels(); + this._updateTimezoneLabels(); + } else { + if (this._clockNotifyId) + this._clock.disconnect(this._clockNotifyId); + this._clockNotifyId = 0; + + if (this._tzNotifyId) + this._clock.disconnect(this._tzNotifyId); + this._tzNotifyId = 0; + } + } + + _getTimezoneOffsetAtLocation(location) { + const tz = location.get_timezone(); + const localOffset = GLib.DateTime.new_now_local().get_utc_offset(); + const utcOffset = GLib.DateTime.new_now(tz).get_utc_offset(); + const offsetCurrentTz = utcOffset - localOffset; + const offsetHours = + Math.floor(Math.abs(offsetCurrentTz) / GLib.TIME_SPAN_HOUR); + const offsetMinutes = + (Math.abs(offsetCurrentTz) % GLib.TIME_SPAN_HOUR) / + GLib.TIME_SPAN_MINUTE; + + const prefix = offsetCurrentTz >= 0 ? '+' : '-'; + const text = offsetMinutes === 0 + ? `${prefix}${offsetHours}` + : `${prefix}${offsetHours}\u2236${offsetMinutes}`; + return text; + } + + _updateTimeLabels() { + for (let i = 0; i < this._locations.length; i++) { + let l = this._locations[i]; + const now = GLib.DateTime.new_now(l.location.get_timezone()); + l.timeLabel.text = Util.formatTime(now, { timeOnly: true }); + } + } + + _updateTimezoneLabels() { + for (let i = 0; i < this._locations.length; i++) { + let l = this._locations[i]; + l.tzLabel.text = this._getTimezoneOffsetAtLocation(l.location); + } + } + + _onProxyReady(proxy, error) { + if (error) { + log(`Failed to create GNOME Clocks proxy: ${error}`); + return; + } + + this._clocksProxy.connect('g-properties-changed', + this._onClocksPropertiesChanged.bind(this)); + this._onClocksPropertiesChanged(); + } + + _onClocksPropertiesChanged() { + if (this._clocksProxy.g_name_owner == null) + return; + + this._settings.set_value('locations', + new GLib.Variant('av', this._clocksProxy.Locations)); + } +}); + +var WeatherSection = GObject.registerClass( +class WeatherSection extends St.Button { + _init() { + super._init({ + style_class: 'weather-button', + can_focus: true, + x_expand: true, + }); + + this._weatherClient = new Weather.WeatherClient(); + + let box = new St.BoxLayout({ + style_class: 'weather-box', + vertical: true, + x_expand: true, + }); + + this.child = box; + + let titleBox = new St.BoxLayout({ style_class: 'weather-header-box' }); + this._titleLabel = new St.Label({ + style_class: 'weather-header', + x_align: Clutter.ActorAlign.START, + x_expand: true, + y_align: Clutter.ActorAlign.END, + }); + titleBox.add_child(this._titleLabel); + box.add_child(titleBox); + + this._titleLocation = new St.Label({ + style_class: 'weather-header location', + x_align: Clutter.ActorAlign.END, + y_align: Clutter.ActorAlign.END, + }); + titleBox.add_child(this._titleLocation); + + let layout = new Clutter.GridLayout({ orientation: Clutter.Orientation.VERTICAL }); + this._forecastGrid = new St.Widget({ + style_class: 'weather-grid', + layout_manager: layout, + }); + layout.hookup_style(this._forecastGrid); + box.add_child(this._forecastGrid); + + this._weatherClient.connect('changed', this._sync.bind(this)); + this._sync(); + } + + vfunc_map() { + this._weatherClient.update(); + super.vfunc_map(); + } + + vfunc_clicked() { + this._weatherClient.activateApp(); + + Main.overview.hide(); + Main.panel.closeCalendar(); + } + + _getInfos() { + let forecasts = this._weatherClient.info.get_forecast_list(); + + let now = GLib.DateTime.new_now_local(); + let current = GLib.DateTime.new_from_unix_local(0); + let infos = []; + for (let i = 0; i < forecasts.length; i++) { + const [valid, timestamp] = forecasts[i].get_value_update(); + if (!valid || timestamp === 0) + continue; // 0 means 'never updated' + + const datetime = GLib.DateTime.new_from_unix_local(timestamp); + if (now.difference(datetime) > 0) + continue; // Ignore earlier forecasts + + if (datetime.difference(current) < GLib.TIME_SPAN_HOUR) + continue; // Enforce a minimum interval of 1h + + if (infos.push(forecasts[i]) == MAX_FORECASTS) + break; // Use a maximum of five forecasts + + current = datetime; + } + return infos; + } + + _addForecasts() { + let layout = this._forecastGrid.layout_manager; + + let infos = this._getInfos(); + if (this._forecastGrid.text_direction == Clutter.TextDirection.RTL) + infos.reverse(); + + let col = 0; + infos.forEach(fc => { + const [valid_, timestamp] = fc.get_value_update(); + let timeStr = Util.formatTime(new Date(timestamp * 1000), { + timeOnly: true, + ampm: false, + }); + const [, tempValue] = fc.get_value_temp(GWeather.TemperatureUnit.DEFAULT); + const tempPrefix = Math.round(tempValue) >= 0 ? ' ' : ''; + + let time = new St.Label({ + style_class: 'weather-forecast-time', + text: timeStr, + x_align: Clutter.ActorAlign.CENTER, + }); + let icon = new St.Icon({ + style_class: 'weather-forecast-icon', + icon_name: fc.get_symbolic_icon_name(), + x_align: Clutter.ActorAlign.CENTER, + x_expand: true, + }); + let temp = new St.Label({ + style_class: 'weather-forecast-temp', + text: `${tempPrefix}${Math.round(tempValue)}°`, + x_align: Clutter.ActorAlign.CENTER, + }); + + temp.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + time.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + + layout.attach(time, col, 0, 1, 1); + layout.attach(icon, col, 1, 1, 1); + layout.attach(temp, col, 2, 1, 1); + col++; + }); + } + + _setStatusLabel(text) { + let layout = this._forecastGrid.layout_manager; + let label = new St.Label({ text }); + layout.attach(label, 0, 0, 1, 1); + } + + _findBestLocationName(loc) { + const locName = loc.get_name(); + + if (loc.get_level() === GWeather.LocationLevel.CITY || + !loc.has_coords()) + return locName; + + const world = GWeather.Location.get_world(); + const city = world.find_nearest_city(...loc.get_coords()); + const cityName = city.get_name(); + + return locName.includes(cityName) ? cityName : locName; + } + + _updateForecasts() { + this._forecastGrid.destroy_all_children(); + + if (!this._weatherClient.hasLocation) + return; + + const { info } = this._weatherClient; + this._titleLocation.text = this._findBestLocationName(info.location); + + if (this._weatherClient.loading) { + this._setStatusLabel(_("Loading…")); + return; + } + + if (info.is_valid()) { + this._addForecasts(); + return; + } + + if (info.network_error()) + this._setStatusLabel(_("Go online for weather information")); + else + this._setStatusLabel(_("Weather information is currently unavailable")); + } + + _sync() { + this.visible = this._weatherClient.available; + + if (!this.visible) + return; + + if (this._weatherClient.hasLocation) + this._titleLabel.text = _('Weather'); + else + this._titleLabel.text = _('Select weather location…'); + + this._forecastGrid.visible = this._weatherClient.hasLocation; + this._titleLocation.visible = this._weatherClient.hasLocation; + + this._updateForecasts(); + } +}); + +var MessagesIndicator = GObject.registerClass( +class MessagesIndicator extends St.Icon { + _init() { + super._init({ + icon_size: 16, + visible: false, + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + + this._sources = []; + this._count = 0; + + this._settings = new Gio.Settings({ + schema_id: 'org.gnome.desktop.notifications', + }); + this._settings.connect('changed::show-banners', this._sync.bind(this)); + + Main.messageTray.connect('source-added', this._onSourceAdded.bind(this)); + Main.messageTray.connect('source-removed', this._onSourceRemoved.bind(this)); + Main.messageTray.connect('queue-changed', this._updateCount.bind(this)); + + let sources = Main.messageTray.getSources(); + sources.forEach(source => this._onSourceAdded(null, source)); + + this._sync(); + + this.connect('destroy', () => { + this._settings.run_dispose(); + this._settings = null; + }); + } + + _onSourceAdded(tray, source) { + source.connect('notify::count', this._updateCount.bind(this)); + this._sources.push(source); + this._updateCount(); + } + + _onSourceRemoved(tray, source) { + this._sources.splice(this._sources.indexOf(source), 1); + this._updateCount(); + } + + _updateCount() { + let count = 0; + this._sources.forEach(source => (count += source.unseenCount)); + this._count = count - Main.messageTray.queueCount; + + this._sync(); + } + + _sync() { + let doNotDisturb = !this._settings.get_boolean('show-banners'); + this.icon_name = doNotDisturb + ? 'notifications-disabled-symbolic' + : 'message-indicator-symbolic'; + this.visible = doNotDisturb || this._count > 0; + } +}); + +var FreezableBinLayout = GObject.registerClass( +class FreezableBinLayout extends Clutter.BinLayout { + _init() { + super._init(); + + this._frozen = false; + this._savedWidth = [NaN, NaN]; + this._savedHeight = [NaN, NaN]; + } + + set frozen(v) { + if (this._frozen == v) + return; + + this._frozen = v; + if (!this._frozen) + this.layout_changed(); + } + + vfunc_get_preferred_width(container, forHeight) { + if (!this._frozen || this._savedWidth.some(isNaN)) + return super.vfunc_get_preferred_width(container, forHeight); + return this._savedWidth; + } + + vfunc_get_preferred_height(container, forWidth) { + if (!this._frozen || this._savedHeight.some(isNaN)) + return super.vfunc_get_preferred_height(container, forWidth); + return this._savedHeight; + } + + vfunc_allocate(container, allocation) { + super.vfunc_allocate(container, allocation); + + let [width, height] = allocation.get_size(); + this._savedWidth = [width, width]; + this._savedHeight = [height, height]; + } +}); + +var CalendarColumnLayout = GObject.registerClass( +class CalendarColumnLayout extends Clutter.BoxLayout { + _init(actors) { + super._init({ orientation: Clutter.Orientation.VERTICAL }); + this._colActors = actors; + } + + vfunc_get_preferred_width(container, forHeight) { + const actors = + this._colActors.filter(a => a.get_parent() === container); + if (actors.length === 0) + return super.vfunc_get_preferred_width(container, forHeight); + return actors.reduce(([minAcc, natAcc], child) => { + const [min, nat] = child.get_preferred_width(forHeight); + return [Math.max(minAcc, min), Math.max(natAcc, nat)]; + }, [0, 0]); + } +}); + +var DateMenuButton = GObject.registerClass( +class DateMenuButton extends PanelMenu.Button { + _init() { + let hbox; + + super._init(0.5); + + this._clockDisplay = new St.Label({ style_class: 'clock' }); + this._clockDisplay.clutter_text.y_align = Clutter.ActorAlign.CENTER; + this._clockDisplay.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + + this._indicator = new MessagesIndicator(); + + const indicatorPad = new St.Widget(); + this._indicator.bind_property('visible', + indicatorPad, 'visible', + GObject.BindingFlags.SYNC_CREATE); + indicatorPad.add_constraint(new Clutter.BindConstraint({ + source: this._indicator, + coordinate: Clutter.BindCoordinate.SIZE, + })); + + let box = new St.BoxLayout({ style_class: 'clock-display-box' }); + box.add_actor(indicatorPad); + box.add_actor(this._clockDisplay); + box.add_actor(this._indicator); + + this.label_actor = this._clockDisplay; + this.add_actor(box); + this.add_style_class_name('clock-display'); + + let layout = new FreezableBinLayout(); + let bin = new St.Widget({ layout_manager: layout }); + // For some minimal compatibility with PopupMenuItem + bin._delegate = this; + this.menu.box.add_child(bin); + + hbox = new St.BoxLayout({ name: 'calendarArea' }); + bin.add_actor(hbox); + + this._calendar = new Calendar.Calendar(); + this._calendar.connect('selected-date-changed', (_calendar, datetime) => { + let date = _gDateTimeToDate(datetime); + layout.frozen = !_isToday(date); + this._eventsItem.setDate(date); + }); + this._date = new TodayButton(this._calendar); + + this.menu.connect('open-state-changed', (menu, isOpen) => { + // Whenever the menu is opened, select today + if (isOpen) { + let now = new Date(); + this._calendar.setDate(now); + this._date.setDate(now); + this._eventsItem.setDate(now); + } + }); + + // Fill up the first column + this._messageList = new Calendar.CalendarMessageList(); + hbox.add_child(this._messageList); + + // Fill up the second column + const boxLayout = new CalendarColumnLayout([this._calendar, this._date]); + const vbox = new St.Widget({ + style_class: 'datemenu-calendar-column', + layout_manager: boxLayout, + }); + boxLayout.hookup_style(vbox); + hbox.add(vbox); + + vbox.add_actor(this._date); + vbox.add_actor(this._calendar); + + this._displaysSection = new St.ScrollView({ + style_class: 'datemenu-displays-section vfade', + x_expand: true, + overlay_scrollbars: true, + }); + this._displaysSection.set_policy(St.PolicyType.NEVER, St.PolicyType.EXTERNAL); + vbox.add_actor(this._displaysSection); + + const displaysBox = new St.BoxLayout({ + vertical: true, + x_expand: true, + style_class: 'datemenu-displays-box', + }); + this._displaysSection.add_actor(displaysBox); + + this._eventsItem = new EventsSection(); + displaysBox.add_child(this._eventsItem); + + this._clocksItem = new WorldClocksSection(); + displaysBox.add_child(this._clocksItem); + + this._weatherItem = new WeatherSection(); + displaysBox.add_child(this._weatherItem); + + // Done with hbox for calendar and event list + + this._clock = new GnomeDesktop.WallClock(); + this._clock.bind_property('clock', this._clockDisplay, 'text', GObject.BindingFlags.SYNC_CREATE); + this._clock.connect('notify::timezone', this._updateTimeZone.bind(this)); + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + this._sessionUpdated(); + } + + _getEventSource() { + return new Calendar.DBusEventSource(); + } + + _setEventSource(eventSource) { + if (this._eventSource) + this._eventSource.destroy(); + + this._calendar.setEventSource(eventSource); + this._eventsItem.setEventSource(eventSource); + + this._eventSource = eventSource; + } + + _updateTimeZone() { + // SpiderMonkey caches the time zone so we must explicitly clear it + // before we can update the calendar, see + // https://bugzilla.gnome.org/show_bug.cgi?id=678507 + System.clearDateCaches(); + + this._calendar.updateTimeZone(); + } + + _sessionUpdated() { + let eventSource; + let showEvents = Main.sessionMode.showCalendarEvents; + if (showEvents) + eventSource = this._getEventSource(); + else + eventSource = new Calendar.EmptyEventSource(); + + this._setEventSource(eventSource); + + // Displays are not actually expected to launch Settings when activated + // but the corresponding app (clocks, weather); however we can consider + // that display-specific settings, so re-use "allowSettings" here ... + this._displaysSection.visible = Main.sessionMode.allowSettings; + } +}); diff --git a/js/ui/dialog.js b/js/ui/dialog.js new file mode 100644 index 0000000..414a3e4 --- /dev/null +++ b/js/ui/dialog.js @@ -0,0 +1,359 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Dialog, MessageDialogContent, ListSection, ListSectionItem */ + +const { Clutter, GLib, GObject, Meta, Pango, St } = imports.gi; + +function _setLabel(label, value) { + label.set({ + text: value || '', + visible: value !== null, + }); +} + +var Dialog = GObject.registerClass( +class Dialog extends St.Widget { + _init(parentActor, styleClass) { + super._init({ + layout_manager: new Clutter.BinLayout(), + reactive: true, + }); + this.connect('destroy', this._onDestroy.bind(this)); + + this._initialKeyFocus = null; + this._pressedKey = null; + this._buttonKeys = {}; + this._createDialog(); + this.add_child(this._dialog); + + if (styleClass != null) + this._dialog.add_style_class_name(styleClass); + + this._parentActor = parentActor; + this._parentActor.add_child(this); + } + + _createDialog() { + this._dialog = new St.BoxLayout({ + style_class: 'modal-dialog', + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + vertical: true, + }); + + // modal dialogs are fixed width and grow vertically; set the request + // mode accordingly so wrapped labels are handled correctly during + // size requests. + this._dialog.request_mode = Clutter.RequestMode.HEIGHT_FOR_WIDTH; + this._dialog.set_offscreen_redirect(Clutter.OffscreenRedirect.ALWAYS); + + this.contentLayout = new St.BoxLayout({ + vertical: true, + style_class: 'modal-dialog-content-box', + y_expand: true, + }); + this._dialog.add_child(this.contentLayout); + + this.buttonLayout = new St.Widget({ + layout_manager: new Clutter.BoxLayout({ homogeneous: true }), + }); + this._dialog.add_child(this.buttonLayout); + } + + makeInactive() { + this.buttonLayout.get_children().forEach(c => c.set_reactive(false)); + } + + _onDestroy() { + this.makeInactive(); + } + + vfunc_event(event) { + if (event.type() == Clutter.EventType.KEY_PRESS) { + this._pressedKey = event.get_key_symbol(); + } else if (event.type() == Clutter.EventType.KEY_RELEASE) { + let pressedKey = this._pressedKey; + this._pressedKey = null; + + let symbol = event.get_key_symbol(); + if (symbol != pressedKey) + return Clutter.EVENT_PROPAGATE; + + let buttonInfo = this._buttonKeys[symbol]; + if (!buttonInfo) + return Clutter.EVENT_PROPAGATE; + + let { button, action } = buttonInfo; + + if (action && button.reactive) { + action(); + return Clutter.EVENT_STOP; + } + } + + return Clutter.EVENT_PROPAGATE; + } + + _setInitialKeyFocus(actor) { + this._initialKeyFocus?.disconnectObject(this); + + this._initialKeyFocus = actor; + + actor.connectObject('destroy', + () => (this._initialKeyFocus = null), this); + } + + get initialKeyFocus() { + return this._initialKeyFocus || this; + } + + addButton(buttonInfo) { + let { label, action, key } = buttonInfo; + let isDefault = buttonInfo['default']; + let keys; + + if (key) + keys = [key]; + else if (isDefault) + keys = [Clutter.KEY_Return, Clutter.KEY_KP_Enter, Clutter.KEY_ISO_Enter]; + else + keys = []; + + let button = new St.Button({ + style_class: 'modal-dialog-linked-button', + button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE, + reactive: true, + can_focus: true, + x_expand: true, + y_expand: true, + label, + }); + button.connect('clicked', () => action()); + + buttonInfo['button'] = button; + + if (isDefault) + button.add_style_pseudo_class('default'); + + if (this._initialKeyFocus == null || isDefault) + this._setInitialKeyFocus(button); + + for (let i in keys) + this._buttonKeys[keys[i]] = buttonInfo; + + this.buttonLayout.add_actor(button); + + return button; + } + + clearButtons() { + this.buttonLayout.destroy_all_children(); + this._buttonKeys = {}; + } +}); + +var MessageDialogContent = GObject.registerClass({ + Properties: { + 'title': GObject.ParamSpec.string( + 'title', 'title', 'title', + GObject.ParamFlags.READWRITE | + GObject.ParamFlags.CONSTRUCT, + null), + 'description': GObject.ParamSpec.string( + 'description', 'description', 'description', + GObject.ParamFlags.READWRITE | + GObject.ParamFlags.CONSTRUCT, + null), + }, +}, class MessageDialogContent extends St.BoxLayout { + _init(params) { + this._title = new St.Label({ style_class: 'message-dialog-title' }); + this._description = new St.Label({ style_class: 'message-dialog-description' }); + + this._description.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._description.clutter_text.line_wrap = true; + + let defaultParams = { + style_class: 'message-dialog-content', + x_expand: true, + vertical: true, + }; + super._init(Object.assign(defaultParams, params)); + + this.connect('notify::size', this._updateTitleStyle.bind(this)); + this.connect('destroy', this._onDestroy.bind(this)); + + this.add_child(this._title); + this.add_child(this._description); + } + + _onDestroy() { + if (this._updateTitleStyleLater) { + Meta.later_remove(this._updateTitleStyleLater); + delete this._updateTitleStyleLater; + } + } + + get title() { + return this._title.text; + } + + get description() { + return this._description.text; + } + + _updateTitleStyle() { + if (!this._title.mapped) + return; + + this._title.ensure_style(); + const [, titleNatWidth] = this._title.get_preferred_width(-1); + + if (titleNatWidth > this.width) { + if (this._updateTitleStyleLater) + return; + + this._updateTitleStyleLater = Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + this._updateTitleStyleLater = 0; + this._title.add_style_class_name('lightweight'); + return GLib.SOURCE_REMOVE; + }); + } + } + + set title(title) { + if (this._title.text === title) + return; + + _setLabel(this._title, title); + + this._title.remove_style_class_name('lightweight'); + this._updateTitleStyle(); + + this.notify('title'); + } + + set description(description) { + if (this._description.text === description) + return; + + _setLabel(this._description, description); + this.notify('description'); + } +}); + +var ListSection = GObject.registerClass({ + Properties: { + 'title': GObject.ParamSpec.string( + 'title', 'title', 'title', + GObject.ParamFlags.READWRITE | + GObject.ParamFlags.CONSTRUCT, + null), + }, +}, class ListSection extends St.BoxLayout { + _init(params) { + this._title = new St.Label({ style_class: 'dialog-list-title' }); + + this._listScrollView = new St.ScrollView({ + style_class: 'dialog-list-scrollview', + hscrollbar_policy: St.PolicyType.NEVER, + }); + + this.list = new St.BoxLayout({ + style_class: 'dialog-list-box', + vertical: true, + }); + this._listScrollView.add_actor(this.list); + + let defaultParams = { + style_class: 'dialog-list', + x_expand: true, + vertical: true, + }; + super._init(Object.assign(defaultParams, params)); + + this.label_actor = this._title; + this.add_child(this._title); + this.add_child(this._listScrollView); + } + + get title() { + return this._title.text; + } + + set title(title) { + _setLabel(this._title, title); + this.notify('title'); + } +}); + +var ListSectionItem = GObject.registerClass({ + Properties: { + 'icon-actor': GObject.ParamSpec.object( + 'icon-actor', 'icon-actor', 'Icon actor', + GObject.ParamFlags.READWRITE, + Clutter.Actor.$gtype), + 'title': GObject.ParamSpec.string( + 'title', 'title', 'title', + GObject.ParamFlags.READWRITE | + GObject.ParamFlags.CONSTRUCT, + null), + 'description': GObject.ParamSpec.string( + 'description', 'description', 'description', + GObject.ParamFlags.READWRITE | + GObject.ParamFlags.CONSTRUCT, + null), + }, +}, class ListSectionItem extends St.BoxLayout { + _init(params) { + this._iconActorBin = new St.Bin(); + + let textLayout = new St.BoxLayout({ + vertical: true, + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + + this._title = new St.Label({ style_class: 'dialog-list-item-title' }); + + this._description = new St.Label({ + style_class: 'dialog-list-item-title-description', + }); + + textLayout.add_child(this._title); + textLayout.add_child(this._description); + + let defaultParams = { style_class: 'dialog-list-item' }; + super._init(Object.assign(defaultParams, params)); + + this.label_actor = this._title; + this.add_child(this._iconActorBin); + this.add_child(textLayout); + } + + get iconActor() { + return this._iconActorBin.get_child(); + } + + set iconActor(actor) { + this._iconActorBin.set_child(actor); + this.notify('icon-actor'); + } + + get title() { + return this._title.text; + } + + set title(title) { + _setLabel(this._title, title); + this.notify('title'); + } + + get description() { + return this._description.text; + } + + set description(description) { + _setLabel(this._description, description); + this.notify('description'); + } +}); diff --git a/js/ui/dnd.js b/js/ui/dnd.js new file mode 100644 index 0000000..613402d --- /dev/null +++ b/js/ui/dnd.js @@ -0,0 +1,841 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported addDragMonitor, removeDragMonitor, makeDraggable */ + +const { Clutter, GLib, Meta, Shell, St } = imports.gi; +const Signals = imports.misc.signals; + +const Main = imports.ui.main; +const Params = imports.misc.params; + +// Time to scale down to maxDragActorSize +var SCALE_ANIMATION_TIME = 250; +// Time to animate to original position on cancel +var SNAP_BACK_ANIMATION_TIME = 250; +// Time to animate to original position on success +var REVERT_ANIMATION_TIME = 750; + +var DragMotionResult = { + NO_DROP: 0, + COPY_DROP: 1, + MOVE_DROP: 2, + CONTINUE: 3, +}; + +var DragState = { + INIT: 0, + DRAGGING: 1, + CANCELLED: 2, +}; + +var DRAG_CURSOR_MAP = { + 0: Meta.Cursor.DND_UNSUPPORTED_TARGET, + 1: Meta.Cursor.DND_COPY, + 2: Meta.Cursor.DND_MOVE, +}; + +var DragDropResult = { + FAILURE: 0, + SUCCESS: 1, + CONTINUE: 2, +}; +var dragMonitors = []; + +let eventHandlerActor = null; +let currentDraggable = null; + +function _getEventHandlerActor() { + if (!eventHandlerActor) { + eventHandlerActor = new Clutter.Actor({ width: 0, height: 0, reactive: true }); + Main.uiGroup.add_actor(eventHandlerActor); + // We connect to 'event' rather than 'captured-event' because the capturing phase doesn't happen + // when you've grabbed the pointer. + eventHandlerActor.connect('event', (actor, event) => { + return currentDraggable._onEvent(actor, event); + }); + } + return eventHandlerActor; +} + +function _getRealActorScale(actor) { + let scale = 1.0; + while (actor) { + scale *= actor.scale_x; + actor = actor.get_parent(); + } + return scale; +} + +function addDragMonitor(monitor) { + dragMonitors.push(monitor); +} + +function removeDragMonitor(monitor) { + for (let i = 0; i < dragMonitors.length; i++) { + if (dragMonitors[i] == monitor) { + dragMonitors.splice(i, 1); + return; + } + } +} + +var _Draggable = class _Draggable extends Signals.EventEmitter { + constructor(actor, params) { + super(); + + params = Params.parse(params, { + manualMode: false, + timeoutThreshold: 0, + restoreOnSuccess: false, + dragActorMaxSize: undefined, + dragActorOpacity: undefined, + }); + + this.actor = actor; + this._dragState = DragState.INIT; + + if (!params.manualMode) { + this.actor.connect('button-press-event', + this._onButtonPress.bind(this)); + this.actor.connect('touch-event', + this._onTouchEvent.bind(this)); + } + + this.actor.connect('destroy', () => { + this._actorDestroyed = true; + + if (this._dragState == DragState.DRAGGING && this._dragCancellable) + this._cancelDrag(global.get_current_time()); + this.disconnectAll(); + }); + this._onEventId = null; + this._touchSequence = null; + + this._restoreOnSuccess = params.restoreOnSuccess; + this._dragActorMaxSize = params.dragActorMaxSize; + this._dragActorOpacity = params.dragActorOpacity; + this._dragTimeoutThreshold = params.timeoutThreshold; + + this._buttonDown = false; // The mouse button has been pressed and has not yet been released. + this._animationInProgress = false; // The drag is over and the item is in the process of animating to its original position (snapping back or reverting). + this._dragCancellable = true; + } + + _onButtonPress(actor, event) { + if (event.get_button() != 1) + return Clutter.EVENT_PROPAGATE; + + this._buttonDown = true; + this._grabActor(event.get_device()); + + let [stageX, stageY] = event.get_coords(); + this._dragStartX = stageX; + this._dragStartY = stageY; + this._dragStartTime = event.get_time(); + this._dragThresholdIgnored = false; + + return Clutter.EVENT_PROPAGATE; + } + + _onTouchEvent(actor, event) { + // We only handle touch events here on wayland. On X11 + // we do get emulated pointer events, which already works + // for single-touch cases. Besides, the X11 passive touch grab + // set up by Mutter will make us see first the touch events + // and later the pointer events, so it will look like two + // unrelated series of events, we want to avoid double handling + // in these cases. + if (!Meta.is_wayland_compositor()) + return Clutter.EVENT_PROPAGATE; + + if (event.type() != Clutter.EventType.TOUCH_BEGIN || + !global.display.is_pointer_emulating_sequence(event.get_event_sequence())) + return Clutter.EVENT_PROPAGATE; + + this._buttonDown = true; + this._grabActor(event.get_device(), event.get_event_sequence()); + this._dragStartTime = event.get_time(); + this._dragThresholdIgnored = false; + + let [stageX, stageY] = event.get_coords(); + this._dragStartX = stageX; + this._dragStartY = stageY; + + return Clutter.EVENT_PROPAGATE; + } + + _grabDevice(actor, pointer, touchSequence) { + this._grab = global.stage.grab(actor); + this._grabbedDevice = pointer; + this._touchSequence = touchSequence; + } + + _ungrabDevice() { + if (this._grab) { + this._grab.dismiss(); + this._grab = null; + } + this._touchSequence = null; + this._grabbedDevice = null; + } + + _grabActor(device, touchSequence) { + this._grabDevice(this.actor, device, touchSequence); + this._onEventId = this.actor.connect('event', + this._onEvent.bind(this)); + } + + _ungrabActor() { + if (!this._onEventId) + return; + + this._ungrabDevice(); + this.actor.disconnect(this._onEventId); + this._onEventId = null; + } + + _grabEvents(device, touchSequence) { + if (!this._eventsGrab) { + let grab = Main.pushModal(_getEventHandlerActor()); + if ((grab.get_seat_state() & Clutter.GrabState.POINTER) !== 0) { + this._grabDevice(_getEventHandlerActor(), device, touchSequence); + this._eventsGrab = grab; + } else { + Main.popModal(grab); + } + } + } + + _ungrabEvents() { + if (this._eventsGrab) { + this._ungrabDevice(); + Main.popModal(this._eventsGrab); + this._eventsGrab = null; + } + } + + _eventIsRelease(event) { + if (event.type() == Clutter.EventType.BUTTON_RELEASE) { + let buttonMask = Clutter.ModifierType.BUTTON1_MASK | + Clutter.ModifierType.BUTTON2_MASK | + Clutter.ModifierType.BUTTON3_MASK; + /* We only obey the last button release from the device, + * other buttons may get pressed/released during the DnD op. + */ + return (event.get_state() & buttonMask) == 0; + } else if (event.type() == Clutter.EventType.TOUCH_END) { + /* For touch, we only obey the pointer emulating sequence */ + return global.display.is_pointer_emulating_sequence(event.get_event_sequence()); + } + + return false; + } + + _onEvent(actor, event) { + let device = event.get_device(); + + if (this._grabbedDevice && + device != this._grabbedDevice && + device.get_device_type() != Clutter.InputDeviceType.KEYBOARD_DEVICE) + return Clutter.EVENT_PROPAGATE; + + // We intercept BUTTON_RELEASE event to know that the button was released in case we + // didn't start the drag, to drop the draggable in case the drag was in progress, and + // to complete the drag and ensure that whatever happens to be under the pointer does + // not get triggered if the drag was cancelled with Esc. + if (this._eventIsRelease(event)) { + this._buttonDown = false; + if (this._dragState == DragState.DRAGGING) { + return this._dragActorDropped(event); + } else if ((this._dragActor != null || this._dragState == DragState.CANCELLED) && + !this._animationInProgress) { + // Drag must have been cancelled with Esc. + this._dragComplete(); + return Clutter.EVENT_STOP; + } else { + // Drag has never started. + this._ungrabActor(); + return Clutter.EVENT_PROPAGATE; + } + // We intercept MOTION event to figure out if the drag has started and to draw + // this._dragActor under the pointer when dragging is in progress + } else if (event.type() == Clutter.EventType.MOTION || + (event.type() == Clutter.EventType.TOUCH_UPDATE && + global.display.is_pointer_emulating_sequence(event.get_event_sequence()))) { + if (this._dragActor && this._dragState == DragState.DRAGGING) + return this._updateDragPosition(event); + else if (this._dragActor == null && this._dragState != DragState.CANCELLED) + return this._maybeStartDrag(event); + + // We intercept KEY_PRESS event so that we can process Esc key press to cancel + // dragging and ignore all other key presses. + } else if (event.type() == Clutter.EventType.KEY_PRESS && this._dragState == DragState.DRAGGING) { + let symbol = event.get_key_symbol(); + if (symbol == Clutter.KEY_Escape) { + this._cancelDrag(event.get_time()); + return Clutter.EVENT_STOP; + } + } + + return Clutter.EVENT_PROPAGATE; + } + + /** + * fakeRelease: + * + * Fake a release event. + * Must be called if you want to intercept release events on draggable + * actors for other purposes (for example if you're using + * PopupMenu.ignoreRelease()) + */ + fakeRelease() { + this._buttonDown = false; + this._ungrabActor(); + } + + /** + * startDrag: + * @param {number} stageX: X coordinate of event + * @param {number} stageY: Y coordinate of event + * @param {number} time: Event timestamp + * @param {Clutter.EventSequence=} sequence: Event sequence + * @param {Clutter.InputDevice=} device: device that originated the event + * + * Directly initiate a drag and drop operation from the given actor. + * This function is useful to call if you've specified manualMode + * for the draggable. + */ + startDrag(stageX, stageY, time, sequence, device) { + if (currentDraggable) + return; + + if (device == undefined) { + let event = Clutter.get_current_event(); + + if (event) + device = event.get_device(); + + if (device == undefined) { + let seat = Clutter.get_default_backend().get_default_seat(); + device = seat.get_pointer(); + } + } + + currentDraggable = this; + this._dragState = DragState.DRAGGING; + + // Special-case St.Button: the pointer grab messes with the internal + // state, so force a reset to a reasonable state here + if (this.actor instanceof St.Button) { + this.actor.fake_release(); + this.actor.hover = false; + } + + this.emit('drag-begin', time); + if (this._onEventId) + this._ungrabActor(); + + this._grabEvents(device, sequence); + global.display.set_cursor(Meta.Cursor.DND_IN_DRAG); + + this._dragX = this._dragStartX = stageX; + this._dragY = this._dragStartY = stageY; + + let scaledWidth, scaledHeight; + + if (this.actor._delegate && this.actor._delegate.getDragActor) { + this._dragActor = this.actor._delegate.getDragActor(); + Main.uiGroup.add_child(this._dragActor); + Main.uiGroup.set_child_above_sibling(this._dragActor, null); + Shell.util_set_hidden_from_pick(this._dragActor, true); + + // Drag actor does not always have to be the same as actor. For example drag actor + // can be an image that's part of the actor. So to perform "snap back" correctly we need + // to know what was the drag actor source. + if (this.actor._delegate.getDragActorSource) { + this._dragActorSource = this.actor._delegate.getDragActorSource(); + // If the user dragged from the source, then position + // the dragActor over it. Otherwise, center it + // around the pointer + let [sourceX, sourceY] = this._dragActorSource.get_transformed_position(); + let x, y; + if (stageX > sourceX && stageX <= sourceX + this._dragActor.width && + stageY > sourceY && stageY <= sourceY + this._dragActor.height) { + x = sourceX; + y = sourceY; + } else { + x = stageX - this._dragActor.width / 2; + y = stageY - this._dragActor.height / 2; + } + this._dragActor.set_position(x, y); + + this._dragActorSourceDestroyId = this._dragActorSource.connect('destroy', () => { + this._dragActorSource = null; + }); + } else { + this._dragActorSource = this.actor; + } + this._dragOrigParent = undefined; + + this._dragOffsetX = this._dragActor.x - this._dragStartX; + this._dragOffsetY = this._dragActor.y - this._dragStartY; + + [scaledWidth, scaledHeight] = this._dragActor.get_transformed_size(); + } else { + this._dragActor = this.actor; + + this._dragActorSource = undefined; + this._dragOrigParent = this.actor.get_parent(); + this._dragActorHadFixedPos = this._dragActor.fixed_position_set; + this._dragOrigX = this._dragActor.allocation.x1; + this._dragOrigY = this._dragActor.allocation.y1; + this._dragActorHadNatWidth = this._dragActor.natural_width_set; + this._dragActorHadNatHeight = this._dragActor.natural_height_set; + this._dragOrigWidth = this._dragActor.allocation.get_width(); + this._dragOrigHeight = this._dragActor.allocation.get_height(); + this._dragOrigScale = this._dragActor.scale_x; + + // Ensure actors with an allocation smaller than their natural size + // retain their size + this._dragActor.set_size(...this._dragActor.allocation.get_size()); + + const transformedExtents = this._dragActor.get_transformed_extents(); + + this._dragOffsetX = transformedExtents.origin.x - this._dragStartX; + this._dragOffsetY = transformedExtents.origin.y - this._dragStartY; + + scaledWidth = transformedExtents.get_width(); + scaledHeight = transformedExtents.get_height(); + + this._dragActor.scale_x = scaledWidth / this._dragOrigWidth; + this._dragActor.scale_y = scaledHeight / this._dragOrigHeight; + + this._dragOrigParent.remove_actor(this._dragActor); + Main.uiGroup.add_child(this._dragActor); + Main.uiGroup.set_child_above_sibling(this._dragActor, null); + Shell.util_set_hidden_from_pick(this._dragActor, true); + + this._dragOrigParentDestroyId = this._dragOrigParent.connect('destroy', () => { + this._dragOrigParent = null; + }); + } + + this._dragActorDestroyId = this._dragActor.connect('destroy', () => { + // Cancel ongoing animation (if any) + this._finishAnimation(); + + this._dragActor = null; + if (this._dragState == DragState.DRAGGING) + this._dragState = DragState.CANCELLED; + }); + this._dragOrigOpacity = this._dragActor.opacity; + if (this._dragActorOpacity != undefined) + this._dragActor.opacity = this._dragActorOpacity; + + this._snapBackX = this._dragStartX + this._dragOffsetX; + this._snapBackY = this._dragStartY + this._dragOffsetY; + this._snapBackScale = this._dragActor.scale_x; + + let origDragOffsetX = this._dragOffsetX; + let origDragOffsetY = this._dragOffsetY; + let [transX, transY] = this._dragActor.get_translation(); + this._dragOffsetX -= transX; + this._dragOffsetY -= transY; + + this._dragActor.set_position( + this._dragX + this._dragOffsetX, + this._dragY + this._dragOffsetY); + + if (this._dragActorMaxSize != undefined) { + let currentSize = Math.max(scaledWidth, scaledHeight); + if (currentSize > this._dragActorMaxSize) { + let scale = this._dragActorMaxSize / currentSize; + let origScale = this._dragActor.scale_x; + + // The position of the actor changes as we scale + // around the drag position, but we can't just tween + // to the final position because that tween would + // fight with updates as the user continues dragging + // the mouse; instead we do the position computations in + // a ::new-frame handler. + this._dragActor.ease({ + scale_x: scale * origScale, + scale_y: scale * origScale, + duration: SCALE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._updateActorPosition(origScale, + origDragOffsetX, origDragOffsetY, transX, transY); + }, + }); + + this._dragActor.get_transition('scale-x').connect('new-frame', () => { + this._updateActorPosition(origScale, + origDragOffsetX, origDragOffsetY, transX, transY); + }); + } + } + } + + _updateActorPosition(origScale, origDragOffsetX, origDragOffsetY, transX, transY) { + const currentScale = this._dragActor.scale_x / origScale; + this._dragOffsetX = currentScale * origDragOffsetX - transX; + this._dragOffsetY = currentScale * origDragOffsetY - transY; + this._dragActor.set_position( + this._dragX + this._dragOffsetX, + this._dragY + this._dragOffsetY); + } + + _maybeStartDrag(event) { + let [stageX, stageY] = event.get_coords(); + + if (this._dragThresholdIgnored) + return Clutter.EVENT_PROPAGATE; + + // See if the user has moved the mouse enough to trigger a drag + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + let threshold = St.Settings.get().drag_threshold * scaleFactor; + if (!currentDraggable && + (Math.abs(stageX - this._dragStartX) > threshold || + Math.abs(stageY - this._dragStartY) > threshold)) { + const deviceType = event.get_source_device().get_device_type(); + const isPointerOrTouchpad = + deviceType === Clutter.InputDeviceType.POINTER_DEVICE || + deviceType === Clutter.InputDeviceType.TOUCHPAD_DEVICE; + const ellapsedTime = event.get_time() - this._dragStartTime; + + // Pointer devices (e.g. mouse) start the drag immediately + if (isPointerOrTouchpad || ellapsedTime > this._dragTimeoutThreshold) { + this.startDrag(stageX, stageY, event.get_time(), this._touchSequence, event.get_device()); + this._updateDragPosition(event); + } else { + this._dragThresholdIgnored = true; + this._ungrabActor(); + return Clutter.EVENT_PROPAGATE; + } + } + + return Clutter.EVENT_STOP; + } + + _pickTargetActor() { + return this._dragActor.get_stage().get_actor_at_pos(Clutter.PickMode.ALL, + this._dragX, this._dragY); + } + + _updateDragHover() { + this._updateHoverId = 0; + let target = this._pickTargetActor(); + + let dragEvent = { + x: this._dragX, + y: this._dragY, + dragActor: this._dragActor, + source: this.actor._delegate, + targetActor: target, + }; + + let targetActorDestroyHandlerId; + let handleTargetActorDestroyClosure; + handleTargetActorDestroyClosure = () => { + target = this._pickTargetActor(); + dragEvent.targetActor = target; + targetActorDestroyHandlerId = + target.connect('destroy', handleTargetActorDestroyClosure); + }; + targetActorDestroyHandlerId = + target.connect('destroy', handleTargetActorDestroyClosure); + + for (let i = 0; i < dragMonitors.length; i++) { + let motionFunc = dragMonitors[i].dragMotion; + if (motionFunc) { + let result = motionFunc(dragEvent); + if (result != DragMotionResult.CONTINUE) { + global.display.set_cursor(DRAG_CURSOR_MAP[result]); + dragEvent.targetActor.disconnect(targetActorDestroyHandlerId); + return GLib.SOURCE_REMOVE; + } + } + } + dragEvent.targetActor.disconnect(targetActorDestroyHandlerId); + + while (target) { + if (target._delegate && target._delegate.handleDragOver) { + let [r_, targX, targY] = target.transform_stage_point(this._dragX, this._dragY); + // We currently loop through all parents on drag-over even if one of the children has handled it. + // We can check the return value of the function and break the loop if it's true if we don't want + // to continue checking the parents. + let result = target._delegate.handleDragOver(this.actor._delegate, + this._dragActor, + targX, + targY, + 0); + if (result != DragMotionResult.CONTINUE) { + global.display.set_cursor(DRAG_CURSOR_MAP[result]); + return GLib.SOURCE_REMOVE; + } + } + target = target.get_parent(); + } + global.display.set_cursor(Meta.Cursor.DND_IN_DRAG); + return GLib.SOURCE_REMOVE; + } + + _queueUpdateDragHover() { + if (this._updateHoverId) + return; + + this._updateHoverId = GLib.idle_add(GLib.PRIORITY_DEFAULT, + this._updateDragHover.bind(this)); + GLib.Source.set_name_by_id(this._updateHoverId, '[gnome-shell] this._updateDragHover'); + } + + _updateDragPosition(event) { + let [stageX, stageY] = event.get_coords(); + this._dragX = stageX; + this._dragY = stageY; + this._dragActor.set_position(stageX + this._dragOffsetX, + stageY + this._dragOffsetY); + + this._queueUpdateDragHover(); + return true; + } + + _dragActorDropped(event) { + let [dropX, dropY] = event.get_coords(); + let target = this._dragActor.get_stage().get_actor_at_pos(Clutter.PickMode.ALL, + dropX, dropY); + + // We call observers only once per motion with the innermost + // target actor. If necessary, the observer can walk the + // parent itself. + let dropEvent = { + dropActor: this._dragActor, + targetActor: target, + clutterEvent: event, + }; + for (let i = 0; i < dragMonitors.length; i++) { + let dropFunc = dragMonitors[i].dragDrop; + if (dropFunc) { + switch (dropFunc(dropEvent)) { + case DragDropResult.FAILURE: + case DragDropResult.SUCCESS: + return true; + case DragDropResult.CONTINUE: + continue; + } + } + } + + // At this point it is too late to cancel a drag by destroying + // the actor, the fate of which is decided by acceptDrop and its + // side-effects + this._dragCancellable = false; + + while (target) { + if (target._delegate && target._delegate.acceptDrop) { + let [r_, targX, targY] = target.transform_stage_point(dropX, dropY); + let accepted = false; + try { + accepted = target._delegate.acceptDrop(this.actor._delegate, + this._dragActor, targX, targY, event.get_time()); + } catch (e) { + // On error, skip this target + logError(e, "Skipping drag target"); + } + if (accepted) { + // If it accepted the drop without taking the actor, + // handle it ourselves. + if (this._dragActor && this._dragActor.get_parent() == Main.uiGroup) { + if (this._restoreOnSuccess) { + this._restoreDragActor(event.get_time()); + return true; + } else { + this._dragActor.destroy(); + } + } + + this._dragState = DragState.INIT; + global.display.set_cursor(Meta.Cursor.DEFAULT); + this.emit('drag-end', event.get_time(), true); + this._dragComplete(); + return true; + } + } + target = target.get_parent(); + } + + this._cancelDrag(event.get_time()); + + return true; + } + + _getRestoreLocation() { + let x, y, scale; + + if (this._dragActorSource && this._dragActorSource.visible) { + // Snap the clone back to its source + [x, y] = this._dragActorSource.get_transformed_position(); + let [sourceScaledWidth] = this._dragActorSource.get_transformed_size(); + scale = sourceScaledWidth ? sourceScaledWidth / this._dragActor.width : 0; + } else if (this._dragOrigParent) { + // Snap the actor back to its original position within + // its parent, adjusting for the fact that the parent + // may have been moved or scaled + let [parentX, parentY] = this._dragOrigParent.get_transformed_position(); + let parentScale = _getRealActorScale(this._dragOrigParent); + + x = parentX + parentScale * this._dragOrigX; + y = parentY + parentScale * this._dragOrigY; + scale = this._dragOrigScale * parentScale; + } else { + // Snap back actor to its original stage position + x = this._snapBackX; + y = this._snapBackY; + scale = this._snapBackScale; + } + + return [x, y, scale]; + } + + _cancelDrag(eventTime) { + this.emit('drag-cancelled', eventTime); + let wasCancelled = this._dragState == DragState.CANCELLED; + this._dragState = DragState.CANCELLED; + + if (this._actorDestroyed || wasCancelled) { + global.display.set_cursor(Meta.Cursor.DEFAULT); + if (!this._buttonDown) + this._dragComplete(); + this.emit('drag-end', eventTime, false); + if (!this._dragOrigParent && this._dragActor) + this._dragActor.destroy(); + + return; + } + + let [snapBackX, snapBackY, snapBackScale] = this._getRestoreLocation(); + + this._animateDragEnd(eventTime, { + x: snapBackX, + y: snapBackY, + scale_x: snapBackScale, + scale_y: snapBackScale, + duration: SNAP_BACK_ANIMATION_TIME, + }); + } + + _restoreDragActor(eventTime) { + this._dragState = DragState.INIT; + let [restoreX, restoreY, restoreScale] = this._getRestoreLocation(); + + // fade the actor back in at its original location + this._dragActor.set_position(restoreX, restoreY); + this._dragActor.set_scale(restoreScale, restoreScale); + this._dragActor.opacity = 0; + + this._animateDragEnd(eventTime, { + duration: REVERT_ANIMATION_TIME, + }); + } + + _animateDragEnd(eventTime, params) { + this._animationInProgress = true; + + // start the animation + this._dragActor.ease(Object.assign(params, { + opacity: this._dragOrigOpacity, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._onAnimationComplete(this._dragActor, eventTime); + }, + })); + } + + _finishAnimation() { + if (!this._animationInProgress) + return; + + this._animationInProgress = false; + if (!this._buttonDown) + this._dragComplete(); + + global.display.set_cursor(Meta.Cursor.DEFAULT); + } + + _onAnimationComplete(dragActor, eventTime) { + if (this._dragOrigParent) { + Main.uiGroup.remove_child(this._dragActor); + this._dragOrigParent.add_actor(this._dragActor); + dragActor.set_scale(this._dragOrigScale, this._dragOrigScale); + if (this._dragActorHadFixedPos) + dragActor.set_position(this._dragOrigX, this._dragOrigY); + else + dragActor.fixed_position_set = false; + if (this._dragActorHadNatWidth) + this._dragActor.set_width(-1); + if (this._dragActorHadNatHeight) + this._dragActor.set_height(-1); + } else { + dragActor.destroy(); + } + + this.emit('drag-end', eventTime, false); + this._finishAnimation(); + } + + _dragComplete() { + if (!this._actorDestroyed && this._dragActor) + Shell.util_set_hidden_from_pick(this._dragActor, false); + + this._ungrabEvents(); + + if (this._updateHoverId) { + GLib.source_remove(this._updateHoverId); + this._updateHoverId = 0; + } + + if (this._dragActor) { + this._dragActor.disconnect(this._dragActorDestroyId); + this._dragActor = null; + } + + if (this._dragOrigParent) { + this._dragOrigParent.disconnect(this._dragOrigParentDestroyId); + this._dragOrigParent = null; + } + + if (this._dragActorSource) { + this._dragActorSource.disconnect(this._dragActorSourceDestroyId); + this._dragActorSource = null; + } + + this._dragState = DragState.INIT; + currentDraggable = null; + } +}; + +/** + * makeDraggable: + * @param {Clutter.Actor} actor: Source actor + * @param {Object=} params: Additional parameters + * @returns {Object} a new Draggable + * + * Create an object which controls drag and drop for the given actor. + * + * If %manualMode is %true in @params, do not automatically start + * drag and drop on click + * + * If %dragActorMaxSize is present in @params, the drag actor will + * be scaled down to be no larger than that size in pixels. + * + * If %dragActorOpacity is present in @params, the drag actor will + * will be set to have that opacity during the drag. + * + * Note that when the drag actor is the source actor and the drop + * succeeds, the actor scale and opacity aren't reset; if the drop + * target wants to reuse the actor, it's up to the drop target to + * reset these values. + */ +function makeDraggable(actor, params) { + return new _Draggable(actor, params); +} diff --git a/js/ui/edgeDragAction.js b/js/ui/edgeDragAction.js new file mode 100644 index 0000000..c0f9e4e --- /dev/null +++ b/js/ui/edgeDragAction.js @@ -0,0 +1,89 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported EdgeDragAction */ + +const { Clutter, GObject, Meta, St } = imports.gi; + +const Main = imports.ui.main; + +var EDGE_THRESHOLD = 20; +var DRAG_DISTANCE = 80; + +var EdgeDragAction = GObject.registerClass({ + Signals: { + 'activated': {}, + 'progress': { param_types: [GObject.TYPE_DOUBLE] }, + }, +}, class EdgeDragAction extends Clutter.GestureAction { + _init(side, allowedModes) { + super._init(); + this._side = side; + this._allowedModes = allowedModes; + this.set_n_touch_points(1); + this.set_threshold_trigger_edge(Clutter.GestureTriggerEdge.AFTER); + + global.display.connect('grab-op-begin', () => this.cancel()); + } + + _getMonitorRect(x, y) { + let rect = new Meta.Rectangle({ x: x - 1, y: y - 1, width: 1, height: 1 }); + let monitorIndex = global.display.get_monitor_index_for_rect(rect); + + return global.display.get_monitor_geometry(monitorIndex); + } + + vfunc_gesture_prepare(_actor) { + if (this.get_n_current_points() == 0) + return false; + + if (!(this._allowedModes & Main.actionMode)) + return false; + + let [x, y] = this.get_press_coords(0); + let monitorRect = this._getMonitorRect(x, y); + + return (this._side == St.Side.LEFT && x < monitorRect.x + EDGE_THRESHOLD) || + (this._side == St.Side.RIGHT && x > monitorRect.x + monitorRect.width - EDGE_THRESHOLD) || + (this._side == St.Side.TOP && y < monitorRect.y + EDGE_THRESHOLD) || + (this._side == St.Side.BOTTOM && y > monitorRect.y + monitorRect.height - EDGE_THRESHOLD); + } + + vfunc_gesture_progress(_actor) { + let [startX, startY] = this.get_press_coords(0); + let [x, y] = this.get_motion_coords(0); + let offsetX = Math.abs(x - startX); + let offsetY = Math.abs(y - startY); + + if (offsetX < EDGE_THRESHOLD && offsetY < EDGE_THRESHOLD) + return true; + + if ((offsetX > offsetY && + (this._side == St.Side.TOP || this._side == St.Side.BOTTOM)) || + (offsetY > offsetX && + (this._side == St.Side.LEFT || this._side == St.Side.RIGHT))) { + this.cancel(); + return false; + } + + if (this._side === St.Side.TOP || + this._side === St.Side.BOTTOM) + this.emit('progress', offsetY); + else + this.emit('progress', offsetX); + + return true; + } + + vfunc_gesture_end(_actor) { + let [startX, startY] = this.get_press_coords(0); + let [x, y] = this.get_motion_coords(0); + let monitorRect = this._getMonitorRect(startX, startY); + + if ((this._side == St.Side.TOP && y > monitorRect.y + DRAG_DISTANCE) || + (this._side == St.Side.BOTTOM && y < monitorRect.y + monitorRect.height - DRAG_DISTANCE) || + (this._side == St.Side.LEFT && x > monitorRect.x + DRAG_DISTANCE) || + (this._side == St.Side.RIGHT && x < monitorRect.x + monitorRect.width - DRAG_DISTANCE)) + this.emit('activated'); + else + this.cancel(); + } +}); diff --git a/js/ui/endSessionDialog.js b/js/ui/endSessionDialog.js new file mode 100644 index 0000000..ca24d06 --- /dev/null +++ b/js/ui/endSessionDialog.js @@ -0,0 +1,798 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported init, EndSessionDialog */ +/* + * Copyright 2010-2016 Red Hat, Inc + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see <http://www.gnu.org/licenses/>. + */ + +const { + AccountsService, Clutter, Gio, GLib, GObject, + Pango, Polkit, Shell, St, UPowerGlib: UPower, +} = imports.gi; + +const CheckBox = imports.ui.checkBox; +const Dialog = imports.ui.dialog; +const GnomeSession = imports.misc.gnomeSession; +const LoginManager = imports.misc.loginManager; +const ModalDialog = imports.ui.modalDialog; +const UserWidget = imports.ui.userWidget; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const _ITEM_ICON_SIZE = 64; + +const LOW_BATTERY_THRESHOLD = 30; + +const EndSessionDialogIface = loadInterfaceXML('org.gnome.SessionManager.EndSessionDialog'); + +const logoutDialogContent = { + subjectWithUser: C_("title", "Log Out %s"), + subject: C_("title", "Log Out"), + descriptionWithUser(user, seconds) { + return ngettext( + '%s will be logged out automatically in %d second.', + '%s will be logged out automatically in %d seconds.', + seconds).format(user, seconds); + }, + description(seconds) { + return ngettext( + 'You will be logged out automatically in %d second.', + 'You will be logged out automatically in %d seconds.', + seconds).format(seconds); + }, + showBatteryWarning: false, + confirmButtons: [{ + signal: 'ConfirmedLogout', + label: C_('button', 'Log Out'), + }], + showOtherSessions: false, +}; + +const shutdownDialogContent = { + subject: C_("title", "Power Off"), + subjectWithUpdates: C_("title", "Install Updates & Power Off"), + description(seconds) { + return ngettext( + 'The system will power off automatically in %d second.', + 'The system will power off automatically in %d seconds.', + seconds).format(seconds); + }, + checkBoxText: C_("checkbox", "Install pending software updates"), + showBatteryWarning: true, + confirmButtons: [{ + signal: 'ConfirmedShutdown', + label: C_('button', 'Power Off'), + }], + iconName: 'system-shutdown-symbolic', + showOtherSessions: true, +}; + +const restartDialogContent = { + subject: C_("title", "Restart"), + subjectWithUpdates: C_('title', 'Install Updates & Restart'), + description(seconds) { + return ngettext( + 'The system will restart automatically in %d second.', + 'The system will restart automatically in %d seconds.', + seconds).format(seconds); + }, + checkBoxText: C_('checkbox', 'Install pending software updates'), + showBatteryWarning: true, + confirmButtons: [{ + signal: 'ConfirmedReboot', + label: C_('button', 'Restart'), + }], + iconName: 'view-refresh-symbolic', + showOtherSessions: true, +}; + +const restartUpdateDialogContent = { + + subject: C_("title", "Restart & Install Updates"), + description(seconds) { + return ngettext( + 'The system will automatically restart and install updates in %d second.', + 'The system will automatically restart and install updates in %d seconds.', + seconds).format(seconds); + }, + showBatteryWarning: true, + confirmButtons: [{ + signal: 'ConfirmedReboot', + label: C_('button', 'Restart & 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: ${inhibitor.get_object_path()}`); + return null; + } + + if (!GLib.str_has_suffix(desktopFile, '.desktop')) + desktopFile += '.desktop'; + + return Shell.AppSystem.get_default().lookup_heuristic_basename(desktopFile); +} + +// The logout timer only shows updates every 10 seconds +// until the last 10 seconds, then it shows updates every +// second. This function takes a given time and returns +// what we should show to the user for that time. +function _roundSecondsToInterval(totalSeconds, secondsLeft, interval) { + let time; + + time = Math.ceil(secondsLeft); + + // Final count down is in decrements of 1 + if (time <= interval) + return time; + + // Round up higher than last displayable time interval + time += interval - 1; + + // Then round down to that time interval + if (time > totalSeconds) + time = Math.ceil(totalSeconds); + else + time -= time % interval; + + return time; +} + +function _setCheckBoxLabel(checkBox, text) { + let label = checkBox.getLabelActor(); + + if (text) { + label.set_text(text); + checkBox.show(); + } else { + label.set_text(''); + checkBox.hide(); + } +} + +function init() { + // This always returns the same singleton object + // By instantiating it initially, we register the + // bus object, etc. + new EndSessionDialog(); +} + +var EndSessionDialog = GObject.registerClass( +class EndSessionDialog extends ModalDialog.ModalDialog { + _init() { + super._init({ + styleClass: 'end-session-dialog', + destroyOnClose: false, + }); + + this._loginManager = LoginManager.getLoginManager(); + this._canRebootToBootLoaderMenu = false; + this._getCanRebootToBootLoaderMenu(); + + this._userManager = AccountsService.UserManager.get_default(); + this._user = this._userManager.get_user(GLib.get_user_name()); + this._updatesPermission = null; + + this._pkOfflineProxy = new PkOfflineProxy(Gio.DBus.system, + 'org.freedesktop.PackageKit', + '/org/freedesktop/PackageKit', + this._onPkOfflineProxyCreated.bind(this)); + + this._powerProxy = new UPowerProxy(Gio.DBus.system, + 'org.freedesktop.UPower', + '/org/freedesktop/UPower/devices/DisplayDevice', + (proxy, error) => { + if (error) { + log(error.message); + return; + } + this._powerProxy.connect('g-properties-changed', + this._sync.bind(this)); + this._sync(); + }); + + this._secondsLeft = 0; + this._totalSecondsToStayOpen = 0; + this._applications = []; + this._sessions = []; + this._capturedEventId = 0; + this._rebootButton = null; + this._rebootButtonAlt = null; + + this.connect('opened', + this._onOpened.bind(this)); + + this._user.connectObject( + 'notify::is-loaded', this._sync.bind(this), + 'changed', this._sync.bind(this), this); + + this._messageDialogContent = new Dialog.MessageDialogContent(); + + this._checkBox = new CheckBox.CheckBox(); + this._checkBox.connect('clicked', this._sync.bind(this)); + this._messageDialogContent.add_child(this._checkBox); + + this._batteryWarning = new St.Label({ + style_class: 'end-session-dialog-battery-warning', + text: _('Low battery power: please plug in before installing updates.'), + }); + this._batteryWarning.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._batteryWarning.clutter_text.line_wrap = true; + this._messageDialogContent.add_child(this._batteryWarning); + + this.contentLayout.add_child(this._messageDialogContent); + + this._applicationSection = new Dialog.ListSection({ + title: _('Some applications are busy or have unsaved work'), + }); + this.contentLayout.add_child(this._applicationSection); + + this._sessionSection = new Dialog.ListSection({ + title: _('Other users are logged in'), + }); + this.contentLayout.add_child(this._sessionSection); + + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(EndSessionDialogIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/gnome/SessionManager/EndSessionDialog'); + } + + async _getCanRebootToBootLoaderMenu() { + const {canRebootToBootLoaderMenu} = await this._loginManager.canRebootToBootLoaderMenu(); + this._canRebootToBootLoaderMenu = canRebootToBootLoaderMenu; + } + + async _onPkOfflineProxyCreated(proxy, error) { + if (error) { + log(error.message); + return; + } + + // Creating a D-Bus proxy won't propagate SERVICE_UNKNOWN or NAME_HAS_NO_OWNER + // errors if PackageKit is not available, but the GIO implementation will make + // sure in that case that the proxy's g-name-owner is set to null, so check that. + if (this._pkOfflineProxy.g_name_owner === null) { + this._pkOfflineProxy = null; + return; + } + + // It only makes sense to check for this permission if PackageKit is available. + try { + this._updatesPermission = await Polkit.Permission.new( + 'org.freedesktop.packagekit.trigger-offline-update', null, null); + } catch (e) { + log(`No permission to trigger offline updates: ${e}`); + } + } + + _isDischargingBattery() { + return this._powerProxy.IsPresent && + this._powerProxy.State !== UPower.DeviceState.CHARGING && + this._powerProxy.State !== UPower.DeviceState.FULLY_CHARGED; + } + + _isBatteryLow() { + return this._isDischargingBattery() && this._powerProxy.Percentage < LOW_BATTERY_THRESHOLD; + } + + _shouldShowLowBatteryWarning(dialogContent) { + if (!dialogContent.showBatteryWarning) + return false; + + if (!this._isBatteryLow()) + return false; + + if (this._checkBox.checked) + return true; + + // Show the warning if updates have already been triggered, but + // the user doesn't have enough permissions to cancel them. + let updatesAllowed = this._updatesPermission && this._updatesPermission.allowed; + return this._updateInfo.UpdatePrepared && this._updateInfo.UpdateTriggered && !updatesAllowed; + } + + _sync() { + let open = this.state == ModalDialog.State.OPENING || this.state == ModalDialog.State.OPENED; + if (!open) + return; + + let dialogContent = DialogContent[this._type]; + + let subject = dialogContent.subject; + + // Use different title when we are installing updates + if (dialogContent.subjectWithUpdates && this._checkBox.checked) + subject = dialogContent.subjectWithUpdates; + + this._batteryWarning.visible = this._shouldShowLowBatteryWarning(dialogContent); + + let description; + let displayTime = _roundSecondsToInterval(this._totalSecondsToStayOpen, + this._secondsLeft, + 10); + + if (this._user.is_loaded) { + let realName = this._user.get_real_name(); + + if (realName != null) { + if (dialogContent.subjectWithUser) + subject = dialogContent.subjectWithUser.format(realName); + + if (dialogContent.descriptionWithUser) + description = dialogContent.descriptionWithUser(realName, displayTime); + } + } + + // Use a different description when we are installing a system upgrade + // if the PackageKit proxy is available (i.e. PackageKit is available). + if (dialogContent.upgradeDescription) { + const { name, version } = this._updateInfo.PreparedUpgrade; + if (name != null && version != null) + description = dialogContent.upgradeDescription(name, version); + } + + // Fall back to regular description + if (!description) + description = dialogContent.description(displayTime); + + this._messageDialogContent.title = subject; + this._messageDialogContent.description = description; + + let hasApplications = this._applications.length > 0; + let hasSessions = this._sessions.length > 0; + + this._applicationSection.visible = hasApplications; + this._sessionSection.visible = hasSessions; + } + + _onCapturedEvent(actor, event) { + let altEnabled = false; + + let type = event.type(); + if (type !== Clutter.EventType.KEY_PRESS && type !== Clutter.EventType.KEY_RELEASE) + return Clutter.EVENT_PROPAGATE; + + let key = event.get_key_symbol(); + if (key !== Clutter.KEY_Alt_L && key !== Clutter.KEY_Alt_R) + return Clutter.EVENT_PROPAGATE; + + if (type === Clutter.EventType.KEY_PRESS) + altEnabled = true; + + this._rebootButton.visible = !altEnabled; + this._rebootButtonAlt.visible = altEnabled; + + return Clutter.EVENT_PROPAGATE; + } + + _updateButtons() { + this.clearButtons(); + + this.addButton({ + action: this.cancel.bind(this), + label: _('Cancel'), + key: Clutter.KEY_Escape, + }); + + let dialogContent = DialogContent[this._type]; + for (let i = 0; i < dialogContent.confirmButtons.length; i++) { + let signal = dialogContent.confirmButtons[i].signal; + let label = dialogContent.confirmButtons[i].label; + let button = this.addButton({ + action: () => { + this.close(true); + let signalId = this.connect('closed', () => { + this.disconnect(signalId); + this._confirm(signal); + }); + }, + label, + }); + + // Add Alt "Boot Options" option to the Reboot button + if (this._canRebootToBootLoaderMenu && signal === 'ConfirmedReboot') { + this._rebootButton = button; + this._rebootButtonAlt = this.addButton({ + action: () => { + this.close(true); + let signalId = this.connect('closed', () => { + this.disconnect(signalId); + this._confirmRebootToBootLoaderMenu(); + }); + }, + label: C_('button', 'Boot Options'), + }); + this._rebootButtonAlt.visible = false; + this._capturedEventId = this.connect('captured-event', + this._onCapturedEvent.bind(this)); + } + } + } + + _stopAltCapture() { + if (this._capturedEventId > 0) { + global.stage.disconnect(this._capturedEventId); + this._capturedEventId = 0; + } + this._rebootButton = null; + this._rebootButtonAlt = null; + } + + close(skipSignal) { + super.close(); + + if (!skipSignal) + this._dbusImpl.emit_signal('Closed', null); + } + + cancel() { + this._stopTimer(); + this._stopAltCapture(); + this._dbusImpl.emit_signal('Canceled', null); + this.close(); + } + + _confirmRebootToBootLoaderMenu() { + this._loginManager.setRebootToBootLoaderMenu(); + this._confirm('ConfirmedReboot'); + } + + async _confirm(signal) { + if (this._checkBox.visible) { + // Trigger the offline update as requested + if (this._checkBox.checked) { + switch (signal) { + case 'ConfirmedReboot': + await this._triggerOfflineUpdateReboot(); + break; + case 'ConfirmedShutdown': + // To actually trigger the offline update, we need to + // reboot to do the upgrade. When the upgrade is complete, + // the computer will shut down automatically. + signal = 'ConfirmedReboot'; + await this._triggerOfflineUpdateShutdown(); + break; + default: + break; + } + } else { + await this._triggerOfflineUpdateCancel(); + } + } + + this._fadeOutDialog(); + this._stopTimer(); + this._stopAltCapture(); + this._dbusImpl.emit_signal(signal, null); + } + + _onOpened() { + this._sync(); + } + + async _triggerOfflineUpdateReboot() { + // Handle this gracefully if PackageKit is not available. + if (!this._pkOfflineProxy) + return; + + try { + await this._pkOfflineProxy.TriggerAsync('reboot'); + } catch (error) { + log(error.message); + } + } + + async _triggerOfflineUpdateShutdown() { + // Handle this gracefully if PackageKit is not available. + if (!this._pkOfflineProxy) + return; + + try { + await this._pkOfflineProxy.TriggerAsync('power-off'); + } catch (error) { + log(error.message); + } + } + + async _triggerOfflineUpdateCancel() { + // Handle this gracefully if PackageKit is not available. + if (!this._pkOfflineProxy) + return; + + try { + await this._pkOfflineProxy.CancelAsync(); + } catch (error) { + log(error.message); + } + } + + _startTimer() { + let startTime = GLib.get_monotonic_time(); + this._secondsLeft = this._totalSecondsToStayOpen; + + this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 1, () => { + let currentTime = GLib.get_monotonic_time(); + let secondsElapsed = (currentTime - startTime) / 1000000; + + this._secondsLeft = this._totalSecondsToStayOpen - secondsElapsed; + if (this._secondsLeft > 0) { + this._sync(); + return GLib.SOURCE_CONTINUE; + } + + let dialogContent = DialogContent[this._type]; + let button = dialogContent.confirmButtons[dialogContent.confirmButtons.length - 1]; + this._confirm(button.signal); + this._timerId = 0; + + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._timerId, '[gnome-shell] this._confirm'); + } + + _stopTimer() { + if (this._timerId > 0) { + GLib.source_remove(this._timerId); + this._timerId = 0; + } + + this._secondsLeft = 0; + } + + _onInhibitorLoaded(inhibitor) { + if (!this._applications.includes(inhibitor)) { + // Stale inhibitor + return; + } + + let app = findAppFromInhibitor(inhibitor); + const [flags] = app ? inhibitor.GetFlagsSync() : [0]; + + if (app && flags & GnomeSession.InhibitFlags.LOGOUT) { + let [description] = inhibitor.GetReasonSync(); + let listItem = new Dialog.ListSectionItem({ + icon_actor: app.create_icon_texture(_ITEM_ICON_SIZE), + title: app.get_name(), + description, + }); + this._applicationSection.list.add_child(listItem); + } else { + // inhibiting app is a service (not an application) or is not + // inhibiting logout/shutdown + this._applications.splice(this._applications.indexOf(inhibitor), 1); + } + + this._sync(); + } + + async _loadSessions() { + let sessionId = GLib.getenv('XDG_SESSION_ID'); + if (!sessionId) { + const currentSessionProxy = await this._loginManager.getCurrentSessionProxy(); + sessionId = currentSessionProxy.Id; + log(`endSessionDialog: No XDG_SESSION_ID, fetched from logind: ${sessionId}`); + } + + const sessions = await this._loginManager.listSessions(); + for (const [id_, uid_, userName, seat_, sessionPath] of sessions) { + let proxy = new LogindSession(Gio.DBus.system, 'org.freedesktop.login1', sessionPath); + + if (proxy.Class !== 'user') + continue; + + if (proxy.State === 'closing') + continue; + + if (proxy.Id === sessionId) + continue; + + const session = { + user: this._userManager.get_user(userName), + username: userName, + type: proxy.Type, + remote: proxy.Remote, + }; + const nSessions = this._sessions.push(session); + + let userAvatar = new UserWidget.Avatar(session.user, { + iconSize: _ITEM_ICON_SIZE, + }); + userAvatar.update(); + + const displayUserName = + session.user.get_real_name() ?? session.username; + + let userLabelText; + if (session.remote) + /* Translators: Remote here refers to a remote session, like a ssh login */ + userLabelText = _('%s (remote)').format(displayUserName); + else if (session.type === 'tty') + /* Translators: Console here refers to a tty like a VT console */ + userLabelText = _('%s (console)').format(displayUserName); + else + userLabelText = userName; + + let listItem = new Dialog.ListSectionItem({ + icon_actor: userAvatar, + title: userLabelText, + }); + this._sessionSection.list.add_child(listItem); + + // limit the number of entries + if (nSessions === MAX_USERS_IN_SESSION_DIALOG) + break; + } + + this._sync(); + } + + async _getUpdateInfo() { + const connection = this._pkOfflineProxy.get_connection(); + const reply = await connection.call( + this._pkOfflineProxy.g_name, + this._pkOfflineProxy.g_object_path, + 'org.freedesktop.DBus.Properties', + 'GetAll', + new GLib.Variant('(s)', [this._pkOfflineProxy.g_interface_name]), + null, + Gio.DBusCallFlags.NONE, + -1, + null); + const [info] = reply.recursiveUnpack(); + return info; + } + + async OpenAsync(parameters, invocation) { + let [type, timestamp, totalSecondsToStayOpen, inhibitorObjectPaths] = parameters; + this._totalSecondsToStayOpen = totalSecondsToStayOpen; + this._type = type; + + try { + this._updateInfo = await this._getUpdateInfo(); + } catch (e) { + if (this._pkOfflineProxy !== null) + log(`Failed to get update info from PackageKit: ${e.message}`); + + this._updateInfo = { + UpdateTriggered: false, + UpdatePrepared: false, + UpgradeTriggered: false, + PreparedUpgrade: {}, + }; + } + + // Only consider updates and upgrades if PackageKit is available. + if (this._pkOfflineProxy && this._type == DialogType.RESTART) { + if (this._updateInfo.UpdateTriggered) + this._type = DialogType.UPDATE_RESTART; + else if (this._updateInfo.UpgradeTriggered) + this._type = DialogType.UPGRADE_RESTART; + } + + this._applications = []; + this._applicationSection.list.destroy_all_children(); + + this._sessions = []; + this._sessionSection.list.destroy_all_children(); + + if (!(this._type in DialogContent)) { + invocation.return_dbus_error('org.gnome.Shell.ModalDialog.TypeError', + "Unknown dialog type requested"); + return; + } + + let dialogContent = DialogContent[this._type]; + + for (let i = 0; i < inhibitorObjectPaths.length; i++) { + let inhibitor = new GnomeSession.Inhibitor(inhibitorObjectPaths[i], proxy => { + this._onInhibitorLoaded(proxy); + }); + + this._applications.push(inhibitor); + } + + if (dialogContent.showOtherSessions) + this._loadSessions(); + + let updatesAllowed = this._updatesPermission && this._updatesPermission.allowed; + + _setCheckBoxLabel(this._checkBox, dialogContent.checkBoxText || ''); + this._checkBox.visible = dialogContent.checkBoxText && this._updateInfo.UpdatePrepared && updatesAllowed; + + if (this._type === DialogType.UPGRADE_RESTART) + this._checkBox.checked = this._checkBox.visible && this._updateInfo.UpdateTriggered && !this._isDischargingBattery(); + else + this._checkBox.checked = this._checkBox.visible && !this._isBatteryLow(); + + this._batteryWarning.visible = this._shouldShowLowBatteryWarning(dialogContent); + + this._updateButtons(); + + if (!this.open(timestamp)) { + invocation.return_dbus_error('org.gnome.Shell.ModalDialog.GrabError', + "Cannot grab pointer and keyboard"); + return; + } + + if (!dialogContent.disableTimer) + this._startTimer(); + + this._sync(); + + let signalId = this.connect('opened', () => { + invocation.return_value(null); + this.disconnect(signalId); + }); + } + + Close(_parameters, _invocation) { + this.close(); + } +}); diff --git a/js/ui/environment.js b/js/ui/environment.js new file mode 100644 index 0000000..8c790da --- /dev/null +++ b/js/ui/environment.js @@ -0,0 +1,470 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported init */ + +const Config = imports.misc.config; + +imports.gi.versions.AccountsService = '1.0'; +imports.gi.versions.Atk = '1.0'; +imports.gi.versions.Atspi = '2.0'; +imports.gi.versions.Clutter = Config.LIBMUTTER_API_VERSION; +imports.gi.versions.Cogl = Config.LIBMUTTER_API_VERSION; +imports.gi.versions.Gcr = '4'; +imports.gi.versions.Gdk = '3.0'; +imports.gi.versions.Gdm = '1.0'; +imports.gi.versions.Geoclue = '2.0'; +imports.gi.versions.Gio = '2.0'; +imports.gi.versions.GDesktopEnums = '3.0'; +imports.gi.versions.GdkPixbuf = '2.0'; +imports.gi.versions.GnomeBluetooth = '3.0'; +imports.gi.versions.GnomeDesktop = '3.0'; +imports.gi.versions.Graphene = '1.0'; +imports.gi.versions.Gtk = '3.0'; +imports.gi.versions.GWeather = '4.0'; +imports.gi.versions.IBus = '1.0'; +imports.gi.versions.Malcontent = '0'; +imports.gi.versions.NM = '1.0'; +imports.gi.versions.NMA = '1.0'; +imports.gi.versions.Pango = '1.0'; +imports.gi.versions.Polkit = '1.0'; +imports.gi.versions.PolkitAgent = '1.0'; +imports.gi.versions.Rsvg = '2.0'; +imports.gi.versions.Soup = '3.0'; +imports.gi.versions.TelepathyGLib = '0.12'; +imports.gi.versions.TelepathyLogger = '0.2'; +imports.gi.versions.UPowerGlib = '1.0'; + +try { + if (Config.HAVE_SOUP2) + throw new Error('Soup3 support not enabled'); + const Soup_ = imports.gi.Soup; +} catch (e) { + imports.gi.versions.Soup = '2.4'; + const { Soup } = imports.gi; + _injectSoup3Compat(Soup); +} + +const { Clutter, Gio, GLib, GObject, Meta, Polkit, Shell, St } = imports.gi; +const Gettext = imports.gettext; +const System = imports.system; +const SignalTracker = imports.misc.signalTracker; + +Gio._promisify(Gio.DataInputStream.prototype, 'fill_async'); +Gio._promisify(Gio.DataInputStream.prototype, 'read_line_async'); +Gio._promisify(Gio.DBus, 'get'); +Gio._promisify(Gio.DBusConnection.prototype, 'call'); +Gio._promisify(Gio.DBusProxy, 'new'); +Gio._promisify(Gio.DBusProxy.prototype, 'init_async'); +Gio._promisify(Gio.DBusProxy.prototype, 'call_with_unix_fd_list'); +Gio._promisify(Polkit.Permission, 'new'); + +let _localTimeZone = null; + +// We can't import shell JS modules yet, because they may have +// variable initializations, etc, that depend on init() already having +// been run. + + +// "monkey patch" in some varargs ClutterContainer methods; we need +// to do this per-container class since there is no representation +// of interfaces in Javascript +function _patchContainerClass(containerClass) { + // This one is a straightforward mapping of the C method + containerClass.prototype.child_set = function (actor, props) { + let meta = this.get_child_meta(actor); + for (let prop in props) + meta[prop] = props[prop]; + }; + + // clutter_container_add() actually is a an add-many-actors + // method. We conveniently, but somewhat dubiously, take the + // this opportunity to make it do something more useful. + containerClass.prototype.add = function (actor, props) { + this.add_actor(actor); + if (props) + this.child_set(actor, props); + }; +} + +function _patchLayoutClass(layoutClass, styleProps) { + if (styleProps) { + layoutClass.prototype.hookup_style = function (container) { + container.connect('style-changed', () => { + let node = container.get_theme_node(); + for (let prop in styleProps) { + let [found, length] = node.lookup_length(styleProps[prop], false); + if (found) + this[prop] = length; + } + }); + }; + } +} + +/** + * Mimick the Soup 3 APIs we use when falling back to Soup 2.4 + * + * @param {object} Soup 2.4 namespace + * @returns {void} + */ +function _injectSoup3Compat(Soup) { + Soup.StatusCode = Soup.KnownStatusCode; + + Soup.Message.new_from_encoded_form = + function (method, uri, form) { + const soupUri = new Soup.URI(uri); + soupUri.set_query(form); + return Soup.Message.new_from_uri(method, soupUri); + }; + Soup.Message.prototype.set_request_body_from_bytes = + function (contentType, bytes) { + this.set_request( + contentType, + Soup.MemoryUse.COPY, + new TextDecoder().decode(bytes.get_data())); + }; + + Soup.Session.prototype.send_and_read_async = + function (message, prio, cancellable, callback) { + this.queue_message(message, () => callback(this, message)); + }; + Soup.Session.prototype.send_and_read_finish = + function (message) { + if (message.status_code !== Soup.KnownStatusCode.OK) + return null; + + return message.response_body.flatten().get_as_bytes(); + }; +} + +function _makeEaseCallback(params, cleanup) { + let onComplete = params.onComplete; + delete params.onComplete; + + let onStopped = params.onStopped; + delete params.onStopped; + + return isFinished => { + cleanup(); + + if (onStopped) + onStopped(isFinished); + if (onComplete && isFinished) + onComplete(); + }; +} + +function _getPropertyTarget(actor, propName) { + if (!propName.startsWith('@')) + return [actor, propName]; + + let [type, name, prop] = propName.split('.'); + switch (type) { + case '@layout': + return [actor.layout_manager, name]; + case '@actions': + return [actor.get_action(name), prop]; + case '@constraints': + return [actor.get_constraint(name), prop]; + case '@content': + return [actor.content, name]; + case '@effects': + return [actor.get_effect(name), prop]; + } + + throw new Error(`Invalid property name ${propName}`); +} + +function _easeActor(actor, params) { + actor.save_easing_state(); + + if (params.duration != undefined) + actor.set_easing_duration(params.duration); + delete params.duration; + + if (params.delay != undefined) + actor.set_easing_delay(params.delay); + delete params.delay; + + let repeatCount = 0; + if (params.repeatCount != undefined) + repeatCount = params.repeatCount; + delete params.repeatCount; + + let autoReverse = false; + if (params.autoReverse != undefined) + autoReverse = params.autoReverse; + delete params.autoReverse; + + // repeatCount doesn't include the initial iteration + const numIterations = repeatCount + 1; + // whether the transition should finish where it started + const isReversed = autoReverse && numIterations % 2 === 0; + + if (params.mode != undefined) + actor.set_easing_mode(params.mode); + delete params.mode; + + const prepare = () => { + Meta.disable_unredirect_for_display(global.display); + global.begin_work(); + }; + const cleanup = () => { + Meta.enable_unredirect_for_display(global.display); + global.end_work(); + }; + let callback = _makeEaseCallback(params, cleanup); + + // cancel overwritten transitions + let animatedProps = Object.keys(params).map(p => p.replace('_', '-', 'g')); + animatedProps.forEach(p => actor.remove_transition(p)); + + if (actor.get_easing_duration() > 0 || !isReversed) + actor.set(params); + actor.restore_easing_state(); + + const transitions = animatedProps + .map(p => actor.get_transition(p)) + .filter(t => t !== null); + + transitions.forEach(t => t.set({ repeatCount, autoReverse })); + + const [transition] = transitions; + + if (transition && transition.delay) + transition.connect('started', () => prepare()); + else + prepare(); + + if (transition) + transition.connect('stopped', (t, finished) => callback(finished)); + else + callback(true); +} + +function _easeActorProperty(actor, propName, target, params) { + // Avoid pointless difference with ease() + if (params.mode) + params.progress_mode = params.mode; + delete params.mode; + + if (params.duration) + params.duration = adjustAnimationTime(params.duration); + let duration = Math.floor(params.duration || 0); + + let repeatCount = 0; + if (params.repeatCount != undefined) + repeatCount = params.repeatCount; + delete params.repeatCount; + + let autoReverse = false; + if (params.autoReverse != undefined) + autoReverse = params.autoReverse; + delete params.autoReverse; + + // repeatCount doesn't include the initial iteration + const numIterations = repeatCount + 1; + // whether the transition should finish where it started + const isReversed = autoReverse && numIterations % 2 === 0; + + // Copy Clutter's behavior for implicit animations, see + // should_skip_implicit_transition() + if (actor instanceof Clutter.Actor && !actor.mapped) + duration = 0; + + const prepare = () => { + Meta.disable_unredirect_for_display(global.display); + global.begin_work(); + }; + const cleanup = () => { + Meta.enable_unredirect_for_display(global.display); + global.end_work(); + }; + let callback = _makeEaseCallback(params, cleanup); + + // cancel overwritten transition + actor.remove_transition(propName); + + if (duration == 0) { + let [obj, prop] = _getPropertyTarget(actor, propName); + + if (!isReversed) + obj[prop] = target; + + prepare(); + callback(true); + + return; + } + + let pspec = actor.find_property(propName); + let transition = new Clutter.PropertyTransition(Object.assign({ + property_name: propName, + interval: new Clutter.Interval({ value_type: pspec.value_type }), + remove_on_complete: true, + repeat_count: repeatCount, + auto_reverse: autoReverse, + }, params)); + actor.add_transition(propName, transition); + + transition.set_to(target); + + if (transition.delay) + transition.connect('started', () => prepare()); + else + prepare(); + + transition.connect('stopped', (t, finished) => callback(finished)); +} + +function init() { + // Add some bindings to the global JS namespace + globalThis.global = Shell.Global.get(); + + globalThis._ = Gettext.gettext; + globalThis.C_ = Gettext.pgettext; + globalThis.ngettext = Gettext.ngettext; + globalThis.N_ = s => s; + + GObject.gtypeNameBasedOnJSPath = true; + + GObject.Object.prototype.connectObject = function (...args) { + SignalTracker.connectObject(this, ...args); + }; + GObject.Object.prototype.connect_object = function (...args) { + SignalTracker.connectObject(this, ...args); + }; + GObject.Object.prototype.disconnectObject = function (...args) { + SignalTracker.disconnectObject(this, ...args); + }; + GObject.Object.prototype.disconnect_object = function (...args) { + SignalTracker.disconnectObject(this, ...args); + }; + + SignalTracker.registerDestroyableType(Clutter.Actor); + + // Miscellaneous monkeypatching + _patchContainerClass(St.BoxLayout); + + _patchLayoutClass(Clutter.GridLayout, { + row_spacing: 'spacing-rows', + column_spacing: 'spacing-columns', + }); + _patchLayoutClass(Clutter.BoxLayout, { spacing: 'spacing' }); + + let origSetEasingDuration = Clutter.Actor.prototype.set_easing_duration; + Clutter.Actor.prototype.set_easing_duration = function (msecs) { + origSetEasingDuration.call(this, adjustAnimationTime(msecs)); + }; + let origSetEasingDelay = Clutter.Actor.prototype.set_easing_delay; + Clutter.Actor.prototype.set_easing_delay = function (msecs) { + origSetEasingDelay.call(this, adjustAnimationTime(msecs)); + }; + + Clutter.Actor.prototype.ease = function (props) { + _easeActor(this, props); + }; + Clutter.Actor.prototype.ease_property = function (propName, target, params) { + _easeActorProperty(this, propName, target, params); + }; + St.Adjustment.prototype.ease = function (target, params) { + // we're not an actor of course, but we implement the same + // transition API as Clutter.Actor, so this works anyway + _easeActorProperty(this, 'value', target, params); + }; + + Clutter.Actor.prototype[Symbol.iterator] = function* () { + for (let c = this.get_first_child(); c; c = c.get_next_sibling()) + yield c; + }; + + Clutter.Actor.prototype.toString = function () { + return St.describe_actor(this); + }; + // Deprecation warning for former JS classes turned into an actor subclass + Object.defineProperty(Clutter.Actor.prototype, 'actor', { + get() { + let klass = this.constructor.name; + let { stack } = new Error(); + log(`Usage of object.actor is deprecated for ${klass}\n${stack}`); + return this; + }, + }); + + Gio.File.prototype.touch_async = function (callback) { + Shell.util_touch_file_async(this, callback); + }; + Gio.File.prototype.touch_finish = function (result) { + return Shell.util_touch_file_finish(this, result); + }; + + St.set_slow_down_factor = function (factor) { + let { stack } = new Error(); + log(`St.set_slow_down_factor() is deprecated, use St.Settings.slow_down_factor\n${stack}`); + St.Settings.get().slow_down_factor = factor; + }; + + let origToString = Object.prototype.toString; + Object.prototype.toString = function () { + let base = origToString.call(this); + try { + if ('actor' in this && this.actor instanceof Clutter.Actor) + return base.replace(/\]$/, ` delegate for ${this.actor.toString().substring(1)}`); + else + return base; + } catch (e) { + return base; + } + }; + + // Override to clear our own timezone cache as well + const origClearDateCaches = System.clearDateCaches; + System.clearDateCaches = function () { + _localTimeZone = null; + origClearDateCaches(); + }; + + // Work around https://bugzilla.mozilla.org/show_bug.cgi?id=508783 + Date.prototype.toLocaleFormat = function (format) { + if (_localTimeZone === null) + _localTimeZone = GLib.TimeZone.new_local(); + + let dt = GLib.DateTime.new(_localTimeZone, + this.getFullYear(), + this.getMonth() + 1, + this.getDate(), + this.getHours(), + this.getMinutes(), + this.getSeconds()); + return dt?.format(format) ?? ''; + }; + + let slowdownEnv = GLib.getenv('GNOME_SHELL_SLOWDOWN_FACTOR'); + if (slowdownEnv) { + let factor = parseFloat(slowdownEnv); + if (!isNaN(factor) && factor > 0.0) + St.Settings.get().slow_down_factor = factor; + } + + // OK, now things are initialized enough that we can import shell JS + const Format = imports.format; + + String.prototype.format = Format.format; + + Math.clamp = function (x, lower, upper) { + return Math.min(Math.max(x, lower), upper); + }; +} + +// adjustAnimationTime: +// @msecs: time in milliseconds +// +// Adjust @msecs to account for St's enable-animations +// and slow-down-factor settings +function adjustAnimationTime(msecs) { + let settings = St.Settings.get(); + + if (!settings.enable_animations) + return Math.min(msecs, 1); + return settings.slow_down_factor * msecs; +} + diff --git a/js/ui/extensionDownloader.js b/js/ui/extensionDownloader.js new file mode 100644 index 0000000..94ba8fa --- /dev/null +++ b/js/ui/extensionDownloader.js @@ -0,0 +1,282 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported init, installExtension, uninstallExtension, checkForUpdates */ + +const { Clutter, Gio, GLib, GObject, Soup } = imports.gi; + +const Config = imports.misc.config; +const Dialog = imports.ui.dialog; +const ExtensionUtils = imports.misc.extensionUtils; +const FileUtils = imports.misc.fileUtils; +const Main = imports.ui.main; +const ModalDialog = imports.ui.modalDialog; + +Gio._promisify(Soup.Session.prototype, 'send_and_read_async'); +Gio._promisify(Gio.OutputStream.prototype, 'write_bytes_async'); +Gio._promisify(Gio.IOStream.prototype, 'close_async'); +Gio._promisify(Gio.Subprocess.prototype, 'wait_check_async'); + +var REPOSITORY_URL_DOWNLOAD = 'https://extensions.gnome.org/download-extension/%s.shell-extension.zip'; +var REPOSITORY_URL_INFO = 'https://extensions.gnome.org/extension-info/'; +var REPOSITORY_URL_UPDATE = 'https://extensions.gnome.org/update-info/'; + +let _httpSession; + +/** + * @param {string} uuid - extension uuid + * @param {Gio.DBusMethodInvocation} invocation - the caller + * @returns {void} + */ +async function installExtension(uuid, invocation) { + const params = { + uuid, + shell_version: Config.PACKAGE_VERSION, + }; + + const message = Soup.Message.new_from_encoded_form('GET', + REPOSITORY_URL_INFO, + Soup.form_encode_hash(params)); + + let info; + try { + const bytes = await _httpSession.send_and_read_async( + message, + GLib.PRIORITY_DEFAULT, + null); + checkResponse(message); + const decoder = new TextDecoder(); + info = JSON.parse(decoder.decode(bytes.get_data())); + } catch (e) { + Main.extensionManager.logExtensionError(uuid, e); + invocation.return_dbus_error( + 'org.gnome.Shell.ExtensionError', e.message); + return; + } + + const dialog = new InstallExtensionDialog(uuid, info, invocation); + dialog.open(global.get_current_time()); +} + +function uninstallExtension(uuid) { + let extension = Main.extensionManager.lookup(uuid); + if (!extension) + return false; + + // Don't try to uninstall system extensions + if (extension.type !== ExtensionUtils.ExtensionType.PER_USER) + return false; + + if (!Main.extensionManager.unloadExtension(extension)) + return false; + + FileUtils.recursivelyDeleteDir(extension.dir, true); + + try { + const updatesDir = Gio.File.new_for_path(GLib.build_filenamev( + [global.userdatadir, 'extension-updates', extension.uuid])); + FileUtils.recursivelyDeleteDir(updatesDir, true); + } catch (e) { + // not an error + } + + return true; +} + +/** + * Check return status of reponse + * + * @param {Soup.Message} message - an http response + * @returns {void} + * @throws + */ +function checkResponse(message) { + const { statusCode } = message; + const phrase = Soup.Status.get_phrase(statusCode); + if (statusCode !== Soup.Status.OK) + throw new Error(`Unexpected response: ${phrase}`); +} + +/** + * @param {GLib.Bytes} bytes - archive data + * @param {Gio.File} dir - target directory + * @returns {void} + */ +async function extractExtensionArchive(bytes, dir) { + if (!dir.query_exists(null)) + dir.make_directory_with_parents(null); + + const [file, stream] = Gio.File.new_tmp('XXXXXX.shell-extension.zip'); + await stream.output_stream.write_bytes_async(bytes, + GLib.PRIORITY_DEFAULT, null); + stream.close_async(GLib.PRIORITY_DEFAULT, null); + + const unzip = Gio.Subprocess.new( + ['unzip', '-uod', dir.get_path(), '--', file.get_path()], + Gio.SubprocessFlags.NONE); + await unzip.wait_check_async(null); +} + +/** + * @param {string} uuid - extension uuid + * @returns {void} + */ +async function downloadExtensionUpdate(uuid) { + if (!Main.extensionManager.updatesSupported) + return; + + const dir = Gio.File.new_for_path( + GLib.build_filenamev([global.userdatadir, 'extension-updates', uuid])); + + const params = { shell_version: Config.PACKAGE_VERSION }; + const message = Soup.Message.new_from_encoded_form('GET', + REPOSITORY_URL_DOWNLOAD.format(uuid), + Soup.form_encode_hash(params)); + + try { + const bytes = await _httpSession.send_and_read_async( + message, + GLib.PRIORITY_DEFAULT, + null); + checkResponse(message); + + await extractExtensionArchive(bytes, dir); + Main.extensionManager.notifyExtensionUpdate(uuid); + } catch (e) { + log(`Error while downloading update for extension ${uuid}: (${e.message})`); + } +} + +/** + * Check extensions.gnome.org for updates + * + * @returns {void} + */ +async function checkForUpdates() { + if (!Main.extensionManager.updatesSupported) + return; + + let metadatas = {}; + Main.extensionManager.getUuids().forEach(uuid => { + let extension = Main.extensionManager.lookup(uuid); + if (extension.type !== ExtensionUtils.ExtensionType.PER_USER) + return; + if (extension.hasUpdate) + return; + metadatas[uuid] = { + version: extension.metadata.version, + }; + }); + + if (Object.keys(metadatas).length === 0) + return; // nothing to update + + const versionCheck = global.settings.get_boolean( + 'disable-extension-version-validation'); + const params = { + shell_version: Config.PACKAGE_VERSION, + disable_version_validation: `${versionCheck}`, + }; + const requestBody = new GLib.Bytes(JSON.stringify(metadatas)); + + const message = Soup.Message.new('POST', + `${REPOSITORY_URL_UPDATE}?${Soup.form_encode_hash(params)}`); + message.set_request_body_from_bytes('application/json', requestBody); + + let json; + try { + const bytes = await _httpSession.send_and_read_async( + message, + GLib.PRIORITY_DEFAULT, + null); + checkResponse(message); + json = new TextDecoder().decode(bytes.get_data()); + } catch (e) { + log(`Update check failed: ${e.message}`); + return; + } + + const operations = JSON.parse(json); + const updates = []; + for (const uuid in operations) { + const operation = operations[uuid]; + if (operation === 'upgrade' || operation === 'downgrade') + updates.push(uuid); + } + + try { + await Promise.allSettled( + updates.map(uuid => downloadExtensionUpdate(uuid))); + } catch (e) { + log(`Some extension updates failed to download: ${e.message}`); + } +} + +var InstallExtensionDialog = GObject.registerClass( +class InstallExtensionDialog extends ModalDialog.ModalDialog { + _init(uuid, info, invocation) { + super._init({ styleClass: 'extension-dialog' }); + + this._uuid = uuid; + this._info = info; + this._invocation = invocation; + + this.setButtons([{ + label: _('Cancel'), + action: this._onCancelButtonPressed.bind(this), + key: Clutter.KEY_Escape, + }, { + label: _('Install'), + action: this._onInstallButtonPressed.bind(this), + default: true, + }]); + + let content = new Dialog.MessageDialogContent({ + title: _('Install Extension'), + description: _('Download and install “%s” from extensions.gnome.org?').format(info.name), + }); + + this.contentLayout.add(content); + } + + _onCancelButtonPressed() { + this.close(); + this._invocation.return_value(GLib.Variant.new('(s)', ['cancelled'])); + } + + async _onInstallButtonPressed() { + this.close(); + + const params = { shell_version: Config.PACKAGE_VERSION }; + const message = Soup.Message.new_from_encoded_form('GET', + REPOSITORY_URL_DOWNLOAD.format(this._uuid), + Soup.form_encode_hash(params)); + + const dir = Gio.File.new_for_path( + GLib.build_filenamev([global.userdatadir, 'extensions', this._uuid])); + + try { + const bytes = await _httpSession.send_and_read_async( + message, + GLib.PRIORITY_DEFAULT, + null); + checkResponse(message); + + await extractExtensionArchive(bytes, dir); + + const extension = Main.extensionManager.createExtensionObject( + this._uuid, dir, ExtensionUtils.ExtensionType.PER_USER); + Main.extensionManager.loadExtension(extension); + if (!Main.extensionManager.enableExtension(this._uuid)) + throw new Error(`Cannot enable ${this._uuid}`); + + this._invocation.return_value(new GLib.Variant('(s)', ['successful'])); + } catch (e) { + log(`Error while installing ${this._uuid}: ${e.message}`); + this._invocation.return_dbus_error( + 'org.gnome.Shell.ExtensionError', e.message); + } + } +}); + +function init() { + _httpSession = new Soup.Session(); +} diff --git a/js/ui/extensionSystem.js b/js/ui/extensionSystem.js new file mode 100644 index 0000000..c21cc7c --- /dev/null +++ b/js/ui/extensionSystem.js @@ -0,0 +1,687 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported init connect disconnect ExtensionManager */ + +const { GLib, Gio, GObject, Shell, St } = imports.gi; +const Signals = imports.misc.signals; + +const ExtensionDownloader = imports.ui.extensionDownloader; +const ExtensionUtils = imports.misc.extensionUtils; +const FileUtils = imports.misc.fileUtils; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; + +const { ExtensionState, ExtensionType } = ExtensionUtils; + +const ENABLED_EXTENSIONS_KEY = 'enabled-extensions'; +const DISABLED_EXTENSIONS_KEY = 'disabled-extensions'; +const DISABLE_USER_EXTENSIONS_KEY = 'disable-user-extensions'; +const EXTENSION_DISABLE_VERSION_CHECK_KEY = 'disable-extension-version-validation'; + +const UPDATE_CHECK_TIMEOUT = 24 * 60 * 60; // 1 day in seconds + +var ExtensionManager = class extends Signals.EventEmitter { + constructor() { + super(); + + this._initialized = false; + this._updateNotified = false; + + this._extensions = new Map(); + this._unloadedExtensions = new Map(); + this._enabledExtensions = []; + this._extensionOrder = []; + this._checkVersion = false; + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + } + + init() { + // The following file should exist for a period of time when extensions + // are enabled after start. If it exists, then the systemd unit will + // disable extensions should gnome-shell crash. + // Should the file already exist from a previous login, then this is OK. + let disableFilename = GLib.build_filenamev([GLib.get_user_runtime_dir(), 'gnome-shell-disable-extensions']); + let disableFile = Gio.File.new_for_path(disableFilename); + try { + disableFile.create(Gio.FileCreateFlags.REPLACE_DESTINATION, null); + } catch (e) { + log(`Failed to create file ${disableFilename}: ${e.message}`); + } + + GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 60, () => { + disableFile.delete(null); + return GLib.SOURCE_REMOVE; + }); + + this._installExtensionUpdates(); + this._sessionUpdated(); + + GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, UPDATE_CHECK_TIMEOUT, () => { + ExtensionDownloader.checkForUpdates(); + return GLib.SOURCE_CONTINUE; + }); + ExtensionDownloader.checkForUpdates(); + } + + get updatesSupported() { + const appSys = Shell.AppSystem.get_default(); + return (appSys.lookup_app('org.gnome.Extensions.desktop') !== null) || + (appSys.lookup_app('com.mattjakeman.ExtensionManager.desktop') !== null); + } + + lookup(uuid) { + return this._extensions.get(uuid); + } + + getUuids() { + return [...this._extensions.keys()]; + } + + _extensionSupportsSessionMode(uuid) { + const extension = this.lookup(uuid); + + if (!extension) + return false; + + if (extension.sessionModes.includes(Main.sessionMode.currentMode)) + return true; + + if (extension.sessionModes.includes(Main.sessionMode.parentMode)) + return true; + + return false; + } + + _callExtensionDisable(uuid) { + let extension = this.lookup(uuid); + if (!extension) + return; + + if (extension.state != ExtensionState.ENABLED) + return; + + // "Rebase" the extension order by disabling and then enabling extensions + // in order to help prevent conflicts. + + // Example: + // order = [A, B, C, D, E] + // user disables C + // this should: disable E, disable D, disable C, enable D, enable E + + let orderIdx = this._extensionOrder.indexOf(uuid); + let order = this._extensionOrder.slice(orderIdx + 1); + let orderReversed = order.slice().reverse(); + + for (let i = 0; i < orderReversed.length; i++) { + let otherUuid = orderReversed[i]; + try { + this.lookup(otherUuid).stateObj.disable(); + } catch (e) { + this.logExtensionError(otherUuid, e); + } + } + + try { + extension.stateObj.disable(); + } catch (e) { + this.logExtensionError(uuid, e); + } + + if (extension.stylesheet) { + let theme = St.ThemeContext.get_for_stage(global.stage).get_theme(); + theme.unload_stylesheet(extension.stylesheet); + delete extension.stylesheet; + } + + for (let i = 0; i < order.length; i++) { + let otherUuid = order[i]; + try { + this.lookup(otherUuid).stateObj.enable(); + } catch (e) { + this.logExtensionError(otherUuid, e); + } + } + + this._extensionOrder.splice(orderIdx, 1); + + if (extension.state != ExtensionState.ERROR) { + extension.state = ExtensionState.DISABLED; + this.emit('extension-state-changed', extension); + } + } + + _callExtensionEnable(uuid) { + if (!this._extensionSupportsSessionMode(uuid)) + return; + + let extension = this.lookup(uuid); + if (!extension) + return; + + if (extension.state == ExtensionState.INITIALIZED) + this._callExtensionInit(uuid); + + if (extension.state != ExtensionState.DISABLED) + return; + + let stylesheetNames = [`${global.session_mode}.css`, 'stylesheet.css']; + let theme = St.ThemeContext.get_for_stage(global.stage).get_theme(); + for (let i = 0; i < stylesheetNames.length; i++) { + try { + let stylesheetFile = extension.dir.get_child(stylesheetNames[i]); + theme.load_stylesheet(stylesheetFile); + extension.stylesheet = stylesheetFile; + break; + } catch (e) { + if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND)) + continue; // not an error + this.logExtensionError(uuid, e); + return; + } + } + + try { + extension.stateObj.enable(); + extension.state = ExtensionState.ENABLED; + this._extensionOrder.push(uuid); + this.emit('extension-state-changed', extension); + } catch (e) { + if (extension.stylesheet) { + theme.unload_stylesheet(extension.stylesheet); + delete extension.stylesheet; + } + this.logExtensionError(uuid, e); + } + } + + enableExtension(uuid) { + if (!this._extensions.has(uuid)) + return false; + + let enabledExtensions = global.settings.get_strv(ENABLED_EXTENSIONS_KEY); + let disabledExtensions = global.settings.get_strv(DISABLED_EXTENSIONS_KEY); + + if (disabledExtensions.includes(uuid)) { + disabledExtensions = disabledExtensions.filter(item => item !== uuid); + global.settings.set_strv(DISABLED_EXTENSIONS_KEY, disabledExtensions); + } + + if (!enabledExtensions.includes(uuid)) { + enabledExtensions.push(uuid); + global.settings.set_strv(ENABLED_EXTENSIONS_KEY, enabledExtensions); + } + + return true; + } + + disableExtension(uuid) { + if (!this._extensions.has(uuid)) + return false; + + let enabledExtensions = global.settings.get_strv(ENABLED_EXTENSIONS_KEY); + let disabledExtensions = global.settings.get_strv(DISABLED_EXTENSIONS_KEY); + + if (enabledExtensions.includes(uuid)) { + enabledExtensions = enabledExtensions.filter(item => item !== uuid); + global.settings.set_strv(ENABLED_EXTENSIONS_KEY, enabledExtensions); + } + + if (!disabledExtensions.includes(uuid)) { + disabledExtensions.push(uuid); + global.settings.set_strv(DISABLED_EXTENSIONS_KEY, disabledExtensions); + } + + return true; + } + + openExtensionPrefs(uuid, parentWindow, options) { + const extension = this.lookup(uuid); + if (!extension || !extension.hasPrefs) + return false; + + Gio.DBus.session.call( + 'org.gnome.Shell.Extensions', + '/org/gnome/Shell/Extensions', + 'org.gnome.Shell.Extensions', + 'OpenExtensionPrefs', + new GLib.Variant('(ssa{sv})', [uuid, parentWindow, options]), + null, + Gio.DBusCallFlags.NONE, + -1, + null); + return true; + } + + notifyExtensionUpdate(uuid) { + let extension = this.lookup(uuid); + if (!extension) + return; + + extension.hasUpdate = true; + this.emit('extension-state-changed', extension); + + if (!this._updateNotified) { + this._updateNotified = true; + + let source = new ExtensionUpdateSource(); + Main.messageTray.add(source); + + let notification = new MessageTray.Notification(source, + _('Extension Updates Available'), + _('Extension updates are ready to be installed.')); + notification.connect('activated', + () => source.open()); + source.showNotification(notification); + } + } + + logExtensionError(uuid, error) { + let extension = this.lookup(uuid); + if (!extension) + return; + + const message = error instanceof Error + ? error.message : error.toString(); + + extension.error = message; + extension.state = ExtensionState.ERROR; + if (!extension.errors) + extension.errors = []; + extension.errors.push(message); + + logError(error, `Extension ${uuid}`); + this._updateCanChange(extension); + this.emit('extension-state-changed', extension); + } + + createExtensionObject(uuid, dir, type) { + let metadataFile = dir.get_child('metadata.json'); + if (!metadataFile.query_exists(null)) + throw new Error('Missing metadata.json'); + + let metadataContents, success_; + try { + [success_, metadataContents] = metadataFile.load_contents(null); + metadataContents = new TextDecoder().decode(metadataContents); + } catch (e) { + throw new Error(`Failed to load metadata.json: ${e}`); + } + let meta; + try { + meta = JSON.parse(metadataContents); + } catch (e) { + throw new Error(`Failed to parse metadata.json: ${e}`); + } + + const requiredProperties = [{ + prop: 'uuid', + typeName: 'string', + }, { + prop: 'name', + typeName: 'string', + }, { + prop: 'description', + typeName: 'string', + }, { + prop: 'shell-version', + typeName: 'string array', + typeCheck: v => Array.isArray(v) && v.length > 0 && v.every(e => typeof e === 'string'), + }]; + for (let i = 0; i < requiredProperties.length; i++) { + const { + prop, typeName, typeCheck = v => typeof v === typeName, + } = requiredProperties[i]; + + if (!meta[prop]) + throw new Error(`missing "${prop}" property in metadata.json`); + if (!typeCheck(meta[prop])) + throw new Error(`property "${prop}" is not of type ${typeName}`); + } + + if (uuid != meta.uuid) + throw new Error(`uuid "${meta.uuid}" from metadata.json does not match directory name "${uuid}"`); + + let extension = { + metadata: meta, + uuid: meta.uuid, + type, + dir, + path: dir.get_path(), + error: '', + hasPrefs: dir.get_child('prefs.js').query_exists(null), + hasUpdate: false, + canChange: false, + sessionModes: meta['session-modes'] ? meta['session-modes'] : ['user'], + }; + this._extensions.set(uuid, extension); + + return extension; + } + + _canLoad(extension) { + if (!this._unloadedExtensions.has(extension.uuid)) + return true; + + const version = this._unloadedExtensions.get(extension.uuid); + return extension.metadata.version === version; + } + + loadExtension(extension) { + // Default to error, we set success as the last step + extension.state = ExtensionState.ERROR; + + if (this._checkVersion && ExtensionUtils.isOutOfDate(extension)) { + extension.state = ExtensionState.OUT_OF_DATE; + } else if (!this._canLoad(extension)) { + this.logExtensionError(extension.uuid, new Error( + 'A different version was loaded previously. You need to log out for changes to take effect.')); + } else { + let enabled = this._enabledExtensions.includes(extension.uuid) && + this._extensionSupportsSessionMode(extension.uuid); + if (enabled) { + if (!this._callExtensionInit(extension.uuid)) + return; + if (extension.state == ExtensionState.DISABLED) + this._callExtensionEnable(extension.uuid); + } else { + extension.state = ExtensionState.INITIALIZED; + } + + this._unloadedExtensions.delete(extension.uuid); + } + + this._updateCanChange(extension); + this.emit('extension-state-changed', extension); + } + + unloadExtension(extension) { + const { uuid, type } = extension; + + // Try to disable it -- if it's ERROR'd, we can't guarantee that, + // but it will be removed on next reboot, and hopefully nothing + // broke too much. + this._callExtensionDisable(uuid); + + extension.state = ExtensionState.UNINSTALLED; + this.emit('extension-state-changed', extension); + + // If we did install an importer, it is now cached and it's + // impossible to load a different version + if (type === ExtensionType.PER_USER && extension.imports) + this._unloadedExtensions.set(uuid, extension.metadata.version); + + this._extensions.delete(uuid); + return true; + } + + reloadExtension(oldExtension) { + // Grab the things we'll need to pass to createExtensionObject + // to reload it. + let { uuid, dir, type } = oldExtension; + + // Then unload the old extension. + this.unloadExtension(oldExtension); + + // Now, recreate the extension and load it. + let newExtension; + try { + newExtension = this.createExtensionObject(uuid, dir, type); + } catch (e) { + this.logExtensionError(uuid, e); + return; + } + + this.loadExtension(newExtension); + } + + _callExtensionInit(uuid) { + if (!this._extensionSupportsSessionMode(uuid)) + return false; + + let extension = this.lookup(uuid); + if (!extension) + throw new Error("Extension was not properly created. Call createExtensionObject first"); + + let dir = extension.dir; + let extensionJs = dir.get_child('extension.js'); + if (!extensionJs.query_exists(null)) { + this.logExtensionError(uuid, new Error('Missing extension.js')); + return false; + } + + let extensionModule; + let extensionState = null; + + ExtensionUtils.installImporter(extension); + try { + extensionModule = extension.imports.extension; + } catch (e) { + this.logExtensionError(uuid, e); + return false; + } + + if (extensionModule.init) { + try { + extensionState = extensionModule.init(extension); + } catch (e) { + this.logExtensionError(uuid, e); + return false; + } + } + + if (!extensionState) + extensionState = extensionModule; + extension.stateObj = extensionState; + + extension.state = ExtensionState.DISABLED; + this.emit('extension-loaded', uuid); + return true; + } + + _getModeExtensions() { + if (Array.isArray(Main.sessionMode.enabledExtensions)) + return Main.sessionMode.enabledExtensions; + return []; + } + + _updateCanChange(extension) { + let hasError = + extension.state == ExtensionState.ERROR || + extension.state == ExtensionState.OUT_OF_DATE; + + let isMode = this._getModeExtensions().includes(extension.uuid); + let modeOnly = global.settings.get_boolean(DISABLE_USER_EXTENSIONS_KEY); + + let changeKey = isMode + ? DISABLE_USER_EXTENSIONS_KEY + : ENABLED_EXTENSIONS_KEY; + + extension.canChange = + !hasError && + global.settings.is_writable(changeKey) && + (isMode || !modeOnly); + } + + _getEnabledExtensions() { + let extensions = this._getModeExtensions(); + + if (!global.settings.get_boolean(DISABLE_USER_EXTENSIONS_KEY)) + extensions = extensions.concat(global.settings.get_strv(ENABLED_EXTENSIONS_KEY)); + + // filter out 'disabled-extensions' which takes precedence + let disabledExtensions = global.settings.get_strv(DISABLED_EXTENSIONS_KEY); + return extensions.filter(item => !disabledExtensions.includes(item)); + } + + _onUserExtensionsEnabledChanged() { + this._onEnabledExtensionsChanged(); + this._onSettingsWritableChanged(); + } + + _onEnabledExtensionsChanged() { + let newEnabledExtensions = this._getEnabledExtensions(); + + // Find and enable all the newly enabled extensions: UUIDs found in the + // new setting, but not in the old one. + newEnabledExtensions + .filter(uuid => !this._enabledExtensions.includes(uuid) && + this._extensionSupportsSessionMode(uuid)) + .forEach(uuid => this._callExtensionEnable(uuid)); + + // Find and disable all the newly disabled extensions: UUIDs found in the + // old setting, but not in the new one. + this._extensionOrder + .filter(uuid => !newEnabledExtensions.includes(uuid) || + !this._extensionSupportsSessionMode(uuid)) + .reverse().forEach(uuid => this._callExtensionDisable(uuid)); + + this._enabledExtensions = newEnabledExtensions; + } + + _onSettingsWritableChanged() { + for (let extension of this._extensions.values()) { + this._updateCanChange(extension); + this.emit('extension-state-changed', extension); + } + } + + _onVersionValidationChanged() { + const checkVersion = !global.settings.get_boolean(EXTENSION_DISABLE_VERSION_CHECK_KEY); + if (checkVersion === this._checkVersion) + return; + + this._checkVersion = checkVersion; + + // Disabling extensions modifies the order array, so use a copy + let extensionOrder = this._extensionOrder.slice(); + + // Disable enabled extensions in the reverse order first to avoid + // the "rebasing" done in _callExtensionDisable... + extensionOrder.slice().reverse().forEach(uuid => { + this._callExtensionDisable(uuid); + }); + + // ...and then reload and enable extensions in the correct order again. + [...this._extensions.values()].sort((a, b) => { + return extensionOrder.indexOf(a.uuid) - extensionOrder.indexOf(b.uuid); + }).forEach(extension => this.reloadExtension(extension)); + } + + _installExtensionUpdates() { + if (!this.updatesSupported) + return; + + FileUtils.collectFromDatadirs('extension-updates', true, (dir, info) => { + let fileType = info.get_file_type(); + if (fileType !== Gio.FileType.DIRECTORY) + return; + let uuid = info.get_name(); + let extensionDir = Gio.File.new_for_path( + GLib.build_filenamev([global.userdatadir, 'extensions', uuid])); + + try { + FileUtils.recursivelyDeleteDir(extensionDir, false); + FileUtils.recursivelyMoveDir(dir, extensionDir); + } catch (e) { + log(`Failed to install extension updates for ${uuid}`); + } finally { + FileUtils.recursivelyDeleteDir(dir, true); + } + }); + } + + _loadExtensions() { + global.settings.connect(`changed::${ENABLED_EXTENSIONS_KEY}`, + this._onEnabledExtensionsChanged.bind(this)); + global.settings.connect(`changed::${DISABLED_EXTENSIONS_KEY}`, + this._onEnabledExtensionsChanged.bind(this)); + global.settings.connect(`changed::${DISABLE_USER_EXTENSIONS_KEY}`, + this._onUserExtensionsEnabledChanged.bind(this)); + global.settings.connect(`changed::${EXTENSION_DISABLE_VERSION_CHECK_KEY}`, + this._onVersionValidationChanged.bind(this)); + global.settings.connect(`writable-changed::${ENABLED_EXTENSIONS_KEY}`, + this._onSettingsWritableChanged.bind(this)); + global.settings.connect(`writable-changed::${DISABLED_EXTENSIONS_KEY}`, + this._onSettingsWritableChanged.bind(this)); + + this._onVersionValidationChanged(); + + this._enabledExtensions = this._getEnabledExtensions(); + + let perUserDir = Gio.File.new_for_path(global.userdatadir); + FileUtils.collectFromDatadirs('extensions', true, (dir, info) => { + let fileType = info.get_file_type(); + if (fileType != Gio.FileType.DIRECTORY) + return; + let uuid = info.get_name(); + let existing = this.lookup(uuid); + if (existing) { + log(`Extension ${uuid} already installed in ${existing.path}. ${dir.get_path()} will not be loaded`); + return; + } + + let extension; + let type = dir.has_prefix(perUserDir) + ? ExtensionType.PER_USER + : ExtensionType.SYSTEM; + try { + extension = this.createExtensionObject(uuid, dir, type); + } catch (e) { + logError(e, `Could not load extension ${uuid}`); + return; + } + this.loadExtension(extension); + }); + } + + _enableAllExtensions() { + if (!this._initialized) { + this._loadExtensions(); + this._initialized = true; + } else { + this._enabledExtensions.forEach(uuid => { + this._callExtensionEnable(uuid); + }); + } + } + + _disableAllExtensions() { + if (this._initialized) { + this._extensionOrder.slice().reverse().forEach(uuid => { + this._callExtensionDisable(uuid); + }); + } + } + + _sessionUpdated() { + // Take care of added or removed sessionMode extensions + this._onEnabledExtensionsChanged(); + this._enableAllExtensions(); + } +}; + +const ExtensionUpdateSource = GObject.registerClass( +class ExtensionUpdateSource extends MessageTray.Source { + _init() { + let appSys = Shell.AppSystem.get_default(); + this._app = appSys.lookup_app('org.gnome.Extensions.desktop'); + if (!this._app) + this._app = appSys.lookup_app('com.mattjakeman.ExtensionManager.desktop'); + + super._init(this._app.get_name()); + } + + getIcon() { + return this._app.app_info.get_icon(); + } + + _createPolicy() { + return new MessageTray.NotificationApplicationPolicy(this._app.id); + } + + open() { + this._app.activate(); + Main.overview.hide(); + Main.panel.closeCalendar(); + } +}); diff --git a/js/ui/focusCaretTracker.js b/js/ui/focusCaretTracker.js new file mode 100644 index 0000000..5cfe7a8 --- /dev/null +++ b/js/ui/focusCaretTracker.js @@ -0,0 +1,91 @@ +/** -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */ +/* + * Copyright 2012 Inclusive Design Research Centre, OCAD University. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see <http://www.gnu.org/licenses/>. + * + * Author: + * Joseph Scheuhammer <clown@alum.mit.edu> + * Contributor: + * Magdalen Berns <m.berns@sms.ed.ac.uk> + */ +/* exported FocusCaretTracker */ + +const Atspi = imports.gi.Atspi; +const Signals = imports.misc.signals; + +const CARETMOVED = 'object:text-caret-moved'; +const STATECHANGED = 'object:state-changed'; + +var FocusCaretTracker = class FocusCaretTracker extends Signals.EventEmitter { + constructor() { + super(); + + this._atspiListener = Atspi.EventListener.new(this._onChanged.bind(this)); + + this._atspiInited = false; + this._focusListenerRegistered = false; + this._caretListenerRegistered = false; + } + + _onChanged(event) { + if (event.type.indexOf(STATECHANGED) == 0) + this.emit('focus-changed', event); + else if (event.type == CARETMOVED) + this.emit('caret-moved', event); + } + + _initAtspi() { + if (!this._atspiInited && Atspi.init() == 0) { + Atspi.set_timeout(250, 250); + this._atspiInited = true; + } + + return this._atspiInited; + } + + registerFocusListener() { + if (!this._initAtspi() || this._focusListenerRegistered) + return; + + this._atspiListener.register(`${STATECHANGED}:focused`); + this._atspiListener.register(`${STATECHANGED}:selected`); + this._focusListenerRegistered = true; + } + + registerCaretListener() { + if (!this._initAtspi() || this._caretListenerRegistered) + return; + + this._atspiListener.register(CARETMOVED); + this._caretListenerRegistered = true; + } + + deregisterFocusListener() { + if (!this._focusListenerRegistered) + return; + + this._atspiListener.deregister(`${STATECHANGED}:focused`); + this._atspiListener.deregister(`${STATECHANGED}:selected`); + this._focusListenerRegistered = false; + } + + deregisterCaretListener() { + if (!this._caretListenerRegistered) + return; + + this._atspiListener.deregister(CARETMOVED); + this._caretListenerRegistered = false; + } +}; diff --git a/js/ui/grabHelper.js b/js/ui/grabHelper.js new file mode 100644 index 0000000..650bec4 --- /dev/null +++ b/js/ui/grabHelper.js @@ -0,0 +1,291 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported GrabHelper */ + +const { Clutter, St } = imports.gi; + +const Main = imports.ui.main; +const Params = imports.misc.params; + +// GrabHelper: +// @owner: the actor that owns the GrabHelper +// @params: optional parameters to pass to Main.pushModal() +// +// Creates a new GrabHelper object, for dealing with keyboard and pointer grabs +// associated with a set of actors. +// +// Note that the grab can be automatically dropped at any time by the user, and +// your code just needs to deal with it; you shouldn't adjust behavior directly +// after you call ungrab(), but instead pass an 'onUngrab' callback when you +// call grab(). +var GrabHelper = class GrabHelper { + constructor(owner, params) { + if (!(owner instanceof Clutter.Actor)) + throw new Error('GrabHelper owner must be a Clutter.Actor'); + + this._owner = owner; + this._modalParams = params; + + this._grabStack = []; + + this._ignoreUntilRelease = false; + + this._modalCount = 0; + } + + _isWithinGrabbedActor(actor) { + let currentActor = this.currentGrab.actor; + while (actor) { + if (actor == currentActor) + return true; + actor = actor.get_parent(); + } + return false; + } + + get currentGrab() { + return this._grabStack[this._grabStack.length - 1] || {}; + } + + get grabbed() { + return this._grabStack.length > 0; + } + + get grabStack() { + return this._grabStack; + } + + _findStackIndex(actor) { + if (!actor) + return -1; + + for (let i = 0; i < this._grabStack.length; i++) { + if (this._grabStack[i].actor === actor) + return i; + } + return -1; + } + + _actorInGrabStack(actor) { + while (actor) { + let idx = this._findStackIndex(actor); + if (idx >= 0) + return idx; + actor = actor.get_parent(); + } + return -1; + } + + isActorGrabbed(actor) { + return this._findStackIndex(actor) >= 0; + } + + // grab: + // @params: A bunch of parameters, see below + // + // The general effect of a "grab" is to ensure that the passed in actor + // and all actors inside the grab get exclusive control of the mouse and + // keyboard, with the grab automatically being dropped if the user tries + // to dismiss it. The actor is passed in through @params.actor. + // + // grab() can be called multiple times, with the scope of the grab being + // changed to a different actor every time. A nested grab does not have + // to have its grabbed actor inside the parent grab actors. + // + // Grabs can be automatically dropped if the user tries to dismiss it + // in one of two ways: the user clicking outside the currently grabbed + // actor, or the user typing the Escape key. + // + // If the user clicks outside the grabbed actors, and the clicked on + // actor is part of a previous grab in the stack, grabs will be popped + // until that grab is active. However, the click event will not be + // replayed to the actor. + // + // If the user types the Escape key, one grab from the grab stack will + // be popped. + // + // When a grab is popped by user interacting as described above, if you + // pass a callback as @params.onUngrab, it will be called with %true. + // + // If @params.focus is not null, we'll set the key focus directly + // to that actor instead of navigating in @params.actor. This is for + // use cases like menus, where we want to grab the menu actor, but keep + // focus on the clicked on menu item. + grab(params) { + params = Params.parse(params, { + actor: null, + focus: null, + onUngrab: null, + }); + + let focus = global.stage.key_focus; + let hadFocus = focus && this._isWithinGrabbedActor(focus); + let newFocus = params.actor; + + if (this.isActorGrabbed(params.actor)) + return true; + + params.savedFocus = focus; + + if (!this._takeModalGrab()) + return false; + + this._grabStack.push(params); + + if (params.focus) { + params.focus.grab_key_focus(); + } else if (newFocus && hadFocus) { + if (!newFocus.navigate_focus(null, St.DirectionType.TAB_FORWARD, false)) + newFocus.grab_key_focus(); + } + + return true; + } + + grabAsync(params) { + return new Promise((resolve, reject) => { + params.onUngrab = resolve; + + if (!this.grab(params)) + reject(new Error('Grab failed')); + }); + } + + _takeModalGrab() { + let firstGrab = this._modalCount == 0; + if (firstGrab) { + let grab = Main.pushModal(this._owner, this._modalParams); + if (grab.get_seat_state() !== Clutter.GrabState.ALL) { + Main.popModal(grab); + return false; + } + + this._grab = grab; + this._capturedEventId = this._owner.connect('captured-event', + (actor, event) => { + return this.onCapturedEvent(event); + }); + } + + this._modalCount++; + return true; + } + + _releaseModalGrab() { + this._modalCount--; + if (this._modalCount > 0) + return; + + this._owner.disconnect(this._capturedEventId); + this._ignoreUntilRelease = false; + + Main.popModal(this._grab); + this._grab = null; + } + + // ignoreRelease: + // + // Make sure that the next button release event evaluated by the + // capture event handler returns false. This is designed for things + // like the ComboBoxMenu that go away on press, but need to eat + // the next release event. + ignoreRelease() { + this._ignoreUntilRelease = true; + } + + // ungrab: + // @params: The parameters for the grab; see below. + // + // Pops @params.actor from the grab stack, potentially dropping + // the grab. If the actor is not on the grab stack, this call is + // ignored with no ill effects. + // + // If the actor is not at the top of the grab stack, grabs are + // popped until the grabbed actor is at the top of the grab stack. + // The onUngrab callback for every grab is called for every popped + // grab with the parameter %false. + ungrab(params) { + params = Params.parse(params, { + actor: this.currentGrab.actor, + isUser: false, + }); + + let grabStackIndex = this._findStackIndex(params.actor); + if (grabStackIndex < 0) + return; + + let focus = global.stage.key_focus; + let hadFocus = focus && this._isWithinGrabbedActor(focus); + + let poppedGrabs = this._grabStack.slice(grabStackIndex); + // "Pop" all newly ungrabbed actors off the grab stack + // by truncating the array. + this._grabStack.length = grabStackIndex; + + for (let i = poppedGrabs.length - 1; i >= 0; i--) { + let poppedGrab = poppedGrabs[i]; + + if (poppedGrab.onUngrab) + poppedGrab.onUngrab(params.isUser); + + this._releaseModalGrab(); + } + + if (hadFocus) { + let poppedGrab = poppedGrabs[0]; + if (poppedGrab.savedFocus) + poppedGrab.savedFocus.grab_key_focus(); + } + } + + onCapturedEvent(event) { + let type = event.type(); + + if (type == Clutter.EventType.KEY_PRESS && + event.get_key_symbol() == Clutter.KEY_Escape) { + this.ungrab({ isUser: true }); + return Clutter.EVENT_STOP; + } + + let motion = type == Clutter.EventType.MOTION; + let press = type == Clutter.EventType.BUTTON_PRESS; + let release = type == Clutter.EventType.BUTTON_RELEASE; + let button = press || release; + + let touchUpdate = type == Clutter.EventType.TOUCH_UPDATE; + let touchBegin = type == Clutter.EventType.TOUCH_BEGIN; + let touchEnd = type == Clutter.EventType.TOUCH_END; + let touch = touchUpdate || touchBegin || touchEnd; + + if (touch && !global.display.is_pointer_emulating_sequence(event.get_event_sequence())) + return Clutter.EVENT_PROPAGATE; + + if (this._ignoreUntilRelease && (motion || release || touch)) { + if (release || touchEnd) + this._ignoreUntilRelease = false; + return Clutter.EVENT_PROPAGATE; + } + + const targetActor = global.stage.get_event_actor(event); + + if (type === Clutter.EventType.ENTER || + type === Clutter.EventType.LEAVE || + this.currentGrab.actor.contains(targetActor)) + return Clutter.EVENT_PROPAGATE; + + if (Main.keyboard.maybeHandleEvent(event)) + return Clutter.EVENT_PROPAGATE; + + if (button || touchBegin) { + // If we have a press event, ignore the next + // motion/release events. + if (press || touchBegin) + this._ignoreUntilRelease = true; + + let i = this._actorInGrabStack(targetActor) + 1; + this.ungrab({ actor: this._grabStack[i].actor, isUser: true }); + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_STOP; + } +}; diff --git a/js/ui/ibusCandidatePopup.js b/js/ui/ibusCandidatePopup.js new file mode 100644 index 0000000..268b324 --- /dev/null +++ b/js/ui/ibusCandidatePopup.js @@ -0,0 +1,359 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported CandidatePopup */ + +const { Clutter, GObject, IBus, St } = imports.gi; + +const BoxPointer = imports.ui.boxpointer; +const Main = imports.ui.main; + +var MAX_CANDIDATES_PER_PAGE = 16; + +var DEFAULT_INDEX_LABELS = [ + '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', + 'a', 'b', 'c', 'd', 'e', 'f', +]; + +var CandidateArea = GObject.registerClass({ + Signals: { + 'candidate-clicked': { + param_types: [ + GObject.TYPE_UINT, GObject.TYPE_UINT, Clutter.ModifierType.$gtype, + ], + }, + 'cursor-down': {}, + 'cursor-up': {}, + 'next-page': {}, + 'previous-page': {}, + }, +}, class CandidateArea extends St.BoxLayout { + _init() { + super._init({ + vertical: true, + reactive: true, + visible: false, + }); + this._candidateBoxes = []; + for (let i = 0; i < MAX_CANDIDATES_PER_PAGE; ++i) { + const box = new St.BoxLayout({ + style_class: 'candidate-box', + reactive: true, + track_hover: true, + }); + box._indexLabel = new St.Label({ style_class: 'candidate-index' }); + box._candidateLabel = new St.Label({ style_class: 'candidate-label' }); + box.add_child(box._indexLabel); + box.add_child(box._candidateLabel); + this._candidateBoxes.push(box); + this.add(box); + + let j = i; + box.connect('button-release-event', (actor, event) => { + this.emit('candidate-clicked', j, event.get_button(), event.get_state()); + return Clutter.EVENT_PROPAGATE; + }); + } + + this._buttonBox = new St.BoxLayout({ style_class: 'candidate-page-button-box' }); + + this._previousButton = new St.Button({ + style_class: 'candidate-page-button candidate-page-button-previous button', + x_expand: true, + }); + this._buttonBox.add_child(this._previousButton); + + this._nextButton = new St.Button({ + style_class: 'candidate-page-button candidate-page-button-next button', + x_expand: true, + }); + this._buttonBox.add_child(this._nextButton); + + this.add(this._buttonBox); + + this._previousButton.connect('button-press-event', () => { + this.emit('previous-page'); + return Clutter.EVENT_STOP; + }); + this._previousButton.connect('touch-event', (actor, event) => { + if (event.type() === Clutter.EventType.TOUCH_BEGIN) { + this.emit('previous-page'); + return Clutter.EVENT_STOP; + } + return Clutter.EVENT_PROPAGATE; + }); + this._nextButton.connect('button-press-event', () => { + this.emit('next-page'); + return Clutter.EVENT_STOP; + }); + this._nextButton.connect('touch-event', (actor, event) => { + if (event.type() === Clutter.EventType.TOUCH_BEGIN) { + this.emit('next-page'); + return Clutter.EVENT_STOP; + } + return Clutter.EVENT_PROPAGATE; + }); + + this._orientation = -1; + this._cursorPosition = 0; + } + + vfunc_scroll_event(scrollEvent) { + switch (scrollEvent.direction) { + case Clutter.ScrollDirection.UP: + this.emit('cursor-up'); + break; + case Clutter.ScrollDirection.DOWN: + this.emit('cursor-down'); + break; + } + return Clutter.EVENT_PROPAGATE; + } + + setOrientation(orientation) { + if (this._orientation == orientation) + return; + + this._orientation = orientation; + + if (this._orientation == IBus.Orientation.HORIZONTAL) { + this.vertical = false; + this.remove_style_class_name('vertical'); + this.add_style_class_name('horizontal'); + this._previousButton.icon_name = 'go-previous-symbolic'; + this._nextButton.icon_name = 'go-next-symbolic'; + } else { // VERTICAL || SYSTEM + this.vertical = true; + this.add_style_class_name('vertical'); + this.remove_style_class_name('horizontal'); + this._previousButton.icon_name = 'go-up-symbolic'; + this._nextButton.icon_name = 'go-down-symbolic'; + } + } + + setCandidates(indexes, candidates, cursorPosition, cursorVisible) { + for (let i = 0; i < MAX_CANDIDATES_PER_PAGE; ++i) { + let visible = i < candidates.length; + let box = this._candidateBoxes[i]; + box.visible = visible; + + if (!visible) + continue; + + box._indexLabel.text = indexes && indexes[i] ? indexes[i] : DEFAULT_INDEX_LABELS[i]; + box._candidateLabel.text = candidates[i]; + } + + this._candidateBoxes[this._cursorPosition].remove_style_pseudo_class('selected'); + this._cursorPosition = cursorPosition; + if (cursorVisible) + this._candidateBoxes[cursorPosition].add_style_pseudo_class('selected'); + } + + updateButtons(wrapsAround, page, nPages) { + if (nPages < 2) { + this._buttonBox.hide(); + return; + } + this._buttonBox.show(); + this._previousButton.reactive = wrapsAround || page > 0; + this._nextButton.reactive = wrapsAround || page < nPages - 1; + } +}); + +var CandidatePopup = GObject.registerClass( +class IbusCandidatePopup extends BoxPointer.BoxPointer { + _init() { + super._init(St.Side.TOP); + this.visible = false; + this.style_class = 'candidate-popup-boxpointer'; + + this._dummyCursor = new Clutter.Actor({ opacity: 0 }); + Main.layoutManager.uiGroup.add_actor(this._dummyCursor); + + Main.layoutManager.addTopChrome(this); + + const box = new St.BoxLayout({ + style_class: 'candidate-popup-content', + vertical: true, + }); + this.bin.set_child(box); + + this._preeditText = new St.Label({ + style_class: 'candidate-popup-text', + visible: false, + }); + box.add(this._preeditText); + + this._auxText = new St.Label({ + style_class: 'candidate-popup-text', + visible: false, + }); + box.add(this._auxText); + + this._candidateArea = new CandidateArea(); + box.add(this._candidateArea); + + this._candidateArea.connect('previous-page', () => { + this._panelService.page_up(); + }); + this._candidateArea.connect('next-page', () => { + this._panelService.page_down(); + }); + + this._candidateArea.connect('cursor-up', () => { + this._panelService.cursor_up(); + }); + this._candidateArea.connect('cursor-down', () => { + this._panelService.cursor_down(); + }); + + this._candidateArea.connect('candidate-clicked', (area, index, button, state) => { + this._panelService.candidate_clicked(index, button, state); + }); + + this._panelService = null; + } + + setPanelService(panelService) { + this._panelService = panelService; + if (!panelService) + return; + + panelService.connect('set-cursor-location', (ps, x, y, w, h) => { + this._setDummyCursorGeometry(x, y, w, h); + }); + try { + panelService.connect('set-cursor-location-relative', (ps, x, y, w, h) => { + if (!global.display.focus_window) + return; + let window = global.display.focus_window.get_compositor_private(); + this._setDummyCursorGeometry(window.x + x, window.y + y, w, h); + }); + } catch (e) { + // Only recent IBus versions have support for this signal + // which is used for wayland clients. In order to work + // with older IBus versions we can silently ignore the + // signal's absence. + } + panelService.connect('update-preedit-text', (ps, text, cursorPosition, visible) => { + this._preeditText.visible = visible; + this._updateVisibility(); + + this._preeditText.text = text.get_text(); + + let attrs = text.get_attributes(); + if (attrs) { + this._setTextAttributes(this._preeditText.clutter_text, + attrs); + } + }); + panelService.connect('show-preedit-text', () => { + this._preeditText.show(); + this._updateVisibility(); + }); + panelService.connect('hide-preedit-text', () => { + this._preeditText.hide(); + this._updateVisibility(); + }); + panelService.connect('update-auxiliary-text', (_ps, text, visible) => { + this._auxText.visible = visible; + this._updateVisibility(); + + this._auxText.text = text.get_text(); + }); + panelService.connect('show-auxiliary-text', () => { + this._auxText.show(); + this._updateVisibility(); + }); + panelService.connect('hide-auxiliary-text', () => { + this._auxText.hide(); + this._updateVisibility(); + }); + panelService.connect('update-lookup-table', (_ps, lookupTable, visible) => { + this._candidateArea.visible = visible; + this._updateVisibility(); + + let nCandidates = lookupTable.get_number_of_candidates(); + let cursorPos = lookupTable.get_cursor_pos(); + let pageSize = lookupTable.get_page_size(); + let nPages = Math.ceil(nCandidates / pageSize); + let page = cursorPos == 0 ? 0 : Math.floor(cursorPos / pageSize); + let startIndex = page * pageSize; + let endIndex = Math.min((page + 1) * pageSize, nCandidates); + + let indexes = []; + let indexLabel; + for (let i = 0; (indexLabel = lookupTable.get_label(i)); ++i) + indexes.push(indexLabel.get_text()); + + Main.keyboard.resetSuggestions(); + Main.keyboard.setSuggestionsVisible(visible); + + let candidates = []; + for (let i = startIndex; i < endIndex; ++i) { + candidates.push(lookupTable.get_candidate(i).get_text()); + + Main.keyboard.addSuggestion(lookupTable.get_candidate(i).get_text(), () => { + let index = i; + this._panelService.candidate_clicked(index, 1, 0); + }); + } + + this._candidateArea.setCandidates(indexes, + candidates, + cursorPos % pageSize, + lookupTable.is_cursor_visible()); + this._candidateArea.setOrientation(lookupTable.get_orientation()); + this._candidateArea.updateButtons(lookupTable.is_round(), page, nPages); + }); + panelService.connect('show-lookup-table', () => { + Main.keyboard.setSuggestionsVisible(true); + this._candidateArea.show(); + this._updateVisibility(); + }); + panelService.connect('hide-lookup-table', () => { + Main.keyboard.setSuggestionsVisible(false); + this._candidateArea.hide(); + this._updateVisibility(); + }); + panelService.connect('focus-out', () => { + this.close(BoxPointer.PopupAnimation.NONE); + Main.keyboard.resetSuggestions(); + }); + } + + _setDummyCursorGeometry(x, y, w, h) { + this._dummyCursor.set_position(Math.round(x), Math.round(y)); + this._dummyCursor.set_size(Math.round(w), Math.round(h)); + + if (this.visible) + this.setPosition(this._dummyCursor, 0); + } + + _updateVisibility() { + let isVisible = !Main.keyboard.visible && + (this._preeditText.visible || + this._auxText.visible || + this._candidateArea.visible); + + if (isVisible) { + this.setPosition(this._dummyCursor, 0); + this.open(BoxPointer.PopupAnimation.NONE); + // We shouldn't be above some components like the screenshot UI, + // so don't raise to the top. + // The on-screen keyboard is expected to be above any entries, + // so just above the keyboard gets us to the right layer. + const { keyboardBox } = Main.layoutManager; + this.get_parent().set_child_above_sibling(this, keyboardBox); + } else { + this.close(BoxPointer.PopupAnimation.NONE); + } + } + + _setTextAttributes(clutterText, ibusAttrList) { + let attr; + for (let i = 0; (attr = ibusAttrList.get(i)); ++i) { + if (attr.get_attr_type() == IBus.AttrType.BACKGROUND) + clutterText.set_selection(attr.get_start_index(), attr.get_end_index()); + } + } +}); diff --git a/js/ui/iconGrid.js b/js/ui/iconGrid.js new file mode 100644 index 0000000..ac8d3ec --- /dev/null +++ b/js/ui/iconGrid.js @@ -0,0 +1,1415 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported BaseIcon, IconGrid, IconGridLayout */ + +const { Clutter, GLib, GObject, Meta, Shell, St } = imports.gi; + +const Params = imports.misc.params; +const Main = imports.ui.main; + +var ICON_SIZE = 96; + +var PAGE_SWITCH_TIME = 300; + +var IconSize = { + LARGE: 96, + MEDIUM: 64, + MEDIUM_SMALL: 48, + SMALL: 32, + SMALLER: 24, + TINY: 16, +}; + +var APPICON_ANIMATION_OUT_SCALE = 3; +var APPICON_ANIMATION_OUT_TIME = 250; + +const ICON_POSITION_DELAY = 10; + +const defaultGridModes = [ + { + rows: 8, + columns: 3, + }, + { + rows: 6, + columns: 4, + }, + { + rows: 4, + columns: 6, + }, + { + rows: 3, + columns: 8, + }, +]; + +var LEFT_DIVIDER_LEEWAY = 20; +var RIGHT_DIVIDER_LEEWAY = 20; + +var DragLocation = { + INVALID: 0, + START_EDGE: 1, + ON_ICON: 2, + END_EDGE: 3, + EMPTY_SPACE: 4, +}; + +var BaseIcon = GObject.registerClass( +class BaseIcon extends Shell.SquareBin { + _init(label, params) { + params = Params.parse(params, { + createIcon: null, + setSizeManually: false, + showLabel: true, + }); + + let styleClass = 'overview-icon'; + if (params.showLabel) + styleClass += ' overview-icon-with-label'; + + super._init({ style_class: styleClass }); + + this._box = new St.BoxLayout({ + vertical: true, + x_expand: true, + y_expand: true, + }); + this.set_child(this._box); + + this.iconSize = ICON_SIZE; + this._iconBin = new St.Bin({ x_align: Clutter.ActorAlign.CENTER }); + + this._box.add_actor(this._iconBin); + + if (params.showLabel) { + this.label = new St.Label({ text: label }); + this.label.clutter_text.set({ + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + this._box.add_actor(this.label); + } else { + this.label = null; + } + + if (params.createIcon) + this.createIcon = params.createIcon; + this._setSizeManually = params.setSizeManually; + + this.icon = null; + + let cache = St.TextureCache.get_default(); + cache.connectObject( + 'icon-theme-changed', this._onIconThemeChanged.bind(this), this); + } + + // This can be overridden by a subclass, or by the createIcon + // parameter to _init() + createIcon(_size) { + throw new GObject.NotImplementedError(`createIcon in ${this.constructor.name}`); + } + + setIconSize(size) { + if (!this._setSizeManually) + throw new Error('setSizeManually has to be set to use setIconsize'); + + if (size === this.iconSize) + return; + + this._createIconTexture(size); + } + + _createIconTexture(size) { + if (this.icon) + this.icon.destroy(); + this.iconSize = size; + this.icon = this.createIcon(this.iconSize); + + this._iconBin.child = this.icon; + } + + vfunc_style_changed() { + super.vfunc_style_changed(); + let node = this.get_theme_node(); + + let size; + if (this._setSizeManually) { + size = this.iconSize; + } else { + const { scaleFactor } = + St.ThemeContext.get_for_stage(global.stage); + + let [found, len] = node.lookup_length('icon-size', false); + size = found ? len / scaleFactor : ICON_SIZE; + } + + if (this.iconSize === size && this._iconBin.child) + return; + + this._createIconTexture(size); + } + + _onIconThemeChanged() { + this._createIconTexture(this.iconSize); + } + + animateZoomOut() { + // Animate only the child instead of the entire actor, so the + // styles like hover and running are not applied while + // animating. + zoomOutActor(this.child); + } + + animateZoomOutAtPos(x, y) { + zoomOutActorAtPos(this.child, x, y); + } + + update() { + this._createIconTexture(this.iconSize); + } +}); + +function zoomOutActor(actor) { + let [x, y] = actor.get_transformed_position(); + zoomOutActorAtPos(actor, x, y); +} + +function zoomOutActorAtPos(actor, x, y) { + const monitor = Main.layoutManager.findMonitorForActor(actor); + if (!monitor) + return; + + const actorClone = new Clutter.Clone({ + source: actor, + reactive: false, + }); + let [width, height] = actor.get_transformed_size(); + + actorClone.set_size(width, height); + actorClone.set_position(x, y); + actorClone.opacity = 255; + actorClone.set_pivot_point(0.5, 0.5); + + Main.uiGroup.add_actor(actorClone); + + // Avoid monitor edges to not zoom outside the current monitor + let scaledWidth = width * APPICON_ANIMATION_OUT_SCALE; + let scaledHeight = height * APPICON_ANIMATION_OUT_SCALE; + let scaledX = x - (scaledWidth - width) / 2; + let scaledY = y - (scaledHeight - height) / 2; + let containedX = Math.clamp(scaledX, monitor.x, monitor.x + monitor.width - scaledWidth); + let containedY = Math.clamp(scaledY, monitor.y, monitor.y + monitor.height - scaledHeight); + + actorClone.ease({ + scale_x: APPICON_ANIMATION_OUT_SCALE, + scale_y: APPICON_ANIMATION_OUT_SCALE, + translation_x: containedX - scaledX, + translation_y: containedY - scaledY, + opacity: 0, + duration: APPICON_ANIMATION_OUT_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => actorClone.destroy(), + }); +} + +function animateIconPosition(icon, box, nChangedIcons) { + if (!icon.has_allocation() || icon.allocation.equal(box) || icon.opacity === 0) { + icon.allocate(box); + return false; + } + + icon.save_easing_state(); + icon.set_easing_mode(Clutter.AnimationMode.EASE_OUT_QUAD); + icon.set_easing_delay(nChangedIcons * ICON_POSITION_DELAY); + + icon.allocate(box); + + icon.restore_easing_state(); + + return true; +} + +function swap(value, length) { + return length - value - 1; +} + +var IconGridLayout = GObject.registerClass({ + Properties: { + 'allow-incomplete-pages': GObject.ParamSpec.boolean('allow-incomplete-pages', + 'Allow incomplete pages', 'Allow incomplete pages', + GObject.ParamFlags.READWRITE, + true), + 'column-spacing': GObject.ParamSpec.int('column-spacing', + 'Column spacing', 'Column spacing', + GObject.ParamFlags.READWRITE, + 0, GLib.MAXINT32, 0), + 'columns-per-page': GObject.ParamSpec.int('columns-per-page', + 'Columns per page', 'Columns per page', + GObject.ParamFlags.READWRITE, + 1, GLib.MAXINT32, 6), + 'fixed-icon-size': GObject.ParamSpec.int('fixed-icon-size', + 'Fixed icon size', 'Fixed icon size', + GObject.ParamFlags.READWRITE, + -1, GLib.MAXINT32, -1), + 'icon-size': GObject.ParamSpec.int('icon-size', + 'Icon size', 'Icon size', + GObject.ParamFlags.READABLE, + 0, GLib.MAXINT32, 0), + 'last-row-align': GObject.ParamSpec.enum('last-row-align', + 'Last row align', 'Last row align', + GObject.ParamFlags.READWRITE, + Clutter.ActorAlign.$gtype, + Clutter.ActorAlign.FILL), + 'max-column-spacing': GObject.ParamSpec.int('max-column-spacing', + 'Maximum column spacing', 'Maximum column spacing', + GObject.ParamFlags.READWRITE, + -1, GLib.MAXINT32, -1), + 'max-row-spacing': GObject.ParamSpec.int('max-row-spacing', + 'Maximum row spacing', 'Maximum row spacing', + GObject.ParamFlags.READWRITE, + -1, GLib.MAXINT32, -1), + 'orientation': GObject.ParamSpec.enum('orientation', + 'Orientation', 'Orientation', + GObject.ParamFlags.READWRITE, + Clutter.Orientation.$gtype, + Clutter.Orientation.VERTICAL), + 'page-halign': GObject.ParamSpec.enum('page-halign', + 'Horizontal page align', + 'Horizontal page align', + GObject.ParamFlags.READWRITE, + Clutter.ActorAlign.$gtype, + Clutter.ActorAlign.FILL), + 'page-padding': GObject.ParamSpec.boxed('page-padding', + 'Page padding', 'Page padding', + GObject.ParamFlags.READWRITE, + Clutter.Margin.$gtype), + 'page-valign': GObject.ParamSpec.enum('page-valign', + 'Vertical page align', + 'Vertical page align', + GObject.ParamFlags.READWRITE, + Clutter.ActorAlign.$gtype, + Clutter.ActorAlign.FILL), + 'row-spacing': GObject.ParamSpec.int('row-spacing', + 'Row spacing', 'Row spacing', + GObject.ParamFlags.READWRITE, + 0, GLib.MAXINT32, 0), + 'rows-per-page': GObject.ParamSpec.int('rows-per-page', + 'Rows per page', 'Rows per page', + GObject.ParamFlags.READWRITE, + 1, GLib.MAXINT32, 4), + }, + Signals: { + 'pages-changed': {}, + }, +}, class IconGridLayout extends Clutter.LayoutManager { + _init(params = {}) { + this._orientation = params.orientation ?? Clutter.Orientation.VERTICAL; + + super._init(params); + + if (!this.pagePadding) + this.pagePadding = new Clutter.Margin(); + + this._iconSize = this.fixedIconSize !== -1 + ? this.fixedIconSize + : IconSize.LARGE; + + this._pageSizeChanged = false; + this._pageHeight = 0; + this._pageWidth = 0; + this._nPages = -1; + + // [ + // { + // children: [ itemData, itemData, itemData, ... ], + // }, + // { + // children: [ itemData, itemData, itemData, ... ], + // }, + // { + // children: [ itemData, itemData, itemData, ... ], + // }, + // ] + this._pages = []; + + // { + // item: { + // actor: Clutter.Actor, + // pageIndex: <index>, + // }, + // item: { + // actor: Clutter.Actor, + // pageIndex: <index>, + // }, + // } + this._items = new Map(); + + this._containerDestroyedId = 0; + this._updateIconSizesLaterId = 0; + + this._childrenMaxSize = -1; + } + + _findBestIconSize() { + const nColumns = this.columnsPerPage; + const nRows = this.rowsPerPage; + const columnSpacingPerPage = this.columnSpacing * (nColumns - 1); + const rowSpacingPerPage = this.rowSpacing * (nRows - 1); + const [firstItem] = this._container; + + if (this.fixedIconSize !== -1) + return this.fixedIconSize; + + const iconSizes = Object.values(IconSize).sort((a, b) => b - a); + for (const size of iconSizes) { + let usedWidth, usedHeight; + + if (firstItem) { + firstItem.icon.setIconSize(size); + const [firstItemWidth, firstItemHeight] = + firstItem.get_preferred_size(); + + const itemSize = Math.max(firstItemWidth, firstItemHeight); + + usedWidth = itemSize * nColumns; + usedHeight = itemSize * nRows; + } else { + usedWidth = size * nColumns; + usedHeight = size * nRows; + } + + const emptyHSpace = + this._pageWidth - usedWidth - columnSpacingPerPage - + this.pagePadding.left - this.pagePadding.right; + const emptyVSpace = + this._pageHeight - usedHeight - rowSpacingPerPage - + this.pagePadding.top - this.pagePadding.bottom; + + if (emptyHSpace >= 0 && emptyVSpace > 0) + return size; + } + + return IconSize.TINY; + } + + _getChildrenMaxSize() { + if (this._childrenMaxSize === -1) { + let minWidth = 0; + let minHeight = 0; + + const nPages = this._pages.length; + for (let pageIndex = 0; pageIndex < nPages; pageIndex++) { + const page = this._pages[pageIndex]; + const nVisibleItems = page.visibleChildren.length; + for (let itemIndex = 0; itemIndex < nVisibleItems; itemIndex++) { + const item = page.visibleChildren[itemIndex]; + + const childMinHeight = item.get_preferred_height(-1)[0]; + const childMinWidth = item.get_preferred_width(-1)[0]; + + minWidth = Math.max(minWidth, childMinWidth); + minHeight = Math.max(minHeight, childMinHeight); + } + } + + this._childrenMaxSize = Math.max(minWidth, minHeight); + } + + return this._childrenMaxSize; + } + + _updateVisibleChildrenForPage(pageIndex) { + this._pages[pageIndex].visibleChildren = + this._pages[pageIndex].children.filter(actor => actor.visible); + } + + _updatePages() { + for (let i = 0; i < this._pages.length; i++) + this._relocateSurplusItems(i); + } + + _unlinkItem(item) { + const itemData = this._items.get(item); + + item.disconnect(itemData.destroyId); + item.disconnect(itemData.visibleId); + item.disconnect(itemData.queueRelayoutId); + + this._items.delete(item); + } + + _removePage(pageIndex) { + // Make sure to not leave any icon left here + this._pages[pageIndex].children.forEach(item => { + this._unlinkItem(item); + }); + + // Adjust the page indexes of items after this page + for (const itemData of this._items.values()) { + if (itemData.pageIndex > pageIndex) + itemData.pageIndex--; + } + + this._pages.splice(pageIndex, 1); + this.emit('pages-changed'); + } + + _fillItemVacancies(pageIndex) { + if (pageIndex >= this._pages.length - 1) + return; + + const visiblePageItems = this._pages[pageIndex].visibleChildren; + const itemsPerPage = this.columnsPerPage * this.rowsPerPage; + + // No reduce needed + if (visiblePageItems.length === itemsPerPage) + return; + + const visibleNextPageItems = this._pages[pageIndex + 1].visibleChildren; + const nMissingItems = Math.min(itemsPerPage - visiblePageItems.length, visibleNextPageItems.length); + + // Append to the current page the first items of the next page + for (let i = 0; i < nMissingItems; i++) { + const reducedItem = visibleNextPageItems[i]; + + this._removeItemData(reducedItem); + this._addItemToPage(reducedItem, pageIndex, -1); + } + } + + _removeItemData(item) { + const itemData = this._items.get(item); + const pageIndex = itemData.pageIndex; + const page = this._pages[pageIndex]; + const itemIndex = page.children.indexOf(item); + + this._unlinkItem(item); + + page.children.splice(itemIndex, 1); + + this._updateVisibleChildrenForPage(pageIndex); + + // Delete the page if this is the last icon in it + const visibleItems = this._pages[pageIndex].visibleChildren; + if (visibleItems.length === 0) + this._removePage(pageIndex); + + if (!this.allowIncompletePages) + this._fillItemVacancies(pageIndex); + } + + _relocateSurplusItems(pageIndex) { + const visiblePageItems = this._pages[pageIndex].visibleChildren; + const itemsPerPage = this.columnsPerPage * this.rowsPerPage; + + // No overflow needed + if (visiblePageItems.length <= itemsPerPage) + return; + + const nExtraItems = visiblePageItems.length - itemsPerPage; + for (let i = 0; i < nExtraItems; i++) { + const overflowIndex = visiblePageItems.length - i - 1; + const overflowItem = visiblePageItems[overflowIndex]; + + this._removeItemData(overflowItem); + this._addItemToPage(overflowItem, pageIndex + 1, 0); + } + } + + _appendPage() { + this._pages.push({ children: [] }); + this.emit('pages-changed'); + } + + _addItemToPage(item, pageIndex, index) { + // Ensure we have at least one page + if (this._pages.length === 0) + this._appendPage(); + + // Append a new page if necessary + if (pageIndex === this._pages.length) + this._appendPage(); + + if (pageIndex === -1) + pageIndex = this._pages.length - 1; + + if (index === -1) + index = this._pages[pageIndex].children.length; + + this._items.set(item, { + actor: item, + pageIndex, + destroyId: item.connect('destroy', () => this._removeItemData(item)), + visibleId: item.connect('notify::visible', () => { + const itemData = this._items.get(item); + + this._updateVisibleChildrenForPage(itemData.pageIndex); + + if (item.visible) + this._relocateSurplusItems(itemData.pageIndex); + else if (!this.allowIncompletePages) + this._fillItemVacancies(itemData.pageIndex); + }), + queueRelayoutId: item.connect('queue-relayout', () => { + this._childrenMaxSize = -1; + }), + }); + + item.icon.setIconSize(this._iconSize); + + this._pages[pageIndex].children.splice(index, 0, item); + this._updateVisibleChildrenForPage(pageIndex); + this._relocateSurplusItems(pageIndex); + } + + _calculateSpacing(childSize) { + const nColumns = this.columnsPerPage; + const nRows = this.rowsPerPage; + const usedWidth = childSize * nColumns; + const usedHeight = childSize * nRows; + const columnSpacingPerPage = this.columnSpacing * (nColumns - 1); + const rowSpacingPerPage = this.rowSpacing * (nRows - 1); + + const emptyHSpace = + this._pageWidth - usedWidth - columnSpacingPerPage - + this.pagePadding.left - this.pagePadding.right; + const emptyVSpace = + this._pageHeight - usedHeight - rowSpacingPerPage - + this.pagePadding.top - this.pagePadding.bottom; + let leftEmptySpace = this.pagePadding.left; + let topEmptySpace = this.pagePadding.top; + let hSpacing; + let vSpacing; + + switch (this.pageHalign) { + case Clutter.ActorAlign.START: + hSpacing = this.columnSpacing; + break; + case Clutter.ActorAlign.CENTER: + leftEmptySpace += Math.floor(emptyHSpace / 2); + hSpacing = this.columnSpacing; + break; + case Clutter.ActorAlign.END: + leftEmptySpace += emptyHSpace; + hSpacing = this.columnSpacing; + break; + case Clutter.ActorAlign.FILL: + hSpacing = this.columnSpacing + emptyHSpace / (nColumns - 1); + + // Maybe constraint horizontal spacing + if (this.maxColumnSpacing !== -1 && hSpacing > this.maxColumnSpacing) { + const extraHSpacing = + (this.maxColumnSpacing - this.columnSpacing) * (nColumns - 1); + + hSpacing = this.maxColumnSpacing; + leftEmptySpace += + Math.max((emptyHSpace - extraHSpacing) / 2, 0); + } + break; + } + + switch (this.pageValign) { + case Clutter.ActorAlign.START: + vSpacing = this.rowSpacing; + break; + case Clutter.ActorAlign.CENTER: + topEmptySpace += Math.floor(emptyVSpace / 2); + vSpacing = this.rowSpacing; + break; + case Clutter.ActorAlign.END: + topEmptySpace += emptyVSpace; + vSpacing = this.rowSpacing; + break; + case Clutter.ActorAlign.FILL: + vSpacing = this.rowSpacing + emptyVSpace / (nRows - 1); + + // Maybe constraint vertical spacing + if (this.maxRowSpacing !== -1 && vSpacing > this.maxRowSpacing) { + const extraVSpacing = + (this.maxRowSpacing - this.rowSpacing) * (nRows - 1); + + vSpacing = this.maxRowSpacing; + topEmptySpace += + Math.max((emptyVSpace - extraVSpacing) / 2, 0); + } + + break; + } + + return [leftEmptySpace, topEmptySpace, hSpacing, vSpacing]; + } + + _getRowPadding(align, items, itemIndex, childSize, spacing) { + if (align === Clutter.ActorAlign.START || + align === Clutter.ActorAlign.FILL) + return 0; + + const nRows = Math.ceil(items.length / this.columnsPerPage); + + let rowAlign = 0; + const row = Math.floor(itemIndex / this.columnsPerPage); + + // Only apply to the last row + if (row < nRows - 1) + return 0; + + const rowStart = row * this.columnsPerPage; + const rowEnd = Math.min((row + 1) * this.columnsPerPage - 1, items.length - 1); + const itemsInThisRow = rowEnd - rowStart + 1; + const nEmpty = this.columnsPerPage - itemsInThisRow; + const availableWidth = nEmpty * (spacing + childSize); + + const isRtl = + Clutter.get_default_text_direction() === Clutter.TextDirection.RTL; + + switch (align) { + case Clutter.ActorAlign.CENTER: + rowAlign = availableWidth / 2; + break; + case Clutter.ActorAlign.END: + rowAlign = availableWidth; + break; + // START and FILL align are handled at the beginning of the function + } + + return isRtl ? rowAlign * -1 : rowAlign; + } + + _onDestroy() { + if (this._updateIconSizesLaterId >= 0) { + Meta.later_remove(this._updateIconSizesLaterId); + this._updateIconSizesLaterId = 0; + } + } + + vfunc_set_container(container) { + this._container?.disconnectObject(this); + + this._container = container; + + if (this._container) + this._container.connectObject('destroy', this._onDestroy.bind(this), this); + } + + vfunc_get_preferred_width(_container, _forHeight) { + let minWidth = -1; + let natWidth = -1; + + switch (this._orientation) { + case Clutter.Orientation.VERTICAL: + minWidth = IconSize.TINY; + natWidth = this._pageWidth; + break; + + case Clutter.Orientation.HORIZONTAL: + minWidth = this._pageWidth * this._pages.length; + natWidth = minWidth; + break; + } + + return [minWidth, natWidth]; + } + + vfunc_get_preferred_height(_container, _forWidth) { + let minHeight = -1; + let natHeight = -1; + + switch (this._orientation) { + case Clutter.Orientation.VERTICAL: + minHeight = this._pageHeight * this._pages.length; + natHeight = minHeight; + break; + + case Clutter.Orientation.HORIZONTAL: + minHeight = IconSize.TINY; + natHeight = this._pageHeight; + break; + } + + return [minHeight, natHeight]; + } + + vfunc_allocate() { + if (this._pageWidth === 0 || this._pageHeight === 0) + throw new Error('IconGridLayout.adaptToSize wasn\'t called before allocation'); + + const isRtl = + Clutter.get_default_text_direction() === Clutter.TextDirection.RTL; + const childSize = this._getChildrenMaxSize(); + + const [leftEmptySpace, topEmptySpace, hSpacing, vSpacing] = + this._calculateSpacing(childSize); + + const childBox = new Clutter.ActorBox(); + + let nChangedIcons = 0; + const columnsPerPage = this.columnsPerPage; + const orientation = this._orientation; + const pageWidth = this._pageWidth; + const pageHeight = this._pageHeight; + const pageSizeChanged = this._pageSizeChanged; + const lastRowAlign = this.lastRowAlign; + const shouldEaseItems = this._shouldEaseItems; + + this._pages.forEach((page, pageIndex) => { + if (isRtl && orientation === Clutter.Orientation.HORIZONTAL) + pageIndex = swap(pageIndex, this._pages.length); + + page.visibleChildren.forEach((item, itemIndex) => { + const row = Math.floor(itemIndex / columnsPerPage); + let column = itemIndex % columnsPerPage; + + if (isRtl) + column = swap(column, columnsPerPage); + + const rowPadding = this._getRowPadding(lastRowAlign, + page.visibleChildren, itemIndex, childSize, hSpacing); + + // Icon position + let x = leftEmptySpace + rowPadding + column * (childSize + hSpacing); + let y = topEmptySpace + row * (childSize + vSpacing); + + // Page start + switch (orientation) { + case Clutter.Orientation.HORIZONTAL: + x += pageIndex * pageWidth; + break; + case Clutter.Orientation.VERTICAL: + y += pageIndex * pageHeight; + break; + } + + childBox.set_origin(Math.floor(x), Math.floor(y)); + + const [,, naturalWidth, naturalHeight] = item.get_preferred_size(); + childBox.set_size( + Math.max(childSize, naturalWidth), + Math.max(childSize, naturalHeight)); + + if (!shouldEaseItems || pageSizeChanged) + item.allocate(childBox); + else if (animateIconPosition(item, childBox, nChangedIcons)) + nChangedIcons++; + }); + }); + + this._pageSizeChanged = false; + this._shouldEaseItems = false; + } + + /** + * addItem: + * @param {Clutter.Actor} item: item to append to the grid + * @param {int} page: page number + * @param {int} index: position in the page + * + * Adds @item to the grid. @item must not be part of the grid. + * + * If @index exceeds the number of items per page, @item will + * be added to the next page. + * + * @page must be a number between 0 and the number of pages. + * Adding to the page after next will create a new page. + */ + addItem(item, page = -1, index = -1) { + if (this._items.has(item)) + throw new Error(`Item ${item} already added to IconGridLayout`); + + if (page > this._pages.length) + throw new Error(`Cannot add ${item} to page ${page}`); + + if (!this._container) + return; + + this._shouldEaseItems = true; + + this._container.add_child(item); + this._addItemToPage(item, page, index); + } + + /** + * appendItem: + * @param {Clutter.Actor} item: item to append to the grid + * + * Appends @item to the grid. @item must not be part of the grid. + */ + appendItem(item) { + this.addItem(item); + } + + /** + * moveItem: + * @param {Clutter.Actor} item: item to move + * @param {int} newPage: new page of the item + * @param {int} newPosition: new page of the item + * + * Moves @item to the grid. @item must be part of the grid. + */ + moveItem(item, newPage, newPosition) { + if (!this._items.has(item)) + throw new Error(`Item ${item} is not part of the IconGridLayout`); + + this._shouldEaseItems = true; + + this._removeItemData(item); + this._addItemToPage(item, newPage, newPosition); + } + + /** + * removeItem: + * @param {Clutter.Actor} item: item to remove from the grid + * + * Removes @item to the grid. @item must be part of the grid. + */ + removeItem(item) { + if (!this._items.has(item)) + throw new Error(`Item ${item} is not part of the IconGridLayout`); + + if (!this._container) + return; + + this._shouldEaseItems = true; + + this._container.remove_child(item); + this._removeItemData(item); + } + + /** + * getItemsAtPage: + * @param {int} pageIndex: page index + * + * Retrieves the children at page @pageIndex. Children may be invisible. + * + * @returns {Array} an array of {Clutter.Actor}s + */ + getItemsAtPage(pageIndex) { + if (pageIndex >= this._pages.length) + throw new Error(`IconGridLayout does not have page ${pageIndex}`); + + return [...this._pages[pageIndex].children]; + } + + /** + * getItemPosition: + * @param {BaseIcon} item: the item + * + * Retrieves the position of @item is its page, or -1 if @item is not + * part of the grid. + * + * @returns {[int, int]} the page and position of @item + */ + getItemPosition(item) { + if (!this._items.has(item)) + return [-1, -1]; + + const itemData = this._items.get(item); + const visibleItems = this._pages[itemData.pageIndex].visibleChildren; + + return [itemData.pageIndex, visibleItems.indexOf(item)]; + } + + /** + * getItemAt: + * @param {int} page: the page + * @param {int} position: the position in page + * + * Retrieves the item at @page and @position. + * + * @returns {BaseItem} the item at @page and @position, or null + */ + getItemAt(page, position) { + if (page < 0 || page >= this._pages.length) + return null; + + const visibleItems = this._pages[page].visibleChildren; + + if (position < 0 || position >= visibleItems.length) + return null; + + return visibleItems[position]; + } + + /** + * getItemPage: + * @param {BaseIcon} item: the item + * + * Retrieves the page @item is in, or -1 if @item is not part of the grid. + * + * @returns {int} the page where @item is in + */ + getItemPage(item) { + if (!this._items.has(item)) + return -1; + + const itemData = this._items.get(item); + return itemData.pageIndex; + } + + ensureIconSizeUpdated() { + if (this._updateIconSizesLaterId === 0) + return Promise.resolve(); + + return new Promise( + resolve => this._iconSizeUpdateResolveCbs.push(resolve)); + } + + adaptToSize(pageWidth, pageHeight) { + if (this._pageWidth === pageWidth && this._pageHeight === pageHeight) + return; + + this._pageWidth = pageWidth; + this._pageHeight = pageHeight; + this._pageSizeChanged = true; + + if (this._updateIconSizesLaterId === 0) { + this._updateIconSizesLaterId = + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + const iconSize = this._findBestIconSize(); + + if (this._iconSize !== iconSize) { + this._iconSize = iconSize; + + for (const child of this._container) + child.icon.setIconSize(iconSize); + + this.notify('icon-size'); + } + + this._updateIconSizesLaterId = 0; + return GLib.SOURCE_REMOVE; + }); + } + } + + /** + * getDropTarget: + * @param {int} x: position of the horizontal axis + * @param {int} y: position of the vertical axis + * + * Retrieves the item located at (@x, @y), as well as the drag location. + * Both @x and @y are relative to the grid. + * + * @returns {[Clutter.Actor, DragLocation]} the item and drag location + * under (@x, @y) + */ + getDropTarget(x, y) { + const childSize = this._getChildrenMaxSize(); + const [leftEmptySpace, topEmptySpace, hSpacing, vSpacing] = + this._calculateSpacing(childSize); + + const isRtl = + Clutter.get_default_text_direction() === Clutter.TextDirection.RTL; + + let page = this._orientation === Clutter.Orientation.VERTICAL + ? Math.floor(y / this._pageHeight) + : Math.floor(x / this._pageWidth); + + // Out of bounds + if (page >= this._pages.length) + return [null, DragLocation.INVALID]; + + if (isRtl && this._orientation === Clutter.Orientation.HORIZONTAL) + page = swap(page, this._pages.length); + + // Page-relative coordinates from now on + if (this._orientation === Clutter.Orientation.HORIZONTAL) + x %= this._pageWidth; + else + y %= this._pageHeight; + + if (x < leftEmptySpace || y < topEmptySpace) + return [null, DragLocation.INVALID]; + + const gridWidth = + childSize * this.columnsPerPage + + hSpacing * (this.columnsPerPage - 1); + const gridHeight = + childSize * this.rowsPerPage + + vSpacing * (this.rowsPerPage - 1); + + if (x > leftEmptySpace + gridWidth || y > topEmptySpace + gridHeight) + return [null, DragLocation.INVALID]; + + const halfHSpacing = hSpacing / 2; + const halfVSpacing = vSpacing / 2; + const visibleItems = this._pages[page].visibleChildren; + + for (const item of visibleItems) { + const childBox = item.allocation.copy(); + + // Page offset + switch (this._orientation) { + case Clutter.Orientation.HORIZONTAL: + childBox.set_origin(childBox.x1 % this._pageWidth, childBox.y1); + break; + case Clutter.Orientation.VERTICAL: + childBox.set_origin(childBox.x1, childBox.y1 % this._pageHeight); + break; + } + + // Outside the icon boundaries + if (x < childBox.x1 - halfHSpacing || + x > childBox.x2 + halfHSpacing || + y < childBox.y1 - halfVSpacing || + y > childBox.y2 + halfVSpacing) + continue; + + let dragLocation; + + if (x < childBox.x1 + LEFT_DIVIDER_LEEWAY) + dragLocation = DragLocation.START_EDGE; + else if (x > childBox.x2 - RIGHT_DIVIDER_LEEWAY) + dragLocation = DragLocation.END_EDGE; + else + dragLocation = DragLocation.ON_ICON; + + if (isRtl) { + if (dragLocation === DragLocation.START_EDGE) + dragLocation = DragLocation.END_EDGE; + else if (dragLocation === DragLocation.END_EDGE) + dragLocation = DragLocation.START_EDGE; + } + + return [item, dragLocation]; + } + + return [null, DragLocation.EMPTY_SPACE]; + } + + get iconSize() { + return this._iconSize; + } + + get nPages() { + return this._pages.length; + } + + get orientation() { + return this._orientation; + } + + set orientation(v) { + if (this._orientation === v) + return; + + switch (v) { + case Clutter.Orientation.VERTICAL: + this.request_mode = Clutter.RequestMode.HEIGHT_FOR_WIDTH; + break; + case Clutter.Orientation.HORIZONTAL: + this.request_mode = Clutter.RequestMode.WIDTH_FOR_HEIGHT; + break; + } + + this._orientation = v; + this.notify('orientation'); + } + + get pageHeight() { + return this._pageHeight; + } + + get pageWidth() { + return this._pageWidth; + } +}); + +var IconGrid = GObject.registerClass({ + Signals: { + 'pages-changed': {}, + }, +}, class IconGrid extends St.Viewport { + _init(layoutParams = {}) { + layoutParams = Params.parse(layoutParams, { + allow_incomplete_pages: false, + orientation: Clutter.Orientation.HORIZONTAL, + columns_per_page: 6, + rows_per_page: 4, + page_halign: Clutter.ActorAlign.FILL, + page_padding: new Clutter.Margin(), + page_valign: Clutter.ActorAlign.FILL, + last_row_align: Clutter.ActorAlign.START, + column_spacing: 0, + row_spacing: 0, + }); + const layoutManager = new IconGridLayout(layoutParams); + const pagesChangedId = layoutManager.connect('pages-changed', + () => this.emit('pages-changed')); + + super._init({ + style_class: 'icon-grid', + layoutManager, + x_expand: true, + y_expand: true, + }); + + this._gridModes = defaultGridModes; + this._currentPage = 0; + this._currentMode = -1; + + this.connect('actor-added', this._childAdded.bind(this)); + this.connect('actor-removed', this._childRemoved.bind(this)); + this.connect('destroy', () => layoutManager.disconnect(pagesChangedId)); + } + + _childAdded(grid, child) { + child._iconGridKeyFocusInId = child.connect('key-focus-in', () => { + this._ensureItemIsVisible(child); + }); + } + + _ensureItemIsVisible(item) { + if (!this.contains(item)) + throw new Error(`${item} is not a child of IconGrid`); + + const itemPage = this.layout_manager.getItemPage(item); + this.goToPage(itemPage); + } + + _setGridMode(modeIndex) { + if (this._currentMode === modeIndex) + return; + + this._currentMode = modeIndex; + + if (modeIndex !== -1) { + const newMode = this._gridModes[modeIndex]; + + this.layout_manager.rows_per_page = newMode.rows; + this.layout_manager.columns_per_page = newMode.columns; + } + } + + _findBestModeForSize(width, height) { + const { pagePadding } = this.layout_manager; + width -= pagePadding.left + pagePadding.right; + height -= pagePadding.top + pagePadding.bottom; + + const sizeRatio = width / height; + let closestRatio = Infinity; + let bestMode = -1; + + for (let modeIndex in this._gridModes) { + const mode = this._gridModes[modeIndex]; + const modeRatio = mode.columns / mode.rows; + + if (Math.abs(sizeRatio - modeRatio) < Math.abs(sizeRatio - closestRatio)) { + closestRatio = modeRatio; + bestMode = modeIndex; + } + } + + this._setGridMode(bestMode); + } + + _childRemoved(grid, child) { + child.disconnect(child._iconGridKeyFocusInId); + delete child._iconGridKeyFocusInId; + } + + vfunc_allocate(box) { + const [width, height] = box.get_size(); + this._findBestModeForSize(width, height); + this.layout_manager.adaptToSize(width, height); + super.vfunc_allocate(box); + } + + vfunc_style_changed() { + super.vfunc_style_changed(); + + const node = this.get_theme_node(); + this.layout_manager.column_spacing = node.get_length('column-spacing'); + this.layout_manager.row_spacing = node.get_length('row-spacing'); + + let [found, value] = node.lookup_length('max-column-spacing', false); + this.layout_manager.max_column_spacing = found ? value : -1; + + [found, value] = node.lookup_length('max-row-spacing', false); + this.layout_manager.max_row_spacing = found ? value : -1; + + const padding = new Clutter.Margin(); + ['top', 'right', 'bottom', 'left'].forEach(side => { + padding[side] = node.get_length(`page-padding-${side}`); + }); + this.layout_manager.page_padding = padding; + } + + /** + * addItem: + * @param {Clutter.Actor} item: item to append to the grid + * @param {int} page: page number + * @param {int} index: position in the page + * + * Adds @item to the grid. @item must not be part of the grid. + * + * If @index exceeds the number of items per page, @item will + * be added to the next page. + * + * @page must be a number between 0 and the number of pages. + * Adding to the page after next will create a new page. + */ + addItem(item, page = -1, index = -1) { + if (!(item.icon instanceof BaseIcon)) + throw new Error('Only items with a BaseIcon icon property can be added to IconGrid'); + + this.layout_manager.addItem(item, page, index); + } + + /** + * appendItem: + * @param {Clutter.Actor} item: item to append to the grid + * + * Appends @item to the grid. @item must not be part of the grid. + */ + appendItem(item) { + this.layout_manager.appendItem(item); + } + + /** + * moveItem: + * @param {Clutter.Actor} item: item to move + * @param {int} newPage: new page of the item + * @param {int} newPosition: new page of the item + * + * Moves @item to the grid. @item must be part of the grid. + */ + moveItem(item, newPage, newPosition) { + this.layout_manager.moveItem(item, newPage, newPosition); + this.queue_relayout(); + } + + /** + * removeItem: + * @param {Clutter.Actor} item: item to remove from the grid + * + * Removes @item to the grid. @item must be part of the grid. + */ + removeItem(item) { + if (!this.contains(item)) + throw new Error(`Item ${item} is not part of the IconGrid`); + + this.layout_manager.removeItem(item); + } + + /** + * goToPage: + * @param {int} pageIndex: page index + * @param {boolean} animate: animate the page transition + * + * Moves the current page to @pageIndex. @pageIndex must be a valid page + * number. + */ + goToPage(pageIndex, animate = true) { + if (pageIndex >= this.nPages) + throw new Error(`IconGrid does not have page ${pageIndex}`); + + let newValue; + let adjustment; + switch (this.layout_manager.orientation) { + case Clutter.Orientation.VERTICAL: + adjustment = this.vadjustment; + newValue = pageIndex * this.layout_manager.pageHeight; + break; + case Clutter.Orientation.HORIZONTAL: + adjustment = this.hadjustment; + newValue = pageIndex * this.layout_manager.pageWidth; + break; + } + + this._currentPage = pageIndex; + + if (!this.mapped) + animate = false; + + adjustment.ease(newValue, { + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + duration: animate ? PAGE_SWITCH_TIME : 0, + }); + } + + /** + * getItemPage: + * @param {BaseIcon} item: the item + * + * Retrieves the page @item is in, or -1 if @item is not part of the grid. + * + * @returns {int} the page where @item is in + */ + getItemPage(item) { + return this.layout_manager.getItemPage(item); + } + + /** + * getItemPosition: + * @param {BaseIcon} item: the item + * + * Retrieves the position of @item is its page, or -1 if @item is not + * part of the grid. + * + * @returns {[int, int]} the page and position of @item + */ + getItemPosition(item) { + if (!this.contains(item)) + return [-1, -1]; + + const layoutManager = this.layout_manager; + return layoutManager.getItemPosition(item); + } + + /** + * getItemAt: + * @param {int} page: the page + * @param {int} position: the position in page + * + * Retrieves the item at @page and @position. + * + * @returns {BaseItem} the item at @page and @position, or null + */ + getItemAt(page, position) { + const layoutManager = this.layout_manager; + return layoutManager.getItemAt(page, position); + } + + /** + * getItemsAtPage: + * @param {int} page: the page index + * + * Retrieves the children at page @page, including invisible children. + * + * @returns {Array} an array of {Clutter.Actor}s + */ + getItemsAtPage(page) { + if (page < 0 || page > this.nPages) + throw new Error(`Page ${page} does not exist at IconGrid`); + + const layoutManager = this.layout_manager; + return layoutManager.getItemsAtPage(page); + } + + get currentPage() { + return this._currentPage; + } + + set currentPage(v) { + this.goToPage(v); + } + + get nPages() { + return this.layout_manager.nPages; + } + + setGridModes(modes) { + this._gridModes = modes ? modes : defaultGridModes; + this.queue_relayout(); + } + + getDropTarget(x, y) { + const layoutManager = this.layout_manager; + return layoutManager.getDropTarget(x, y, this._currentPage); + } + + get itemsPerPage() { + const layoutManager = this.layout_manager; + return layoutManager.rows_per_page * layoutManager.columns_per_page; + } +}); diff --git a/js/ui/inhibitShortcutsDialog.js b/js/ui/inhibitShortcutsDialog.js new file mode 100644 index 0000000..7c3d159 --- /dev/null +++ b/js/ui/inhibitShortcutsDialog.js @@ -0,0 +1,160 @@ +/* exported InhibitShortcutsDialog */ +const {Clutter, Gio, GObject, Gtk, Meta, Pango, Shell, St} = imports.gi; + +const Dialog = imports.ui.dialog; +const ModalDialog = imports.ui.modalDialog; +const PermissionStore = imports.misc.permissionStore; + +const WAYLAND_KEYBINDINGS_SCHEMA = 'org.gnome.mutter.wayland.keybindings'; + +const APP_ALLOWLIST = ['org.gnome.Settings.desktop']; +const APP_PERMISSIONS_TABLE = 'gnome'; +const APP_PERMISSIONS_ID = 'shortcuts-inhibitor'; +const GRANTED = 'GRANTED'; +const DENIED = 'DENIED'; + +var DialogResponse = Meta.InhibitShortcutsDialogResponse; + +var InhibitShortcutsDialog = GObject.registerClass({ + Implements: [Meta.InhibitShortcutsDialog], + Properties: { + 'window': GObject.ParamSpec.override('window', Meta.InhibitShortcutsDialog), + }, +}, class InhibitShortcutsDialog extends GObject.Object { + _init(window) { + super._init(); + this._window = window; + + this._dialog = new ModalDialog.ModalDialog(); + this._buildLayout(); + } + + get window() { + return this._window; + } + + set window(window) { + this._window = window; + } + + get _app() { + let windowTracker = Shell.WindowTracker.get_default(); + return windowTracker.get_window_app(this._window); + } + + _getRestoreAccel() { + let settings = new Gio.Settings({ schema_id: WAYLAND_KEYBINDINGS_SCHEMA }); + let accel = settings.get_strv('restore-shortcuts')[0] || ''; + return Gtk.accelerator_get_label.apply(null, + Gtk.accelerator_parse(accel)); + } + + _shouldUsePermStore() { + return this._app && !this._app.is_window_backed(); + } + + async _saveToPermissionStore(grant) { + if (!this._shouldUsePermStore() || this._permStore == null) + return; + + try { + await this._permStore.SetPermissionAsync(APP_PERMISSIONS_TABLE, + true, + APP_PERMISSIONS_ID, + this._app.get_id(), + [grant]); + } catch (error) { + log(error.message); + } + } + + _buildLayout() { + const name = this._app?.get_name() ?? this._window.title; + + let content = new Dialog.MessageDialogContent({ + title: _('Allow inhibiting shortcuts'), + description: name + /* Translators: %s is an application name like "Settings" */ + ? _('The application %s wants to inhibit shortcuts').format(name) + : _('An application wants to inhibit shortcuts'), + }); + + let restoreAccel = this._getRestoreAccel(); + if (restoreAccel) { + let restoreLabel = new St.Label({ + /* Translators: %s is a keyboard shortcut like "Super+x" */ + text: _('You can restore shortcuts by pressing %s.').format(restoreAccel), + style_class: 'message-dialog-description', + }); + restoreLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + restoreLabel.clutter_text.line_wrap = true; + content.add_child(restoreLabel); + } + + this._dialog.contentLayout.add_child(content); + + this._dialog.addButton({ + label: _('Deny'), + action: () => { + this._saveToPermissionStore(DENIED); + this._emitResponse(DialogResponse.DENY); + }, + key: Clutter.KEY_Escape, + }); + + this._dialog.addButton({ + label: _('Allow'), + action: () => { + this._saveToPermissionStore(GRANTED); + this._emitResponse(DialogResponse.ALLOW); + }, + default: true, + }); + } + + _emitResponse(response) { + this.emit('response', response); + this._dialog.close(); + } + + vfunc_show() { + if (this._app && APP_ALLOWLIST.includes(this._app.get_id())) { + this._emitResponse(DialogResponse.ALLOW); + return; + } + + if (!this._shouldUsePermStore()) { + this._dialog.open(); + return; + } + + /* Check with the permission store */ + let appId = this._app.get_id(); + this._permStore = new PermissionStore.PermissionStore(async (proxy, error) => { + if (error) { + log(error.message); + this._dialog.open(); + return; + } + + try { + const [permissions] = await this._permStore.LookupAsync( + APP_PERMISSIONS_TABLE, APP_PERMISSIONS_ID); + + if (permissions[appId] === undefined) // Not found + this._dialog.open(); + else if (permissions[appId][0] === GRANTED) + this._emitResponse(DialogResponse.ALLOW); + else + this._emitResponse(DialogResponse.DENY); + } catch (err) { + this._dialog.open(); + log(err.message); + } + }); + } + + vfunc_hide() { + this._dialog.close(); + } +}); diff --git a/js/ui/init.js b/js/ui/init.js new file mode 100644 index 0000000..a0fe633 --- /dev/null +++ b/js/ui/init.js @@ -0,0 +1,6 @@ +import { setConsoleLogDomain } from 'console'; + +setConsoleLogDomain('GNOME Shell'); + +imports.ui.environment.init(); +imports.ui.main.start(); diff --git a/js/ui/kbdA11yDialog.js b/js/ui/kbdA11yDialog.js new file mode 100644 index 0000000..6d1608c --- /dev/null +++ b/js/ui/kbdA11yDialog.js @@ -0,0 +1,76 @@ +/* exported KbdA11yDialog */ +const { Clutter, Gio, GObject, Meta } = imports.gi; + +const Dialog = imports.ui.dialog; +const ModalDialog = imports.ui.modalDialog; + +const KEYBOARD_A11Y_SCHEMA = 'org.gnome.desktop.a11y.keyboard'; +const KEY_STICKY_KEYS_ENABLED = 'stickykeys-enable'; +const KEY_SLOW_KEYS_ENABLED = 'slowkeys-enable'; + +var KbdA11yDialog = GObject.registerClass( +class KbdA11yDialog extends GObject.Object { + _init() { + super._init(); + + this._a11ySettings = new Gio.Settings({ schema_id: KEYBOARD_A11Y_SCHEMA }); + + let seat = Clutter.get_default_backend().get_default_seat(); + seat.connect('kbd-a11y-flags-changed', + this._showKbdA11yDialog.bind(this)); + } + + _showKbdA11yDialog(seat, newFlags, whatChanged) { + let dialog = new ModalDialog.ModalDialog(); + let title, description; + let key, enabled; + + if (whatChanged & Meta.KeyboardA11yFlags.SLOW_KEYS_ENABLED) { + key = KEY_SLOW_KEYS_ENABLED; + enabled = (newFlags & Meta.KeyboardA11yFlags.SLOW_KEYS_ENABLED) > 0; + title = enabled + ? _("Slow Keys Turned On") + : _("Slow Keys Turned Off"); + description = _('You just held down the Shift key for 8 seconds. This is the shortcut ' + + 'for the Slow Keys feature, which affects the way your keyboard works.'); + } else if (whatChanged & Meta.KeyboardA11yFlags.STICKY_KEYS_ENABLED) { + key = KEY_STICKY_KEYS_ENABLED; + enabled = (newFlags & Meta.KeyboardA11yFlags.STICKY_KEYS_ENABLED) > 0; + title = enabled + ? _("Sticky Keys Turned On") + : _("Sticky Keys Turned Off"); + description = enabled + ? _("You just pressed the Shift key 5 times in a row. This is the shortcut " + + "for the Sticky Keys feature, which affects the way your keyboard works.") + : _("You just pressed two keys at once, or pressed the Shift key 5 times in a row. " + + "This turns off the Sticky Keys feature, which affects the way your keyboard works."); + } else { + return; + } + + let content = new Dialog.MessageDialogContent({ title, description }); + dialog.contentLayout.add_child(content); + + dialog.addButton({ + label: enabled ? _('Leave On') : _('Turn On'), + action: () => { + this._a11ySettings.set_boolean(key, true); + dialog.close(); + }, + default: enabled, + key: !enabled ? Clutter.KEY_Escape : null, + }); + + dialog.addButton({ + label: enabled ? _('Turn Off') : _('Leave Off'), + action: () => { + this._a11ySettings.set_boolean(key, false); + dialog.close(); + }, + default: !enabled, + key: enabled ? Clutter.KEY_Escape : null, + }); + + dialog.open(); + } +}); diff --git a/js/ui/keyboard.js b/js/ui/keyboard.js new file mode 100644 index 0000000..be128d3 --- /dev/null +++ b/js/ui/keyboard.js @@ -0,0 +1,2275 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported KeyboardManager */ + +const {Clutter, Gio, GLib, GObject, Graphene, IBus, Meta, Shell, St} = imports.gi; +const Signals = imports.misc.signals; + +const EdgeDragAction = imports.ui.edgeDragAction; +const InputSourceManager = imports.ui.status.keyboard; +const IBusManager = imports.misc.ibusManager; +const BoxPointer = imports.ui.boxpointer; +const Main = imports.ui.main; +const PageIndicators = imports.ui.pageIndicators; +const PopupMenu = imports.ui.popupMenu; +const SwipeTracker = imports.ui.swipeTracker; + +var KEYBOARD_ANIMATION_TIME = 150; +var KEYBOARD_REST_TIME = KEYBOARD_ANIMATION_TIME * 2; +var KEY_LONG_PRESS_TIME = 250; + +const A11Y_APPLICATIONS_SCHEMA = 'org.gnome.desktop.a11y.applications'; +const SHOW_KEYBOARD = 'screen-keyboard-enabled'; +const EMOJI_PAGE_SEPARATION = 32; + +/* KeyContainer puts keys in a grid where a 1:1 key takes this size */ +const KEY_SIZE = 2; + +const KEY_RELEASE_TIMEOUT = 50; +const BACKSPACE_WORD_DELETE_THRESHOLD = 50; + +var AspectContainer = GObject.registerClass( +class AspectContainer extends St.Widget { + _init(params) { + super._init(params); + this._ratio = 1; + } + + setRatio(relWidth, relHeight) { + this._ratio = relWidth / relHeight; + this.queue_relayout(); + } + + vfunc_get_preferred_width(forHeight) { + let [min, nat] = super.vfunc_get_preferred_width(forHeight); + + if (forHeight > 0) + nat = forHeight * this._ratio; + + return [min, nat]; + } + + vfunc_get_preferred_height(forWidth) { + let [min, nat] = super.vfunc_get_preferred_height(forWidth); + + if (forWidth > 0) + nat = forWidth / this._ratio; + + return [min, nat]; + } + + vfunc_allocate(box) { + if (box.get_width() > 0 && box.get_height() > 0) { + let sizeRatio = box.get_width() / box.get_height(); + if (sizeRatio >= this._ratio) { + /* Restrict horizontally */ + let width = box.get_height() * this._ratio; + let diff = box.get_width() - width; + + box.x1 += Math.floor(diff / 2); + box.x2 -= Math.ceil(diff / 2); + } + } + + super.vfunc_allocate(box); + } +}); + +var KeyContainer = GObject.registerClass( +class KeyContainer extends St.Widget { + _init() { + const gridLayout = new Clutter.GridLayout({ + orientation: Clutter.Orientation.HORIZONTAL, + column_homogeneous: true, + row_homogeneous: true, + }); + super._init({ + layout_manager: gridLayout, + x_expand: true, + y_expand: true, + }); + this._gridLayout = gridLayout; + this._currentRow = 0; + this._currentCol = 0; + this._maxCols = 0; + + this._currentRow = null; + this._rows = []; + } + + appendRow() { + this._currentRow++; + this._currentCol = 0; + + let row = { + keys: [], + width: 0, + }; + this._rows.push(row); + } + + appendKey(key, width = 1, height = 1) { + let keyInfo = { + key, + left: this._currentCol, + top: this._currentRow, + width, + height, + }; + + let row = this._rows[this._rows.length - 1]; + row.keys.push(keyInfo); + row.width += width; + + this._currentCol += width; + this._maxCols = Math.max(this._currentCol, this._maxCols); + } + + layoutButtons() { + let nCol = 0, nRow = 0; + + for (let i = 0; i < this._rows.length; i++) { + let row = this._rows[i]; + + /* When starting a new row, see if we need some padding */ + if (nCol == 0) { + let diff = this._maxCols - row.width; + if (diff >= 1) + nCol = diff * KEY_SIZE / 2; + else + nCol = diff * KEY_SIZE; + } + + for (let j = 0; j < row.keys.length; j++) { + let keyInfo = row.keys[j]; + let width = keyInfo.width * KEY_SIZE; + let height = keyInfo.height * KEY_SIZE; + + this._gridLayout.attach(keyInfo.key, nCol, nRow, width, height); + nCol += width; + } + + nRow += KEY_SIZE; + nCol = 0; + } + } + + getRatio() { + return [this._maxCols, this._rows.length]; + } +}); + +var Suggestions = GObject.registerClass( +class Suggestions extends St.BoxLayout { + _init() { + super._init({ + style_class: 'word-suggestions', + vertical: false, + x_align: Clutter.ActorAlign.CENTER, + }); + this.show(); + } + + add(word, callback) { + let button = new St.Button({ label: word }); + button.connect('button-press-event', () => { + callback(); + return Clutter.EVENT_STOP; + }); + button.connect('touch-event', (actor, event) => { + if (event.type() !== Clutter.EventType.TOUCH_BEGIN) + return Clutter.EVENT_PROPAGATE; + + callback(); + return Clutter.EVENT_STOP; + }); + this.add_child(button); + } + + clear() { + this.remove_all_children(); + } + + setVisible(visible) { + for (const child of this) + child.visible = visible; + } +}); + +var LanguageSelectionPopup = class extends PopupMenu.PopupMenu { + constructor(actor) { + super(actor, 0.5, St.Side.BOTTOM); + + let inputSourceManager = InputSourceManager.getInputSourceManager(); + let inputSources = inputSourceManager.inputSources; + + let item; + for (let i in inputSources) { + let is = inputSources[i]; + + item = this.addAction(is.displayName, () => { + inputSourceManager.activateInputSource(is, true); + }); + item.can_focus = false; + item.setOrnament(is === inputSourceManager.currentSource + ? PopupMenu.Ornament.DOT + : PopupMenu.Ornament.NONE); + } + + this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + item = this.addSettingsAction(_("Region & Language Settings"), 'gnome-region-panel.desktop'); + item.can_focus = false; + + actor.connectObject('notify::mapped', () => { + if (!actor.is_mapped()) + this.close(true); + }, this); + } + + _onCapturedEvent(actor, event) { + const targetActor = global.stage.get_event_actor(event); + + if (targetActor === this.actor || + this.actor.contains(targetActor)) + return Clutter.EVENT_PROPAGATE; + + if (event.type() == Clutter.EventType.BUTTON_RELEASE || event.type() == Clutter.EventType.TOUCH_END) + this.close(true); + + return Clutter.EVENT_STOP; + } + + open(animate) { + super.open(animate); + global.stage.connectObject( + 'captured-event', this._onCapturedEvent.bind(this), this); + } + + close(animate) { + super.close(animate); + global.stage.disconnectObject(this); + } + + destroy() { + global.stage.disconnectObject(this); + this.sourceActor.disconnectObject(this); + super.destroy(); + } +}; + +var Key = GObject.registerClass({ + Signals: { + 'long-press': {}, + 'pressed': {}, + 'released': {}, + 'commit': {param_types: [GObject.TYPE_UINT, GObject.TYPE_STRING]}, + }, +}, class Key extends St.BoxLayout { + _init(params, extendedKeys = []) { + const {label, iconName, commitString, keyval} = {keyval: 0, ...params}; + super._init({ style_class: 'key-container' }); + + this._keyval = parseInt(keyval, 16); + this.keyButton = this._makeKey(commitString, label, iconName); + + /* Add the key in a container, so keys can be padded without losing + * logical proportions between those. + */ + this.add_child(this.keyButton); + this.connect('destroy', this._onDestroy.bind(this)); + + this._extendedKeys = extendedKeys; + this._extendedKeyboard = null; + this._pressTimeoutId = 0; + this._capturedPress = false; + } + + get iconName() { + return this._icon.icon_name; + } + + set iconName(value) { + this._icon.icon_name = value; + } + + _onDestroy() { + if (this._boxPointer) { + this._boxPointer.destroy(); + this._boxPointer = null; + } + + this.cancel(); + } + + _ensureExtendedKeysPopup() { + if (this._extendedKeys.length === 0) + return; + + if (this._boxPointer) + return; + + this._boxPointer = new BoxPointer.BoxPointer(St.Side.BOTTOM); + this._boxPointer.hide(); + Main.layoutManager.addTopChrome(this._boxPointer); + this._boxPointer.setPosition(this.keyButton, 0.5); + + // Adds style to existing keyboard style to avoid repetition + this._boxPointer.add_style_class_name('keyboard-subkeys'); + this._getExtendedKeys(); + this.keyButton._extendedKeys = this._extendedKeyboard; + } + + _getKeyvalFromString(string) { + let unicode = string?.length ? string.charCodeAt(0) : undefined; + return Clutter.unicode_to_keysym(unicode); + } + + _press(button) { + if (button === this.keyButton) { + this._pressTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, + KEY_LONG_PRESS_TIME, + () => { + this._pressTimeoutId = 0; + + this.emit('long-press'); + + if (this._extendedKeys.length > 0) { + this._touchPressSlot = null; + this._ensureExtendedKeysPopup(); + this.keyButton.set_hover(false); + this.keyButton.fake_release(); + this._showSubkeys(); + } + + return GLib.SOURCE_REMOVE; + }); + } + + this.emit('pressed'); + this._pressed = true; + } + + _release(button, commitString) { + if (this._pressTimeoutId != 0) { + GLib.source_remove(this._pressTimeoutId); + this._pressTimeoutId = 0; + } + + let keyval; + if (button === this.keyButton) + keyval = this._keyval; + if (!keyval && commitString) + keyval = this._getKeyvalFromString(commitString); + console.assert(keyval !== undefined, 'Need keyval or commitString'); + + if (this._pressed && (commitString || keyval)) + this.emit('commit', keyval, commitString || ''); + + this.emit('released'); + this._hideSubkeys(); + this._pressed = false; + } + + cancel() { + if (this._pressTimeoutId != 0) { + GLib.source_remove(this._pressTimeoutId); + this._pressTimeoutId = 0; + } + this._touchPressSlot = null; + this.keyButton.set_hover(false); + this.keyButton.fake_release(); + } + + _onCapturedEvent(actor, event) { + let type = event.type(); + let press = type == Clutter.EventType.BUTTON_PRESS || type == Clutter.EventType.TOUCH_BEGIN; + let release = type == Clutter.EventType.BUTTON_RELEASE || type == Clutter.EventType.TOUCH_END; + const targetActor = global.stage.get_event_actor(event); + + if (targetActor === this._boxPointer.bin || + this._boxPointer.bin.contains(targetActor)) + return Clutter.EVENT_PROPAGATE; + + if (press) + this._capturedPress = true; + else if (release && this._capturedPress) + this._hideSubkeys(); + + return Clutter.EVENT_STOP; + } + + _showSubkeys() { + this._boxPointer.open(BoxPointer.PopupAnimation.FULL); + global.stage.connectObject( + 'captured-event', this._onCapturedEvent.bind(this), this); + this.keyButton.connectObject('notify::mapped', () => { + if (!this.keyButton.is_mapped()) + this._hideSubkeys(); + }, this); + } + + _hideSubkeys() { + if (this._boxPointer) + this._boxPointer.close(BoxPointer.PopupAnimation.FULL); + global.stage.disconnectObject(this); + this.keyButton.disconnectObject(this); + this._capturedPress = false; + } + + _makeKey(commitString, label, icon) { + let button = new St.Button({ + style_class: 'keyboard-key', + x_expand: true, + }); + + if (icon) { + const child = new St.Icon({icon_name: icon}); + button.set_child(child); + this._icon = child; + } else if (label) { + button.set_label(label); + } else if (commitString) { + const str = GLib.markup_escape_text(commitString, -1); + button.set_label(str); + } + + button.keyWidth = 1; + button.connect('button-press-event', () => { + this._press(button, commitString); + button.add_style_pseudo_class('active'); + return Clutter.EVENT_STOP; + }); + button.connect('button-release-event', () => { + this._release(button, commitString); + button.remove_style_pseudo_class('active'); + return Clutter.EVENT_STOP; + }); + button.connect('touch-event', (actor, event) => { + // We only handle touch events here on wayland. On X11 + // we do get emulated pointer events, which already works + // for single-touch cases. Besides, the X11 passive touch grab + // set up by Mutter will make us see first the touch events + // and later the pointer events, so it will look like two + // unrelated series of events, we want to avoid double handling + // in these cases. + if (!Meta.is_wayland_compositor()) + return Clutter.EVENT_PROPAGATE; + + const slot = event.get_event_sequence().get_slot(); + + if (!this._touchPressSlot && + event.type() == Clutter.EventType.TOUCH_BEGIN) { + this._touchPressSlot = slot; + this._press(button, commitString); + button.add_style_pseudo_class('active'); + } else if (event.type() === Clutter.EventType.TOUCH_END) { + if (!this._touchPressSlot || + this._touchPressSlot === slot) { + this._release(button, commitString); + button.remove_style_pseudo_class('active'); + } + + if (this._touchPressSlot === slot) + this._touchPressSlot = null; + } + return Clutter.EVENT_STOP; + }); + + return button; + } + + _getExtendedKeys() { + this._extendedKeyboard = new St.BoxLayout({ + style_class: 'key-container', + vertical: false, + }); + for (let i = 0; i < this._extendedKeys.length; ++i) { + let extendedKey = this._extendedKeys[i]; + let key = this._makeKey(extendedKey); + + key.extendedKey = extendedKey; + this._extendedKeyboard.add(key); + + key.set_size(...this.keyButton.allocation.get_size()); + this.keyButton.connect('notify::allocation', + () => key.set_size(...this.keyButton.allocation.get_size())); + } + this._boxPointer.bin.add_actor(this._extendedKeyboard); + } + + get subkeys() { + return this._boxPointer; + } + + setWidth(width) { + this.keyButton.keyWidth = width; + } + + setLatched(latched) { + if (latched) + this.keyButton.add_style_pseudo_class('latched'); + else + this.keyButton.remove_style_pseudo_class('latched'); + } +}); + +var KeyboardModel = class { + constructor(groupName) { + let names = [groupName]; + if (groupName.includes('+')) + names.push(groupName.replace(/\+.*/, '')); + names.push('us'); + + for (let i = 0; i < names.length; i++) { + try { + this._model = this._loadModel(names[i]); + break; + } catch (e) { + } + } + } + + _loadModel(groupName) { + const file = Gio.File.new_for_uri( + `resource:///org/gnome/shell/osk-layouts/${groupName}.json`); + let [success_, contents] = file.load_contents(null); + + const decoder = new TextDecoder(); + return JSON.parse(decoder.decode(contents)); + } + + getLevels() { + return this._model.levels; + } + + getKeysForLevel(levelName) { + return this._model.levels.find(level => level == levelName); + } +}; + +var FocusTracker = class extends Signals.EventEmitter { + constructor() { + super(); + + this._rect = null; + + global.display.connectObject( + 'notify::focus-window', () => { + this._setCurrentWindow(global.display.focus_window); + this.emit('window-changed', this._currentWindow); + }, + 'grab-op-begin', (display, window, op) => { + if (window === this._currentWindow && + (op === Meta.GrabOp.MOVING || op === Meta.GrabOp.KEYBOARD_MOVING)) + this.emit('window-grabbed'); + }, this); + + this._setCurrentWindow(global.display.focus_window); + + /* Valid for wayland clients */ + Main.inputMethod.connectObject('cursor-location-changed', + (o, rect) => this._setCurrentRect(rect), this); + + this._ibusManager = IBusManager.getIBusManager(); + this._ibusManager.connectObject( + 'set-cursor-location', (manager, rect) => { + /* Valid for X11 clients only */ + if (Main.inputMethod.currentFocus) + return; + + const grapheneRect = new Graphene.Rect(); + grapheneRect.init(rect.x, rect.y, rect.width, rect.height); + + this._setCurrentRect(grapheneRect); + }, + 'focus-in', () => this.emit('focus-changed', true), + 'focus-out', () => this.emit('focus-changed', false), + this); + } + + destroy() { + this._currentWindow?.disconnectObject(this); + global.display.disconnectObject(this); + Main.inputMethod.disconnectObject(this); + this._ibusManager.disconnectObject(this); + } + + get currentWindow() { + return this._currentWindow; + } + + _setCurrentWindow(window) { + this._currentWindow?.disconnectObject(this); + + this._currentWindow = window; + + if (this._currentWindow) { + this._currentWindow.connectObject( + 'position-changed', () => this.emit('window-moved'), this); + } + } + + _setCurrentRect(rect) { + // Some clients give us 0-sized rects, in that case set size to 1 + if (rect.size.width <= 0) + rect.size.width = 1; + if (rect.size.height <= 0) + rect.size.height = 1; + + if (this._currentWindow) { + const frameRect = this._currentWindow.get_frame_rect(); + const grapheneFrameRect = new Graphene.Rect(); + grapheneFrameRect.init(frameRect.x, frameRect.y, + frameRect.width, frameRect.height); + + const rectInsideFrameRect = grapheneFrameRect.intersection(rect)[0]; + if (!rectInsideFrameRect) + return; + } + + if (this._rect && this._rect.equal(rect)) + return; + + this._rect = rect; + this.emit('position-changed'); + } + + getCurrentRect() { + const rect = { + x: this._rect.origin.x, + y: this._rect.origin.y, + width: this._rect.size.width, + height: this._rect.size.height, + }; + + return rect; + } +}; + +var EmojiPager = GObject.registerClass({ + Properties: { + 'delta': GObject.ParamSpec.int( + 'delta', 'delta', 'delta', + GObject.ParamFlags.READWRITE, + GLib.MININT32, GLib.MAXINT32, 0), + }, + Signals: { + 'emoji': { param_types: [GObject.TYPE_STRING] }, + 'page-changed': { + param_types: [GObject.TYPE_INT, GObject.TYPE_INT, GObject.TYPE_INT], + }, + }, +}, class EmojiPager extends St.Widget { + _init(sections) { + super._init({ + layout_manager: new Clutter.BinLayout(), + reactive: true, + clip_to_allocation: true, + y_expand: true, + }); + this._sections = sections; + + this._pages = []; + this._panel = null; + this._curPage = null; + this._followingPage = null; + this._followingPanel = null; + this._currentKey = null; + this._delta = 0; + this._width = null; + + const swipeTracker = new SwipeTracker.SwipeTracker(this, + Clutter.Orientation.HORIZONTAL, + Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, + {allowDrag: true, allowScroll: true}); + swipeTracker.connect('begin', this._onSwipeBegin.bind(this)); + swipeTracker.connect('update', this._onSwipeUpdate.bind(this)); + swipeTracker.connect('end', this._onSwipeEnd.bind(this)); + this._swipeTracker = swipeTracker; + + this.connect('destroy', () => this._onDestroy()); + + this.bind_property( + 'visible', this._swipeTracker, 'enabled', + GObject.BindingFlags.DEFAULT); + } + + _onDestroy() { + if (this._swipeTracker) { + this._swipeTracker.destroy(); + delete this._swipeTracker; + } + } + + get delta() { + return this._delta; + } + + set delta(value) { + if (this._delta == value) + return; + + this._delta = value; + this.notify('delta'); + + let followingPage = this.getFollowingPage(); + + if (this._followingPage != followingPage) { + if (this._followingPanel) { + this._followingPanel.destroy(); + this._followingPanel = null; + } + + if (followingPage != null) { + this._followingPanel = this._generatePanel(followingPage); + this.add_child(this._followingPanel); + } + + this._followingPage = followingPage; + } + + const multiplier = this.text_direction === Clutter.TextDirection.RTL + ? -1 : 1; + + this._panel.translation_x = value * multiplier; + if (this._followingPanel) { + const translation = value < 0 + ? this._width + EMOJI_PAGE_SEPARATION + : -this._width - EMOJI_PAGE_SEPARATION; + + this._followingPanel.translation_x = + (value * multiplier) + (translation * multiplier); + } + } + + _prevPage(nPage) { + return (nPage + this._pages.length - 1) % this._pages.length; + } + + _nextPage(nPage) { + return (nPage + 1) % this._pages.length; + } + + getFollowingPage() { + if (this.delta == 0) + return null; + + if (this.delta < 0) + return this._nextPage(this._curPage); + else + return this._prevPage(this._curPage); + } + + _onSwipeUpdate(tracker, progress) { + this.delta = -progress * this._width; + + if (this._currentKey != null) { + this._currentKey.cancel(); + this._currentKey = null; + } + + return false; + } + + _onSwipeBegin(tracker) { + this._width = this.width; + const points = [-1, 0, 1]; + tracker.confirmSwipe(this._width, points, 0, 0); + } + + _onSwipeEnd(tracker, duration, endProgress) { + this.remove_all_transitions(); + if (endProgress === 0) { + this.ease_property('delta', 0, {duration}); + } else { + const value = endProgress < 0 + ? this._width + EMOJI_PAGE_SEPARATION + : -this._width - EMOJI_PAGE_SEPARATION; + this.ease_property('delta', value, { + duration, + onComplete: () => { + this.setCurrentPage(this.getFollowingPage()); + }, + }); + } + } + + _initPagingInfo() { + this._pages = []; + + for (let i = 0; i < this._sections.length; i++) { + let section = this._sections[i]; + let itemsPerPage = this._nCols * this._nRows; + let nPages = Math.ceil(section.keys.length / itemsPerPage); + let page = -1; + let pageKeys; + + for (let j = 0; j < section.keys.length; j++) { + if (j % itemsPerPage == 0) { + page++; + pageKeys = []; + this._pages.push({ pageKeys, nPages, page, section: this._sections[i] }); + } + + pageKeys.push(section.keys[j]); + } + } + } + + _lookupSection(section, nPage) { + for (let i = 0; i < this._pages.length; i++) { + let page = this._pages[i]; + + if (page.section == section && page.page == nPage) + return i; + } + + return -1; + } + + _generatePanel(nPage) { + const gridLayout = new Clutter.GridLayout({ + orientation: Clutter.Orientation.HORIZONTAL, + column_homogeneous: true, + row_homogeneous: true, + }); + const panel = new St.Widget({ + layout_manager: gridLayout, + style_class: 'emoji-page', + x_expand: true, + y_expand: true, + }); + + /* Set an expander actor so all proportions are right despite the panel + * not having all rows/cols filled in. + */ + let expander = new Clutter.Actor(); + gridLayout.attach(expander, 0, 0, this._nCols, this._nRows); + + let page = this._pages[nPage]; + let col = 0; + let row = 0; + + for (let i = 0; i < page.pageKeys.length; i++) { + let modelKey = page.pageKeys[i]; + let key = new Key({commitString: modelKey.label}, modelKey.variants); + + key.keyButton.set_button_mask(0); + + key.connect('pressed', () => { + this._currentKey = key; + }); + key.connect('commit', (actor, keyval, str) => { + if (this._currentKey != key) + return; + this._currentKey = null; + this.emit('emoji', str); + }); + + gridLayout.attach(key, col, row, 1, 1); + + col++; + if (col >= this._nCols) { + col = 0; + row++; + } + } + + return panel; + } + + setCurrentPage(nPage) { + if (this._curPage == nPage) + return; + + this._curPage = nPage; + + if (this._panel) { + this._panel.destroy(); + this._panel = null; + } + + /* Reuse followingPage if possible */ + if (nPage == this._followingPage) { + this._panel = this._followingPanel; + this._followingPanel = null; + } + + if (this._followingPanel) + this._followingPanel.destroy(); + + this._followingPanel = null; + this._followingPage = null; + this._delta = 0; + + if (!this._panel) { + this._panel = this._generatePanel(nPage); + this.add_child(this._panel); + } + + let page = this._pages[nPage]; + this.emit('page-changed', page.section.label, page.page, page.nPages); + } + + setCurrentSection(section, nPage) { + for (let i = 0; i < this._pages.length; i++) { + let page = this._pages[i]; + + if (page.section == section && page.page == nPage) { + this.setCurrentPage(i); + break; + } + } + } + + setRatio(nCols, nRows) { + this._nCols = nCols; + this._nRows = nRows; + this._initPagingInfo(); + } +}); + +var EmojiSelection = GObject.registerClass({ + Signals: { + 'emoji-selected': { param_types: [GObject.TYPE_STRING] }, + 'close-request': {}, + 'toggle': {}, + }, +}, class EmojiSelection extends St.Widget { + _init() { + const gridLayout = new Clutter.GridLayout({ + orientation: Clutter.Orientation.HORIZONTAL, + column_homogeneous: true, + row_homogeneous: true, + }); + super._init({ + layout_manager: gridLayout, + style_class: 'emoji-panel', + x_expand: true, + y_expand: true, + text_direction: global.stage.text_direction, + }); + + this._sections = [ + { first: 'grinning face', label: '🙂️' }, + { first: 'selfie', label: '👍️' }, + { first: 'monkey face', label: '🌷️' }, + { first: 'grapes', label: '🍴️' }, + { first: 'globe showing Europe-Africa', label: '✈️' }, + { first: 'jack-o-lantern', label: '🏃️' }, + { first: 'muted speaker', label: '🔔️' }, + { first: 'ATM sign', label: '❤️' }, + { first: 'chequered flag', label: '🚩️' }, + ]; + + this._gridLayout = gridLayout; + this._populateSections(); + + this._pagerBox = new Clutter.Actor({ + layout_manager: new Clutter.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + }), + }); + + this._emojiPager = new EmojiPager(this._sections); + this._emojiPager.connect('page-changed', (pager, sectionLabel, page, nPages) => { + this._onPageChanged(sectionLabel, page, nPages); + }); + this._emojiPager.connect('emoji', (pager, str) => { + this.emit('emoji-selected', str); + }); + this._pagerBox.add_child(this._emojiPager); + + this._pageIndicator = new PageIndicators.PageIndicators( + Clutter.Orientation.HORIZONTAL); + this._pageIndicator.y_expand = false; + this._pageIndicator.y_align = Clutter.ActorAlign.START; + this._pagerBox.add_child(this._pageIndicator); + this._pageIndicator.setReactive(false); + + this._emojiPager.connect('notify::delta', () => { + this._updateIndicatorPosition(); + }); + + this._bottomRow = this._createBottomRow(); + + this._curPage = 0; + } + + vfunc_map() { + this._emojiPager.setCurrentPage(0); + super.vfunc_map(); + } + + _onPageChanged(sectionLabel, page, nPages) { + this._curPage = page; + this._pageIndicator.setNPages(nPages); + this._updateIndicatorPosition(); + + for (let i = 0; i < this._sections.length; i++) { + let sect = this._sections[i]; + sect.button.setLatched(sectionLabel == sect.label); + } + } + + _updateIndicatorPosition() { + this._pageIndicator.setCurrentPosition(this._curPage - + this._emojiPager.delta / this._emojiPager.width); + } + + _findSection(emoji) { + for (let i = 0; i < this._sections.length; i++) { + if (this._sections[i].first == emoji) + return this._sections[i]; + } + + return null; + } + + _populateSections() { + let file = Gio.File.new_for_uri('resource:///org/gnome/shell/osk-layouts/emoji.json'); + let [success_, contents] = file.load_contents(null); + + let emoji = JSON.parse(new TextDecoder().decode(contents)); + + let variants = []; + let currentKey = 0; + let currentSection = null; + + for (let i = 0; i < emoji.length; i++) { + /* Group variants of a same emoji so they appear on the key popover */ + if (emoji[i].name.startsWith(emoji[currentKey].name)) { + variants.push(emoji[i].char); + if (i < emoji.length - 1) + continue; + } + + let newSection = this._findSection(emoji[currentKey].name); + if (newSection != null) { + currentSection = newSection; + currentSection.keys = []; + } + + /* Create the key */ + let label = emoji[currentKey].char + String.fromCharCode(0xFE0F); + currentSection.keys.push({ label, variants }); + currentKey = i; + variants = []; + } + } + + _createBottomRow() { + let row = new KeyContainer(); + let key; + + row.appendRow(); + + key = new Key({label: 'ABC'}, []); + key.keyButton.add_style_class_name('default-key'); + key.connect('released', () => this.emit('toggle')); + row.appendKey(key, 1.5); + + for (let i = 0; i < this._sections.length; i++) { + let section = this._sections[i]; + + key = new Key({label: section.label}, []); + key.connect('released', () => this._emojiPager.setCurrentSection(section, 0)); + row.appendKey(key); + + section.button = key; + } + + key = new Key({iconName: 'go-down-symbolic'}); + key.keyButton.add_style_class_name('default-key'); + key.keyButton.add_style_class_name('hide-key'); + key.connect('released', () => { + this.emit('close-request'); + }); + row.appendKey(key); + row.layoutButtons(); + + const actor = new AspectContainer({ + layout_manager: new Clutter.BinLayout(), + x_expand: true, + y_expand: true, + }); + actor.add_child(row); + + return actor; + } + + setRatio(nCols, nRows) { + this._emojiPager.setRatio(Math.floor(nCols), Math.floor(nRows) - 1); + this._bottomRow.setRatio(nCols, 1); + + // (Re)attach actors so the emoji panel fits the ratio and + // the bottom row is ensured to take 1 row high. + if (this._pagerBox.get_parent()) + this.remove_child(this._pagerBox); + if (this._bottomRow.get_parent()) + this.remove_child(this._bottomRow); + + this._gridLayout.attach(this._pagerBox, 0, 0, 1, Math.floor(nRows) - 1); + this._gridLayout.attach(this._bottomRow, 0, Math.floor(nRows) - 1, 1, 1); + } +}); + +var Keypad = GObject.registerClass({ + Signals: { + 'keyval': { param_types: [GObject.TYPE_UINT] }, + }, +}, class Keypad extends AspectContainer { + _init() { + let keys = [ + { label: '1', keyval: Clutter.KEY_1, left: 0, top: 0 }, + { label: '2', keyval: Clutter.KEY_2, left: 1, top: 0 }, + { label: '3', keyval: Clutter.KEY_3, left: 2, top: 0 }, + { label: '4', keyval: Clutter.KEY_4, left: 0, top: 1 }, + { label: '5', keyval: Clutter.KEY_5, left: 1, top: 1 }, + { label: '6', keyval: Clutter.KEY_6, left: 2, top: 1 }, + { label: '7', keyval: Clutter.KEY_7, left: 0, top: 2 }, + { label: '8', keyval: Clutter.KEY_8, left: 1, top: 2 }, + { label: '9', keyval: Clutter.KEY_9, left: 2, top: 2 }, + { label: '0', keyval: Clutter.KEY_0, left: 1, top: 3 }, + { keyval: Clutter.KEY_BackSpace, icon: 'edit-clear-symbolic', left: 3, top: 0 }, + { keyval: Clutter.KEY_Return, extraClassName: 'enter-key', icon: 'keyboard-enter-symbolic', left: 3, top: 1, height: 2 }, + ]; + + super._init({ + layout_manager: new Clutter.BinLayout(), + x_expand: true, + y_expand: true, + }); + + const gridLayout = new Clutter.GridLayout({ + orientation: Clutter.Orientation.HORIZONTAL, + column_homogeneous: true, + row_homogeneous: true, + }); + this._box = new St.Widget({ layout_manager: gridLayout, x_expand: true, y_expand: true }); + this.add_child(this._box); + + for (let i = 0; i < keys.length; i++) { + let cur = keys[i]; + let key = new Key({ + label: cur.label, + iconName: cur.icon, + }); + + if (keys[i].extraClassName) + key.keyButton.add_style_class_name(cur.extraClassName); + + let w, h; + w = cur.width || 1; + h = cur.height || 1; + gridLayout.attach(key, cur.left, cur.top, w, h); + + key.connect('released', () => { + this.emit('keyval', cur.keyval); + }); + } + } +}); + +var KeyboardManager = class extends Signals.EventEmitter { + constructor() { + super(); + + this._keyboard = null; + this._a11yApplicationsSettings = new Gio.Settings({ schema_id: A11Y_APPLICATIONS_SCHEMA }); + this._a11yApplicationsSettings.connect('changed', this._syncEnabled.bind(this)); + + this._seat = Clutter.get_default_backend().get_default_seat(); + this._seat.connect('notify::touch-mode', this._syncEnabled.bind(this)); + + this._lastDevice = null; + global.backend.connect('last-device-changed', (backend, device) => { + if (device.device_type === Clutter.InputDeviceType.KEYBOARD_DEVICE) + return; + + this._lastDevice = device; + this._syncEnabled(); + }); + + const mode = Shell.ActionMode.ALL & ~Shell.ActionMode.LOCK_SCREEN; + const bottomDragAction = new EdgeDragAction.EdgeDragAction(St.Side.BOTTOM, mode); + bottomDragAction.connect('activated', () => { + if (this._keyboard) + this._keyboard.gestureActivate(Main.layoutManager.bottomIndex); + }); + bottomDragAction.connect('progress', (_action, progress) => { + if (this._keyboard) + this._keyboard.gestureProgress(progress); + }); + bottomDragAction.connect('gesture-cancel', () => { + if (this._keyboard) + this._keyboard.gestureCancel(); + }); + global.stage.add_action_full('osk', Clutter.EventPhase.CAPTURE, bottomDragAction); + this._bottomDragAction = bottomDragAction; + + this._syncEnabled(); + } + + _lastDeviceIsTouchscreen() { + if (!this._lastDevice) + return false; + + let deviceType = this._lastDevice.get_device_type(); + return deviceType == Clutter.InputDeviceType.TOUCHSCREEN_DEVICE; + } + + _syncEnabled() { + let enableKeyboard = this._a11yApplicationsSettings.get_boolean(SHOW_KEYBOARD); + let autoEnabled = this._seat.get_touch_mode() && this._lastDeviceIsTouchscreen(); + let enabled = enableKeyboard || autoEnabled; + + if (!enabled && !this._keyboard) + return; + + if (enabled && !this._keyboard) { + this._keyboard = new Keyboard(); + this._keyboard.connect('visibility-changed', () => { + this.emit('visibility-changed'); + this._bottomDragAction.enabled = !this._keyboard.visible; + }); + } else if (!enabled && this._keyboard) { + this._keyboard.setCursorLocation(null); + this._keyboard.destroy(); + this._keyboard = null; + this._bottomDragAction.enabled = true; + } + } + + get keyboardActor() { + return this._keyboard; + } + + get visible() { + return this._keyboard && this._keyboard.visible; + } + + open(monitor) { + Main.layoutManager.keyboardIndex = monitor; + + if (this._keyboard) + this._keyboard.open(); + } + + close() { + if (this._keyboard) + this._keyboard.close(); + } + + addSuggestion(text, callback) { + if (this._keyboard) + this._keyboard.addSuggestion(text, callback); + } + + resetSuggestions() { + if (this._keyboard) + this._keyboard.resetSuggestions(); + } + + setSuggestionsVisible(visible) { + this._keyboard?.setSuggestionsVisible(visible); + } + + maybeHandleEvent(event) { + if (!this._keyboard) + return false; + + const actor = global.stage.get_event_actor(event); + + if (Main.layoutManager.keyboardBox.contains(actor) || + !!actor._extendedKeys || !!actor.extendedKey) { + actor.event(event, true); + actor.event(event, false); + return true; + } + + return false; + } +}; + +var Keyboard = GObject.registerClass({ + Signals: { + 'visibility-changed': {}, + }, +}, class Keyboard extends St.BoxLayout { + _init() { + super._init({ + name: 'keyboard', + reactive: true, + // Keyboard models are defined in LTR, we must override + // the locale setting in order to avoid flipping the + // keyboard on RTL locales. + text_direction: Clutter.TextDirection.LTR, + vertical: true, + }); + this._focusInExtendedKeys = false; + this._emojiActive = false; + + this._languagePopup = null; + this._focusWindow = null; + this._focusWindowStartY = null; + + this._latched = false; // current level is latched + this._modifiers = new Set(); + this._modifierKeys = new Map(); + + this._suggestions = null; + this._emojiKeyVisible = Meta.is_wayland_compositor(); + + this._focusTracker = new FocusTracker(); + this._focusTracker.connectObject( + 'position-changed', this._onFocusPositionChanged.bind(this), + 'window-grabbed', this._onFocusWindowMoving.bind(this), this); + + this._windowMovedId = this._focusTracker.connect('window-moved', + this._onFocusWindowMoving.bind(this)); + + // Valid only for X11 + if (!Meta.is_wayland_compositor()) { + this._focusTracker.connectObject('focus-changed', (_tracker, focused) => { + if (focused) + this.open(Main.layoutManager.focusIndex); + else + this.close(); + }, this); + } + + this._showIdleId = 0; + + this._keyboardVisible = false; + this._keyboardRequested = false; + this._keyboardRestingId = 0; + + Main.layoutManager.connectObject('monitors-changed', + this._relayout.bind(this), this); + + this._setupKeyboard(); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + get visible() { + return this._keyboardVisible && super.visible; + } + + set visible(visible) { + super.visible = visible; + } + + _onFocusPositionChanged(focusTracker) { + let rect = focusTracker.getCurrentRect(); + this.setCursorLocation(focusTracker.currentWindow, rect.x, rect.y, rect.width, rect.height); + } + + _onDestroy() { + if (this._windowMovedId) { + this._focusTracker.disconnect(this._windowMovedId); + delete this._windowMovedId; + } + + if (this._focusTracker) { + this._focusTracker.destroy(); + delete this._focusTracker; + } + + this._clearShowIdle(); + + this._keyboardController.destroy(); + + Main.layoutManager.untrackChrome(this); + Main.layoutManager.keyboardBox.remove_actor(this); + Main.layoutManager.keyboardBox.hide(); + + if (this._languagePopup) { + this._languagePopup.destroy(); + this._languagePopup = null; + } + + IBusManager.getIBusManager().setCompletionEnabled(false, () => Main.inputMethod.update()); + } + + _setupKeyboard() { + Main.layoutManager.keyboardBox.add_actor(this); + Main.layoutManager.trackChrome(this); + + this._keyboardController = new KeyboardController(); + + this._groups = {}; + this._currentPage = null; + + this._suggestions = new Suggestions(); + this.add_child(this._suggestions); + + this._aspectContainer = new AspectContainer({ + layout_manager: new Clutter.BinLayout(), + y_expand: true, + }); + this.add_child(this._aspectContainer); + + this._emojiSelection = new EmojiSelection(); + this._emojiSelection.connect('toggle', this._toggleEmoji.bind(this)); + this._emojiSelection.connect('close-request', () => this.close()); + this._emojiSelection.connect('emoji-selected', (selection, emoji) => { + this._keyboardController.commitString(emoji); + }); + + this._emojiSelection.hide(); + this._aspectContainer.add_child(this._emojiSelection); + + this._keypad = new Keypad(); + this._keypad.connectObject('keyval', (_keypad, keyval) => { + this._keyboardController.keyvalPress(keyval); + this._keyboardController.keyvalRelease(keyval); + }, this); + this._aspectContainer.add_child(this._keypad); + this._keypad.hide(); + this._keypadVisible = false; + + this._ensureKeysForGroup(this._keyboardController.getCurrentGroup()); + this._setActiveLayer(0); + + Main.inputMethod.connectObject( + 'terminal-mode-changed', this._onTerminalModeChanged.bind(this), + this); + + this._keyboardController.connectObject( + 'active-group', this._onGroupChanged.bind(this), + 'groups-changed', this._onKeyboardGroupsChanged.bind(this), + 'panel-state', this._onKeyboardStateChanged.bind(this), + 'keypad-visible', this._onKeypadVisible.bind(this), + this); + global.stage.connectObject('notify::key-focus', + this._onKeyFocusChanged.bind(this), this); + + if (Meta.is_wayland_compositor()) { + this._keyboardController.connectObject('emoji-visible', + this._onEmojiKeyVisible.bind(this), this); + } + + this._relayout(); + } + + _onKeyFocusChanged() { + let focus = global.stage.key_focus; + + // Showing an extended key popup and clicking a key from the extended keys + // will grab focus, but ignore that + let extendedKeysWereFocused = this._focusInExtendedKeys; + this._focusInExtendedKeys = focus && (focus._extendedKeys || focus.extendedKey); + if (this._focusInExtendedKeys || extendedKeysWereFocused) + return; + + if (!(focus instanceof Clutter.Text)) { + this.close(); + return; + } + + if (!this._showIdleId) { + this._showIdleId = GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + this.open(Main.layoutManager.focusIndex); + this._showIdleId = 0; + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._showIdleId, '[gnome-shell] this.open'); + } + } + + _createLayersForGroup(groupName) { + let keyboardModel = new KeyboardModel(groupName); + let layers = {}; + let levels = keyboardModel.getLevels(); + for (let i = 0; i < levels.length; i++) { + let currentLevel = levels[i]; + /* There are keyboard maps which consist of 3 levels (no uppercase, + * basically). We however make things consistent by skipping that + * second level. + */ + let level = i >= 1 && levels.length == 3 ? i + 1 : i; + + let layout = new KeyContainer(); + layout.shiftKeys = []; + layout.mode = currentLevel.mode; + + this._loadRows(currentLevel, level, levels.length, layout); + layers[level] = layout; + this._aspectContainer.add_child(layout); + layout.layoutButtons(); + + layout.hide(); + } + + return layers; + } + + _ensureKeysForGroup(group) { + if (!this._groups[group]) + this._groups[group] = this._createLayersForGroup(group); + } + + _addRowKeys(keys, layout) { + for (let i = 0; i < keys.length; ++i) { + const key = keys[i]; + const {strings} = key; + const commitString = strings?.shift(); + + let button = new Key({ + commitString, + label: key.label, + iconName: key.iconName, + keyval: key.keyval, + }, strings); + + if (key.width !== null) + button.setWidth(key.width); + + if (key.action !== 'modifier') { + button.connect('commit', (_actor, keyval, str) => { + this._commitAction(keyval, str).then(() => { + if (layout.mode === 'latched' && !this._latched) + this._setActiveLayer(0); + }); + }); + } + + if (key.action !== null) { + button.connect('released', () => { + if (key.action === 'hide') { + this.close(); + } else if (key.action === 'languageMenu') { + this._popupLanguageMenu(button); + } else if (key.action === 'emoji') { + this._toggleEmoji(); + } else if (key.action === 'modifier') { + this._toggleModifier(key.keyval); + } else if (key.action === 'delete') { + this._toggleDelete(true); + this._toggleDelete(false); + } else if (!this._longPressed && key.action === 'levelSwitch') { + this._setActiveLayer(key.level); + this._setLatched( + key.level === 1 && + key.iconName === 'keyboard-caps-lock-symbolic'); + } + + this._longPressed = false; + }); + } + + if (key.action === 'levelSwitch' && + key.iconName === 'keyboard-shift-symbolic') { + layout.shiftKeys.push(button); + if (key.level === 1) { + button.connect('long-press', () => { + this._setActiveLayer(key.level); + this._setLatched(true); + this._longPressed = true; + }); + } + } + + if (key.action === 'delete') { + button.connect('long-press', + () => this._toggleDelete(true)); + } + + if (key.action === 'modifier') { + let modifierKeys = this._modifierKeys[key.keyval] || []; + modifierKeys.push(button); + this._modifierKeys[key.keyval] = modifierKeys; + } + + if (key.action || key.keyval) + button.keyButton.add_style_class_name('default-key'); + + layout.appendKey(button, button.keyButton.keyWidth); + } + } + + async _commitAction(keyval, str) { + if (this._modifiers.size === 0 && str !== '' && + keyval && this._oskCompletionEnabled) { + if (await Main.inputMethod.handleVirtualKey(keyval)) + return; + } + + if (str === '' || !Main.inputMethod.currentFocus || + (keyval && this._oskCompletionEnabled) || + this._modifiers.size > 0 || + !this._keyboardController.commitString(str, true)) { + if (keyval !== 0) { + this._forwardModifiers(this._modifiers, Clutter.EventType.KEY_PRESS); + this._keyboardController.keyvalPress(keyval); + GLib.timeout_add(GLib.PRIORITY_DEFAULT, KEY_RELEASE_TIMEOUT, () => { + this._keyboardController.keyvalRelease(keyval); + this._forwardModifiers(this._modifiers, Clutter.EventType.KEY_RELEASE); + this._disableAllModifiers(); + return GLib.SOURCE_REMOVE; + }); + } + } + } + + _previousWordPosition(text, cursor) { + /* Skip word prior to cursor */ + let pos = Math.max(0, text.slice(0, cursor).search(/\s+\S+\s*$/)); + if (pos < 0) + return 0; + + /* Skip contiguous spaces */ + for (; pos >= 0; pos--) { + if (text.charAt(pos) !== ' ') + return GLib.utf8_strlen(text.slice(0, pos + 1), -1); + } + + return 0; + } + + _toggleDelete(enabled) { + if (this._deleteEnabled === enabled) + return; + + this._deleteEnabled = enabled; + this._timesDeleted = 0; + + if (!Main.inputMethod.currentFocus || + Main.inputMethod.hasPreedit() || + Main.inputMethod.terminalMode) { + /* If there is no IM focus or are in the middle of preedit, + * fallback to keypresses */ + if (enabled) + this._keyboardController.keyvalPress(Clutter.KEY_BackSpace); + else + this._keyboardController.keyvalRelease(Clutter.KEY_BackSpace); + return; + } + + if (enabled) { + let func = (text, cursor) => { + if (cursor === 0) + return; + + let encoder = new TextEncoder(); + let decoder = new TextDecoder(); + + /* Find cursor/anchor position in characters */ + const cursorIdx = GLib.utf8_strlen(decoder.decode(encoder.encode( + text).slice(0, cursor)), -1); + const anchorIdx = this._timesDeleted < BACKSPACE_WORD_DELETE_THRESHOLD + ? cursorIdx - 1 + : this._previousWordPosition(text, cursor); + /* Now get offset from cursor */ + const offset = anchorIdx - cursorIdx; + + this._timesDeleted++; + Main.inputMethod.delete_surrounding(offset, Math.abs(offset)); + }; + + this._surroundingUpdateId = Main.inputMethod.connect( + 'surrounding-text-set', () => { + let [text, cursor] = Main.inputMethod.getSurroundingText(); + if (this._timesDeleted === 0) { + func(text, cursor); + } else { + if (this._surroundingUpdateTimeoutId > 0) { + GLib.source_remove(this._surroundingUpdateTimeoutId); + this._surroundingUpdateTimeoutId = 0; + } + this._surroundingUpdateTimeoutId = + GLib.timeout_add(GLib.PRIORITY_DEFAULT, KEY_RELEASE_TIMEOUT, () => { + func(text, cursor); + this._surroundingUpdateTimeoutId = 0; + return GLib.SOURCE_REMOVE; + }); + } + }); + + let [text, cursor] = Main.inputMethod.getSurroundingText(); + if (text) + func(text, cursor); + else + Main.inputMethod.request_surrounding(); + } else { + if (this._surroundingUpdateId > 0) { + Main.inputMethod.disconnect(this._surroundingUpdateId); + this._surroundingUpdateId = 0; + } + if (this._surroundingUpdateTimeoutId > 0) { + GLib.source_remove(this._surroundingUpdateTimeoutId); + this._surroundingUpdateTimeoutId = 0; + } + } + } + + _setLatched(latched) { + this._latched = latched; + this._setCurrentLevelLatched(this._currentPage, this._latched); + } + + _setModifierEnabled(keyval, enabled) { + if (enabled) + this._modifiers.add(keyval); + else + this._modifiers.delete(keyval); + + for (const key of this._modifierKeys[keyval]) + key.setLatched(enabled); + } + + _toggleModifier(keyval) { + const isActive = this._modifiers.has(keyval); + this._setModifierEnabled(keyval, !isActive); + } + + _forwardModifiers(modifiers, type) { + for (const keyval of modifiers) { + if (type === Clutter.EventType.KEY_PRESS) + this._keyboardController.keyvalPress(keyval); + else if (type === Clutter.EventType.KEY_RELEASE) + this._keyboardController.keyvalRelease(keyval); + } + } + + _disableAllModifiers() { + for (const keyval of this._modifiers) + this._setModifierEnabled(keyval, false); + } + + _popupLanguageMenu(keyActor) { + if (this._languagePopup) + this._languagePopup.destroy(); + + this._languagePopup = new LanguageSelectionPopup(keyActor); + Main.layoutManager.addTopChrome(this._languagePopup.actor); + this._languagePopup.open(true); + } + + _updateCurrentPageVisible() { + if (this._currentPage) + this._currentPage.visible = !this._emojiActive && !this._keypadVisible; + } + + _setEmojiActive(active) { + this._emojiActive = active; + this._emojiSelection.visible = this._emojiActive; + this._updateCurrentPageVisible(); + } + + _toggleEmoji() { + this._setEmojiActive(!this._emojiActive); + } + + _setCurrentLevelLatched(layout, latched) { + for (let i = 0; i < layout.shiftKeys.length; i++) { + let key = layout.shiftKeys[i]; + key.setLatched(latched); + key.iconName = latched + ? 'keyboard-caps-lock-symbolic' : 'keyboard-shift-symbolic'; + } + } + + _loadRows(model, level, numLevels, layout) { + let rows = model.rows; + for (let i = 0; i < rows.length; ++i) { + layout.appendRow(); + this._addRowKeys(rows[i], layout); + } + } + + _getGridSlots() { + let numOfHorizSlots = 0, numOfVertSlots; + let rows = this._currentPage.get_children(); + numOfVertSlots = rows.length; + + for (let i = 0; i < rows.length; ++i) { + let keyboardRow = rows[i]; + let keys = keyboardRow.get_children(); + + numOfHorizSlots = Math.max(numOfHorizSlots, keys.length); + } + + return [numOfHorizSlots, numOfVertSlots]; + } + + _relayout() { + let monitor = Main.layoutManager.keyboardMonitor; + + if (!monitor) + return; + + this.width = monitor.width; + + if (monitor.width > monitor.height) + this.height = monitor.height / 3; + else + this.height = monitor.height / 4; + } + + _updateKeys() { + this._ensureKeysForGroup(this._keyboardController.getCurrentGroup()); + this._setActiveLayer(0); + } + + _onGroupChanged() { + this._updateKeys(); + } + + _onTerminalModeChanged() { + this._updateKeys(); + } + + _onKeyboardGroupsChanged() { + let nonGroupActors = [this._emojiSelection, this._keypad]; + this._aspectContainer.get_children().filter(c => !nonGroupActors.includes(c)).forEach(c => { + c.destroy(); + }); + + this._groups = {}; + this._onGroupChanged(); + } + + _onKeypadVisible(controller, visible) { + if (visible == this._keypadVisible) + return; + + this._keypadVisible = visible; + this._keypad.visible = this._keypadVisible; + this._updateCurrentPageVisible(); + } + + _onEmojiKeyVisible(controller, visible) { + if (visible == this._emojiKeyVisible) + return; + + this._emojiKeyVisible = visible; + /* Rebuild keyboard widgetry to include emoji button */ + this._onKeyboardGroupsChanged(); + } + + _onKeyboardStateChanged(controller, state) { + let enabled; + if (state == Clutter.InputPanelState.OFF) + enabled = false; + else if (state == Clutter.InputPanelState.ON) + enabled = true; + else if (state == Clutter.InputPanelState.TOGGLE) + enabled = this._keyboardVisible == false; + else + return; + + if (enabled) + this.open(Main.layoutManager.focusIndex); + else + this.close(); + } + + _setActiveLayer(activeLevel) { + let activeGroupName = this._keyboardController.getCurrentGroup(); + let layers = this._groups[activeGroupName]; + let currentPage = layers[activeLevel]; + + if (this._currentPage == currentPage) { + this._updateCurrentPageVisible(); + return; + } + + if (this._currentPage != null) { + this._setCurrentLevelLatched(this._currentPage, false); + this._currentPage.disconnect(this._currentPage._destroyID); + this._currentPage.hide(); + delete this._currentPage._destroyID; + } + + this._disableAllModifiers(); + this._currentPage = currentPage; + this._currentPage._destroyID = this._currentPage.connect('destroy', () => { + this._currentPage = null; + }); + this._updateCurrentPageVisible(); + this._aspectContainer.setRatio(...this._currentPage.getRatio()); + this._emojiSelection.setRatio(...this._currentPage.getRatio()); + } + + _clearKeyboardRestTimer() { + if (!this._keyboardRestingId) + return; + GLib.source_remove(this._keyboardRestingId); + this._keyboardRestingId = 0; + } + + open(immediate = false) { + this._clearShowIdle(); + this._keyboardRequested = true; + + if (this._keyboardVisible) { + this._relayout(); + return; + } + + this._oskCompletionEnabled = + IBusManager.getIBusManager().setCompletionEnabled(true, () => Main.inputMethod.update()); + this._clearKeyboardRestTimer(); + + if (immediate) { + this._open(); + return; + } + + this._keyboardRestingId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, + KEYBOARD_REST_TIME, + () => { + this._clearKeyboardRestTimer(); + this._open(); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._keyboardRestingId, '[gnome-shell] this._clearKeyboardRestTimer'); + } + + _open() { + if (!this._keyboardRequested) + return; + + this._relayout(); + this._animateShow(); + + this._setEmojiActive(false); + } + + close(immediate = false) { + this._clearShowIdle(); + this._keyboardRequested = false; + + if (!this._keyboardVisible) + return; + + IBusManager.getIBusManager().setCompletionEnabled(false, () => Main.inputMethod.update()); + this._oskCompletionEnabled = false; + this._clearKeyboardRestTimer(); + + if (immediate) { + this._close(); + return; + } + + this._keyboardRestingId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, + KEYBOARD_REST_TIME, + () => { + this._clearKeyboardRestTimer(); + this._close(); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._keyboardRestingId, '[gnome-shell] this._clearKeyboardRestTimer'); + } + + _close() { + if (this._keyboardRequested) + return; + + this._animateHide(); + this.setCursorLocation(null); + this._disableAllModifiers(); + } + + _animateShow() { + if (this._focusWindow) + this._animateWindow(this._focusWindow, true); + + Main.layoutManager.keyboardBox.show(); + this.ease({ + translation_y: -this.height, + opacity: 255, + duration: KEYBOARD_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._animateShowComplete(); + }, + }); + this._keyboardVisible = true; + this.emit('visibility-changed'); + } + + _animateShowComplete() { + let keyboardBox = Main.layoutManager.keyboardBox; + this._keyboardHeightNotifyId = keyboardBox.connect('notify::height', () => { + this.translation_y = -this.height; + }); + + // Toggle visibility so the keyboardBox can update its chrome region. + if (!Meta.is_wayland_compositor()) { + keyboardBox.hide(); + keyboardBox.show(); + } + } + + _animateHide() { + if (this._focusWindow) + this._animateWindow(this._focusWindow, false); + + if (this._keyboardHeightNotifyId) { + Main.layoutManager.keyboardBox.disconnect(this._keyboardHeightNotifyId); + this._keyboardHeightNotifyId = 0; + } + this.ease({ + translation_y: 0, + opacity: 0, + duration: KEYBOARD_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_IN_QUAD, + onComplete: () => { + this._animateHideComplete(); + }, + }); + + this._keyboardVisible = false; + this.emit('visibility-changed'); + } + + _animateHideComplete() { + Main.layoutManager.keyboardBox.hide(); + } + + gestureProgress(delta) { + this._gestureInProgress = true; + Main.layoutManager.keyboardBox.show(); + let progress = Math.min(delta, this.height) / this.height; + this.translation_y = -this.height * progress; + this.opacity = 255 * progress; + const windowActor = this._focusWindow?.get_compositor_private(); + if (windowActor) + windowActor.y = this._focusWindowStartY - (this.height * progress); + } + + gestureActivate() { + this.open(true); + this._gestureInProgress = false; + } + + gestureCancel() { + if (this._gestureInProgress) + this._animateHide(); + this._gestureInProgress = false; + } + + resetSuggestions() { + if (this._suggestions) + this._suggestions.clear(); + } + + setSuggestionsVisible(visible) { + this._suggestions?.setVisible(visible); + } + + addSuggestion(text, callback) { + if (!this._suggestions) + return; + this._suggestions.add(text, callback); + this._suggestions.show(); + } + + _clearShowIdle() { + if (!this._showIdleId) + return; + GLib.source_remove(this._showIdleId); + this._showIdleId = 0; + } + + _windowSlideAnimationComplete(window, finalY) { + // Synchronize window positions again. + const frameRect = window.get_frame_rect(); + const bufferRect = window.get_buffer_rect(); + + finalY += frameRect.y - bufferRect.y; + + frameRect.y = finalY; + + this._focusTracker.disconnect(this._windowMovedId); + window.move_frame(true, frameRect.x, frameRect.y); + this._windowMovedId = this._focusTracker.connect('window-moved', + this._onFocusWindowMoving.bind(this)); + } + + _animateWindow(window, show) { + let windowActor = window.get_compositor_private(); + if (!windowActor) + return; + + const finalY = show + ? this._focusWindowStartY - Main.layoutManager.keyboardBox.height + : this._focusWindowStartY; + + windowActor.ease({ + y: finalY, + duration: KEYBOARD_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onStopped: () => { + windowActor.y = finalY; + this._windowSlideAnimationComplete(window, finalY); + }, + }); + } + + _onFocusWindowMoving() { + if (this._focusTracker.currentWindow === this._focusWindow) { + // Don't use _setFocusWindow() here because that would move the + // window while the user has grabbed it. Instead we simply "let go" + // of the window. + this._focusWindow = null; + this._focusWindowStartY = null; + } + + this.close(true); + } + + _setFocusWindow(window) { + if (this._focusWindow === window) + return; + + if (this._keyboardVisible && this._focusWindow) + this._animateWindow(this._focusWindow, false); + + const windowActor = window?.get_compositor_private(); + windowActor?.remove_transition('y'); + this._focusWindowStartY = windowActor ? windowActor.y : null; + + if (this._keyboardVisible && window) + this._animateWindow(window, true); + + this._focusWindow = window; + } + + setCursorLocation(window, x, y, w, h) { + let monitor = Main.layoutManager.keyboardMonitor; + + if (window && monitor) { + const keyboardHeight = Main.layoutManager.keyboardBox.height; + const keyboardY1 = (monitor.y + monitor.height) - keyboardHeight; + + if (this._focusWindow === window) { + if (y + h + keyboardHeight < keyboardY1) + this._setFocusWindow(null); + + return; + } + + if (y + h >= keyboardY1) + this._setFocusWindow(window); + else + this._setFocusWindow(null); + } else { + this._setFocusWindow(null); + } + } +}); + +var KeyboardController = class extends Signals.EventEmitter { + constructor() { + super(); + + let seat = Clutter.get_default_backend().get_default_seat(); + this._virtualDevice = seat.create_virtual_device(Clutter.InputDeviceType.KEYBOARD_DEVICE); + + this._inputSourceManager = InputSourceManager.getInputSourceManager(); + this._inputSourceManager.connectObject( + 'current-source-changed', this._onSourceChanged.bind(this), + 'sources-changed', this._onSourcesModified.bind(this), this); + this._currentSource = this._inputSourceManager.currentSource; + + Main.inputMethod.connectObject( + 'notify::content-purpose', this._onContentPurposeHintsChanged.bind(this), + 'notify::content-hints', this._onContentPurposeHintsChanged.bind(this), + 'input-panel-state', (o, state) => this.emit('panel-state', state), this); + } + + destroy() { + this._inputSourceManager.disconnectObject(this); + Main.inputMethod.disconnectObject(this); + + // Make sure any buttons pressed by the virtual device are released + // immediately instead of waiting for the next GC cycle + this._virtualDevice.run_dispose(); + } + + _onSourcesModified() { + this.emit('groups-changed'); + } + + _onSourceChanged(inputSourceManager, _oldSource) { + let source = inputSourceManager.currentSource; + this._currentSource = source; + this.emit('active-group', source.id); + } + + _onContentPurposeHintsChanged(method) { + let purpose = method.content_purpose; + let emojiVisible = false; + let keypadVisible = false; + + if (purpose == Clutter.InputContentPurpose.NORMAL || + purpose == Clutter.InputContentPurpose.ALPHA || + purpose == Clutter.InputContentPurpose.PASSWORD || + purpose == Clutter.InputContentPurpose.TERMINAL) + emojiVisible = true; + if (purpose == Clutter.InputContentPurpose.DIGITS || + purpose == Clutter.InputContentPurpose.NUMBER || + purpose == Clutter.InputContentPurpose.PHONE) + keypadVisible = true; + + this.emit('emoji-visible', emojiVisible); + this.emit('keypad-visible', keypadVisible); + } + + getGroups() { + let inputSources = this._inputSourceManager.inputSources; + let groups = []; + + for (let i in inputSources) { + let is = inputSources[i]; + groups[is.index] = is.xkbId; + } + + return groups; + } + + getCurrentGroup() { + if (Main.inputMethod.terminalMode) + return 'us-extended'; + + // Special case for Korean, if Hangul mode is disabled, use the 'us' keymap + if (this._currentSource.id === 'hangul') { + const inputSourceManager = InputSourceManager.getInputSourceManager(); + const currentSource = inputSourceManager.currentSource; + let prop; + for (let i = 0; (prop = currentSource.properties.get(i)) !== null; ++i) { + if (prop.get_key() === 'InputMode' && + prop.get_prop_type() === IBus.PropType.TOGGLE && + prop.get_state() !== IBus.PropState.CHECKED) + return 'us'; + } + } + + return this._currentSource.xkbId; + } + + commitString(string, fromKey) { + if (string == null) + return false; + /* Let ibus methods fall through keyval emission */ + if (fromKey && this._currentSource.type == InputSourceManager.INPUT_SOURCE_TYPE_IBUS) + return false; + + Main.inputMethod.commit(string); + return true; + } + + keyvalPress(keyval) { + this._virtualDevice.notify_keyval(Clutter.get_current_event_time() * 1000, + keyval, Clutter.KeyState.PRESSED); + } + + keyvalRelease(keyval) { + this._virtualDevice.notify_keyval(Clutter.get_current_event_time() * 1000, + keyval, Clutter.KeyState.RELEASED); + } +}; diff --git a/js/ui/layout.js b/js/ui/layout.js new file mode 100644 index 0000000..69bf148 --- /dev/null +++ b/js/ui/layout.js @@ -0,0 +1,1451 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported MonitorConstraint, LayoutManager */ + +const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; +const Signals = imports.misc.signals; + +const Background = imports.ui.background; +const BackgroundMenu = imports.ui.backgroundMenu; + +const DND = imports.ui.dnd; +const Main = imports.ui.main; +const Params = imports.misc.params; +const Ripples = imports.ui.ripples; + +var STARTUP_ANIMATION_TIME = 500; +var BACKGROUND_FADE_ANIMATION_TIME = 1000; + +var HOT_CORNER_PRESSURE_THRESHOLD = 100; // pixels +var HOT_CORNER_PRESSURE_TIMEOUT = 1000; // ms + +const SCREEN_TRANSITION_DELAY = 250; // ms +const SCREEN_TRANSITION_DURATION = 500; // ms + +function isPopupMetaWindow(actor) { + switch (actor.meta_window.get_window_type()) { + case Meta.WindowType.DROPDOWN_MENU: + case Meta.WindowType.POPUP_MENU: + case Meta.WindowType.COMBO: + return true; + default: + return false; + } +} + +var MonitorConstraint = GObject.registerClass({ + Properties: { + 'primary': GObject.ParamSpec.boolean('primary', + 'Primary', 'Track primary monitor', + GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE, + false), + 'index': GObject.ParamSpec.int('index', + 'Monitor index', 'Track specific monitor', + GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE, + -1, 64, -1), + 'work-area': GObject.ParamSpec.boolean('work-area', + 'Work-area', 'Track monitor\'s work-area', + GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE, + false), + }, +}, class MonitorConstraint extends Clutter.Constraint { + _init(props) { + this._primary = false; + this._index = -1; + this._workArea = false; + + super._init(props); + } + + get primary() { + return this._primary; + } + + set primary(v) { + if (v) + this._index = -1; + this._primary = v; + if (this.actor) + this.actor.queue_relayout(); + this.notify('primary'); + } + + get index() { + return this._index; + } + + set index(v) { + this._primary = false; + this._index = v; + if (this.actor) + this.actor.queue_relayout(); + this.notify('index'); + } + + get workArea() { + return this._workArea; + } + + set workArea(v) { + if (v == this._workArea) + return; + this._workArea = v; + if (this.actor) + this.actor.queue_relayout(); + this.notify('work-area'); + } + + vfunc_set_actor(actor) { + if (actor) { + if (!this._monitorsChangedId) { + this._monitorsChangedId = + Main.layoutManager.connect('monitors-changed', () => { + this.actor.queue_relayout(); + }); + } + + if (!this._workareasChangedId) { + this._workareasChangedId = + global.display.connect('workareas-changed', () => { + if (this._workArea) + this.actor.queue_relayout(); + }); + } + } else { + if (this._monitorsChangedId) + Main.layoutManager.disconnect(this._monitorsChangedId); + this._monitorsChangedId = 0; + + if (this._workareasChangedId) + global.display.disconnect(this._workareasChangedId); + this._workareasChangedId = 0; + } + + super.vfunc_set_actor(actor); + } + + vfunc_update_allocation(actor, actorBox) { + if (!this._primary && this._index < 0) + return; + + if (!Main.layoutManager.primaryMonitor) + return; + + let index; + if (this._primary) + index = Main.layoutManager.primaryIndex; + else + index = Math.min(this._index, Main.layoutManager.monitors.length - 1); + + let rect; + if (this._workArea) { + let workspaceManager = global.workspace_manager; + let ws = workspaceManager.get_workspace_by_index(0); + rect = ws.get_work_area_for_monitor(index); + } else { + rect = Main.layoutManager.monitors[index]; + } + + actorBox.init_rect(rect.x, rect.y, rect.width, rect.height); + } +}); + +var Monitor = class Monitor { + constructor(index, geometry, geometryScale) { + this.index = index; + this.x = geometry.x; + this.y = geometry.y; + this.width = geometry.width; + this.height = geometry.height; + this.geometry_scale = geometryScale; + } + + get inFullscreen() { + return global.display.get_monitor_in_fullscreen(this.index); + } +}; + +const UiActor = GObject.registerClass( +class UiActor extends St.Widget { + vfunc_get_preferred_width(_forHeight) { + let width = global.stage.width; + return [width, width]; + } + + vfunc_get_preferred_height(_forWidth) { + let height = global.stage.height; + return [height, height]; + } +}); + +const defaultParams = { + trackFullscreen: false, + affectsStruts: false, + affectsInputRegion: true, +}; + +var LayoutManager = GObject.registerClass({ + Signals: { + 'hot-corners-changed': {}, + 'startup-complete': {}, + 'startup-prepared': {}, + 'monitors-changed': {}, + 'system-modal-opened': {}, + }, +}, class LayoutManager extends GObject.Object { + _init() { + super._init(); + + this._rtl = Clutter.get_default_text_direction() == Clutter.TextDirection.RTL; + this.monitors = []; + this.primaryMonitor = null; + this.primaryIndex = -1; + this.hotCorners = []; + + this._keyboardIndex = -1; + this._rightPanelBarrier = null; + + this._inOverview = false; + this._updateRegionIdle = 0; + + this._trackedActors = []; + this._topActors = []; + this._isPopupWindowVisible = false; + this._startingUp = true; + this._pendingLoadBackground = false; + + // Set up stage hierarchy to group all UI actors under one container. + this.uiGroup = new UiActor({ name: 'uiGroup' }); + this.uiGroup.set_flags(Clutter.ActorFlags.NO_LAYOUT); + + global.stage.add_child(this.uiGroup); + + global.stage.remove_actor(global.window_group); + this.uiGroup.add_actor(global.window_group); + + // Using addChrome() to add actors to uiGroup will position actors + // underneath the top_window_group. + // To insert actors at the top of uiGroup, we use addTopChrome() or + // add the actor directly using uiGroup.add_actor(). + global.stage.remove_actor(global.top_window_group); + this.uiGroup.add_actor(global.top_window_group); + + this.overviewGroup = new St.Widget({ + name: 'overviewGroup', + visible: false, + reactive: true, + constraints: new Clutter.BindConstraint({ + source: this.uiGroup, + coordinate: Clutter.BindCoordinate.ALL, + }), + }); + this.addChrome(this.overviewGroup); + + this.screenShieldGroup = new St.Widget({ + name: 'screenShieldGroup', + visible: false, + clip_to_allocation: true, + layout_manager: new Clutter.BinLayout(), + constraints: new Clutter.BindConstraint({ + source: this.uiGroup, + coordinate: Clutter.BindCoordinate.ALL, + }), + }); + this.addChrome(this.screenShieldGroup); + + this.panelBox = new St.BoxLayout({ + name: 'panelBox', + vertical: true, + }); + this.addChrome(this.panelBox, { + affectsStruts: true, + trackFullscreen: true, + }); + this.panelBox.connect('notify::allocation', + this._panelBoxChanged.bind(this)); + + this.modalDialogGroup = new St.Widget({ + name: 'modalDialogGroup', + layout_manager: new Clutter.BinLayout(), + }); + this.uiGroup.add_actor(this.modalDialogGroup); + + this.keyboardBox = new St.BoxLayout({ + name: 'keyboardBox', + reactive: true, + track_hover: true, + }); + this.addTopChrome(this.keyboardBox); + this._keyboardHeightNotifyId = 0; + + this.screenshotUIGroup = new St.Widget({ + name: 'screenshotUIGroup', + layout_manager: new Clutter.BinLayout(), + }); + this.addTopChrome(this.screenshotUIGroup); + + // A dummy actor that tracks the mouse or text cursor, based on the + // position and size set in setDummyCursorGeometry. + this.dummyCursor = new St.Widget({ width: 0, height: 0, opacity: 0 }); + this.uiGroup.add_actor(this.dummyCursor); + + let feedbackGroup = Meta.get_feedback_group_for_display(global.display); + global.stage.remove_actor(feedbackGroup); + this.uiGroup.add_actor(feedbackGroup); + + this._backgroundGroup = new Meta.BackgroundGroup(); + global.window_group.add_child(this._backgroundGroup); + global.window_group.set_child_below_sibling(this._backgroundGroup, null); + this._bgManagers = []; + + this._interfaceSettings = new Gio.Settings({ + schema_id: 'org.gnome.desktop.interface', + }); + + this._interfaceSettings.connect('changed::enable-hot-corners', + this._updateHotCorners.bind(this)); + + // Need to update struts on new workspaces when they are added + let workspaceManager = global.workspace_manager; + workspaceManager.connect('notify::n-workspaces', + this._queueUpdateRegions.bind(this)); + + let display = global.display; + display.connect('restacked', + this._windowsRestacked.bind(this)); + display.connect('in-fullscreen-changed', + this._updateFullscreen.bind(this)); + + let monitorManager = Meta.MonitorManager.get(); + monitorManager.connect('monitors-changed', + this._monitorsChanged.bind(this)); + this._monitorsChanged(); + + this.screenTransition = new ScreenTransition(); + this.uiGroup.add_child(this.screenTransition); + this.screenTransition.add_constraint(new Clutter.BindConstraint({ + source: this.uiGroup, + coordinate: Clutter.BindCoordinate.ALL, + })); + } + + // This is called by Main after everything else is constructed + init() { + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + + this._loadBackground(); + } + + showOverview() { + this.overviewGroup.show(); + this.screenTransition.hide(); + + this._inOverview = true; + this._updateVisibility(); + } + + hideOverview() { + this.overviewGroup.hide(); + this.screenTransition.hide(); + + this._inOverview = false; + this._updateVisibility(); + } + + _sessionUpdated() { + this._updateVisibility(); + this._queueUpdateRegions(); + } + + _updateMonitors() { + let display = global.display; + + this.monitors = []; + let nMonitors = display.get_n_monitors(); + for (let i = 0; i < nMonitors; i++) { + this.monitors.push(new Monitor(i, + display.get_monitor_geometry(i), + display.get_monitor_scale(i))); + } + + if (nMonitors == 0) { + this.primaryIndex = this.bottomIndex = -1; + } else if (nMonitors == 1) { + this.primaryIndex = this.bottomIndex = 0; + } else { + // If there are monitors below the primary, then we need + // to split primary from bottom. + this.primaryIndex = this.bottomIndex = display.get_primary_monitor(); + for (let i = 0; i < this.monitors.length; i++) { + let monitor = this.monitors[i]; + if (this._isAboveOrBelowPrimary(monitor)) { + if (monitor.y > this.monitors[this.bottomIndex].y) + this.bottomIndex = i; + } + } + } + if (this.primaryIndex != -1) { + this.primaryMonitor = this.monitors[this.primaryIndex]; + this.bottomMonitor = this.monitors[this.bottomIndex]; + + if (this._pendingLoadBackground) { + this._loadBackground(); + this._pendingLoadBackground = false; + } + } else { + this.primaryMonitor = null; + this.bottomMonitor = null; + } + } + + _updateHotCorners() { + // destroy old hot corners + this.hotCorners.forEach(corner => { + if (corner) + corner.destroy(); + }); + this.hotCorners = []; + + if (!this._interfaceSettings.get_boolean('enable-hot-corners')) { + this.emit('hot-corners-changed'); + return; + } + + let size = this.panelBox.height; + + // build new hot corners + for (let i = 0; i < this.monitors.length; i++) { + let monitor = this.monitors[i]; + let cornerX = this._rtl ? monitor.x + monitor.width : monitor.x; + let cornerY = monitor.y; + + let haveTopLeftCorner = true; + + if (i != this.primaryIndex) { + // Check if we have a top left (right for RTL) corner. + // I.e. if there is no monitor directly above or to the left(right) + let besideX = this._rtl ? monitor.x + 1 : cornerX - 1; + let besideY = cornerY; + let aboveX = cornerX; + let aboveY = cornerY - 1; + + for (let j = 0; j < this.monitors.length; j++) { + if (i == j) + continue; + let otherMonitor = this.monitors[j]; + if (besideX >= otherMonitor.x && + besideX < otherMonitor.x + otherMonitor.width && + besideY >= otherMonitor.y && + besideY < otherMonitor.y + otherMonitor.height) { + haveTopLeftCorner = false; + break; + } + if (aboveX >= otherMonitor.x && + aboveX < otherMonitor.x + otherMonitor.width && + aboveY >= otherMonitor.y && + aboveY < otherMonitor.y + otherMonitor.height) { + haveTopLeftCorner = false; + break; + } + } + } + + if (haveTopLeftCorner) { + let corner = new HotCorner(this, monitor, cornerX, cornerY); + corner.setBarrierSize(size); + this.hotCorners.push(corner); + } else { + this.hotCorners.push(null); + } + } + + this.emit('hot-corners-changed'); + } + + _addBackgroundMenu(bgManager) { + BackgroundMenu.addBackgroundMenu(bgManager.backgroundActor, this); + } + + _createBackgroundManager(monitorIndex) { + const bgManager = new Background.BackgroundManager({ + container: this._backgroundGroup, + layoutManager: this, + monitorIndex, + }); + + bgManager.connect('changed', this._addBackgroundMenu.bind(this)); + this._addBackgroundMenu(bgManager); + + return bgManager; + } + + _showSecondaryBackgrounds() { + for (let i = 0; i < this.monitors.length; i++) { + if (i != this.primaryIndex) { + let backgroundActor = this._bgManagers[i].backgroundActor; + backgroundActor.show(); + backgroundActor.opacity = 0; + backgroundActor.ease({ + opacity: 255, + duration: BACKGROUND_FADE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + } + } + + _waitLoaded(bgManager) { + return new Promise(resolve => { + const id = bgManager.connect('loaded', () => { + bgManager.disconnect(id); + resolve(); + }); + }); + } + + _updateBackgrounds() { + for (let i = 0; i < this._bgManagers.length; i++) + this._bgManagers[i].destroy(); + + this._bgManagers = []; + + if (Main.sessionMode.isGreeter) + return Promise.resolve(); + + for (let i = 0; i < this.monitors.length; i++) { + let bgManager = this._createBackgroundManager(i); + this._bgManagers.push(bgManager); + + if (i != this.primaryIndex && this._startingUp) + bgManager.backgroundActor.hide(); + } + + return Promise.all(this._bgManagers.map(this._waitLoaded)); + } + + _updateKeyboardBox() { + this.keyboardBox.set_position(this.keyboardMonitor.x, + this.keyboardMonitor.y + this.keyboardMonitor.height); + this.keyboardBox.set_size(this.keyboardMonitor.width, -1); + } + + _updateBoxes() { + if (!this.primaryMonitor) + return; + + this.panelBox.set_position(this.primaryMonitor.x, this.primaryMonitor.y); + this.panelBox.set_size(this.primaryMonitor.width, -1); + + this.keyboardIndex = this.primaryIndex; + } + + _panelBoxChanged() { + this._updatePanelBarrier(); + + let size = this.panelBox.height; + this.hotCorners.forEach(corner => { + if (corner) + corner.setBarrierSize(size); + }); + } + + _updatePanelBarrier() { + if (this._rightPanelBarrier) { + this._rightPanelBarrier.destroy(); + this._rightPanelBarrier = null; + } + + if (!this.primaryMonitor) + return; + + if (this.panelBox.height) { + let primary = this.primaryMonitor; + + this._rightPanelBarrier = new Meta.Barrier({ + display: global.display, + x1: primary.x + primary.width, y1: primary.y, + x2: primary.x + primary.width, y2: primary.y + this.panelBox.height, + directions: Meta.BarrierDirection.NEGATIVE_X, + }); + } + } + + _monitorsChanged() { + this._updateMonitors(); + this._updateBoxes(); + this._updateHotCorners(); + this._updateBackgrounds(); + this._updateFullscreen(); + this._updateVisibility(); + this._queueUpdateRegions(); + + this.emit('monitors-changed'); + } + + _isAboveOrBelowPrimary(monitor) { + let primary = this.monitors[this.primaryIndex]; + let monitorLeft = monitor.x, monitorRight = monitor.x + monitor.width; + let primaryLeft = primary.x, primaryRight = primary.x + primary.width; + + if ((monitorLeft >= primaryLeft && monitorLeft < primaryRight) || + (monitorRight > primaryLeft && monitorRight <= primaryRight) || + (primaryLeft >= monitorLeft && primaryLeft < monitorRight) || + (primaryRight > monitorLeft && primaryRight <= monitorRight)) + return true; + + return false; + } + + get currentMonitor() { + let index = global.display.get_current_monitor(); + return this.monitors[index]; + } + + get keyboardMonitor() { + return this.monitors[this.keyboardIndex]; + } + + get focusIndex() { + let i = Main.layoutManager.primaryIndex; + + if (global.stage.key_focus != null) + i = this.findIndexForActor(global.stage.key_focus); + else if (global.display.focus_window != null) + i = global.display.focus_window.get_monitor(); + return i; + } + + get focusMonitor() { + if (this.focusIndex < 0) + return null; + return this.monitors[this.focusIndex]; + } + + set keyboardIndex(v) { + this._keyboardIndex = v; + this._updateKeyboardBox(); + } + + get keyboardIndex() { + return this._keyboardIndex; + } + + _loadBackground() { + if (!this.primaryMonitor) { + this._pendingLoadBackground = true; + return; + } + this._systemBackground = new Background.SystemBackground(); + this._systemBackground.hide(); + + global.stage.insert_child_below(this._systemBackground, null); + + const constraint = new Clutter.BindConstraint({ + source: global.stage, + coordinate: Clutter.BindCoordinate.ALL, + }); + this._systemBackground.add_constraint(constraint); + + let signalId = this._systemBackground.connect('loaded', () => { + this._systemBackground.disconnect(signalId); + + // We're mostly prepared for the startup animation + // now, but since a lot is going on asynchronously + // during startup, let's defer the startup animation + // until the event loop is uncontended and idle. + // This helps to prevent us from running the animation + // when the system is bogged down + const id = GLib.idle_add(GLib.PRIORITY_LOW, () => { + if (this.primaryMonitor) { + this._systemBackground.show(); + global.stage.show(); + this._prepareStartupAnimation(); + return GLib.SOURCE_REMOVE; + } else { + return GLib.SOURCE_CONTINUE; + } + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] Startup Animation'); + }); + } + + // Startup Animations + // + // We have two different animations, depending on whether we're a greeter + // or a normal session. + // + // In the greeter, we want to animate the panel from the top, and smoothly + // fade the login dialog on top of whatever plymouth left on screen which + // we get as a still frame background before drawing anything else. + // + // Here we just have the code to animate the panel, and fade up the background. + // The login dialog animation is handled by modalDialog.js + // + // When starting a normal user session, we want to grow it out of the middle + // of the screen. + + async _prepareStartupAnimation() { + // During the initial transition, add a simple actor to block all events, + // so they don't get delivered to X11 windows that have been transformed. + this._coverPane = new Clutter.Actor({ + opacity: 0, + width: global.screen_width, + height: global.screen_height, + reactive: true, + }); + this.addChrome(this._coverPane); + + // Force an update of the regions before we scale the UI group to + // get the correct allocation for the struts. + // Do this even when we don't animate on restart, so that maximized + // windows restore to the right size. + this._updateRegions(); + + if (Meta.is_restart()) { + // On restart, we don't do an animation. + } else if (Main.sessionMode.isGreeter) { + this.panelBox.translation_y = -this.panelBox.height; + } else { + this.keyboardBox.hide(); + + let monitor = this.primaryMonitor; + + if (!Main.sessionMode.hasOverview) { + const x = monitor.x + monitor.width / 2.0; + const y = monitor.y + monitor.height / 2.0; + + this.uiGroup.set_pivot_point( + x / global.screen_width, + y / global.screen_height); + this.uiGroup.scale_x = this.uiGroup.scale_y = 0.75; + this.uiGroup.opacity = 0; + } + + global.window_group.set_clip(monitor.x, monitor.y, monitor.width, monitor.height); + + await this._updateBackgrounds(); + } + + this.emit('startup-prepared'); + + this._startupAnimation(); + } + + _startupAnimation() { + if (Meta.is_restart()) + this._startupAnimationComplete(); + else if (Main.sessionMode.isGreeter) + this._startupAnimationGreeter(); + else + this._startupAnimationSession(); + } + + _startupAnimationGreeter() { + this.panelBox.ease({ + translation_y: 0, + duration: STARTUP_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onStopped: () => this._startupAnimationComplete(), + }); + } + + _startupAnimationSession() { + const onStopped = () => this._startupAnimationComplete(); + if (Main.sessionMode.hasOverview) { + Main.overview.runStartupAnimation(onStopped); + } else { + this.uiGroup.ease({ + scale_x: 1, + scale_y: 1, + opacity: 255, + duration: STARTUP_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onStopped, + }); + } + } + + _startupAnimationComplete() { + this._coverPane.destroy(); + this._coverPane = null; + + this._systemBackground.destroy(); + this._systemBackground = null; + + this._startingUp = false; + + this.keyboardBox.show(); + + if (!Main.sessionMode.isGreeter) { + this._showSecondaryBackgrounds(); + global.window_group.remove_clip(); + } + + this._queueUpdateRegions(); + + this.emit('startup-complete'); + } + + // setDummyCursorGeometry: + // + // The cursor dummy is a standard widget commonly used for popup + // menus and box pointers to track, as the box pointer API only + // tracks actors. If you want to pop up a menu based on where the + // user clicked, or where the text cursor is, the cursor dummy + // is what you should use. Given that the menu should not track + // the actual mouse pointer as it moves, you need to call this + // function before you show the menu to ensure it is at the right + // position and has the right size. + setDummyCursorGeometry(x, y, w, h) { + this.dummyCursor.set_position(Math.round(x), Math.round(y)); + this.dummyCursor.set_size(Math.round(w), Math.round(h)); + } + + // addChrome: + // @actor: an actor to add to the chrome + // @params: (optional) additional params + // + // Adds @actor to the chrome, and (unless %affectsInputRegion in + // @params is %false) extends the input region to include it. + // Changes in @actor's size, position, and visibility will + // automatically result in appropriate changes to the input + // region. + // + // If %affectsStruts in @params is %true (and @actor is along a + // screen edge), then @actor's size and position will also affect + // the window manager struts. Changes to @actor's visibility will + // NOT affect whether or not the strut is present, however. + // + // If %trackFullscreen in @params is %true, the actor's visibility + // will be bound to the presence of fullscreen windows on the same + // monitor (it will be hidden whenever a fullscreen window is visible, + // and shown otherwise) + addChrome(actor, params) { + this.uiGroup.add_actor(actor); + if (this.uiGroup.contains(global.top_window_group)) + this.uiGroup.set_child_below_sibling(actor, global.top_window_group); + this._trackActor(actor, params); + } + + // addTopChrome: + // @actor: an actor to add to the chrome + // @params: (optional) additional params + // + // Like addChrome(), but adds @actor above all windows, including popups. + addTopChrome(actor, params) { + this.uiGroup.add_actor(actor); + this._trackActor(actor, params); + } + + // trackChrome: + // @actor: a descendant of the chrome to begin tracking + // @params: parameters describing how to track @actor + // + // Tells the chrome to track @actor. This can be used to extend the + // struts or input region to cover specific children. + // + // @params can have any of the same values as in addChrome(), + // though some possibilities don't make sense. By default, @actor has + // the same params as its chrome ancestor. + trackChrome(actor, params = {}) { + let ancestor = actor.get_parent(); + let index = this._findActor(ancestor); + while (ancestor && index == -1) { + ancestor = ancestor.get_parent(); + index = this._findActor(ancestor); + } + + let ancestorData = ancestor + ? this._trackedActors[index] + : defaultParams; + // We can't use Params.parse here because we want to drop + // the extra values like ancestorData.actor + for (let prop in defaultParams) { + if (!Object.prototype.hasOwnProperty.call(params, prop)) + params[prop] = ancestorData[prop]; + } + + this._trackActor(actor, params); + } + + // untrackChrome: + // @actor: an actor previously tracked via trackChrome() + // + // Undoes the effect of trackChrome() + untrackChrome(actor) { + this._untrackActor(actor); + } + + // removeChrome: + // @actor: a chrome actor + // + // Removes @actor from the chrome + removeChrome(actor) { + this.uiGroup.remove_actor(actor); + this._untrackActor(actor); + } + + _findActor(actor) { + for (let i = 0; i < this._trackedActors.length; i++) { + let actorData = this._trackedActors[i]; + if (actorData.actor == actor) + return i; + } + return -1; + } + + _trackActor(actor, params) { + if (this._findActor(actor) != -1) + throw new Error('trying to re-track existing chrome actor'); + + let actorData = Params.parse(params, defaultParams); + actorData.actor = actor; + actor.connectObject( + 'notify::visible', this._queueUpdateRegions.bind(this), + 'notify::allocation', this._queueUpdateRegions.bind(this), + 'destroy', this._untrackActor.bind(this), this); + // Note that destroying actor will unset its parent, so we don't + // need to connect to 'destroy' too. + + this._trackedActors.push(actorData); + this._updateActorVisibility(actorData); + this._queueUpdateRegions(); + } + + _untrackActor(actor) { + let i = this._findActor(actor); + + if (i == -1) + return; + + this._trackedActors.splice(i, 1); + actor.disconnectObject(this); + + this._queueUpdateRegions(); + } + + _updateActorVisibility(actorData) { + if (!actorData.trackFullscreen) + return; + + let monitor = this.findMonitorForActor(actorData.actor); + actorData.actor.visible = !(global.window_group.visible && + monitor && + monitor.inFullscreen); + } + + _updateVisibility() { + let windowsVisible = Main.sessionMode.hasWindows && !this._inOverview; + + global.window_group.visible = windowsVisible; + global.top_window_group.visible = windowsVisible; + + this._trackedActors.forEach(this._updateActorVisibility.bind(this)); + } + + getWorkAreaForMonitor(monitorIndex) { + // Assume that all workspaces will have the same + // struts and pick the first one. + let workspaceManager = global.workspace_manager; + let ws = workspaceManager.get_workspace_by_index(0); + return ws.get_work_area_for_monitor(monitorIndex); + } + + // This call guarantees that we return some monitor to simplify usage of it + // In practice all tracked actors should be visible on some monitor anyway + findIndexForActor(actor) { + let [x, y] = actor.get_transformed_position(); + let [w, h] = actor.get_transformed_size(); + let rect = new Meta.Rectangle({ x, y, width: w, height: h }); + return global.display.get_monitor_index_for_rect(rect); + } + + findMonitorForActor(actor) { + let index = this.findIndexForActor(actor); + if (index >= 0 && index < this.monitors.length) + return this.monitors[index]; + return null; + } + + _queueUpdateRegions() { + if (!this._updateRegionIdle) { + this._updateRegionIdle = Meta.later_add(Meta.LaterType.BEFORE_REDRAW, + this._updateRegions.bind(this)); + } + } + + _updateFullscreen() { + this._updateVisibility(); + this._queueUpdateRegions(); + } + + _windowsRestacked() { + let changed = false; + + if (this._isPopupWindowVisible != global.top_window_group.get_children().some(isPopupMetaWindow)) + changed = true; + + if (changed) { + this._updateVisibility(); + this._queueUpdateRegions(); + } + } + + _updateRegions() { + if (this._updateRegionIdle) { + Meta.later_remove(this._updateRegionIdle); + delete this._updateRegionIdle; + } + + let rects = [], struts = [], i; + let isPopupMenuVisible = global.top_window_group.get_children().some(isPopupMetaWindow); + const wantsInputRegion = + !this._startingUp && + !isPopupMenuVisible && + Main.modalCount === 0 && + !Meta.is_wayland_compositor(); + + for (i = 0; i < this._trackedActors.length; i++) { + let actorData = this._trackedActors[i]; + if (!(actorData.affectsInputRegion && wantsInputRegion) && !actorData.affectsStruts) + continue; + + let [x, y] = actorData.actor.get_transformed_position(); + let [w, h] = actorData.actor.get_transformed_size(); + x = Math.round(x); + y = Math.round(y); + w = Math.round(w); + h = Math.round(h); + + if (actorData.affectsInputRegion && wantsInputRegion && actorData.actor.get_paint_visibility()) + rects.push(new Meta.Rectangle({ x, y, width: w, height: h })); + + let monitor = null; + if (actorData.affectsStruts) + monitor = this.findMonitorForActor(actorData.actor); + + if (monitor) { + // Limit struts to the size of the screen + let x1 = Math.max(x, 0); + let x2 = Math.min(x + w, global.screen_width); + let y1 = Math.max(y, 0); + let y2 = Math.min(y + h, global.screen_height); + + // Metacity wants to know what side of the monitor the + // strut is considered to be attached to. First, we find + // the monitor that contains the strut. If the actor is + // only touching one edge, or is touching the entire + // border of that monitor, then it's obvious which side + // to call it. If it's in a corner, we pick a side + // arbitrarily. If it doesn't touch any edges, or it + // spans the width/height across the middle of the + // screen, then we don't create a strut for it at all. + + let side; + if (x1 <= monitor.x && x2 >= monitor.x + monitor.width) { + if (y1 <= monitor.y) + side = Meta.Side.TOP; + else if (y2 >= monitor.y + monitor.height) + side = Meta.Side.BOTTOM; + else + continue; + } else if (y1 <= monitor.y && y2 >= monitor.y + monitor.height) { + if (x1 <= monitor.x) + side = Meta.Side.LEFT; + else if (x2 >= monitor.x + monitor.width) + side = Meta.Side.RIGHT; + else + continue; + } else if (x1 <= monitor.x) { + side = Meta.Side.LEFT; + } else if (y1 <= monitor.y) { + side = Meta.Side.TOP; + } else if (x2 >= monitor.x + monitor.width) { + side = Meta.Side.RIGHT; + } else if (y2 >= monitor.y + monitor.height) { + side = Meta.Side.BOTTOM; + } else { + continue; + } + + let strutRect = new Meta.Rectangle({ x: x1, y: y1, width: x2 - x1, height: y2 - y1 }); + let strut = new Meta.Strut({ rect: strutRect, side }); + struts.push(strut); + } + } + + if (wantsInputRegion) + global.set_stage_input_region(rects); + + this._isPopupWindowVisible = isPopupMenuVisible; + + let workspaceManager = global.workspace_manager; + for (let w = 0; w < workspaceManager.n_workspaces; w++) { + let workspace = workspaceManager.get_workspace_by_index(w); + workspace.set_builtin_struts(struts); + } + + return GLib.SOURCE_REMOVE; + } + + modalEnded() { + // We don't update the stage input region while in a modal, + // so queue an update now. + this._queueUpdateRegions(); + } +}); + + +// HotCorner: +// +// This class manages a "hot corner" that can toggle switching to +// overview. +var HotCorner = GObject.registerClass( +class HotCorner extends Clutter.Actor { + _init(layoutManager, monitor, x, y) { + super._init(); + + // We use this flag to mark the case where the user has entered the + // hot corner and has not left both the hot corner and a surrounding + // guard area (the "environs"). This avoids triggering the hot corner + // multiple times due to an accidental jitter. + this._entered = false; + + this._monitor = monitor; + + this._x = x; + this._y = y; + + this._setupFallbackCornerIfNeeded(layoutManager); + + this._pressureBarrier = new PressureBarrier(HOT_CORNER_PRESSURE_THRESHOLD, + HOT_CORNER_PRESSURE_TIMEOUT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW); + this._pressureBarrier.connect('trigger', this._toggleOverview.bind(this)); + + let px = 0.0; + let py = 0.0; + if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) { + px = 1.0; + py = 0.0; + } + + this._ripples = new Ripples.Ripples(px, py, 'ripple-box'); + this._ripples.addTo(layoutManager.uiGroup); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + setBarrierSize(size) { + if (this._verticalBarrier) { + this._pressureBarrier.removeBarrier(this._verticalBarrier); + this._verticalBarrier.destroy(); + this._verticalBarrier = null; + } + + if (this._horizontalBarrier) { + this._pressureBarrier.removeBarrier(this._horizontalBarrier); + this._horizontalBarrier.destroy(); + this._horizontalBarrier = null; + } + + if (size > 0) { + if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) { + this._verticalBarrier = new Meta.Barrier({ + display: global.display, + x1: this._x, x2: this._x, y1: this._y, y2: this._y + size, + directions: Meta.BarrierDirection.NEGATIVE_X, + }); + this._horizontalBarrier = new Meta.Barrier({ + display: global.display, + x1: this._x - size, x2: this._x, y1: this._y, y2: this._y, + directions: Meta.BarrierDirection.POSITIVE_Y, + }); + } else { + this._verticalBarrier = new Meta.Barrier({ + display: global.display, + x1: this._x, x2: this._x, y1: this._y, y2: this._y + size, + directions: Meta.BarrierDirection.POSITIVE_X, + }); + this._horizontalBarrier = new Meta.Barrier({ + display: global.display, + x1: this._x, x2: this._x + size, y1: this._y, y2: this._y, + directions: Meta.BarrierDirection.POSITIVE_Y, + }); + } + + this._pressureBarrier.addBarrier(this._verticalBarrier); + this._pressureBarrier.addBarrier(this._horizontalBarrier); + } + } + + _setupFallbackCornerIfNeeded(layoutManager) { + if (!global.display.supports_extended_barriers()) { + this.set({ + name: 'hot-corner-environs', + x: this._x, + y: this._y, + width: 3, + height: 3, + reactive: true, + }); + + this._corner = new Clutter.Actor({ + name: 'hot-corner', + width: 1, + height: 1, + opacity: 0, + reactive: true, + }); + this._corner._delegate = this; + + this.add_child(this._corner); + layoutManager.addChrome(this); + + if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) { + this._corner.set_position(this.width - this._corner.width, 0); + this.set_pivot_point(1.0, 0.0); + this.translation_x = -this.width; + } else { + this._corner.set_position(0, 0); + } + + this._corner.connect('enter-event', + this._onCornerEntered.bind(this)); + this._corner.connect('leave-event', + this._onCornerLeft.bind(this)); + } + } + + _onDestroy() { + this.setBarrierSize(0); + this._pressureBarrier.destroy(); + this._pressureBarrier = null; + + this._ripples.destroy(); + } + + _toggleOverview() { + if (this._monitor.inFullscreen && !Main.overview.visible) + return; + + if (Main.overview.shouldToggleByCornerOrButton()) { + Main.overview.toggle(); + if (Main.overview.animationInProgress) + this._ripples.playAnimation(this._x, this._y); + } + } + + handleDragOver(source, _actor, _x, _y, _time) { + if (source != Main.xdndHandler) + return DND.DragMotionResult.CONTINUE; + + this._toggleOverview(); + + return DND.DragMotionResult.CONTINUE; + } + + _onCornerEntered() { + if (!this._entered) { + this._entered = true; + this._toggleOverview(); + } + return Clutter.EVENT_PROPAGATE; + } + + _onCornerLeft(actor, event) { + if (event.get_related() != this) + this._entered = false; + // Consume event, otherwise this will confuse onEnvironsLeft + return Clutter.EVENT_STOP; + } + + vfunc_leave_event(crossingEvent) { + if (crossingEvent.related != this._corner) + this._entered = false; + return Clutter.EVENT_PROPAGATE; + } +}); + +var PressureBarrier = class PressureBarrier extends Signals.EventEmitter { + constructor(threshold, timeout, actionMode) { + super(); + + this._threshold = threshold; + this._timeout = timeout; + this._actionMode = actionMode; + this._barriers = []; + this._eventFilter = null; + + this._isTriggered = false; + this._reset(); + } + + addBarrier(barrier) { + barrier._pressureHitId = barrier.connect('hit', this._onBarrierHit.bind(this)); + barrier._pressureLeftId = barrier.connect('left', this._onBarrierLeft.bind(this)); + + this._barriers.push(barrier); + } + + _disconnectBarrier(barrier) { + barrier.disconnect(barrier._pressureHitId); + barrier.disconnect(barrier._pressureLeftId); + } + + removeBarrier(barrier) { + this._disconnectBarrier(barrier); + this._barriers.splice(this._barriers.indexOf(barrier), 1); + } + + destroy() { + this._barriers.forEach(this._disconnectBarrier.bind(this)); + this._barriers = []; + } + + setEventFilter(filter) { + this._eventFilter = filter; + } + + _reset() { + this._barrierEvents = []; + this._currentPressure = 0; + this._lastTime = 0; + } + + _isHorizontal(barrier) { + return barrier.y1 == barrier.y2; + } + + _getDistanceAcrossBarrier(barrier, event) { + if (this._isHorizontal(barrier)) + return Math.abs(event.dy); + else + return Math.abs(event.dx); + } + + _getDistanceAlongBarrier(barrier, event) { + if (this._isHorizontal(barrier)) + return Math.abs(event.dx); + else + return Math.abs(event.dy); + } + + _trimBarrierEvents() { + // Events are guaranteed to be sorted in time order from + // oldest to newest, so just look for the first old event, + // and then chop events after that off. + let i = 0; + let threshold = this._lastTime - this._timeout; + + while (i < this._barrierEvents.length) { + let [time, distance_] = this._barrierEvents[i]; + if (time >= threshold) + break; + i++; + } + + let firstNewEvent = i; + + for (i = 0; i < firstNewEvent; i++) { + let [time_, distance] = this._barrierEvents[i]; + this._currentPressure -= distance; + } + + this._barrierEvents = this._barrierEvents.slice(firstNewEvent); + } + + _onBarrierLeft(barrier, _event) { + barrier._isHit = false; + if (this._barriers.every(b => !b._isHit)) { + this._reset(); + this._isTriggered = false; + } + } + + _trigger() { + this._isTriggered = true; + this.emit('trigger'); + this._reset(); + } + + _onBarrierHit(barrier, event) { + barrier._isHit = true; + + // If we've triggered the barrier, wait until the pointer has the + // left the barrier hitbox until we trigger it again. + if (this._isTriggered) + return; + + if (this._eventFilter && this._eventFilter(event)) + return; + + // Throw out all events not in the proper keybinding mode + if (!(this._actionMode & Main.actionMode)) + return; + + let slide = this._getDistanceAlongBarrier(barrier, event); + let distance = this._getDistanceAcrossBarrier(barrier, event); + + if (distance >= this._threshold) { + this._trigger(); + return; + } + + // Throw out events where the cursor is move more + // along the axis of the barrier than moving with + // the barrier. + if (slide > distance) + return; + + this._lastTime = event.time; + + this._trimBarrierEvents(); + distance = Math.min(15, distance); + + this._barrierEvents.push([event.time, distance]); + this._currentPressure += distance; + + if (this._currentPressure >= this._threshold) + this._trigger(); + } +}; + +var ScreenTransition = GObject.registerClass( +class ScreenTransition extends Clutter.Actor { + _init() { + super._init({ visible: false }); + } + + vfunc_hide() { + this.content = null; + super.vfunc_hide(); + } + + run() { + if (this.visible) + return; + + Main.uiGroup.set_child_above_sibling(this, null); + + const rect = new imports.gi.cairo.RectangleInt({ + x: 0, + y: 0, + width: global.screen_width, + height: global.screen_height, + }); + const [, , , scale] = global.stage.get_capture_final_size(rect); + this.content = global.stage.paint_to_content(rect, scale, Clutter.PaintFlag.NO_CURSORS); + + this.opacity = 255; + this.show(); + + this.ease({ + opacity: 0, + duration: SCREEN_TRANSITION_DURATION, + delay: SCREEN_TRANSITION_DELAY, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onStopped: () => this.hide(), + }); + } +}); diff --git a/js/ui/lightbox.js b/js/ui/lightbox.js new file mode 100644 index 0000000..b0ca77a --- /dev/null +++ b/js/ui/lightbox.js @@ -0,0 +1,289 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Lightbox */ + +const { Clutter, GObject, Shell, St } = imports.gi; + +const Params = imports.misc.params; + +var DEFAULT_FADE_FACTOR = 0.4; +var VIGNETTE_BRIGHTNESS = 0.5; +var VIGNETTE_SHARPNESS = 0.7; + +const VIGNETTE_DECLARATIONS = ' \ +uniform float brightness; \n\ +uniform float vignette_sharpness; \n\ +float rand(vec2 p) { \n\ + return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453123); \n\ +} \n'; + +const VIGNETTE_CODE = ' \ +cogl_color_out.a = cogl_color_in.a; \n\ +cogl_color_out.rgb = vec3(0.0, 0.0, 0.0); \n\ +vec2 position = cogl_tex_coord_in[0].xy - 0.5; \n\ +float t = clamp(length(1.41421 * position), 0.0, 1.0); \n\ +float pixel_brightness = mix(1.0, 1.0 - vignette_sharpness, t); \n\ +cogl_color_out.a *= 1.0 - pixel_brightness * brightness; \n\ +cogl_color_out.a += (rand(position) - 0.5) / 100.0; \n'; + + +var RadialShaderEffect = GObject.registerClass({ + Properties: { + 'brightness': GObject.ParamSpec.float( + 'brightness', 'brightness', 'brightness', + GObject.ParamFlags.READWRITE, + 0, 1, 1), + 'sharpness': GObject.ParamSpec.float( + 'sharpness', 'sharpness', 'sharpness', + GObject.ParamFlags.READWRITE, + 0, 1, 0), + }, +}, class RadialShaderEffect extends Shell.GLSLEffect { + _init(params) { + this._brightness = undefined; + this._sharpness = undefined; + + super._init(params); + + this._brightnessLocation = this.get_uniform_location('brightness'); + this._sharpnessLocation = this.get_uniform_location('vignette_sharpness'); + + this.brightness = 1.0; + this.sharpness = 0.0; + } + + vfunc_build_pipeline() { + this.add_glsl_snippet(Shell.SnippetHook.FRAGMENT, + VIGNETTE_DECLARATIONS, VIGNETTE_CODE, true); + } + + get brightness() { + return this._brightness; + } + + set brightness(v) { + if (this._brightness === v) + return; + this._brightness = v; + this.set_uniform_float(this._brightnessLocation, + 1, [this._brightness]); + this.notify('brightness'); + } + + get sharpness() { + return this._sharpness; + } + + set sharpness(v) { + if (this._sharpness === v) + return; + this._sharpness = v; + this.set_uniform_float(this._sharpnessLocation, + 1, [this._sharpness]); + this.notify('sharpness'); + } +}); + +/** + * Lightbox: + * @container: parent Clutter.Container + * @params: (optional) additional parameters: + * - inhibitEvents: whether to inhibit events for @container + * - width: shade actor width + * - height: shade actor height + * - fadeFactor: fading opacity factor + * - radialEffect: whether to enable the GLSL radial effect + * + * Lightbox creates a dark translucent "shade" actor to hide the + * contents of @container, and allows you to specify particular actors + * in @container to highlight by bringing them above the shade. It + * tracks added and removed actors in @container while the lightboxing + * is active, and ensures that all actors are returned to their + * original stacking order when the lightboxing is removed. (However, + * if actors are restacked by outside code while the lightboxing is + * active, the lightbox may later revert them back to their original + * order.) + * + * By default, the shade window will have the height and width of + * @container and will track any changes in its size. You can override + * this by passing an explicit width and height in @params. + */ +var Lightbox = GObject.registerClass({ + Properties: { + 'active': GObject.ParamSpec.boolean( + 'active', 'active', 'active', GObject.ParamFlags.READABLE, false), + }, +}, class Lightbox extends St.Bin { + _init(container, params) { + params = Params.parse(params, { + inhibitEvents: false, + width: null, + height: null, + fadeFactor: DEFAULT_FADE_FACTOR, + radialEffect: false, + }); + + super._init({ + reactive: params.inhibitEvents, + width: params.width, + height: params.height, + visible: false, + }); + + this._active = false; + this._container = container; + this._children = container.get_children(); + this._fadeFactor = params.fadeFactor; + this._radialEffect = params.radialEffect; + + if (this._radialEffect) + this.add_effect(new RadialShaderEffect({ name: 'radial' })); + else + this.set({ opacity: 0, style_class: 'lightbox' }); + + container.add_actor(this); + container.set_child_above_sibling(this, null); + + this.connect('destroy', this._onDestroy.bind(this)); + + if (!params.width || !params.height) { + this.add_constraint(new Clutter.BindConstraint({ + source: container, + coordinate: Clutter.BindCoordinate.ALL, + })); + } + + container.connectObject( + 'actor-added', this._actorAdded.bind(this), + 'actor-removed', this._actorRemoved.bind(this), this); + + this._highlighted = null; + } + + get active() { + return this._active; + } + + _actorAdded(container, newChild) { + let children = this._container.get_children(); + let myIndex = children.indexOf(this); + let newChildIndex = children.indexOf(newChild); + + if (newChildIndex > myIndex) { + // The child was added above the shade (presumably it was + // made the new top-most child). Move it below the shade, + // and add it to this._children as the new topmost actor. + this._container.set_child_above_sibling(this, newChild); + this._children.push(newChild); + } else if (newChildIndex == 0) { + // Bottom of stack + this._children.unshift(newChild); + } else { + // Somewhere else; insert it into the correct spot + let prevChild = this._children.indexOf(children[newChildIndex - 1]); + if (prevChild != -1) // paranoia + this._children.splice(prevChild + 1, 0, newChild); + } + } + + lightOn(fadeInTime) { + this.remove_all_transitions(); + + let easeProps = { + duration: fadeInTime || 0, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }; + + let onComplete = () => { + this._active = true; + this.notify('active'); + }; + + this.show(); + + if (this._radialEffect) { + this.ease_property( + '@effects.radial.brightness', VIGNETTE_BRIGHTNESS, easeProps); + this.ease_property( + '@effects.radial.sharpness', VIGNETTE_SHARPNESS, + Object.assign({ onComplete }, easeProps)); + } else { + this.ease(Object.assign(easeProps, { + opacity: 255 * this._fadeFactor, + onComplete, + })); + } + } + + lightOff(fadeOutTime) { + this.remove_all_transitions(); + + this._active = false; + this.notify('active'); + + let easeProps = { + duration: fadeOutTime || 0, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }; + + let onComplete = () => this.hide(); + + if (this._radialEffect) { + this.ease_property( + '@effects.radial.brightness', 1.0, easeProps); + this.ease_property( + '@effects.radial.sharpness', 0.0, Object.assign({ onComplete }, easeProps)); + } else { + this.ease(Object.assign(easeProps, { opacity: 0, onComplete })); + } + } + + _actorRemoved(container, child) { + let index = this._children.indexOf(child); + if (index != -1) // paranoia + this._children.splice(index, 1); + + if (child == this._highlighted) + this._highlighted = null; + } + + /** + * highlight: + * @param {Clutter.Actor=} window: actor to highlight + * + * Highlights the indicated actor and unhighlights any other + * currently-highlighted actor. With no arguments or a false/null + * argument, all actors will be unhighlighted. + */ + highlight(window) { + if (this._highlighted == window) + return; + + // Walk this._children raising and lowering actors as needed. + // Things get a little tricky if the to-be-raised and + // to-be-lowered actors were originally adjacent, in which + // case we may need to indicate some *other* actor as the new + // sibling of the to-be-lowered one. + + let below = this; + for (let i = this._children.length - 1; i >= 0; i--) { + if (this._children[i] == window) + this._container.set_child_above_sibling(this._children[i], null); + else if (this._children[i] == this._highlighted) + this._container.set_child_below_sibling(this._children[i], below); + else + below = this._children[i]; + } + + this._highlighted = window; + } + + /** + * _onDestroy: + * + * This is called when the lightbox' actor is destroyed, either + * by destroying its container or by explicitly calling this.destroy(). + */ + _onDestroy() { + this.highlight(null); + } +}); diff --git a/js/ui/locatePointer.js b/js/ui/locatePointer.js new file mode 100644 index 0000000..6ae2941 --- /dev/null +++ b/js/ui/locatePointer.js @@ -0,0 +1,39 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported LocatePointer */ + +const { Gio } = imports.gi; +const Ripples = imports.ui.ripples; +const Main = imports.ui.main; + +const LOCATE_POINTER_KEY = "locate-pointer"; +const LOCATE_POINTER_SCHEMA = "org.gnome.desktop.interface"; + +var LocatePointer = class { + constructor() { + this._settings = new Gio.Settings({ schema_id: LOCATE_POINTER_SCHEMA }); + this._settings.connect(`changed::${LOCATE_POINTER_KEY}`, () => this._syncEnabled()); + this._syncEnabled(); + } + + _syncEnabled() { + let enabled = this._settings.get_boolean(LOCATE_POINTER_KEY); + if (enabled == !!this._ripples) + return; + + if (enabled) { + this._ripples = new Ripples.Ripples(0.5, 0.5, 'ripple-pointer-location'); + this._ripples.addTo(Main.uiGroup); + } else { + this._ripples.destroy(); + this._ripples = null; + } + } + + show() { + if (!this._ripples) + return; + + let [x, y] = global.get_pointer(); + this._ripples.playAnimation(x, y); + } +}; diff --git a/js/ui/lookingGlass.js b/js/ui/lookingGlass.js new file mode 100644 index 0000000..6b6b65f --- /dev/null +++ b/js/ui/lookingGlass.js @@ -0,0 +1,1670 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported LookingGlass */ + +const { + Clutter, Cogl, Gio, GLib, GObject, Graphene, Meta, Pango, Shell, St, +} = imports.gi; +const Signals = imports.misc.signals; +const System = imports.system; + +const History = imports.misc.history; +const ExtensionUtils = imports.misc.extensionUtils; +const PopupMenu = imports.ui.popupMenu; +const ShellEntry = imports.ui.shellEntry; +const Main = imports.ui.main; +const JsParse = imports.misc.jsParse; + +const { ExtensionState } = ExtensionUtils; + +const CHEVRON = '>>> '; + +/* Imports...feel free to add here as needed */ +var commandHeader = 'const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; ' + + 'const Main = imports.ui.main; ' + + /* Utility functions...we should probably be able to use these + * in the shell core code too. */ + 'const stage = global.stage; ' + + /* Special lookingGlass functions */ + 'const inspect = Main.lookingGlass.inspect.bind(Main.lookingGlass); ' + + 'const it = Main.lookingGlass.getIt(); ' + + 'const r = Main.lookingGlass.getResult.bind(Main.lookingGlass); '; + +const HISTORY_KEY = 'looking-glass-history'; +// Time between tabs for them to count as a double-tab event +var AUTO_COMPLETE_DOUBLE_TAB_DELAY = 500; +var AUTO_COMPLETE_SHOW_COMPLETION_ANIMATION_DURATION = 200; +var AUTO_COMPLETE_GLOBAL_KEYWORDS = _getAutoCompleteGlobalKeywords(); + +const LG_ANIMATION_TIME = 500; + +const CLUTTER_DEBUG_FLAG_CATEGORIES = new Map([ + // Paint debugging can easily result in a non-responsive session + ['DebugFlag', { argPos: 0, exclude: ['PAINT'] }], + ['DrawDebugFlag', { argPos: 1, exclude: [] }], + // Exluded due to the only current option likely to result in shooting ones + // foot + // ['PickDebugFlag', { argPos: 2, exclude: [] }], +]); + +function _getAutoCompleteGlobalKeywords() { + const keywords = ['true', 'false', 'null', 'new']; + // Don't add the private properties of globalThis (i.e., ones starting with '_') + const windowProperties = Object.getOwnPropertyNames(globalThis).filter( + a => a.charAt(0) !== '_'); + const headerProperties = JsParse.getDeclaredConstants(commandHeader); + + return keywords.concat(windowProperties).concat(headerProperties); +} + +var AutoComplete = class AutoComplete extends Signals.EventEmitter { + constructor(entry) { + super(); + + this._entry = entry; + this._entry.connect('key-press-event', this._entryKeyPressEvent.bind(this)); + this._lastTabTime = global.get_current_time(); + } + + _processCompletionRequest(event) { + if (event.completions.length == 0) + return; + + // Unique match = go ahead and complete; multiple matches + single tab = complete the common starting string; + // multiple matches + double tab = emit a suggest event with all possible options + if (event.completions.length == 1) { + this.additionalCompletionText(event.completions[0], event.attrHead); + this.emit('completion', { completion: event.completions[0], type: 'whole-word' }); + } else if (event.completions.length > 1 && event.tabType === 'single') { + let commonPrefix = JsParse.getCommonPrefix(event.completions); + + if (commonPrefix.length > 0) { + this.additionalCompletionText(commonPrefix, event.attrHead); + this.emit('completion', { completion: commonPrefix, type: 'prefix' }); + this.emit('suggest', { completions: event.completions }); + } + } else if (event.completions.length > 1 && event.tabType === 'double') { + this.emit('suggest', { completions: event.completions }); + } + } + + _entryKeyPressEvent(actor, event) { + let cursorPos = this._entry.clutter_text.get_cursor_position(); + let text = this._entry.get_text(); + if (cursorPos != -1) + text = text.slice(0, cursorPos); + + if (event.get_key_symbol() == Clutter.KEY_Tab) { + let [completions, attrHead] = JsParse.getCompletions(text, commandHeader, AUTO_COMPLETE_GLOBAL_KEYWORDS); + let currTime = global.get_current_time(); + if ((currTime - this._lastTabTime) < AUTO_COMPLETE_DOUBLE_TAB_DELAY) { + this._processCompletionRequest({ + tabType: 'double', + completions, + attrHead, + }); + } else { + this._processCompletionRequest({ + tabType: 'single', + completions, + attrHead, + }); + } + this._lastTabTime = currTime; + } + return Clutter.EVENT_PROPAGATE; + } + + // Insert characters of text not already included in head at cursor position. i.e., if text="abc" and head="a", + // the string "bc" will be appended to this._entry + additionalCompletionText(text, head) { + let additionalCompletionText = text.slice(head.length); + let cursorPos = this._entry.clutter_text.get_cursor_position(); + + this._entry.clutter_text.insert_text(additionalCompletionText, cursorPos); + } +}; + + +var Notebook = GObject.registerClass({ + Signals: { 'selection': { param_types: [Clutter.Actor.$gtype] } }, +}, class Notebook extends St.BoxLayout { + _init() { + super._init({ + vertical: true, + y_expand: true, + }); + + this.tabControls = new St.BoxLayout({ style_class: 'labels' }); + + this._selectedIndex = -1; + this._tabs = []; + } + + appendPage(name, child) { + const labelBox = new St.BoxLayout({ + style_class: 'notebook-tab', + reactive: true, + track_hover: true, + }); + let label = new St.Button({ label: name }); + label.connect('clicked', () => { + this.selectChild(child); + return true; + }); + labelBox.add_child(label); + this.tabControls.add(labelBox); + + let scrollview = new St.ScrollView({ y_expand: true }); + scrollview.get_hscroll_bar().hide(); + scrollview.add_actor(child); + + const tabData = { + child, + labelBox, + label, + scrollView: scrollview, + _scrollToBottom: false, + }; + this._tabs.push(tabData); + scrollview.hide(); + this.add_child(scrollview); + + let vAdjust = scrollview.vscroll.adjustment; + vAdjust.connect('changed', () => this._onAdjustScopeChanged(tabData)); + vAdjust.connect('notify::value', () => this._onAdjustValueChanged(tabData)); + + if (this._selectedIndex == -1) + this.selectIndex(0); + } + + _unselect() { + if (this._selectedIndex < 0) + return; + let tabData = this._tabs[this._selectedIndex]; + tabData.labelBox.remove_style_pseudo_class('selected'); + tabData.scrollView.hide(); + this._selectedIndex = -1; + } + + selectIndex(index) { + if (index == this._selectedIndex) + return; + if (index < 0) { + this._unselect(); + this.emit('selection', null); + return; + } + + // Focus the new tab before unmapping the old one + let tabData = this._tabs[index]; + if (!tabData.scrollView.navigate_focus(null, St.DirectionType.TAB_FORWARD, false)) + this.grab_key_focus(); + + this._unselect(); + + tabData.labelBox.add_style_pseudo_class('selected'); + tabData.scrollView.show(); + this._selectedIndex = index; + this.emit('selection', tabData.child); + } + + selectChild(child) { + if (child == null) { + this.selectIndex(-1); + } else { + for (let i = 0; i < this._tabs.length; i++) { + let tabData = this._tabs[i]; + if (tabData.child == child) { + this.selectIndex(i); + return; + } + } + } + } + + scrollToBottom(index) { + let tabData = this._tabs[index]; + tabData._scrollToBottom = true; + } + + _onAdjustValueChanged(tabData) { + let vAdjust = tabData.scrollView.vscroll.adjustment; + if (vAdjust.value < (vAdjust.upper - vAdjust.lower - 0.5)) + tabData._scrolltoBottom = false; + } + + _onAdjustScopeChanged(tabData) { + if (!tabData._scrollToBottom) + return; + let vAdjust = tabData.scrollView.vscroll.adjustment; + vAdjust.value = vAdjust.upper - vAdjust.page_size; + } + + nextTab() { + let nextIndex = this._selectedIndex; + if (nextIndex < this._tabs.length - 1) + ++nextIndex; + + this.selectIndex(nextIndex); + } + + prevTab() { + let prevIndex = this._selectedIndex; + if (prevIndex > 0) + --prevIndex; + + this.selectIndex(prevIndex); + } +}); + +function objectToString(o) { + if (typeof o == typeof objectToString) { + // special case this since the default is way, way too verbose + return '<js function>'; + } else if (o && o.toString === undefined) { + // eeks, something unprintable. we'll have to guess, probably a module + return typeof o === 'object' && !(o instanceof Object) + ? '<module>' + : '<unknown>'; + } else { + return `${o}`; + } +} + +var ObjLink = GObject.registerClass( +class ObjLink extends St.Button { + _init(lookingGlass, o, title) { + let text; + if (title) + text = title; + else + text = objectToString(o); + text = GLib.markup_escape_text(text, -1); + + super._init({ + reactive: true, + track_hover: true, + style_class: 'shell-link', + label: text, + x_align: Clutter.ActorAlign.START, + }); + this.get_child().single_line_mode = true; + + this._obj = o; + this._lookingGlass = lookingGlass; + } + + vfunc_clicked() { + this._lookingGlass.inspectObject(this._obj, this); + } +}); + +var Result = GObject.registerClass( +class Result extends St.BoxLayout { + _init(lookingGlass, command, o, index) { + super._init({ vertical: true }); + + this.index = index; + this.o = o; + + this._lookingGlass = lookingGlass; + + let cmdTxt = new St.Label({ text: command }); + cmdTxt.clutter_text.ellipsize = Pango.EllipsizeMode.END; + this.add(cmdTxt); + let box = new St.BoxLayout({}); + this.add(box); + let resultTxt = new St.Label({ text: `r(${index}) = ` }); + resultTxt.clutter_text.ellipsize = Pango.EllipsizeMode.END; + box.add(resultTxt); + let objLink = new ObjLink(this._lookingGlass, o); + box.add(objLink); + } +}); + +var WindowList = GObject.registerClass({ +}, class WindowList extends St.BoxLayout { + _init(lookingGlass) { + super._init({ name: 'Windows', vertical: true, style: 'spacing: 8px' }); + let tracker = Shell.WindowTracker.get_default(); + this._updateId = Main.initializeDeferredWork(this, this._updateWindowList.bind(this)); + global.display.connect('window-created', this._updateWindowList.bind(this)); + tracker.connect('tracked-windows-changed', this._updateWindowList.bind(this)); + + this._lookingGlass = lookingGlass; + } + + _updateWindowList() { + if (!this._lookingGlass.isOpen) + return; + + this.destroy_all_children(); + let windows = global.get_window_actors(); + let tracker = Shell.WindowTracker.get_default(); + for (let i = 0; i < windows.length; i++) { + let metaWindow = windows[i].metaWindow; + // Avoid multiple connections + if (!metaWindow._lookingGlassManaged) { + metaWindow.connect('unmanaged', this._updateWindowList.bind(this)); + metaWindow._lookingGlassManaged = true; + } + let box = new St.BoxLayout({ vertical: true }); + this.add(box); + let windowLink = new ObjLink(this._lookingGlass, metaWindow, metaWindow.title); + box.add_child(windowLink); + let propsBox = new St.BoxLayout({ vertical: true, style: 'padding-left: 6px;' }); + box.add(propsBox); + propsBox.add(new St.Label({ text: `wmclass: ${metaWindow.get_wm_class()}` })); + let app = tracker.get_window_app(metaWindow); + if (app != null && !app.is_window_backed()) { + let icon = app.create_icon_texture(22); + let propBox = new St.BoxLayout({ style: 'spacing: 6px; ' }); + propsBox.add(propBox); + propBox.add_child(new St.Label({ text: 'app: ' })); + let appLink = new ObjLink(this._lookingGlass, app, app.get_id()); + propBox.add_child(appLink); + propBox.add_child(icon); + } else { + propsBox.add(new St.Label({ text: '<untracked>' })); + } + } + } + + update() { + this._updateWindowList(); + } +}); + +var ObjInspector = GObject.registerClass( +class ObjInspector extends St.ScrollView { + _init(lookingGlass) { + super._init({ + pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }), + }); + + this._obj = null; + this._previousObj = null; + + this._parentList = []; + + this.get_hscroll_bar().hide(); + this._container = new St.BoxLayout({ + name: 'LookingGlassPropertyInspector', + style_class: 'lg-dialog', + vertical: true, + x_expand: true, + y_expand: true, + }); + this.add_actor(this._container); + + this._lookingGlass = lookingGlass; + } + + selectObject(obj, skipPrevious) { + if (!skipPrevious) + this._previousObj = this._obj; + else + this._previousObj = null; + this._obj = obj; + + this._container.destroy_all_children(); + + let hbox = new St.BoxLayout({ style_class: 'lg-obj-inspector-title' }); + this._container.add_actor(hbox); + let label = new St.Label({ + text: `Inspecting: ${typeof obj}: ${objectToString(obj)}`, + x_expand: true, + }); + label.single_line_mode = true; + hbox.add_child(label); + let button = new St.Button({ label: 'Insert', style_class: 'lg-obj-inspector-button' }); + button.connect('clicked', this._onInsert.bind(this)); + hbox.add(button); + + if (this._previousObj != null) { + button = new St.Button({ label: 'Back', style_class: 'lg-obj-inspector-button' }); + button.connect('clicked', this._onBack.bind(this)); + hbox.add(button); + } + + button = new St.Button({ + style_class: 'window-close', + icon_name: 'window-close-symbolic', + }); + button.connect('clicked', this.close.bind(this)); + hbox.add(button); + if (typeof obj == typeof {}) { + let properties = []; + for (let propName in obj) + properties.push(propName); + properties.sort(); + + for (let i = 0; i < properties.length; i++) { + let propName = properties[i]; + let link; + try { + let prop = obj[propName]; + link = new ObjLink(this._lookingGlass, prop); + } catch (e) { + link = new St.Label({ text: '<error>' }); + } + let box = new St.BoxLayout(); + box.add(new St.Label({ text: `${propName}: ` })); + box.add(link); + this._container.add_actor(box); + } + } + } + + open(sourceActor) { + if (this._open) + return; + + const grab = Main.pushModal(this, { actionMode: Shell.ActionMode.LOOKING_GLASS }); + if (grab.get_seat_state() !== Clutter.GrabState.ALL) { + Main.popModal(grab); + return; + } + + this._grab = grab; + this._previousObj = null; + this._open = true; + this.show(); + if (sourceActor) { + this.set_scale(0, 0); + this.ease({ + scale_x: 1, + scale_y: 1, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: 200, + }); + } else { + this.set_scale(1, 1); + } + } + + close() { + if (!this._open) + return; + Main.popModal(this._grab); + this._grab = null; + this._open = false; + this.hide(); + this._previousObj = null; + this._obj = null; + } + + vfunc_key_press_event(keyPressEvent) { + const symbol = keyPressEvent.keyval; + if (symbol === Clutter.KEY_Escape) { + this.close(); + return Clutter.EVENT_STOP; + } + return super.vfunc_key_press_event(keyPressEvent); + } + + _onInsert() { + let obj = this._obj; + this.close(); + this._lookingGlass.insertObject(obj); + } + + _onBack() { + this.selectObject(this._previousObj, true); + } +}); + +var RedBorderEffect = GObject.registerClass( +class RedBorderEffect extends Clutter.Effect { + _init() { + super._init(); + this._pipeline = null; + } + + vfunc_paint_node(node, paintContext) { + let actor = this.get_actor(); + + const actorNode = new Clutter.ActorNode(actor, -1); + node.add_child(actorNode); + + if (!this._pipeline) { + const framebuffer = paintContext.get_framebuffer(); + const coglContext = framebuffer.get_context(); + + let color = new Cogl.Color(); + color.init_from_4ub(0xff, 0, 0, 0xc4); + + this._pipeline = new Cogl.Pipeline(coglContext); + this._pipeline.set_color(color); + } + + let alloc = actor.get_allocation_box(); + let width = 2; + + const pipelineNode = new Clutter.PipelineNode(this._pipeline); + pipelineNode.set_name('Red Border'); + node.add_child(pipelineNode); + + const box = new Clutter.ActorBox(); + + // clockwise order + box.set_origin(0, 0); + box.set_size(alloc.get_width(), width); + pipelineNode.add_rectangle(box); + + box.set_origin(alloc.get_width() - width, width); + box.set_size(width, alloc.get_height() - width); + pipelineNode.add_rectangle(box); + + box.set_origin(0, alloc.get_height() - width); + box.set_size(alloc.get_width() - width, width); + pipelineNode.add_rectangle(box); + + box.set_origin(0, width); + box.set_size(width, alloc.get_height() - width * 2); + pipelineNode.add_rectangle(box); + } +}); + +var Inspector = GObject.registerClass({ + Signals: { + 'closed': {}, + 'target': { param_types: [Clutter.Actor.$gtype, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] }, + }, +}, class Inspector extends Clutter.Actor { + _init(lookingGlass) { + super._init({ width: 0, height: 0 }); + + Main.uiGroup.add_actor(this); + + const eventHandler = new St.BoxLayout({ + name: 'LookingGlassDialog', + vertical: false, + reactive: true, + }); + this._eventHandler = eventHandler; + this.add_actor(eventHandler); + this._displayText = new St.Label({ x_expand: true }); + eventHandler.add_child(this._displayText); + + eventHandler.connect('key-press-event', this._onKeyPressEvent.bind(this)); + eventHandler.connect('button-press-event', this._onButtonPressEvent.bind(this)); + eventHandler.connect('scroll-event', this._onScrollEvent.bind(this)); + eventHandler.connect('motion-event', this._onMotionEvent.bind(this)); + + this._grab = global.stage.grab(eventHandler); + + // this._target is the actor currently shown by the inspector. + // this._pointerTarget is the actor directly under the pointer. + // Normally these are the same, but if you use the scroll wheel + // to drill down, they'll diverge until you either scroll back + // out, or move the pointer outside of _pointerTarget. + this._target = null; + this._pointerTarget = null; + + this._lookingGlass = lookingGlass; + } + + vfunc_allocate(box) { + this.set_allocation(box); + + if (!this._eventHandler) + return; + + let primary = Main.layoutManager.primaryMonitor; + + let [, , natWidth, natHeight] = + this._eventHandler.get_preferred_size(); + + let childBox = new Clutter.ActorBox(); + childBox.x1 = primary.x + Math.floor((primary.width - natWidth) / 2); + childBox.x2 = childBox.x1 + natWidth; + childBox.y1 = primary.y + Math.floor((primary.height - natHeight) / 2); + childBox.y2 = childBox.y1 + natHeight; + this._eventHandler.allocate(childBox); + } + + _close() { + if (this._grab) { + this._grab.dismiss(); + this._grab = null; + } + this._eventHandler.destroy(); + this._eventHandler = null; + this.emit('closed'); + } + + _onKeyPressEvent(actor, event) { + if (event.get_key_symbol() === Clutter.KEY_Escape) + this._close(); + return Clutter.EVENT_STOP; + } + + _onButtonPressEvent(actor, event) { + if (this._target) { + let [stageX, stageY] = event.get_coords(); + this.emit('target', this._target, stageX, stageY); + } + this._close(); + return Clutter.EVENT_STOP; + } + + _onScrollEvent(actor, event) { + switch (event.get_scroll_direction()) { + case Clutter.ScrollDirection.UP: { + // select parent + let parent = this._target.get_parent(); + if (parent != null) { + this._target = parent; + this._update(event); + } + break; + } + + case Clutter.ScrollDirection.DOWN: + // select child + if (this._target != this._pointerTarget) { + let child = this._pointerTarget; + while (child) { + let parent = child.get_parent(); + if (parent == this._target) + break; + child = parent; + } + if (child) { + this._target = child; + this._update(event); + } + } + break; + + default: + break; + } + return Clutter.EVENT_STOP; + } + + _onMotionEvent(actor, event) { + this._update(event); + return Clutter.EVENT_STOP; + } + + _update(event) { + let [stageX, stageY] = event.get_coords(); + let target = global.stage.get_actor_at_pos(Clutter.PickMode.ALL, + stageX, + stageY); + + if (target != this._pointerTarget) + this._target = target; + this._pointerTarget = target; + + let position = `[inspect x: ${stageX} y: ${stageY}]`; + this._displayText.text = ''; + this._displayText.text = `${position} ${this._target}`; + + this._lookingGlass.setBorderPaintTarget(this._target); + } +}); + +var Extensions = GObject.registerClass({ +}, class Extensions extends St.BoxLayout { + _init(lookingGlass) { + super._init({ vertical: true, name: 'lookingGlassExtensions' }); + + this._lookingGlass = lookingGlass; + this._noExtensions = new St.Label({ + style_class: 'lg-extensions-none', + text: _('No extensions installed'), + }); + this._numExtensions = 0; + this._extensionsList = new St.BoxLayout({ + vertical: true, + style_class: 'lg-extensions-list', + }); + this._extensionsList.add(this._noExtensions); + this.add(this._extensionsList); + + Main.extensionManager.getUuids().forEach(uuid => { + this._loadExtension(null, uuid); + }); + + Main.extensionManager.connect('extension-loaded', + this._loadExtension.bind(this)); + } + + _loadExtension(o, uuid) { + let extension = Main.extensionManager.lookup(uuid); + // There can be cases where we create dummy extension metadata + // that's not really a proper extension. Don't bother with these. + if (!extension.metadata.name) + return; + + let extensionDisplay = this._createExtensionDisplay(extension); + if (this._numExtensions == 0) + this._extensionsList.remove_actor(this._noExtensions); + + this._numExtensions++; + const { name } = extension.metadata; + const pos = [...this._extensionsList].findIndex( + dsp => dsp._extension.metadata.name.localeCompare(name) > 0); + this._extensionsList.insert_child_at_index(extensionDisplay, pos); + } + + _onViewSource(actor) { + let extension = actor._extension; + let uri = extension.dir.get_uri(); + Gio.app_info_launch_default_for_uri(uri, global.create_app_launch_context(0, -1)); + this._lookingGlass.close(); + } + + _onWebPage(actor) { + let extension = actor._extension; + Gio.app_info_launch_default_for_uri(extension.metadata.url, global.create_app_launch_context(0, -1)); + this._lookingGlass.close(); + } + + _onViewErrors(actor) { + let extension = actor._extension; + let shouldShow = !actor._isShowing; + + if (shouldShow) { + let errors = extension.errors; + let errorDisplay = new St.BoxLayout({ vertical: true }); + if (errors && errors.length) { + for (let i = 0; i < errors.length; i++) + errorDisplay.add(new St.Label({ text: errors[i] })); + } else { + /* Translators: argument is an extension UUID. */ + let message = _("%s has not emitted any errors.").format(extension.uuid); + errorDisplay.add(new St.Label({ text: message })); + } + + actor._errorDisplay = errorDisplay; + actor._parentBox.add(errorDisplay); + actor.label = _("Hide Errors"); + } else { + actor._errorDisplay.destroy(); + actor._errorDisplay = null; + actor.label = _("Show Errors"); + } + + actor._isShowing = shouldShow; + } + + _stateToString(extensionState) { + switch (extensionState) { + case ExtensionState.ENABLED: + return _("Enabled"); + case ExtensionState.DISABLED: + case ExtensionState.INITIALIZED: + return _("Disabled"); + case ExtensionState.ERROR: + return _("Error"); + case ExtensionState.OUT_OF_DATE: + return _("Out of date"); + case ExtensionState.DOWNLOADING: + return _("Downloading"); + } + return 'Unknown'; // Not translated, shouldn't appear + } + + _createExtensionDisplay(extension) { + let box = new St.BoxLayout({ style_class: 'lg-extension', vertical: true }); + box._extension = extension; + let name = new St.Label({ + style_class: 'lg-extension-name', + text: extension.metadata.name, + x_expand: true, + }); + box.add_child(name); + let description = new St.Label({ + style_class: 'lg-extension-description', + text: extension.metadata.description || 'No description', + x_expand: true, + }); + box.add_child(description); + + let metaBox = new St.BoxLayout({ style_class: 'lg-extension-meta' }); + box.add(metaBox); + const state = new St.Label({ + style_class: 'lg-extension-state', + text: this._stateToString(extension.state), + }); + metaBox.add(state); + + const viewsource = new St.Button({ + reactive: true, + track_hover: true, + style_class: 'shell-link', + label: _('View Source'), + }); + viewsource._extension = extension; + viewsource.connect('clicked', this._onViewSource.bind(this)); + metaBox.add(viewsource); + + if (extension.metadata.url) { + const webpage = new St.Button({ + reactive: true, + track_hover: true, + style_class: 'shell-link', + label: _('Web Page'), + }); + webpage._extension = extension; + webpage.connect('clicked', this._onWebPage.bind(this)); + metaBox.add(webpage); + } + + const viewerrors = new St.Button({ + reactive: true, + track_hover: true, + style_class: 'shell-link', + label: _('Show Errors'), + }); + viewerrors._extension = extension; + viewerrors._parentBox = box; + viewerrors._isShowing = false; + viewerrors.connect('clicked', this._onViewErrors.bind(this)); + metaBox.add(viewerrors); + + return box; + } +}); + + +var ActorLink = GObject.registerClass({ + Signals: { + 'inspect-actor': {}, + }, +}, class ActorLink extends St.Button { + _init(actor) { + this._arrow = new St.Icon({ + icon_name: 'pan-end-symbolic', + icon_size: 8, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }), + }); + + const label = new St.Label({ + text: actor.toString(), + x_align: Clutter.ActorAlign.START, + }); + + const inspectButton = new St.Button({ + icon_name: 'insert-object-symbolic', + reactive: true, + x_expand: true, + x_align: Clutter.ActorAlign.START, + y_align: Clutter.ActorAlign.CENTER, + }); + inspectButton.connect('clicked', () => this.emit('inspect-actor')); + + const box = new St.BoxLayout(); + box.add_child(this._arrow); + box.add_child(label); + box.add_child(inspectButton); + + super._init({ + reactive: true, + track_hover: true, + toggle_mode: true, + style_class: 'actor-link', + child: box, + x_align: Clutter.ActorAlign.START, + }); + + this._actor = actor; + } + + vfunc_clicked() { + this._arrow.ease({ + rotation_angle_z: this.checked ? 90 : 0, + duration: 250, + }); + } +}); + +var ActorTreeViewer = GObject.registerClass( +class ActorTreeViewer extends St.BoxLayout { + _init(lookingGlass) { + super._init(); + + this._lookingGlass = lookingGlass; + this._actorData = new Map(); + } + + _showActorChildren(actor) { + const data = this._actorData.get(actor); + if (!data || data.visible) + return; + + data.visible = true; + data.actorAddedId = actor.connect('actor-added', (container, child) => { + this._addActor(data.children, child); + }); + data.actorRemovedId = actor.connect('actor-removed', (container, child) => { + this._removeActor(child); + }); + + for (let child of actor) + this._addActor(data.children, child); + } + + _hideActorChildren(actor) { + const data = this._actorData.get(actor); + if (!data || !data.visible) + return; + + for (let child of actor) + this._removeActor(child); + + data.visible = false; + if (data.actorAddedId > 0) { + actor.disconnect(data.actorAddedId); + data.actorAddedId = 0; + } + if (data.actorRemovedId > 0) { + actor.disconnect(data.actorRemovedId); + data.actorRemovedId = 0; + } + data.children.remove_all_children(); + } + + _addActor(container, actor) { + if (this._actorData.has(actor)) + return; + + if (actor === this._lookingGlass) + return; + + const button = new ActorLink(actor); + button.connect('notify::checked', () => { + this._lookingGlass.setBorderPaintTarget(actor); + if (button.checked) + this._showActorChildren(actor); + else + this._hideActorChildren(actor); + }); + button.connect('inspect-actor', () => { + this._lookingGlass.inspectObject(actor, button); + }); + + const mainContainer = new St.BoxLayout({ vertical: true }); + const childrenContainer = new St.BoxLayout({ + vertical: true, + style: 'padding: 0 0 0 18px', + }); + + mainContainer.add_child(button); + mainContainer.add_child(childrenContainer); + + this._actorData.set(actor, { + button, + container: mainContainer, + children: childrenContainer, + visible: false, + actorAddedId: 0, + actorRemovedId: 0, + actorDestroyedId: actor.connect('destroy', () => this._removeActor(actor)), + }); + + let belowChild = null; + const nextSibling = actor.get_next_sibling(); + if (nextSibling && this._actorData.has(nextSibling)) + belowChild = this._actorData.get(nextSibling).container; + + container.insert_child_above(mainContainer, belowChild); + } + + _removeActor(actor) { + const data = this._actorData.get(actor); + if (!data) + return; + + for (let child of actor) + this._removeActor(child); + + if (data.actorAddedId > 0) { + actor.disconnect(data.actorAddedId); + data.actorAddedId = 0; + } + if (data.actorRemovedId > 0) { + actor.disconnect(data.actorRemovedId); + data.actorRemovedId = 0; + } + if (data.actorDestroyedId > 0) { + actor.disconnect(data.actorDestroyedId); + data.actorDestroyedId = 0; + } + data.container.destroy(); + this._actorData.delete(actor); + } + + vfunc_map() { + super.vfunc_map(); + this._addActor(this, global.stage); + } + + vfunc_unmap() { + super.vfunc_unmap(); + this._removeActor(global.stage); + } +}); + +var DebugFlag = GObject.registerClass({ + GTypeFlags: GObject.TypeFlags.ABSTRACT, +}, class DebugFlag extends St.Button { + _init(label) { + const box = new St.BoxLayout(); + + const flagLabel = new St.Label({ + text: label, + x_expand: true, + x_align: Clutter.ActorAlign.START, + y_align: Clutter.ActorAlign.CENTER, + }); + box.add_child(flagLabel); + + this._flagSwitch = new PopupMenu.Switch(false); + this._stateHandler = this._flagSwitch.connect('notify::state', () => { + if (this._flagSwitch.state) + this._enable(); + else + this._disable(); + }); + + // Update state whenever the switch is mapped, because most debug flags + // don't have a way of notifying us of changes. + this._flagSwitch.connect('notify::mapped', () => { + if (!this._flagSwitch.is_mapped()) + return; + + const state = this._isEnabled(); + if (state === this._flagSwitch.state) + return; + + this._flagSwitch.block_signal_handler(this._stateHandler); + this._flagSwitch.state = state; + this._flagSwitch.unblock_signal_handler(this._stateHandler); + }); + + box.add_child(this._flagSwitch); + + super._init({ + style_class: 'lg-debug-flag-button', + can_focus: true, + toggleMode: true, + child: box, + label_actor: flagLabel, + y_align: Clutter.ActorAlign.CENTER, + }); + + this.connect('clicked', () => this._flagSwitch.toggle()); + } + + _isEnabled() { + throw new Error('Method not implemented'); + } + + _enable() { + throw new Error('Method not implemented'); + } + + _disable() { + throw new Error('Method not implemented'); + } +}); + + +var ClutterDebugFlag = GObject.registerClass( +class ClutterDebugFlag extends DebugFlag { + _init(categoryName, flagName) { + super._init(flagName); + + this._argPos = CLUTTER_DEBUG_FLAG_CATEGORIES.get(categoryName).argPos; + this._enumValue = Clutter[categoryName][flagName]; + } + + _isEnabled() { + const enabledFlags = Meta.get_clutter_debug_flags(); + return !!(enabledFlags[this._argPos] & this._enumValue); + } + + _getArgs() { + const args = [0, 0, 0]; + args[this._argPos] = this._enumValue; + return args; + } + + _enable() { + Meta.add_clutter_debug_flags(...this._getArgs()); + } + + _disable() { + Meta.remove_clutter_debug_flags(...this._getArgs()); + } +}); + +var MutterPaintDebugFlag = GObject.registerClass( +class MutterPaintDebugFlag extends DebugFlag { + _init(flagName) { + super._init(flagName); + + this._enumValue = Meta.DebugPaintFlag[flagName]; + } + + _isEnabled() { + return !!(Meta.get_debug_paint_flags() & this._enumValue); + } + + _enable() { + Meta.add_debug_paint_flag(this._enumValue); + } + + _disable() { + Meta.remove_debug_paint_flag(this._enumValue); + } +}); + +var MutterTopicDebugFlag = GObject.registerClass( +class MutterTopicDebugFlag extends DebugFlag { + _init(flagName) { + super._init(flagName); + + this._enumValue = Meta.DebugTopic[flagName]; + } + + _isEnabled() { + return Meta.is_topic_enabled(this._enumValue); + } + + _enable() { + Meta.add_verbose_topic(this._enumValue); + } + + _disable() { + Meta.remove_verbose_topic(this._enumValue); + } +}); + +var UnsafeModeDebugFlag = GObject.registerClass( +class UnsafeModeDebugFlag extends DebugFlag { + _init() { + super._init('unsafe-mode'); + } + + _isEnabled() { + return global.context.unsafe_mode; + } + + _enable() { + global.context.unsafe_mode = true; + } + + _disable() { + global.context.unsafe_mode = false; + } +}); + +var DebugFlags = GObject.registerClass( +class DebugFlags extends St.BoxLayout { + _init() { + super._init({ + name: 'lookingGlassDebugFlags', + vertical: true, + x_align: Clutter.ActorAlign.CENTER, + }); + + // Clutter debug flags + for (const [categoryName, props] of CLUTTER_DEBUG_FLAG_CATEGORIES.entries()) { + this._addHeader(`Clutter${categoryName}`); + for (const flagName of this._getFlagNames(Clutter[categoryName])) { + if (props.exclude.includes(flagName)) + continue; + this.add_child(new ClutterDebugFlag(categoryName, flagName)); + } + } + + // Meta paint flags + this._addHeader('MetaDebugPaintFlag'); + for (const flagName of this._getFlagNames(Meta.DebugPaintFlag)) + this.add_child(new MutterPaintDebugFlag(flagName)); + + // Meta debug topics + this._addHeader('MetaDebugTopic'); + for (const flagName of this._getFlagNames(Meta.DebugTopic)) + this.add_child(new MutterTopicDebugFlag(flagName)); + + // MetaContext::unsafe-mode + this._addHeader('MetaContext'); + this.add_child(new UnsafeModeDebugFlag()); + } + + _addHeader(title) { + const header = new St.Label({ + text: title, + style_class: 'lg-debug-flags-header', + x_align: Clutter.ActorAlign.START, + }); + this.add_child(header); + } + + *_getFlagNames(enumObject) { + for (const flagName of Object.getOwnPropertyNames(enumObject)) { + if (typeof enumObject[flagName] !== 'number') + continue; + + if (enumObject[flagName] <= 0) + continue; + + yield flagName; + } + } +}); + + +var LookingGlass = GObject.registerClass( +class LookingGlass extends St.BoxLayout { + _init() { + super._init({ + name: 'LookingGlassDialog', + style_class: 'lg-dialog', + vertical: true, + visible: false, + reactive: true, + }); + + this._borderPaintTarget = null; + this._redBorderEffect = new RedBorderEffect(); + + this._open = false; + + this._it = null; + this._offset = 0; + + // Sort of magic, but...eh. + this._maxItems = 150; + + this._interfaceSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' }); + this._interfaceSettings.connect('changed::monospace-font-name', + this._updateFont.bind(this)); + this._updateFont(); + + // We want it to appear to slide out from underneath the panel + Main.uiGroup.add_actor(this); + Main.uiGroup.set_child_below_sibling(this, + Main.layoutManager.panelBox); + Main.layoutManager.panelBox.connect('notify::allocation', + this._queueResize.bind(this)); + Main.layoutManager.keyboardBox.connect('notify::allocation', + this._queueResize.bind(this)); + + this._objInspector = new ObjInspector(this); + Main.uiGroup.add_actor(this._objInspector); + this._objInspector.hide(); + + let toolbar = new St.BoxLayout({ name: 'Toolbar' }); + this.add_actor(toolbar); + const inspectButton = new St.Button({ + style_class: 'lg-toolbar-button', + icon_name: 'find-location-symbolic', + }); + toolbar.add_actor(inspectButton); + inspectButton.connect('clicked', () => { + let inspector = new Inspector(this); + inspector.connect('target', (i, target, stageX, stageY) => { + this._pushResult(`inspect(${Math.round(stageX)}, ${Math.round(stageY)})`, target); + }); + inspector.connect('closed', () => { + this.show(); + global.stage.set_key_focus(this._entry); + }); + this.hide(); + return Clutter.EVENT_STOP; + }); + + const gcButton = new St.Button({ + style_class: 'lg-toolbar-button', + icon_name: 'user-trash-full-symbolic', + }); + toolbar.add_actor(gcButton); + gcButton.connect('clicked', () => { + gcButton.child.icon_name = 'user-trash-symbolic'; + System.gc(); + this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => { + gcButton.child.icon_name = 'user-trash-full-symbolic'; + this._timeoutId = 0; + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id( + this._timeoutId, + '[gnome-shell] gcButton.child.icon_name = \'user-trash-full-symbolic\'' + ); + return Clutter.EVENT_PROPAGATE; + }); + + let notebook = new Notebook(); + this._notebook = notebook; + this.add_child(notebook); + + let emptyBox = new St.Bin({ x_expand: true }); + toolbar.add_child(emptyBox); + toolbar.add_actor(notebook.tabControls); + + this._evalBox = new St.BoxLayout({ name: 'EvalBox', vertical: true }); + notebook.appendPage('Evaluator', this._evalBox); + + this._resultsArea = new St.BoxLayout({ + name: 'ResultsArea', + vertical: true, + y_expand: true, + }); + this._evalBox.add_child(this._resultsArea); + + this._entryArea = new St.BoxLayout({ + name: 'EntryArea', + y_align: Clutter.ActorAlign.END, + }); + this._evalBox.add_actor(this._entryArea); + + let label = new St.Label({ text: CHEVRON }); + this._entryArea.add(label); + + this._entry = new St.Entry({ + can_focus: true, + x_expand: true, + }); + ShellEntry.addContextMenu(this._entry); + this._entryArea.add_child(this._entry); + + this._windowList = new WindowList(this); + notebook.appendPage('Windows', this._windowList); + + this._extensions = new Extensions(this); + notebook.appendPage('Extensions', this._extensions); + + this._actorTreeViewer = new ActorTreeViewer(this); + notebook.appendPage('Actors', this._actorTreeViewer); + + this._debugFlags = new DebugFlags(); + notebook.appendPage('Flags', this._debugFlags); + + this._entry.clutter_text.connect('activate', (o, _e) => { + // Hide any completions we are currently showing + this._hideCompletions(); + + let text = o.get_text(); + // Ensure we don't get newlines in the command; the history file is + // newline-separated. + text = text.replace('\n', ' '); + this._evaluate(text); + return true; + }); + + this._history = new History.HistoryManager({ + gsettingsKey: HISTORY_KEY, + entry: this._entry.clutter_text, + }); + + this._autoComplete = new AutoComplete(this._entry); + this._autoComplete.connect('suggest', (a, e) => { + this._showCompletions(e.completions); + }); + // If a completion is completed unambiguously, the currently-displayed completion + // suggestions become irrelevant. + this._autoComplete.connect('completion', (a, e) => { + if (e.type == 'whole-word') + this._hideCompletions(); + }); + + this._resize(); + } + + vfunc_captured_event(event) { + if (Main.keyboard.maybeHandleEvent(event)) + return Clutter.EVENT_STOP; + + return Clutter.EVENT_PROPAGATE; + } + + _updateFont() { + let fontName = this._interfaceSettings.get_string('monospace-font-name'); + let fontDesc = Pango.FontDescription.from_string(fontName); + // We ignore everything but size and style; you'd be crazy to set your system-wide + // monospace font to be bold/oblique/etc. Could easily be added here. + let size = fontDesc.get_size() / 1024.; + let unit = fontDesc.get_size_is_absolute() ? 'px' : 'pt'; + this.style = ` + font-size: ${size}${unit}; + font-family: "${fontDesc.get_family()}";`; + } + + setBorderPaintTarget(obj) { + if (this._borderPaintTarget != null) + this._borderPaintTarget.remove_effect(this._redBorderEffect); + this._borderPaintTarget = obj; + if (this._borderPaintTarget != null) + this._borderPaintTarget.add_effect(this._redBorderEffect); + } + + _pushResult(command, obj) { + let index = this._resultsArea.get_n_children() + this._offset; + let result = new Result(this, CHEVRON + command, obj, index); + this._resultsArea.add(result); + if (obj instanceof Clutter.Actor) + this.setBorderPaintTarget(obj); + + if (this._resultsArea.get_n_children() > this._maxItems) { + this._resultsArea.get_first_child().destroy(); + this._offset++; + } + this._it = obj; + + // Scroll to bottom + this._notebook.scrollToBottom(0); + } + + _showCompletions(completions) { + if (!this._completionActor) { + this._completionActor = new St.Label({ name: 'LookingGlassAutoCompletionText', style_class: 'lg-completions-text' }); + this._completionActor.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._completionActor.clutter_text.line_wrap = true; + this._evalBox.insert_child_below(this._completionActor, this._entryArea); + } + + this._completionActor.set_text(completions.join(', ')); + + // Setting the height to -1 allows us to get its actual preferred height rather than + // whatever was last set when animating + this._completionActor.set_height(-1); + let [, naturalHeight] = this._completionActor.get_preferred_height(this._resultsArea.get_width()); + + // Don't reanimate if we are already visible + if (this._completionActor.visible) { + this._completionActor.height = naturalHeight; + } else { + let settings = St.Settings.get(); + let duration = AUTO_COMPLETE_SHOW_COMPLETION_ANIMATION_DURATION / settings.slow_down_factor; + this._completionActor.show(); + this._completionActor.remove_all_transitions(); + this._completionActor.ease({ + height: naturalHeight, + opacity: 255, + duration, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + } + + _hideCompletions() { + if (this._completionActor) { + let settings = St.Settings.get(); + let duration = AUTO_COMPLETE_SHOW_COMPLETION_ANIMATION_DURATION / settings.slow_down_factor; + this._completionActor.remove_all_transitions(); + this._completionActor.ease({ + height: 0, + opacity: 0, + duration, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._completionActor.hide(); + }, + }); + } + } + + _evaluate(command) { + command = this._history.addItem(command); // trims command + if (!command) + return; + + let lines = command.split(';'); + lines.push(`return ${lines.pop()}`); + + let fullCmd = commandHeader + lines.join(';'); + + let resultObj; + try { + resultObj = Function(fullCmd)(); + } catch (e) { + resultObj = `<exception ${e}>`; + } + + this._pushResult(command, resultObj); + this._entry.text = ''; + } + + inspect(x, y) { + return global.stage.get_actor_at_pos(Clutter.PickMode.REACTIVE, x, y); + } + + getIt() { + return this._it; + } + + getResult(idx) { + try { + return this._resultsArea.get_child_at_index(idx - this._offset).o; + } catch (e) { + throw new Error(`Unknown result at index ${idx}`); + } + } + + toggle() { + if (this._open) + this.close(); + else + this.open(); + } + + _queueResize() { + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + this._resize(); + return GLib.SOURCE_REMOVE; + }); + } + + _resize() { + let primary = Main.layoutManager.primaryMonitor; + let myWidth = primary.width * 0.7; + let availableHeight = primary.height - Main.layoutManager.keyboardBox.height; + let myHeight = Math.min(primary.height * 0.7, availableHeight * 0.9); + this.x = primary.x + (primary.width - myWidth) / 2; + this._hiddenY = primary.y + Main.layoutManager.panelBox.height - myHeight; + this._targetY = this._hiddenY + myHeight; + this.y = this._hiddenY; + this.width = myWidth; + this.height = myHeight; + this._objInspector.set_size(Math.floor(myWidth * 0.8), Math.floor(myHeight * 0.8)); + this._objInspector.set_position(this.x + Math.floor(myWidth * 0.1), + this._targetY + Math.floor(myHeight * 0.1)); + } + + insertObject(obj) { + this._pushResult('<insert>', obj); + } + + inspectObject(obj, sourceActor) { + this._objInspector.open(sourceActor); + this._objInspector.selectObject(obj); + } + + // Handle key events which are relevant for all tabs of the LookingGlass + vfunc_key_press_event(keyPressEvent) { + let symbol = keyPressEvent.keyval; + if (symbol == Clutter.KEY_Escape) { + this.close(); + return Clutter.EVENT_STOP; + } + // Ctrl+PgUp and Ctrl+PgDown switches tabs in the notebook view + if (keyPressEvent.modifier_state & Clutter.ModifierType.CONTROL_MASK) { + if (symbol == Clutter.KEY_Page_Up) + this._notebook.prevTab(); + else if (symbol == Clutter.KEY_Page_Down) + this._notebook.nextTab(); + } + return super.vfunc_key_press_event(keyPressEvent); + } + + open() { + if (this._open) + return; + + let grab = Main.pushModal(this, { actionMode: Shell.ActionMode.LOOKING_GLASS }); + if (grab.get_seat_state() !== Clutter.GrabState.ALL) { + Main.popModal(grab); + return; + } + + this._grab = grab; + this._notebook.selectIndex(0); + this.show(); + this._open = true; + this._history.lastItem(); + + this.remove_all_transitions(); + + // We inverse compensate for the slow-down so you can change the factor + // through LookingGlass without long waits. + let duration = LG_ANIMATION_TIME / St.Settings.get().slow_down_factor; + this.ease({ + y: this._targetY, + duration, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + + this._windowList.update(); + this._entry.grab_key_focus(); + } + + close() { + if (!this._open) + return; + + this._objInspector.hide(); + + this._open = false; + this.remove_all_transitions(); + + this.setBorderPaintTarget(null); + + let settings = St.Settings.get(); + let duration = Math.min(LG_ANIMATION_TIME / settings.slow_down_factor, + LG_ANIMATION_TIME); + this.ease({ + y: this._hiddenY, + duration, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + Main.popModal(this._grab); + this._grab = null; + this.hide(); + }, + }); + } + + get isOpen() { + return this._open; + } +}); diff --git a/js/ui/magnifier.js b/js/ui/magnifier.js new file mode 100644 index 0000000..bd69047 --- /dev/null +++ b/js/ui/magnifier.js @@ -0,0 +1,2093 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Magnifier */ + +const { + Atspi, Clutter, GDesktopEnums, Gio, GLib, GObject, Meta, Shell, St, +} = imports.gi; +const Signals = imports.misc.signals; + +const Background = imports.ui.background; +const FocusCaretTracker = imports.ui.focusCaretTracker; +const Main = imports.ui.main; +const Params = imports.misc.params; +const PointerWatcher = imports.ui.pointerWatcher; + +var CROSSHAIRS_CLIP_SIZE = [100, 100]; +var NO_CHANGE = 0.0; + +var POINTER_REST_TIME = 1000; // milliseconds + +// Settings +const MAGNIFIER_SCHEMA = 'org.gnome.desktop.a11y.magnifier'; +const SCREEN_POSITION_KEY = 'screen-position'; +const MAG_FACTOR_KEY = 'mag-factor'; +const INVERT_LIGHTNESS_KEY = 'invert-lightness'; +const COLOR_SATURATION_KEY = 'color-saturation'; +const BRIGHT_RED_KEY = 'brightness-red'; +const BRIGHT_GREEN_KEY = 'brightness-green'; +const BRIGHT_BLUE_KEY = 'brightness-blue'; +const CONTRAST_RED_KEY = 'contrast-red'; +const CONTRAST_GREEN_KEY = 'contrast-green'; +const CONTRAST_BLUE_KEY = 'contrast-blue'; +const LENS_MODE_KEY = 'lens-mode'; +const CLAMP_MODE_KEY = 'scroll-at-edges'; +const MOUSE_TRACKING_KEY = 'mouse-tracking'; +const FOCUS_TRACKING_KEY = 'focus-tracking'; +const CARET_TRACKING_KEY = 'caret-tracking'; +const SHOW_CROSS_HAIRS_KEY = 'show-cross-hairs'; +const CROSS_HAIRS_THICKNESS_KEY = 'cross-hairs-thickness'; +const CROSS_HAIRS_COLOR_KEY = 'cross-hairs-color'; +const CROSS_HAIRS_OPACITY_KEY = 'cross-hairs-opacity'; +const CROSS_HAIRS_LENGTH_KEY = 'cross-hairs-length'; +const CROSS_HAIRS_CLIP_KEY = 'cross-hairs-clip'; + +var MouseSpriteContent = GObject.registerClass({ + Implements: [Clutter.Content], +}, class MouseSpriteContent extends GObject.Object { + _init() { + super._init(); + this._texture = null; + } + + vfunc_get_preferred_size() { + if (!this._texture) + return [false, 0, 0]; + + return [true, this._texture.get_width(), this._texture.get_height()]; + } + + vfunc_paint_content(actor, node, _paintContext) { + if (!this._texture) + return; + + let color = Clutter.Color.get_static(Clutter.StaticColor.WHITE); + let [minFilter, magFilter] = actor.get_content_scaling_filters(); + let textureNode = new Clutter.TextureNode(this._texture, + color, minFilter, magFilter); + textureNode.set_name('MouseSpriteContent'); + node.add_child(textureNode); + + textureNode.add_rectangle(actor.get_content_box()); + } + + get texture() { + return this._texture; + } + + set texture(coglTexture) { + if (this._texture == coglTexture) + return; + + let oldTexture = this._texture; + this._texture = coglTexture; + this.invalidate(); + + if (!oldTexture || !coglTexture || + oldTexture.get_width() != coglTexture.get_width() || + oldTexture.get_height() != coglTexture.get_height()) + this.invalidate_size(); + } +}); + +var Magnifier = class Magnifier extends Signals.EventEmitter { + constructor() { + super(); + + // Magnifier is a manager of ZoomRegions. + this._zoomRegions = []; + + // Create small clutter tree for the magnified mouse. + let cursorTracker = Meta.CursorTracker.get_for_display(global.display); + this._cursorTracker = cursorTracker; + + this._mouseSprite = new Clutter.Actor({ request_mode: Clutter.RequestMode.CONTENT_SIZE }); + this._mouseSprite.content = new MouseSpriteContent(); + + this._cursorRoot = new Clutter.Actor(); + this._cursorRoot.add_actor(this._mouseSprite); + + // Create the first ZoomRegion and initialize it according to the + // magnification settings. + + [this.xMouse, this.yMouse] = global.get_pointer(); + + let aZoomRegion = new ZoomRegion(this, this._cursorRoot); + this._zoomRegions.push(aZoomRegion); + this._settingsInit(aZoomRegion); + aZoomRegion.scrollContentsTo(this.xMouse, this.yMouse); + + St.Settings.get().connect('notify::magnifier-active', () => { + this.setActive(St.Settings.get().magnifier_active); + }); + + this.setActive(St.Settings.get().magnifier_active); + } + + /** + * showSystemCursor: + * Show the system mouse pointer. + */ + showSystemCursor() { + const seat = Clutter.get_default_backend().get_default_seat(); + + if (seat.is_unfocus_inhibited()) + seat.uninhibit_unfocus(); + + if (this._cursorVisibilityChangedId) { + this._cursorTracker.disconnect(this._cursorVisibilityChangedId); + delete this._cursorVisibilityChangedId; + + this._cursorTracker.set_pointer_visible(true); + } + } + + /** + * hideSystemCursor: + * Hide the system mouse pointer. + */ + hideSystemCursor() { + const seat = Clutter.get_default_backend().get_default_seat(); + + if (!seat.is_unfocus_inhibited()) + seat.inhibit_unfocus(); + + if (!this._cursorVisibilityChangedId) { + this._cursorTracker.set_pointer_visible(false); + this._cursorVisibilityChangedId = this._cursorTracker.connect('visibility-changed', () => { + if (this._cursorTracker.get_pointer_visible()) + this._cursorTracker.set_pointer_visible(false); + }); + } + } + + /** + * setActive: + * Show/hide all the zoom regions. + * @param {bool} activate: Boolean to activate or de-activate the magnifier. + */ + setActive(activate) { + let isActive = this.isActive(); + + this._zoomRegions.forEach(zoomRegion => { + zoomRegion.setActive(activate); + }); + + if (isActive === activate) + return; + + if (activate) { + this._updateMouseSprite(); + this._cursorTracker.connectObject( + 'cursor-changed', this._updateMouseSprite.bind(this), this); + Meta.disable_unredirect_for_display(global.display); + this.startTrackingMouse(); + } else { + this._cursorTracker.disconnectObject(this); + this._mouseSprite.content.texture = null; + Meta.enable_unredirect_for_display(global.display); + this.stopTrackingMouse(); + } + + if (this._crossHairs) + this._crossHairs.setEnabled(activate); + + // Make sure system mouse pointer is shown when all zoom regions are + // invisible. + if (!activate) + this.showSystemCursor(); + + // Notify interested parties of this change + this.emit('active-changed', activate); + } + + /** + * isActive: + * @returns {bool} Whether the magnifier is active. + */ + isActive() { + // Sufficient to check one ZoomRegion since Magnifier's active + // state applies to all of them. + if (this._zoomRegions.length == 0) + return false; + else + return this._zoomRegions[0].isActive(); + } + + /** + * startTrackingMouse: + * Turn on mouse tracking, if not already doing so. + */ + startTrackingMouse() { + if (!this._pointerWatch) { + let interval = 1000 / 60; + this._pointerWatch = PointerWatcher.getPointerWatcher().addWatch(interval, this.scrollToMousePos.bind(this)); + + this.scrollToMousePos(); + } + } + + /** + * stopTrackingMouse: + * Turn off mouse tracking, if not already doing so. + */ + stopTrackingMouse() { + if (this._pointerWatch) + this._pointerWatch.remove(); + + this._pointerWatch = null; + } + + /** + * isTrackingMouse: + * @returns {bool} whether the magnifier is currently tracking the mouse + */ + isTrackingMouse() { + return !!this._mouseTrackingId; + } + + /** + * scrollToMousePos: + * Position all zoom regions' ROI relative to the current location of the + * system pointer. + */ + scrollToMousePos(...args) { + const [xMouse, yMouse] = args.length ? args : global.get_pointer(); + + if (xMouse === this.xMouse && yMouse === this.yMouse) + return; + + this.xMouse = xMouse; + this.yMouse = yMouse; + + let sysMouseOverAny = false; + this._zoomRegions.forEach(zoomRegion => { + if (zoomRegion.scrollToMousePos()) + sysMouseOverAny = true; + }); + if (sysMouseOverAny) + this.hideSystemCursor(); + else + this.showSystemCursor(); + } + + /** + * createZoomRegion: + * Create a ZoomRegion instance with the given properties. + * @param {number} xMagFactor: + * The power to set horizontal magnification of the ZoomRegion. A value + * of 1.0 means no magnification, a value of 2.0 doubles the size. + * @param {number} yMagFactor: + * The power to set the vertical magnification of the ZoomRegion. + * @param {{x: number, y: number, width: number, height: number}} roi: + * The reg Object that defines the region to magnify, given in + * unmagnified coordinates. + * @param {{x: number, y: number, width: number, height: number}} viewPort: + * Object that defines the position of the ZoomRegion on screen. + * @returns {ZoomRegion} the newly created ZoomRegion. + */ + createZoomRegion(xMagFactor, yMagFactor, roi, viewPort) { + let zoomRegion = new ZoomRegion(this, this._cursorRoot); + zoomRegion.setViewPort(viewPort); + + // We ignore the redundant width/height on the ROI + let fixedROI = Object.create(roi); + fixedROI.width = viewPort.width / xMagFactor; + fixedROI.height = viewPort.height / yMagFactor; + zoomRegion.setROI(fixedROI); + + zoomRegion.addCrosshairs(this._crossHairs); + return zoomRegion; + } + + /** + * addZoomRegion: + * Append the given ZoomRegion to the list of currently defined ZoomRegions + * for this Magnifier instance. + * @param {ZoomRegion} zoomRegion: The zoomRegion to add. + */ + addZoomRegion(zoomRegion) { + if (zoomRegion) { + this._zoomRegions.push(zoomRegion); + if (!this.isTrackingMouse()) + this.startTrackingMouse(); + } + } + + /** + * getZoomRegions: + * Return a list of ZoomRegion's for this Magnifier. + * @returns {number[]} The Magnifier's zoom region list. + */ + getZoomRegions() { + return this._zoomRegions; + } + + /** + * clearAllZoomRegions: + * Remove all the zoom regions from this Magnfier's ZoomRegion list. + */ + clearAllZoomRegions() { + for (let i = 0; i < this._zoomRegions.length; i++) + this._zoomRegions[i].setActive(false); + + this._zoomRegions.length = 0; + this.stopTrackingMouse(); + this.showSystemCursor(); + } + + /** + * addCrosshairs: + * Add and show a cross hair centered on the magnified mouse. + */ + addCrosshairs() { + if (!this._crossHairs) + this._crossHairs = new Crosshairs(); + + let thickness = this._settings.get_int(CROSS_HAIRS_THICKNESS_KEY); + let color = this._settings.get_string(CROSS_HAIRS_COLOR_KEY); + let opacity = this._settings.get_double(CROSS_HAIRS_OPACITY_KEY); + let length = this._settings.get_int(CROSS_HAIRS_LENGTH_KEY); + let clip = this._settings.get_boolean(CROSS_HAIRS_CLIP_KEY); + + this.setCrosshairsThickness(thickness); + this.setCrosshairsColor(color); + this.setCrosshairsOpacity(opacity); + this.setCrosshairsLength(length); + this.setCrosshairsClip(clip); + + let theCrossHairs = this._crossHairs; + this._zoomRegions.forEach(zoomRegion => { + zoomRegion.addCrosshairs(theCrossHairs); + }); + } + + /** + * setCrosshairsVisible: + * Show or hide the cross hair. + * @param {bool} visible: Flag that indicates show (true) or hide (false). + */ + setCrosshairsVisible(visible) { + if (visible) { + if (!this._crossHairs) + this.addCrosshairs(); + this._crossHairs.show(); + } else { + // eslint-disable-next-line no-lonely-if + if (this._crossHairs) + this._crossHairs.hide(); + } + } + + /** + * setCrosshairsColor: + * Set the color of the crosshairs for all ZoomRegions. + * @param {string} color: The color as a string, e.g. '#ff0000ff' or 'red'. + */ + setCrosshairsColor(color) { + if (this._crossHairs) { + let [res_, clutterColor] = Clutter.Color.from_string(color); + this._crossHairs.setColor(clutterColor); + } + } + + /** + * getCrosshairsColor: + * Get the color of the crosshairs. + * @returns {string} The color as a string, e.g. '#0000ffff' or 'blue'. + */ + getCrosshairsColor() { + if (this._crossHairs) { + let clutterColor = this._crossHairs.getColor(); + return clutterColor.to_string(); + } else { + return '#00000000'; + } + } + + /** + * setCrosshairsThickness: + * Set the crosshairs thickness for all ZoomRegions. + * @param {number} thickness: The width of the vertical and + * horizontal lines of the crosshairs. + */ + setCrosshairsThickness(thickness) { + if (this._crossHairs) + this._crossHairs.setThickness(thickness); + } + + /** + * getCrosshairsThickness: + * Get the crosshairs thickness. + * @returns {number} The width of the vertical and horizontal + * lines of the crosshairs. + */ + getCrosshairsThickness() { + if (this._crossHairs) + return this._crossHairs.getThickness(); + else + return 0; + } + + /** + * setCrosshairsOpacity: + * @param {number} opacity: Value between 0.0 (transparent) + * and 1.0 (fully opaque). + */ + setCrosshairsOpacity(opacity) { + if (this._crossHairs) + this._crossHairs.setOpacity(opacity * 255); + } + + /** + * getCrosshairsOpacity: + * @returns {number} Value between 0.0 (transparent) and 1.0 (fully opaque). + */ + getCrosshairsOpacity() { + if (this._crossHairs) + return this._crossHairs.getOpacity() / 255.0; + else + return 0.0; + } + + /** + * setCrosshairsLength: + * Set the crosshairs length for all ZoomRegions. + * @param {number} length: The length of the vertical and horizontal + * lines making up the crosshairs. + */ + setCrosshairsLength(length) { + if (this._crossHairs) { + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + this._crossHairs.setLength(length / scaleFactor); + } + } + + /** + * getCrosshairsLength: + * Get the crosshairs length. + * @returns {number} The length of the vertical and horizontal + * lines making up the crosshairs. + */ + getCrosshairsLength() { + if (this._crossHairs) + return this._crossHairs.getLength(); + else + return 0; + } + + /** + * setCrosshairsClip: + * Set whether the crosshairs are clipped at their intersection. + * @param {bool} clip: Flag to indicate whether to clip the crosshairs. + */ + setCrosshairsClip(clip) { + if (!this._crossHairs) + return; + + // Setting no clipping on crosshairs means a zero sized clip rectangle. + this._crossHairs.setClip(clip ? CROSSHAIRS_CLIP_SIZE : [0, 0]); + } + + /** + * getCrosshairsClip: + * Get whether the crosshairs are clipped by the mouse image. + * @returns {bool} Whether the crosshairs are clipped. + */ + getCrosshairsClip() { + if (this._crossHairs) { + let [clipWidth, clipHeight] = this._crossHairs.getClip(); + return clipWidth > 0 && clipHeight > 0; + } else { + return false; + } + } + + // Private methods // + + _updateMouseSprite() { + this._updateSpriteTexture(); + let [xHot, yHot] = this._cursorTracker.get_hot(); + this._mouseSprite.set({ + translation_x: -xHot, + translation_y: -yHot, + }); + } + + _updateSpriteTexture() { + let sprite = this._cursorTracker.get_sprite(); + + if (sprite) { + this._mouseSprite.content.texture = sprite; + this._mouseSprite.show(); + } else { + this._mouseSprite.hide(); + } + } + + _settingsInit(zoomRegion) { + this._settings = new Gio.Settings({ schema_id: MAGNIFIER_SCHEMA }); + + this._settings.connect(`changed::${SCREEN_POSITION_KEY}`, + this._updateScreenPosition.bind(this)); + this._settings.connect(`changed::${MAG_FACTOR_KEY}`, + this._updateMagFactor.bind(this)); + this._settings.connect(`changed::${LENS_MODE_KEY}`, + this._updateLensMode.bind(this)); + this._settings.connect(`changed::${CLAMP_MODE_KEY}`, + this._updateClampMode.bind(this)); + this._settings.connect(`changed::${MOUSE_TRACKING_KEY}`, + this._updateMouseTrackingMode.bind(this)); + this._settings.connect(`changed::${FOCUS_TRACKING_KEY}`, + this._updateFocusTrackingMode.bind(this)); + this._settings.connect(`changed::${CARET_TRACKING_KEY}`, + this._updateCaretTrackingMode.bind(this)); + + this._settings.connect(`changed::${INVERT_LIGHTNESS_KEY}`, + this._updateInvertLightness.bind(this)); + this._settings.connect(`changed::${COLOR_SATURATION_KEY}`, + this._updateColorSaturation.bind(this)); + + this._settings.connect(`changed::${BRIGHT_RED_KEY}`, + this._updateBrightness.bind(this)); + this._settings.connect(`changed::${BRIGHT_GREEN_KEY}`, + this._updateBrightness.bind(this)); + this._settings.connect(`changed::${BRIGHT_BLUE_KEY}`, + this._updateBrightness.bind(this)); + + this._settings.connect(`changed::${CONTRAST_RED_KEY}`, + this._updateContrast.bind(this)); + this._settings.connect(`changed::${CONTRAST_GREEN_KEY}`, + this._updateContrast.bind(this)); + this._settings.connect(`changed::${CONTRAST_BLUE_KEY}`, + this._updateContrast.bind(this)); + + this._settings.connect(`changed::${SHOW_CROSS_HAIRS_KEY}`, () => { + this.setCrosshairsVisible(this._settings.get_boolean(SHOW_CROSS_HAIRS_KEY)); + }); + + this._settings.connect(`changed::${CROSS_HAIRS_THICKNESS_KEY}`, () => { + this.setCrosshairsThickness(this._settings.get_int(CROSS_HAIRS_THICKNESS_KEY)); + }); + + this._settings.connect(`changed::${CROSS_HAIRS_COLOR_KEY}`, () => { + this.setCrosshairsColor(this._settings.get_string(CROSS_HAIRS_COLOR_KEY)); + }); + + this._settings.connect(`changed::${CROSS_HAIRS_OPACITY_KEY}`, () => { + this.setCrosshairsOpacity(this._settings.get_double(CROSS_HAIRS_OPACITY_KEY)); + }); + + this._settings.connect(`changed::${CROSS_HAIRS_LENGTH_KEY}`, () => { + this.setCrosshairsLength(this._settings.get_int(CROSS_HAIRS_LENGTH_KEY)); + }); + + this._settings.connect(`changed::${CROSS_HAIRS_CLIP_KEY}`, () => { + this.setCrosshairsClip(this._settings.get_boolean(CROSS_HAIRS_CLIP_KEY)); + }); + + if (zoomRegion) { + // Mag factor is accurate to two decimal places. + let aPref = parseFloat(this._settings.get_double(MAG_FACTOR_KEY).toFixed(2)); + if (aPref != 0.0) + zoomRegion.setMagFactor(aPref, aPref); + + aPref = this._settings.get_enum(SCREEN_POSITION_KEY); + if (aPref) + zoomRegion.setScreenPosition(aPref); + + zoomRegion.setLensMode(this._settings.get_boolean(LENS_MODE_KEY)); + zoomRegion.setClampScrollingAtEdges(!this._settings.get_boolean(CLAMP_MODE_KEY)); + + aPref = this._settings.get_enum(MOUSE_TRACKING_KEY); + if (aPref) + zoomRegion.setMouseTrackingMode(aPref); + + aPref = this._settings.get_enum(FOCUS_TRACKING_KEY); + if (aPref) + zoomRegion.setFocusTrackingMode(aPref); + + aPref = this._settings.get_enum(CARET_TRACKING_KEY); + if (aPref) + zoomRegion.setCaretTrackingMode(aPref); + + aPref = this._settings.get_boolean(INVERT_LIGHTNESS_KEY); + if (aPref) + zoomRegion.setInvertLightness(aPref); + + aPref = this._settings.get_double(COLOR_SATURATION_KEY); + if (aPref) + zoomRegion.setColorSaturation(aPref); + + let bc = {}; + bc.r = this._settings.get_double(BRIGHT_RED_KEY); + bc.g = this._settings.get_double(BRIGHT_GREEN_KEY); + bc.b = this._settings.get_double(BRIGHT_BLUE_KEY); + zoomRegion.setBrightness(bc); + + bc.r = this._settings.get_double(CONTRAST_RED_KEY); + bc.g = this._settings.get_double(CONTRAST_GREEN_KEY); + bc.b = this._settings.get_double(CONTRAST_BLUE_KEY); + zoomRegion.setContrast(bc); + } + + let showCrosshairs = this._settings.get_boolean(SHOW_CROSS_HAIRS_KEY); + this.addCrosshairs(); + this.setCrosshairsVisible(showCrosshairs); + } + + _updateScreenPosition() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + let position = this._settings.get_enum(SCREEN_POSITION_KEY); + this._zoomRegions[0].setScreenPosition(position); + if (position != GDesktopEnums.MagnifierScreenPosition.FULL_SCREEN) + this._updateLensMode(); + } + } + + _updateMagFactor() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + // Mag factor is accurate to two decimal places. + let magFactor = parseFloat(this._settings.get_double(MAG_FACTOR_KEY).toFixed(2)); + this._zoomRegions[0].setMagFactor(magFactor, magFactor); + } + } + + _updateLensMode() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) + this._zoomRegions[0].setLensMode(this._settings.get_boolean(LENS_MODE_KEY)); + } + + _updateClampMode() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + this._zoomRegions[0].setClampScrollingAtEdges( + !this._settings.get_boolean(CLAMP_MODE_KEY)); + } + } + + _updateMouseTrackingMode() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + this._zoomRegions[0].setMouseTrackingMode( + this._settings.get_enum(MOUSE_TRACKING_KEY)); + } + } + + _updateFocusTrackingMode() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + this._zoomRegions[0].setFocusTrackingMode( + this._settings.get_enum(FOCUS_TRACKING_KEY)); + } + } + + _updateCaretTrackingMode() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + this._zoomRegions[0].setCaretTrackingMode( + this._settings.get_enum(CARET_TRACKING_KEY)); + } + } + + _updateInvertLightness() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + this._zoomRegions[0].setInvertLightness( + this._settings.get_boolean(INVERT_LIGHTNESS_KEY)); + } + } + + _updateColorSaturation() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + this._zoomRegions[0].setColorSaturation( + this._settings.get_double(COLOR_SATURATION_KEY)); + } + } + + _updateBrightness() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + let brightness = {}; + brightness.r = this._settings.get_double(BRIGHT_RED_KEY); + brightness.g = this._settings.get_double(BRIGHT_GREEN_KEY); + brightness.b = this._settings.get_double(BRIGHT_BLUE_KEY); + this._zoomRegions[0].setBrightness(brightness); + } + } + + _updateContrast() { + // Applies only to the first zoom region. + if (this._zoomRegions.length) { + let contrast = {}; + contrast.r = this._settings.get_double(CONTRAST_RED_KEY); + contrast.g = this._settings.get_double(CONTRAST_GREEN_KEY); + contrast.b = this._settings.get_double(CONTRAST_BLUE_KEY); + this._zoomRegions[0].setContrast(contrast); + } + } +}; + +var ZoomRegion = class ZoomRegion { + constructor(magnifier, mouseSourceActor) { + this._magnifier = magnifier; + this._focusCaretTracker = new FocusCaretTracker.FocusCaretTracker(); + + this._mouseTrackingMode = GDesktopEnums.MagnifierMouseTrackingMode.NONE; + this._focusTrackingMode = GDesktopEnums.MagnifierFocusTrackingMode.NONE; + this._caretTrackingMode = GDesktopEnums.MagnifierCaretTrackingMode.NONE; + this._clampScrollingAtEdges = false; + this._lensMode = false; + this._screenPosition = GDesktopEnums.MagnifierScreenPosition.FULL_SCREEN; + this._invertLightness = false; + this._colorSaturation = 1.0; + this._brightness = { r: NO_CHANGE, g: NO_CHANGE, b: NO_CHANGE }; + this._contrast = { r: NO_CHANGE, g: NO_CHANGE, b: NO_CHANGE }; + + this._magView = null; + this._background = null; + this._uiGroupClone = null; + this._mouseSourceActor = mouseSourceActor; + this._mouseActor = null; + this._crossHairs = null; + this._crossHairsActor = null; + + this._viewPortX = 0; + this._viewPortY = 0; + this._viewPortWidth = global.screen_width; + this._viewPortHeight = global.screen_height; + this._xCenter = this._viewPortWidth / 2; + this._yCenter = this._viewPortHeight / 2; + this._xMagFactor = 1; + this._yMagFactor = 1; + this._followingCursor = false; + this._xFocus = 0; + this._yFocus = 0; + this._xCaret = 0; + this._yCaret = 0; + + this._pointerIdleMonitor = global.backend.get_core_idle_monitor(); + this._scrollContentsTimerId = 0; + } + + _connectSignals() { + if (this._signalConnections) + return; + + this._signalConnections = []; + let id = Main.layoutManager.connect('monitors-changed', + this._monitorsChanged.bind(this)); + this._signalConnections.push([Main.layoutManager, id]); + + id = this._focusCaretTracker.connect('caret-moved', this._updateCaret.bind(this)); + this._signalConnections.push([this._focusCaretTracker, id]); + + id = this._focusCaretTracker.connect('focus-changed', this._updateFocus.bind(this)); + this._signalConnections.push([this._focusCaretTracker, id]); + } + + _disconnectSignals() { + for (let [obj, id] of this._signalConnections) + obj.disconnect(id); + + delete this._signalConnections; + } + + _updateScreenPosition() { + if (this._screenPosition == GDesktopEnums.MagnifierScreenPosition.NONE) { + this._setViewPort({ + x: this._viewPortX, + y: this._viewPortY, + width: this._viewPortWidth, + height: this._viewPortHeight, + }); + } else { + this.setScreenPosition(this._screenPosition); + } + } + + _convertExtentsToScreenSpace(accessible, extents) { + const toplevelWindowTypes = new Set([ + Atspi.Role.FRAME, + Atspi.Role.DIALOG, + Atspi.Role.WINDOW, + ]); + + try { + let app = null; + let parentWindow = null; + let iter = accessible; + while (iter) { + if (iter.get_role() === Atspi.Role.APPLICATION) { + app = iter; + /* This is the last Accessible we are interested in */ + break; + } else if (toplevelWindowTypes.has(iter.get_role())) { + parentWindow = iter; + } + iter = iter.get_parent(); + } + + /* We don't want to translate our own events to the focus window. + * They are also already scaled by clutter before being sent, so + * we don't need to do that here either. */ + if (app && app.get_name() === 'gnome-shell') + return extents; + + /* Only events from the focused widget of the focused window. Some + * widgets seem to claim to have focus when the window does not so + * check both. */ + const windowActive = parentWindow && + parentWindow.get_state_set().contains(Atspi.StateType.ACTIVE); + const accessibleFocused = + accessible.get_state_set().contains(Atspi.StateType.FOCUSED); + if (!windowActive || !accessibleFocused) + return null; + } catch (e) { + throw new Error(`Failed to validate parent window: ${e}`); + } + + const { focusWindow } = global.display; + if (!focusWindow) + return null; + + let windowRect = focusWindow.get_frame_rect(); + if (!focusWindow.is_client_decorated()) + windowRect = focusWindow.frame_rect_to_client_rect(windowRect); + + const scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + const screenSpaceExtents = new Atspi.Rect({ + x: windowRect.x + (scaleFactor * extents.x), + y: windowRect.y + (scaleFactor * extents.y), + width: scaleFactor * extents.width, + height: scaleFactor * extents.height, + }); + + return screenSpaceExtents; + } + + _updateFocus(caller, event) { + let component = event.source.get_component_iface(); + if (!component || event.detail1 != 1) + return; + let extents; + try { + extents = component.get_extents(Atspi.CoordType.WINDOW); + extents = this._convertExtentsToScreenSpace(event.source, extents); + if (!extents) + return; + } catch (e) { + log(`Failed to read extents of focused component: ${e.message}`); + return; + } + + const [xFocus, yFocus] = [ + extents.x + (extents.width / 2), + extents.y + (extents.height / 2), + ]; + + if (this._xFocus !== xFocus || this._yFocus !== yFocus) { + [this._xFocus, this._yFocus] = [xFocus, yFocus]; + this._centerFromFocusPosition(); + } + } + + _updateCaret(caller, event) { + let text = event.source.get_text_iface(); + if (!text) + return; + let extents; + try { + extents = text.get_character_extents(text.get_caret_offset(), + Atspi.CoordType.WINDOW); + extents = this._convertExtentsToScreenSpace(text, extents); + if (!extents) + return; + } catch (e) { + log(`Failed to read extents of text caret: ${e.message}`); + return; + } + + const [xCaret, yCaret] = [extents.x, extents.y]; + + // Ignore event(s) if the caret size is none (0x0). This happens a lot if + // the cursor offset can't be translated into a location. This is a work + // around. + if (extents.width === 0 && extents.height === 0) + return; + + if (this._xCaret !== xCaret || this._yCaret !== yCaret) { + [this._xCaret, this._yCaret] = [xCaret, yCaret]; + this._centerFromCaretPosition(); + } + } + + /** + * setActive: + * @param {bool} activate: Boolean to show/hide the ZoomRegion. + */ + setActive(activate) { + if (activate == this.isActive()) + return; + + if (activate) { + this._createActors(); + if (this._isMouseOverRegion()) + this._magnifier.hideSystemCursor(); + this._updateScreenPosition(); + this._updateMagViewGeometry(); + this._updateCloneGeometry(); + this._updateMousePosition(); + this._connectSignals(); + } else { + Main.uiGroup.set_opacity(255); + this._disconnectSignals(); + this._destroyActors(); + } + + this._syncCaretTracking(); + this._syncFocusTracking(); + } + + /** + * isActive: + * @returns {bool} Whether this ZoomRegion is active + */ + isActive() { + return this._magView != null; + } + + /** + * setMagFactor: + * @param {number} xMagFactor: The power to set the horizontal + * magnification factor to of the magnified view. A value of 1.0 + * means no magnification. A value of 2.0 doubles the size. + * @param {number} yMagFactor: The power to set the vertical + * magnification factor to of the magnified view. + */ + setMagFactor(xMagFactor, yMagFactor) { + this._changeROI({ + xMagFactor, + yMagFactor, + redoCursorTracking: this._followingCursor, + animate: true, + }); + } + + /** + * getMagFactor: + * @returns {number[]} an array, [xMagFactor, yMagFactor], containing + * the horizontal and vertical magnification powers. A value of + * 1.0 means no magnification. A value of 2.0 means the contents + * are doubled in size, and so on. + */ + getMagFactor() { + return [this._xMagFactor, this._yMagFactor]; + } + + /** + * setMouseTrackingMode + * @param {GDesktopEnums.MagnifierMouseTrackingMode} mode: the new mode + */ + setMouseTrackingMode(mode) { + if (mode >= GDesktopEnums.MagnifierMouseTrackingMode.NONE && + mode <= GDesktopEnums.MagnifierMouseTrackingMode.PUSH) + this._mouseTrackingMode = mode; + } + + /** + * getMouseTrackingMode + * @returns {GDesktopEnums.MagnifierMouseTrackingMode} the current mode + */ + getMouseTrackingMode() { + return this._mouseTrackingMode; + } + + /** + * setFocusTrackingMode + * @param {GDesktopEnums.MagnifierFocusTrackingMode} mode: the new mode + */ + setFocusTrackingMode(mode) { + this._focusTrackingMode = mode; + this._syncFocusTracking(); + } + + /** + * setCaretTrackingMode + * @param {GDesktopEnums.MagnifierCaretTrackingMode} mode: the new mode + */ + setCaretTrackingMode(mode) { + this._caretTrackingMode = mode; + this._syncCaretTracking(); + } + + _syncFocusTracking() { + let enabled = this._focusTrackingMode != GDesktopEnums.MagnifierFocusTrackingMode.NONE && + this.isActive(); + + if (enabled) + this._focusCaretTracker.registerFocusListener(); + else + this._focusCaretTracker.deregisterFocusListener(); + } + + _syncCaretTracking() { + let enabled = this._caretTrackingMode != GDesktopEnums.MagnifierCaretTrackingMode.NONE && + this.isActive(); + + if (enabled) + this._focusCaretTracker.registerCaretListener(); + else + this._focusCaretTracker.deregisterCaretListener(); + } + + /** + * setViewPort + * Sets the position and size of the ZoomRegion on screen. + * @param {{x: number, y: number, width: number, height: number}} viewPort: + * Object defining the position and size of the view port. + * The values are in stage coordinate space. + */ + setViewPort(viewPort) { + this._setViewPort(viewPort); + this._screenPosition = GDesktopEnums.MagnifierScreenPosition.NONE; + } + + /** + * setROI + * Sets the "region of interest" that the ZoomRegion is magnifying. + * @param {{x: number, y: number, width: number, height: number}} roi: + * Object that defines the region of the screen to magnify. + * The values are in screen (unmagnified) coordinate space. + */ + setROI(roi) { + if (roi.width <= 0 || roi.height <= 0) + return; + + this._followingCursor = false; + this._changeROI({ + xMagFactor: this._viewPortWidth / roi.width, + yMagFactor: this._viewPortHeight / roi.height, + xCenter: roi.x + roi.width / 2, + yCenter: roi.y + roi.height / 2, + }); + } + + /** + * getROI: + * Retrieves the "region of interest" -- the rectangular bounds of that part + * of the desktop that the magnified view is showing (x, y, width, height). + * The bounds are given in non-magnified coordinates. + * @returns {number[]} an array, [x, y, width, height], representing + * the bounding rectangle of what is shown in the magnified view. + */ + getROI() { + let roiWidth = this._viewPortWidth / this._xMagFactor; + let roiHeight = this._viewPortHeight / this._yMagFactor; + + return [ + this._xCenter - roiWidth / 2, + this._yCenter - roiHeight / 2, + roiWidth, roiHeight, + ]; + } + + /** + * setLensMode: + * Turn lens mode on/off. In full screen mode, lens mode does nothing since + * a lens the size of the screen is pointless. + * @param {bool} lensMode: Whether lensMode should be active + */ + setLensMode(lensMode) { + this._lensMode = lensMode; + if (!this._lensMode) + this.setScreenPosition(this._screenPosition); + } + + /** + * isLensMode: + * Is lens mode on or off? + * @returns {bool} The lens mode state. + */ + isLensMode() { + return this._lensMode; + } + + /** + * setClampScrollingAtEdges: + * Stop vs. allow scrolling of the magnified contents when it scroll beyond + * the edges of the screen. + * @param {bool} clamp: Boolean to turn on/off clamping. + */ + setClampScrollingAtEdges(clamp) { + this._clampScrollingAtEdges = clamp; + if (clamp) + this._changeROI(); + } + + /** + * setTopHalf: + * Magnifier view occupies the top half of the screen. + */ + setTopHalf() { + let viewPort = {}; + viewPort.x = 0; + viewPort.y = 0; + viewPort.width = global.screen_width; + viewPort.height = global.screen_height / 2; + this._setViewPort(viewPort); + this._screenPosition = GDesktopEnums.MagnifierScreenPosition.TOP_HALF; + } + + /** + * setBottomHalf: + * Magnifier view occupies the bottom half of the screen. + */ + setBottomHalf() { + let viewPort = {}; + viewPort.x = 0; + viewPort.y = global.screen_height / 2; + viewPort.width = global.screen_width; + viewPort.height = global.screen_height / 2; + this._setViewPort(viewPort); + this._screenPosition = GDesktopEnums.MagnifierScreenPosition.BOTTOM_HALF; + } + + /** + * setLeftHalf: + * Magnifier view occupies the left half of the screen. + */ + setLeftHalf() { + let viewPort = {}; + viewPort.x = 0; + viewPort.y = 0; + viewPort.width = global.screen_width / 2; + viewPort.height = global.screen_height; + this._setViewPort(viewPort); + this._screenPosition = GDesktopEnums.MagnifierScreenPosition.LEFT_HALF; + } + + /** + * setRightHalf: + * Magnifier view occupies the right half of the screen. + */ + setRightHalf() { + let viewPort = {}; + viewPort.x = global.screen_width / 2; + viewPort.y = 0; + viewPort.width = global.screen_width / 2; + viewPort.height = global.screen_height; + this._setViewPort(viewPort); + this._screenPosition = GDesktopEnums.MagnifierScreenPosition.RIGHT_HALF; + } + + /** + * setFullScreenMode: + * Set the ZoomRegion to full-screen mode. + * Note: disallows lens mode. + */ + setFullScreenMode() { + let viewPort = {}; + viewPort.x = 0; + viewPort.y = 0; + viewPort.width = global.screen_width; + viewPort.height = global.screen_height; + this.setViewPort(viewPort); + + this._screenPosition = GDesktopEnums.MagnifierScreenPosition.FULL_SCREEN; + } + + /** + * setScreenPosition: + * Positions the zoom region to one of the enumerated positions on the + * screen. + * @param {GDesktopEnums.MagnifierScreenPosition} inPosition: the position + */ + setScreenPosition(inPosition) { + switch (inPosition) { + case GDesktopEnums.MagnifierScreenPosition.FULL_SCREEN: + this.setFullScreenMode(); + break; + case GDesktopEnums.MagnifierScreenPosition.TOP_HALF: + this.setTopHalf(); + break; + case GDesktopEnums.MagnifierScreenPosition.BOTTOM_HALF: + this.setBottomHalf(); + break; + case GDesktopEnums.MagnifierScreenPosition.LEFT_HALF: + this.setLeftHalf(); + break; + case GDesktopEnums.MagnifierScreenPosition.RIGHT_HALF: + this.setRightHalf(); + break; + } + } + + /** + * getScreenPosition: + * Tell the outside world what the current mode is -- magnifiying the + * top half, bottom half, etc. + * @returns {GDesktopEnums.MagnifierScreenPosition}: the current position. + */ + getScreenPosition() { + return this._screenPosition; + } + + _clearScrollContentsTimer() { + if (this._scrollContentsTimerId !== 0) { + GLib.source_remove(this._scrollContentsTimerId); + this._scrollContentsTimerId = 0; + } + } + + /** + * scrollToMousePos: + * Set the region of interest based on the position of the system pointer. + * @returns {bool}: Whether the system mouse pointer is over the + * magnified view. + */ + scrollToMousePos() { + this._followingCursor = true; + if (this._mouseTrackingMode != GDesktopEnums.MagnifierMouseTrackingMode.NONE) + this._changeROI({ redoCursorTracking: true }); + else + this._updateMousePosition(); + + this._clearScrollContentsTimer(); + this._scrollContentsTimerId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, POINTER_REST_TIME, () => { + this._followingCursor = false; + if (this._xDelayed !== null && this._yDelayed !== null) { + this._scrollContentsToDelayed(this._xDelayed, this._yDelayed); + this._xDelayed = null; + this._yDelayed = null; + } + + this._scrollContentsTimerId = 0; + + return GLib.SOURCE_REMOVE; + }); + + // Determine whether the system mouse pointer is over this zoom region. + return this._isMouseOverRegion(); + } + + _scrollContentsToDelayed(x, y) { + if (this._followingCursor) { + this._xDelayed = x; + this._yDelayed = y; + } else { + this.scrollContentsTo(x, y); + } + } + + /** + * scrollContentsTo: + * Shift the contents of the magnified view such it is centered on the given + * coordinate. + * @param {number} x: The x-coord of the point to center on. + * @param {number} y: The y-coord of the point to center on. + */ + scrollContentsTo(x, y) { + if (x < 0 || x > global.screen_width || + y < 0 || y > global.screen_height) + return; + + this._clearScrollContentsTimer(); + + this._followingCursor = false; + this._changeROI({ + xCenter: x, + yCenter: y, + animate: true, + }); + } + + /** + * addCrosshairs: + * Add crosshairs centered on the magnified mouse. + * @param {Crosshairs} crossHairs: Crosshairs instance + */ + addCrosshairs(crossHairs) { + this._crossHairs = crossHairs; + + // If the crossHairs is not already within a larger container, add it + // to this zoom region. Otherwise, add a clone. + if (crossHairs && this.isActive()) + this._crossHairsActor = crossHairs.addToZoomRegion(this, this._mouseActor); + } + + /** + * setInvertLightness: + * Set whether to invert the lightness of the magnified view. + * @param {bool} flag: whether brightness should be inverted + */ + setInvertLightness(flag) { + this._invertLightness = flag; + if (this._magShaderEffects) + this._magShaderEffects.setInvertLightness(this._invertLightness); + } + + /** + * getInvertLightness: + * Retrieve whether the lightness is inverted. + * @returns {bool} whether brightness should be inverted + */ + getInvertLightness() { + return this._invertLightness; + } + + /** + * setColorSaturation: + * Set the color saturation of the magnified view. + * @param {number} saturation: A value from 0.0 to 1.0 that defines + * the color saturation, with 0.0 defining no color (grayscale), + * and 1.0 defining full color. + */ + setColorSaturation(saturation) { + this._colorSaturation = saturation; + if (this._magShaderEffects) + this._magShaderEffects.setColorSaturation(this._colorSaturation); + } + + /** + * getColorSaturation: + * Retrieve the color saturation of the magnified view. + * @returns {number} the color saturation + */ + getColorSaturation() { + return this._colorSaturation; + } + + /** + * setBrightness: + * Alter the brightness of the magnified view. + * @param {Object} brightness: Object containing the contrast for the + * red, green, and blue channels. Values of 0.0 represent "standard" + * brightness (no change), whereas values less or greater than + * 0.0 indicate decreased or incresaed brightness, respectively. + * + * {number} brightness.r - the red component + * {number} brightness.g - the green component + * {number} brightness.b - the blue component + */ + setBrightness(brightness) { + this._brightness.r = brightness.r; + this._brightness.g = brightness.g; + this._brightness.b = brightness.b; + if (this._magShaderEffects) + this._magShaderEffects.setBrightness(this._brightness); + } + + /** + * setContrast: + * Alter the contrast of the magnified view. + * @param {Object} contrast: Object containing the contrast for the + * red, green, and blue channels. Values of 0.0 represent "standard" + * contrast (no change), whereas values less or greater than + * 0.0 indicate decreased or incresaed contrast, respectively. + * + * {number} contrast.r - the red component + * {number} contrast.g - the green component + * {number} contrast.b - the blue component + */ + setContrast(contrast) { + this._contrast.r = contrast.r; + this._contrast.g = contrast.g; + this._contrast.b = contrast.b; + if (this._magShaderEffects) + this._magShaderEffects.setContrast(this._contrast); + } + + /** + * getContrast: + * Retrieve the contrast of the magnified view. + * @returns {{r: number, g: number, b: number}}: Object containing + * the contrast for the red, green, and blue channels. + */ + getContrast() { + let contrast = {}; + contrast.r = this._contrast.r; + contrast.g = this._contrast.g; + contrast.b = this._contrast.b; + return contrast; + } + + // Private methods // + + _createActors() { + // The root actor for the zoom region + this._magView = new St.Bin({ style_class: 'magnifier-zoom-region' }); + global.stage.add_actor(this._magView); + + // hide the magnified region from CLUTTER_PICK_ALL + Shell.util_set_hidden_from_pick(this._magView, true); + + // Add a group to clip the contents of the magnified view. + let mainGroup = new Clutter.Actor({ clip_to_allocation: true }); + this._magView.set_child(mainGroup); + + // Add a background for when the magnified uiGroup is scrolled + // out of view (don't want to see desktop showing through). + this._background = new Background.SystemBackground(); + mainGroup.add_actor(this._background); + + // Clone the group that contains all of UI on the screen. This is the + // chrome, the windows, etc. + this._uiGroupClone = new Clutter.Clone({ + source: Main.uiGroup, + clip_to_allocation: true, + }); + mainGroup.add_actor(this._uiGroupClone); + + // Add either the given mouseSourceActor to the ZoomRegion, or a clone of + // it. + if (this._mouseSourceActor.get_parent() != null) + this._mouseActor = new Clutter.Clone({ source: this._mouseSourceActor }); + else + this._mouseActor = this._mouseSourceActor; + mainGroup.add_actor(this._mouseActor); + + if (this._crossHairs) + this._crossHairsActor = this._crossHairs.addToZoomRegion(this, this._mouseActor); + else + this._crossHairsActor = null; + + // Contrast and brightness effects. + this._magShaderEffects = new MagShaderEffects(mainGroup); + this._magShaderEffects.setColorSaturation(this._colorSaturation); + this._magShaderEffects.setInvertLightness(this._invertLightness); + this._magShaderEffects.setBrightness(this._brightness); + this._magShaderEffects.setContrast(this._contrast); + } + + _destroyActors() { + if (this._mouseActor == this._mouseSourceActor) + this._mouseActor.get_parent().remove_actor(this._mouseActor); + if (this._crossHairs) + this._crossHairs.removeFromParent(this._crossHairsActor); + + this._magShaderEffects.destroyEffects(); + this._magShaderEffects = null; + this._magView.destroy(); + this._magView = null; + this._background = null; + this._uiGroupClone = null; + this._mouseActor = null; + this._crossHairsActor = null; + } + + _setViewPort(viewPort, fromROIUpdate) { + // Sets the position of the zoom region on the screen + + let width = Math.round(Math.min(viewPort.width, global.screen_width)); + let height = Math.round(Math.min(viewPort.height, global.screen_height)); + let x = Math.max(viewPort.x, 0); + let y = Math.max(viewPort.y, 0); + + x = Math.round(Math.min(x, global.screen_width - width)); + y = Math.round(Math.min(y, global.screen_height - height)); + + this._viewPortX = x; + this._viewPortY = y; + this._viewPortWidth = width; + this._viewPortHeight = height; + + this._updateMagViewGeometry(); + + if (!fromROIUpdate) + this._changeROI({ redoCursorTracking: this._followingCursor }); // will update mouse + + if (this.isActive() && this._isMouseOverRegion()) + this._magnifier.hideSystemCursor(); + + const uiGroupIsOccluded = this.isActive() && this._isFullScreen(); + Main.uiGroup.set_opacity(uiGroupIsOccluded ? 0 : 255); + } + + _changeROI(params) { + // Updates the area we are viewing; the magnification factors + // and center can be set explicitly, or we can recompute + // the position based on the mouse cursor position + + params = Params.parse(params, { + xMagFactor: this._xMagFactor, + yMagFactor: this._yMagFactor, + xCenter: this._xCenter, + yCenter: this._yCenter, + redoCursorTracking: false, + animate: false, + }); + + if (params.xMagFactor <= 0) + params.xMagFactor = this._xMagFactor; + if (params.yMagFactor <= 0) + params.yMagFactor = this._yMagFactor; + + this._xMagFactor = params.xMagFactor; + this._yMagFactor = params.yMagFactor; + + if (params.redoCursorTracking && + this._mouseTrackingMode != GDesktopEnums.MagnifierMouseTrackingMode.NONE) { + // This depends on this.xMagFactor/yMagFactor already being updated + [params.xCenter, params.yCenter] = this._centerFromMousePosition(); + } + + if (this._clampScrollingAtEdges) { + let roiWidth = this._viewPortWidth / this._xMagFactor; + let roiHeight = this._viewPortHeight / this._yMagFactor; + + params.xCenter = Math.min(params.xCenter, global.screen_width - roiWidth / 2); + params.xCenter = Math.max(params.xCenter, roiWidth / 2); + params.yCenter = Math.min(params.yCenter, global.screen_height - roiHeight / 2); + params.yCenter = Math.max(params.yCenter, roiHeight / 2); + } + + this._xCenter = params.xCenter; + this._yCenter = params.yCenter; + + // If in lens mode, move the magnified view such that it is centered + // over the actual mouse. However, in full screen mode, the "lens" is + // the size of the screen -- pointless to move such a large lens around. + if (this._lensMode && !this._isFullScreen()) { + this._setViewPort({ + x: this._xCenter - this._viewPortWidth / 2, + y: this._yCenter - this._viewPortHeight / 2, + width: this._viewPortWidth, + height: this._viewPortHeight, + }, true); + } + + this._updateCloneGeometry(params.animate); + } + + _isMouseOverRegion() { + // Return whether the system mouse sprite is over this ZoomRegion. If the + // mouse's position is not given, then it is fetched. + let mouseIsOver = false; + if (this.isActive()) { + let xMouse = this._magnifier.xMouse; + let yMouse = this._magnifier.yMouse; + + mouseIsOver = + xMouse >= this._viewPortX && xMouse < (this._viewPortX + this._viewPortWidth) && + yMouse >= this._viewPortY && yMouse < (this._viewPortY + this._viewPortHeight); + } + return mouseIsOver; + } + + _isFullScreen() { + // Does the magnified view occupy the whole screen? Note that this + // doesn't necessarily imply + // this._screenPosition = GDesktopEnums.MagnifierScreenPosition.FULL_SCREEN; + + if (this._viewPortX != 0 || this._viewPortY != 0) + return false; + if (this._viewPortWidth != global.screen_width || + this._viewPortHeight != global.screen_height) + return false; + return true; + } + + _centerFromMousePosition() { + // Determines where the center should be given the current cursor + // position and mouse tracking mode + + let xMouse = this._magnifier.xMouse; + let yMouse = this._magnifier.yMouse; + + if (this._mouseTrackingMode == GDesktopEnums.MagnifierMouseTrackingMode.PROPORTIONAL) + return this._centerFromPointProportional(xMouse, yMouse); + else if (this._mouseTrackingMode == GDesktopEnums.MagnifierMouseTrackingMode.PUSH) + return this._centerFromPointPush(xMouse, yMouse); + else if (this._mouseTrackingMode == GDesktopEnums.MagnifierMouseTrackingMode.CENTERED) + return this._centerFromPointCentered(xMouse, yMouse); + + return null; // Should never be hit + } + + _centerFromCaretPosition() { + let xCaret = this._xCaret; + let yCaret = this._yCaret; + + if (this._caretTrackingMode == GDesktopEnums.MagnifierCaretTrackingMode.PROPORTIONAL) + [xCaret, yCaret] = this._centerFromPointProportional(xCaret, yCaret); + else if (this._caretTrackingMode == GDesktopEnums.MagnifierCaretTrackingMode.PUSH) + [xCaret, yCaret] = this._centerFromPointPush(xCaret, yCaret); + else if (this._caretTrackingMode == GDesktopEnums.MagnifierCaretTrackingMode.CENTERED) + [xCaret, yCaret] = this._centerFromPointCentered(xCaret, yCaret); + + this._scrollContentsToDelayed(xCaret, yCaret); + } + + _centerFromFocusPosition() { + let xFocus = this._xFocus; + let yFocus = this._yFocus; + + if (this._focusTrackingMode == GDesktopEnums.MagnifierFocusTrackingMode.PROPORTIONAL) + [xFocus, yFocus] = this._centerFromPointProportional(xFocus, yFocus); + else if (this._focusTrackingMode == GDesktopEnums.MagnifierFocusTrackingMode.PUSH) + [xFocus, yFocus] = this._centerFromPointPush(xFocus, yFocus); + else if (this._focusTrackingMode == GDesktopEnums.MagnifierFocusTrackingMode.CENTERED) + [xFocus, yFocus] = this._centerFromPointCentered(xFocus, yFocus); + + this._scrollContentsToDelayed(xFocus, yFocus); + } + + _centerFromPointPush(xPoint, yPoint) { + let [xRoi, yRoi, widthRoi, heightRoi] = this.getROI(); + let [cursorWidth, cursorHeight] = this._mouseSourceActor.get_size(); + let xPos = xRoi + widthRoi / 2; + let yPos = yRoi + heightRoi / 2; + let xRoiRight = xRoi + widthRoi - cursorWidth; + let yRoiBottom = yRoi + heightRoi - cursorHeight; + + if (xPoint < xRoi) + xPos -= xRoi - xPoint; + else if (xPoint > xRoiRight) + xPos += xPoint - xRoiRight; + + if (yPoint < yRoi) + yPos -= yRoi - yPoint; + else if (yPoint > yRoiBottom) + yPos += yPoint - yRoiBottom; + + return [xPos, yPos]; + } + + _centerFromPointProportional(xPoint, yPoint) { + let [xRoi_, yRoi_, widthRoi, heightRoi] = this.getROI(); + let halfScreenWidth = global.screen_width / 2; + let halfScreenHeight = global.screen_height / 2; + // We want to pad with a constant distance after zooming, so divide + // by the magnification factor. + let unscaledPadding = Math.min(this._viewPortWidth, this._viewPortHeight) / 5; + let xPadding = unscaledPadding / this._xMagFactor; + let yPadding = unscaledPadding / this._yMagFactor; + let xProportion = (xPoint - halfScreenWidth) / halfScreenWidth; // -1 ... 1 + let yProportion = (yPoint - halfScreenHeight) / halfScreenHeight; // -1 ... 1 + let xPos = xPoint - xProportion * (widthRoi / 2 - xPadding); + let yPos = yPoint - yProportion * (heightRoi / 2 - yPadding); + + return [xPos, yPos]; + } + + _centerFromPointCentered(xPoint, yPoint) { + return [xPoint, yPoint]; + } + + _screenToViewPort(screenX, screenY) { + // Converts coordinates relative to the (unmagnified) screen to coordinates + // relative to the origin of this._magView + return [ + this._viewPortWidth / 2 + (screenX - this._xCenter) * this._xMagFactor, + this._viewPortHeight / 2 + (screenY - this._yCenter) * this._yMagFactor, + ]; + } + + _updateMagViewGeometry() { + if (!this.isActive()) + return; + + if (this._isFullScreen()) + this._magView.add_style_class_name('full-screen'); + else + this._magView.remove_style_class_name('full-screen'); + + this._magView.set_size(this._viewPortWidth, this._viewPortHeight); + this._magView.set_position(this._viewPortX, this._viewPortY); + } + + _updateCloneGeometry(animate = false) { + if (!this.isActive()) + return; + + let [x, y] = this._screenToViewPort(0, 0); + this._uiGroupClone.ease({ + x: Math.round(x), + y: Math.round(y), + scale_x: this._xMagFactor, + scale_y: this._yMagFactor, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: animate ? 100 : 0, + }); + + let [mouseX, mouseY] = this._getMousePosition(); + this._mouseActor.ease({ + x: mouseX, + y: mouseY, + scale_x: this._xMagFactor, + scale_y: this._yMagFactor, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: animate ? 100 : 0, + }); + + if (this._crossHairsActor) { + let [crossX, crossY] = this._getCrossHairsPosition(); + this._crossHairsActor.ease({ + x: crossX, + y: crossY, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: animate ? 100 : 0, + }); + } + } + + _updateMousePosition() { + let [xMagMouse, yMagMouse] = this._getMousePosition(); + this._mouseActor.set_position(xMagMouse, yMagMouse); + + if (this._crossHairsActor) { + let [crossX, crossY] = this._getCrossHairsPosition(); + this._crossHairsActor.set_position(crossX, crossY); + } + } + + _getMousePosition() { + let [xMagMouse, yMagMouse] = this._screenToViewPort( + this._magnifier.xMouse, this._magnifier.yMouse); + return [Math.round(xMagMouse), Math.round(yMagMouse)]; + } + + _getCrossHairsPosition() { + let [xMagMouse, yMagMouse] = this._getMousePosition(); + let [groupWidth, groupHeight] = this._crossHairsActor.get_size(); + + return [xMagMouse - groupWidth / 2, yMagMouse - groupHeight / 2]; + } + + _monitorsChanged() { + this._background.set_size(global.screen_width, global.screen_height); + this._updateScreenPosition(); + } +}; + +var Crosshairs = GObject.registerClass( +class Crosshairs extends Clutter.Actor { + _init() { + // Set the group containing the crosshairs to three times the desktop + // size in case the crosshairs need to appear to be infinite in + // length (i.e., extend beyond the edges of the view they appear in). + let groupWidth = global.screen_width * 3; + let groupHeight = global.screen_height * 3; + + super._init({ + clip_to_allocation: false, + width: groupWidth, + height: groupHeight, + }); + this._horizLeftHair = new Clutter.Actor(); + this._horizRightHair = new Clutter.Actor(); + this._vertTopHair = new Clutter.Actor(); + this._vertBottomHair = new Clutter.Actor(); + this.add_actor(this._horizLeftHair); + this.add_actor(this._horizRightHair); + this.add_actor(this._vertTopHair); + this.add_actor(this._vertBottomHair); + this._clipSize = [0, 0]; + this._clones = []; + this.reCenter(); + this._monitorsChangedId = 0; + } + + _monitorsChanged() { + this.set_size(global.screen_width * 3, global.screen_height * 3); + this.reCenter(); + } + + setEnabled(enabled) { + if (enabled && this._monitorsChangedId === 0) { + this._monitorsChangedId = Main.layoutManager.connect( + 'monitors-changed', this._monitorsChanged.bind(this)); + } else if (!enabled && this._monitorsChangedId !== 0) { + Main.layoutManager.disconnect(this._monitorsChangedId); + this._monitorsChangedId = 0; + } + } + + /** + * addToZoomRegion + * Either add the crosshairs actor to the given ZoomRegion, or, if it is + * already part of some other ZoomRegion, create a clone of the crosshairs + * actor, and add the clone instead. Returns either the original or the + * clone. + * @param {ZoomRegion} zoomRegion: The container to add the crosshairs + * group to. + * @param {Clutter.Actor} magnifiedMouse: The mouse actor for the + * zoom region -- used to position the crosshairs and properly + * layer them below the mouse. + * @returns {Clutter.Actor} The crosshairs actor, or its clone. + */ + addToZoomRegion(zoomRegion, magnifiedMouse) { + let crosshairsActor = null; + if (zoomRegion && magnifiedMouse) { + let container = magnifiedMouse.get_parent(); + if (container) { + crosshairsActor = this; + if (this.get_parent() != null) { + crosshairsActor = new Clutter.Clone({ source: this }); + this._clones.push(crosshairsActor); + + // Clones don't share visibility. + this.bind_property('visible', crosshairsActor, 'visible', + GObject.BindingFlags.SYNC_CREATE); + } + + container.add_actor(crosshairsActor); + container.set_child_above_sibling(magnifiedMouse, crosshairsActor); + let [xMouse, yMouse] = magnifiedMouse.get_position(); + let [crosshairsWidth, crosshairsHeight] = crosshairsActor.get_size(); + crosshairsActor.set_position(xMouse - crosshairsWidth / 2, yMouse - crosshairsHeight / 2); + } + } + return crosshairsActor; + } + + /** + * removeFromParent: + * @param {Clutter.Actor} childActor: the actor returned from + * addToZoomRegion + * Remove the crosshairs actor from its parent container, or destroy the + * child actor if it was just a clone of the crosshairs actor. + */ + removeFromParent(childActor) { + if (childActor == this) + childActor.get_parent().remove_actor(childActor); + else + childActor.destroy(); + } + + /** + * setColor: + * Set the color of the crosshairs. + * @param {Clutter.Color} clutterColor: The color + */ + setColor(clutterColor) { + this._horizLeftHair.background_color = clutterColor; + this._horizRightHair.background_color = clutterColor; + this._vertTopHair.background_color = clutterColor; + this._vertBottomHair.background_color = clutterColor; + } + + /** + * getColor: + * Get the color of the crosshairs. + * @returns {ClutterColor} the crosshairs color + */ + getColor() { + return this._horizLeftHair.get_color(); + } + + /** + * setThickness: + * Set the width of the vertical and horizontal lines of the crosshairs. + * @param {number} thickness: the new thickness value + */ + setThickness(thickness) { + this._horizLeftHair.set_height(thickness); + this._horizRightHair.set_height(thickness); + this._vertTopHair.set_width(thickness); + this._vertBottomHair.set_width(thickness); + this.reCenter(); + } + + /** + * getThickness: + * Get the width of the vertical and horizontal lines of the crosshairs. + * @returns {number} The thickness of the crosshairs. + */ + getThickness() { + return this._horizLeftHair.get_height(); + } + + /** + * setOpacity: + * Set how opaque the crosshairs are. + * @param {number} opacity: Value between 0 (fully transparent) + * and 255 (full opaque). + */ + setOpacity(opacity) { + // set_opacity() throws an exception for values outside the range + // [0, 255]. + if (opacity < 0) + opacity = 0; + else if (opacity > 255) + opacity = 255; + + this._horizLeftHair.set_opacity(opacity); + this._horizRightHair.set_opacity(opacity); + this._vertTopHair.set_opacity(opacity); + this._vertBottomHair.set_opacity(opacity); + } + + /** + * setLength: + * Set the length of the vertical and horizontal lines in the crosshairs. + * @param {number} length: The length of the crosshairs. + */ + setLength(length) { + this._horizLeftHair.set_width(length); + this._horizRightHair.set_width(length); + this._vertTopHair.set_height(length); + this._vertBottomHair.set_height(length); + this.reCenter(); + } + + /** + * getLength: + * Get the length of the vertical and horizontal lines in the crosshairs. + * @returns {number} The length of the crosshairs. + */ + getLength() { + return this._horizLeftHair.get_width(); + } + + /** + * setClip: + * Set the width and height of the rectangle that clips the crosshairs at + * their intersection + * @param {number[]} size: Array of [width, height] defining the size + * of the clip rectangle. + */ + setClip(size) { + if (size) { + // Take a chunk out of the crosshairs where it intersects the + // mouse. + this._clipSize = size; + this.reCenter(); + } else { + // Restore the missing chunk. + this._clipSize = [0, 0]; + this.reCenter(); + } + } + + /** + * reCenter: + * Reposition the horizontal and vertical hairs such that they cross at + * the center of crosshairs group. If called with the dimensions of + * the clip rectangle, these are used to update the size of the clip. + * @param {number[]=} clipSize: If present, the clip's [width, height]. + */ + reCenter(clipSize) { + let [groupWidth, groupHeight] = this.get_size(); + let leftLength = this._horizLeftHair.get_width(); + let topLength = this._vertTopHair.get_height(); + let thickness = this._horizLeftHair.get_height(); + + // Deal with clip rectangle. + if (clipSize) + this._clipSize = clipSize; + let clipWidth = this._clipSize[0]; + let clipHeight = this._clipSize[1]; + + let left = groupWidth / 2 - clipWidth / 2 - leftLength - thickness / 2; + let right = groupWidth / 2 + clipWidth / 2 + thickness / 2; + let top = groupHeight / 2 - clipHeight / 2 - topLength - thickness / 2; + let bottom = groupHeight / 2 + clipHeight / 2 + thickness / 2; + this._horizLeftHair.set_position(left, (groupHeight - thickness) / 2); + this._horizRightHair.set_position(right, (groupHeight - thickness) / 2); + this._vertTopHair.set_position((groupWidth - thickness) / 2, top); + this._vertBottomHair.set_position((groupWidth - thickness) / 2, bottom); + } +}); + +var MagShaderEffects = class MagShaderEffects { + constructor(uiGroupClone) { + this._inverse = new Shell.InvertLightnessEffect(); + this._brightnessContrast = new Clutter.BrightnessContrastEffect(); + this._colorDesaturation = new Clutter.DesaturateEffect(); + this._inverse.set_enabled(false); + this._brightnessContrast.set_enabled(false); + this._colorDesaturation.set_enabled(false); + + this._magView = uiGroupClone; + this._magView.add_effect(this._inverse); + this._magView.add_effect(this._brightnessContrast); + this._magView.add_effect(this._colorDesaturation); + } + + /** + * destroyEffects: + * Remove contrast and brightness effects from the magnified view, and + * lose the reference to the actor they were applied to. Don't use this + * object after calling this. + */ + destroyEffects() { + this._magView.clear_effects(); + this._colorDesaturation = null; + this._brightnessContrast = null; + this._inverse = null; + this._magView = null; + } + + /** + * setInvertLightness: + * Enable/disable invert lightness effect. + * @param {bool} invertFlag: Enabled flag. + */ + setInvertLightness(invertFlag) { + this._inverse.set_enabled(invertFlag); + } + + setColorSaturation(factor) { + this._colorDesaturation.set_factor(1.0 - factor); + this._colorDesaturation.set_enabled(factor !== 1.0); + } + + /** + * setBrightness: + * Set the brightness of the magnified view. + * @param {Object} brightness: Object containing the contrast for the + * red, green, and blue channels. Values of 0.0 represent "standard" + * brightness (no change), whereas values less or greater than + * 0.0 indicate decreased or incresaed brightness, respectively. + * + * {number} brightness.r - the red component + * {number} brightness.g - the green component + * {number} brightness.b - the blue component + */ + setBrightness(brightness) { + let bRed = brightness.r; + let bGreen = brightness.g; + let bBlue = brightness.b; + this._brightnessContrast.set_brightness_full(bRed, bGreen, bBlue); + + // Enable the effect if the brightness OR contrast change are such that + // it modifies the brightness and/or contrast. + let [cRed, cGreen, cBlue] = this._brightnessContrast.get_contrast(); + this._brightnessContrast.set_enabled( + bRed !== NO_CHANGE || bGreen !== NO_CHANGE || bBlue !== NO_CHANGE || + cRed !== NO_CHANGE || cGreen !== NO_CHANGE || cBlue !== NO_CHANGE); + } + + /** + * Set the contrast of the magnified view. + * @param {Object} contrast: Object containing the contrast for the + * red, green, and blue channels. Values of 0.0 represent "standard" + * contrast (no change), whereas values less or greater than + * 0.0 indicate decreased or incresaed contrast, respectively. + * + * {number} contrast.r - the red component + * {number} contrast.g - the green component + * {number} contrast.b - the blue component + */ + setContrast(contrast) { + let cRed = contrast.r; + let cGreen = contrast.g; + let cBlue = contrast.b; + + this._brightnessContrast.set_contrast_full(cRed, cGreen, cBlue); + + // Enable the effect if the contrast OR brightness change are such that + // it modifies the brightness and/or contrast. + // should be able to use Clutter.color_equal(), but that complains of + // a null first argument. + let [bRed, bGreen, bBlue] = this._brightnessContrast.get_brightness(); + this._brightnessContrast.set_enabled( + cRed !== NO_CHANGE || cGreen !== NO_CHANGE || cBlue !== NO_CHANGE || + bRed !== NO_CHANGE || bGreen !== NO_CHANGE || bBlue !== NO_CHANGE); + } +}; diff --git a/js/ui/main.js b/js/ui/main.js new file mode 100644 index 0000000..2d8804a --- /dev/null +++ b/js/ui/main.js @@ -0,0 +1,958 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported componentManager, notificationDaemon, windowAttentionHandler, + ctrlAltTabManager, padOsdService, osdWindowManager, + osdMonitorLabeler, shellMountOpDBusService, shellDBusService, + shellAccessDialogDBusService, shellAudioSelectionDBusService, + screenSaverDBus, uiGroup, magnifier, xdndHandler, keyboard, + kbdA11yDialog, introspectService, start, pushModal, popModal, + activateWindow, moveWindowToMonitorAndWorkspace, + createLookingGlass, initializeDeferredWork, + getThemeStylesheet, setThemeStylesheet, screenshotUI */ + +const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; + +const AccessDialog = imports.ui.accessDialog; +const AudioDeviceSelection = imports.ui.audioDeviceSelection; +const Components = imports.ui.components; +const CtrlAltTab = imports.ui.ctrlAltTab; +const EndSessionDialog = imports.ui.endSessionDialog; +const ExtensionSystem = imports.ui.extensionSystem; +const ExtensionDownloader = imports.ui.extensionDownloader; +const InputMethod = imports.misc.inputMethod; +const Introspect = imports.misc.introspect; +const Keyboard = imports.ui.keyboard; +const MessageTray = imports.ui.messageTray; +const ModalDialog = imports.ui.modalDialog; +const OsdWindow = imports.ui.osdWindow; +const OsdMonitorLabeler = imports.ui.osdMonitorLabeler; +const Overview = imports.ui.overview; +const PadOsd = imports.ui.padOsd; +const Panel = imports.ui.panel; +const Params = imports.misc.params; +const RunDialog = imports.ui.runDialog; +const WelcomeDialog = imports.ui.welcomeDialog; +const Layout = imports.ui.layout; +const LoginManager = imports.misc.loginManager; +const LookingGlass = imports.ui.lookingGlass; +const NotificationDaemon = imports.ui.notificationDaemon; +const WindowAttentionHandler = imports.ui.windowAttentionHandler; +const Screenshot = imports.ui.screenshot; +const ScreenShield = imports.ui.screenShield; +const Scripting = imports.ui.scripting; +const SessionMode = imports.ui.sessionMode; +const ShellDBus = imports.ui.shellDBus; +const ShellMountOperation = imports.ui.shellMountOperation; +const WindowManager = imports.ui.windowManager; +const Magnifier = imports.ui.magnifier; +const XdndHandler = imports.ui.xdndHandler; +const KbdA11yDialog = imports.ui.kbdA11yDialog; +const LocatePointer = imports.ui.locatePointer; +const PointerA11yTimeout = imports.ui.pointerA11yTimeout; +const ParentalControlsManager = imports.misc.parentalControlsManager; +const Config = imports.misc.config; +const Util = imports.misc.util; + +const WELCOME_DIALOG_LAST_SHOWN_VERSION = 'welcome-dialog-last-shown-version'; +// Make sure to mention the point release, otherwise it will show every time +// until this version is current +const WELCOME_DIALOG_LAST_TOUR_CHANGE = '40.beta'; +const LOG_DOMAIN = 'GNOME Shell'; +const GNOMESHELL_STARTED_MESSAGE_ID = 'f3ea493c22934e26811cd62abe8e203a'; + +var componentManager = null; +var extensionManager = null; +var panel = null; +var overview = null; +var runDialog = null; +var lookingGlass = null; +var welcomeDialog = null; +var wm = null; +var messageTray = null; +var screenShield = null; +var notificationDaemon = null; +var windowAttentionHandler = null; +var ctrlAltTabManager = null; +var padOsdService = null; +var osdWindowManager = null; +var osdMonitorLabeler = null; +var sessionMode = null; +var screenshotUI = null; +var shellAccessDialogDBusService = null; +var shellAudioSelectionDBusService = null; +var shellDBusService = null; +var shellMountOpDBusService = null; +var screenSaverDBus = null; +var modalCount = 0; +var actionMode = Shell.ActionMode.NONE; +var modalActorFocusStack = []; +var uiGroup = null; +var magnifier = null; +var xdndHandler = null; +var keyboard = null; +var layoutManager = null; +var kbdA11yDialog = null; +var inputMethod = null; +var introspectService = null; +var locatePointer = null; +let _startDate; +let _defaultCssStylesheet = null; +let _cssStylesheet = null; +let _themeResource = null; +let _oskResource = null; +let _iconResource = null; + +Gio._promisify(Gio.File.prototype, 'delete_async'); +Gio._promisify(Gio.File.prototype, 'touch_async'); + +let _remoteAccessInhibited = false; + +function _sessionUpdated() { + if (sessionMode.isPrimary) + _loadDefaultStylesheet(); + + wm.allowKeybinding('overlay-key', Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW); + + wm.allowKeybinding('locate-pointer-key', Shell.ActionMode.ALL); + + wm.setCustomKeybindingHandler('panel-run-dialog', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + sessionMode.hasRunDialog ? openRunDialog : null); + + if (!sessionMode.hasRunDialog) { + if (runDialog) + runDialog.close(); + if (lookingGlass) + lookingGlass.close(); + if (welcomeDialog) + welcomeDialog.close(); + } + + let remoteAccessController = global.backend.get_remote_access_controller(); + if (remoteAccessController && !global.backend.is_headless()) { + if (sessionMode.allowScreencast && _remoteAccessInhibited) { + remoteAccessController.uninhibit_remote_access(); + _remoteAccessInhibited = false; + } else if (!sessionMode.allowScreencast && !_remoteAccessInhibited) { + remoteAccessController.inhibit_remote_access(); + _remoteAccessInhibited = true; + } + } +} + +/** + * @param {any...} args a list of values to log + */ +function _loggingFunc(...args) { + let fields = { 'MESSAGE': args.join(', ') }; + let domain = 'GNOME Shell'; + + // If the caller is an extension, add it as metadata + let extension = imports.misc.extensionUtils.getCurrentExtension(); + if (extension != null) { + domain = extension.metadata.name; + fields['GNOME_SHELL_EXTENSION_UUID'] = extension.uuid; + fields['GNOME_SHELL_EXTENSION_NAME'] = extension.metadata.name; + } + + GLib.log_structured(domain, GLib.LogLevelFlags.LEVEL_MESSAGE, fields); +} + +function start() { + globalThis.log = _loggingFunc; + + // These are here so we don't break compatibility. + global.logError = globalThis.log; + global.log = globalThis.log; + + // Chain up async errors reported from C + global.connect('notify-error', (global, msg, detail) => { + notifyError(msg, detail); + }); + + let currentDesktop = GLib.getenv('XDG_CURRENT_DESKTOP'); + if (!currentDesktop || !currentDesktop.split(':').includes('GNOME')) + Gio.DesktopAppInfo.set_desktop_env('GNOME'); + + sessionMode = new SessionMode.SessionMode(); + sessionMode.connect('updated', _sessionUpdated); + + St.Settings.get().connect('notify::high-contrast', _loadDefaultStylesheet); + + // Initialize ParentalControlsManager before the UI + ParentalControlsManager.getDefault(); + + _initializeUI(); + + shellAccessDialogDBusService = new AccessDialog.AccessDialogDBus(); + shellAudioSelectionDBusService = new AudioDeviceSelection.AudioDeviceSelectionDBus(); + shellDBusService = new ShellDBus.GnomeShell(); + shellMountOpDBusService = new ShellMountOperation.GnomeShellMountOpHandler(); + + const watchId = Gio.DBus.session.watch_name('org.gnome.Shell.Notifications', + Gio.BusNameWatcherFlags.AUTO_START, + bus => bus.unwatch_name(watchId), + bus => bus.unwatch_name(watchId)); + + _sessionUpdated(); +} + +function _initializeUI() { + // Ensure ShellWindowTracker and ShellAppUsage are initialized; this will + // also initialize ShellAppSystem first. ShellAppSystem + // needs to load all the .desktop files, and ShellWindowTracker + // will use those to associate with windows. Right now + // the Monitor doesn't listen for installed app changes + // and recalculate application associations, so to avoid + // races for now we initialize it here. It's better to + // be predictable anyways. + Shell.WindowTracker.get_default(); + Shell.AppUsage.get_default(); + + reloadThemeResource(); + _loadIcons(); + _loadOskLayouts(); + _loadDefaultStylesheet(); + + new AnimationsSettings(); + + // Setup the stage hierarchy early + layoutManager = new Layout.LayoutManager(); + + // Various parts of the codebase still refer to Main.uiGroup + // instead of using the layoutManager. This keeps that code + // working until it's updated. + uiGroup = layoutManager.uiGroup; + + padOsdService = new PadOsd.PadOsdService(); + xdndHandler = new XdndHandler.XdndHandler(); + ctrlAltTabManager = new CtrlAltTab.CtrlAltTabManager(); + osdWindowManager = new OsdWindow.OsdWindowManager(); + osdMonitorLabeler = new OsdMonitorLabeler.OsdMonitorLabeler(); + overview = new Overview.Overview(); + kbdA11yDialog = new KbdA11yDialog.KbdA11yDialog(); + wm = new WindowManager.WindowManager(); + magnifier = new Magnifier.Magnifier(); + locatePointer = new LocatePointer.LocatePointer(); + + if (LoginManager.canLock()) + screenShield = new ScreenShield.ScreenShield(); + + inputMethod = new InputMethod.InputMethod(); + Clutter.get_default_backend().set_input_method(inputMethod); + + screenshotUI = new Screenshot.ScreenshotUI(); + + messageTray = new MessageTray.MessageTray(); + panel = new Panel.Panel(); + keyboard = new Keyboard.KeyboardManager(); + notificationDaemon = new NotificationDaemon.NotificationDaemon(); + windowAttentionHandler = new WindowAttentionHandler.WindowAttentionHandler(); + componentManager = new Components.ComponentManager(); + + introspectService = new Introspect.IntrospectService(); + + layoutManager.init(); + overview.init(); + + new PointerA11yTimeout.PointerA11yTimeout(); + + global.connect('locate-pointer', () => { + locatePointer.show(); + }); + + global.display.connect('show-restart-message', (display, message) => { + showRestartMessage(message); + return true; + }); + + global.display.connect('restart', () => { + global.reexec_self(); + return true; + }); + + global.display.connect('gl-video-memory-purged', loadTheme); + + global.context.connect('notify::unsafe-mode', () => { + if (!global.context.unsafe_mode) + return; // we're safe + if (lookingGlass?.isOpen) + return; // assume user action + + const source = new MessageTray.SystemNotificationSource(); + messageTray.add(source); + const notification = new MessageTray.Notification(source, + _('System was put in unsafe mode'), + _('Applications now have unrestricted access')); + notification.addAction(_('Undo'), + () => (global.context.unsafe_mode = false)); + notification.setTransient(true); + source.showNotification(notification); + }); + + // Provide the bus object for gnome-session to + // initiate logouts. + EndSessionDialog.init(); + + // We're ready for the session manager to move to the next phase + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + Shell.util_sd_notify(); + global.context.notify_ready(); + return GLib.SOURCE_REMOVE; + }); + + _startDate = new Date(); + + ExtensionDownloader.init(); + extensionManager = new ExtensionSystem.ExtensionManager(); + extensionManager.init(); + + if (sessionMode.isGreeter && screenShield) { + layoutManager.connect('startup-prepared', () => { + screenShield.showDialog(); + }); + } + + layoutManager.connect('startup-complete', () => { + if (actionMode == Shell.ActionMode.NONE) + actionMode = Shell.ActionMode.NORMAL; + + if (screenShield) + screenShield.lockIfWasLocked(); + + if (sessionMode.currentMode != 'gdm' && + sessionMode.currentMode != 'initial-setup') { + GLib.log_structured(LOG_DOMAIN, GLib.LogLevelFlags.LEVEL_MESSAGE, { + 'MESSAGE': `GNOME Shell started at ${_startDate}`, + 'MESSAGE_ID': GNOMESHELL_STARTED_MESSAGE_ID, + }); + } + + let credentials = new Gio.Credentials(); + if (credentials.get_unix_user() === 0) { + notify(_('Logged in as a privileged user'), + _('Running a session as a privileged user should be avoided for security reasons. If possible, you should log in as a normal user.')); + } else if (sessionMode.showWelcomeDialog) { + _handleShowWelcomeScreen(); + } + + if (sessionMode.currentMode !== 'gdm' && + sessionMode.currentMode !== 'initial-setup') + _handleLockScreenWarning(); + + LoginManager.registerSessionWithGDM(); + + let perfModuleName = GLib.getenv("SHELL_PERF_MODULE"); + if (perfModuleName) { + let perfOutput = GLib.getenv("SHELL_PERF_OUTPUT"); + let module = eval(`imports.perf.${perfModuleName};`); + Scripting.runPerfScript(module, perfOutput); + } + }); +} + +function _handleShowWelcomeScreen() { + const lastShownVersion = global.settings.get_string(WELCOME_DIALOG_LAST_SHOWN_VERSION); + if (Util.GNOMEversionCompare(WELCOME_DIALOG_LAST_TOUR_CHANGE, lastShownVersion) > 0) { + openWelcomeDialog(); + global.settings.set_string(WELCOME_DIALOG_LAST_SHOWN_VERSION, Config.PACKAGE_VERSION); + } +} + +async function _handleLockScreenWarning() { + const path = `${global.userdatadir}/lock-warning-shown`; + const file = Gio.File.new_for_path(path); + + const hasLockScreen = screenShield !== null; + if (hasLockScreen) { + try { + await file.delete_async(0, null); + } catch (e) { + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND)) + logError(e); + } + } else { + try { + if (!await file.touch_async()) + return; + } catch (e) { + logError(e); + } + + notify( + _('Screen Lock disabled'), + _('Screen Locking requires the GNOME display manager.')); + } +} + +function _getStylesheet(name) { + let stylesheet; + + stylesheet = Gio.File.new_for_uri(`resource:///org/gnome/shell/theme/${name}`); + if (stylesheet.query_exists(null)) + return stylesheet; + + let dataDirs = GLib.get_system_data_dirs(); + for (let i = 0; i < dataDirs.length; i++) { + let path = GLib.build_filenamev([dataDirs[i], 'gnome-shell', 'theme', name]); + stylesheet = Gio.file_new_for_path(path); + if (stylesheet.query_exists(null)) + return stylesheet; + } + + stylesheet = Gio.File.new_for_path(`${global.datadir}/theme/${name}`); + if (stylesheet.query_exists(null)) + return stylesheet; + + return null; +} + +function _getDefaultStylesheet() { + let stylesheet = null; + let name = sessionMode.stylesheetName; + + // Look for a high-contrast variant first + if (St.Settings.get().high_contrast) + stylesheet = _getStylesheet(name.replace('.css', '-high-contrast.css')); + + if (stylesheet == null) + stylesheet = _getStylesheet(sessionMode.stylesheetName); + + return stylesheet; +} + +function _loadDefaultStylesheet() { + let stylesheet = _getDefaultStylesheet(); + if (_defaultCssStylesheet && _defaultCssStylesheet.equal(stylesheet)) + return; + + _defaultCssStylesheet = stylesheet; + loadTheme(); +} + +/** + * getThemeStylesheet: + * + * Get the theme CSS file that the shell will load + * + * @returns {?Gio.File}: A #GFile that contains the theme CSS, + * null if using the default + */ +function getThemeStylesheet() { + return _cssStylesheet; +} + +/** + * setThemeStylesheet: + * @param {string=} cssStylesheet: A file path that contains the theme CSS, + * set it to null to use the default + * + * Set the theme CSS file that the shell will load + */ +function setThemeStylesheet(cssStylesheet) { + _cssStylesheet = cssStylesheet ? Gio.File.new_for_path(cssStylesheet) : null; +} + +function reloadThemeResource() { + if (_themeResource) + _themeResource._unregister(); + + _themeResource = Gio.Resource.load( + `${global.datadir}/${sessionMode.themeResourceName}`); + _themeResource._register(); +} + +/** @private */ +function _loadIcons() { + _iconResource = Gio.Resource.load(`${global.datadir}/gnome-shell-icons.gresource`); + _iconResource._register(); +} + +function _loadOskLayouts() { + _oskResource = Gio.Resource.load(`${global.datadir}/gnome-shell-osk-layouts.gresource`); + _oskResource._register(); +} + +/** + * loadTheme: + * + * Reloads the theme CSS file + */ +function loadTheme() { + let themeContext = St.ThemeContext.get_for_stage(global.stage); + let previousTheme = themeContext.get_theme(); + + let theme = new St.Theme({ + application_stylesheet: _cssStylesheet, + default_stylesheet: _defaultCssStylesheet, + }); + + if (theme.default_stylesheet == null) + throw new Error(`No valid stylesheet found for '${sessionMode.stylesheetName}'`); + + if (previousTheme) { + let customStylesheets = previousTheme.get_custom_stylesheets(); + + for (let i = 0; i < customStylesheets.length; i++) + theme.load_stylesheet(customStylesheets[i]); + } + + themeContext.set_theme(theme); +} + +/** + * notify: + * @param {string} msg: A message + * @param {string} details: Additional information + */ +function notify(msg, details) { + let source = new MessageTray.SystemNotificationSource(); + messageTray.add(source); + let notification = new MessageTray.Notification(source, msg, details); + notification.setTransient(true); + source.showNotification(notification); +} + +/** + * notifyError: + * @param {string} msg: An error message + * @param {string} details: Additional information + * + * See shell_global_notify_problem(). + */ +function notifyError(msg, details) { + // Also print to stderr so it's logged somewhere + if (details) + console.warn(`error: ${msg}: ${details}`); + else + console.warn(`error: ${msg}`); + + notify(msg, details); +} + +/** + * _findModal: + * + * @param {Clutter.Grab} grab - grab + * + * Private function. + * + */ +function _findModal(grab) { + for (let i = 0; i < modalActorFocusStack.length; i++) { + if (modalActorFocusStack[i].grab === grab) + return i; + } + return -1; +} + +/** + * pushModal: + * @param {Clutter.Actor} actor: actor which will be given keyboard focus + * @param {Object=} params: optional parameters + * + * Ensure we are in a mode where all keyboard and mouse input goes to + * the stage, and focus @actor. Multiple calls to this function act in + * a stacking fashion; the effect will be undone when an equal number + * of popModal() invocations have been made. + * + * Next, record the current Clutter keyboard focus on a stack. If the + * modal stack returns to this actor, reset the focus to the actor + * which was focused at the time pushModal() was invoked. + * + * @params may be used to provide the following parameters: + * - timestamp: used to associate the call with a specific user initiated + * event. If not provided then the value of + * global.get_current_time() is assumed. + * + * - options: Meta.ModalOptions flags to indicate that the pointer is + * already grabbed + * + * - actionMode: used to set the current Shell.ActionMode to filter + * global keybindings; the default of NONE will filter + * out all keybindings + * + * @returns {Clutter.Grab}: the grab handle created + */ +function pushModal(actor, params) { + params = Params.parse(params, { + timestamp: global.get_current_time(), + options: 0, + actionMode: Shell.ActionMode.NONE, + }); + + let grab = global.stage.grab(actor); + + if (modalCount === 0) + Meta.disable_unredirect_for_display(global.display); + + modalCount += 1; + let actorDestroyId = actor.connect('destroy', () => { + let index = _findModal(grab); + if (index >= 0) + popModal(grab); + }); + + let prevFocus = global.stage.get_key_focus(); + let prevFocusDestroyId; + if (prevFocus != null) { + prevFocusDestroyId = prevFocus.connect('destroy', () => { + const index = modalActorFocusStack.findIndex( + record => record.prevFocus === prevFocus); + + if (index >= 0) + modalActorFocusStack[index].prevFocus = null; + }); + } + modalActorFocusStack.push({ + actor, + grab, + destroyId: actorDestroyId, + prevFocus, + prevFocusDestroyId, + actionMode, + }); + + actionMode = params.actionMode; + global.stage.set_key_focus(actor); + return grab; +} + +/** + * popModal: + * @param {Clutter.Grab} grab - the grab given by pushModal() + * @param {number=} timestamp - optional timestamp + * + * Reverse the effect of pushModal(). If this invocation is undoing + * the topmost invocation, then the focus will be restored to the + * previous focus at the time when pushModal() was invoked. + * + * @timestamp is optionally used to associate the call with a specific user + * initiated event. If not provided then the value of + * global.get_current_time() is assumed. + */ +function popModal(grab, timestamp) { + if (timestamp == undefined) + timestamp = global.get_current_time(); + + let focusIndex = _findModal(grab); + if (focusIndex < 0) { + global.stage.set_key_focus(null); + actionMode = Shell.ActionMode.NORMAL; + + throw new Error('incorrect pop'); + } + + modalCount -= 1; + + let record = modalActorFocusStack[focusIndex]; + record.actor.disconnect(record.destroyId); + + record.grab.dismiss(); + + if (focusIndex == modalActorFocusStack.length - 1) { + if (record.prevFocus) + record.prevFocus.disconnect(record.prevFocusDestroyId); + actionMode = record.actionMode; + global.stage.set_key_focus(record.prevFocus); + } else { + // If we have: + // global.stage.set_focus(a); + // Main.pushModal(b); + // Main.pushModal(c); + // Main.pushModal(d); + // + // then we have the stack: + // [{ prevFocus: a, actor: b }, + // { prevFocus: b, actor: c }, + // { prevFocus: c, actor: d }] + // + // When actor c is destroyed/popped, if we only simply remove the + // record, then the focus stack will be [a, c], rather than the correct + // [a, b]. Shift the focus stack up before removing the record to ensure + // that we get the correct result. + let t = modalActorFocusStack[modalActorFocusStack.length - 1]; + if (t.prevFocus) + t.prevFocus.disconnect(t.prevFocusDestroyId); + // Remove from the middle, shift the focus chain up + for (let i = modalActorFocusStack.length - 1; i > focusIndex; i--) { + modalActorFocusStack[i].prevFocus = modalActorFocusStack[i - 1].prevFocus; + modalActorFocusStack[i].prevFocusDestroyId = modalActorFocusStack[i - 1].prevFocusDestroyId; + modalActorFocusStack[i].actionMode = modalActorFocusStack[i - 1].actionMode; + } + } + modalActorFocusStack.splice(focusIndex, 1); + + if (modalCount > 0) + return; + + layoutManager.modalEnded(); + Meta.enable_unredirect_for_display(global.display); + actionMode = Shell.ActionMode.NORMAL; +} + +function createLookingGlass() { + if (lookingGlass == null) + lookingGlass = new LookingGlass.LookingGlass(); + + return lookingGlass; +} + +function openRunDialog() { + if (runDialog == null) + runDialog = new RunDialog.RunDialog(); + + runDialog.open(); +} + +function openWelcomeDialog() { + if (welcomeDialog === null) + welcomeDialog = new WelcomeDialog.WelcomeDialog(); + + welcomeDialog.open(); +} + +/** + * activateWindow: + * @param {Meta.Window} window: the window to activate + * @param {number=} time: current event time + * @param {number=} workspaceNum: window's workspace number + * + * Activates @window, switching to its workspace first if necessary, + * and switching out of the overview if it's currently active + */ +function activateWindow(window, time, workspaceNum) { + let workspaceManager = global.workspace_manager; + let activeWorkspaceNum = workspaceManager.get_active_workspace_index(); + let windowWorkspaceNum = workspaceNum !== undefined ? workspaceNum : window.get_workspace().index(); + + if (!time) + time = global.get_current_time(); + + if (windowWorkspaceNum != activeWorkspaceNum) { + let workspace = workspaceManager.get_workspace_by_index(windowWorkspaceNum); + workspace.activate_with_focus(window, time); + } else { + window.activate(time); + } + + overview.hide(); + panel.closeCalendar(); +} + +/** + * Move @window to the specified monitor and workspace. + * + * @param {Meta.Window} window - the window to move + * @param {number} monitorIndex - the requested monitor + * @param {number} workspaceIndex - the requested workspace + * @param {bool} append - create workspace if it doesn't exist + */ +function moveWindowToMonitorAndWorkspace(window, monitorIndex, workspaceIndex, append = false) { + // We need to move the window before changing the workspace, because + // the move itself could cause a workspace change if the window enters + // the primary monitor + if (window.get_monitor() !== monitorIndex) { + // Wait for the monitor change to take effect + const id = global.display.connect('window-entered-monitor', + (dsp, num, w) => { + if (w !== window) + return; + window.change_workspace_by_index(workspaceIndex, append); + global.display.disconnect(id); + }); + window.move_to_monitor(monitorIndex); + } else { + window.change_workspace_by_index(workspaceIndex, append); + } +} + +// TODO - replace this timeout with some system to guess when the user might +// be e.g. just reading the screen and not likely to interact. +var DEFERRED_TIMEOUT_SECONDS = 20; +var _deferredWorkData = {}; +// Work scheduled for some point in the future +var _deferredWorkQueue = []; +// Work we need to process before the next redraw +var _beforeRedrawQueue = []; +// Counter to assign work ids +var _deferredWorkSequence = 0; +var _deferredTimeoutId = 0; + +function _runDeferredWork(workId) { + if (!_deferredWorkData[workId]) + return; + let index = _deferredWorkQueue.indexOf(workId); + if (index < 0) + return; + + _deferredWorkQueue.splice(index, 1); + _deferredWorkData[workId].callback(); + if (_deferredWorkQueue.length == 0 && _deferredTimeoutId > 0) { + GLib.source_remove(_deferredTimeoutId); + _deferredTimeoutId = 0; + } +} + +function _runAllDeferredWork() { + while (_deferredWorkQueue.length > 0) + _runDeferredWork(_deferredWorkQueue[0]); +} + +function _runBeforeRedrawQueue() { + for (let i = 0; i < _beforeRedrawQueue.length; i++) { + let workId = _beforeRedrawQueue[i]; + _runDeferredWork(workId); + } + _beforeRedrawQueue = []; +} + +function _queueBeforeRedraw(workId) { + _beforeRedrawQueue.push(workId); + if (_beforeRedrawQueue.length == 1) { + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + _runBeforeRedrawQueue(); + return false; + }); + } +} + +/** + * initializeDeferredWork: + * @param {Clutter.Actor} actor: an actor + * @param {callback} callback: Function to invoke to perform work + * + * This function sets up a callback to be invoked when either the + * given actor is mapped, or after some period of time when the machine + * is idle. This is useful if your actor isn't always visible on the + * screen (for example, all actors in the overview), and you don't want + * to consume resources updating if the actor isn't actually going to be + * displaying to the user. + * + * Note that queueDeferredWork is called by default immediately on + * initialization as well, under the assumption that new actors + * will need it. + * + * @returns {string}: A string work identifier + */ +function initializeDeferredWork(actor, callback) { + // Turn into a string so we can use as an object property + let workId = `${++_deferredWorkSequence}`; + _deferredWorkData[workId] = { + actor, + callback, + }; + actor.connect('notify::mapped', () => { + if (!(actor.mapped && _deferredWorkQueue.includes(workId))) + return; + _queueBeforeRedraw(workId); + }); + actor.connect('destroy', () => { + let index = _deferredWorkQueue.indexOf(workId); + if (index >= 0) + _deferredWorkQueue.splice(index, 1); + delete _deferredWorkData[workId]; + }); + queueDeferredWork(workId); + return workId; +} + +/** + * queueDeferredWork: + * @param {string} workId: work identifier + * + * Ensure that the work identified by @workId will be + * run on map or timeout. You should call this function + * for example when data being displayed by the actor has + * changed. + */ +function queueDeferredWork(workId) { + let data = _deferredWorkData[workId]; + if (!data) { + let message = `Invalid work id ${workId}`; + logError(new Error(message), message); + return; + } + if (!_deferredWorkQueue.includes(workId)) + _deferredWorkQueue.push(workId); + if (data.actor.mapped) { + _queueBeforeRedraw(workId); + } else if (_deferredTimeoutId == 0) { + _deferredTimeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, DEFERRED_TIMEOUT_SECONDS, () => { + _runAllDeferredWork(); + _deferredTimeoutId = 0; + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(_deferredTimeoutId, '[gnome-shell] _runAllDeferredWork'); + } +} + +var RestartMessage = GObject.registerClass( +class RestartMessage extends ModalDialog.ModalDialog { + _init(message) { + super._init({ + shellReactive: true, + styleClass: 'restart-message headline', + shouldFadeIn: false, + destroyOnClose: true, + }); + + let label = new St.Label({ + text: message, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + + this.contentLayout.add_child(label); + this.buttonLayout.hide(); + } +}); + +function showRestartMessage(message) { + let restartMessage = new RestartMessage(message); + restartMessage.open(); +} + +var AnimationsSettings = class { + constructor() { + let backend = global.backend; + if (!backend.is_rendering_hardware_accelerated()) { + St.Settings.get().inhibit_animations(); + return; + } + + let isXvnc = Shell.util_has_x11_display_extension( + global.display, 'VNC-EXTENSION'); + if (isXvnc) { + St.Settings.get().inhibit_animations(); + return; + } + + let remoteAccessController = backend.get_remote_access_controller(); + if (!remoteAccessController) + return; + + this._handles = new Set(); + remoteAccessController.connect('new-handle', + (_, handle) => this._onNewRemoteAccessHandle(handle)); + } + + _onRemoteAccessHandleStopped(handle) { + let settings = St.Settings.get(); + + settings.uninhibit_animations(); + this._handles.delete(handle); + } + + _onNewRemoteAccessHandle(handle) { + if (!handle.get_disable_animations()) + return; + + let settings = St.Settings.get(); + + settings.inhibit_animations(); + this._handles.add(handle); + handle.connect('stopped', this._onRemoteAccessHandleStopped.bind(this)); + } +}; diff --git a/js/ui/messageList.js b/js/ui/messageList.js new file mode 100644 index 0000000..c910ca7 --- /dev/null +++ b/js/ui/messageList.js @@ -0,0 +1,760 @@ +/* exported MessageListSection */ +const { + Atk, Clutter, Gio, GLib, GObject, Graphene, Meta, Pango, St, +} = imports.gi; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; + +const Util = imports.misc.util; + +var MESSAGE_ANIMATION_TIME = 100; + +var DEFAULT_EXPAND_LINES = 6; + +function _fixMarkup(text, allowMarkup) { + if (allowMarkup) { + // Support &, ", ', < 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://${url}`; + + Gio.app_info_launch_default_for_uri( + url, global.create_app_launch_context(0, -1)); + return Clutter.EVENT_STOP; + } + return Clutter.EVENT_PROPAGATE; + } + + vfunc_motion_event(motionEvent) { + if (!this.visible || this.get_paint_opacity() == 0) + return Clutter.EVENT_PROPAGATE; + + let urlId = this._findUrlAtPos(motionEvent); + if (urlId != -1 && !this._cursorChanged) { + global.display.set_cursor(Meta.Cursor.POINTING_HAND); + this._cursorChanged = true; + } else if (urlId == -1) { + global.display.set_cursor(Meta.Cursor.DEFAULT); + this._cursorChanged = false; + } + return Clutter.EVENT_PROPAGATE; + } + + vfunc_leave_event(crossingEvent) { + if (!this.visible || this.get_paint_opacity() == 0) + return Clutter.EVENT_PROPAGATE; + + if (this._cursorChanged) { + this._cursorChanged = false; + global.display.set_cursor(Meta.Cursor.DEFAULT); + } + return super.vfunc_leave_event(crossingEvent); + } + + setMarkup(text, allowMarkup) { + text = text ? _fixMarkup(text, allowMarkup) : ''; + this._text = text; + + this.clutter_text.set_markup(text); + /* clutter_text.text contain text without markup */ + this._urls = Util.findUrls(this.clutter_text.text); + this._highlightUrls(); + } + + _highlightUrls() { + // text here contain markup + let urls = Util.findUrls(this._text); + let markup = ''; + let pos = 0; + for (let i = 0; i < urls.length; i++) { + let url = urls[i]; + let str = this._text.substr(pos, url.pos - pos); + markup += `${str}<span foreground="${this._linkColor}"><u>${url.url}</u></span>`; + pos = url.pos + url.url.length; + } + markup += this._text.substr(pos); + this.clutter_text.set_markup(markup); + } + + _findUrlAtPos(event) { + let { x, y } = event; + [, x, y] = this.transform_stage_point(x, y); + let findPos = -1; + for (let i = 0; i < this.clutter_text.text.length; i++) { + let [, px, py, lineHeight] = this.clutter_text.position_to_coords(i); + if (py > y || py + lineHeight < y || x < px) + continue; + findPos = i; + } + if (findPos != -1) { + for (let i = 0; i < this._urls.length; i++) { + if (findPos >= this._urls[i].pos && + this._urls[i].pos + this._urls[i].url.length > findPos) + return i; + } + } + return -1; + } +}); + +var ScaleLayout = GObject.registerClass( +class ScaleLayout extends Clutter.BinLayout { + _init(params) { + this._container = null; + super._init(params); + } + + _connectContainer(container) { + if (this._container == container) + return; + + this._container?.disconnectObject(this); + + this._container = container; + + if (this._container) { + this._container.connectObject( + 'notify::scale-x', () => this.layout_changed(), + 'notify::scale-y', () => this.layout_changed(), this); + } + } + + vfunc_get_preferred_width(container, forHeight) { + this._connectContainer(container); + + let [min, nat] = super.vfunc_get_preferred_width(container, forHeight); + return [ + Math.floor(min * container.scale_x), + Math.floor(nat * container.scale_x), + ]; + } + + vfunc_get_preferred_height(container, forWidth) { + this._connectContainer(container); + + let [min, nat] = super.vfunc_get_preferred_height(container, forWidth); + return [ + Math.floor(min * container.scale_y), + Math.floor(nat * container.scale_y), + ]; + } +}); + +var LabelExpanderLayout = GObject.registerClass({ + Properties: { + 'expansion': GObject.ParamSpec.double('expansion', + 'Expansion', + 'Expansion of the layout, between 0 (collapsed) ' + + 'and 1 (fully expanded', + GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE, + 0, 1, 0), + }, +}, class LabelExpanderLayout extends Clutter.LayoutManager { + _init(params) { + this._expansion = 0; + this._expandLines = DEFAULT_EXPAND_LINES; + + super._init(params); + } + + get expansion() { + return this._expansion; + } + + set expansion(v) { + if (v == this._expansion) + return; + this._expansion = v; + this.notify('expansion'); + + let visibleIndex = this._expansion > 0 ? 1 : 0; + for (let i = 0; this._container && i < this._container.get_n_children(); i++) + this._container.get_child_at_index(i).visible = i == visibleIndex; + + this.layout_changed(); + } + + set expandLines(v) { + if (v == this._expandLines) + return; + this._expandLines = v; + if (this._expansion > 0) + this.layout_changed(); + } + + vfunc_set_container(container) { + this._container = container; + } + + vfunc_get_preferred_width(container, forHeight) { + let [min, nat] = [0, 0]; + + for (let i = 0; i < container.get_n_children(); i++) { + if (i > 1) + break; // we support one unexpanded + one expanded child + + let child = container.get_child_at_index(i); + let [childMin, childNat] = child.get_preferred_width(forHeight); + [min, nat] = [Math.max(min, childMin), Math.max(nat, childNat)]; + } + + return [min, nat]; + } + + vfunc_get_preferred_height(container, forWidth) { + let [min, nat] = [0, 0]; + + let children = container.get_children(); + if (children[0]) + [min, nat] = children[0].get_preferred_height(forWidth); + + if (children[1]) { + let [min2, nat2] = children[1].get_preferred_height(forWidth); + const [expMin, expNat] = [ + Math.min(min2, min * this._expandLines), + Math.min(nat2, nat * this._expandLines), + ]; + [min, nat] = [ + min + this._expansion * (expMin - min), + nat + this._expansion * (expNat - nat), + ]; + } + + return [min, nat]; + } + + vfunc_allocate(container, box) { + for (let i = 0; i < container.get_n_children(); i++) { + let child = container.get_child_at_index(i); + + if (child.visible) + child.allocate(box); + } + } +}); + + +var Message = GObject.registerClass({ + Signals: { + 'close': {}, + 'expanded': {}, + 'unexpanded': {}, + }, +}, class Message extends St.Button { + _init(title, body) { + super._init({ + style_class: 'message', + accessible_role: Atk.Role.NOTIFICATION, + can_focus: true, + x_expand: true, + y_expand: true, + }); + + this.expanded = false; + this._useBodyMarkup = false; + + let vbox = new St.BoxLayout({ + vertical: true, + x_expand: true, + }); + this.set_child(vbox); + + let hbox = new St.BoxLayout(); + vbox.add_actor(hbox); + + this._actionBin = new St.Widget({ + layout_manager: new ScaleLayout(), + visible: false, + }); + vbox.add_actor(this._actionBin); + + this._iconBin = new St.Bin({ + style_class: 'message-icon-bin', + y_expand: true, + y_align: Clutter.ActorAlign.START, + visible: false, + }); + hbox.add_actor(this._iconBin); + + const contentBox = new St.BoxLayout({ + style_class: 'message-content', + vertical: true, + x_expand: true, + }); + hbox.add_actor(contentBox); + + this._mediaControls = new St.BoxLayout(); + hbox.add_actor(this._mediaControls); + + let titleBox = new St.BoxLayout(); + contentBox.add_actor(titleBox); + + this.titleLabel = new St.Label({ style_class: 'message-title' }); + this.setTitle(title); + titleBox.add_actor(this.titleLabel); + + this._secondaryBin = new St.Bin({ + style_class: 'message-secondary-bin', + x_expand: true, y_expand: true, + }); + titleBox.add_actor(this._secondaryBin); + + this._closeButton = new St.Button({ + style_class: 'message-close-button', + icon_name: 'window-close-symbolic', + y_align: Clutter.ActorAlign.CENTER, + opacity: 0, + }); + titleBox.add_actor(this._closeButton); + + this._bodyStack = new St.Widget({ x_expand: true }); + this._bodyStack.layout_manager = new LabelExpanderLayout(); + contentBox.add_actor(this._bodyStack); + + this.bodyLabel = new URLHighlighter('', false, this._useBodyMarkup); + this.bodyLabel.add_style_class_name('message-body'); + this._bodyStack.add_actor(this.bodyLabel); + this.setBody(body); + + this._closeButton.connect('clicked', this.close.bind(this)); + let actorHoverId = this.connect('notify::hover', this._sync.bind(this)); + this._closeButton.connect('destroy', this.disconnect.bind(this, actorHoverId)); + this.connect('destroy', this._onDestroy.bind(this)); + this._sync(); + } + + close() { + this.emit('close'); + } + + setIcon(actor) { + this._iconBin.child = actor; + this._iconBin.visible = actor != null; + } + + setSecondaryActor(actor) { + this._secondaryBin.child = actor; + } + + setTitle(text) { + let title = text ? _fixMarkup(text.replace(/\n/g, ' '), false) : ''; + this.titleLabel.clutter_text.set_markup(title); + } + + setBody(text) { + this._bodyText = text; + this.bodyLabel.setMarkup(text ? text.replace(/\n/g, ' ') : '', + this._useBodyMarkup); + if (this._expandedLabel) + this._expandedLabel.setMarkup(text, this._useBodyMarkup); + } + + setUseBodyMarkup(enable) { + if (this._useBodyMarkup === enable) + return; + this._useBodyMarkup = enable; + if (this.bodyLabel) + this.setBody(this._bodyText); + } + + setActionArea(actor) { + if (actor == null) { + if (this._actionBin.get_n_children() > 0) + this._actionBin.get_child_at_index(0).destroy(); + return; + } + + if (this._actionBin.get_n_children() > 0) + throw new Error('Message already has an action area'); + + this._actionBin.add_actor(actor); + this._actionBin.visible = this.expanded; + } + + addMediaControl(iconName, callback) { + const button = new St.Button({ + style_class: 'message-media-control', + iconName, + }); + button.connect('clicked', callback); + this._mediaControls.add_actor(button); + return button; + } + + setExpandedBody(actor) { + if (actor == null) { + if (this._bodyStack.get_n_children() > 1) + this._bodyStack.get_child_at_index(1).destroy(); + return; + } + + if (this._bodyStack.get_n_children() > 1) + throw new Error('Message already has an expanded body actor'); + + this._bodyStack.insert_child_at_index(actor, 1); + } + + setExpandedLines(nLines) { + this._bodyStack.layout_manager.expandLines = nLines; + } + + expand(animate) { + this.expanded = true; + + this._actionBin.visible = this._actionBin.get_n_children() > 0; + + if (this._bodyStack.get_n_children() < 2) { + this._expandedLabel = new URLHighlighter(this._bodyText, + true, this._useBodyMarkup); + this.setExpandedBody(this._expandedLabel); + } + + if (animate) { + this._bodyStack.ease_property('@layout.expansion', 1, { + progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: MessageTray.ANIMATION_TIME, + }); + + this._actionBin.scale_y = 0; + this._actionBin.ease({ + scale_y: 1, + duration: MessageTray.ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } else { + this._bodyStack.layout_manager.expansion = 1; + this._actionBin.scale_y = 1; + } + + this.emit('expanded'); + } + + unexpand(animate) { + if (animate) { + this._bodyStack.ease_property('@layout.expansion', 0, { + progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: MessageTray.ANIMATION_TIME, + }); + + this._actionBin.ease({ + scale_y: 0, + duration: MessageTray.ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._actionBin.hide(); + this.expanded = false; + }, + }); + } else { + this._bodyStack.layout_manager.expansion = 0; + this._actionBin.scale_y = 0; + this.expanded = false; + } + + this.emit('unexpanded'); + } + + canClose() { + return false; + } + + _sync() { + let visible = this.hover && this.canClose(); + this._closeButton.opacity = visible ? 255 : 0; + this._closeButton.reactive = visible; + } + + _onDestroy() { + } + + vfunc_key_press_event(keyEvent) { + let keysym = keyEvent.keyval; + + if (keysym === Clutter.KEY_Delete || + keysym === Clutter.KEY_KP_Delete || + keysym === Clutter.KEY_BackSpace) { + if (this.canClose()) { + this.close(); + return Clutter.EVENT_STOP; + } + } + return super.vfunc_key_press_event(keyEvent); + } +}); + +var MessageListSection = GObject.registerClass({ + Properties: { + 'can-clear': GObject.ParamSpec.boolean( + 'can-clear', 'can-clear', 'can-clear', + GObject.ParamFlags.READABLE, + false), + 'empty': GObject.ParamSpec.boolean( + 'empty', 'empty', 'empty', + GObject.ParamFlags.READABLE, + true), + }, + Signals: { + 'can-clear-changed': {}, + 'empty-changed': {}, + 'message-focused': { param_types: [Message.$gtype] }, + }, +}, class MessageListSection extends St.BoxLayout { + _init() { + super._init({ + style_class: 'message-list-section', + clip_to_allocation: true, + vertical: true, + x_expand: true, + }); + + this._list = new St.BoxLayout({ + style_class: 'message-list-section-list', + vertical: true, + }); + this.add_actor(this._list); + + this._list.connect('actor-added', this._sync.bind(this)); + this._list.connect('actor-removed', this._sync.bind(this)); + + Main.sessionMode.connectObject( + 'updated', () => this._sync(), this); + + this._empty = true; + this._canClear = false; + this._sync(); + } + + get empty() { + return this._empty; + } + + get canClear() { + return this._canClear; + } + + get _messages() { + return this._list.get_children().map(i => i.child); + } + + _onKeyFocusIn(messageActor) { + this.emit('message-focused', messageActor); + } + + get allowed() { + return true; + } + + addMessage(message, animate) { + this.addMessageAtIndex(message, -1, animate); + } + + addMessageAtIndex(message, index, animate) { + if (this._messages.includes(message)) + throw new Error('Message was already added previously'); + + let listItem = new St.Bin({ + child: message, + layout_manager: new ScaleLayout(), + pivot_point: new Graphene.Point({ x: .5, y: .5 }), + }); + listItem._connectionsIds = []; + + listItem._connectionsIds.push(message.connect('key-focus-in', + this._onKeyFocusIn.bind(this))); + listItem._connectionsIds.push(message.connect('close', () => { + this.removeMessage(message, true); + })); + listItem._connectionsIds.push(message.connect('destroy', () => { + listItem._connectionsIds.forEach(id => message.disconnect(id)); + listItem.destroy(); + })); + + this._list.insert_child_at_index(listItem, index); + + if (animate) { + listItem.set({ scale_x: 0, scale_y: 0 }); + listItem.ease({ + scale_x: 1, + scale_y: 1, + duration: MESSAGE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + } + + moveMessage(message, index, animate) { + if (!this._messages.includes(message)) + throw new Error(`Impossible to move untracked message`); + + let listItem = message.get_parent(); + + if (!animate) { + this._list.set_child_at_index(listItem, index); + return; + } + + let onComplete = () => { + this._list.set_child_at_index(listItem, index); + listItem.ease({ + scale_x: 1, + scale_y: 1, + duration: MESSAGE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + }; + listItem.ease({ + scale_x: 0, + scale_y: 0, + duration: MESSAGE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete, + }); + } + + removeMessage(message, animate) { + const messages = this._messages; + + if (!messages.includes(message)) + throw new Error(`Impossible to remove untracked message`); + + let listItem = message.get_parent(); + listItem._connectionsIds.forEach(id => message.disconnect(id)); + + let nextMessage = null; + + if (message.has_key_focus()) { + const index = messages.indexOf(message); + nextMessage = + messages[index + 1] || + messages[index - 1] || + this._list; + } + + if (animate) { + listItem.ease({ + scale_x: 0, + scale_y: 0, + duration: MESSAGE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + listItem.destroy(); + nextMessage?.grab_key_focus(); + }, + }); + } else { + listItem.destroy(); + nextMessage?.grab_key_focus(); + } + } + + clear() { + let messages = this._messages.filter(msg => msg.canClose()); + + // If there are few messages, letting them all zoom out looks OK + if (messages.length < 2) { + messages.forEach(message => { + message.close(); + }); + } else { + // Otherwise we slide them out one by one, and then zoom them + // out "off-screen" in the end to smoothly shrink the parent + let delay = MESSAGE_ANIMATION_TIME / Math.max(messages.length, 5); + for (let i = 0; i < messages.length; i++) { + let message = messages[i]; + message.get_parent().ease({ + translation_x: this._list.width, + opacity: 0, + duration: MESSAGE_ANIMATION_TIME, + delay: i * delay, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => message.close(), + }); + } + } + } + + _shouldShow() { + return !this.empty; + } + + _sync() { + let messages = this._messages; + let empty = messages.length == 0; + + if (this._empty != empty) { + this._empty = empty; + this.notify('empty'); + } + + let canClear = messages.some(m => m.canClose()); + if (this._canClear != canClear) { + this._canClear = canClear; + this.notify('can-clear'); + } + + this.visible = this.allowed && this._shouldShow(); + } +}); diff --git a/js/ui/messageTray.js b/js/ui/messageTray.js new file mode 100644 index 0000000..1edd932 --- /dev/null +++ b/js/ui/messageTray.js @@ -0,0 +1,1423 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported NotificationPolicy, NotificationGenericPolicy, + NotificationApplicationPolicy, Source, SourceActor, + SystemNotificationSource, MessageTray */ + +const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; + +const Calendar = imports.ui.calendar; +const GnomeSession = imports.misc.gnomeSession; +const Layout = imports.ui.layout; +const Main = imports.ui.main; +const Params = imports.misc.params; +const SignalTracker = imports.misc.signalTracker; + +const SHELL_KEYBINDINGS_SCHEMA = 'org.gnome.shell.keybindings'; + +var ANIMATION_TIME = 200; +var NOTIFICATION_TIMEOUT = 4000; + +var HIDE_TIMEOUT = 200; +var LONGER_HIDE_TIMEOUT = 600; + +var MAX_NOTIFICATIONS_IN_QUEUE = 3; +var MAX_NOTIFICATIONS_PER_SOURCE = 3; +var MAX_NOTIFICATION_BUTTONS = 3; + +// We delay hiding of the tray if the mouse is within MOUSE_LEFT_ACTOR_THRESHOLD +// range from the point where it left the tray. +var MOUSE_LEFT_ACTOR_THRESHOLD = 20; + +var IDLE_TIME = 1000; + +var State = { + HIDDEN: 0, + SHOWING: 1, + SHOWN: 2, + HIDING: 3, +}; + +// These reasons are useful when we destroy the notifications received through +// the notification daemon. We use EXPIRED for notifications that we dismiss +// and the user did not interact with, DISMISSED for all other notifications +// that were destroyed as a result of a user action, SOURCE_CLOSED for the +// notifications that were requested to be destroyed by the associated source, +// and REPLACED for notifications that were destroyed as a consequence of a +// newer version having replaced them. +var NotificationDestroyedReason = { + EXPIRED: 1, + DISMISSED: 2, + SOURCE_CLOSED: 3, + REPLACED: 4, +}; + +// Message tray has its custom Urgency enumeration. LOW, NORMAL and CRITICAL +// urgency values map to the corresponding values for the notifications received +// through the notification daemon. HIGH urgency value is used for chats received +// through the Telepathy client. +var Urgency = { + LOW: 0, + NORMAL: 1, + HIGH: 2, + CRITICAL: 3, +}; + +// The privacy of the details of a notification. USER is for notifications which +// contain private information to the originating user account (for example, +// details of an e-mail they’ve received). SYSTEM is for notifications which +// contain information private to the physical system (for example, battery +// status) and hence the same for every user. This affects whether the content +// of a notification is shown on the lock screen. +var PrivacyScope = { + USER: 0, + SYSTEM: 1, +}; + +var FocusGrabber = class FocusGrabber { + constructor(actor) { + this._actor = actor; + this._prevKeyFocusActor = null; + this._focused = false; + } + + grabFocus() { + if (this._focused) + return; + + this._prevKeyFocusActor = global.stage.get_key_focus(); + + global.stage.connectObject('notify::key-focus', + this._focusActorChanged.bind(this), this); + + if (!this._actor.navigate_focus(null, St.DirectionType.TAB_FORWARD, false)) + this._actor.grab_key_focus(); + + this._focused = true; + } + + _focusUngrabbed() { + if (!this._focused) + return false; + + global.stage.disconnectObject(this); + + this._focused = false; + return true; + } + + _focusActorChanged() { + let focusedActor = global.stage.get_key_focus(); + if (!focusedActor || !this._actor.contains(focusedActor)) + this._focusUngrabbed(); + } + + ungrabFocus() { + if (!this._focusUngrabbed()) + return; + + if (this._prevKeyFocusActor) { + global.stage.set_key_focus(this._prevKeyFocusActor); + this._prevKeyFocusActor = null; + } else { + let focusedActor = global.stage.get_key_focus(); + if (focusedActor && this._actor.contains(focusedActor)) + global.stage.set_key_focus(null); + } + } +}; + +// NotificationPolicy: +// An object that holds all bits of configurable policy related to a notification +// source, such as whether to play sound or honour the critical bit. +// +// A notification without a policy object will inherit the default one. +var NotificationPolicy = GObject.registerClass({ + GTypeFlags: GObject.TypeFlags.ABSTRACT, + Properties: { + 'enable': GObject.ParamSpec.boolean( + 'enable', 'enable', 'enable', GObject.ParamFlags.READABLE, true), + 'enable-sound': GObject.ParamSpec.boolean( + 'enable-sound', 'enable-sound', 'enable-sound', + GObject.ParamFlags.READABLE, true), + 'show-banners': GObject.ParamSpec.boolean( + 'show-banners', 'show-banners', 'show-banners', + GObject.ParamFlags.READABLE, true), + 'force-expanded': GObject.ParamSpec.boolean( + 'force-expanded', 'force-expanded', 'force-expanded', + GObject.ParamFlags.READABLE, false), + 'show-in-lock-screen': GObject.ParamSpec.boolean( + 'show-in-lock-screen', 'show-in-lock-screen', 'show-in-lock-screen', + GObject.ParamFlags.READABLE, false), + 'details-in-lock-screen': GObject.ParamSpec.boolean( + 'details-in-lock-screen', 'details-in-lock-screen', 'details-in-lock-screen', + GObject.ParamFlags.READABLE, false), + }, +}, class NotificationPolicy extends GObject.Object { + // Do nothing for the default policy. These methods are only useful for the + // GSettings policy. + store() { } + + destroy() { + this.run_dispose(); + } + + get enable() { + return true; + } + + get enableSound() { + return true; + } + + get showBanners() { + return true; + } + + get forceExpanded() { + return false; + } + + get showInLockScreen() { + return false; + } + + get detailsInLockScreen() { + return false; + } +}); + +var NotificationGenericPolicy = GObject.registerClass({ +}, class NotificationGenericPolicy extends NotificationPolicy { + _init() { + super._init(); + this.id = 'generic'; + + this._masterSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.notifications' }); + this._masterSettings.connect('changed', this._changed.bind(this)); + } + + destroy() { + this._masterSettings.run_dispose(); + + super.destroy(); + } + + _changed(settings, key) { + if (this.constructor.find_property(key)) + this.notify(key); + } + + get showBanners() { + return this._masterSettings.get_boolean('show-banners'); + } + + get showInLockScreen() { + return this._masterSettings.get_boolean('show-in-lock-screen'); + } +}); + +var NotificationApplicationPolicy = GObject.registerClass({ +}, class NotificationApplicationPolicy extends NotificationPolicy { + _init(id) { + super._init(); + + this.id = id; + this._canonicalId = this._canonicalizeId(id); + + this._masterSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.notifications' }); + this._settings = new Gio.Settings({ + schema_id: 'org.gnome.desktop.notifications.application', + path: `/org/gnome/desktop/notifications/application/${this._canonicalId}/`, + }); + + this._masterSettings.connect('changed', this._changed.bind(this)); + this._settings.connect('changed', this._changed.bind(this)); + } + + store() { + this._settings.set_string('application-id', `${this.id}.desktop`); + + let apps = this._masterSettings.get_strv('application-children'); + if (!apps.includes(this._canonicalId)) { + apps.push(this._canonicalId); + this._masterSettings.set_strv('application-children', apps); + } + } + + destroy() { + this._masterSettings.run_dispose(); + this._settings.run_dispose(); + + super.destroy(); + } + + _changed(settings, key) { + if (this.constructor.find_property(key)) + this.notify(key); + } + + _canonicalizeId(id) { + // Keys are restricted to lowercase alphanumeric characters and dash, + // and two dashes cannot be in succession + return id.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/--+/g, '-'); + } + + get enable() { + return this._settings.get_boolean('enable'); + } + + get enableSound() { + return this._settings.get_boolean('enable-sound-alerts'); + } + + get showBanners() { + return this._masterSettings.get_boolean('show-banners') && + this._settings.get_boolean('show-banners'); + } + + get forceExpanded() { + return this._settings.get_boolean('force-expanded'); + } + + get showInLockScreen() { + return this._masterSettings.get_boolean('show-in-lock-screen') && + this._settings.get_boolean('show-in-lock-screen'); + } + + get detailsInLockScreen() { + return this._settings.get_boolean('details-in-lock-screen'); + } +}); + +// Notification: +// @source: the notification's Source +// @title: the title +// @banner: the banner text +// @params: optional additional params +// +// Creates a notification. In the banner mode, the notification +// will show an icon, @title (in bold) and @banner, all on a single +// line (with @banner ellipsized if necessary). +// +// The notification will be expandable if either it has additional +// elements that were added to it or if the @banner text did not +// fit fully in the banner mode. When the notification is expanded, +// the @banner text from the top line is always removed. The complete +// @banner text is added as the first element in the content section, +// unless 'customContent' parameter with the value 'true' is specified +// in @params. +// +// Additional notification content can be added with addActor() and +// addBody() methods. The notification content is put inside a +// scrollview, so if it gets too tall, the notification will scroll +// rather than continue to grow. In addition to this main content +// area, there is also a single-row action area, which is not +// scrolled and can contain a single actor. The action area can +// be set by calling setActionArea() method. There is also a +// convenience method addButton() for adding a button to the action +// area. +// +// If @params contains a 'customContent' parameter with the value %true, +// then @banner will not be shown in the body of the notification when the +// notification is expanded and calls to update() will not clear the content +// unless 'clear' parameter with value %true is explicitly specified. +// +// By default, the icon shown is the same as the source's. +// However, if @params contains a 'gicon' parameter, the passed in gicon +// will be used. +// +// You can add a secondary icon to the banner with 'secondaryGIcon'. There +// is no fallback for this icon. +// +// If @params contains 'bannerMarkup', with the value %true, a subset (<b>, +// <i> and <u>) of the markup in [1] will be interpreted within @banner. If +// the parameter is not present, then anything that looks like markup +// in @banner will appear literally in the output. +// +// If @params contains a 'clear' parameter with the value %true, then +// the content and the action area of the notification will be cleared. +// The content area is also always cleared if 'customContent' is false +// because it might contain the @banner that didn't fit in the banner mode. +// +// If @params contains 'soundName' or 'soundFile', the corresponding +// event sound is played when the notification is shown (if the policy for +// @source allows playing sounds). +// +// [1] https://developer.gnome.org/notification-spec/#markup +var Notification = GObject.registerClass({ + Properties: { + 'acknowledged': GObject.ParamSpec.boolean( + 'acknowledged', 'acknowledged', 'acknowledged', + GObject.ParamFlags.READWRITE, + false), + }, + Signals: { + 'activated': {}, + 'destroy': { param_types: [GObject.TYPE_UINT] }, + 'updated': { param_types: [GObject.TYPE_BOOLEAN] }, + }, +}, class Notification extends GObject.Object { + _init(source, title, banner, params) { + super._init(); + + this.source = source; + this.title = title; + this.urgency = Urgency.NORMAL; + // 'transient' is a reserved keyword in JS, so we have to use an alternate variable name + this.isTransient = false; + this.privacyScope = PrivacyScope.USER; + this.forFeedback = false; + this.bannerBodyText = null; + this.bannerBodyMarkup = false; + this._soundName = null; + this._soundFile = null; + this._soundPlayed = false; + this.actions = []; + this.setResident(false); + + // If called with only one argument we assume the caller + // will call .update() later on. This is the case of + // NotificationDaemon, which wants to use the same code + // for new and updated notifications + if (arguments.length != 1) + this.update(title, banner, params); + } + + // update: + // @title: the new title + // @banner: the new banner + // @params: as in the Notification constructor + // + // Updates the notification by regenerating its icon and updating + // the title/banner. If @params.clear is %true, it will also + // remove any additional actors/action buttons previously added. + update(title, banner, params) { + params = Params.parse(params, { + gicon: null, + secondaryGIcon: null, + bannerMarkup: false, + clear: false, + datetime: null, + soundName: null, + soundFile: null, + }); + + this.title = title; + this.bannerBodyText = banner; + this.bannerBodyMarkup = params.bannerMarkup; + + if (params.datetime) + this.datetime = params.datetime; + else + this.datetime = GLib.DateTime.new_now_local(); + + if (params.gicon || params.clear) + this.gicon = params.gicon; + + if (params.secondaryGIcon || params.clear) + this.secondaryGIcon = params.secondaryGIcon; + + if (params.clear) + this.actions = []; + + if (this._soundName != params.soundName || + this._soundFile != params.soundFile) { + this._soundName = params.soundName; + this._soundFile = params.soundFile; + this._soundPlayed = false; + } + + this.emit('updated', params.clear); + } + + // addAction: + // @label: the label for the action's button + // @callback: the callback for the action + addAction(label, callback) { + this.actions.push({ label, callback }); + } + + setUrgency(urgency) { + this.urgency = urgency; + } + + setResident(resident) { + this.resident = resident; + } + + setTransient(isTransient) { + this.isTransient = isTransient; + } + + setForFeedback(forFeedback) { + this.forFeedback = forFeedback; + } + + setPrivacyScope(privacyScope) { + this.privacyScope = privacyScope; + } + + playSound() { + if (this._soundPlayed) + return; + + if (!this.source.policy.enableSound) { + this._soundPlayed = true; + return; + } + + let player = global.display.get_sound_player(); + if (this._soundName) + player.play_from_theme(this._soundName, this.title, null); + else if (this._soundFile) + player.play_from_file(this._soundFile, this.title, null); + } + + // Allow customizing the banner UI: + // the default implementation defers the creation to + // the source (which will create a NotificationBanner), + // so customization can be done by subclassing either + // Notification or Source + createBanner() { + return this.source.createBanner(this); + } + + activate() { + this.emit('activated'); + + if (!this.resident) + this.destroy(); + } + + destroy(reason = NotificationDestroyedReason.DISMISSED) { + this.emit('destroy', reason); + this.run_dispose(); + } +}); +SignalTracker.registerDestroyableType(Notification); + +var NotificationBanner = GObject.registerClass({ + Signals: { + 'done-displaying': {}, + 'unfocused': {}, + }, +}, class NotificationBanner extends Calendar.NotificationMessage { + _init(notification) { + super._init(notification); + + this.can_focus = false; + this.add_style_class_name('notification-banner'); + + this._buttonBox = null; + + this._addActions(); + this._addSecondaryIcon(); + + this.notification.connectObject('activated', () => { + // We hide all types of notifications once the user clicks on + // them because the common outcome of clicking should be the + // relevant window being brought forward and the user's + // attention switching to the window. + this.emit('done-displaying'); + }, this); + } + + _onUpdated(n, clear) { + super._onUpdated(n, clear); + + if (clear) { + this.setSecondaryActor(null); + this.setActionArea(null); + this._buttonBox = null; + } + + this._addActions(); + this._addSecondaryIcon(); + } + + _addActions() { + this.notification.actions.forEach(action => { + this.addAction(action.label, action.callback); + }); + } + + _addSecondaryIcon() { + if (this.notification.secondaryGIcon) { + const icon = new St.Icon({ + gicon: this.notification.secondaryGIcon, + x_align: Clutter.ActorAlign.END, + }); + this.setSecondaryActor(icon); + } + } + + addButton(button, callback) { + if (!this._buttonBox) { + this._buttonBox = new St.BoxLayout({ + style_class: 'notification-actions', + x_expand: true, + }); + this.setActionArea(this._buttonBox); + global.focus_manager.add_group(this._buttonBox); + } + + if (this._buttonBox.get_n_children() >= MAX_NOTIFICATION_BUTTONS) + return null; + + this._buttonBox.add(button); + button.connect('clicked', () => { + callback(); + + if (!this.notification.resident) { + // We don't hide a resident notification when the user invokes one of its actions, + // because it is common for such notifications to update themselves with new + // information based on the action. We'd like to display the updated information + // in place, rather than pop-up a new notification. + this.emit('done-displaying'); + this.notification.destroy(); + } + }); + + return button; + } + + addAction(label, callback) { + const button = new St.Button({ + style_class: 'notification-button', + label, + x_expand: true, + can_focus: true, + }); + + return this.addButton(button, callback); + } +}); + +var SourceActor = GObject.registerClass( +class SourceActor extends St.Widget { + _init(source, size) { + super._init(); + + this._source = source; + this._size = size; + + this.connect('destroy', + () => (this._actorDestroyed = true)); + this._actorDestroyed = false; + + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + this._iconBin = new St.Bin({ + x_expand: true, + height: size * scaleFactor, + width: size * scaleFactor, + }); + + this.add_actor(this._iconBin); + + this._source.connectObject('icon-updated', + this._updateIcon.bind(this), this); + this._updateIcon(); + } + + setIcon(icon) { + this._iconBin.child = icon; + this._iconSet = true; + } + + _updateIcon() { + if (this._actorDestroyed) + return; + + if (!this._iconSet) + this._iconBin.child = this._source.createIcon(this._size); + } +}); + +var Source = GObject.registerClass({ + Properties: { + 'count': GObject.ParamSpec.int( + 'count', 'count', 'count', + GObject.ParamFlags.READABLE, + 0, GLib.MAXINT32, 0), + 'policy': GObject.ParamSpec.object( + 'policy', 'policy', 'policy', + GObject.ParamFlags.READWRITE, + NotificationPolicy.$gtype), + 'title': GObject.ParamSpec.string( + 'title', 'title', 'title', + GObject.ParamFlags.READWRITE, + null), + }, + Signals: { + 'destroy': { param_types: [GObject.TYPE_UINT] }, + 'icon-updated': {}, + 'notification-added': { param_types: [Notification.$gtype] }, + 'notification-show': { param_types: [Notification.$gtype] }, + }, +}, class Source extends GObject.Object { + _init(title, iconName) { + super._init({ title }); + + this.SOURCE_ICON_SIZE = 48; + + this.iconName = iconName; + + this.isChat = false; + + this.notifications = []; + + this._policy = this._createPolicy(); + } + + get policy() { + return this._policy; + } + + set policy(policy) { + if (this._policy) + this._policy.destroy(); + this._policy = policy; + } + + get count() { + return this.notifications.length; + } + + get unseenCount() { + return this.notifications.filter(n => !n.acknowledged).length; + } + + get countVisible() { + return this.count > 1; + } + + countUpdated() { + this.notify('count'); + } + + _createPolicy() { + return new NotificationGenericPolicy(); + } + + get narrowestPrivacyScope() { + return this.notifications.every(n => n.privacyScope == PrivacyScope.SYSTEM) + ? PrivacyScope.SYSTEM + : PrivacyScope.USER; + } + + setTitle(newTitle) { + if (this.title == newTitle) + return; + + this.title = newTitle; + this.notify('title'); + } + + createBanner(notification) { + return new NotificationBanner(notification); + } + + // Called to create a new icon actor. + // Provides a sane default implementation, override if you need + // something more fancy. + createIcon(size) { + return new St.Icon({ + gicon: this.getIcon(), + icon_size: size, + }); + } + + getIcon() { + return new Gio.ThemedIcon({ name: this.iconName }); + } + + _onNotificationDestroy(notification) { + let index = this.notifications.indexOf(notification); + if (index < 0) + return; + + this.notifications.splice(index, 1); + this.countUpdated(); + + if (this.notifications.length == 0) + this.destroy(); + } + + pushNotification(notification) { + if (this.notifications.includes(notification)) + return; + + while (this.notifications.length >= MAX_NOTIFICATIONS_PER_SOURCE) + this.notifications.shift().destroy(NotificationDestroyedReason.EXPIRED); + + notification.connect('destroy', this._onNotificationDestroy.bind(this)); + notification.connect('notify::acknowledged', this.countUpdated.bind(this)); + this.notifications.push(notification); + this.emit('notification-added', notification); + + this.countUpdated(); + } + + showNotification(notification) { + notification.acknowledged = false; + this.pushNotification(notification); + + if (notification.urgency === Urgency.LOW) + return; + + if (this.policy.showBanners || notification.urgency == Urgency.CRITICAL) + this.emit('notification-show', notification); + } + + destroy(reason) { + let notifications = this.notifications; + this.notifications = []; + + for (let i = 0; i < notifications.length; i++) + notifications[i].destroy(reason); + + this.emit('destroy', reason); + + this.policy.destroy(); + this.run_dispose(); + } + + iconUpdated() { + this.emit('icon-updated'); + } + + // To be overridden by subclasses + open() { + } + + destroyNonResidentNotifications() { + for (let i = this.notifications.length - 1; i >= 0; i--) { + if (!this.notifications[i].resident) + this.notifications[i].destroy(); + } + } +}); +SignalTracker.registerDestroyableType(Source); + +var MessageTray = GObject.registerClass({ + Signals: { + 'queue-changed': {}, + 'source-added': { param_types: [Source.$gtype] }, + 'source-removed': { param_types: [Source.$gtype] }, + }, +}, class MessageTray extends St.Widget { + _init() { + super._init({ + visible: false, + clip_to_allocation: true, + layout_manager: new Clutter.BinLayout(), + }); + + this._presence = new GnomeSession.Presence((proxy, _error) => { + this._onStatusChanged(proxy.status); + }); + this._busy = false; + this._bannerBlocked = false; + this._presence.connectSignal('StatusChanged', (proxy, senderName, [status]) => { + this._onStatusChanged(status); + }); + + let constraint = new Layout.MonitorConstraint({ primary: true }); + Main.layoutManager.panelBox.bind_property('visible', + constraint, 'work-area', + GObject.BindingFlags.SYNC_CREATE); + this.add_constraint(constraint); + + this._bannerBin = new St.Widget({ + name: 'notification-container', + reactive: true, + track_hover: true, + y_align: Clutter.ActorAlign.START, + x_align: Clutter.ActorAlign.CENTER, + y_expand: true, + x_expand: true, + layout_manager: new Clutter.BinLayout(), + }); + this._bannerBin.connect('key-release-event', + this._onNotificationKeyRelease.bind(this)); + this._bannerBin.connect('notify::hover', + this._onNotificationHoverChanged.bind(this)); + this.add_actor(this._bannerBin); + + this._notificationFocusGrabber = new FocusGrabber(this._bannerBin); + this._notificationQueue = []; + this._notification = null; + this._banner = null; + + this._userActiveWhileNotificationShown = false; + + this.idleMonitor = global.backend.get_core_idle_monitor(); + + this._useLongerNotificationLeftTimeout = false; + + // pointerInNotification is sort of a misnomer -- it tracks whether + // a message tray notification should expand. The value is + // partially driven by the hover state of the notification, but has + // a lot of complex state related to timeouts and the current + // state of the pointer when a notification pops up. + this._pointerInNotification = false; + + // This tracks this._bannerBin.hover and is used to fizzle + // out non-changing hover notifications in onNotificationHoverChanged. + this._notificationHovered = false; + + this._notificationState = State.HIDDEN; + this._notificationTimeoutId = 0; + this._notificationRemoved = false; + + Main.layoutManager.addChrome(this, { affectsInputRegion: false }); + Main.layoutManager.trackChrome(this._bannerBin, { affectsInputRegion: true }); + + global.display.connect('in-fullscreen-changed', this._updateState.bind(this)); + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + + Main.overview.connect('window-drag-begin', + this._onDragBegin.bind(this)); + Main.overview.connect('window-drag-cancelled', + this._onDragEnd.bind(this)); + Main.overview.connect('window-drag-end', + this._onDragEnd.bind(this)); + + Main.overview.connect('item-drag-begin', + this._onDragBegin.bind(this)); + Main.overview.connect('item-drag-cancelled', + this._onDragEnd.bind(this)); + Main.overview.connect('item-drag-end', + this._onDragEnd.bind(this)); + + Main.xdndHandler.connect('drag-begin', + this._onDragBegin.bind(this)); + Main.xdndHandler.connect('drag-end', + this._onDragEnd.bind(this)); + + Main.wm.addKeybinding('focus-active-notification', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.NONE, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._expandActiveNotification.bind(this)); + + this._sources = new Set(); + + this._sessionUpdated(); + } + + _sessionUpdated() { + this._updateState(); + } + + _onDragBegin() { + Shell.util_set_hidden_from_pick(this, true); + } + + _onDragEnd() { + Shell.util_set_hidden_from_pick(this, false); + } + + get bannerAlignment() { + return this._bannerBin.get_x_align(); + } + + set bannerAlignment(align) { + this._bannerBin.set_x_align(align); + } + + _onNotificationKeyRelease(actor, event) { + if (event.get_key_symbol() == Clutter.KEY_Escape && event.get_state() == 0) { + this._expireNotification(); + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_PROPAGATE; + } + + _expireNotification() { + this._notificationExpired = true; + this._updateState(); + } + + get queueCount() { + return this._notificationQueue.length; + } + + set bannerBlocked(v) { + if (this._bannerBlocked == v) + return; + this._bannerBlocked = v; + this._updateState(); + } + + contains(source) { + return this._sources.has(source); + } + + add(source) { + if (this.contains(source)) { + log(`Trying to re-add source ${source.title}`); + return; + } + + // Register that we got a notification for this source + source.policy.store(); + + source.policy.connect('notify::enable', () => { + this._onSourceEnableChanged(source.policy, source); + }); + source.policy.connect('notify', this._updateState.bind(this)); + this._onSourceEnableChanged(source.policy, source); + } + + _addSource(source) { + this._sources.add(source); + + source.connectObject( + 'notification-show', this._onNotificationShow.bind(this), + 'destroy', () => this._removeSource(source), this); + + this.emit('source-added', source); + } + + _removeSource(source) { + this._sources.delete(source); + source.disconnectObject(this); + this.emit('source-removed', source); + } + + getSources() { + return [...this._sources.keys()]; + } + + _onSourceEnableChanged(policy, source) { + let wasEnabled = this.contains(source); + let shouldBeEnabled = policy.enable; + + if (wasEnabled != shouldBeEnabled) { + if (shouldBeEnabled) + this._addSource(source); + else + this._removeSource(source); + } + } + + _onNotificationDestroy(notification) { + if (this._notification === notification) { + this._notificationRemoved = true; + if (this._notificationState === State.SHOWN || + this._notificationState === State.SHOWING) { + this._updateNotificationTimeout(0); + this._updateState(); + } + } else { + const index = this._notificationQueue.indexOf(notification); + if (index !== -1) { + this._notificationQueue.splice(index, 1); + this.emit('queue-changed'); + } + } + } + + _onNotificationShow(_source, notification) { + if (this._notification == notification) { + // If a notification that is being shown is updated, we update + // how it is shown and extend the time until it auto-hides. + // If a new notification is updated while it is being hidden, + // we stop hiding it and show it again. + this._updateShowingNotification(); + } else if (!this._notificationQueue.includes(notification)) { + // If the queue is "full", we skip banner mode and just show a small + // indicator in the panel; however do make an exception for CRITICAL + // notifications, as only banner mode allows expansion. + let bannerCount = this._notification ? 1 : 0; + let full = this.queueCount + bannerCount >= MAX_NOTIFICATIONS_IN_QUEUE; + if (!full || notification.urgency == Urgency.CRITICAL) { + notification.connect('destroy', + this._onNotificationDestroy.bind(this)); + this._notificationQueue.push(notification); + this._notificationQueue.sort( + (n1, n2) => n2.urgency - n1.urgency); + this.emit('queue-changed'); + } + } + this._updateState(); + } + + _resetNotificationLeftTimeout() { + this._useLongerNotificationLeftTimeout = false; + if (this._notificationLeftTimeoutId) { + GLib.source_remove(this._notificationLeftTimeoutId); + this._notificationLeftTimeoutId = 0; + this._notificationLeftMouseX = -1; + this._notificationLeftMouseY = -1; + } + } + + _onNotificationHoverChanged() { + if (this._bannerBin.hover == this._notificationHovered) + return; + + this._notificationHovered = this._bannerBin.hover; + if (this._notificationHovered) { + this._resetNotificationLeftTimeout(); + + if (this._showNotificationMouseX >= 0) { + let actorAtShowNotificationPosition = + global.stage.get_actor_at_pos(Clutter.PickMode.ALL, this._showNotificationMouseX, this._showNotificationMouseY); + this._showNotificationMouseX = -1; + this._showNotificationMouseY = -1; + // Don't set this._pointerInNotification to true if the pointer was initially in the area where the notification + // popped up. That way we will not be expanding notifications that happen to pop up over the pointer + // automatically. Instead, the user is able to expand the notification by mousing away from it and then + // mousing back in. Because this is an expected action, we set the boolean flag that indicates that a longer + // timeout should be used before popping down the notification. + if (this._bannerBin.contains(actorAtShowNotificationPosition)) { + this._useLongerNotificationLeftTimeout = true; + return; + } + } + + this._pointerInNotification = true; + this._updateState(); + } else { + // We record the position of the mouse the moment it leaves the tray. These coordinates are used in + // this._onNotificationLeftTimeout() to determine if the mouse has moved far enough during the initial timeout for us + // to consider that the user intended to leave the tray and therefore hide the tray. If the mouse is still + // close to its previous position, we extend the timeout once. + let [x, y] = global.get_pointer(); + this._notificationLeftMouseX = x; + this._notificationLeftMouseY = y; + + // We wait just a little before hiding the message tray in case the user quickly moves the mouse back into it. + // We wait for a longer period if the notification popped up where the mouse pointer was already positioned. + // That gives the user more time to mouse away from the notification and mouse back in in order to expand it. + let timeout = this._useLongerNotificationLeftTimeout ? LONGER_HIDE_TIMEOUT : HIDE_TIMEOUT; + this._notificationLeftTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, timeout, this._onNotificationLeftTimeout.bind(this)); + GLib.Source.set_name_by_id(this._notificationLeftTimeoutId, '[gnome-shell] this._onNotificationLeftTimeout'); + } + } + + _onStatusChanged(status) { + if (status == GnomeSession.PresenceStatus.BUSY) { + // remove notification and allow the summary to be closed now + this._updateNotificationTimeout(0); + this._busy = true; + } else if (status != GnomeSession.PresenceStatus.IDLE) { + // We preserve the previous value of this._busy if the status turns to IDLE + // so that we don't start showing notifications queued during the BUSY state + // as the screensaver gets activated. + this._busy = false; + } + + this._updateState(); + } + + _onNotificationLeftTimeout() { + let [x, y] = global.get_pointer(); + // We extend the timeout once if the mouse moved no further than MOUSE_LEFT_ACTOR_THRESHOLD to either side. + if (this._notificationLeftMouseX > -1 && + y < this._notificationLeftMouseY + MOUSE_LEFT_ACTOR_THRESHOLD && + y > this._notificationLeftMouseY - MOUSE_LEFT_ACTOR_THRESHOLD && + x < this._notificationLeftMouseX + MOUSE_LEFT_ACTOR_THRESHOLD && + x > this._notificationLeftMouseX - MOUSE_LEFT_ACTOR_THRESHOLD) { + this._notificationLeftMouseX = -1; + this._notificationLeftTimeoutId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + LONGER_HIDE_TIMEOUT, + this._onNotificationLeftTimeout.bind(this)); + GLib.Source.set_name_by_id(this._notificationLeftTimeoutId, '[gnome-shell] this._onNotificationLeftTimeout'); + } else { + this._notificationLeftTimeoutId = 0; + this._useLongerNotificationLeftTimeout = false; + this._pointerInNotification = false; + this._updateNotificationTimeout(0); + this._updateState(); + } + return GLib.SOURCE_REMOVE; + } + + _escapeTray() { + this._pointerInNotification = false; + this._updateNotificationTimeout(0); + this._updateState(); + } + + // All of the logic for what happens when occurs here; the various + // event handlers merely update variables such as + // 'this._pointerInNotification', 'this._traySummoned', etc, and + // _updateState() figures out what (if anything) needs to be done + // at the present time. + _updateState() { + let hasMonitor = Main.layoutManager.primaryMonitor != null; + this.visible = !this._bannerBlocked && hasMonitor && this._banner != null; + if (this._bannerBlocked || !hasMonitor) + return; + + // If our state changes caused _updateState to be called, + // just exit now to prevent reentrancy issues. + if (this._updatingState) + return; + + this._updatingState = true; + + // Filter out acknowledged notifications. + let changed = false; + this._notificationQueue = this._notificationQueue.filter(n => { + changed ||= n.acknowledged; + return !n.acknowledged; + }); + + if (changed) + this.emit('queue-changed'); + + let hasNotifications = Main.sessionMode.hasNotifications; + + if (this._notificationState == State.HIDDEN) { + let nextNotification = this._notificationQueue[0] || null; + if (hasNotifications && nextNotification) { + let limited = this._busy || Main.layoutManager.primaryMonitor.inFullscreen; + let showNextNotification = !limited || nextNotification.forFeedback || nextNotification.urgency == Urgency.CRITICAL; + if (showNextNotification) + this._showNotification(); + } + } else if (this._notificationState === State.SHOWING || + this._notificationState === State.SHOWN) { + let expired = (this._userActiveWhileNotificationShown && + this._notificationTimeoutId == 0 && + this._notification.urgency != Urgency.CRITICAL && + !this._banner.focused && + !this._pointerInNotification) || this._notificationExpired; + let mustClose = this._notificationRemoved || !hasNotifications || expired; + + if (mustClose) { + let animate = hasNotifications && !this._notificationRemoved; + this._hideNotification(animate); + } else if (this._notificationState === State.SHOWN && + this._pointerInNotification) { + if (!this._banner.expanded) + this._expandBanner(false); + else + this._ensureBannerFocused(); + } + } + + this._updatingState = false; + + // Clean transient variables that are used to communicate actions + // to updateState() + this._notificationExpired = false; + } + + _onIdleMonitorBecameActive() { + this._userActiveWhileNotificationShown = true; + this._updateNotificationTimeout(2000); + this._updateState(); + } + + _showNotification() { + this._notification = this._notificationQueue.shift(); + this.emit('queue-changed'); + + this._userActiveWhileNotificationShown = this.idleMonitor.get_idletime() <= IDLE_TIME; + if (!this._userActiveWhileNotificationShown) { + // If the user isn't active, set up a watch to let us know + // when the user becomes active. + this.idleMonitor.add_user_active_watch(this._onIdleMonitorBecameActive.bind(this)); + } + + this._banner = this._notification.createBanner(); + this._banner.connectObject( + 'done-displaying', this._escapeTray.bind(this), + 'unfocused', () => this._updateState(), this); + + this._bannerBin.add_actor(this._banner); + + this._bannerBin.opacity = 0; + this._bannerBin.y = -this._banner.height; + this.show(); + + Meta.disable_unredirect_for_display(global.display); + this._updateShowingNotification(); + + let [x, y] = global.get_pointer(); + // We save the position of the mouse at the time when we started showing the notification + // in order to determine if the notification popped up under it. We make that check if + // the user starts moving the mouse and _onNotificationHoverChanged() gets called. We don't + // expand the notification if it just happened to pop up under the mouse unless the user + // explicitly mouses away from it and then mouses back in. + this._showNotificationMouseX = x; + this._showNotificationMouseY = y; + // We save the coordinates of the mouse at the time when we started showing the notification + // and then we update it in _notificationTimeout(). We don't pop down the notification if + // the mouse is moving towards it or within it. + this._lastSeenMouseX = x; + this._lastSeenMouseY = y; + + this._resetNotificationLeftTimeout(); + } + + _updateShowingNotification() { + this._notification.acknowledged = true; + this._notification.playSound(); + + // We auto-expand notifications with CRITICAL urgency, or for which the relevant setting + // is on in the control center. + if (this._notification.urgency == Urgency.CRITICAL || + this._notification.source.policy.forceExpanded) + this._expandBanner(true); + + // We tween all notifications to full opacity. This ensures that both new notifications and + // notifications that might have been in the process of hiding get full opacity. + // + // We tween any notification showing in the banner mode to the appropriate height + // (which is banner height or expanded height, depending on the notification state) + // This ensures that both new notifications and notifications in the banner mode that might + // have been in the process of hiding are shown with the correct height. + // + // We use this._showNotificationCompleted() onComplete callback to extend the time the updated + // notification is being shown. + + this._notificationState = State.SHOWING; + this._bannerBin.remove_all_transitions(); + this._bannerBin.ease({ + opacity: 255, + duration: ANIMATION_TIME, + mode: Clutter.AnimationMode.LINEAR, + }); + this._bannerBin.ease({ + y: 0, + duration: ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_BACK, + onComplete: () => { + this._notificationState = State.SHOWN; + this._showNotificationCompleted(); + this._updateState(); + }, + }); + } + + _showNotificationCompleted() { + if (this._notification.urgency != Urgency.CRITICAL) + this._updateNotificationTimeout(NOTIFICATION_TIMEOUT); + } + + _updateNotificationTimeout(timeout) { + if (this._notificationTimeoutId) { + GLib.source_remove(this._notificationTimeoutId); + this._notificationTimeoutId = 0; + } + if (timeout > 0) { + this._notificationTimeoutId = + GLib.timeout_add(GLib.PRIORITY_DEFAULT, timeout, + this._notificationTimeout.bind(this)); + GLib.Source.set_name_by_id(this._notificationTimeoutId, '[gnome-shell] this._notificationTimeout'); + } + } + + _notificationTimeout() { + let [x, y] = global.get_pointer(); + if (y < this._lastSeenMouseY - 10 && !this._notificationHovered) { + // The mouse is moving towards the notification, so don't + // hide it yet. (We just create a new timeout (and destroy + // the old one) each time because the bookkeeping is + // simpler.) + this._updateNotificationTimeout(1000); + } else if (this._useLongerNotificationLeftTimeout && !this._notificationLeftTimeoutId && + (x != this._lastSeenMouseX || y != this._lastSeenMouseY)) { + // Refresh the timeout if the notification originally + // popped up under the pointer, and the pointer is hovering + // inside it. + this._updateNotificationTimeout(1000); + } else { + this._notificationTimeoutId = 0; + this._updateState(); + } + + this._lastSeenMouseX = x; + this._lastSeenMouseY = y; + return GLib.SOURCE_REMOVE; + } + + _hideNotification(animate) { + this._notificationFocusGrabber.ungrabFocus(); + + this._banner.disconnectObject(this); + + this._resetNotificationLeftTimeout(); + this._bannerBin.remove_all_transitions(); + + if (animate) { + this._notificationState = State.HIDING; + this._bannerBin.ease({ + opacity: 0, + duration: ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_BACK, + }); + this._bannerBin.ease({ + y: -this._bannerBin.height, + duration: ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_BACK, + onComplete: () => { + this._notificationState = State.HIDDEN; + this._hideNotificationCompleted(); + this._updateState(); + }, + }); + } else { + this._bannerBin.y = -this._bannerBin.height; + this._bannerBin.opacity = 0; + this._notificationState = State.HIDDEN; + this._hideNotificationCompleted(); + } + } + + _hideNotificationCompleted() { + let notification = this._notification; + this._notification = null; + if (!this._notificationRemoved && notification.isTransient) + notification.destroy(NotificationDestroyedReason.EXPIRED); + + this._pointerInNotification = false; + this._notificationRemoved = false; + Meta.enable_unredirect_for_display(global.display); + + this._banner.destroy(); + this._banner = null; + this.hide(); + } + + _expandActiveNotification() { + if (!this._banner) + return; + + this._expandBanner(false); + } + + _expandBanner(autoExpanding) { + // Don't animate changes in notifications that are auto-expanding. + this._banner.expand(!autoExpanding); + + // Don't focus notifications that are auto-expanding. + if (!autoExpanding) + this._ensureBannerFocused(); + } + + _ensureBannerFocused() { + this._notificationFocusGrabber.grabFocus(); + } +}); + +var SystemNotificationSource = GObject.registerClass( +class SystemNotificationSource extends Source { + _init() { + super._init(_("System Information"), 'dialog-information-symbolic'); + } + + open() { + this.destroy(); + } +}); diff --git a/js/ui/modalDialog.js b/js/ui/modalDialog.js new file mode 100644 index 0000000..0561b8b --- /dev/null +++ b/js/ui/modalDialog.js @@ -0,0 +1,288 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported ModalDialog */ + +const { Atk, Clutter, GObject, Shell, St } = imports.gi; + +const Dialog = imports.ui.dialog; +const Layout = imports.ui.layout; +const Lightbox = imports.ui.lightbox; +const Main = imports.ui.main; +const Params = imports.misc.params; + +var OPEN_AND_CLOSE_TIME = 100; +var FADE_OUT_DIALOG_TIME = 1000; + +var State = { + OPENED: 0, + CLOSED: 1, + OPENING: 2, + CLOSING: 3, + FADED_OUT: 4, +}; + +var ModalDialog = GObject.registerClass({ + Properties: { + 'state': GObject.ParamSpec.int('state', 'Dialog state', 'state', + GObject.ParamFlags.READABLE, + Math.min(...Object.values(State)), + Math.max(...Object.values(State)), + State.CLOSED), + }, + Signals: { 'opened': {}, 'closed': {} }, +}, class ModalDialog extends St.Widget { + _init(params) { + super._init({ + visible: false, + reactive: true, + x: 0, + y: 0, + accessible_role: Atk.Role.DIALOG, + }); + + params = Params.parse(params, { + shellReactive: false, + styleClass: null, + actionMode: Shell.ActionMode.SYSTEM_MODAL, + shouldFadeIn: true, + shouldFadeOut: true, + destroyOnClose: true, + }); + + this._state = State.CLOSED; + this._hasModal = false; + this._actionMode = params.actionMode; + this._shellReactive = params.shellReactive; + this._shouldFadeIn = params.shouldFadeIn; + this._shouldFadeOut = params.shouldFadeOut; + this._destroyOnClose = params.destroyOnClose; + + Main.layoutManager.modalDialogGroup.add_actor(this); + + const constraint = new Clutter.BindConstraint({ + source: global.stage, + coordinate: Clutter.BindCoordinate.ALL, + }); + this.add_constraint(constraint); + + this.backgroundStack = new St.Widget({ + layout_manager: new Clutter.BinLayout(), + x_expand: true, + y_expand: true, + }); + this._backgroundBin = new St.Bin({ child: this.backgroundStack }); + this._monitorConstraint = new Layout.MonitorConstraint(); + this._backgroundBin.add_constraint(this._monitorConstraint); + this.add_actor(this._backgroundBin); + + this.dialogLayout = new Dialog.Dialog(this.backgroundStack, params.styleClass); + this.contentLayout = this.dialogLayout.contentLayout; + this.buttonLayout = this.dialogLayout.buttonLayout; + + if (!this._shellReactive) { + this._lightbox = new Lightbox.Lightbox(this, { + inhibitEvents: true, + radialEffect: true, + }); + this._lightbox.highlight(this._backgroundBin); + + this._eventBlocker = new Clutter.Actor({ reactive: true }); + this.backgroundStack.add_actor(this._eventBlocker); + } + + global.focus_manager.add_group(this.dialogLayout); + this._initialKeyFocus = null; + this._initialKeyFocusDestroyId = 0; + this._savedKeyFocus = null; + } + + get state() { + return this._state; + } + + _setState(state) { + if (this._state == state) + return; + + this._state = state; + this.notify('state'); + } + + vfunc_key_press_event() { + if (global.focus_manager.navigate_from_event(Clutter.get_current_event())) + return Clutter.EVENT_STOP; + + return Clutter.EVENT_PROPAGATE; + } + + vfunc_captured_event(event) { + if (Main.keyboard.maybeHandleEvent(event)) + return Clutter.EVENT_STOP; + + return Clutter.EVENT_PROPAGATE; + } + + clearButtons() { + this.dialogLayout.clearButtons(); + } + + setButtons(buttons) { + this.clearButtons(); + + for (let buttonInfo of buttons) + this.addButton(buttonInfo); + } + + addButton(buttonInfo) { + return this.dialogLayout.addButton(buttonInfo); + } + + _fadeOpen(onPrimary) { + if (onPrimary) + this._monitorConstraint.primary = true; + else + this._monitorConstraint.index = global.display.get_current_monitor(); + + this._setState(State.OPENING); + + this.dialogLayout.opacity = 255; + if (this._lightbox) + this._lightbox.lightOn(); + this.opacity = 0; + this.show(); + this.ease({ + opacity: 255, + duration: this._shouldFadeIn ? OPEN_AND_CLOSE_TIME : 0, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._setState(State.OPENED); + this.emit('opened'); + }, + }); + } + + setInitialKeyFocus(actor) { + this._initialKeyFocus?.disconnectObject(this); + + this._initialKeyFocus = actor; + + actor.connectObject('destroy', + () => (this._initialKeyFocus = null), this); + } + + open(timestamp, onPrimary) { + if (this.state == State.OPENED || this.state == State.OPENING) + return true; + + if (!this.pushModal(timestamp)) + return false; + + this._fadeOpen(onPrimary); + return true; + } + + _closeComplete() { + this._setState(State.CLOSED); + this.hide(); + this.emit('closed'); + + if (this._destroyOnClose) + this.destroy(); + } + + close(timestamp) { + if (this.state == State.CLOSED || this.state == State.CLOSING) + return; + + this._setState(State.CLOSING); + this.popModal(timestamp); + this._savedKeyFocus = null; + + if (this._shouldFadeOut) { + this.ease({ + opacity: 0, + duration: OPEN_AND_CLOSE_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._closeComplete(), + }); + } else { + this._closeComplete(); + } + } + + // Drop modal status without closing the dialog; this makes the + // dialog insensitive as well, so it needs to be followed shortly + // by either a close() or a pushModal() + popModal(timestamp) { + if (!this._hasModal) + return; + + let focus = global.stage.key_focus; + if (focus && this.contains(focus)) + this._savedKeyFocus = focus; + else + this._savedKeyFocus = null; + Main.popModal(this._grab, timestamp); + this._grab = null; + this._hasModal = false; + + if (!this._shellReactive) + this.backgroundStack.set_child_above_sibling(this._eventBlocker, null); + } + + pushModal(timestamp) { + if (this._hasModal) + return true; + + let params = { actionMode: this._actionMode }; + if (timestamp) + params['timestamp'] = timestamp; + let grab = Main.pushModal(this, params); + if (grab.get_seat_state() !== Clutter.GrabState.ALL) { + Main.popModal(grab); + return false; + } + + this._grab = grab; + Main.layoutManager.emit('system-modal-opened'); + + this._hasModal = true; + if (this._savedKeyFocus) { + this._savedKeyFocus.grab_key_focus(); + this._savedKeyFocus = null; + } else { + let focus = this._initialKeyFocus || this.dialogLayout.initialKeyFocus; + focus.grab_key_focus(); + } + + if (!this._shellReactive) + this.backgroundStack.set_child_below_sibling(this._eventBlocker, null); + return true; + } + + // This method is like close, but fades the dialog out much slower, + // and leaves the lightbox in place. Once in the faded out state, + // the dialog can be brought back by an open call, or the lightbox + // can be dismissed by a close call. + // + // The main point of this method is to give some indication to the user + // that the dialog response has been acknowledged but will take a few + // moments before being processed. + // e.g., if a user clicked "Log Out" then the dialog should go away + // immediately, but the lightbox should remain until the logout is + // complete. + _fadeOutDialog(timestamp) { + if (this.state == State.CLOSED || this.state == State.CLOSING) + return; + + if (this.state == State.FADED_OUT) + return; + + this.popModal(timestamp); + this.dialogLayout.ease({ + opacity: 0, + duration: FADE_OUT_DIALOG_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._setState(State.FADED_OUT), + }); + } +}); diff --git a/js/ui/mpris.js b/js/ui/mpris.js new file mode 100644 index 0000000..f44f87e --- /dev/null +++ b/js/ui/mpris.js @@ -0,0 +1,297 @@ +/* exported MediaSection */ +const { Gio, GObject, Shell, St } = imports.gi; +const Signals = imports.misc.signals; + +const Main = imports.ui.main; +const MessageList = imports.ui.messageList; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const DBusIface = loadInterfaceXML('org.freedesktop.DBus'); +const DBusProxy = Gio.DBusProxy.makeProxyWrapper(DBusIface); + +const MprisIface = loadInterfaceXML('org.mpris.MediaPlayer2'); +const MprisProxy = Gio.DBusProxy.makeProxyWrapper(MprisIface); + +const MprisPlayerIface = loadInterfaceXML('org.mpris.MediaPlayer2.Player'); +const MprisPlayerProxy = Gio.DBusProxy.makeProxyWrapper(MprisPlayerIface); + +const MPRIS_PLAYER_PREFIX = 'org.mpris.MediaPlayer2.'; + +var MediaMessage = GObject.registerClass( +class MediaMessage extends MessageList.Message { + _init(player) { + super._init('', ''); + + this._player = player; + + this._icon = new St.Icon({ style_class: 'media-message-cover-icon' }); + this.setIcon(this._icon); + + // reclaim space used by unused elements + this._secondaryBin.hide(); + this._closeButton.hide(); + + this._prevButton = this.addMediaControl('media-skip-backward-symbolic', + () => { + this._player.previous(); + }); + + this._playPauseButton = this.addMediaControl('', + () => { + this._player.playPause(); + }); + + this._nextButton = this.addMediaControl('media-skip-forward-symbolic', + () => { + this._player.next(); + }); + + this._player.connectObject( + 'changed', this._update.bind(this), + 'closed', this.close.bind(this), this); + this._update(); + } + + vfunc_clicked() { + this._player.raise(); + Main.panel.closeCalendar(); + } + + _updateNavButton(button, sensitive) { + button.reactive = sensitive; + } + + _update() { + this.setTitle(this._player.trackTitle); + this.setBody(this._player.trackArtists.join(', ')); + + if (this._player.trackCoverUrl) { + let file = Gio.File.new_for_uri(this._player.trackCoverUrl); + this._icon.gicon = new Gio.FileIcon({ file }); + this._icon.remove_style_class_name('fallback'); + } else { + this._icon.icon_name = 'audio-x-generic-symbolic'; + this._icon.add_style_class_name('fallback'); + } + + let isPlaying = this._player.status == 'Playing'; + let iconName = isPlaying + ? 'media-playback-pause-symbolic' + : 'media-playback-start-symbolic'; + this._playPauseButton.child.icon_name = iconName; + + this._updateNavButton(this._prevButton, this._player.canGoPrevious); + this._updateNavButton(this._nextButton, this._player.canGoNext); + } +}); + +var MprisPlayer = class MprisPlayer extends Signals.EventEmitter { + constructor(busName) { + super(); + + this._mprisProxy = new MprisProxy(Gio.DBus.session, busName, + '/org/mpris/MediaPlayer2', + this._onMprisProxyReady.bind(this)); + this._playerProxy = new MprisPlayerProxy(Gio.DBus.session, busName, + '/org/mpris/MediaPlayer2', + this._onPlayerProxyReady.bind(this)); + + this._visible = false; + this._trackArtists = []; + this._trackTitle = ''; + this._trackCoverUrl = ''; + this._busName = busName; + } + + get status() { + return this._playerProxy.PlaybackStatus; + } + + get trackArtists() { + return this._trackArtists; + } + + get trackTitle() { + return this._trackTitle; + } + + get trackCoverUrl() { + return this._trackCoverUrl; + } + + playPause() { + this._playerProxy.PlayPauseAsync().catch(logError); + } + + get canGoNext() { + return this._playerProxy.CanGoNext; + } + + next() { + this._playerProxy.NextAsync().catch(logError); + } + + get canGoPrevious() { + return this._playerProxy.CanGoPrevious; + } + + previous() { + this._playerProxy.PreviousAsync().catch(logError); + } + + raise() { + // The remote Raise() method may run into focus stealing prevention, + // so prefer activating the app via .desktop file if possible + let app = null; + if (this._mprisProxy.DesktopEntry) { + let desktopId = `${this._mprisProxy.DesktopEntry}.desktop`; + app = Shell.AppSystem.get_default().lookup_app(desktopId); + } + + if (app) + app.activate(); + else if (this._mprisProxy.CanRaise) + this._mprisProxy.RaiseAsync().catch(logError); + } + + _close() { + this._mprisProxy.disconnectObject(this); + this._mprisProxy = null; + + this._playerProxy.disconnectObject(this); + this._playerProxy = null; + + this.emit('closed'); + } + + _onMprisProxyReady() { + this._mprisProxy.connectObject('notify::g-name-owner', + () => { + if (!this._mprisProxy.g_name_owner) + this._close(); + }, this); + // It is possible for the bus to disappear before the previous signal + // is connected, so we must ensure that the bus still exists at this + // point. + if (!this._mprisProxy.g_name_owner) + this._close(); + } + + _onPlayerProxyReady() { + this._playerProxy.connectObject( + 'g-properties-changed', () => this._updateState(), this); + this._updateState(); + } + + _updateState() { + let metadata = {}; + for (let prop in this._playerProxy.Metadata) + metadata[prop] = this._playerProxy.Metadata[prop].deepUnpack(); + + // Validate according to the spec; some clients send buggy metadata: + // https://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata + this._trackArtists = metadata['xesam:artist']; + if (!Array.isArray(this._trackArtists) || + !this._trackArtists.every(artist => typeof artist === 'string')) { + if (typeof this._trackArtists !== 'undefined') { + log(`Received faulty track artist metadata from ${ + this._busName}; expected an array of strings, got ${ + this._trackArtists} (${typeof this._trackArtists})`); + } + this._trackArtists = [_("Unknown artist")]; + } + + this._trackTitle = metadata['xesam:title']; + if (typeof this._trackTitle !== 'string') { + if (typeof this._trackTitle !== 'undefined') { + log(`Received faulty track title metadata from ${ + this._busName}; expected a string, got ${ + this._trackTitle} (${typeof this._trackTitle})`); + } + this._trackTitle = _("Unknown title"); + } + + this._trackCoverUrl = metadata['mpris:artUrl']; + if (typeof this._trackCoverUrl !== 'string') { + if (typeof this._trackCoverUrl !== 'undefined') { + log(`Received faulty track cover art metadata from ${ + this._busName}; expected a string, got ${ + this._trackCoverUrl} (${typeof this._trackCoverUrl})`); + } + this._trackCoverUrl = ''; + } + + this.emit('changed'); + + let visible = this._playerProxy.CanPlay; + + if (this._visible != visible) { + this._visible = visible; + if (visible) + this.emit('show'); + else + this.emit('hide'); + } + } +}; + +var MediaSection = GObject.registerClass( +class MediaSection extends MessageList.MessageListSection { + _init() { + super._init(); + + this._players = new Map(); + + this._proxy = new DBusProxy(Gio.DBus.session, + 'org.freedesktop.DBus', + '/org/freedesktop/DBus', + this._onProxyReady.bind(this)); + } + + get allowed() { + return !Main.sessionMode.isGreeter; + } + + _addPlayer(busName) { + if (this._players.get(busName)) + return; + + let player = new MprisPlayer(busName); + let message = null; + player.connect('closed', + () => { + this._players.delete(busName); + }); + player.connect('show', () => { + message = new MediaMessage(player); + this.addMessage(message, true); + }); + player.connect('hide', () => { + this.removeMessage(message, true); + message = null; + }); + + this._players.set(busName, player); + } + + async _onProxyReady() { + const [names] = await this._proxy.ListNamesAsync(); + names.forEach(name => { + if (!name.startsWith(MPRIS_PLAYER_PREFIX)) + return; + + this._addPlayer(name); + }); + this._proxy.connectSignal('NameOwnerChanged', + this._onNameOwnerChanged.bind(this)); + } + + _onNameOwnerChanged(proxy, sender, [name, oldOwner, newOwner]) { + if (!name.startsWith(MPRIS_PLAYER_PREFIX)) + return; + + if (newOwner && !oldOwner) + this._addPlayer(name); + } +}); diff --git a/js/ui/notificationDaemon.js b/js/ui/notificationDaemon.js new file mode 100644 index 0000000..b27158e --- /dev/null +++ b/js/ui/notificationDaemon.js @@ -0,0 +1,771 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported NotificationDaemon */ + +const { GdkPixbuf, Gio, GLib, GObject, Shell, St } = imports.gi; + +const Config = imports.misc.config; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; +const Params = imports.misc.params; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const FdoNotificationsIface = loadInterfaceXML('org.freedesktop.Notifications'); + +var NotificationClosedReason = { + EXPIRED: 1, + DISMISSED: 2, + APP_CLOSED: 3, + UNDEFINED: 4, +}; + +var Urgency = { + LOW: 0, + NORMAL: 1, + CRITICAL: 2, +}; + +var FdoNotificationDaemon = class FdoNotificationDaemon { + constructor() { + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(FdoNotificationsIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/freedesktop/Notifications'); + + this._sources = []; + this._notifications = {}; + + this._nextNotificationId = 1; + } + + _imageForNotificationData(hints) { + if (hints['image-data']) { + const [ + width, height, rowStride, hasAlpha, + bitsPerSample, nChannels_, data, + ] = hints['image-data']; + return Shell.util_create_pixbuf_from_data(data, GdkPixbuf.Colorspace.RGB, hasAlpha, + bitsPerSample, width, height, rowStride); + } else if (hints['image-path']) { + return this._iconForNotificationData(hints['image-path']); + } + return null; + } + + _fallbackIconForNotificationData(hints) { + let stockIcon; + switch (hints.urgency) { + case Urgency.LOW: + case Urgency.NORMAL: + stockIcon = 'dialog-information'; + break; + case Urgency.CRITICAL: + stockIcon = 'dialog-error'; + break; + } + return new Gio.ThemedIcon({ name: stockIcon }); + } + + _iconForNotificationData(icon) { + if (icon) { + if (icon.substr(0, 7) == 'file://') + return new Gio.FileIcon({ file: Gio.File.new_for_uri(icon) }); + else if (icon[0] == '/') + return new Gio.FileIcon({ file: Gio.File.new_for_path(icon) }); + else + return new Gio.ThemedIcon({ name: icon }); + } + return null; + } + + _lookupSource(title, pid) { + for (let i = 0; i < this._sources.length; i++) { + let source = this._sources[i]; + if (source.pid == pid && source.initialTitle == title) + return source; + } + return null; + } + + // Returns the source associated with ndata.notification if it is set. + // If the existing or requested source is associated with a tray icon + // and passed in pid matches a pid of an existing source, the title + // match is ignored to enable representing a tray icon and notifications + // from the same application with a single source. + // + // If no existing source is found, a new source is created as long as + // pid is provided. + _getSource(title, pid, ndata, sender) { + if (!pid && !(ndata && ndata.notification)) + throw new Error('Either a pid or ndata.notification is needed'); + + // We use notification's source for the notifications we still have + // around that are getting replaced because we don't keep sources + // for transient notifications in this._sources, but we still want + // the notification associated with them to get replaced correctly. + if (ndata && ndata.notification) + return ndata.notification.source; + + let source = this._lookupSource(title, pid); + if (source) { + source.setTitle(title); + return source; + } + + const appId = ndata?.hints['desktop-entry']; + source = new FdoNotificationDaemonSource(title, pid, sender, appId); + + this._sources.push(source); + source.connect('destroy', () => { + let index = this._sources.indexOf(source); + if (index >= 0) + this._sources.splice(index, 1); + }); + + Main.messageTray.add(source); + return source; + } + + NotifyAsync(params, invocation) { + let [appName, replacesId, icon, summary, body, actions, hints, timeout] = params; + let id; + + for (let hint in hints) { + // unpack the variants + hints[hint] = hints[hint].deepUnpack(); + } + + hints = Params.parse(hints, { urgency: Urgency.NORMAL }, true); + + // Filter out chat, presence, calls and invitation notifications from + // Empathy, since we handle that information from telepathyClient.js + // + // Note that empathy uses im.received for one to one chats and + // x-empathy.im.mentioned for multi-user, so we're good here + if (appName == 'Empathy' && hints['category'] == 'im.received') { + // Ignore replacesId since we already sent back a + // NotificationClosed for that id. + id = this._nextNotificationId++; + let idleId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this._emitNotificationClosed(id, NotificationClosedReason.DISMISSED); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(idleId, '[gnome-shell] this._emitNotificationClosed'); + return invocation.return_value(GLib.Variant.new('(u)', [id])); + } + + // Be compatible with the various hints for image data and image path + // 'image-data' and 'image-path' are the latest name of these hints, introduced in 1.2 + + if (!hints['image-path'] && hints['image_path']) + hints['image-path'] = hints['image_path']; // version 1.1 of the spec + + if (!hints['image-data']) { + if (hints['image_data']) + hints['image-data'] = hints['image_data']; // version 1.1 of the spec + else if (hints['icon_data'] && !hints['image-path']) + // early versions of the spec; 'icon_data' should only be used if 'image-path' is not available + hints['image-data'] = hints['icon_data']; + } + + const ndata = { + appName, + icon, + summary, + body, + actions, + hints, + timeout, + }; + if (replacesId != 0 && this._notifications[replacesId]) { + ndata.id = id = replacesId; + ndata.notification = this._notifications[replacesId].notification; + } else { + replacesId = 0; + ndata.id = id = this._nextNotificationId++; + } + this._notifications[id] = ndata; + + let sender = invocation.get_sender(); + let pid = hints['sender-pid']; + + let source = this._getSource(appName, pid, ndata, sender, null); + this._notifyForSource(source, ndata); + + return invocation.return_value(GLib.Variant.new('(u)', [id])); + } + + _notifyForSource(source, ndata) { + const { icon, summary, body, actions, hints } = ndata; + let { notification } = ndata; + + if (notification == null) { + notification = new MessageTray.Notification(source); + ndata.notification = notification; + notification.connect('destroy', (n, reason) => { + delete this._notifications[ndata.id]; + let notificationClosedReason; + switch (reason) { + case MessageTray.NotificationDestroyedReason.EXPIRED: + notificationClosedReason = NotificationClosedReason.EXPIRED; + break; + case MessageTray.NotificationDestroyedReason.DISMISSED: + notificationClosedReason = NotificationClosedReason.DISMISSED; + break; + case MessageTray.NotificationDestroyedReason.SOURCE_CLOSED: + notificationClosedReason = NotificationClosedReason.APP_CLOSED; + break; + } + this._emitNotificationClosed(ndata.id, notificationClosedReason); + }); + } + + // 'image-data' (or 'image-path') takes precedence over 'app-icon'. + let gicon = this._imageForNotificationData(hints); + + if (!gicon) + gicon = this._iconForNotificationData(icon); + + if (!gicon) + gicon = this._fallbackIconForNotificationData(hints); + + const soundFile = 'sound-file' in hints + ? Gio.File.new_for_path(hints['sound-file']) : null; + + notification.update(summary, body, { + gicon, + bannerMarkup: true, + clear: true, + soundFile, + soundName: hints['sound-name'], + }); + + let hasDefaultAction = false; + + if (actions.length) { + for (let i = 0; i < actions.length - 1; i += 2) { + let [actionId, label] = [actions[i], actions[i + 1]]; + if (actionId == 'default') { + hasDefaultAction = true; + } else { + notification.addAction(label, () => { + this._emitActionInvoked(ndata.id, actionId); + }); + } + } + } + + if (hasDefaultAction) { + notification.connect('activated', () => { + this._emitActionInvoked(ndata.id, 'default'); + }); + } else { + notification.connect('activated', () => { + source.open(); + }); + } + + switch (hints.urgency) { + case Urgency.LOW: + notification.setUrgency(MessageTray.Urgency.LOW); + break; + case Urgency.NORMAL: + notification.setUrgency(MessageTray.Urgency.NORMAL); + break; + case Urgency.CRITICAL: + notification.setUrgency(MessageTray.Urgency.CRITICAL); + break; + } + notification.setResident(!!hints.resident); + // 'transient' is a reserved keyword in JS, so we have to retrieve the value + // of the 'transient' hint with hints['transient'] rather than hints.transient + notification.setTransient(!!hints['transient']); + + let privacyScope = hints['x-gnome-privacy-scope'] || 'user'; + notification.setPrivacyScope(privacyScope == 'system' + ? MessageTray.PrivacyScope.SYSTEM + : MessageTray.PrivacyScope.USER); + + let sourceGIcon = source.useNotificationIcon ? gicon : null; + source.processNotification(notification, sourceGIcon); + } + + CloseNotification(id) { + let ndata = this._notifications[id]; + if (ndata) { + if (ndata.notification) + ndata.notification.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); + delete this._notifications[id]; + } + } + + GetCapabilities() { + return [ + 'actions', + // 'action-icons', + 'body', + // 'body-hyperlinks', + // 'body-images', + 'body-markup', + // 'icon-multi', + 'icon-static', + 'persistence', + 'sound', + ]; + } + + GetServerInformation() { + return [ + Config.PACKAGE_NAME, + 'GNOME', + Config.PACKAGE_VERSION, + '1.2', + ]; + } + + _emitNotificationClosed(id, reason) { + this._dbusImpl.emit_signal('NotificationClosed', + GLib.Variant.new('(uu)', [id, reason])); + } + + _emitActionInvoked(id, action) { + this._dbusImpl.emit_signal('ActionInvoked', + GLib.Variant.new('(us)', [id, action])); + } +}; + +var FdoNotificationDaemonSource = GObject.registerClass( +class FdoNotificationDaemonSource extends MessageTray.Source { + _init(title, pid, sender, appId) { + this.pid = pid; + this.initialTitle = title; + this.app = this._getApp(appId); + + super._init(title); + + if (this.app) + this.title = this.app.get_name(); + else + this.useNotificationIcon = true; + + if (sender) { + this._nameWatcherId = Gio.DBus.session.watch_name(sender, + Gio.BusNameWatcherFlags.NONE, + null, + this._onNameVanished.bind(this)); + } else { + this._nameWatcherId = 0; + } + } + + _createPolicy() { + if (this.app && this.app.get_app_info()) { + let id = this.app.get_id().replace(/\.desktop$/, ''); + return new MessageTray.NotificationApplicationPolicy(id); + } else { + return new MessageTray.NotificationGenericPolicy(); + } + } + + _onNameVanished() { + // Destroy the notification source when its sender is removed from DBus. + // Only do so if this.app is set to avoid removing "notify-send" sources, senders + // of which аre removed from DBus immediately. + // Sender being removed from DBus would normally result in a tray icon being removed, + // so allow the code path that handles the tray icon being removed to handle that case. + if (this.app) + this.destroy(); + } + + processNotification(notification, gicon) { + if (gicon) + this._gicon = gicon; + this.iconUpdated(); + + let tracker = Shell.WindowTracker.get_default(); + if (notification.resident && this.app && tracker.focus_app == this.app) + this.pushNotification(notification); + else + this.showNotification(notification); + } + + _getApp(appId) { + const appSys = Shell.AppSystem.get_default(); + let app; + + app = Shell.WindowTracker.get_default().get_app_from_pid(this.pid); + if (app != null) + return app; + + if (appId) + app = appSys.lookup_app(`${appId}.desktop`); + + if (!app) + app = appSys.lookup_app(`${this.initialTitle}.desktop`); + + return app; + } + + setTitle(title) { + // Do nothing if .app is set, we don't want to override the + // app name with whatever is provided through libnotify (usually + // garbage) + if (this.app) + return; + + super.setTitle(title); + } + + open() { + this.openApp(); + this.destroyNonResidentNotifications(); + } + + openApp() { + if (this.app == null) + return; + + this.app.activate(); + Main.overview.hide(); + Main.panel.closeCalendar(); + } + + destroy() { + if (this._nameWatcherId) { + Gio.DBus.session.unwatch_name(this._nameWatcherId); + this._nameWatcherId = 0; + } + + super.destroy(); + } + + createIcon(size) { + if (this.app) { + return this.app.create_icon_texture(size); + } else if (this._gicon) { + return new St.Icon({ + gicon: this._gicon, + icon_size: size, + }); + } else { + return null; + } + } +}); + +const PRIORITY_URGENCY_MAP = { + low: MessageTray.Urgency.LOW, + normal: MessageTray.Urgency.NORMAL, + high: MessageTray.Urgency.HIGH, + urgent: MessageTray.Urgency.CRITICAL, +}; + +var GtkNotificationDaemonNotification = GObject.registerClass( +class GtkNotificationDaemonNotification extends MessageTray.Notification { + _init(source, notification) { + super._init(source); + this._serialized = GLib.Variant.new('a{sv}', notification); + + const { + title, + body, + icon: gicon, + urgent, + priority, + buttons, + 'default-action': defaultAction, + 'default-action-target': defaultActionTarget, + timestamp: time, + } = notification; + + if (priority) { + let urgency = PRIORITY_URGENCY_MAP[priority.unpack()]; + this.setUrgency(urgency != undefined ? urgency : MessageTray.Urgency.NORMAL); + } else if (urgent) { + this.setUrgency(urgent.unpack() + ? MessageTray.Urgency.CRITICAL + : MessageTray.Urgency.NORMAL); + } else { + this.setUrgency(MessageTray.Urgency.NORMAL); + } + + if (buttons) { + buttons.deepUnpack().forEach(button => { + this.addAction(button.label.unpack(), () => { + this._onButtonClicked(button); + }); + }); + } + + this._defaultAction = defaultAction?.unpack(); + this._defaultActionTarget = defaultActionTarget; + + this.update(title.unpack(), body?.unpack(), { + gicon: gicon + ? Gio.icon_deserialize(gicon) : null, + datetime: time + ? GLib.DateTime.new_from_unix_local(time.unpack()) : null, + }); + } + + _activateAction(namespacedActionId, target) { + if (namespacedActionId) { + if (namespacedActionId.startsWith('app.')) { + let actionId = namespacedActionId.slice('app.'.length); + this.source.activateAction(actionId, target); + } + } else { + this.source.open(); + } + } + + _onButtonClicked(button) { + let { action, target } = button; + this._activateAction(action.unpack(), target); + } + + activate() { + this._activateAction(this._defaultAction, this._defaultActionTarget); + super.activate(); + } + + serialize() { + return this._serialized; + } +}); + +const FdoApplicationIface = loadInterfaceXML('org.freedesktop.Application'); +const FdoApplicationProxy = Gio.DBusProxy.makeProxyWrapper(FdoApplicationIface); + +function objectPathFromAppId(appId) { + return `/${appId.replace(/\./g, '/').replace(/-/g, '_')}`; +} + +function getPlatformData() { + let startupId = GLib.Variant.new('s', `_TIME${global.get_current_time()}`); + return { "desktop-startup-id": startupId }; +} + +function InvalidAppError() {} + +var GtkNotificationDaemonAppSource = GObject.registerClass( +class GtkNotificationDaemonAppSource extends MessageTray.Source { + _init(appId) { + let objectPath = objectPathFromAppId(appId); + if (!GLib.Variant.is_object_path(objectPath)) + throw new InvalidAppError(); + + let app = Shell.AppSystem.get_default().lookup_app(`${appId}.desktop`); + if (!app) + throw new InvalidAppError(); + + this._appId = appId; + this._app = app; + this._objectPath = objectPath; + + super._init(app.get_name()); + + this._notifications = {}; + this._notificationPending = false; + } + + createIcon(size) { + return this._app.create_icon_texture(size); + } + + _createPolicy() { + return new MessageTray.NotificationApplicationPolicy(this._appId); + } + + _createApp() { + return new Promise((resolve, reject) => { + new FdoApplicationProxy(Gio.DBus.session, + this._appId, this._objectPath, (proxy, err) => { + if (err) + reject(err); + else + resolve(proxy); + }); + }); + } + + _createNotification(params) { + return new GtkNotificationDaemonNotification(this, params); + } + + async activateAction(actionId, target) { + try { + const app = await this._createApp(); + const params = target ? [target] : []; + app.ActivateActionAsync(actionId, params, getPlatformData()); + } catch (error) { + logError(error, 'Failed to activate application proxy'); + } + Main.overview.hide(); + Main.panel.closeCalendar(); + } + + async open() { + try { + const app = await this._createApp(); + app.ActivateAsync(getPlatformData()); + } catch (error) { + logError(error, 'Failed to open application proxy'); + } + Main.overview.hide(); + Main.panel.closeCalendar(); + } + + addNotification(notificationId, notificationParams, showBanner) { + this._notificationPending = true; + + if (this._notifications[notificationId]) + this._notifications[notificationId].destroy(MessageTray.NotificationDestroyedReason.REPLACED); + + let notification = this._createNotification(notificationParams); + notification.connect('destroy', () => { + delete this._notifications[notificationId]; + }); + this._notifications[notificationId] = notification; + + if (showBanner) + this.showNotification(notification); + else + this.pushNotification(notification); + + this._notificationPending = false; + } + + destroy(reason) { + if (this._notificationPending) + return; + super.destroy(reason); + } + + removeNotification(notificationId) { + if (this._notifications[notificationId]) + this._notifications[notificationId].destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); + } + + serialize() { + let notifications = []; + for (let notificationId in this._notifications) { + let notification = this._notifications[notificationId]; + notifications.push([notificationId, notification.serialize()]); + } + return [this._appId, notifications]; + } +}); + +const GtkNotificationsIface = loadInterfaceXML('org.gtk.Notifications'); + +var GtkNotificationDaemon = class GtkNotificationDaemon { + constructor() { + this._sources = {}; + + this._loadNotifications(); + + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(GtkNotificationsIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/gtk/Notifications'); + + Gio.DBus.session.own_name('org.gtk.Notifications', Gio.BusNameOwnerFlags.REPLACE, null, null); + } + + _ensureAppSource(appId) { + if (this._sources[appId]) + return this._sources[appId]; + + let source = new GtkNotificationDaemonAppSource(appId); + + source.connect('destroy', () => { + delete this._sources[appId]; + this._saveNotifications(); + }); + source.connect('notify::count', this._saveNotifications.bind(this)); + Main.messageTray.add(source); + this._sources[appId] = source; + return source; + } + + _loadNotifications() { + this._isLoading = true; + + try { + let value = global.get_persistent_state('a(sa(sv))', 'notifications'); + if (value) { + let sources = value.deepUnpack(); + sources.forEach(([appId, notifications]) => { + if (notifications.length == 0) + return; + + let source; + try { + source = this._ensureAppSource(appId); + } catch (e) { + if (e instanceof InvalidAppError) + return; + throw e; + } + + notifications.forEach(([notificationId, notification]) => { + source.addNotification(notificationId, notification.deepUnpack(), false); + }); + }); + } + } catch (e) { + logError(e, 'Failed to load saved notifications'); + } finally { + this._isLoading = false; + } + } + + _saveNotifications() { + if (this._isLoading) + return; + + let sources = []; + for (let appId in this._sources) { + let source = this._sources[appId]; + sources.push(source.serialize()); + } + + global.set_persistent_state('notifications', new GLib.Variant('a(sa(sv))', sources)); + } + + AddNotificationAsync(params, invocation) { + let [appId, notificationId, notification] = params; + + let source; + try { + source = this._ensureAppSource(appId); + } catch (e) { + if (e instanceof InvalidAppError) { + invocation.return_dbus_error('org.gtk.Notifications.InvalidApp', + `The app by ID "${appId}" could not be found`); + return; + } + throw e; + } + + let timestamp = GLib.DateTime.new_now_local().to_unix(); + notification['timestamp'] = new GLib.Variant('x', timestamp); + + source.addNotification(notificationId, notification, true); + + invocation.return_value(null); + } + + RemoveNotificationAsync(params, invocation) { + let [appId, notificationId] = params; + let source = this._sources[appId]; + if (source) + source.removeNotification(notificationId); + + invocation.return_value(null); + } +}; + +var NotificationDaemon = class NotificationDaemon { + constructor() { + this._fdoNotificationDaemon = new FdoNotificationDaemon(); + this._gtkNotificationDaemon = new GtkNotificationDaemon(); + } +}; diff --git a/js/ui/osdMonitorLabeler.js b/js/ui/osdMonitorLabeler.js new file mode 100644 index 0000000..07c7d65 --- /dev/null +++ b/js/ui/osdMonitorLabeler.js @@ -0,0 +1,117 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported OsdMonitorLabeler */ + +const { Clutter, Gio, GObject, Meta, St } = imports.gi; + +const Main = imports.ui.main; + +var OsdMonitorLabel = GObject.registerClass( +class OsdMonitorLabel extends St.Widget { + _init(monitor, label) { + super._init({ x_expand: true, y_expand: true }); + + this._monitor = monitor; + + this._box = new St.BoxLayout({ + vertical: true, + }); + this.add_actor(this._box); + + this._label = new St.Label({ + style_class: 'osd-monitor-label', + text: label, + }); + this._box.add(this._label); + + Main.uiGroup.add_child(this); + Main.uiGroup.set_child_above_sibling(this, null); + this._position(); + + Meta.disable_unredirect_for_display(global.display); + this.connect('destroy', () => { + Meta.enable_unredirect_for_display(global.display); + }); + } + + _position() { + let workArea = Main.layoutManager.getWorkAreaForMonitor(this._monitor); + + if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) + this._box.x = workArea.x + (workArea.width - this._box.width); + else + this._box.x = workArea.x; + + this._box.y = workArea.y; + } +}); + +var OsdMonitorLabeler = class { + constructor() { + this._monitorManager = Meta.MonitorManager.get(); + this._client = null; + this._clientWatchId = 0; + this._osdLabels = []; + this._monitorLabels = null; + Main.layoutManager.connect('monitors-changed', + this._reset.bind(this)); + this._reset(); + } + + _reset() { + for (let i in this._osdLabels) + this._osdLabels[i].destroy(); + this._osdLabels = []; + this._monitorLabels = new Map(); + let monitors = Main.layoutManager.monitors; + for (let i in monitors) + this._monitorLabels.set(monitors[i].index, []); + } + + _trackClient(client) { + if (this._client) + return this._client == client; + + this._client = client; + this._clientWatchId = Gio.bus_watch_name(Gio.BusType.SESSION, client, 0, null, + (c, name) => { + this.hide(name); + }); + return true; + } + + _untrackClient(client) { + if (!this._client || this._client != client) + return false; + + Gio.bus_unwatch_name(this._clientWatchId); + this._clientWatchId = 0; + this._client = null; + return true; + } + + show(client, params) { + if (!this._trackClient(client)) + return; + + this._reset(); + + for (let connector in params) { + let monitor = this._monitorManager.get_monitor_for_connector(connector); + if (monitor == -1) + continue; + this._monitorLabels.get(monitor).push(params[connector].deepUnpack()); + } + + for (let [monitor, labels] of this._monitorLabels.entries()) { + labels.sort(); + this._osdLabels.push(new OsdMonitorLabel(monitor, labels.join(' '))); + } + } + + hide(client) { + if (!this._untrackClient(client)) + return; + + this._reset(); + } +}; diff --git a/js/ui/osdWindow.js b/js/ui/osdWindow.js new file mode 100644 index 0000000..b183333 --- /dev/null +++ b/js/ui/osdWindow.js @@ -0,0 +1,192 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported OsdWindowManager */ + +const { Clutter, GLib, GObject, Meta, St } = imports.gi; + +const BarLevel = imports.ui.barLevel; +const Layout = imports.ui.layout; +const Main = imports.ui.main; + +var HIDE_TIMEOUT = 1500; +var FADE_TIME = 100; +var LEVEL_ANIMATION_TIME = 100; + +var OsdWindow = GObject.registerClass( +class OsdWindow extends Clutter.Actor { + _init(monitorIndex) { + super._init({ + x_expand: true, + y_expand: true, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.END, + }); + + this._monitorIndex = monitorIndex; + let constraint = new Layout.MonitorConstraint({ index: monitorIndex }); + this.add_constraint(constraint); + + this._hbox = new St.BoxLayout({ + style_class: 'osd-window', + }); + this.add_actor(this._hbox); + + this._icon = new St.Icon({ y_expand: true }); + this._hbox.add_child(this._icon); + + this._vbox = new St.BoxLayout({ + vertical: true, + y_align: Clutter.ActorAlign.CENTER, + }); + this._hbox.add_child(this._vbox); + + this._label = new St.Label(); + this._vbox.add_child(this._label); + + this._level = new BarLevel.BarLevel({ + style_class: 'level', + value: 0, + }); + this._vbox.add_child(this._level); + + this._hideTimeoutId = 0; + this._reset(); + Main.uiGroup.add_child(this); + } + + _updateBoxVisibility() { + this._vbox.visible = [...this._vbox].some(c => c.visible); + } + + setIcon(icon) { + this._icon.gicon = icon; + } + + setLabel(label) { + this._label.visible = label != undefined; + if (label) + this._label.text = label; + this._updateBoxVisibility(); + } + + setLevel(value) { + this._level.visible = value != undefined; + if (value != undefined) { + if (this.visible) { + this._level.ease_property('value', value, { + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: LEVEL_ANIMATION_TIME, + }); + } else { + this._level.value = value; + } + } + this._updateBoxVisibility(); + } + + setMaxLevel(maxLevel = 1) { + this._level.maximum_value = maxLevel; + } + + show() { + if (!this._icon.gicon) + return; + + if (!this.visible) { + Meta.disable_unredirect_for_display(global.display); + super.show(); + this.opacity = 0; + this.get_parent().set_child_above_sibling(this, null); + + this.ease({ + opacity: 255, + duration: FADE_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + if (this._hideTimeoutId) + GLib.source_remove(this._hideTimeoutId); + this._hideTimeoutId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, HIDE_TIMEOUT, this._hide.bind(this)); + GLib.Source.set_name_by_id(this._hideTimeoutId, '[gnome-shell] this._hide'); + } + + cancel() { + if (!this._hideTimeoutId) + return; + + GLib.source_remove(this._hideTimeoutId); + this._hide(); + } + + _hide() { + this._hideTimeoutId = 0; + this.ease({ + opacity: 0, + duration: FADE_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._reset(); + Meta.enable_unredirect_for_display(global.display); + }, + }); + return GLib.SOURCE_REMOVE; + } + + _reset() { + super.hide(); + this.setLabel(null); + this.setMaxLevel(null); + this.setLevel(null); + } +}); + +var OsdWindowManager = class { + constructor() { + this._osdWindows = []; + Main.layoutManager.connect('monitors-changed', + this._monitorsChanged.bind(this)); + this._monitorsChanged(); + } + + _monitorsChanged() { + for (let i = 0; i < Main.layoutManager.monitors.length; i++) { + if (this._osdWindows[i] == undefined) + this._osdWindows[i] = new OsdWindow(i); + } + + for (let i = Main.layoutManager.monitors.length; i < this._osdWindows.length; i++) { + this._osdWindows[i].destroy(); + this._osdWindows[i] = null; + } + + this._osdWindows.length = Main.layoutManager.monitors.length; + } + + _showOsdWindow(monitorIndex, icon, label, level, maxLevel) { + this._osdWindows[monitorIndex].setIcon(icon); + this._osdWindows[monitorIndex].setLabel(label); + this._osdWindows[monitorIndex].setMaxLevel(maxLevel); + this._osdWindows[monitorIndex].setLevel(level); + this._osdWindows[monitorIndex].show(); + } + + show(monitorIndex, icon, label, level, maxLevel) { + if (monitorIndex != -1) { + for (let i = 0; i < this._osdWindows.length; i++) { + if (i == monitorIndex) + this._showOsdWindow(i, icon, label, level, maxLevel); + else + this._osdWindows[i].cancel(); + } + } else { + for (let i = 0; i < this._osdWindows.length; i++) + this._showOsdWindow(i, icon, label, level, maxLevel); + } + } + + hideAll() { + for (let i = 0; i < this._osdWindows.length; i++) + this._osdWindows[i].cancel(); + } +}; diff --git a/js/ui/overview.js b/js/ui/overview.js new file mode 100644 index 0000000..757a8e4 --- /dev/null +++ b/js/ui/overview.js @@ -0,0 +1,715 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Overview, ANIMATION_TIME */ + +const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; +const Signals = imports.misc.signals; + +// Time for initial animation going into Overview mode; +// this is defined here to make it available in imports. +var ANIMATION_TIME = 250; + +const DND = imports.ui.dnd; +const LayoutManager = imports.ui.layout; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; +const OverviewControls = imports.ui.overviewControls; +const Params = imports.misc.params; +const SwipeTracker = imports.ui.swipeTracker; +const WindowManager = imports.ui.windowManager; +const WorkspaceThumbnail = imports.ui.workspaceThumbnail; + +var DND_WINDOW_SWITCH_TIMEOUT = 750; + +var OVERVIEW_ACTIVATION_TIMEOUT = 0.5; + +var ShellInfo = class { + constructor() { + this._source = null; + } + + setMessage(text, options) { + options = Params.parse(options, { + undoCallback: null, + forFeedback: false, + }); + + let undoCallback = options.undoCallback; + let forFeedback = options.forFeedback; + + if (this._source == null) { + this._source = new MessageTray.SystemNotificationSource(); + this._source.connect('destroy', () => { + this._source = null; + }); + Main.messageTray.add(this._source); + } + + let notification = null; + if (this._source.notifications.length == 0) { + notification = new MessageTray.Notification(this._source, text, null); + notification.setTransient(true); + notification.setForFeedback(forFeedback); + } else { + notification = this._source.notifications[0]; + notification.update(text, null, { clear: true }); + } + + if (undoCallback) + notification.addAction(_('Undo'), () => undoCallback()); + + this._source.showNotification(notification); + } +}; + +var OverviewActor = GObject.registerClass( +class OverviewActor extends St.BoxLayout { + _init() { + super._init({ + name: 'overview', + /* Translators: This is the main view to select + activities. See also note for "Activities" string. */ + accessible_name: _("Overview"), + vertical: true, + }); + + this.add_constraint(new LayoutManager.MonitorConstraint({ primary: true })); + + this._controls = new OverviewControls.ControlsManager(); + this.add_child(this._controls); + } + + prepareToEnterOverview() { + this._controls.prepareToEnterOverview(); + } + + prepareToLeaveOverview() { + this._controls.prepareToLeaveOverview(); + } + + animateToOverview(state, callback) { + this._controls.animateToOverview(state, callback); + } + + animateFromOverview(callback) { + this._controls.animateFromOverview(callback); + } + + runStartupAnimation(callback) { + this._controls.runStartupAnimation(callback); + } + + get dash() { + return this._controls.dash; + } + + get searchEntry() { + return this._controls.searchEntry; + } + + get controls() { + return this._controls; + } +}); + +const OverviewShownState = { + HIDDEN: 'HIDDEN', + HIDING: 'HIDING', + SHOWING: 'SHOWING', + SHOWN: 'SHOWN', +}; + +const OVERVIEW_SHOWN_TRANSITIONS = { + [OverviewShownState.HIDDEN]: { + signal: 'hidden', + allowedTransitions: [OverviewShownState.SHOWING], + }, + [OverviewShownState.HIDING]: { + signal: 'hiding', + allowedTransitions: + [OverviewShownState.HIDDEN, OverviewShownState.SHOWING], + }, + [OverviewShownState.SHOWING]: { + signal: 'showing', + allowedTransitions: + [OverviewShownState.SHOWN, OverviewShownState.HIDING], + }, + [OverviewShownState.SHOWN]: { + signal: 'shown', + allowedTransitions: [OverviewShownState.HIDING], + }, +}; + +var Overview = class extends Signals.EventEmitter { + constructor() { + super(); + + this._initCalled = false; + this._visible = false; + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + this._sessionUpdated(); + } + + get dash() { + return this._overview.dash; + } + + get dashIconSize() { + logError(new Error('Usage of Overview.\'dashIconSize\' is deprecated, ' + + 'use \'dash.iconSize\' property instead')); + return this.dash.iconSize; + } + + get animationInProgress() { + return this._animationInProgress; + } + + get visible() { + return this._visible; + } + + get visibleTarget() { + return this._visibleTarget; + } + + get closing() { + return this._animationInProgress && !this._visibleTarget; + } + + _createOverview() { + if (this._overview) + return; + + if (this.isDummy) + return; + + this._activationTime = 0; + + this._visible = false; // animating to overview, in overview, animating out + this._shown = false; // show() and not hide() + this._modal = false; // have a modal grab + this._animationInProgress = false; + this._visibleTarget = false; + this._shownState = OverviewShownState.HIDDEN; + + // During transitions, we raise this to the top to avoid having the overview + // area be reactive; it causes too many issues such as double clicks on + // Dash elements, or mouseover handlers in the workspaces. + this._coverPane = new Clutter.Actor({ + opacity: 0, + reactive: true, + }); + Main.layoutManager.overviewGroup.add_child(this._coverPane); + this._coverPane.connect('event', (_actor, event) => { + return event.type() === Clutter.EventType.ENTER || + event.type() === Clutter.EventType.LEAVE + ? Clutter.EVENT_PROPAGATE : Clutter.EVENT_STOP; + }); + this._coverPane.hide(); + + // XDND + this._dragMonitor = { + dragMotion: this._onDragMotion.bind(this), + }; + + + Main.layoutManager.overviewGroup.connect('scroll-event', + this._onScrollEvent.bind(this)); + Main.xdndHandler.connect('drag-begin', this._onDragBegin.bind(this)); + Main.xdndHandler.connect('drag-end', this._onDragEnd.bind(this)); + + global.display.connect('restacked', this._onRestacked.bind(this)); + + this._windowSwitchTimeoutId = 0; + this._windowSwitchTimestamp = 0; + this._lastActiveWorkspaceIndex = -1; + this._lastHoveredWindow = null; + + if (this._initCalled) + this.init(); + } + + _sessionUpdated() { + const { hasOverview } = Main.sessionMode; + if (!hasOverview) + this.hide(); + + this.isDummy = !hasOverview; + this._createOverview(); + } + + // The members we construct that are implemented in JS might + // want to access the overview as Main.overview to connect + // signal handlers and so forth. So we create them after + // construction in this init() method. + init() { + this._initCalled = true; + + if (this.isDummy) + return; + + this._overview = new OverviewActor(); + this._overview._delegate = this; + Main.layoutManager.overviewGroup.add_child(this._overview); + + this._shellInfo = new ShellInfo(); + + Main.layoutManager.connect('monitors-changed', this._relayout.bind(this)); + this._relayout(); + + Main.wm.addKeybinding( + 'toggle-overview', + new Gio.Settings({ schema_id: WindowManager.SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, + this.toggle.bind(this)); + + const swipeTracker = new SwipeTracker.SwipeTracker(global.stage, + Clutter.Orientation.VERTICAL, + Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, + { allowDrag: false, allowScroll: false }); + swipeTracker.orientation = Clutter.Orientation.VERTICAL; + swipeTracker.connect('begin', this._gestureBegin.bind(this)); + swipeTracker.connect('update', this._gestureUpdate.bind(this)); + swipeTracker.connect('end', this._gestureEnd.bind(this)); + this._swipeTracker = swipeTracker; + } + + // + // options: + // - undoCallback (function): the callback to be called if undo support is needed + // - forFeedback (boolean): whether the message is for direct feedback of a user action + // + setMessage(text, options) { + if (this.isDummy) + return; + + this._shellInfo.setMessage(text, options); + } + + _changeShownState(state) { + const {allowedTransitions} = + OVERVIEW_SHOWN_TRANSITIONS[this._shownState]; + + if (!allowedTransitions.includes(state)) { + throw new Error('Invalid overview shown transition from ' + + `${this._shownState} to ${state}`); + } + + this._shownState = state; + this.emit(OVERVIEW_SHOWN_TRANSITIONS[state].signal); + } + + _onDragBegin() { + this._inXdndDrag = true; + + DND.addDragMonitor(this._dragMonitor); + // Remember the workspace we started from + let workspaceManager = global.workspace_manager; + this._lastActiveWorkspaceIndex = workspaceManager.get_active_workspace_index(); + } + + _onDragEnd() { + this._inXdndDrag = false; + + // In case the drag was canceled while in the overview + // we have to go back to where we started and hide + // the overview + if (this._shown) { + let workspaceManager = global.workspace_manager; + workspaceManager.get_workspace_by_index(this._lastActiveWorkspaceIndex) + .activate(global.get_current_time()); + this.hide(); + } + this._resetWindowSwitchTimeout(); + this._lastHoveredWindow = null; + DND.removeDragMonitor(this._dragMonitor); + this.endItemDrag(); + } + + _resetWindowSwitchTimeout() { + if (this._windowSwitchTimeoutId != 0) { + GLib.source_remove(this._windowSwitchTimeoutId); + this._windowSwitchTimeoutId = 0; + } + } + + _onDragMotion(dragEvent) { + let targetIsWindow = dragEvent.targetActor && + dragEvent.targetActor._delegate && + dragEvent.targetActor._delegate.metaWindow && + !(dragEvent.targetActor._delegate instanceof WorkspaceThumbnail.WindowClone); + + this._windowSwitchTimestamp = global.get_current_time(); + + if (targetIsWindow && + dragEvent.targetActor._delegate.metaWindow == this._lastHoveredWindow) + return DND.DragMotionResult.CONTINUE; + + this._lastHoveredWindow = null; + + this._resetWindowSwitchTimeout(); + + if (targetIsWindow) { + this._lastHoveredWindow = dragEvent.targetActor._delegate.metaWindow; + this._windowSwitchTimeoutId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + DND_WINDOW_SWITCH_TIMEOUT, + () => { + this._windowSwitchTimeoutId = 0; + Main.activateWindow(dragEvent.targetActor._delegate.metaWindow, + this._windowSwitchTimestamp); + this.hide(); + this._lastHoveredWindow = null; + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._windowSwitchTimeoutId, '[gnome-shell] Main.activateWindow'); + } + + return DND.DragMotionResult.CONTINUE; + } + + _onScrollEvent(actor, event) { + this.emit('scroll-event', event); + return Clutter.EVENT_PROPAGATE; + } + + _relayout() { + // To avoid updating the position and size of the workspaces + // we just hide the overview. The positions will be updated + // when it is next shown. + this.hide(); + + this._coverPane.set_position(0, 0); + this._coverPane.set_size(global.screen_width, global.screen_height); + } + + _onRestacked() { + let stack = global.get_window_actors(); + let stackIndices = {}; + + for (let i = 0; i < stack.length; i++) { + // Use the stable sequence for an integer to use as a hash key + stackIndices[stack[i].get_meta_window().get_stable_sequence()] = i; + } + + this.emit('windows-restacked', stackIndices); + } + + _gestureBegin(tracker) { + this._overview.controls.gestureBegin(tracker); + } + + _gestureUpdate(tracker, progress) { + if (!this._shown) { + Meta.disable_unredirect_for_display(global.display); + + this._shown = true; + this._visible = true; + this._visibleTarget = true; + this._animationInProgress = true; + + Main.layoutManager.overviewGroup.set_child_above_sibling( + this._coverPane, null); + this._coverPane.show(); + this._changeShownState(OverviewShownState.SHOWING); + + Main.layoutManager.showOverview(); + this._syncGrab(); + } + + this._overview.controls.gestureProgress(progress); + } + + _gestureEnd(tracker, duration, endProgress) { + let onComplete; + if (endProgress === 0) { + this._shown = false; + this._visibleTarget = false; + this._changeShownState(OverviewShownState.HIDING); + Main.panel.style = `transition-duration: ${duration}ms;`; + onComplete = () => this._hideDone(); + } else { + onComplete = () => this._showDone(); + } + + this._overview.controls.gestureEnd(endProgress, duration, onComplete); + } + + beginItemDrag(source) { + this.emit('item-drag-begin', source); + this._inItemDrag = true; + } + + cancelledItemDrag(source) { + this.emit('item-drag-cancelled', source); + } + + endItemDrag(source) { + if (!this._inItemDrag) + return; + this.emit('item-drag-end', source); + this._inItemDrag = false; + } + + beginWindowDrag(window) { + this.emit('window-drag-begin', window); + this._inWindowDrag = true; + } + + cancelledWindowDrag(window) { + this.emit('window-drag-cancelled', window); + } + + endWindowDrag(window) { + if (!this._inWindowDrag) + return; + this.emit('window-drag-end', window); + this._inWindowDrag = false; + } + + focusSearch() { + this.show(); + this._overview.searchEntry.grab_key_focus(); + } + + // Checks if the Activities button is currently sensitive to + // clicks. The first call to this function within the + // OVERVIEW_ACTIVATION_TIMEOUT time of the hot corner being + // triggered will return false. This avoids opening and closing + // the overview if the user both triggered the hot corner and + // clicked the Activities button. + shouldToggleByCornerOrButton() { + if (this._animationInProgress) + return false; + if (this._inItemDrag || this._inWindowDrag) + return false; + if (!this._activationTime || + GLib.get_monotonic_time() / GLib.USEC_PER_SEC - this._activationTime > OVERVIEW_ACTIVATION_TIMEOUT) + return true; + return false; + } + + _syncGrab() { + // We delay grab changes during animation so that when removing the + // overview we don't have a problem with the release of a press/release + // going to an application. + if (this._animationInProgress) + return true; + + if (this._shown) { + let shouldBeModal = !this._inXdndDrag; + if (shouldBeModal && !this._modal) { + if (global.display.get_grab_op() !== Meta.GrabOp.NONE && + global.display.get_grab_op() !== Meta.GrabOp.WAYLAND_POPUP) { + this.hide(); + return false; + } + + const grab = Main.pushModal(global.stage, { + actionMode: Shell.ActionMode.OVERVIEW, + }); + if (grab.get_seat_state() !== Clutter.GrabState.ALL) { + Main.popModal(grab); + this.hide(); + return false; + } + + this._grab = grab; + this._modal = true; + } + } else { + // eslint-disable-next-line no-lonely-if + if (this._modal) { + Main.popModal(this._grab); + this._grab = false; + this._modal = false; + } + } + return true; + } + + // show: + // + // Animates the overview visible and grabs mouse and keyboard input + show(state = OverviewControls.ControlsState.WINDOW_PICKER) { + if (state === OverviewControls.ControlsState.HIDDEN) + throw new Error('Invalid state, use hide() to hide'); + + if (this.isDummy) + return; + if (this._shown) + return; + this._shown = true; + + if (!this._syncGrab()) + return; + + Main.layoutManager.showOverview(); + this._animateVisible(state); + } + + + _animateVisible(state) { + if (this._visible || this._animationInProgress) + return; + + this._visible = true; + this._animationInProgress = true; + this._visibleTarget = true; + this._activationTime = GLib.get_monotonic_time() / GLib.USEC_PER_SEC; + + Meta.disable_unredirect_for_display(global.display); + + Main.layoutManager.overviewGroup.set_child_above_sibling( + this._coverPane, null); + this._coverPane.show(); + + this._overview.prepareToEnterOverview(); + this._changeShownState(OverviewShownState.SHOWING); + this._overview.animateToOverview(state, () => this._showDone()); + } + + _showDone() { + this._animationInProgress = false; + this._coverPane.hide(); + + if (this._shownState !== OverviewShownState.SHOWN) + this._changeShownState(OverviewShownState.SHOWN); + + // Handle any calls to hide* while we were showing + if (!this._shown) + this._animateNotVisible(); + + this._syncGrab(); + } + + // hide: + // + // Reverses the effect of show() + hide() { + if (this.isDummy) + return; + + if (!this._shown) + return; + + let event = Clutter.get_current_event(); + if (event) { + let type = event.type(); + let button = type == Clutter.EventType.BUTTON_PRESS || + type == Clutter.EventType.BUTTON_RELEASE; + let ctrl = (event.get_state() & Clutter.ModifierType.CONTROL_MASK) != 0; + if (button && ctrl) + return; + } + + this._shown = false; + + this._animateNotVisible(); + this._syncGrab(); + } + + _animateNotVisible() { + if (!this._visible || this._animationInProgress) + return; + + this._animationInProgress = true; + this._visibleTarget = false; + + Main.layoutManager.overviewGroup.set_child_above_sibling( + this._coverPane, null); + this._coverPane.show(); + + this._overview.prepareToLeaveOverview(); + this._changeShownState(OverviewShownState.HIDING); + this._overview.animateFromOverview(() => this._hideDone()); + } + + _hideDone() { + // Re-enable unredirection + Meta.enable_unredirect_for_display(global.display); + + this._coverPane.hide(); + + this._visible = false; + this._animationInProgress = false; + + // Handle any calls to show* while we were hiding + if (this._shown) { + this._changeShownState(OverviewShownState.HIDDEN); + this._animateVisible(OverviewControls.ControlsState.WINDOW_PICKER); + } else { + Main.layoutManager.hideOverview(); + this._changeShownState(OverviewShownState.HIDDEN); + } + + Main.panel.style = null; + + this._syncGrab(); + } + + toggle() { + if (this.isDummy) + return; + + if (this._visible) + this.hide(); + else + this.show(); + } + + showApps() { + this.show(OverviewControls.ControlsState.APP_GRID); + } + + selectApp(id) { + this.showApps(); + this._overview.controls.appDisplay.selectApp(id); + } + + runStartupAnimation(callback) { + Main.panel.style = 'transition-duration: 0ms;'; + + this._shown = true; + this._visible = true; + this._visibleTarget = true; + Main.layoutManager.showOverview(); + // We should call this._syncGrab() here, but moved it to happen after + // the animation because of a race in the xserver where the grab + // fails when requested very early during startup. + + Meta.disable_unredirect_for_display(global.display); + + this._changeShownState(OverviewShownState.SHOWING); + + this._overview.runStartupAnimation(() => { + // Overview got hidden during startup animation + if (this._shownState !== OverviewShownState.SHOWING) { + callback(); + return; + } + + if (!this._syncGrab()) { + callback(); + this.hide(); + return; + } + + Main.panel.style = null; + this._changeShownState(OverviewShownState.SHOWN); + callback(); + }); + } + + getShowAppsButton() { + logError(new Error('Usage of Overview.\'getShowAppsButton\' is deprecated, ' + + 'use \'dash.showAppsButton\' property instead')); + + return this.dash.showAppsButton; + } + + get searchEntry() { + return this._overview.searchEntry; + } +}; diff --git a/js/ui/overviewControls.js b/js/ui/overviewControls.js new file mode 100644 index 0000000..29aac35 --- /dev/null +++ b/js/ui/overviewControls.js @@ -0,0 +1,867 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported ControlsManager */ + +const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; + +const AppDisplay = imports.ui.appDisplay; +const Dash = imports.ui.dash; +const Layout = imports.ui.layout; +const Main = imports.ui.main; +const Overview = imports.ui.overview; +const SearchController = imports.ui.searchController; +const Util = imports.misc.util; +const WindowManager = imports.ui.windowManager; +const WorkspaceThumbnail = imports.ui.workspaceThumbnail; +const WorkspacesView = imports.ui.workspacesView; + +const SMALL_WORKSPACE_RATIO = 0.15; +const DASH_MAX_HEIGHT_RATIO = 0.15; + +const A11Y_SCHEMA = 'org.gnome.desktop.a11y.keyboard'; + +var SIDE_CONTROLS_ANIMATION_TIME = Overview.ANIMATION_TIME; + +var ControlsState = { + HIDDEN: 0, + WINDOW_PICKER: 1, + APP_GRID: 2, +}; + +var ControlsManagerLayout = GObject.registerClass( +class ControlsManagerLayout extends Clutter.BoxLayout { + _init(searchEntry, appDisplay, workspacesDisplay, workspacesThumbnails, + searchController, dash, stateAdjustment) { + super._init({ orientation: Clutter.Orientation.VERTICAL }); + + this._appDisplay = appDisplay; + this._workspacesDisplay = workspacesDisplay; + this._workspacesThumbnails = workspacesThumbnails; + this._stateAdjustment = stateAdjustment; + this._searchEntry = searchEntry; + this._searchController = searchController; + this._dash = dash; + + this._cachedWorkspaceBoxes = new Map(); + this._postAllocationCallbacks = []; + + stateAdjustment.connect('notify::value', () => this.layout_changed()); + + this._workAreaBox = new Clutter.ActorBox(); + global.display.connectObject( + 'workareas-changed', () => this._updateWorkAreaBox(), + this); + this._updateWorkAreaBox(); + } + + _updateWorkAreaBox() { + const monitor = Main.layoutManager.primaryMonitor; + if (!monitor) + return; + + const workArea = Main.layoutManager.getWorkAreaForMonitor(monitor.index); + const startX = workArea.x - monitor.x; + const startY = workArea.y - monitor.y; + this._workAreaBox.set_origin(startX, startY); + this._workAreaBox.set_size(workArea.width, workArea.height); + } + + _computeWorkspacesBoxForState(state, box, searchHeight, dashHeight, thumbnailsHeight) { + const workspaceBox = box.copy(); + const [width, height] = workspaceBox.get_size(); + const {y1: startY} = this._workAreaBox; + const {spacing} = this; + const {expandFraction} = this._workspacesThumbnails; + + switch (state) { + case ControlsState.HIDDEN: + workspaceBox.set_origin(...this._workAreaBox.get_origin()); + workspaceBox.set_size(...this._workAreaBox.get_size()); + break; + case ControlsState.WINDOW_PICKER: + workspaceBox.set_origin(0, + startY + searchHeight + spacing + + thumbnailsHeight + spacing * expandFraction); + workspaceBox.set_size(width, + height - + dashHeight - spacing - + searchHeight - spacing - + thumbnailsHeight - spacing * expandFraction); + break; + case ControlsState.APP_GRID: + workspaceBox.set_origin(0, startY + searchHeight + spacing); + workspaceBox.set_size( + width, + Math.round(height * SMALL_WORKSPACE_RATIO)); + break; + } + + return workspaceBox; + } + + _getAppDisplayBoxForState(state, box, searchHeight, dashHeight, appGridBox) { + const [width, height] = box.get_size(); + const {y1: startY} = this._workAreaBox; + const appDisplayBox = new Clutter.ActorBox(); + const {spacing} = this; + + switch (state) { + case ControlsState.HIDDEN: + case ControlsState.WINDOW_PICKER: + appDisplayBox.set_origin(0, box.y2); + break; + case ControlsState.APP_GRID: + appDisplayBox.set_origin(0, + startY + searchHeight + spacing + appGridBox.get_height()); + break; + } + + appDisplayBox.set_size(width, + height - + searchHeight - spacing - + appGridBox.get_height() - spacing - + dashHeight); + + return appDisplayBox; + } + + _runPostAllocation() { + if (this._postAllocationCallbacks.length === 0) + return; + + this._postAllocationCallbacks.forEach(cb => cb()); + this._postAllocationCallbacks = []; + } + + vfunc_set_container(container) { + this._container = container; + if (container) + this.hookup_style(container); + } + + vfunc_get_preferred_width(_container, _forHeight) { + // The MonitorConstraint will allocate us a fixed size anyway + return [0, 0]; + } + + vfunc_get_preferred_height(_container, _forWidth) { + // The MonitorConstraint will allocate us a fixed size anyway + return [0, 0]; + } + + vfunc_allocate(container, box) { + const childBox = new Clutter.ActorBox(); + + const { spacing } = this; + + const startY = this._workAreaBox.y1; + box.y1 += startY; + const [width, height] = box.get_size(); + let availableHeight = height; + + // Search entry + let [searchHeight] = this._searchEntry.get_preferred_height(width); + childBox.set_origin(0, startY); + childBox.set_size(width, searchHeight); + this._searchEntry.allocate(childBox); + + availableHeight -= searchHeight + spacing; + + // Dash + const maxDashHeight = Math.round(box.get_height() * DASH_MAX_HEIGHT_RATIO); + this._dash.setMaxSize(width, maxDashHeight); + + let [, dashHeight] = this._dash.get_preferred_height(width); + dashHeight = Math.min(dashHeight, maxDashHeight); + childBox.set_origin(0, startY + height - dashHeight); + childBox.set_size(width, dashHeight); + this._dash.allocate(childBox); + + availableHeight -= dashHeight + spacing; + + // Workspace Thumbnails + let thumbnailsHeight = 0; + if (this._workspacesThumbnails.visible) { + const { expandFraction } = this._workspacesThumbnails; + [thumbnailsHeight] = + this._workspacesThumbnails.get_preferred_height(width); + thumbnailsHeight = Math.min( + thumbnailsHeight * expandFraction, + height * WorkspaceThumbnail.MAX_THUMBNAIL_SCALE); + childBox.set_origin(0, startY + searchHeight + spacing); + childBox.set_size(width, thumbnailsHeight); + this._workspacesThumbnails.allocate(childBox); + } + + // Workspaces + let params = [box, searchHeight, dashHeight, thumbnailsHeight]; + const transitionParams = this._stateAdjustment.getStateTransitionParams(); + + // Update cached boxes + for (const state of Object.values(ControlsState)) { + this._cachedWorkspaceBoxes.set( + state, this._computeWorkspacesBoxForState(state, ...params)); + } + + let workspacesBox; + if (!transitionParams.transitioning) { + workspacesBox = this._cachedWorkspaceBoxes.get(transitionParams.currentState); + } else { + const initialBox = this._cachedWorkspaceBoxes.get(transitionParams.initialState); + const finalBox = this._cachedWorkspaceBoxes.get(transitionParams.finalState); + workspacesBox = initialBox.interpolate(finalBox, transitionParams.progress); + } + + this._workspacesDisplay.allocate(workspacesBox); + + // AppDisplay + if (this._appDisplay.visible) { + const workspaceAppGridBox = + this._cachedWorkspaceBoxes.get(ControlsState.APP_GRID); + + params = [box, searchHeight, dashHeight, workspaceAppGridBox]; + let appDisplayBox; + if (!transitionParams.transitioning) { + appDisplayBox = + this._getAppDisplayBoxForState(transitionParams.currentState, ...params); + } else { + const initialBox = + this._getAppDisplayBoxForState(transitionParams.initialState, ...params); + const finalBox = + this._getAppDisplayBoxForState(transitionParams.finalState, ...params); + + appDisplayBox = initialBox.interpolate(finalBox, transitionParams.progress); + } + + this._appDisplay.allocate(appDisplayBox); + } + + // Search + childBox.set_origin(0, startY + searchHeight + spacing); + childBox.set_size(width, availableHeight); + + this._searchController.allocate(childBox); + + this._runPostAllocation(); + } + + ensureAllocation() { + this.layout_changed(); + return new Promise( + resolve => this._postAllocationCallbacks.push(resolve)); + } + + getWorkspacesBoxForState(state) { + return this._cachedWorkspaceBoxes.get(state); + } +}); + +var OverviewAdjustment = GObject.registerClass({ + Properties: { + 'gesture-in-progress': GObject.ParamSpec.boolean( + 'gesture-in-progress', 'Gesture in progress', 'Gesture in progress', + GObject.ParamFlags.READWRITE, + false), + }, +}, class OverviewAdjustment extends St.Adjustment { + _init(actor) { + super._init({ + actor, + value: ControlsState.WINDOW_PICKER, + lower: ControlsState.HIDDEN, + upper: ControlsState.APP_GRID, + }); + } + + getStateTransitionParams() { + const currentState = this.value; + + const transition = this.get_transition('value'); + let initialState = transition + ? transition.get_interval().peek_initial_value() + : currentState; + let finalState = transition + ? transition.get_interval().peek_final_value() + : currentState; + + if (initialState > finalState) { + initialState = Math.ceil(initialState); + finalState = Math.floor(finalState); + } else { + initialState = Math.floor(initialState); + finalState = Math.ceil(finalState); + } + + const length = Math.abs(finalState - initialState); + const progress = length > 0 + ? Math.abs((currentState - initialState) / length) + : 1; + + return { + transitioning: transition !== null || this.gestureInProgress, + currentState, + initialState, + finalState, + progress, + }; + } +}); + +var ControlsManager = GObject.registerClass( +class ControlsManager extends St.Widget { + _init() { + super._init({ + style_class: 'controls-manager', + x_expand: true, + y_expand: true, + clip_to_allocation: true, + }); + + this._ignoreShowAppsButtonToggle = false; + + this._searchEntry = new St.Entry({ + style_class: 'search-entry', + /* Translators: this is the text displayed + in the search entry when no search is + active; it should not exceed ~30 + characters. */ + hint_text: _('Type to search'), + track_hover: true, + can_focus: true, + }); + this._searchEntry.set_offscreen_redirect(Clutter.OffscreenRedirect.ALWAYS); + this._searchEntryBin = new St.Bin({ + child: this._searchEntry, + x_align: Clutter.ActorAlign.CENTER, + }); + + this.dash = new Dash.Dash(); + + let workspaceManager = global.workspace_manager; + let activeWorkspaceIndex = workspaceManager.get_active_workspace_index(); + + this._workspaceAdjustment = new St.Adjustment({ + actor: this, + value: activeWorkspaceIndex, + lower: 0, + page_increment: 1, + page_size: 1, + step_increment: 0, + upper: workspaceManager.n_workspaces, + }); + + this._stateAdjustment = new OverviewAdjustment(this); + this._stateAdjustment.connect('notify::value', this._update.bind(this)); + + workspaceManager.connectObject( + 'notify::n-workspaces', () => this._updateAdjustment(), this); + + this._searchController = new SearchController.SearchController( + this._searchEntry, + this.dash.showAppsButton); + this._searchController.connect('notify::search-active', this._onSearchChanged.bind(this)); + + Main.layoutManager.connect('monitors-changed', () => { + this._thumbnailsBox.setMonitorIndex(Main.layoutManager.primaryIndex); + }); + this._thumbnailsBox = new WorkspaceThumbnail.ThumbnailsBox( + this._workspaceAdjustment, Main.layoutManager.primaryIndex); + this._thumbnailsBox.connect('notify::should-show', () => { + this._thumbnailsBox.show(); + this._thumbnailsBox.ease_property('expand-fraction', + this._thumbnailsBox.should_show ? 1 : 0, { + duration: SIDE_CONTROLS_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._updateThumbnailsBox(), + }); + }); + + this._workspacesDisplay = new WorkspacesView.WorkspacesDisplay( + this, + this._workspaceAdjustment, + this._stateAdjustment); + this._appDisplay = new AppDisplay.AppDisplay(); + + this.add_child(this._searchEntryBin); + this.add_child(this._appDisplay); + this.add_child(this.dash); + this.add_child(this._searchController); + this.add_child(this._thumbnailsBox); + this.add_child(this._workspacesDisplay); + + this.layout_manager = new ControlsManagerLayout( + this._searchEntryBin, + this._appDisplay, + this._workspacesDisplay, + this._thumbnailsBox, + this._searchController, + this.dash, + this._stateAdjustment); + + this.dash.showAppsButton.connect('notify::checked', + this._onShowAppsButtonToggled.bind(this)); + + Main.ctrlAltTabManager.addGroup( + this.appDisplay, + _('Applications'), + 'view-app-grid-symbolic', { + proxy: this, + focusCallback: () => { + this.dash.showAppsButton.checked = true; + this.appDisplay.navigate_focus( + null, St.DirectionType.TAB_FORWARD, false); + }, + }); + + Main.ctrlAltTabManager.addGroup( + this._workspacesDisplay, + _('Windows'), + 'focus-windows-symbolic', { + proxy: this, + focusCallback: () => { + this.dash.showAppsButton.checked = false; + this._workspacesDisplay.navigate_focus( + null, St.DirectionType.TAB_FORWARD, false); + }, + }); + + this._a11ySettings = new Gio.Settings({ schema_id: A11Y_SCHEMA }); + + this._lastOverlayKeyTime = 0; + global.display.connect('overlay-key', () => { + if (this._a11ySettings.get_boolean('stickykeys-enable')) + return; + + const { initialState, finalState, transitioning } = + this._stateAdjustment.getStateTransitionParams(); + + const time = GLib.get_monotonic_time() / 1000; + const timeDiff = time - this._lastOverlayKeyTime; + this._lastOverlayKeyTime = time; + + const shouldShift = St.Settings.get().enable_animations + ? transitioning && finalState > initialState + : Main.overview.visible && timeDiff < Overview.ANIMATION_TIME; + + if (shouldShift) + this._shiftState(Meta.MotionDirection.UP); + else + Main.overview.toggle(); + }); + + // connect_after to give search controller first dibs on the event + global.stage.connect_after('key-press-event', (actor, event) => { + if (this._searchController.searchActive) + return Clutter.EVENT_PROPAGATE; + + if (global.stage.key_focus && + !this.contains(global.stage.key_focus)) + return Clutter.EVENT_PROPAGATE; + + const { finalState } = + this._stateAdjustment.getStateTransitionParams(); + let keynavDisplay; + + if (finalState === ControlsState.WINDOW_PICKER) + keynavDisplay = this._workspacesDisplay; + else if (finalState === ControlsState.APP_GRID) + keynavDisplay = this._appDisplay; + + if (!keynavDisplay) + return Clutter.EVENT_PROPAGATE; + + const symbol = event.get_key_symbol(); + if (symbol === Clutter.KEY_Tab || symbol === Clutter.KEY_Down) { + keynavDisplay.navigate_focus( + null, St.DirectionType.TAB_FORWARD, false); + return Clutter.EVENT_STOP; + } else if (symbol === Clutter.KEY_ISO_Left_Tab) { + keynavDisplay.navigate_focus( + null, St.DirectionType.TAB_BACKWARD, false); + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_PROPAGATE; + }); + + Main.wm.addKeybinding( + 'toggle-application-view', + new Gio.Settings({ schema_id: WindowManager.SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, + this._toggleAppsPage.bind(this)); + + Main.wm.addKeybinding('shift-overview-up', + new Gio.Settings({ schema_id: WindowManager.SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, + () => this._shiftState(Meta.MotionDirection.UP)); + + Main.wm.addKeybinding('shift-overview-down', + new Gio.Settings({ schema_id: WindowManager.SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, + () => this._shiftState(Meta.MotionDirection.DOWN)); + + this._update(); + } + + _getFitModeForState(state) { + switch (state) { + case ControlsState.HIDDEN: + case ControlsState.WINDOW_PICKER: + return WorkspacesView.FitMode.SINGLE; + case ControlsState.APP_GRID: + return WorkspacesView.FitMode.ALL; + default: + return WorkspacesView.FitMode.SINGLE; + } + } + + _getThumbnailsBoxParams() { + const { initialState, finalState, progress } = + this._stateAdjustment.getStateTransitionParams(); + + const paramsForState = s => { + let opacity, scale, translationY; + switch (s) { + case ControlsState.HIDDEN: + case ControlsState.WINDOW_PICKER: + opacity = 255; + scale = 1; + translationY = 0; + break; + case ControlsState.APP_GRID: + opacity = 0; + scale = 0.5; + translationY = this._thumbnailsBox.height / 2; + break; + default: + opacity = 255; + scale = 1; + translationY = 0; + break; + } + + return { opacity, scale, translationY }; + }; + + const initialParams = paramsForState(initialState); + const finalParams = paramsForState(finalState); + + return [ + Util.lerp(initialParams.opacity, finalParams.opacity, progress), + Util.lerp(initialParams.scale, finalParams.scale, progress), + Util.lerp(initialParams.translationY, finalParams.translationY, progress), + ]; + } + + _updateThumbnailsBox(animate = false) { + const { shouldShow } = this._thumbnailsBox; + const { searchActive } = this._searchController; + const [opacity, scale, translationY] = this._getThumbnailsBoxParams(); + + const thumbnailsBoxVisible = shouldShow && !searchActive && opacity !== 0; + if (thumbnailsBoxVisible) { + this._thumbnailsBox.opacity = 0; + this._thumbnailsBox.visible = thumbnailsBoxVisible; + } + + const params = { + opacity: searchActive ? 0 : opacity, + duration: animate ? SIDE_CONTROLS_ANIMATION_TIME : 0, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => (this._thumbnailsBox.visible = thumbnailsBoxVisible), + }; + + if (!searchActive) { + params.scale_x = scale; + params.scale_y = scale; + params.translation_y = translationY; + } + + this._thumbnailsBox.ease(params); + } + + _updateAppDisplayVisibility(stateTransitionParams = null) { + if (!stateTransitionParams) + stateTransitionParams = this._stateAdjustment.getStateTransitionParams(); + + const { initialState, finalState } = stateTransitionParams; + const state = Math.max(initialState, finalState); + + this._appDisplay.visible = + state > ControlsState.WINDOW_PICKER && + !this._searchController.searchActive; + } + + _update() { + const params = this._stateAdjustment.getStateTransitionParams(); + + const fitMode = Util.lerp( + this._getFitModeForState(params.initialState), + this._getFitModeForState(params.finalState), + params.progress); + + const { fitModeAdjustment } = this._workspacesDisplay; + fitModeAdjustment.value = fitMode; + + this._updateThumbnailsBox(); + this._updateAppDisplayVisibility(params); + } + + _onSearchChanged() { + const { searchActive } = this._searchController; + + if (!searchActive) { + this._updateAppDisplayVisibility(); + this._workspacesDisplay.reactive = true; + this._workspacesDisplay.setPrimaryWorkspaceVisible(true); + } else { + this._searchController.show(); + } + + this._updateThumbnailsBox(true); + + this._appDisplay.ease({ + opacity: searchActive ? 0 : 255, + duration: SIDE_CONTROLS_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._updateAppDisplayVisibility(), + }); + this._workspacesDisplay.ease({ + opacity: searchActive ? 0 : 255, + duration: SIDE_CONTROLS_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._workspacesDisplay.reactive = !searchActive; + this._workspacesDisplay.setPrimaryWorkspaceVisible(!searchActive); + }, + }); + this._searchController.ease({ + opacity: searchActive ? 255 : 0, + duration: SIDE_CONTROLS_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => (this._searchController.visible = searchActive), + }); + } + + _onShowAppsButtonToggled() { + if (this._ignoreShowAppsButtonToggle) + return; + + const checked = this.dash.showAppsButton.checked; + + const value = checked + ? ControlsState.APP_GRID : ControlsState.WINDOW_PICKER; + this._stateAdjustment.remove_transition('value'); + this._stateAdjustment.ease(value, { + duration: SIDE_CONTROLS_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + _toggleAppsPage() { + if (Main.overview.visible) { + const checked = this.dash.showAppsButton.checked; + this.dash.showAppsButton.checked = !checked; + } else { + Main.overview.show(ControlsState.APP_GRID); + } + } + + _shiftState(direction) { + let { currentState, finalState } = this._stateAdjustment.getStateTransitionParams(); + + if (direction === Meta.MotionDirection.DOWN) + finalState = Math.max(finalState - 1, ControlsState.HIDDEN); + else if (direction === Meta.MotionDirection.UP) + finalState = Math.min(finalState + 1, ControlsState.APP_GRID); + + if (finalState === currentState) + return; + + if (currentState === ControlsState.HIDDEN && + finalState === ControlsState.WINDOW_PICKER) { + Main.overview.show(); + } else if (finalState === ControlsState.HIDDEN) { + Main.overview.hide(); + } else { + this._stateAdjustment.ease(finalState, { + duration: SIDE_CONTROLS_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this.dash.showAppsButton.checked = + finalState === ControlsState.APP_GRID; + }, + }); + } + } + + _updateAdjustment() { + let workspaceManager = global.workspace_manager; + let newNumWorkspaces = workspaceManager.n_workspaces; + let activeIndex = workspaceManager.get_active_workspace_index(); + + this._workspaceAdjustment.upper = newNumWorkspaces; + + // A workspace might have been inserted or removed before the active + // one, causing the adjustment to go out of sync, so update the value + this._workspaceAdjustment.remove_transition('value'); + this._workspaceAdjustment.value = activeIndex; + } + + vfunc_unmap() { + super.vfunc_unmap(); + this._workspacesDisplay.hide(); + } + + prepareToEnterOverview() { + this._searchController.prepareToEnterOverview(); + this._workspacesDisplay.prepareToEnterOverview(); + } + + prepareToLeaveOverview() { + this._workspacesDisplay.prepareToLeaveOverview(); + } + + animateToOverview(state, callback) { + this._ignoreShowAppsButtonToggle = true; + + this._stateAdjustment.value = ControlsState.HIDDEN; + this._stateAdjustment.ease(state, { + duration: Overview.ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onStopped: () => { + if (callback) + callback(); + }, + }); + + this.dash.showAppsButton.checked = + state === ControlsState.APP_GRID; + + this._ignoreShowAppsButtonToggle = false; + } + + animateFromOverview(callback) { + this._ignoreShowAppsButtonToggle = true; + + this._stateAdjustment.ease(ControlsState.HIDDEN, { + duration: Overview.ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onStopped: () => { + this.dash.showAppsButton.checked = false; + this._ignoreShowAppsButtonToggle = false; + + if (callback) + callback(); + }, + }); + } + + getWorkspacesBoxForState(state) { + return this.layoutManager.getWorkspacesBoxForState(state); + } + + gestureBegin(tracker) { + const baseDistance = global.screen_height; + const progress = this._stateAdjustment.value; + const points = [ + ControlsState.HIDDEN, + ControlsState.WINDOW_PICKER, + ControlsState.APP_GRID, + ]; + + const transition = this._stateAdjustment.get_transition('value'); + const cancelProgress = transition + ? transition.get_interval().peek_final_value() + : Math.round(progress); + this._stateAdjustment.remove_transition('value'); + + tracker.confirmSwipe(baseDistance, points, progress, cancelProgress); + this.prepareToEnterOverview(); + this._stateAdjustment.gestureInProgress = true; + } + + gestureProgress(progress) { + this._stateAdjustment.value = progress; + } + + gestureEnd(target, duration, onComplete) { + if (target === ControlsState.HIDDEN) + this.prepareToLeaveOverview(); + + this.dash.showAppsButton.checked = + target === ControlsState.APP_GRID; + + this._stateAdjustment.remove_transition('value'); + this._stateAdjustment.ease(target, { + duration, + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + onComplete, + }); + + this._stateAdjustment.gestureInProgress = false; + } + + async runStartupAnimation(callback) { + this._ignoreShowAppsButtonToggle = true; + + this.prepareToEnterOverview(); + + this._stateAdjustment.value = ControlsState.HIDDEN; + this._stateAdjustment.ease(ControlsState.WINDOW_PICKER, { + duration: Overview.ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + + this.dash.showAppsButton.checked = false; + this._ignoreShowAppsButtonToggle = false; + + // Set the opacity here to avoid a 1-frame flicker + this.opacity = 0; + + // We can't run the animation before the first allocation happens + await this.layout_manager.ensureAllocation(); + + const { STARTUP_ANIMATION_TIME } = Layout; + + // Opacity + this.ease({ + opacity: 255, + duration: STARTUP_ANIMATION_TIME, + mode: Clutter.AnimationMode.LINEAR, + }); + + // Search bar falls from the ceiling + const { primaryMonitor } = Main.layoutManager; + const [, y] = this._searchEntryBin.get_transformed_position(); + const yOffset = y - primaryMonitor.y; + + this._searchEntryBin.translation_y = -(yOffset + this._searchEntryBin.height); + this._searchEntryBin.ease({ + translation_y: 0, + duration: STARTUP_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + + // The Dash rises from the bottom. This is the last animation to finish, + // so run the callback there. + this.dash.translation_y = this.dash.height + this.dash.margin_bottom; + this.dash.ease({ + translation_y: 0, + delay: STARTUP_ANIMATION_TIME, + duration: STARTUP_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => callback(), + }); + } + + get searchEntry() { + return this._searchEntry; + } + + get appDisplay() { + return this._appDisplay; + } +}); diff --git a/js/ui/padOsd.js b/js/ui/padOsd.js new file mode 100644 index 0000000..e1e24f7 --- /dev/null +++ b/js/ui/padOsd.js @@ -0,0 +1,991 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported PadOsd, PadOsdService */ + +const { + Atk, Clutter, GDesktopEnums, Gio, + GLib, GObject, Gtk, Meta, Pango, Rsvg, St, +} = imports.gi; +const Signals = imports.misc.signals; + +const Main = imports.ui.main; +const PopupMenu = imports.ui.popupMenu; +const Layout = imports.ui.layout; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const ACTIVE_COLOR = "#729fcf"; + +const LTR = 0; +const RTL = 1; + +const CW = 0; +const CCW = 1; + +const UP = 0; +const DOWN = 1; + +var PadChooser = GObject.registerClass({ + Signals: { 'pad-selected': { param_types: [Clutter.InputDevice.$gtype] } }, +}, class PadChooser extends St.Button { + _init(device, groupDevices) { + super._init({ + style_class: 'pad-chooser-button', + toggle_mode: true, + }); + this.currentDevice = device; + this._padChooserMenu = null; + + let arrow = new St.Icon({ + style_class: 'popup-menu-arrow', + icon_name: 'pan-down-symbolic', + accessible_role: Atk.Role.ARROW, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + this.set_child(arrow); + this._ensureMenu(groupDevices); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + vfunc_clicked() { + if (this.get_checked()) { + if (this._padChooserMenu != null) + this._padChooserMenu.open(true); + else + this.set_checked(false); + } else { + this._padChooserMenu.close(true); + } + } + + _ensureMenu(devices) { + this._padChooserMenu = new PopupMenu.PopupMenu(this, 0.5, St.Side.TOP); + this._padChooserMenu.connect('menu-closed', () => { + this.set_checked(false); + }); + this._padChooserMenu.actor.hide(); + Main.uiGroup.add_actor(this._padChooserMenu.actor); + + this._menuManager = new PopupMenu.PopupMenuManager(this); + this._menuManager.addMenu(this._padChooserMenu); + + for (let i = 0; i < devices.length; i++) { + let device = devices[i]; + if (device == this.currentDevice) + continue; + + this._padChooserMenu.addAction(device.get_device_name(), () => { + this.emit('pad-selected', device); + }); + } + } + + _onDestroy() { + this._padChooserMenu.destroy(); + } + + update(devices) { + if (this._padChooserMenu) + this._padChooserMenu.actor.destroy(); + this.set_checked(false); + this._ensureMenu(devices); + } +}); + +var KeybindingEntry = GObject.registerClass({ + Signals: { 'keybinding-edited': { param_types: [GObject.TYPE_STRING] } }, +}, class KeybindingEntry extends St.Entry { + _init() { + super._init({ hint_text: _("New shortcut…"), style: 'width: 10em' }); + } + + vfunc_captured_event(event) { + if (event.type() != Clutter.EventType.KEY_PRESS) + return Clutter.EVENT_PROPAGATE; + + let str = Gtk.accelerator_name_with_keycode(null, + event.get_key_symbol(), + event.get_key_code(), + event.get_state()); + this.set_text(str); + this.emit('keybinding-edited', str); + return Clutter.EVENT_STOP; + } +}); + +var ActionComboBox = GObject.registerClass({ + Signals: { 'action-selected': { param_types: [GObject.TYPE_INT] } }, +}, class ActionComboBox extends St.Button { + _init() { + super._init({ style_class: 'button' }); + this.set_toggle_mode(true); + + const boxLayout = new Clutter.BoxLayout({ + orientation: Clutter.Orientation.HORIZONTAL, + spacing: 6, + }); + let box = new St.Widget({ layout_manager: boxLayout }); + this.set_child(box); + + this._label = new St.Label({ style_class: 'combo-box-label' }); + box.add_child(this._label); + + const arrow = new St.Icon({ + style_class: 'popup-menu-arrow', + icon_name: 'pan-down-symbolic', + accessible_role: Atk.Role.ARROW, + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + box.add_child(arrow); + + this._editMenu = new PopupMenu.PopupMenu(this, 0, St.Side.TOP); + this._editMenu.connect('menu-closed', () => { + this.set_checked(false); + }); + this._editMenu.actor.hide(); + Main.uiGroup.add_actor(this._editMenu.actor); + + this._editMenuManager = new PopupMenu.PopupMenuManager(this); + this._editMenuManager.addMenu(this._editMenu); + + this._actionLabels = new Map(); + this._actionLabels.set(GDesktopEnums.PadButtonAction.NONE, _("Application defined")); + this._actionLabels.set(GDesktopEnums.PadButtonAction.HELP, _("Show on-screen help")); + this._actionLabels.set(GDesktopEnums.PadButtonAction.SWITCH_MONITOR, _("Switch monitor")); + this._actionLabels.set(GDesktopEnums.PadButtonAction.KEYBINDING, _("Assign keystroke")); + + this._buttonItems = []; + + for (let [action, label] of this._actionLabels.entries()) { + let selectedAction = action; + let item = this._editMenu.addAction(label, () => { + this._onActionSelected(selectedAction); + }); + + /* These actions only apply to pad buttons */ + if (selectedAction == GDesktopEnums.PadButtonAction.HELP || + selectedAction == GDesktopEnums.PadButtonAction.SWITCH_MONITOR) + this._buttonItems.push(item); + } + + this.setAction(GDesktopEnums.PadButtonAction.NONE); + } + + _onActionSelected(action) { + this.setAction(action); + this.popdown(); + this.emit('action-selected', action); + } + + setAction(action) { + this._label.set_text(this._actionLabels.get(action)); + } + + popup() { + this._editMenu.open(true); + } + + popdown() { + this._editMenu.close(true); + } + + vfunc_clicked() { + if (this.get_checked()) + this.popup(); + else + this.popdown(); + } + + setButtonActionsActive(active) { + this._buttonItems.forEach(item => item.setSensitive(active)); + } +}); + +var ActionEditor = GObject.registerClass({ + Signals: { 'done': {} }, +}, class ActionEditor extends St.Widget { + _init() { + const boxLayout = new Clutter.BoxLayout({ + orientation: Clutter.Orientation.HORIZONTAL, + spacing: 12, + }); + + super._init({ layout_manager: boxLayout }); + + this._actionComboBox = new ActionComboBox(); + this._actionComboBox.connect('action-selected', this._onActionSelected.bind(this)); + this.add_actor(this._actionComboBox); + + this._keybindingEdit = new KeybindingEntry(); + this._keybindingEdit.connect('keybinding-edited', this._onKeybindingEdited.bind(this)); + this.add_actor(this._keybindingEdit); + + this._doneButton = new St.Button({ + label: _('Done'), + style_class: 'button', + x_expand: false, + }); + this._doneButton.connect('clicked', this._onEditingDone.bind(this)); + this.add_actor(this._doneButton); + } + + _updateKeybindingEntryState() { + if (this._currentAction == GDesktopEnums.PadButtonAction.KEYBINDING) { + this._keybindingEdit.set_text(this._currentKeybinding); + this._keybindingEdit.show(); + this._keybindingEdit.grab_key_focus(); + } else { + this._keybindingEdit.hide(); + } + } + + setSettings(settings, action) { + this._buttonSettings = settings; + + this._currentAction = this._buttonSettings.get_enum('action'); + this._currentKeybinding = this._buttonSettings.get_string('keybinding'); + this._actionComboBox.setAction(this._currentAction); + this._updateKeybindingEntryState(); + + let isButton = action == Meta.PadActionType.BUTTON; + this._actionComboBox.setButtonActionsActive(isButton); + } + + close() { + this._actionComboBox.popdown(); + this.hide(); + } + + _onKeybindingEdited(entry, keybinding) { + this._currentKeybinding = keybinding; + } + + _onActionSelected(menu, action) { + this._currentAction = action; + this._updateKeybindingEntryState(); + } + + _storeSettings() { + if (!this._buttonSettings) + return; + + let keybinding = null; + + if (this._currentAction == GDesktopEnums.PadButtonAction.KEYBINDING) + keybinding = this._currentKeybinding; + + this._buttonSettings.set_enum('action', this._currentAction); + + if (keybinding) + this._buttonSettings.set_string('keybinding', keybinding); + else + this._buttonSettings.reset('keybinding'); + } + + _onEditingDone() { + this._storeSettings(); + this.close(); + this.emit('done'); + } +}); + +var PadDiagram = GObject.registerClass({ + Properties: { + 'left-handed': GObject.ParamSpec.boolean('left-handed', + 'left-handed', 'Left handed', + GObject.ParamFlags.READWRITE | + GObject.ParamFlags.CONSTRUCT_ONLY, + false), + 'image': GObject.ParamSpec.string('image', 'image', 'Image', + GObject.ParamFlags.READWRITE | + GObject.ParamFlags.CONSTRUCT_ONLY, + null), + 'editor-actor': GObject.ParamSpec.object('editor-actor', + 'editor-actor', + 'Editor actor', + GObject.ParamFlags.READWRITE | + GObject.ParamFlags.CONSTRUCT_ONLY, + Clutter.Actor.$gtype), + }, +}, class PadDiagram extends St.DrawingArea { + _init(params) { + let file = Gio.File.new_for_uri('resource:///org/gnome/shell/theme/pad-osd.css'); + let [success_, css] = file.load_contents(null); + this._curEdited = null; + this._prevEdited = null; + this._css = new TextDecoder().decode(css); + this._labels = []; + this._activeButtons = []; + super._init(params); + } + + get image() { + return this._imagePath; + } + + set image(imagePath) { + let originalHandle = Rsvg.Handle.new_from_file(imagePath); + let dimensions = originalHandle.get_dimensions(); + this._imageWidth = dimensions.width; + this._imageHeight = dimensions.height; + + this._imagePath = imagePath; + this._handle = this._composeStyledDiagram(); + this._initLabels(); + } + + get editorActor() { + return this._editorActor; + } + + set editorActor(actor) { + actor.hide(); + this._editorActor = actor; + this.add_actor(actor); + } + + _initLabels() { + let i = 0; + for (i = 0; ; i++) { + if (!this._addLabel(Meta.PadActionType.BUTTON, i)) + break; + } + + for (i = 0; ; i++) { + if (!this._addLabel(Meta.PadActionType.RING, i, CW) || + !this._addLabel(Meta.PadActionType.RING, i, CCW)) + break; + } + + for (i = 0; ; i++) { + if (!this._addLabel(Meta.PadActionType.STRIP, i, UP) || + !this._addLabel(Meta.PadActionType.STRIP, i, DOWN)) + break; + } + } + + _wrappingSvgHeader() { + return '<?xml version="1.0" encoding="UTF-8" standalone="no"?>' + + '<svg version="1.1" xmlns="http://www.w3.org/2000/svg" ' + + 'xmlns:xi="http://www.w3.org/2001/XInclude" ' + + `width="${this._imageWidth}" height="${this._imageHeight}"> ` + + '<style type="text/css">'; + } + + _wrappingSvgFooter() { + return '%s%s%s'.format( + '</style>', + '<xi:include href="%s" />'.format(this._imagePath), + '</svg>'); + } + + _cssString() { + let css = this._css; + + for (let i = 0; i < this._activeButtons.length; i++) { + let ch = String.fromCharCode('A'.charCodeAt() + this._activeButtons[i]); + css += `.${ch}.Leader { stroke: ${ACTIVE_COLOR} !important; }`; + css += `.${ch}.Button { stroke: ${ACTIVE_COLOR} !important; fill: ${ACTIVE_COLOR} !important; }`; + } + + return css; + } + + _composeStyledDiagram() { + let svgData = ''; + + if (!GLib.file_test(this._imagePath, GLib.FileTest.EXISTS)) + return null; + + svgData += this._wrappingSvgHeader(); + svgData += this._cssString(); + svgData += this._wrappingSvgFooter(); + + let istream = new Gio.MemoryInputStream(); + istream.add_bytes(new GLib.Bytes(svgData)); + + return Rsvg.Handle.new_from_stream_sync(istream, + Gio.File.new_for_path(this._imagePath), 0, null); + } + + _updateDiagramScale() { + [this._actorWidth, this._actorHeight] = this.get_size(); + let dimensions = this._handle.get_dimensions(); + let scaleX = this._actorWidth / dimensions.width; + let scaleY = this._actorHeight / dimensions.height; + this._scale = Math.min(scaleX, scaleY); + } + + _allocateChild(child, x, y, direction) { + let [, natHeight] = child.get_preferred_height(-1); + let [, natWidth] = child.get_preferred_width(natHeight); + let childBox = new Clutter.ActorBox(); + + // I miss Cairo.Matrix + let dimensions = this._handle.get_dimensions(); + x = x * this._scale + this._actorWidth / 2 - dimensions.width / 2 * this._scale; + y = y * this._scale + this._actorHeight / 2 - dimensions.height / 2 * this._scale; + + if (direction == LTR) { + childBox.x1 = x; + childBox.x2 = x + natWidth; + } else { + childBox.x1 = x - natWidth; + childBox.x2 = x; + } + + childBox.y1 = y - natHeight / 2; + childBox.y2 = y + natHeight / 2; + child.allocate(childBox); + } + + vfunc_allocate(box) { + super.vfunc_allocate(box); + if (this._handle === null) + return; + + this._updateDiagramScale(); + + for (let i = 0; i < this._labels.length; i++) { + const { label, x, y, arrangement } = this._labels[i]; + this._allocateChild(label, x, y, arrangement); + } + + if (this._editorActor && this._curEdited) { + const { x, y, arrangement } = this._curEdited; + this._allocateChild(this._editorActor, x, y, arrangement); + } + } + + vfunc_repaint() { + if (this._handle == null) + return; + + if (this._scale == null) + this._updateDiagramScale(); + + let [width, height] = this.get_surface_size(); + let dimensions = this._handle.get_dimensions(); + let cr = this.get_context(); + + cr.save(); + cr.translate(width / 2, height / 2); + cr.scale(this._scale, this._scale); + if (this.leftHanded) + cr.rotate(Math.PI); + cr.translate(-dimensions.width / 2, -dimensions.height / 2); + this._handle.render_cairo(cr); + cr.restore(); + cr.$dispose(); + } + + _getItemLabelCoords(labelName, leaderName) { + if (this._handle == null) + return [false]; + + const [labelFound, labelPos] = this._handle.get_position_sub(`#${labelName}`); + const [, labelSize] = this._handle.get_dimensions_sub(`#${labelName}`); + if (!labelFound) + return [false]; + + const [leaderFound, leaderPos] = this._handle.get_position_sub(`#${leaderName}`); + const [, leaderSize] = this._handle.get_dimensions_sub(`#${leaderName}`); + if (!leaderFound) + return [false]; + + let direction; + if (labelPos.x > leaderPos.x + leaderSize.width) + direction = LTR; + else + direction = RTL; + + let pos = {x: labelPos.x, y: labelPos.y + labelSize.height}; + if (this.leftHanded) { + direction = 1 - direction; + pos.x = this._imageWidth - pos.x; + pos.y = this._imageHeight - pos.y; + } + + return [true, pos.x, pos.y, direction]; + } + + _getButtonLabels(button) { + let ch = String.fromCharCode('A'.charCodeAt() + button); + const labelName = `Label${ch}`; + const leaderName = `Leader${ch}`; + return [labelName, leaderName]; + } + + _getRingLabels(number, dir) { + let numStr = number > 0 ? (number + 1).toString() : ''; + let dirStr = dir == CW ? 'CW' : 'CCW'; + const labelName = `LabelRing${numStr}${dirStr}`; + const leaderName = `LeaderRing${numStr}${dirStr}`; + return [labelName, leaderName]; + } + + _getStripLabels(number, dir) { + let numStr = number > 0 ? (number + 1).toString() : ''; + let dirStr = dir == UP ? 'Up' : 'Down'; + const labelName = `LabelStrip${numStr}${dirStr}`; + const leaderName = `LeaderStrip${numStr}${dirStr}`; + return [labelName, leaderName]; + } + + _getLabelCoords(action, idx, dir) { + if (action == Meta.PadActionType.BUTTON) + return this._getItemLabelCoords(...this._getButtonLabels(idx)); + else if (action == Meta.PadActionType.RING) + return this._getItemLabelCoords(...this._getRingLabels(idx, dir)); + else if (action == Meta.PadActionType.STRIP) + return this._getItemLabelCoords(...this._getStripLabels(idx, dir)); + + return [false]; + } + + _invalidateSvg() { + if (this._handle == null) + return; + this._handle = this._composeStyledDiagram(); + this.queue_repaint(); + } + + activateButton(button) { + this._activeButtons.push(button); + this._invalidateSvg(); + } + + deactivateButton(button) { + for (let i = 0; i < this._activeButtons.length; i++) { + if (this._activeButtons[i] == button) + this._activeButtons.splice(i, 1); + } + this._invalidateSvg(); + } + + _addLabel(action, idx, dir) { + let [found, x, y, arrangement] = this._getLabelCoords(action, idx, dir); + if (!found) + return false; + + let label = new St.Label(); + this._labels.push({ label, action, idx, dir, x, y, arrangement }); + this.add_actor(label); + return true; + } + + updateLabels(getText) { + for (let i = 0; i < this._labels.length; i++) { + const { label, action, idx, dir } = this._labels[i]; + let str = getText(action, idx, dir); + label.set_text(str); + } + + this.queue_relayout(); + } + + _applyLabel(label, action, idx, dir, str) { + if (str !== null) + label.set_text(str); + label.show(); + } + + stopEdition(continues, str) { + this._editorActor.hide(); + + if (this._prevEdited) { + const { label, action, idx, dir } = this._prevEdited; + this._applyLabel(label, action, idx, dir, str); + this._prevEdited = null; + } + + if (this._curEdited) { + const { label, action, idx, dir } = this._curEdited; + this._applyLabel(label, action, idx, dir, str); + if (continues) + this._prevEdited = this._curEdited; + this._curEdited = null; + } + + this.queue_relayout(); + } + + startEdition(action, idx, dir) { + let editedLabel; + + if (this._curEdited) + return; + + for (let i = 0; i < this._labels.length; i++) { + if (action == this._labels[i].action && + idx == this._labels[i].idx && dir == this._labels[i].dir) { + this._curEdited = this._labels[i]; + editedLabel = this._curEdited.label; + break; + } + } + + if (this._curEdited == null) + return; + this._editorActor.show(); + editedLabel.hide(); + this.queue_relayout(); + } +}); + +var PadOsd = GObject.registerClass({ + Signals: { + 'pad-selected': { param_types: [Clutter.InputDevice.$gtype] }, + 'closed': {}, + }, +}, class PadOsd extends St.BoxLayout { + _init(padDevice, settings, imagePath, editionMode, monitorIndex) { + super._init({ + style_class: 'pad-osd-window', + vertical: true, + x_expand: true, + y_expand: true, + reactive: true, + }); + + this.padDevice = padDevice; + this._groupPads = [padDevice]; + this._settings = settings; + this._imagePath = imagePath; + this._editionMode = editionMode; + this._padChooser = null; + + let seat = Clutter.get_default_backend().get_default_seat(); + seat.connectObject( + 'device-added', (_seat, device) => { + if (device.get_device_type() === Clutter.InputDeviceType.PAD_DEVICE && + this.padDevice.is_grouped(device)) { + this._groupPads.push(device); + this._updatePadChooser(); + } + }, + 'device-removed', (_seat, device) => { + // If the device is being removed, destroy the padOsd. + if (device === this.padDevice) { + this.destroy(); + } else if (this._groupPads.includes(device)) { + // Or update the pad chooser if the device belongs to + // the same group. + this._groupPads.splice(this._groupPads.indexOf(device), 1); + this._updatePadChooser(); + } + }, this); + + seat.list_devices().forEach(device => { + if (device != this.padDevice && + device.get_device_type() == Clutter.InputDeviceType.PAD_DEVICE && + this.padDevice.is_grouped(device)) + this._groupPads.push(device); + }); + + this.connect('destroy', this._onDestroy.bind(this)); + Main.uiGroup.add_actor(this); + + this._monitorIndex = monitorIndex; + let constraint = new Layout.MonitorConstraint({ index: monitorIndex }); + this.add_constraint(constraint); + + this._titleBox = new St.BoxLayout({ + style_class: 'pad-osd-title-box', + vertical: false, + x_expand: false, + x_align: Clutter.ActorAlign.CENTER, + }); + this.add_actor(this._titleBox); + + const labelBox = new St.BoxLayout({ + style_class: 'pad-osd-title-menu-box', + vertical: true, + }); + this._titleBox.add_actor(labelBox); + + this._titleLabel = new St.Label({ + style: 'font-side: larger; font-weight: bold;', + x_align: Clutter.ActorAlign.CENTER, + }); + this._titleLabel.clutter_text.set_ellipsize(Pango.EllipsizeMode.NONE); + this._titleLabel.clutter_text.set_text(padDevice.get_device_name()); + labelBox.add_actor(this._titleLabel); + + this._tipLabel = new St.Label({ x_align: Clutter.ActorAlign.CENTER }); + labelBox.add_actor(this._tipLabel); + + this._updatePadChooser(); + + this._actionEditor = new ActionEditor(); + this._actionEditor.connect('done', this._endActionEdition.bind(this)); + + this._padDiagram = new PadDiagram({ + image: this._imagePath, + left_handed: settings.get_boolean('left-handed'), + editor_actor: this._actionEditor, + x_expand: true, + y_expand: true, + }); + this.add_actor(this._padDiagram); + this._updateActionLabels(); + + const buttonBox = new St.Widget({ + layout_manager: new Clutter.BinLayout(), + x_expand: true, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + this.add_actor(buttonBox); + this._editButton = new St.Button({ + label: _('Edit…'), + style_class: 'button', + can_focus: true, + x_align: Clutter.ActorAlign.CENTER, + }); + this._editButton.connect('clicked', () => { + this.setEditionMode(true); + }); + buttonBox.add_actor(this._editButton); + + this._syncEditionMode(); + this._grab = Main.pushModal(this); + } + + _updatePadChooser() { + if (this._groupPads.length > 1) { + if (this._padChooser == null) { + this._padChooser = new PadChooser(this.padDevice, this._groupPads); + this._padChooser.connect('pad-selected', (chooser, pad) => { + this._requestForOtherPad(pad); + }); + this._titleBox.add_child(this._padChooser); + } else { + this._padChooser.update(this._groupPads); + } + } else if (this._padChooser != null) { + this._padChooser.destroy(); + this._padChooser = null; + } + } + + _requestForOtherPad(pad) { + if (pad == this.padDevice || !this._groupPads.includes(pad)) + return; + + let editionMode = this._editionMode; + this.destroy(); + global.display.request_pad_osd(pad, editionMode); + } + + _getActionText(type, number) { + let str = global.display.get_pad_action_label(this.padDevice, type, number); + return str ?? _('None'); + } + + _updateActionLabels() { + this._padDiagram.updateLabels(this._getActionText.bind(this)); + } + + vfunc_captured_event(event) { + let isModeSwitch = + (event.type() == Clutter.EventType.PAD_BUTTON_PRESS || + event.type() == Clutter.EventType.PAD_BUTTON_RELEASE) && + this.padDevice.get_mode_switch_button_group(event.get_button()) >= 0; + + if (event.type() == Clutter.EventType.PAD_BUTTON_PRESS && + event.get_source_device() == this.padDevice) { + this._padDiagram.activateButton(event.get_button()); + + /* Buttons that switch between modes cannot be edited */ + if (this._editionMode && !isModeSwitch) + this._startButtonActionEdition(event.get_button()); + return Clutter.EVENT_STOP; + } else if (event.type() == Clutter.EventType.PAD_BUTTON_RELEASE && + event.get_source_device() == this.padDevice) { + this._padDiagram.deactivateButton(event.get_button()); + + if (isModeSwitch) { + this._endActionEdition(); + this._updateActionLabels(); + } + return Clutter.EVENT_STOP; + } else if (event.type() == Clutter.EventType.KEY_PRESS && + (!this._editionMode || event.get_key_symbol() === Clutter.KEY_Escape)) { + if (this._editedAction != null) + this._endActionEdition(); + else + this.destroy(); + return Clutter.EVENT_STOP; + } else if (event.get_source_device() == this.padDevice && + event.type() == Clutter.EventType.PAD_STRIP) { + if (this._editionMode) { + let [retval_, number, mode] = event.get_pad_event_details(); + this._startStripActionEdition(number, UP, mode); + } + } else if (event.get_source_device() == this.padDevice && + event.type() == Clutter.EventType.PAD_RING) { + if (this._editionMode) { + let [retval_, number, mode] = event.get_pad_event_details(); + this._startRingActionEdition(number, CCW, mode); + } + } + + // If the event comes from another pad in the same group, + // show the OSD for it. + if (this._groupPads.includes(event.get_source_device())) { + this._requestForOtherPad(event.get_source_device()); + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_PROPAGATE; + } + + _syncEditionMode() { + this._editButton.set_reactive(!this._editionMode); + this._editButton.save_easing_state(); + this._editButton.set_easing_duration(200); + this._editButton.set_opacity(this._editionMode ? 128 : 255); + this._editButton.restore_easing_state(); + + let title; + + if (this._editionMode) { + title = _("Press a button to configure"); + this._tipLabel.set_text(_("Press Esc to exit")); + } else { + title = this.padDevice.get_device_name(); + this._tipLabel.set_text(_("Press any key to exit")); + } + + this._titleLabel.set_text(title); + } + + _isEditedAction(type, number, dir) { + if (!this._editedAction) + return false; + + return this._editedAction.type == type && + this._editedAction.number == number && + this._editedAction.dir == dir; + } + + _followUpActionEdition(str) { + let { type, dir, number, mode } = this._editedAction; + let hasNextAction = type == Meta.PadActionType.RING && dir == CCW || + type == Meta.PadActionType.STRIP && dir == UP; + if (!hasNextAction) + return false; + + this._padDiagram.stopEdition(true, str); + this._editedAction = null; + if (type == Meta.PadActionType.RING) + this._startRingActionEdition(number, CW, mode); + else + this._startStripActionEdition(number, DOWN, mode); + + return true; + } + + _endActionEdition() { + this._actionEditor.close(); + + if (this._editedAction != null) { + let str = global.display.get_pad_action_label(this.padDevice, + this._editedAction.type, + this._editedAction.number); + if (this._followUpActionEdition(str)) + return; + + this._padDiagram.stopEdition(false, str ?? _('None')); + this._editedAction = null; + } + + this._editedActionSettings = null; + } + + _startActionEdition(key, type, number, dir, mode) { + if (this._isEditedAction(type, number, dir)) + return; + + this._endActionEdition(); + this._editedAction = { type, number, dir, mode }; + + const settingsPath = `${this._settings.path}${key}/`; + this._editedActionSettings = Gio.Settings.new_with_path('org.gnome.desktop.peripherals.tablet.pad-button', + settingsPath); + this._actionEditor.setSettings(this._editedActionSettings, type); + this._padDiagram.startEdition(type, number, dir); + } + + _startButtonActionEdition(button) { + let ch = String.fromCharCode('A'.charCodeAt() + button); + let key = `button${ch}`; + this._startActionEdition(key, Meta.PadActionType.BUTTON, button); + } + + _startRingActionEdition(ring, dir, mode) { + let ch = String.fromCharCode('A'.charCodeAt() + ring); + const key = `ring${ch}-${dir === CCW ? 'ccw' : 'cw'}-mode-${mode}`; + this._startActionEdition(key, Meta.PadActionType.RING, ring, dir, mode); + } + + _startStripActionEdition(strip, dir, mode) { + let ch = String.fromCharCode('A'.charCodeAt() + strip); + const key = `strip${ch}-${dir === UP ? 'up' : 'down'}-mode-${mode}`; + this._startActionEdition(key, Meta.PadActionType.STRIP, strip, dir, mode); + } + + setEditionMode(editionMode) { + if (this._editionMode == editionMode) + return; + + this._editionMode = editionMode; + this._syncEditionMode(); + } + + _onDestroy() { + Main.popModal(this._grab); + this._grab = null; + this._actionEditor.close(); + + this.emit('closed'); + } +}); + +const PadOsdIface = loadInterfaceXML('org.gnome.Shell.Wacom.PadOsd'); + +var PadOsdService = class extends Signals.EventEmitter { + constructor() { + super(); + + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(PadOsdIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell/Wacom'); + Gio.DBus.session.own_name('org.gnome.Shell.Wacom.PadOsd', Gio.BusNameOwnerFlags.REPLACE, null, null); + } + + ShowAsync(params, invocation) { + let [deviceNode, editionMode] = params; + let seat = Clutter.get_default_backend().get_default_seat(); + let devices = seat.list_devices(); + let padDevice = null; + + devices.forEach(device => { + if (deviceNode == device.get_device_node() && + device.get_device_type() == Clutter.InputDeviceType.PAD_DEVICE) + padDevice = device; + }); + + if (padDevice == null) { + invocation.return_error_literal(Gio.IOErrorEnum, + Gio.IOErrorEnum.CANCELLED, + "Invalid params"); + return; + } + + global.display.request_pad_osd(padDevice, editionMode); + invocation.return_value(null); + } +}; diff --git a/js/ui/pageIndicators.js b/js/ui/pageIndicators.js new file mode 100644 index 0000000..18a376c --- /dev/null +++ b/js/ui/pageIndicators.js @@ -0,0 +1,116 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported PageIndicators */ + +const { Clutter, Graphene, GObject, St } = imports.gi; + +const INDICATOR_INACTIVE_OPACITY = 128; +const INDICATOR_INACTIVE_OPACITY_HOVER = 255; +const INDICATOR_INACTIVE_SCALE = 2 / 3; +const INDICATOR_INACTIVE_SCALE_PRESSED = 0.5; + +var PageIndicators = GObject.registerClass({ + Signals: { 'page-activated': { param_types: [GObject.TYPE_INT] } }, +}, class PageIndicators extends St.BoxLayout { + _init(orientation = Clutter.Orientation.VERTICAL) { + let vertical = orientation == Clutter.Orientation.VERTICAL; + super._init({ + style_class: 'page-indicators', + vertical, + x_expand: true, y_expand: true, + x_align: vertical ? Clutter.ActorAlign.END : Clutter.ActorAlign.CENTER, + y_align: vertical ? Clutter.ActorAlign.CENTER : Clutter.ActorAlign.END, + reactive: true, + clip_to_allocation: true, + }); + this._nPages = 0; + this._currentPosition = 0; + this._reactive = true; + this._reactive = true; + this._orientation = orientation; + } + + vfunc_get_preferred_height(forWidth) { + // We want to request the natural height of all our children as our + // natural height, so we chain up to St.BoxLayout, but we only request 0 + // as minimum height, since it's not that important if some indicators + // are not shown + let [, natHeight] = super.vfunc_get_preferred_height(forWidth); + return [0, natHeight]; + } + + setReactive(reactive) { + let children = this.get_children(); + for (let i = 0; i < children.length; i++) + children[i].reactive = reactive; + + this._reactive = reactive; + } + + setNPages(nPages) { + if (this._nPages == nPages) + return; + + let diff = nPages - this._nPages; + if (diff > 0) { + for (let i = 0; i < diff; i++) { + let pageIndex = this._nPages + i; + const indicator = new St.Button({ + style_class: 'page-indicator', + button_mask: St.ButtonMask.ONE | + St.ButtonMask.TWO | + St.ButtonMask.THREE, + reactive: this._reactive, + }); + indicator.child = new St.Widget({ + style_class: 'page-indicator-icon', + pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }), + }); + indicator.connect('clicked', () => { + this.emit('page-activated', pageIndex); + }); + indicator.connect('notify::hover', () => { + this._updateIndicator(indicator, pageIndex); + }); + indicator.connect('notify::pressed', () => { + this._updateIndicator(indicator, pageIndex); + }); + this._updateIndicator(indicator, pageIndex); + this.add_actor(indicator); + } + } else { + let children = this.get_children().splice(diff); + for (let i = 0; i < children.length; i++) + children[i].destroy(); + } + this._nPages = nPages; + this.visible = this._nPages > 1; + } + + _updateIndicator(indicator, pageIndex) { + let progress = + Math.max(1 - Math.abs(this._currentPosition - pageIndex), 0); + + let inactiveScale = indicator.pressed + ? INDICATOR_INACTIVE_SCALE_PRESSED : INDICATOR_INACTIVE_SCALE; + let inactiveOpacity = indicator.hover + ? INDICATOR_INACTIVE_OPACITY_HOVER : INDICATOR_INACTIVE_OPACITY; + + let scale = inactiveScale + (1 - inactiveScale) * progress; + let opacity = inactiveOpacity + (255 - inactiveOpacity) * progress; + + indicator.child.set_scale(scale, scale); + indicator.child.opacity = opacity; + } + + setCurrentPosition(currentPosition) { + this._currentPosition = currentPosition; + + let children = this.get_children(); + for (let i = 0; i < children.length; i++) + this._updateIndicator(children[i], i); + } + + get nPages() { + return this._nPages; + } +}); diff --git a/js/ui/panel.js b/js/ui/panel.js new file mode 100644 index 0000000..94dffda --- /dev/null +++ b/js/ui/panel.js @@ -0,0 +1,774 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Panel */ + +const { Atk, Clutter, GLib, GObject, Meta, Shell, St } = imports.gi; + +const Animation = imports.ui.animation; +const { AppMenu } = imports.ui.appMenu; +const Config = imports.misc.config; +const CtrlAltTab = imports.ui.ctrlAltTab; +const DND = imports.ui.dnd; +const Overview = imports.ui.overview; +const PopupMenu = imports.ui.popupMenu; +const PanelMenu = imports.ui.panelMenu; +const {QuickSettingsMenu, SystemIndicator} = imports.ui.quickSettings; +const Main = imports.ui.main; + +var PANEL_ICON_SIZE = 16; +var APP_MENU_ICON_MARGIN = 0; + +var BUTTON_DND_ACTIVATION_TIMEOUT = 250; + +const N_QUICK_SETTINGS_COLUMNS = 2; + +/** + * AppMenuButton: + * + * This class manages the "application menu" component. It tracks the + * currently focused application. However, when an app is launched, + * this menu also handles startup notification for it. So when we + * have an active startup notification, we switch modes to display that. + */ +var AppMenuButton = GObject.registerClass({ + Signals: { 'changed': {} }, +}, class AppMenuButton extends PanelMenu.Button { + _init(panel) { + super._init(0.0, null, true); + + this.accessible_role = Atk.Role.MENU; + + this._startingApps = []; + + this._menuManager = panel.menuManager; + this._targetApp = null; + + let bin = new St.Bin({ name: 'appMenu' }); + this.add_actor(bin); + + this.bind_property("reactive", this, "can-focus", 0); + this.reactive = false; + + this._container = new St.BoxLayout({ style_class: 'panel-status-menu-box' }); + bin.set_child(this._container); + + let textureCache = St.TextureCache.get_default(); + textureCache.connect('icon-theme-changed', + this._onIconThemeChanged.bind(this)); + + let iconEffect = new Clutter.DesaturateEffect(); + this._iconBox = new St.Bin({ + style_class: 'app-menu-icon', + y_align: Clutter.ActorAlign.CENTER, + }); + this._iconBox.add_effect(iconEffect); + this._container.add_actor(this._iconBox); + + this._iconBox.connect('style-changed', () => { + let themeNode = this._iconBox.get_theme_node(); + iconEffect.enabled = themeNode.get_icon_style() == St.IconStyle.SYMBOLIC; + }); + + this._label = new St.Label({ + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + this._container.add_actor(this._label); + + this._visible = !Main.overview.visible; + if (!this._visible) + this.hide(); + Main.overview.connectObject( + 'hiding', this._sync.bind(this), + 'showing', this._sync.bind(this), this); + + this._spinner = new Animation.Spinner(PANEL_ICON_SIZE, { + animate: true, + hideOnStop: true, + }); + this._container.add_actor(this._spinner); + + let menu = new AppMenu(this); + this.setMenu(menu); + this._menuManager.addMenu(menu); + + Shell.WindowTracker.get_default().connectObject('notify::focus-app', + this._focusAppChanged.bind(this), this); + Shell.AppSystem.get_default().connectObject('app-state-changed', + this._onAppStateChanged.bind(this), this); + global.window_manager.connectObject('switch-workspace', + this._sync.bind(this), this); + + this._sync(); + } + + fadeIn() { + if (this._visible) + return; + + this._visible = true; + this.reactive = true; + this.remove_all_transitions(); + this.ease({ + opacity: 255, + duration: Overview.ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + fadeOut() { + if (!this._visible) + return; + + this._visible = false; + this.reactive = false; + this.remove_all_transitions(); + this.ease({ + opacity: 0, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: Overview.ANIMATION_TIME, + }); + } + + _syncIcon(app) { + const icon = app.create_icon_texture(PANEL_ICON_SIZE - APP_MENU_ICON_MARGIN); + this._iconBox.set_child(icon); + } + + _onIconThemeChanged() { + if (this._iconBox.child == null) + return; + + if (this._targetApp) + this._syncIcon(this._targetApp); + } + + stopAnimation() { + this._spinner.stop(); + } + + startAnimation() { + this._spinner.play(); + } + + _onAppStateChanged(appSys, app) { + let state = app.state; + if (state != Shell.AppState.STARTING) + this._startingApps = this._startingApps.filter(a => a != app); + else if (state == Shell.AppState.STARTING) + this._startingApps.push(app); + // For now just resync on all running state changes; this is mainly to handle + // cases where the focused window's application changes without the focus + // changing. An example case is how we map OpenOffice.org based on the window + // title which is a dynamic property. + this._sync(); + } + + _focusAppChanged() { + let tracker = Shell.WindowTracker.get_default(); + let focusedApp = tracker.focus_app; + if (!focusedApp) { + // If the app has just lost focus to the panel, pretend + // nothing happened; otherwise you can't keynav to the + // app menu. + if (global.stage.key_focus != null) + return; + } + this._sync(); + } + + _findTargetApp() { + let workspaceManager = global.workspace_manager; + let workspace = workspaceManager.get_active_workspace(); + let tracker = Shell.WindowTracker.get_default(); + let focusedApp = tracker.focus_app; + if (focusedApp && focusedApp.is_on_workspace(workspace)) + return focusedApp; + + for (let i = 0; i < this._startingApps.length; i++) { + if (this._startingApps[i].is_on_workspace(workspace)) + return this._startingApps[i]; + } + + return null; + } + + _sync() { + let targetApp = this._findTargetApp(); + + if (this._targetApp != targetApp) { + this._targetApp?.disconnectObject(this); + + this._targetApp = targetApp; + + if (this._targetApp) { + this._targetApp.connectObject('notify::busy', this._sync.bind(this), this); + this._label.set_text(this._targetApp.get_name()); + this.set_accessible_name(this._targetApp.get_name()); + + this._syncIcon(this._targetApp); + } + } + + let visible = this._targetApp != null && !Main.overview.visibleTarget; + if (visible) + this.fadeIn(); + else + this.fadeOut(); + + let isBusy = this._targetApp != null && + (this._targetApp.get_state() == Shell.AppState.STARTING || + this._targetApp.get_busy()); + if (isBusy) + this.startAnimation(); + else + this.stopAnimation(); + + this.reactive = visible && !isBusy; + + this.menu.setApp(this._targetApp); + this.emit('changed'); + } +}); + +var ActivitiesButton = GObject.registerClass( +class ActivitiesButton extends PanelMenu.Button { + _init() { + super._init(0.0, null, true); + this.accessible_role = Atk.Role.TOGGLE_BUTTON; + + this.name = 'panelActivities'; + + /* Translators: If there is no suitable word for "Activities" + in your language, you can use the word for "Overview". */ + this._label = new St.Label({ + text: _('Activities'), + y_align: Clutter.ActorAlign.CENTER, + }); + this.add_actor(this._label); + + this.label_actor = this._label; + + Main.overview.connect('showing', () => { + this.add_style_pseudo_class('overview'); + this.add_accessible_state(Atk.StateType.CHECKED); + }); + Main.overview.connect('hiding', () => { + this.remove_style_pseudo_class('overview'); + this.remove_accessible_state(Atk.StateType.CHECKED); + }); + + this._xdndTimeOut = 0; + } + + handleDragOver(source, _actor, _x, _y, _time) { + if (source != Main.xdndHandler) + return DND.DragMotionResult.CONTINUE; + + if (this._xdndTimeOut != 0) + GLib.source_remove(this._xdndTimeOut); + this._xdndTimeOut = GLib.timeout_add(GLib.PRIORITY_DEFAULT, BUTTON_DND_ACTIVATION_TIMEOUT, () => { + this._xdndToggleOverview(); + }); + GLib.Source.set_name_by_id(this._xdndTimeOut, '[gnome-shell] this._xdndToggleOverview'); + + return DND.DragMotionResult.CONTINUE; + } + + vfunc_captured_event(event) { + if (event.type() == Clutter.EventType.BUTTON_PRESS || + event.type() == Clutter.EventType.TOUCH_BEGIN) { + if (!Main.overview.shouldToggleByCornerOrButton()) + return Clutter.EVENT_STOP; + } + return Clutter.EVENT_PROPAGATE; + } + + vfunc_event(event) { + if (event.type() == Clutter.EventType.TOUCH_END || + event.type() == Clutter.EventType.BUTTON_RELEASE) { + if (Main.overview.shouldToggleByCornerOrButton()) + Main.overview.toggle(); + } + + return Clutter.EVENT_PROPAGATE; + } + + vfunc_key_release_event(keyEvent) { + let symbol = keyEvent.keyval; + if (symbol == Clutter.KEY_Return || symbol == Clutter.KEY_space) { + if (Main.overview.shouldToggleByCornerOrButton()) { + Main.overview.toggle(); + return Clutter.EVENT_STOP; + } + } + + return Clutter.EVENT_PROPAGATE; + } + + _xdndToggleOverview() { + let [x, y] = global.get_pointer(); + let pickedActor = global.stage.get_actor_at_pos(Clutter.PickMode.REACTIVE, x, y); + + if (pickedActor == this && Main.overview.shouldToggleByCornerOrButton()) + Main.overview.toggle(); + + GLib.source_remove(this._xdndTimeOut); + this._xdndTimeOut = 0; + return GLib.SOURCE_REMOVE; + } +}); + +const UnsafeModeIndicator = GObject.registerClass( +class UnsafeModeIndicator extends SystemIndicator { + _init() { + super._init(); + + this._indicator = this._addIndicator(); + this._indicator.icon_name = 'channel-insecure-symbolic'; + + global.context.bind_property('unsafe-mode', + this._indicator, 'visible', + GObject.BindingFlags.SYNC_CREATE); + } +}); + +var QuickSettings = GObject.registerClass( +class QuickSettings extends PanelMenu.Button { + _init() { + super._init(0.0, C_('System menu in the top bar', 'System'), true); + + this._indicators = new St.BoxLayout({ + style_class: 'panel-status-indicators-box', + }); + this.add_child(this._indicators); + + this.setMenu(new QuickSettingsMenu(this, N_QUICK_SETTINGS_COLUMNS)); + + if (Config.HAVE_NETWORKMANAGER) + this._network = new imports.ui.status.network.Indicator(); + else + this._network = null; + + if (Config.HAVE_BLUETOOTH) + this._bluetooth = new imports.ui.status.bluetooth.Indicator(); + else + this._bluetooth = null; + + this._system = new imports.ui.status.system.Indicator(); + this._volume = new imports.ui.status.volume.Indicator(); + this._brightness = new imports.ui.status.brightness.Indicator(); + this._remoteAccess = new imports.ui.status.remoteAccess.RemoteAccessApplet(); + this._location = new imports.ui.status.location.Indicator(); + this._thunderbolt = new imports.ui.status.thunderbolt.Indicator(); + this._nightLight = new imports.ui.status.nightLight.Indicator(); + this._darkMode = new imports.ui.status.darkMode.Indicator(); + this._powerProfiles = new imports.ui.status.powerProfiles.Indicator(); + this._rfkill = new imports.ui.status.rfkill.Indicator(); + this._autoRotate = new imports.ui.status.autoRotate.Indicator(); + this._unsafeMode = new UnsafeModeIndicator(); + + this._indicators.add_child(this._brightness); + this._indicators.add_child(this._remoteAccess); + this._indicators.add_child(this._thunderbolt); + this._indicators.add_child(this._location); + this._indicators.add_child(this._nightLight); + if (this._network) + this._indicators.add_child(this._network); + this._indicators.add_child(this._darkMode); + this._indicators.add_child(this._powerProfiles); + if (this._bluetooth) + this._indicators.add_child(this._bluetooth); + this._indicators.add_child(this._rfkill); + this._indicators.add_child(this._autoRotate); + this._indicators.add_child(this._volume); + this._indicators.add_child(this._unsafeMode); + this._indicators.add_child(this._system); + + this._addItems(this._system.quickSettingsItems, N_QUICK_SETTINGS_COLUMNS); + this._addItems(this._volume.quickSettingsItems, N_QUICK_SETTINGS_COLUMNS); + this._addItems(this._brightness.quickSettingsItems, N_QUICK_SETTINGS_COLUMNS); + + this._addItems(this._remoteAccess.quickSettingsItems); + this._addItems(this._thunderbolt.quickSettingsItems); + this._addItems(this._location.quickSettingsItems); + if (this._network) + this._addItems(this._network.quickSettingsItems); + if (this._bluetooth) + this._addItems(this._bluetooth.quickSettingsItems); + this._addItems(this._powerProfiles.quickSettingsItems); + this._addItems(this._nightLight.quickSettingsItems); + this._addItems(this._darkMode.quickSettingsItems); + this._addItems(this._rfkill.quickSettingsItems); + this._addItems(this._autoRotate.quickSettingsItems); + this._addItems(this._unsafeMode.quickSettingsItems); + } + + _addItems(items, colSpan = 1) { + items.forEach(item => this.menu.addItem(item, colSpan)); + } +}); + +const PANEL_ITEM_IMPLEMENTATIONS = { + 'activities': ActivitiesButton, + 'appMenu': AppMenuButton, + 'quickSettings': QuickSettings, + 'dateMenu': imports.ui.dateMenu.DateMenuButton, + 'a11y': imports.ui.status.accessibility.ATIndicator, + 'keyboard': imports.ui.status.keyboard.InputSourceIndicator, + 'dwellClick': imports.ui.status.dwellClick.DwellClickIndicator, + 'screenRecording': imports.ui.status.remoteAccess.ScreenRecordingIndicator, + 'screenSharing': imports.ui.status.remoteAccess.ScreenSharingIndicator, +}; + +var Panel = GObject.registerClass( +class Panel extends St.Widget { + _init() { + super._init({ + name: 'panel', + reactive: true, + }); + + this.set_offscreen_redirect(Clutter.OffscreenRedirect.ALWAYS); + + this._sessionStyle = null; + + this.statusArea = {}; + + this.menuManager = new PopupMenu.PopupMenuManager(this); + + this._leftBox = new St.BoxLayout({ name: 'panelLeft' }); + this.add_child(this._leftBox); + this._centerBox = new St.BoxLayout({ name: 'panelCenter' }); + this.add_child(this._centerBox); + this._rightBox = new St.BoxLayout({ name: 'panelRight' }); + this.add_child(this._rightBox); + + this.connect('button-press-event', this._onButtonPress.bind(this)); + this.connect('touch-event', this._onTouchEvent.bind(this)); + + Main.overview.connect('showing', () => { + this.add_style_pseudo_class('overview'); + }); + Main.overview.connect('hiding', () => { + this.remove_style_pseudo_class('overview'); + }); + + Main.layoutManager.panelBox.add(this); + Main.ctrlAltTabManager.addGroup(this, _("Top Bar"), 'focus-top-bar-symbolic', + { sortGroup: CtrlAltTab.SortGroup.TOP }); + + Main.sessionMode.connect('updated', this._updatePanel.bind(this)); + + global.display.connect('workareas-changed', () => this.queue_relayout()); + this._updatePanel(); + } + + vfunc_get_preferred_width(_forHeight) { + let primaryMonitor = Main.layoutManager.primaryMonitor; + + if (primaryMonitor) + return [0, primaryMonitor.width]; + + return [0, 0]; + } + + vfunc_allocate(box) { + this.set_allocation(box); + + let allocWidth = box.x2 - box.x1; + let allocHeight = box.y2 - box.y1; + + let [, leftNaturalWidth] = this._leftBox.get_preferred_width(-1); + let [, centerNaturalWidth] = this._centerBox.get_preferred_width(-1); + let [, rightNaturalWidth] = this._rightBox.get_preferred_width(-1); + + let sideWidth, centerWidth; + centerWidth = centerNaturalWidth; + + // get workspace area and center date entry relative to it + let monitor = Main.layoutManager.findMonitorForActor(this); + let centerOffset = 0; + if (monitor) { + let workArea = Main.layoutManager.getWorkAreaForMonitor(monitor.index); + centerOffset = 2 * (workArea.x - monitor.x) + workArea.width - monitor.width; + } + + sideWidth = Math.max(0, (allocWidth - centerWidth + centerOffset) / 2); + + let childBox = new Clutter.ActorBox(); + + childBox.y1 = 0; + childBox.y2 = allocHeight; + if (this.get_text_direction() == Clutter.TextDirection.RTL) { + childBox.x1 = Math.max(allocWidth - Math.min(Math.floor(sideWidth), + leftNaturalWidth), + 0); + childBox.x2 = allocWidth; + } else { + childBox.x1 = 0; + childBox.x2 = Math.min(Math.floor(sideWidth), + leftNaturalWidth); + } + this._leftBox.allocate(childBox); + + childBox.x1 = Math.ceil(sideWidth); + childBox.y1 = 0; + childBox.x2 = childBox.x1 + centerWidth; + childBox.y2 = allocHeight; + this._centerBox.allocate(childBox); + + childBox.y1 = 0; + childBox.y2 = allocHeight; + if (this.get_text_direction() == Clutter.TextDirection.RTL) { + childBox.x1 = 0; + childBox.x2 = Math.min(Math.floor(sideWidth), + rightNaturalWidth); + } else { + childBox.x1 = Math.max(allocWidth - Math.min(Math.floor(sideWidth), + rightNaturalWidth), + 0); + childBox.x2 = allocWidth; + } + this._rightBox.allocate(childBox); + } + + _tryDragWindow(event) { + if (Main.modalCount > 0) + return Clutter.EVENT_PROPAGATE; + + const targetActor = global.stage.get_event_actor(event); + if (targetActor !== this) + return Clutter.EVENT_PROPAGATE; + + const [x, y] = event.get_coords(); + let dragWindow = this._getDraggableWindowForPosition(x); + + if (!dragWindow) + return Clutter.EVENT_PROPAGATE; + + const button = event.type() === Clutter.EventType.BUTTON_PRESS + ? event.get_button() : -1; + + return global.display.begin_grab_op( + dragWindow, + Meta.GrabOp.MOVING, + false, /* pointer grab */ + true, /* frame action */ + button, + event.get_state(), + event.get_time(), + x, y) ? Clutter.EVENT_STOP : Clutter.EVENT_PROPAGATE; + } + + _onButtonPress(actor, event) { + if (event.get_button() !== Clutter.BUTTON_PRIMARY) + return Clutter.EVENT_PROPAGATE; + + return this._tryDragWindow(event); + } + + _onTouchEvent(actor, event) { + if (event.type() !== Clutter.EventType.TOUCH_BEGIN) + return Clutter.EVENT_PROPAGATE; + + return this._tryDragWindow(event); + } + + vfunc_key_press_event(keyEvent) { + let symbol = keyEvent.keyval; + if (symbol == Clutter.KEY_Escape) { + global.display.focus_default_window(keyEvent.time); + return Clutter.EVENT_STOP; + } + + return super.vfunc_key_press_event(keyEvent); + } + + _toggleMenu(indicator) { + if (!indicator || !indicator.mapped) + return; // menu not supported by current session mode + + let menu = indicator.menu; + if (!indicator.reactive) + return; + + menu.toggle(); + if (menu.isOpen) + menu.actor.navigate_focus(null, St.DirectionType.TAB_FORWARD, false); + } + + _closeMenu(indicator) { + if (!indicator || !indicator.mapped) + return; // menu not supported by current session mode + + if (!indicator.reactive) + return; + + indicator.menu.close(); + } + + toggleAppMenu() { + this._toggleMenu(this.statusArea.appMenu); + } + + toggleCalendar() { + this._toggleMenu(this.statusArea.dateMenu); + } + + closeCalendar() { + this._closeMenu(this.statusArea.dateMenu); + } + + closeQuickSettings() { + this._closeMenu(this.statusArea.quickSettings); + } + + set boxOpacity(value) { + let isReactive = value > 0; + + this._leftBox.opacity = value; + this._leftBox.reactive = isReactive; + this._centerBox.opacity = value; + this._centerBox.reactive = isReactive; + this._rightBox.opacity = value; + this._rightBox.reactive = isReactive; + } + + get boxOpacity() { + return this._leftBox.opacity; + } + + _updatePanel() { + let panel = Main.sessionMode.panel; + this._hideIndicators(); + this._updateBox(panel.left, this._leftBox); + this._updateBox(panel.center, this._centerBox); + this._updateBox(panel.right, this._rightBox); + + if (panel.left.includes('dateMenu')) + Main.messageTray.bannerAlignment = Clutter.ActorAlign.START; + else if (panel.right.includes('dateMenu')) + Main.messageTray.bannerAlignment = Clutter.ActorAlign.END; + // Default to center if there is no dateMenu + else + Main.messageTray.bannerAlignment = Clutter.ActorAlign.CENTER; + + if (this._sessionStyle) + this.remove_style_class_name(this._sessionStyle); + + this._sessionStyle = Main.sessionMode.panelStyle; + if (this._sessionStyle) + this.add_style_class_name(this._sessionStyle); + } + + _hideIndicators() { + for (let role in PANEL_ITEM_IMPLEMENTATIONS) { + let indicator = this.statusArea[role]; + if (!indicator) + continue; + indicator.container.hide(); + } + } + + _ensureIndicator(role) { + let indicator = this.statusArea[role]; + if (!indicator) { + let constructor = PANEL_ITEM_IMPLEMENTATIONS[role]; + if (!constructor) { + // This icon is not implemented (this is a bug) + return null; + } + indicator = new constructor(this); + this.statusArea[role] = indicator; + } + return indicator; + } + + _updateBox(elements, box) { + let nChildren = box.get_n_children(); + + for (let i = 0; i < elements.length; i++) { + let role = elements[i]; + let indicator = this._ensureIndicator(role); + if (indicator == null) + continue; + + this._addToPanelBox(role, indicator, i + nChildren, box); + } + } + + _addToPanelBox(role, indicator, position, box) { + let container = indicator.container; + container.show(); + + let parent = container.get_parent(); + if (parent) + parent.remove_actor(container); + + + box.insert_child_at_index(container, position); + this.statusArea[role] = indicator; + let destroyId = indicator.connect('destroy', emitter => { + delete this.statusArea[role]; + emitter.disconnect(destroyId); + }); + indicator.connect('menu-set', this._onMenuSet.bind(this)); + this._onMenuSet(indicator); + } + + addToStatusArea(role, indicator, position, box) { + if (this.statusArea[role]) + throw new Error(`Extension point conflict: there is already a status indicator for role ${role}`); + + if (!(indicator instanceof PanelMenu.Button)) + throw new TypeError('Status indicator must be an instance of PanelMenu.Button'); + + position ??= 0; + let boxes = { + left: this._leftBox, + center: this._centerBox, + right: this._rightBox, + }; + let boxContainer = boxes[box] || this._rightBox; + this.statusArea[role] = indicator; + this._addToPanelBox(role, indicator, position, boxContainer); + return indicator; + } + + _onMenuSet(indicator) { + if (!indicator.menu || indicator.menu._openChangedId) + return; + + this.menuManager.addMenu(indicator.menu); + + indicator.menu._openChangedId = indicator.menu.connect('open-state-changed', + (menu, isOpen) => { + let boxAlignment; + if (this._leftBox.contains(indicator.container)) + boxAlignment = Clutter.ActorAlign.START; + else if (this._centerBox.contains(indicator.container)) + boxAlignment = Clutter.ActorAlign.CENTER; + else if (this._rightBox.contains(indicator.container)) + boxAlignment = Clutter.ActorAlign.END; + + if (boxAlignment == Main.messageTray.bannerAlignment) + Main.messageTray.bannerBlocked = isOpen; + }); + } + + _getDraggableWindowForPosition(stageX) { + let workspaceManager = global.workspace_manager; + const windows = workspaceManager.get_active_workspace().list_windows(); + const allWindowsByStacking = + global.display.sort_windows_by_stacking(windows).reverse(); + + return allWindowsByStacking.find(metaWindow => { + let rect = metaWindow.get_frame_rect(); + return metaWindow.is_on_primary_monitor() && + metaWindow.showing_on_its_workspace() && + metaWindow.get_window_type() != Meta.WindowType.DESKTOP && + metaWindow.maximized_vertically && + stageX > rect.x && stageX < rect.x + rect.width; + }); + } +}); diff --git a/js/ui/panelMenu.js b/js/ui/panelMenu.js new file mode 100644 index 0000000..a5445ce --- /dev/null +++ b/js/ui/panelMenu.js @@ -0,0 +1,233 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Button, SystemIndicator */ + +const { Atk, Clutter, GObject, St } = imports.gi; + +const Main = imports.ui.main; +const Params = imports.misc.params; +const PopupMenu = imports.ui.popupMenu; + +var ButtonBox = GObject.registerClass( +class ButtonBox extends St.Widget { + _init(params) { + params = Params.parse(params, { + style_class: 'panel-button', + x_expand: true, + y_expand: true, + }, true); + + super._init(params); + + this._delegate = this; + + this.container = new St.Bin({ child: this }); + + this.connect('style-changed', this._onStyleChanged.bind(this)); + this.connect('destroy', this._onDestroy.bind(this)); + + this._minHPadding = this._natHPadding = 0.0; + } + + _onStyleChanged(actor) { + let themeNode = actor.get_theme_node(); + + this._minHPadding = themeNode.get_length('-minimum-hpadding'); + this._natHPadding = themeNode.get_length('-natural-hpadding'); + } + + vfunc_get_preferred_width(_forHeight) { + let child = this.get_first_child(); + let minimumSize, naturalSize; + + if (child) + [minimumSize, naturalSize] = child.get_preferred_width(-1); + else + minimumSize = naturalSize = 0; + + minimumSize += 2 * this._minHPadding; + naturalSize += 2 * this._natHPadding; + + return [minimumSize, naturalSize]; + } + + vfunc_get_preferred_height(_forWidth) { + let child = this.get_first_child(); + + if (child) + return child.get_preferred_height(-1); + + return [0, 0]; + } + + vfunc_allocate(box) { + this.set_allocation(box); + + let child = this.get_first_child(); + if (!child) + return; + + let [, natWidth] = child.get_preferred_width(-1); + + let availWidth = box.x2 - box.x1; + let availHeight = box.y2 - box.y1; + + let childBox = new Clutter.ActorBox(); + if (natWidth + 2 * this._natHPadding <= availWidth) { + childBox.x1 = this._natHPadding; + childBox.x2 = availWidth - this._natHPadding; + } else { + childBox.x1 = this._minHPadding; + childBox.x2 = availWidth - this._minHPadding; + } + + childBox.y1 = 0; + childBox.y2 = availHeight; + + child.allocate(childBox); + } + + _onDestroy() { + this.container.child = null; + this.container.destroy(); + } +}); + +var Button = GObject.registerClass({ + Signals: { 'menu-set': {} }, +}, class PanelMenuButton extends ButtonBox { + _init(menuAlignment, nameText, dontCreateMenu) { + super._init({ + reactive: true, + can_focus: true, + track_hover: true, + accessible_name: nameText ?? '', + accessible_role: Atk.Role.MENU, + }); + + if (dontCreateMenu) + this.menu = new PopupMenu.PopupDummyMenu(this); + else + this.setMenu(new PopupMenu.PopupMenu(this, menuAlignment, St.Side.TOP, 0)); + + this.connect('key-press-event', + (o, ev) => global.focus_manager.navigate_from_event(ev)); + } + + setSensitive(sensitive) { + this.reactive = sensitive; + this.can_focus = sensitive; + this.track_hover = sensitive; + } + + setMenu(menu) { + if (this.menu) + this.menu.destroy(); + + this.menu = menu; + if (this.menu) { + this.menu.actor.add_style_class_name('panel-menu'); + this.menu.connect('open-state-changed', this._onOpenStateChanged.bind(this)); + this.menu.actor.connect('key-press-event', this._onMenuKeyPress.bind(this)); + + Main.uiGroup.add_actor(this.menu.actor); + this.menu.actor.hide(); + } + this.emit('menu-set'); + } + + vfunc_event(event) { + if (this.menu && + (event.type() == Clutter.EventType.TOUCH_BEGIN || + event.type() == Clutter.EventType.BUTTON_PRESS)) + this.menu.toggle(); + + return Clutter.EVENT_PROPAGATE; + } + + vfunc_hide() { + super.vfunc_hide(); + + if (this.menu) + this.menu.close(); + } + + _onMenuKeyPress(actor, event) { + if (global.focus_manager.navigate_from_event(event)) + return Clutter.EVENT_STOP; + + let symbol = event.get_key_symbol(); + if (symbol == Clutter.KEY_Left || symbol == Clutter.KEY_Right) { + let group = global.focus_manager.get_group(this); + if (group) { + let direction = symbol == Clutter.KEY_Left ? St.DirectionType.LEFT : St.DirectionType.RIGHT; + group.navigate_focus(this, direction, false); + return Clutter.EVENT_STOP; + } + } + return Clutter.EVENT_PROPAGATE; + } + + _onOpenStateChanged(menu, open) { + if (open) + this.add_style_pseudo_class('active'); + else + this.remove_style_pseudo_class('active'); + + // Setting the max-height won't do any good if the minimum height of the + // menu is higher then the screen; it's useful if part of the menu is + // scrollable so the minimum height is smaller than the natural height + let workArea = Main.layoutManager.getWorkAreaForMonitor(Main.layoutManager.primaryIndex); + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + let verticalMargins = this.menu.actor.margin_top + this.menu.actor.margin_bottom; + + // The workarea and margin dimensions are in physical pixels, but CSS + // measures are in logical pixels, so make sure to consider the scale + // factor when computing max-height + let maxHeight = Math.round((workArea.height - verticalMargins) / scaleFactor); + this.menu.actor.style = `max-height: ${maxHeight}px;`; + } + + _onDestroy() { + if (this.menu) + this.menu.destroy(); + super._onDestroy(); + } +}); + +/* SystemIndicator: + * + * This class manages one system indicator, which are the icons + * that you see at the top right. A system indicator is composed + * of an icon and a menu section, which will be composed into the + * aggregate menu. + */ +var SystemIndicator = GObject.registerClass( +class SystemIndicator extends St.BoxLayout { + _init() { + super._init({ + style_class: 'panel-status-indicators-box', + reactive: true, + visible: false, + }); + this.menu = new PopupMenu.PopupMenuSection(); + } + + get indicators() { + let klass = this.constructor.name; + let { stack } = new Error(); + log(`Usage of indicator.indicators is deprecated for ${klass}\n${stack}`); + return this; + } + + _syncIndicatorsVisible() { + this.visible = this.get_children().some(a => a.visible); + } + + _addIndicator() { + let icon = new St.Icon({ style_class: 'system-status-icon' }); + this.add_actor(icon); + icon.connect('notify::visible', this._syncIndicatorsVisible.bind(this)); + this._syncIndicatorsVisible(); + return icon; + } +}); diff --git a/js/ui/pointerA11yTimeout.js b/js/ui/pointerA11yTimeout.js new file mode 100644 index 0000000..263cc3e --- /dev/null +++ b/js/ui/pointerA11yTimeout.js @@ -0,0 +1,134 @@ +/* exported PointerA11yTimeout */ +const { Clutter, GObject, Meta, St } = imports.gi; +const Main = imports.ui.main; +const Cairo = imports.cairo; + +const SUCCESS_ZOOM_OUT_DURATION = 150; + +var PieTimer = GObject.registerClass({ + Properties: { + 'angle': GObject.ParamSpec.double( + 'angle', 'angle', 'angle', + GObject.ParamFlags.READWRITE, + 0, 2 * Math.PI, 0), + }, +}, class PieTimer extends St.DrawingArea { + _init() { + this._angle = 0; + super._init({ + style_class: 'pie-timer', + opacity: 0, + visible: false, + can_focus: false, + reactive: false, + }); + + this.set_pivot_point(0.5, 0.5); + } + + get angle() { + return this._angle; + } + + set angle(angle) { + if (this._angle == angle) + return; + + this._angle = angle; + this.notify('angle'); + this.queue_repaint(); + } + + vfunc_repaint() { + let node = this.get_theme_node(); + let backgroundColor = node.get_color('-pie-background-color'); + let borderColor = node.get_color('-pie-border-color'); + let borderWidth = node.get_length('-pie-border-width'); + let [width, height] = this.get_surface_size(); + let radius = Math.min(width / 2, height / 2); + + let startAngle = 3 * Math.PI / 2; + let endAngle = startAngle + this._angle; + + let cr = this.get_context(); + cr.setLineCap(Cairo.LineCap.ROUND); + cr.setLineJoin(Cairo.LineJoin.ROUND); + cr.translate(width / 2, height / 2); + + if (this._angle < 2 * Math.PI) + cr.moveTo(0, 0); + + cr.arc(0, 0, radius - borderWidth, startAngle, endAngle); + + if (this._angle < 2 * Math.PI) + cr.lineTo(0, 0); + + cr.closePath(); + + cr.setLineWidth(0); + Clutter.cairo_set_source_color(cr, backgroundColor); + cr.fillPreserve(); + + cr.setLineWidth(borderWidth); + Clutter.cairo_set_source_color(cr, borderColor); + cr.stroke(); + + cr.$dispose(); + } + + start(x, y, duration) { + this.x = x - this.width / 2; + this.y = y - this.height / 2; + this.show(); + + this.ease({ + opacity: 255, + duration: duration / 4, + mode: Clutter.AnimationMode.EASE_IN_QUAD, + }); + + this.ease_property('angle', 2 * Math.PI, { + duration, + mode: Clutter.AnimationMode.LINEAR, + onComplete: this._onTransitionComplete.bind(this), + }); + } + + _onTransitionComplete() { + this.ease({ + scale_x: 2, + scale_y: 2, + opacity: 0, + duration: SUCCESS_ZOOM_OUT_DURATION, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onStopped: () => this.destroy(), + }); + } +}); + +var PointerA11yTimeout = class PointerA11yTimeout { + constructor() { + let seat = Clutter.get_default_backend().get_default_seat(); + + seat.connect('ptr-a11y-timeout-started', (o, device, type, timeout) => { + let [x, y] = global.get_pointer(); + + this._pieTimer = new PieTimer(); + Main.uiGroup.add_actor(this._pieTimer); + Main.uiGroup.set_child_above_sibling(this._pieTimer, null); + + this._pieTimer.start(x, y, timeout); + + if (type == Clutter.PointerA11yTimeoutType.GESTURE) + global.display.set_cursor(Meta.Cursor.CROSSHAIR); + }); + + seat.connect('ptr-a11y-timeout-stopped', (o, device, type, clicked) => { + if (!clicked) + this._pieTimer.destroy(); + + if (type == Clutter.PointerA11yTimeoutType.GESTURE) + global.display.set_cursor(Meta.Cursor.DEFAULT); + }); + } +}; diff --git a/js/ui/pointerWatcher.js b/js/ui/pointerWatcher.js new file mode 100644 index 0000000..2af35b6 --- /dev/null +++ b/js/ui/pointerWatcher.js @@ -0,0 +1,125 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported getPointerWatcher */ + +const { GLib } = imports.gi; + +// We stop polling if the user is idle for more than this amount of time +var IDLE_TIME = 1000; + +// This file implements a reasonably efficient system for tracking the position +// of the mouse pointer. We simply query the pointer from the X server in a loop, +// but we turn off the polling when the user is idle. + +let _pointerWatcher = null; +function getPointerWatcher() { + if (_pointerWatcher == null) + _pointerWatcher = new PointerWatcher(); + + return _pointerWatcher; +} + +var PointerWatch = class { + constructor(watcher, interval, callback) { + this.watcher = watcher; + this.interval = interval; + this.callback = callback; + } + + // remove: + // remove this watch. This function may safely be called + // while the callback is executing. + remove() { + this.watcher._removeWatch(this); + } +}; + +var PointerWatcher = class { + constructor() { + this._idleMonitor = global.backend.get_core_idle_monitor(); + this._idleMonitor.add_idle_watch(IDLE_TIME, this._onIdleMonitorBecameIdle.bind(this)); + this._idle = this._idleMonitor.get_idletime() > IDLE_TIME; + this._watches = []; + this.pointerX = null; + this.pointerY = null; + } + + // addWatch: + // @interval: hint as to the time resolution needed. When the user is + // not idle, the position of the pointer will be queried at least + // once every this many milliseconds. + // @callback to call when the pointer position changes - takes + // two arguments, X and Y. + // + // Set up a watch on the position of the mouse pointer. Returns a + // PointerWatch object which has a remove() method to remove the watch. + addWatch(interval, callback) { + // Avoid unreliably calling the watch for the current position + this._updatePointer(); + + let watch = new PointerWatch(this, interval, callback); + this._watches.push(watch); + this._updateTimeout(); + return watch; + } + + _removeWatch(watch) { + for (let i = 0; i < this._watches.length; i++) { + if (this._watches[i] == watch) { + this._watches.splice(i, 1); + this._updateTimeout(); + return; + } + } + } + + _onIdleMonitorBecameActive() { + this._idle = false; + this._updatePointer(); + this._updateTimeout(); + } + + _onIdleMonitorBecameIdle() { + this._idle = true; + this._idleMonitor.add_user_active_watch(this._onIdleMonitorBecameActive.bind(this)); + this._updateTimeout(); + } + + _updateTimeout() { + if (this._timeoutId) { + GLib.source_remove(this._timeoutId); + this._timeoutId = 0; + } + + if (this._idle || this._watches.length == 0) + return; + + let minInterval = this._watches[0].interval; + for (let i = 1; i < this._watches.length; i++) + minInterval = Math.min(this._watches[i].interval, minInterval); + + this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, minInterval, + this._onTimeout.bind(this)); + GLib.Source.set_name_by_id(this._timeoutId, '[gnome-shell] this._onTimeout'); + } + + _onTimeout() { + this._updatePointer(); + return GLib.SOURCE_CONTINUE; + } + + _updatePointer() { + let [x, y] = global.get_pointer(); + if (this.pointerX == x && this.pointerY == y) + return; + + this.pointerX = x; + this.pointerY = y; + + for (let i = 0; i < this._watches.length;) { + let watch = this._watches[i]; + watch.callback(x, y); + if (watch == this._watches[i]) // guard against self-removal + i++; + } + } +}; diff --git a/js/ui/popupMenu.js b/js/ui/popupMenu.js new file mode 100644 index 0000000..2f57c58 --- /dev/null +++ b/js/ui/popupMenu.js @@ -0,0 +1,1415 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported PopupMenuItem, PopupSeparatorMenuItem, Switch, PopupSwitchMenuItem, + PopupImageMenuItem, PopupMenu, PopupDummyMenu, PopupSubMenu, + PopupMenuSection, PopupSubMenuMenuItem, PopupMenuManager */ + +const { Atk, Clutter, Gio, GObject, Graphene, Shell, St } = imports.gi; +const Signals = imports.misc.signals; + +const BoxPointer = imports.ui.boxpointer; +const Main = imports.ui.main; +const Params = imports.misc.params; + +var Ornament = { + NONE: 0, + DOT: 1, + CHECK: 2, + HIDDEN: 3, +}; + +function isPopupMenuItemVisible(child) { + if (child._delegate instanceof PopupMenuSection) { + if (child._delegate.isEmpty()) + return false; + } + return child.visible; +} + +/** + * arrowIcon + * @param {St.Side} side - Side to which the arrow points. + * @returns {St.Icon} a new arrow icon + */ +function arrowIcon(side) { + let iconName; + switch (side) { + case St.Side.TOP: + iconName = 'pan-up-symbolic'; + break; + case St.Side.RIGHT: + iconName = 'pan-end-symbolic'; + break; + case St.Side.BOTTOM: + iconName = 'pan-down-symbolic'; + break; + case St.Side.LEFT: + iconName = 'pan-start-symbolic'; + break; + } + + const arrow = new St.Icon({ + style_class: 'popup-menu-arrow', + icon_name: iconName, + accessible_role: Atk.Role.ARROW, + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + + return arrow; +} + +var PopupBaseMenuItem = GObject.registerClass({ + Properties: { + 'active': GObject.ParamSpec.boolean('active', 'active', 'active', + GObject.ParamFlags.READWRITE, + false), + 'sensitive': GObject.ParamSpec.boolean('sensitive', 'sensitive', 'sensitive', + GObject.ParamFlags.READWRITE, + true), + }, + Signals: { + 'activate': { param_types: [Clutter.Event.$gtype] }, + }, +}, class PopupBaseMenuItem extends St.BoxLayout { + _init(params) { + params = Params.parse(params, { + reactive: true, + activate: true, + hover: true, + style_class: null, + can_focus: true, + }); + super._init({ + style_class: 'popup-menu-item', + reactive: params.reactive, + track_hover: params.reactive, + can_focus: params.can_focus, + accessible_role: Atk.Role.MENU_ITEM, + }); + this._delegate = this; + + this._ornament = Ornament.NONE; + this._ornamentLabel = new St.Label({ style_class: 'popup-menu-ornament' }); + this.add(this._ornamentLabel); + + this._parent = null; + this._active = false; + this._activatable = params.reactive && params.activate; + this._sensitive = true; + + if (!this._activatable) + this.add_style_class_name('popup-inactive-menu-item'); + + if (params.style_class) + this.add_style_class_name(params.style_class); + + if (params.reactive && params.hover) + this.bind_property('hover', this, 'active', GObject.BindingFlags.SYNC_CREATE); + } + + get actor() { + /* This is kept for compatibility with current implementation, and we + don't want to warn here yet since PopupMenu depends on this */ + return this; + } + + _getTopMenu() { + if (this._parent) + return this._parent._getTopMenu(); + else + return this; + } + + _setParent(parent) { + this._parent = parent; + } + + vfunc_button_press_event() { + if (!this._activatable) + return Clutter.EVENT_PROPAGATE; + + // This is the CSS active state + this.add_style_pseudo_class('active'); + return Clutter.EVENT_PROPAGATE; + } + + vfunc_button_release_event() { + if (!this._activatable) + return Clutter.EVENT_PROPAGATE; + + this.remove_style_pseudo_class('active'); + this.activate(Clutter.get_current_event()); + return Clutter.EVENT_STOP; + } + + vfunc_touch_event(touchEvent) { + if (!this._activatable) + return Clutter.EVENT_PROPAGATE; + + if (touchEvent.type == Clutter.EventType.TOUCH_END) { + this.remove_style_pseudo_class('active'); + this.activate(Clutter.get_current_event()); + return Clutter.EVENT_STOP; + } else if (touchEvent.type == Clutter.EventType.TOUCH_BEGIN) { + // This is the CSS active state + this.add_style_pseudo_class('active'); + } + return Clutter.EVENT_PROPAGATE; + } + + vfunc_key_press_event(keyEvent) { + if (global.focus_manager.navigate_from_event(Clutter.get_current_event())) + return Clutter.EVENT_STOP; + + if (!this._activatable) + return super.vfunc_key_press_event(keyEvent); + + let state = keyEvent.modifier_state; + + // if user has a modifier down (except capslock and numlock) + // then don't handle the key press here + state &= ~Clutter.ModifierType.LOCK_MASK; + state &= ~Clutter.ModifierType.MOD2_MASK; + state &= Clutter.ModifierType.MODIFIER_MASK; + + if (state) + return Clutter.EVENT_PROPAGATE; + + let symbol = keyEvent.keyval; + if (symbol == Clutter.KEY_space || symbol == Clutter.KEY_Return) { + this.activate(Clutter.get_current_event()); + return Clutter.EVENT_STOP; + } + return Clutter.EVENT_PROPAGATE; + } + + vfunc_key_focus_in() { + super.vfunc_key_focus_in(); + this.active = true; + } + + vfunc_key_focus_out() { + super.vfunc_key_focus_out(); + this.active = false; + } + + activate(event) { + this.emit('activate', event); + } + + get active() { + return this._active; + } + + set active(active) { + let activeChanged = active != this.active; + if (activeChanged) { + this._active = active; + if (active) { + this.add_style_class_name('selected'); + if (this.can_focus) + this.grab_key_focus(); + } else { + this.remove_style_class_name('selected'); + // Remove the CSS active state if the user press the button and + // while holding moves to another menu item, so we don't paint all items. + // The correct behaviour would be to set the new item with the CSS + // active state as well, but button-press-event is not triggered, + // so we should track it in our own, which would involve some work + // in the container + this.remove_style_pseudo_class('active'); + } + this.notify('active'); + } + } + + syncSensitive() { + let sensitive = this.sensitive; + this.reactive = sensitive; + this.can_focus = sensitive; + this.notify('sensitive'); + return sensitive; + } + + getSensitive() { + const parentSensitive = this._parent?.sensitive ?? true; + return this._activatable && this._sensitive && parentSensitive; + } + + setSensitive(sensitive) { + if (this._sensitive == sensitive) + return; + + this._sensitive = sensitive; + this.syncSensitive(); + } + + get sensitive() { + return this.getSensitive(); + } + + set sensitive(sensitive) { + this.setSensitive(sensitive); + } + + setOrnament(ornament) { + if (ornament == this._ornament) + return; + + this._ornament = ornament; + + if (ornament == Ornament.DOT) { + this._ornamentLabel.text = '\u2022'; + this.add_accessible_state(Atk.StateType.CHECKED); + } else if (ornament == Ornament.CHECK) { + this._ornamentLabel.text = '\u2713'; + this.add_accessible_state(Atk.StateType.CHECKED); + } else if (ornament == Ornament.NONE || ornament == Ornament.HIDDEN) { + this._ornamentLabel.text = ''; + this.remove_accessible_state(Atk.StateType.CHECKED); + } + + this._ornamentLabel.visible = ornament != Ornament.HIDDEN; + } +}); + +var PopupMenuItem = GObject.registerClass( +class PopupMenuItem extends PopupBaseMenuItem { + _init(text, params) { + super._init(params); + + this.label = new St.Label({ + text, + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + this.add_child(this.label); + this.label_actor = this.label; + } +}); + + +var PopupSeparatorMenuItem = GObject.registerClass( +class PopupSeparatorMenuItem extends PopupBaseMenuItem { + _init(text) { + super._init({ + style_class: 'popup-separator-menu-item', + reactive: false, + can_focus: false, + }); + + this.label = new St.Label({ text: text || '' }); + this.add(this.label); + this.label_actor = this.label; + + this.label.connect('notify::text', + this._syncVisibility.bind(this)); + this._syncVisibility(); + + this._separator = new St.Widget({ + style_class: 'popup-separator-menu-item-separator', + x_expand: true, + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + this.add_child(this._separator); + } + + _syncVisibility() { + this.label.visible = this.label.text != ''; + } +}); + +var Switch = GObject.registerClass({ + Properties: { + 'state': GObject.ParamSpec.boolean( + 'state', 'state', 'state', + GObject.ParamFlags.READWRITE, + false), + }, +}, class Switch extends St.Bin { + _init(state) { + this._state = false; + + super._init({ + style_class: 'toggle-switch', + accessible_role: Atk.Role.CHECK_BOX, + state, + }); + } + + get state() { + return this._state; + } + + set state(state) { + if (this._state === state) + return; + + if (state) + this.add_style_pseudo_class('checked'); + else + this.remove_style_pseudo_class('checked'); + + this._state = state; + this.notify('state'); + } + + toggle() { + this.state = !this.state; + } +}); + +var PopupSwitchMenuItem = GObject.registerClass({ + Signals: { 'toggled': { param_types: [GObject.TYPE_BOOLEAN] } }, +}, class PopupSwitchMenuItem extends PopupBaseMenuItem { + _init(text, active, params) { + super._init(params); + + this.label = new St.Label({ + text, + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + this._switch = new Switch(active); + + this.accessible_role = Atk.Role.CHECK_MENU_ITEM; + this.checkAccessibleState(); + this.label_actor = this.label; + + this.add_child(this.label); + + this._statusBin = new St.Bin({ + x_align: Clutter.ActorAlign.END, + x_expand: true, + }); + this.add_child(this._statusBin); + + this._statusLabel = new St.Label({ + text: '', + style_class: 'popup-status-menu-item', + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + this._statusBin.child = this._switch; + } + + setStatus(text) { + if (text != null) { + this._statusLabel.text = text; + this._statusBin.child = this._statusLabel; + this.reactive = false; + this.accessible_role = Atk.Role.MENU_ITEM; + } else { + this._statusBin.child = this._switch; + this.reactive = true; + this.accessible_role = Atk.Role.CHECK_MENU_ITEM; + } + this.checkAccessibleState(); + } + + activate(event) { + if (this._switch.mapped) + this.toggle(); + + // we allow pressing space to toggle the switch + // without closing the menu + if (event.type() == Clutter.EventType.KEY_PRESS && + event.get_key_symbol() == Clutter.KEY_space) + return; + + super.activate(event); + } + + toggle() { + this._switch.toggle(); + this.emit('toggled', this._switch.state); + this.checkAccessibleState(); + } + + get state() { + return this._switch.state; + } + + setToggleState(state) { + this._switch.state = state; + this.checkAccessibleState(); + } + + checkAccessibleState() { + switch (this.accessible_role) { + case Atk.Role.CHECK_MENU_ITEM: + if (this._switch.state) + this.add_accessible_state(Atk.StateType.CHECKED); + else + this.remove_accessible_state(Atk.StateType.CHECKED); + break; + default: + this.remove_accessible_state(Atk.StateType.CHECKED); + } + } +}); + +var PopupImageMenuItem = GObject.registerClass( +class PopupImageMenuItem extends PopupBaseMenuItem { + _init(text, icon, params) { + super._init(params); + + this._icon = new St.Icon({ + style_class: 'popup-menu-icon', + x_align: Clutter.ActorAlign.END, + }); + this.add_child(this._icon); + this.label = new St.Label({ + text, + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + this.add_child(this.label); + this.label_actor = this.label; + + this.set_child_above_sibling(this._ornamentLabel, this.label); + + this.setIcon(icon); + } + + setIcon(icon) { + // The 'icon' parameter can be either a Gio.Icon or a string. + if (icon instanceof GObject.Object && GObject.type_is_a(icon, Gio.Icon)) + this._icon.gicon = icon; + else + this._icon.icon_name = icon; + } +}); + +var PopupMenuBase = class extends Signals.EventEmitter { + constructor(sourceActor, styleClass) { + super(); + + if (this.constructor === PopupMenuBase) + throw new TypeError(`Cannot instantiate abstract class ${this.constructor.name}`); + + this.sourceActor = sourceActor; + this.focusActor = sourceActor; + this._parent = null; + + this.box = new St.BoxLayout({ + vertical: true, + x_expand: true, + y_expand: true, + }); + + if (styleClass !== undefined) + this.box.style_class = styleClass; + this.length = 0; + + this.isOpen = false; + + this._activeMenuItem = null; + this._settingsActions = { }; + + this._sensitive = true; + + Main.sessionMode.connectObject('updated', () => this._sessionUpdated(), this); + } + + _getTopMenu() { + if (this._parent) + return this._parent._getTopMenu(); + else + return this; + } + + _setParent(parent) { + this._parent = parent; + } + + getSensitive() { + const parentSensitive = this._parent?.sensitive ?? true; + return this._sensitive && parentSensitive; + } + + setSensitive(sensitive) { + this._sensitive = sensitive; + this.emit('notify::sensitive'); + } + + get sensitive() { + return this.getSensitive(); + } + + set sensitive(sensitive) { + this.setSensitive(sensitive); + } + + _sessionUpdated() { + this._setSettingsVisibility(Main.sessionMode.allowSettings); + this.close(); + } + + addAction(title, callback, icon) { + let menuItem; + if (icon != undefined) + menuItem = new PopupImageMenuItem(title, icon); + else + menuItem = new PopupMenuItem(title); + + this.addMenuItem(menuItem); + menuItem.connect('activate', (o, event) => { + callback(event); + }); + + return menuItem; + } + + addSettingsAction(title, desktopFile) { + let menuItem = this.addAction(title, () => { + let app = Shell.AppSystem.get_default().lookup_app(desktopFile); + + if (!app) { + log(`Settings panel for desktop file ${desktopFile} could not be loaded!`); + return; + } + + Main.overview.hide(); + Main.panel.closeQuickSettings(); + app.activate(); + }); + + menuItem.visible = Main.sessionMode.allowSettings; + this._settingsActions[desktopFile] = menuItem; + + return menuItem; + } + + _setSettingsVisibility(visible) { + for (let id in this._settingsActions) { + let item = this._settingsActions[id]; + item.visible = visible; + } + } + + isEmpty() { + let hasVisibleChildren = this.box.get_children().some(child => { + if (child._delegate instanceof PopupSeparatorMenuItem) + return false; + return isPopupMenuItemVisible(child); + }); + + return !hasVisibleChildren; + } + + itemActivated(animate) { + if (animate == undefined) + animate = BoxPointer.PopupAnimation.FULL; + + this._getTopMenu().close(animate); + } + + _subMenuActiveChanged(submenu, submenuItem) { + if (this._activeMenuItem && this._activeMenuItem != submenuItem) + this._activeMenuItem.active = false; + this._activeMenuItem = submenuItem; + this.emit('active-changed', submenuItem); + } + + _connectItemSignals(menuItem) { + menuItem.connectObject( + 'notify::active', () => { + const { active } = menuItem; + if (active && this._activeMenuItem !== menuItem) { + if (this._activeMenuItem) + this._activeMenuItem.active = false; + this._activeMenuItem = menuItem; + this.emit('active-changed', menuItem); + } else if (!active && this._activeMenuItem === menuItem) { + this._activeMenuItem = null; + this.emit('active-changed', null); + } + }, + 'notify::sensitive', () => { + const { sensitive } = menuItem; + if (!sensitive && this._activeMenuItem === menuItem) { + if (!this.actor.navigate_focus(menuItem.actor, + St.DirectionType.TAB_FORWARD, true)) + this.actor.grab_key_focus(); + } else if (sensitive && this._activeMenuItem === null) { + if (global.stage.get_key_focus() === this.actor) + menuItem.actor.grab_key_focus(); + } + }, + 'activate', () => { + this.emit('activate', menuItem); + this.itemActivated(BoxPointer.PopupAnimation.FULL); + }, GObject.ConnectFlags.AFTER, + 'destroy', () => { + if (menuItem === this._activeMenuItem) + this._activeMenuItem = null; + }, this); + + this.connectObject('notify::sensitive', + () => menuItem.syncSensitive(), menuItem); + } + + _updateSeparatorVisibility(menuItem) { + if (menuItem.label.text) + return; + + let children = this.box.get_children(); + + let index = children.indexOf(menuItem.actor); + + if (index < 0) + return; + + let childBeforeIndex = index - 1; + + while (childBeforeIndex >= 0 && !isPopupMenuItemVisible(children[childBeforeIndex])) + childBeforeIndex--; + + if (childBeforeIndex < 0 || + children[childBeforeIndex]._delegate instanceof PopupSeparatorMenuItem) { + menuItem.actor.hide(); + return; + } + + let childAfterIndex = index + 1; + + while (childAfterIndex < children.length && !isPopupMenuItemVisible(children[childAfterIndex])) + childAfterIndex++; + + if (childAfterIndex >= children.length || + children[childAfterIndex]._delegate instanceof PopupSeparatorMenuItem) { + menuItem.actor.hide(); + return; + } + + menuItem.show(); + } + + moveMenuItem(menuItem, position) { + let items = this._getMenuItems(); + let i = 0; + + while (i < items.length && position > 0) { + if (items[i] != menuItem) + position--; + i++; + } + + if (i < items.length) { + if (items[i] != menuItem) + this.box.set_child_below_sibling(menuItem.actor, items[i].actor); + } else { + this.box.set_child_above_sibling(menuItem.actor, null); + } + } + + addMenuItem(menuItem, position) { + let beforeItem = null; + if (position == undefined) { + this.box.add(menuItem.actor); + } else { + let items = this._getMenuItems(); + if (position < items.length) { + beforeItem = items[position].actor; + this.box.insert_child_below(menuItem.actor, beforeItem); + } else { + this.box.add(menuItem.actor); + } + } + + if (menuItem instanceof PopupMenuSection) { + menuItem.connectObject( + 'active-changed', this._subMenuActiveChanged.bind(this), + 'destroy', () => this.length--, this); + + this.connectObject( + 'open-state-changed', (self, open) => { + if (open) + menuItem.open(); + else + menuItem.close(); + }, + 'menu-closed', () => menuItem.emit('menu-closed'), + 'notify::sensitive', () => menuItem.emit('notify::sensitive'), + menuItem); + } else if (menuItem instanceof PopupSubMenuMenuItem) { + if (beforeItem == null) + this.box.add(menuItem.menu.actor); + else + this.box.insert_child_below(menuItem.menu.actor, beforeItem); + + this._connectItemSignals(menuItem); + menuItem.menu.connectObject('active-changed', + this._subMenuActiveChanged.bind(this), this); + this.connectObject('menu-closed', () => { + menuItem.menu.close(BoxPointer.PopupAnimation.NONE); + }, menuItem); + } else if (menuItem instanceof PopupSeparatorMenuItem) { + this._connectItemSignals(menuItem); + + // updateSeparatorVisibility needs to get called any time the + // separator's adjacent siblings change visibility or position. + // open-state-changed isn't exactly that, but doing it in more + // precise ways would require a lot more bookkeeping. + this.connectObject('open-state-changed', () => { + this._updateSeparatorVisibility(menuItem); + }, menuItem); + } else if (menuItem instanceof PopupBaseMenuItem) { + this._connectItemSignals(menuItem); + } else { + throw TypeError("Invalid argument to PopupMenuBase.addMenuItem()"); + } + + menuItem._setParent(this); + + this.length++; + } + + _getMenuItems() { + return this.box.get_children().map(a => a._delegate).filter(item => { + return item instanceof PopupBaseMenuItem || item instanceof PopupMenuSection; + }); + } + + get firstMenuItem() { + let items = this._getMenuItems(); + if (items.length) + return items[0]; + else + return null; + } + + get numMenuItems() { + return this._getMenuItems().length; + } + + removeAll() { + let children = this._getMenuItems(); + for (let i = 0; i < children.length; i++) { + let item = children[i]; + item.destroy(); + } + } + + toggle() { + if (this.isOpen) + this.close(BoxPointer.PopupAnimation.FULL); + else + this.open(BoxPointer.PopupAnimation.FULL); + } + + destroy() { + this.close(); + this.removeAll(); + this.actor.destroy(); + + this.emit('destroy'); + + Main.sessionMode.disconnectObject(this); + } +}; + +var PopupMenu = class extends PopupMenuBase { + constructor(sourceActor, arrowAlignment, arrowSide) { + super(sourceActor, 'popup-menu-content'); + + this._arrowAlignment = arrowAlignment; + this._arrowSide = arrowSide; + + this._boxPointer = new BoxPointer.BoxPointer(arrowSide); + this.actor = this._boxPointer; + this.actor._delegate = this; + this.actor.style_class = 'popup-menu-boxpointer'; + + this._boxPointer.bin.set_child(this.box); + this.actor.add_style_class_name('popup-menu'); + + global.focus_manager.add_group(this.actor); + this.actor.reactive = true; + + if (this.sourceActor) { + this.sourceActor.connectObject( + 'key-press-event', this._onKeyPress.bind(this), + 'notify::mapped', () => { + if (!this.sourceActor.mapped) + this.close(); + }, this); + } + + this._systemModalOpenedId = 0; + this._openedSubMenu = null; + } + + _setOpenedSubMenu(submenu) { + if (this._openedSubMenu) + this._openedSubMenu.close(true); + + this._openedSubMenu = submenu; + } + + _onKeyPress(actor, event) { + // Disable toggling the menu by keyboard + // when it cannot be toggled by pointer + if (!actor.reactive) + return Clutter.EVENT_PROPAGATE; + + let navKey; + switch (this._boxPointer.arrowSide) { + case St.Side.TOP: + navKey = Clutter.KEY_Down; + break; + case St.Side.BOTTOM: + navKey = Clutter.KEY_Up; + break; + case St.Side.LEFT: + navKey = Clutter.KEY_Right; + break; + case St.Side.RIGHT: + navKey = Clutter.KEY_Left; + break; + } + + let state = event.get_state(); + + // if user has a modifier down (except capslock and numlock) + // then don't handle the key press here + state &= ~Clutter.ModifierType.LOCK_MASK; + state &= ~Clutter.ModifierType.MOD2_MASK; + state &= Clutter.ModifierType.MODIFIER_MASK; + + if (state) + return Clutter.EVENT_PROPAGATE; + + let symbol = event.get_key_symbol(); + + if (symbol == Clutter.KEY_space || symbol == Clutter.KEY_Return) { + this.toggle(); + return Clutter.EVENT_STOP; + } else if (symbol == navKey) { + if (!this.isOpen) + this.toggle(); + this.actor.navigate_focus(null, St.DirectionType.TAB_FORWARD, false); + return Clutter.EVENT_STOP; + } else { + return Clutter.EVENT_PROPAGATE; + } + } + + setArrowOrigin(origin) { + this._boxPointer.setArrowOrigin(origin); + } + + setSourceAlignment(alignment) { + this._boxPointer.setSourceAlignment(alignment); + } + + open(animate) { + if (this.isOpen) + return; + + if (this.isEmpty()) + return; + + if (!this._systemModalOpenedId) { + this._systemModalOpenedId = + Main.layoutManager.connect('system-modal-opened', () => this.close()); + } + + this.isOpen = true; + + this._boxPointer.setPosition(this.sourceActor, this._arrowAlignment); + this._boxPointer.open(animate); + + this.actor.get_parent().set_child_above_sibling(this.actor, null); + + this.emit('open-state-changed', true); + } + + close(animate) { + if (this._activeMenuItem) + this._activeMenuItem.active = false; + + if (this._boxPointer.visible) { + this._boxPointer.close(animate, () => { + this.emit('menu-closed'); + }); + } + + if (!this.isOpen) + return; + + this.isOpen = false; + this.emit('open-state-changed', false); + } + + destroy() { + this.sourceActor?.disconnectObject(this); + + if (this._systemModalOpenedId) + Main.layoutManager.disconnect(this._systemModalOpenedId); + this._systemModalOpenedId = 0; + + super.destroy(); + } +}; + +var PopupDummyMenu = class extends Signals.EventEmitter { + constructor(sourceActor) { + super(); + + this.sourceActor = sourceActor; + this.actor = sourceActor; + this.actor._delegate = this; + } + + getSensitive() { + return true; + } + + get sensitive() { + return this.getSensitive(); + } + + open() { + if (this.isOpen) + return; + this.isOpen = true; + this.emit('open-state-changed', true); + } + + close() { + if (!this.isOpen) + return; + this.isOpen = false; + this.emit('open-state-changed', false); + } + + toggle() {} + + destroy() { + this.emit('destroy'); + } +}; + +var PopupSubMenu = class extends PopupMenuBase { + constructor(sourceActor, sourceArrow) { + super(sourceActor); + + this._arrow = sourceArrow; + + // Since a function of a submenu might be to provide a "More.." expander + // with long content, we make it scrollable - the scrollbar will only take + // effect if a CSS max-height is set on the top menu. + this.actor = new St.ScrollView({ + style_class: 'popup-sub-menu', + hscrollbar_policy: St.PolicyType.NEVER, + vscrollbar_policy: St.PolicyType.NEVER, + }); + + this.actor.add_actor(this.box); + this.actor._delegate = this; + this.actor.clip_to_allocation = true; + this.actor.connect('key-press-event', this._onKeyPressEvent.bind(this)); + this.actor.hide(); + } + + _needsScrollbar() { + let topMenu = this._getTopMenu(); + let [, topNaturalHeight] = topMenu.actor.get_preferred_height(-1); + let topThemeNode = topMenu.actor.get_theme_node(); + + let topMaxHeight = topThemeNode.get_max_height(); + return topMaxHeight >= 0 && topNaturalHeight >= topMaxHeight; + } + + getSensitive() { + return this._sensitive && this.sourceActor.sensitive; + } + + get sensitive() { + return this.getSensitive(); + } + + open(animate) { + if (this.isOpen) + return; + + if (this.isEmpty()) + return; + + this.isOpen = true; + this.emit('open-state-changed', true); + + this.actor.show(); + + let needsScrollbar = this._needsScrollbar(); + + // St.ScrollView always requests space horizontally for a possible vertical + // scrollbar if in AUTOMATIC mode. Doing better would require implementation + // of width-for-height in St.BoxLayout and St.ScrollView. This looks bad + // when we *don't* need it, so turn off the scrollbar when that's true. + // Dynamic changes in whether we need it aren't handled properly. + this.actor.vscrollbar_policy = + needsScrollbar ? St.PolicyType.AUTOMATIC : St.PolicyType.NEVER; + + if (needsScrollbar) + this.actor.add_style_pseudo_class('scrolled'); + else + this.actor.remove_style_pseudo_class('scrolled'); + + // It looks funny if we animate with a scrollbar (at what point is + // the scrollbar added?) so just skip that case + if (animate && needsScrollbar) + animate = false; + + let targetAngle = this.actor.text_direction == Clutter.TextDirection.RTL ? -90 : 90; + + if (animate) { + let [, naturalHeight] = this.actor.get_preferred_height(-1); + this.actor.height = 0; + this.actor.ease({ + height: naturalHeight, + duration: 250, + mode: Clutter.AnimationMode.EASE_OUT_EXPO, + onComplete: () => this.actor.set_height(-1), + }); + this._arrow.ease({ + rotation_angle_z: targetAngle, + duration: 250, + mode: Clutter.AnimationMode.EASE_OUT_EXPO, + }); + } else { + this._arrow.rotation_angle_z = targetAngle; + } + } + + close(animate) { + if (!this.isOpen) + return; + + this.isOpen = false; + this.emit('open-state-changed', false); + + if (this._activeMenuItem) + this._activeMenuItem.active = false; + + if (animate && this._needsScrollbar()) + animate = false; + + if (animate) { + this.actor.ease({ + height: 0, + duration: 250, + mode: Clutter.AnimationMode.EASE_OUT_EXPO, + onComplete: () => { + this.actor.hide(); + this.actor.set_height(-1); + }, + }); + this._arrow.ease({ + rotation_angle_z: 0, + duration: 250, + mode: Clutter.AnimationMode.EASE_OUT_EXPO, + }); + } else { + this._arrow.rotation_angle_z = 0; + this.actor.hide(); + } + } + + _onKeyPressEvent(actor, event) { + // Move focus back to parent menu if the user types Left. + + if (this.isOpen && event.get_key_symbol() == Clutter.KEY_Left) { + this.close(BoxPointer.PopupAnimation.FULL); + this.sourceActor._delegate.active = true; + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_PROPAGATE; + } +}; + +/** + * PopupMenuSection: + * + * A section of a PopupMenu which is handled like a submenu + * (you can add and remove items, you can destroy it, you + * can add it to another menu), but is completely transparent + * to the user + */ +var PopupMenuSection = class extends PopupMenuBase { + constructor() { + super(); + + this.actor = this.box; + this.actor._delegate = this; + this.isOpen = true; + + this.actor.add_style_class_name('popup-menu-section'); + } + + // deliberately ignore any attempt to open() or close(), but emit the + // corresponding signal so children can still pick it up + open() { + this.emit('open-state-changed', true); + } + + close() { + this.emit('open-state-changed', false); + } +}; + +var PopupSubMenuMenuItem = GObject.registerClass( +class PopupSubMenuMenuItem extends PopupBaseMenuItem { + _init(text, wantIcon) { + super._init(); + + this.add_style_class_name('popup-submenu-menu-item'); + + if (wantIcon) { + this.icon = new St.Icon({ style_class: 'popup-menu-icon' }); + this.add_child(this.icon); + } + + this.label = new St.Label({ + text, + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + this.add_child(this.label); + this.label_actor = this.label; + + let expander = new St.Bin({ + style_class: 'popup-menu-item-expander', + x_expand: true, + }); + this.add_child(expander); + + this._triangle = arrowIcon(St.Side.RIGHT); + this._triangle.pivot_point = new Graphene.Point({ x: 0.5, y: 0.6 }); + + this._triangleBin = new St.Widget({ + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + this._triangleBin.add_child(this._triangle); + + this.add_child(this._triangleBin); + this.add_accessible_state(Atk.StateType.EXPANDABLE); + + this.menu = new PopupSubMenu(this, this._triangle); + this.menu.connect('open-state-changed', this._subMenuOpenStateChanged.bind(this)); + this.connect('destroy', () => this.menu.destroy()); + } + + _setParent(parent) { + super._setParent(parent); + this.menu._setParent(parent); + } + + syncSensitive() { + let sensitive = super.syncSensitive(); + this._triangle.visible = sensitive; + if (!sensitive) + this.menu.close(false); + } + + _subMenuOpenStateChanged(menu, open) { + if (open) { + this.add_style_pseudo_class('open'); + this._getTopMenu()._setOpenedSubMenu(this.menu); + this.add_accessible_state(Atk.StateType.EXPANDED); + this.add_style_pseudo_class('checked'); + } else { + this.remove_style_pseudo_class('open'); + this._getTopMenu()._setOpenedSubMenu(null); + this.remove_accessible_state(Atk.StateType.EXPANDED); + this.remove_style_pseudo_class('checked'); + } + } + + setSubmenuShown(open) { + if (open) + this.menu.open(BoxPointer.PopupAnimation.FULL); + else + this.menu.close(BoxPointer.PopupAnimation.FULL); + } + + _setOpenState(open) { + this.setSubmenuShown(open); + } + + _getOpenState() { + return this.menu.isOpen; + } + + vfunc_key_press_event(keyPressEvent) { + let symbol = keyPressEvent.keyval; + + if (symbol == Clutter.KEY_Right) { + this._setOpenState(true); + this.menu.actor.navigate_focus(null, St.DirectionType.DOWN, false); + return Clutter.EVENT_STOP; + } else if (symbol == Clutter.KEY_Left && this._getOpenState()) { + this._setOpenState(false); + return Clutter.EVENT_STOP; + } + + return super.vfunc_key_press_event(keyPressEvent); + } + + activate(_event) { + this._setOpenState(true); + } + + vfunc_button_release_event() { + // Since we override the parent, we need to manage what the parent does + // with the active style class + this.remove_style_pseudo_class('active'); + this._setOpenState(!this._getOpenState()); + return Clutter.EVENT_PROPAGATE; + } + + vfunc_touch_event(touchEvent) { + if (touchEvent.type == Clutter.EventType.TOUCH_END) { + // Since we override the parent, we need to manage what the parent does + // with the active style class + this.remove_style_pseudo_class('active'); + this._setOpenState(!this._getOpenState()); + } + return Clutter.EVENT_PROPAGATE; + } +}); + +/* Basic implementation of a menu manager. + * Call addMenu to add menus + */ +var PopupMenuManager = class { + constructor(owner, grabParams) { + this._grabParams = Params.parse(grabParams, + { actionMode: Shell.ActionMode.POPUP }); + global.stage.connect('notify::key-focus', () => { + if (!this.activeMenu) + return; + + let actor = global.stage.get_key_focus(); + let newMenu = this._findMenuForSource(actor); + + if (newMenu) + this._changeMenu(newMenu); + }); + this._menus = []; + } + + addMenu(menu, position) { + if (this._menus.includes(menu)) + return; + + menu.connectObject( + 'open-state-changed', this._onMenuOpenState.bind(this), + 'destroy', () => this.removeMenu(menu), this); + menu.actor.connectObject('captured-event', + this._onCapturedEvent.bind(this), this); + + if (position == undefined) + this._menus.push(menu); + else + this._menus.splice(position, 0, menu); + } + + removeMenu(menu) { + if (menu === this.activeMenu) { + Main.popModal(this._grab); + this._grab = null; + } + + const position = this._menus.indexOf(menu); + if (position == -1) // not a menu we manage + return; + + menu.disconnectObject(this); + menu.actor.disconnectObject(this); + + this._menus.splice(position, 1); + } + + ignoreRelease() { + } + + _onMenuOpenState(menu, open) { + if (open && this.activeMenu === menu) + return; + + if (open) { + const oldMenu = this.activeMenu; + const oldGrab = this._grab; + this._grab = Main.pushModal(menu.actor, this._grabParams); + this.activeMenu = menu; + oldMenu?.close(BoxPointer.PopupAnimation.FADE); + if (oldGrab) + Main.popModal(oldGrab); + } else if (this.activeMenu === menu) { + this.activeMenu = null; + Main.popModal(this._grab); + this._grab = null; + } + } + + _changeMenu(newMenu) { + newMenu.open(this.activeMenu + ? BoxPointer.PopupAnimation.FADE + : BoxPointer.PopupAnimation.FULL); + } + + _onCapturedEvent(actor, event) { + let menu = actor._delegate; + const targetActor = global.stage.get_event_actor(event); + + if (event.type() === Clutter.EventType.KEY_PRESS) { + let symbol = event.get_key_symbol(); + if (symbol === Clutter.KEY_Down && + global.stage.get_key_focus() === menu.actor) { + actor.navigate_focus(null, St.DirectionType.TAB_FORWARD, false); + return Clutter.EVENT_STOP; + } else if (symbol === Clutter.KEY_Escape && menu.isOpen) { + menu.close(BoxPointer.PopupAnimation.FULL); + return Clutter.EVENT_STOP; + } + } else if (event.type() === Clutter.EventType.ENTER && + (event.get_flags() & Clutter.EventFlags.FLAG_GRAB_NOTIFY) === 0) { + let hoveredMenu = this._findMenuForSource(targetActor); + + if (hoveredMenu && hoveredMenu !== menu) + this._changeMenu(hoveredMenu); + } else if ((event.type() === Clutter.EventType.BUTTON_PRESS || + event.type() === Clutter.EventType.TOUCH_BEGIN) && + !actor.contains(targetActor)) { + menu.close(BoxPointer.PopupAnimation.FULL); + } + + return Clutter.EVENT_PROPAGATE; + } + + _findMenuForSource(source) { + while (source) { + let actor = source; + const menu = this._menus.find(m => m.sourceActor === actor); + if (menu) + return menu; + source = source.get_parent(); + } + + return null; + } + + _closeMenu(isUser, menu) { + // If this isn't a user action, we called close() + // on the BoxPointer ourselves, so we shouldn't + // reanimate. + if (isUser) + menu.close(BoxPointer.PopupAnimation.FULL); + } +}; diff --git a/js/ui/quickSettings.js b/js/ui/quickSettings.js new file mode 100644 index 0000000..7cfd4f4 --- /dev/null +++ b/js/ui/quickSettings.js @@ -0,0 +1,717 @@ +/* exported QuickToggle, QuickMenuToggle, QuickSlider, QuickSettingsMenu, SystemIndicator */ +const {Atk, Clutter, Gio, GLib, GObject, Graphene, Meta, Pango, St} = imports.gi; + +const Main = imports.ui.main; +const PopupMenu = imports.ui.popupMenu; +const {Slider} = imports.ui.slider; + +const {PopupAnimation} = imports.ui.boxpointer; + +const DIM_BRIGHTNESS = -0.4; +const POPUP_ANIMATION_TIME = 400; + +var QuickSettingsItem = GObject.registerClass({ + Properties: { + 'has-menu': GObject.ParamSpec.boolean( + 'has-menu', 'has-menu', 'has-menu', + GObject.ParamFlags.READWRITE | + GObject.ParamFlags.CONSTRUCT_ONLY, + false), + }, +}, class QuickSettingsItem extends St.Button { + _init(params) { + super._init(params); + + if (this.hasMenu) { + this.menu = new QuickToggleMenu(this); + this.menu.actor.hide(); + + this._menuManager = new PopupMenu.PopupMenuManager(this); + this._menuManager.addMenu(this.menu); + } + } +}); + +var QuickToggle = GObject.registerClass({ + Properties: { + 'label': GObject.ParamSpec.override('label', St.Button), + 'icon-name': GObject.ParamSpec.override('icon-name', St.Button), + 'gicon': GObject.ParamSpec.object('gicon', '', '', + GObject.ParamFlags.READWRITE, + Gio.Icon), + }, +}, class QuickToggle extends QuickSettingsItem { + _init(params) { + super._init({ + style_class: 'quick-toggle button', + accessible_role: Atk.Role.TOGGLE_BUTTON, + can_focus: true, + ...params, + }); + + this._box = new St.BoxLayout(); + this.set_child(this._box); + + const iconProps = {}; + if (this.gicon) + iconProps['gicon'] = this.gicon; + if (this.iconName) + iconProps['icon-name'] = this.iconName; + + this._icon = new St.Icon({ + style_class: 'quick-toggle-icon', + x_expand: false, + ...iconProps, + }); + this._box.add_child(this._icon); + + // bindings are in the "wrong" direction, so we + // pick up StIcon's linking of the two properties + this._icon.bind_property('icon-name', + this, 'icon-name', + GObject.BindingFlags.SYNC_CREATE | + GObject.BindingFlags.BIDIRECTIONAL); + this._icon.bind_property('gicon', + this, 'gicon', + GObject.BindingFlags.SYNC_CREATE | + GObject.BindingFlags.BIDIRECTIONAL); + + this._label = new St.Label({ + style_class: 'quick-toggle-label', + y_align: Clutter.ActorAlign.CENTER, + x_align: Clutter.ActorAlign.START, + x_expand: true, + }); + this.label_actor = this._label; + this._box.add_child(this._label); + + this._label.clutter_text.ellipsize = Pango.EllipsizeMode.END; + + this.bind_property('label', + this._label, 'text', + GObject.BindingFlags.SYNC_CREATE); + } +}); + +var QuickMenuToggle = GObject.registerClass({ + Properties: { + 'menu-enabled': GObject.ParamSpec.boolean( + 'menu-enabled', '', '', + GObject.ParamFlags.READWRITE, + true), + }, +}, class QuickMenuToggle extends QuickToggle { + _init(params) { + super._init({ + ...params, + hasMenu: true, + }); + + this.add_style_class_name('quick-menu-toggle'); + + this._menuButton = new St.Button({ + child: new St.Icon({ + style_class: 'quick-toggle-arrow', + icon_name: 'go-next-symbolic', + }), + x_expand: false, + y_expand: true, + }); + this._box.add_child(this._menuButton); + + this.bind_property('menu-enabled', + this._menuButton, 'visible', + GObject.BindingFlags.SYNC_CREATE); + this.bind_property('reactive', + this._menuButton, 'reactive', + GObject.BindingFlags.SYNC_CREATE); + this._menuButton.connect('clicked', () => this.menu.open()); + this.connect('popup-menu', () => { + if (this.menuEnabled) + this.menu.open(); + }); + } +}); + +var QuickSlider = GObject.registerClass({ + Properties: { + 'icon-name': GObject.ParamSpec.override('icon-name', St.Button), + 'gicon': GObject.ParamSpec.object('gicon', '', '', + GObject.ParamFlags.READWRITE, + Gio.Icon), + 'menu-enabled': GObject.ParamSpec.boolean( + 'menu-enabled', '', '', + GObject.ParamFlags.READWRITE, + false), + }, +}, class QuickSlider extends QuickSettingsItem { + _init(params) { + super._init({ + style_class: 'quick-slider', + ...params, + can_focus: false, + reactive: false, + hasMenu: true, + }); + + const box = new St.BoxLayout(); + this.set_child(box); + + const iconProps = {}; + if (this.gicon) + iconProps['gicon'] = this.gicon; + if (this.iconName) + iconProps['icon-name'] = this.iconName; + + this._icon = new St.Icon({ + style_class: 'quick-toggle-icon', + ...iconProps, + }); + box.add_child(this._icon); + + // bindings are in the "wrong" direction, so we + // pick up StIcon's linking of the two properties + this._icon.bind_property('icon-name', + this, 'icon-name', + GObject.BindingFlags.SYNC_CREATE | + GObject.BindingFlags.BIDIRECTIONAL); + this._icon.bind_property('gicon', + this, 'gicon', + GObject.BindingFlags.SYNC_CREATE | + GObject.BindingFlags.BIDIRECTIONAL); + + this.slider = new Slider(0); + + // for focus indication + const sliderBin = new St.Bin({ + style_class: 'slider-bin', + child: this.slider, + reactive: true, + can_focus: true, + x_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + box.add_child(sliderBin); + + // Make the slider bin transparent for a11y + const sliderAccessible = this.slider.get_accessible(); + sliderAccessible.set_parent(sliderBin.get_parent().get_accessible()); + sliderBin.set_accessible(sliderAccessible); + sliderBin.connect('event', (bin, event) => this.slider.event(event, false)); + + this._menuButton = new St.Button({ + child: new St.Icon({icon_name: 'go-next-symbolic'}), + style_class: 'icon-button flat', + can_focus: true, + x_expand: false, + y_expand: true, + }); + box.add_child(this._menuButton); + + this.bind_property('menu-enabled', + this._menuButton, 'visible', + GObject.BindingFlags.SYNC_CREATE); + this._menuButton.connect('clicked', () => this.menu.open()); + this.slider.connect('popup-menu', () => { + if (this.menuEnabled) + this.menu.open(); + }); + } +}); + +class QuickToggleMenu extends PopupMenu.PopupMenuBase { + constructor(sourceActor) { + super(sourceActor, 'quick-toggle-menu'); + + const constraints = new Clutter.BindConstraint({ + coordinate: Clutter.BindCoordinate.Y, + source: sourceActor, + }); + sourceActor.bind_property('height', + constraints, 'offset', + GObject.BindingFlags.DEFAULT); + + this.actor = new St.Widget({ + layout_manager: new Clutter.BinLayout(), + style_class: 'quick-toggle-menu-container', + reactive: true, + x_expand: true, + y_expand: false, + constraints, + }); + this.actor._delegate = this; + this.actor.add_child(this.box); + + global.focus_manager.add_group(this.actor); + + const headerLayout = new Clutter.GridLayout(); + this._header = new St.Widget({ + style_class: 'header', + layout_manager: headerLayout, + visible: false, + }); + headerLayout.hookup_style(this._header); + this.box.add_child(this._header); + + this._headerIcon = new St.Icon({ + style_class: 'icon', + y_align: Clutter.ActorAlign.CENTER, + }); + this._headerTitle = new St.Label({ + style_class: 'title', + y_align: Clutter.ActorAlign.CENTER, + y_expand: true, + }); + this._headerSubtitle = new St.Label({ + style_class: 'subtitle', + y_align: Clutter.ActorAlign.CENTER, + }); + this._headerSpacer = new Clutter.Actor({x_expand: true}); + + const side = this.actor.text_direction === Clutter.TextDirection.RTL + ? Clutter.GridPosition.LEFT + : Clutter.GridPosition.RIGHT; + + headerLayout.attach(this._headerIcon, 0, 0, 1, 2); + headerLayout.attach_next_to(this._headerTitle, + this._headerIcon, side, 1, 1); + headerLayout.attach_next_to(this._headerSpacer, + this._headerTitle, side, 1, 1); + headerLayout.attach_next_to(this._headerSubtitle, + this._headerTitle, Clutter.GridPosition.BOTTOM, 1, 1); + + sourceActor.connect('notify::checked', + () => this._syncChecked()); + this._syncChecked(); + } + + setHeader(icon, title, subtitle = '') { + if (icon instanceof Gio.Icon) + this._headerIcon.gicon = icon; + else + this._headerIcon.icon_name = icon; + + this._headerTitle.text = title; + this._headerSubtitle.set({ + text: subtitle, + visible: !!subtitle, + }); + + this._header.show(); + } + + addHeaderSuffix(actor) { + const {layoutManager: headerLayout} = this._header; + const side = this.actor.text_direction === Clutter.TextDirection.RTL + ? Clutter.GridPosition.LEFT + : Clutter.GridPosition.RIGHT; + this._header.remove_child(this._headerSpacer); + headerLayout.attach_next_to(actor, this._headerTitle, side, 1, 1); + headerLayout.attach_next_to(this._headerSpacer, actor, side, 1, 1); + } + + open(animate) { + if (this.isOpen) + return; + + this.actor.show(); + this.isOpen = true; + + this.actor.height = -1; + const [targetHeight] = this.actor.get_preferred_height(-1); + + const duration = animate !== PopupAnimation.NONE + ? POPUP_ANIMATION_TIME / 2 + : 0; + + this.actor.height = 0; + this.box.opacity = 0; + this.actor.ease({ + duration, + height: targetHeight, + onComplete: () => { + this.box.ease({ + duration, + opacity: 255, + }); + this.actor.height = -1; + }, + }); + this.emit('open-state-changed', true); + } + + close(animate) { + if (!this.isOpen) + return; + + const duration = animate !== PopupAnimation.NONE + ? POPUP_ANIMATION_TIME / 2 + : 0; + + this.box.ease({ + duration, + opacity: 0, + onComplete: () => { + this.actor.ease({ + duration, + height: 0, + onComplete: () => { + this.actor.hide(); + this.emit('menu-closed'); + }, + }); + }, + }); + + this.isOpen = false; + this.emit('open-state-changed', false); + } + + _syncChecked() { + if (this.sourceActor.checked) + this._headerIcon.add_style_class_name('active'); + else + this._headerIcon.remove_style_class_name('active'); + } + + // expected on toplevel menus + _setOpenedSubMenu(submenu) { + this._openedSubMenu?.close(true); + this._openedSubMenu = submenu; + } +} + +const QuickSettingsLayoutMeta = GObject.registerClass({ + Properties: { + 'column-span': GObject.ParamSpec.int( + 'column-span', '', '', + GObject.ParamFlags.READWRITE, + 1, GLib.MAXINT32, 1), + }, +}, class QuickSettingsLayoutMeta extends Clutter.LayoutMeta {}); + +const QuickSettingsLayout = GObject.registerClass({ + Properties: { + 'row-spacing': GObject.ParamSpec.int( + 'row-spacing', 'row-spacing', 'row-spacing', + GObject.ParamFlags.READWRITE, + 0, GLib.MAXINT32, 0), + 'column-spacing': GObject.ParamSpec.int( + 'column-spacing', 'column-spacing', 'column-spacing', + GObject.ParamFlags.READWRITE, + 0, GLib.MAXINT32, 0), + 'n-columns': GObject.ParamSpec.int( + 'n-columns', 'n-columns', 'n-columns', + GObject.ParamFlags.READWRITE, + 1, GLib.MAXINT32, 1), + }, +}, class QuickSettingsLayout extends Clutter.LayoutManager { + _init(overlay, params) { + super._init(params); + + this._overlay = overlay; + } + + _containerStyleChanged() { + const node = this._container.get_theme_node(); + + let changed = false; + let found, length; + [found, length] = node.lookup_length('spacing-rows', false); + changed ||= found; + if (found) + this.rowSpacing = length; + + [found, length] = node.lookup_length('spacing-columns', false); + changed ||= found; + if (found) + this.columnSpacing = length; + + if (changed) + this.layout_changed(); + } + + _getColSpan(container, child) { + const {columnSpan} = this.get_child_meta(container, child); + return Math.clamp(columnSpan, 1, this.nColumns); + } + + _getMaxChildWidth(container) { + let [minWidth, natWidth] = [0, 0]; + + for (const child of container) { + if (child === this._overlay) + continue; + + const [childMin, childNat] = child.get_preferred_width(-1); + const colSpan = this._getColSpan(container, child); + minWidth = Math.max(minWidth, childMin / colSpan); + natWidth = Math.max(natWidth, childNat / colSpan); + } + + return [minWidth, natWidth]; + } + + _getRows(container) { + const rows = []; + let lineIndex = 0; + let curRow; + + /** private */ + function appendRow() { + curRow = []; + rows.push(curRow); + lineIndex = 0; + } + + for (const child of container) { + if (!child.visible) + continue; + + if (child === this._overlay) + continue; + + if (lineIndex === 0) + appendRow(); + + const colSpan = this._getColSpan(container, child); + const fitsRow = lineIndex + colSpan <= this.nColumns; + + if (!fitsRow) + appendRow(); + + curRow.push(child); + lineIndex = (lineIndex + colSpan) % this.nColumns; + } + + return rows; + } + + _getRowHeight(children) { + let [minHeight, natHeight] = [0, 0]; + + children.forEach(child => { + const [childMin, childNat] = child.get_preferred_height(-1); + minHeight = Math.max(minHeight, childMin); + natHeight = Math.max(natHeight, childNat); + }); + + return [minHeight, natHeight]; + } + + vfunc_get_child_meta_type() { + return QuickSettingsLayoutMeta.$gtype; + } + + vfunc_set_container(container) { + this._container?.disconnectObject(this); + + this._container = container; + + this._container?.connectObject('style-changed', + () => this._containerStyleChanged(), this); + } + + vfunc_get_preferred_width(container, _forHeight) { + const [childMin, childNat] = this._getMaxChildWidth(container); + const spacing = (this.nColumns - 1) * this.column_spacing; + return [this.nColumns * childMin + spacing, this.nColumns * childNat + spacing]; + } + + vfunc_get_preferred_height(container, _forWidth) { + const rows = this._getRows(container); + + let [minHeight, natHeight] = this._overlay + ? this._overlay.get_preferred_height(-1) + : [0, 0]; + + const spacing = (rows.length - 1) * this.row_spacing; + minHeight += spacing; + natHeight += spacing; + + rows.forEach(row => { + const [rowMin, rowNat] = this._getRowHeight(row); + minHeight += rowMin; + natHeight += rowNat; + }); + + return [minHeight, natHeight]; + } + + vfunc_allocate(container, box) { + const rows = this._getRows(container); + + const [, overlayHeight] = this._overlay + ? this._overlay.get_preferred_height(-1) + : [0, 0]; + + const availWidth = box.get_width() - (this.nColumns - 1) * this.column_spacing; + const childWidth = Math.floor(availWidth / this.nColumns); + + this._overlay?.allocate_available_size(0, 0, box.get_width(), box.get_height()); + + const isRtl = container.text_direction === Clutter.TextDirection.RTL; + + const childBox = new Clutter.ActorBox(); + let y = box.y1; + rows.forEach(row => { + const [, rowNat] = this._getRowHeight(row); + + let lineIndex = 0; + row.forEach(child => { + const colSpan = this._getColSpan(container, child); + const width = + childWidth * colSpan + this.column_spacing * (colSpan - 1); + let x = box.x1 + lineIndex * (childWidth + this.column_spacing); + if (isRtl) + x = box.x2 - width - x; + + childBox.set_origin(x, y); + childBox.set_size(width, rowNat); + child.allocate(childBox); + + lineIndex = (lineIndex + colSpan) % this.nColumns; + }); + + y += rowNat + this.row_spacing; + + if (row.some(c => c.menu?.actor.visible)) + y += overlayHeight; + }); + } +}); + +var QuickSettingsMenu = class extends PopupMenu.PopupMenu { + constructor(sourceActor, nColumns = 1) { + super(sourceActor, 0, St.Side.TOP); + + this.actor = new St.Widget({reactive: true, width: 0, height: 0}); + this.actor.add_child(this._boxPointer); + this.actor._delegate = this; + + this.connect('menu-closed', () => this.actor.hide()); + + Main.layoutManager.connectObject('system-modal-opened', + () => this.close(), this); + + this._dimEffect = new Clutter.BrightnessContrastEffect({ + enabled: false, + }); + this._boxPointer.add_effect_with_name('dim', this._dimEffect); + this.box.add_style_class_name('quick-settings'); + + // Overlay layer for menus + this._overlay = new Clutter.Actor({ + layout_manager: new Clutter.BinLayout(), + }); + + // "clone" + const placeholder = new Clutter.Actor({ + constraints: new Clutter.BindConstraint({ + coordinate: Clutter.BindCoordinate.HEIGHT, + source: this._overlay, + }), + }); + + this._grid = new St.Widget({ + style_class: 'quick-settings-grid', + layout_manager: new QuickSettingsLayout(placeholder, { + nColumns, + }), + }); + this.box.add_child(this._grid); + this._grid.add_child(placeholder); + + const yConstraint = new Clutter.BindConstraint({ + coordinate: Clutter.BindCoordinate.Y, + source: this._boxPointer, + }); + + // Pick up additional spacing from any intermediate actors + const updateOffset = () => { + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + const offset = this._grid.apply_relative_transform_to_point( + this._boxPointer, new Graphene.Point3D()); + yConstraint.offset = offset.y; + return GLib.SOURCE_REMOVE; + }); + }; + this._grid.connect('notify::y', updateOffset); + this.box.connect('notify::y', updateOffset); + this._boxPointer.bin.connect('notify::y', updateOffset); + + this._overlay.add_constraint(yConstraint); + this._overlay.add_constraint(new Clutter.BindConstraint({ + coordinate: Clutter.BindCoordinate.X, + source: this._boxPointer, + })); + this._overlay.add_constraint(new Clutter.BindConstraint({ + coordinate: Clutter.BindCoordinate.WIDTH, + source: this._boxPointer, + })); + + this.actor.add_child(this._overlay); + } + + addItem(item, colSpan = 1) { + this._grid.add_child(item); + this._grid.layout_manager.child_set_property( + this._grid, item, 'column-span', colSpan); + + if (item.menu) { + this._overlay.add_child(item.menu.actor); + + item.menu.connect('open-state-changed', (m, isOpen) => { + this._setDimmed(isOpen); + this._activeMenu = isOpen ? item.menu : null; + }); + } + } + + open(animate) { + this.actor.show(); + super.open(animate); + } + + close(animate) { + this._activeMenu?.close(animate); + super.close(animate); + } + + _setDimmed(dim) { + const val = 127 * (1 + (dim ? 1 : 0) * DIM_BRIGHTNESS); + const color = Clutter.Color.new(val, val, val, 255); + + this._boxPointer.ease_property('@effects.dim.brightness', color, { + mode: Clutter.AnimationMode.LINEAR, + duration: POPUP_ANIMATION_TIME, + onStopped: () => (this._dimEffect.enabled = dim), + }); + this._dimEffect.enabled = true; + } +}; + +var SystemIndicator = GObject.registerClass( +class SystemIndicator extends St.BoxLayout { + _init() { + super._init({ + style_class: 'panel-status-indicators-box', + reactive: true, + visible: false, + }); + + this.quickSettingsItems = []; + } + + _syncIndicatorsVisible() { + this.visible = this.get_children().some(a => a.visible); + } + + _addIndicator() { + const icon = new St.Icon({style_class: 'system-status-icon'}); + this.add_actor(icon); + icon.connect('notify::visible', () => this._syncIndicatorsVisible()); + this._syncIndicatorsVisible(); + return icon; + } +}); diff --git a/js/ui/remoteSearch.js b/js/ui/remoteSearch.js new file mode 100644 index 0000000..87ee384 --- /dev/null +++ b/js/ui/remoteSearch.js @@ -0,0 +1,332 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported loadRemoteSearchProviders */ + +const { GdkPixbuf, Gio, GLib, Shell, St } = imports.gi; + +const FileUtils = imports.misc.fileUtils; + +const KEY_FILE_GROUP = 'Shell Search Provider'; + +const SearchProviderIface = ` +<node> +<interface name="org.gnome.Shell.SearchProvider"> +<method name="GetInitialResultSet"> + <arg type="as" direction="in" /> + <arg type="as" direction="out" /> +</method> +<method name="GetSubsearchResultSet"> + <arg type="as" direction="in" /> + <arg type="as" direction="in" /> + <arg type="as" direction="out" /> +</method> +<method name="GetResultMetas"> + <arg type="as" direction="in" /> + <arg type="aa{sv}" direction="out" /> +</method> +<method name="ActivateResult"> + <arg type="s" direction="in" /> +</method> +</interface> +</node>`; + +const SearchProvider2Iface = ` +<node> +<interface name="org.gnome.Shell.SearchProvider2"> +<method name="GetInitialResultSet"> + <arg type="as" direction="in" /> + <arg type="as" direction="out" /> +</method> +<method name="GetSubsearchResultSet"> + <arg type="as" direction="in" /> + <arg type="as" direction="in" /> + <arg type="as" direction="out" /> +</method> +<method name="GetResultMetas"> + <arg type="as" direction="in" /> + <arg type="aa{sv}" direction="out" /> +</method> +<method name="ActivateResult"> + <arg type="s" direction="in" /> + <arg type="as" direction="in" /> + <arg type="u" direction="in" /> +</method> +<method name="LaunchSearch"> + <arg type="as" direction="in" /> + <arg type="u" direction="in" /> +</method> +</interface> +</node>`; + +var SearchProviderProxyInfo = Gio.DBusInterfaceInfo.new_for_xml(SearchProviderIface); +var SearchProvider2ProxyInfo = Gio.DBusInterfaceInfo.new_for_xml(SearchProvider2Iface); + +/** + * loadRemoteSearchProviders: + * + * @param {Gio.Settings} searchSettings - search settings + * @returns {RemoteSearchProvider[]} - the list of remote providers + */ +function loadRemoteSearchProviders(searchSettings) { + let objectPaths = {}; + let loadedProviders = []; + + function loadRemoteSearchProvider(file) { + let keyfile = new GLib.KeyFile(); + let path = file.get_path(); + + try { + keyfile.load_from_file(path, 0); + } catch (e) { + return; + } + + if (!keyfile.has_group(KEY_FILE_GROUP)) + return; + + let remoteProvider; + try { + let group = KEY_FILE_GROUP; + let busName = keyfile.get_string(group, 'BusName'); + let objectPath = keyfile.get_string(group, 'ObjectPath'); + + if (objectPaths[objectPath]) + return; + + let appInfo = null; + try { + let desktopId = keyfile.get_string(group, 'DesktopId'); + appInfo = Gio.DesktopAppInfo.new(desktopId); + if (!appInfo.should_show()) + return; + } catch (e) { + log(`Ignoring search provider ${path}: missing DesktopId`); + return; + } + + let autoStart = true; + try { + autoStart = keyfile.get_boolean(group, 'AutoStart'); + } catch (e) { + // ignore error + } + + let version = '1'; + try { + version = keyfile.get_string(group, 'Version'); + } catch (e) { + // ignore error + } + + if (version >= 2) + remoteProvider = new RemoteSearchProvider2(appInfo, busName, objectPath, autoStart); + else + remoteProvider = new RemoteSearchProvider(appInfo, busName, objectPath, autoStart); + + remoteProvider.defaultEnabled = true; + try { + remoteProvider.defaultEnabled = !keyfile.get_boolean(group, 'DefaultDisabled'); + } catch (e) { + // ignore error + } + + objectPaths[objectPath] = remoteProvider; + loadedProviders.push(remoteProvider); + } catch (e) { + log(`Failed to add search provider ${path}: ${e}`); + } + } + + if (searchSettings.get_boolean('disable-external')) + return []; + + FileUtils.collectFromDatadirs('search-providers', false, loadRemoteSearchProvider); + + let sortOrder = searchSettings.get_strv('sort-order'); + + // Special case gnome-control-center to be always active and always first + sortOrder.unshift('org.gnome.Settings.desktop'); + + const disabled = searchSettings.get_strv('disabled'); + const enabled = searchSettings.get_strv('enabled'); + + loadedProviders = loadedProviders.filter(provider => { + let appId = provider.appInfo.get_id(); + + if (provider.defaultEnabled) + return !disabled.includes(appId); + else + return enabled.includes(appId); + }); + + loadedProviders.sort((providerA, providerB) => { + let idxA, idxB; + let appIdA, appIdB; + + appIdA = providerA.appInfo.get_id(); + appIdB = providerB.appInfo.get_id(); + + idxA = sortOrder.indexOf(appIdA); + idxB = sortOrder.indexOf(appIdB); + + // if no provider is found in the order, use alphabetical order + if ((idxA == -1) && (idxB == -1)) { + let nameA = providerA.appInfo.get_name(); + let nameB = providerB.appInfo.get_name(); + + return GLib.utf8_collate(nameA, nameB); + } + + // if providerA isn't found, it's sorted after providerB + if (idxA == -1) + return 1; + + // if providerB isn't found, it's sorted after providerA + if (idxB == -1) + return -1; + + // finally, if both providers are found, return their order in the list + return idxA - idxB; + }); + + return loadedProviders; +} + +var RemoteSearchProvider = class { + constructor(appInfo, dbusName, dbusPath, autoStart, proxyInfo) { + if (!proxyInfo) + proxyInfo = SearchProviderProxyInfo; + + let gFlags = Gio.DBusProxyFlags.DO_NOT_LOAD_PROPERTIES; + if (autoStart) + gFlags |= Gio.DBusProxyFlags.DO_NOT_AUTO_START_AT_CONSTRUCTION; + else + gFlags |= Gio.DBusProxyFlags.DO_NOT_AUTO_START; + + this.proxy = new Gio.DBusProxy({ + g_bus_type: Gio.BusType.SESSION, + g_name: dbusName, + g_object_path: dbusPath, + g_interface_info: proxyInfo, + g_interface_name: proxyInfo.name, + gFlags, + }); + this.proxy.init_async(GLib.PRIORITY_DEFAULT, null); + + this.appInfo = appInfo; + this.id = appInfo.get_id(); + this.isRemoteProvider = true; + this.canLaunchSearch = false; + } + + createIcon(size, meta) { + let gicon = null; + let icon = null; + + if (meta['icon']) { + gicon = Gio.icon_deserialize(meta['icon']); + } else if (meta['gicon']) { + gicon = Gio.icon_new_for_string(meta['gicon']); + } else if (meta['icon-data']) { + const [ + width, height, rowStride, hasAlpha, + bitsPerSample, nChannels_, data, + ] = meta['icon-data']; + gicon = Shell.util_create_pixbuf_from_data(data, GdkPixbuf.Colorspace.RGB, hasAlpha, + bitsPerSample, width, height, rowStride); + } + + if (gicon) + icon = new St.Icon({ gicon, icon_size: size }); + return icon; + } + + filterResults(results, maxNumber) { + if (results.length <= maxNumber) + return results; + + let regularResults = results.filter(r => !r.startsWith('special:')); + let specialResults = results.filter(r => r.startsWith('special:')); + + return regularResults.slice(0, maxNumber).concat(specialResults.slice(0, maxNumber)); + } + + async getInitialResultSet(terms, cancellable) { + try { + const [results] = await this.proxy.GetInitialResultSetAsync(terms, cancellable); + return results; + } catch (error) { + if (!error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) + log(`Received error from D-Bus search provider ${this.id}: ${error}`); + return []; + } + } + + async getSubsearchResultSet(previousResults, newTerms, cancellable) { + try { + const [results] = await this.proxy.GetSubsearchResultSetAsync(previousResults, newTerms, cancellable); + return results; + } catch (error) { + if (!error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) + log(`Received error from D-Bus search provider ${this.id}: ${error}`); + return []; + } + } + + async getResultMetas(ids, cancellable) { + let metas; + try { + [metas] = await this.proxy.GetResultMetasAsync(ids, cancellable); + } catch (error) { + if (!error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) + log(`Received error from D-Bus search provider ${this.id} during GetResultMetas: ${error}`); + return []; + } + + let resultMetas = []; + for (let i = 0; i < metas.length; i++) { + for (let prop in metas[i]) { + // we can use the serialized icon variant directly + if (prop !== 'icon') + metas[i][prop] = metas[i][prop].deepUnpack(); + } + + resultMetas.push({ + id: metas[i]['id'], + name: metas[i]['name'], + description: metas[i]['description'], + createIcon: size => this.createIcon(size, metas[i]), + clipboardText: metas[i]['clipboardText'], + }); + } + return resultMetas; + } + + activateResult(id) { + this.proxy.ActivateResultAsync(id).catch(logError); + } + + launchSearch(_terms) { + // the provider is not compatible with the new version of the interface, launch + // the app itself but warn so we can catch the error in logs + log(`Search provider ${this.appInfo.get_id()} does not implement LaunchSearch`); + this.appInfo.launch([], global.create_app_launch_context(0, -1)); + } +}; + +var RemoteSearchProvider2 = class extends RemoteSearchProvider { + constructor(appInfo, dbusName, dbusPath, autoStart) { + super(appInfo, dbusName, dbusPath, autoStart, SearchProvider2ProxyInfo); + + this.canLaunchSearch = true; + } + + activateResult(id, terms) { + this.proxy.ActivateResultAsync( + id, terms, global.get_current_time()).catch(logError); + } + + launchSearch(terms) { + this.proxy.LaunchSearchAsync( + terms, global.get_current_time()).catch(logError); + } +}; diff --git a/js/ui/ripples.js b/js/ui/ripples.js new file mode 100644 index 0000000..20ca9ed --- /dev/null +++ b/js/ui/ripples.js @@ -0,0 +1,110 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Ripples */ + +const { Clutter, St } = imports.gi; + +// Shamelessly copied from the layout "hotcorner" ripples implementation +var Ripples = class Ripples { + constructor(px, py, styleClass) { + this._x = 0; + this._y = 0; + + this._px = px; + this._py = py; + + this._ripple1 = new St.BoxLayout({ + style_class: styleClass, + opacity: 0, + can_focus: false, + reactive: false, + visible: false, + }); + this._ripple1.set_pivot_point(px, py); + + this._ripple2 = new St.BoxLayout({ + style_class: styleClass, + opacity: 0, + can_focus: false, + reactive: false, + visible: false, + }); + this._ripple2.set_pivot_point(px, py); + + this._ripple3 = new St.BoxLayout({ + style_class: styleClass, + opacity: 0, + can_focus: false, + reactive: false, + visible: false, + }); + this._ripple3.set_pivot_point(px, py); + } + + destroy() { + this._ripple1.destroy(); + this._ripple2.destroy(); + this._ripple3.destroy(); + } + + _animRipple(ripple, delay, duration, startScale, startOpacity, finalScale) { + // We draw a ripple by using a source image and animating it scaling + // outwards and fading away. We want the ripples to move linearly + // or it looks unrealistic, but if the opacity of the ripple goes + // linearly to zero it fades away too quickly, so we use a separate + // tween to give a non-linear curve to the fade-away and make + // it more visible in the middle section. + + ripple.x = this._x; + ripple.y = this._y; + ripple.visible = true; + ripple.opacity = 255 * Math.sqrt(startOpacity); + ripple.scale_x = ripple.scale_y = startScale; + ripple.set_translation(-this._px * ripple.width, -this._py * ripple.height, 0.0); + + ripple.ease({ + opacity: 0, + delay, + duration, + mode: Clutter.AnimationMode.EASE_IN_QUAD, + }); + ripple.ease({ + scale_x: finalScale, + scale_y: finalScale, + delay, + duration, + mode: Clutter.AnimationMode.LINEAR, + onComplete: () => (ripple.visible = false), + }); + } + + addTo(stage) { + if (this._stage !== undefined) + throw new Error('Ripples already added'); + + this._stage = stage; + this._stage.add_actor(this._ripple1); + this._stage.add_actor(this._ripple2); + this._stage.add_actor(this._ripple3); + } + + playAnimation(x, y) { + if (this._stage === undefined) + throw new Error('Ripples not added'); + + this._x = x; + this._y = y; + + this._stage.set_child_above_sibling(this._ripple1, null); + this._stage.set_child_above_sibling(this._ripple2, this._ripple1); + this._stage.set_child_above_sibling(this._ripple3, this._ripple2); + + // Show three concentric ripples expanding outwards; the exact + // parameters were found by trial and error, so don't look + // for them to make perfect sense mathematically + + // delay time scale opacity => scale + this._animRipple(this._ripple1, 0, 830, 0.25, 1.0, 1.5); + this._animRipple(this._ripple2, 50, 1000, 0.0, 0.7, 1.25); + this._animRipple(this._ripple3, 350, 1000, 0.0, 0.3, 1); + } +}; diff --git a/js/ui/runDialog.js b/js/ui/runDialog.js new file mode 100644 index 0000000..fe9b33e --- /dev/null +++ b/js/ui/runDialog.js @@ -0,0 +1,256 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported RunDialog */ + +const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; + +const Dialog = imports.ui.dialog; +const Main = imports.ui.main; +const ModalDialog = imports.ui.modalDialog; +const ShellEntry = imports.ui.shellEntry; +const Util = imports.misc.util; +const History = imports.misc.history; + +const HISTORY_KEY = 'command-history'; + +const LOCKDOWN_SCHEMA = 'org.gnome.desktop.lockdown'; +const DISABLE_COMMAND_LINE_KEY = 'disable-command-line'; + +const TERMINAL_SCHEMA = 'org.gnome.desktop.default-applications.terminal'; +const EXEC_KEY = 'exec'; +const EXEC_ARG_KEY = 'exec-arg'; + +var RunDialog = GObject.registerClass( +class RunDialog extends ModalDialog.ModalDialog { + _init() { + super._init({ + styleClass: 'run-dialog', + destroyOnClose: false, + }); + + this._lockdownSettings = new Gio.Settings({ schema_id: LOCKDOWN_SCHEMA }); + this._terminalSettings = new Gio.Settings({ schema_id: TERMINAL_SCHEMA }); + global.settings.connect('changed::development-tools', () => { + this._enableInternalCommands = global.settings.get_boolean('development-tools'); + }); + this._enableInternalCommands = global.settings.get_boolean('development-tools'); + + this._internalCommands = { + 'lg': () => Main.createLookingGlass().open(), + + 'r': this._restart.bind(this), + + // Developer brain backwards compatibility + 'restart': this._restart.bind(this), + + 'debugexit': () => global.context.terminate(), + + // rt is short for "reload theme" + 'rt': () => { + Main.reloadThemeResource(); + Main.loadTheme(); + }, + + 'check_cloexec_fds': () => { + Shell.util_check_cloexec_fds(); + }, + }; + + let title = _('Run a Command'); + + let content = new Dialog.MessageDialogContent({ title }); + this.contentLayout.add_actor(content); + + let entry = new St.Entry({ + style_class: 'run-dialog-entry', + can_focus: true, + }); + ShellEntry.addContextMenu(entry); + + this._entryText = entry.clutter_text; + content.add_child(entry); + this.setInitialKeyFocus(this._entryText); + + let defaultDescriptionText = _('Press ESC to close'); + + this._descriptionLabel = new St.Label({ + style_class: 'run-dialog-description', + text: defaultDescriptionText, + }); + content.add_child(this._descriptionLabel); + + this._commandError = false; + + this._pathCompleter = new Gio.FilenameCompleter(); + + this._history = new History.HistoryManager({ + gsettingsKey: HISTORY_KEY, + entry: this._entryText, + }); + this._entryText.connect('activate', o => { + this.popModal(); + this._run(o.get_text(), + Clutter.get_current_event().get_state() & Clutter.ModifierType.CONTROL_MASK); + if (!this._commandError || + !this.pushModal()) + this.close(); + }); + this._entryText.connect('key-press-event', (o, e) => { + let symbol = e.get_key_symbol(); + if (symbol === Clutter.KEY_Tab) { + let text = o.get_text(); + let prefix; + if (text.lastIndexOf(' ') == -1) + prefix = text; + else + prefix = text.substr(text.lastIndexOf(' ') + 1); + let postfix = this._getCompletion(prefix); + if (postfix != null && postfix.length > 0) { + o.insert_text(postfix, -1); + o.set_cursor_position(text.length + postfix.length); + } + return Clutter.EVENT_STOP; + } + return Clutter.EVENT_PROPAGATE; + }); + this._entryText.connect('text-changed', () => { + this._descriptionLabel.set_text(defaultDescriptionText); + }); + } + + vfunc_key_release_event(event) { + if (event.keyval === Clutter.KEY_Escape) { + this.close(); + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_PROPAGATE; + } + + _getCommandCompletion(text) { + function _getCommon(s1, s2) { + if (s1 == null) + return s2; + + let k = 0; + for (; k < s1.length && k < s2.length; k++) { + if (s1[k] != s2[k]) + break; + } + if (k == 0) + return ''; + return s1.substr(0, k); + } + + let paths = GLib.getenv('PATH').split(':'); + paths.push(GLib.get_home_dir()); + let someResults = paths.map(path => { + let results = []; + try { + let file = Gio.File.new_for_path(path); + let fileEnum = file.enumerate_children('standard::name', Gio.FileQueryInfoFlags.NONE, null); + let info; + while ((info = fileEnum.next_file(null))) { + let name = info.get_name(); + if (name.slice(0, text.length) == text) + results.push(name); + } + } catch (e) { + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND) && + !e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_DIRECTORY)) + log(e); + } + return results; + }); + let results = someResults.reduce((a, b) => a.concat(b), []); + + if (!results.length) + return null; + + let common = results.reduce(_getCommon, null); + return common.substr(text.length); + } + + _getCompletion(text) { + if (text.includes('/')) + return this._pathCompleter.get_completion_suffix(text); + else + return this._getCommandCompletion(text); + } + + _run(input, inTerminal) { + input = this._history.addItem(input); // trims input + let command = input; + + this._commandError = false; + let f; + if (this._enableInternalCommands) + f = this._internalCommands[input]; + else + f = null; + if (f) { + f(); + } else { + try { + if (inTerminal) { + let exec = this._terminalSettings.get_string(EXEC_KEY); + let execArg = this._terminalSettings.get_string(EXEC_ARG_KEY); + command = `${exec} ${execArg} ${input}`; + } + Util.trySpawnCommandLine(command); + } catch (e) { + // Mmmh, that failed - see if @input matches an existing file + let path = null; + if (input.charAt(0) == '/') { + path = input; + } else if (input) { + if (input.charAt(0) == '~') + input = input.slice(1); + path = `${GLib.get_home_dir()}/${input}`; + } + + if (path && GLib.file_test(path, GLib.FileTest.EXISTS)) { + let file = Gio.file_new_for_path(path); + try { + Gio.app_info_launch_default_for_uri(file.get_uri(), + global.create_app_launch_context(0, -1)); + } catch (err) { + // The exception from gjs contains an error string like: + // Error invoking Gio.app_info_launch_default_for_uri: No application + // is registered as handling this file + // We are only interested in the part after the first colon. + let message = err.message.replace(/[^:]*: *(.+)/, '$1'); + this._showError(message); + } + } else { + this._showError(e.message); + } + } + } + } + + _showError(message) { + this._commandError = true; + this._descriptionLabel.set_text(message); + } + + _restart() { + if (Meta.is_wayland_compositor()) { + this._showError(_('Restart is not available on Wayland')); + return; + } + this._shouldFadeOut = false; + this.close(); + Meta.restart(_('Restarting…'), global.context); + } + + open() { + this._history.lastItem(); + this._entryText.set_text(''); + this._commandError = false; + + if (this._lockdownSettings.get_boolean(DISABLE_COMMAND_LINE_KEY)) + return false; + + return super.open(); + } +}); diff --git a/js/ui/screenShield.js b/js/ui/screenShield.js new file mode 100644 index 0000000..325fbff --- /dev/null +++ b/js/ui/screenShield.js @@ -0,0 +1,686 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported ScreenShield */ + +const { + AccountsService, Clutter, Gio, + GLib, Graphene, Meta, Shell, St, +} = imports.gi; +const Signals = imports.misc.signals; + +const GnomeSession = imports.misc.gnomeSession; +const OVirt = imports.gdm.oVirt; +const LoginManager = imports.misc.loginManager; +const Lightbox = imports.ui.lightbox; +const Main = imports.ui.main; +const Overview = imports.ui.overview; +const MessageTray = imports.ui.messageTray; +const ShellDBus = imports.ui.shellDBus; +const SmartcardManager = imports.misc.smartcardManager; + +const { adjustAnimationTime } = imports.ui.environment; + +const SCREENSAVER_SCHEMA = 'org.gnome.desktop.screensaver'; +const LOCK_ENABLED_KEY = 'lock-enabled'; +const LOCK_DELAY_KEY = 'lock-delay'; + +const LOCKDOWN_SCHEMA = 'org.gnome.desktop.lockdown'; +const DISABLE_LOCK_KEY = 'disable-lock-screen'; + +const LOCKED_STATE_STR = 'screenShield.locked'; + +// ScreenShield animation time +// - STANDARD_FADE_TIME is used when the session goes idle +// - MANUAL_FADE_TIME is used for lowering the shield when asked by the user, +// or when cancelling the dialog +// - CURTAIN_SLIDE_TIME is used when raising the shield before unlocking +var STANDARD_FADE_TIME = 10000; +var MANUAL_FADE_TIME = 300; +var CURTAIN_SLIDE_TIME = 300; + +/** + * If you are setting org.gnome.desktop.session.idle-delay directly in dconf, + * rather than through System Settings, you also need to set + * org.gnome.settings-daemon.plugins.power.sleep-display-ac and + * org.gnome.settings-daemon.plugins.power.sleep-display-battery to the same value. + * This will ensure that the screen blanks at the right time when it fades out. + * https://bugzilla.gnome.org/show_bug.cgi?id=668703 explains the dependency. + */ +var ScreenShield = class extends Signals.EventEmitter { + constructor() { + super(); + + this.actor = Main.layoutManager.screenShieldGroup; + + this._lockScreenState = MessageTray.State.HIDDEN; + this._lockScreenGroup = new St.Widget({ + x_expand: true, + y_expand: true, + reactive: true, + can_focus: true, + name: 'lockScreenGroup', + visible: false, + }); + + this._lockDialogGroup = new St.Widget({ + x_expand: true, + y_expand: true, + reactive: true, + can_focus: true, + pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }), + name: 'lockDialogGroup', + }); + + this.actor.add_actor(this._lockScreenGroup); + this.actor.add_actor(this._lockDialogGroup); + + this._presence = new GnomeSession.Presence((proxy, error) => { + if (error) { + logError(error, 'Error while reading gnome-session presence'); + return; + } + + this._onStatusChanged(proxy.status); + }); + this._presence.connectSignal('StatusChanged', (proxy, senderName, [status]) => { + this._onStatusChanged(status); + }); + + this._screenSaverDBus = new ShellDBus.ScreenSaverDBus(this); + + this._smartcardManager = SmartcardManager.getSmartcardManager(); + this._smartcardManager.connect('smartcard-inserted', + (manager, token) => { + if (this._isLocked && token.UsedToLogin) + this._activateDialog(); + }); + + this._oVirtCredentialsManager = OVirt.getOVirtCredentialsManager(); + this._oVirtCredentialsManager.connect('user-authenticated', + () => { + if (this._isLocked) + this._activateDialog(); + }); + + this._loginManager = LoginManager.getLoginManager(); + this._loginManager.connect('prepare-for-sleep', + this._prepareForSleep.bind(this)); + + this._loginSession = null; + this._getLoginSession(); + + this._settings = new Gio.Settings({ schema_id: SCREENSAVER_SCHEMA }); + this._settings.connect(`changed::${LOCK_ENABLED_KEY}`, this._syncInhibitor.bind(this)); + + this._lockSettings = new Gio.Settings({ schema_id: LOCKDOWN_SCHEMA }); + this._lockSettings.connect(`changed::${DISABLE_LOCK_KEY}`, this._syncInhibitor.bind(this)); + + this._isModal = false; + this._isGreeter = false; + this._isActive = false; + this._isLocked = false; + this._inUnlockAnimation = false; + this._inhibited = false; + this._activationTime = 0; + this._becameActiveId = 0; + this._lockTimeoutId = 0; + + // The "long" lightbox is used for the longer (20 seconds) fade from session + // to idle status, the "short" is used for quickly fading to black when locking + // manually + this._longLightbox = new Lightbox.Lightbox(Main.uiGroup, { + inhibitEvents: true, + fadeFactor: 1, + }); + this._longLightbox.connect('notify::active', this._onLongLightbox.bind(this)); + this._shortLightbox = new Lightbox.Lightbox(Main.uiGroup, { + inhibitEvents: true, + fadeFactor: 1, + }); + this._shortLightbox.connect('notify::active', this._onShortLightbox.bind(this)); + + this.idleMonitor = global.backend.get_core_idle_monitor(); + this._cursorTracker = Meta.CursorTracker.get_for_display(global.display); + + this._syncInhibitor(); + } + + async _getLoginSession() { + this._loginSession = await this._loginManager.getCurrentSessionProxy(); + this._loginSession.connectSignal('Lock', + () => this.lock(false)); + this._loginSession.connectSignal('Unlock', + () => this.deactivate(false)); + this._loginSession.connect('g-properties-changed', + () => this._syncInhibitor()); + this._syncInhibitor(); + } + + _setActive(active) { + let prevIsActive = this._isActive; + this._isActive = active; + + if (prevIsActive != this._isActive) + this.emit('active-changed'); + + this._syncInhibitor(); + } + + _setLocked(locked) { + let prevIsLocked = this._isLocked; + this._isLocked = locked; + + if (prevIsLocked !== this._isLocked) + this.emit('locked-changed'); + + if (this._loginSession) + this._loginSession.SetLockedHintAsync(locked).catch(logError); + } + + _activateDialog() { + if (this._isLocked) { + this._ensureUnlockDialog(true /* allowCancel */); + this._dialog.activate(); + } else { + this.deactivate(true /* animate */); + } + } + + _maybeCancelDialog() { + if (!this._dialog) + return; + + this._dialog.cancel(); + if (this._isGreeter) { + // LoginDialog.cancel() will grab the key focus + // on its own, so ensure it stays on lock screen + // instead + this._dialog.grab_key_focus(); + } + } + + _becomeModal() { + if (this._isModal) + return true; + + let grab = Main.pushModal(Main.uiGroup, { actionMode: Shell.ActionMode.LOCK_SCREEN }); + + // We expect at least a keyboard grab here + this._isModal = (grab.get_seat_state() & Clutter.GrabState.KEYBOARD) !== 0; + if (this._isModal) + this._grab = grab; + else + Main.popModal(grab); + + return this._isModal; + } + + async _syncInhibitor() { + const lockEnabled = this._settings.get_boolean(LOCK_ENABLED_KEY); + const lockLocked = this._lockSettings.get_boolean(DISABLE_LOCK_KEY); + const inhibit = !!this._loginSession && this._loginSession.Active && + !this._isActive && lockEnabled && !lockLocked && + !!Main.sessionMode.unlockDialog; + + if (inhibit === this._inhibited) + return; + + this._inhibited = inhibit; + + this._inhibitCancellable?.cancel(); + this._inhibitCancellable = new Gio.Cancellable(); + + if (inhibit) { + try { + this._inhibitor = await this._loginManager.inhibit( + _('GNOME needs to lock the screen'), + this._inhibitCancellable); + } catch (e) { + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) + log('Failed to inhibit suspend: %s'.format(e.message)); + } + } else { + this._inhibitor?.close(null); + this._inhibitor = null; + } + } + + _prepareForSleep(loginManager, aboutToSuspend) { + if (aboutToSuspend) { + if (this._settings.get_boolean(LOCK_ENABLED_KEY)) + this.lock(true); + } else { + this._wakeUpScreen(); + } + } + + _onStatusChanged(status) { + if (status != GnomeSession.PresenceStatus.IDLE) + return; + + this._maybeCancelDialog(); + + if (this._longLightbox.visible) { + // We're in the process of showing. + return; + } + + if (!this._becomeModal()) { + // We could not become modal, so we can't activate the + // screenshield. The user is probably very upset at this + // point, but any application using global grabs is broken + // Just tell them to stop using this app + // + // XXX: another option is to kick the user into the gdm login + // screen, where we're not affected by grabs + Main.notifyError(_("Unable to lock"), + _("Lock was blocked by an application")); + return; + } + + if (this._activationTime == 0) + this._activationTime = GLib.get_monotonic_time(); + + let shouldLock = this._settings.get_boolean(LOCK_ENABLED_KEY) && !this._isLocked; + + if (shouldLock) { + let lockTimeout = Math.max( + adjustAnimationTime(STANDARD_FADE_TIME), + this._settings.get_uint(LOCK_DELAY_KEY) * 1000); + this._lockTimeoutId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + lockTimeout, + () => { + this._lockTimeoutId = 0; + this.lock(false); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._lockTimeoutId, '[gnome-shell] this.lock'); + } + + this._activateFade(this._longLightbox, STANDARD_FADE_TIME); + } + + _activateFade(lightbox, time) { + Main.uiGroup.set_child_above_sibling(lightbox, null); + lightbox.lightOn(time); + + if (this._becameActiveId == 0) + this._becameActiveId = this.idleMonitor.add_user_active_watch(this._onUserBecameActive.bind(this)); + } + + _onUserBecameActive() { + // This function gets called here when the user becomes active + // after we activated a lightbox + // There are two possibilities here: + // - we're called when already locked; we just go back to the lock screen curtain + // - we're called because the session is IDLE but before the lightbox + // is fully shown; at this point isActive is false, so we just hide + // the lightbox, reset the activationTime and go back to the unlocked + // desktop + // using deactivate() is a little of overkill, but it ensures we + // don't forget of some bit like modal, DBus properties or idle watches + // + // Note: if the (long) lightbox is shown then we're necessarily + // active, because we call activate() without animation. + + this.idleMonitor.remove_watch(this._becameActiveId); + this._becameActiveId = 0; + + if (this._isLocked) { + this._longLightbox.lightOff(); + this._shortLightbox.lightOff(); + } else { + this.deactivate(false); + } + } + + _onLongLightbox(lightBox) { + if (lightBox.active) + this.activate(false); + } + + _onShortLightbox(lightBox) { + if (lightBox.active) + this._completeLockScreenShown(); + } + + showDialog() { + if (!this._becomeModal()) { + // In the login screen, this is a hard error. Fail-whale + const error = new GLib.Error( + Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED, + 'Could not acquire modal grab for the login screen. Aborting login process.'); + global.context.terminate_with_error(error); + } + + this.actor.show(); + this._isGreeter = Main.sessionMode.isGreeter; + this._isLocked = true; + this._ensureUnlockDialog(true); + } + + _hideLockScreenComplete() { + this._lockScreenState = MessageTray.State.HIDDEN; + this._lockScreenGroup.hide(); + + if (this._dialog) { + this._dialog.grab_key_focus(); + this._dialog.navigate_focus(null, St.DirectionType.TAB_FORWARD, false); + } + } + + _showPointer() { + this._cursorTracker.set_pointer_visible(true); + + if (this._motionId) { + global.stage.disconnect(this._motionId); + this._motionId = 0; + } + } + + _hidePointerUntilMotion() { + this._motionId = global.stage.connect('captured-event', (stage, event) => { + if (event.type() === Clutter.EventType.MOTION) + this._showPointer(); + + return Clutter.EVENT_PROPAGATE; + }); + this._cursorTracker.set_pointer_visible(false); + } + + _hideLockScreen(animate) { + if (this._lockScreenState == MessageTray.State.HIDDEN) + return; + + this._lockScreenState = MessageTray.State.HIDING; + + this._lockDialogGroup.remove_all_transitions(); + + if (animate) { + // Animate the lock screen out of screen + // if velocity is not specified (i.e. we come here from pressing ESC), + // use the same speed regardless of original position + // if velocity is specified, it's in pixels per milliseconds + let h = global.stage.height; + let delta = h + this._lockDialogGroup.translation_y; + let velocity = global.stage.height / CURTAIN_SLIDE_TIME; + let duration = delta / velocity; + + this._lockDialogGroup.ease({ + translation_y: -h, + duration, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._hideLockScreenComplete(), + }); + } else { + this._hideLockScreenComplete(); + } + + this._showPointer(); + } + + _ensureUnlockDialog(allowCancel) { + if (!this._dialog) { + let constructor = Main.sessionMode.unlockDialog; + if (!constructor) { + // This session mode has no locking capabilities + this.deactivate(true); + return false; + } + + this._dialog = new constructor(this._lockDialogGroup); + + let time = global.get_current_time(); + if (!this._dialog.open(time)) { + // This is kind of an impossible error: we're already modal + // by the time we reach this... + log('Could not open login dialog: failed to acquire grab'); + this.deactivate(true); + return false; + } + + this._dialog.connect('failed', this._onUnlockFailed.bind(this)); + this._wakeUpScreenId = this._dialog.connect( + 'wake-up-screen', this._wakeUpScreen.bind(this)); + } + + this._dialog.allowCancel = allowCancel; + this._dialog.grab_key_focus(); + return true; + } + + _onUnlockFailed() { + this._resetLockScreen({ + animateLockScreen: true, + fadeToBlack: false, + }); + } + + _resetLockScreen(params) { + // Don't reset the lock screen unless it is completely hidden + // This prevents the shield going down if the lock-delay timeout + // fires while the user is dragging (which has the potential + // to confuse our state) + if (this._lockScreenState != MessageTray.State.HIDDEN) + return; + + this._lockScreenGroup.show(); + this._lockScreenState = MessageTray.State.SHOWING; + + let fadeToBlack = params.fadeToBlack; + + if (params.animateLockScreen) { + this._lockDialogGroup.translation_y = -global.screen_height; + this._lockDialogGroup.remove_all_transitions(); + this._lockDialogGroup.ease({ + translation_y: 0, + duration: Overview.ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._lockScreenShown({ fadeToBlack, animateFade: true }); + }, + }); + } else { + this._lockDialogGroup.translation_y = 0; + this._lockScreenShown({ fadeToBlack, animateFade: false }); + } + + this._dialog.grab_key_focus(); + } + + _lockScreenShown(params) { + this._hidePointerUntilMotion(); + + this._lockScreenState = MessageTray.State.SHOWN; + + if (params.fadeToBlack && params.animateFade) { + // Take a beat + + let id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, MANUAL_FADE_TIME, () => { + this._activateFade(this._shortLightbox, MANUAL_FADE_TIME); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] this._activateFade'); + } else { + if (params.fadeToBlack) + this._activateFade(this._shortLightbox, 0); + + this._completeLockScreenShown(); + } + } + + _completeLockScreenShown() { + this._setActive(true); + this.emit('lock-screen-shown'); + } + + _wakeUpScreen() { + if (!this.active) + return; // already woken up, or not yet asleep + this._onUserBecameActive(); + this.emit('wake-up-screen'); + } + + get locked() { + return this._isLocked; + } + + get active() { + return this._isActive; + } + + get activationTime() { + return this._activationTime; + } + + deactivate(animate) { + if (this._dialog) + this._dialog.finish(() => this._continueDeactivate(animate)); + else + this._continueDeactivate(animate); + } + + _continueDeactivate(animate) { + this._hideLockScreen(animate); + + if (Main.sessionMode.currentMode == 'unlock-dialog') + Main.sessionMode.popMode('unlock-dialog'); + + this.emit('wake-up-screen'); + + if (this._isGreeter) { + // We don't want to "deactivate" any more than + // this. In particular, we don't want to drop + // the modal, hide ourselves or destroy the dialog + // But we do want to set isActive to false, so that + // gnome-session will reset the idle counter, and + // gnome-settings-daemon will stop blanking the screen + + this._activationTime = 0; + this._setActive(false); + return; + } + + if (this._dialog && !this._isGreeter) + this._dialog.popModal(); + + if (this._isModal) { + Main.popModal(this._grab); + this._grab = null; + this._isModal = false; + } + + this._longLightbox.lightOff(); + this._shortLightbox.lightOff(); + + this._lockDialogGroup.ease({ + translation_y: -global.screen_height, + duration: Overview.ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._completeDeactivate(), + }); + } + + _completeDeactivate() { + if (this._dialog) { + this._dialog.destroy(); + this._dialog = null; + } + + this.actor.hide(); + + if (this._becameActiveId != 0) { + this.idleMonitor.remove_watch(this._becameActiveId); + this._becameActiveId = 0; + } + + if (this._lockTimeoutId != 0) { + GLib.source_remove(this._lockTimeoutId); + this._lockTimeoutId = 0; + } + + this._activationTime = 0; + this._setActive(false); + this._setLocked(false); + global.set_runtime_state(LOCKED_STATE_STR, null); + } + + activate(animate) { + if (this._activationTime == 0) + this._activationTime = GLib.get_monotonic_time(); + + if (!this._ensureUnlockDialog(true)) + return; + + this.actor.show(); + + if (Main.sessionMode.currentMode !== 'unlock-dialog') { + this._isGreeter = Main.sessionMode.isGreeter; + if (!this._isGreeter) + Main.sessionMode.pushMode('unlock-dialog'); + } + + this._resetLockScreen({ + animateLockScreen: animate, + fadeToBlack: true, + }); + // On wayland, a crash brings down the entire session, so we don't + // need to defend against being restarted unlocked + if (!Meta.is_wayland_compositor()) + global.set_runtime_state(LOCKED_STATE_STR, GLib.Variant.new('b', true)); + + // We used to set isActive and emit active-changed here, + // but now we do that from lockScreenShown, which means + // there is a 0.3 seconds window during which the lock + // screen is effectively visible and the screen is locked, but + // the DBus interface reports the screensaver is off. + // This is because when we emit ActiveChanged(true), + // gnome-settings-daemon blanks the screen, and we don't want + // blank during the animation. + // This is not a problem for the idle fade case, because we + // activate without animation in that case. + } + + lock(animate) { + if (this._lockSettings.get_boolean(DISABLE_LOCK_KEY)) { + log('Screen lock is locked down, not locking'); // lock, lock - who's there? + return; + } + + // Warn the user if we can't become modal + if (!this._becomeModal()) { + Main.notifyError(_("Unable to lock"), + _("Lock was blocked by an application")); + return; + } + + // Clear the clipboard - otherwise, its contents may be leaked + // to unauthorized parties by pasting into the unlock dialog's + // password entry and unmasking the entry + St.Clipboard.get_default().set_text(St.ClipboardType.CLIPBOARD, ''); + St.Clipboard.get_default().set_text(St.ClipboardType.PRIMARY, ''); + + let userManager = AccountsService.UserManager.get_default(); + let user = userManager.get_user(GLib.get_user_name()); + + this.activate(animate); + + const lock = this._isGreeter + ? true + : user.password_mode !== AccountsService.UserPasswordMode.NONE; + this._setLocked(lock); + } + + // If the previous shell crashed, and gnome-session restarted us, then re-lock + lockIfWasLocked() { + if (!this._settings.get_boolean(LOCK_ENABLED_KEY)) + return; + let wasLocked = global.get_runtime_state('b', LOCKED_STATE_STR); + if (wasLocked === null) + return; + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + this.lock(false); + return GLib.SOURCE_REMOVE; + }); + } +}; diff --git a/js/ui/screenshot.js b/js/ui/screenshot.js new file mode 100644 index 0000000..5139052 --- /dev/null +++ b/js/ui/screenshot.js @@ -0,0 +1,2897 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported ScreenshotService, ScreenshotUI, showScreenshotUI, captureScreenshot */ + +const { Clutter, Cogl, Gio, GObject, GLib, Graphene, Gtk, Meta, Shell, St } = imports.gi; + +const GrabHelper = imports.ui.grabHelper; +const Layout = imports.ui.layout; +const Lightbox = imports.ui.lightbox; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; +const Workspace = imports.ui.workspace; + +Gio._promisify(Shell.Screenshot.prototype, 'pick_color'); +Gio._promisify(Shell.Screenshot.prototype, 'screenshot'); +Gio._promisify(Shell.Screenshot.prototype, 'screenshot_window'); +Gio._promisify(Shell.Screenshot.prototype, 'screenshot_area'); +Gio._promisify(Shell.Screenshot.prototype, 'screenshot_stage_to_content'); +Gio._promisify(Shell.Screenshot, 'composite_to_stream'); + +const { loadInterfaceXML } = imports.misc.fileUtils; +const { DBusSenderChecker } = imports.misc.util; + +const ScreenshotIface = loadInterfaceXML('org.gnome.Shell.Screenshot'); + +const ScreencastIface = loadInterfaceXML('org.gnome.Shell.Screencast'); +const ScreencastProxy = Gio.DBusProxy.makeProxyWrapper(ScreencastIface); + +var IconLabelButton = GObject.registerClass( +class IconLabelButton extends St.Button { + _init(iconName, label, params) { + super._init(params); + + this._container = new St.BoxLayout({ + vertical: true, + style_class: 'icon-label-button-container', + }); + this.set_child(this._container); + + this._container.add_child(new St.Icon({ icon_name: iconName })); + this._container.add_child(new St.Label({ + text: label, + x_align: Clutter.ActorAlign.CENTER, + })); + } +}); + +var Tooltip = GObject.registerClass( +class Tooltip extends St.Label { + _init(widget, params) { + super._init(params); + + this._widget = widget; + this._timeoutId = null; + + this._widget.connect('notify::hover', () => { + if (this._widget.hover) + this.open(); + else + this.close(); + }); + } + + open() { + if (this._timeoutId) + return; + + this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 300, () => { + this.opacity = 0; + this.show(); + + const extents = this._widget.get_transformed_extents(); + + const xOffset = Math.floor((extents.get_width() - this.width) / 2); + const x = + Math.clamp(extents.get_x() + xOffset, 0, global.stage.width - this.width); + + const node = this.get_theme_node(); + const yOffset = node.get_length('-y-offset'); + + const y = extents.get_y() - this.height - yOffset; + + this.set_position(x, y); + this.ease({ + opacity: 255, + duration: 150, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + + this._timeoutId = null; + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._timeoutId, '[gnome-shell] tooltip.open'); + } + + close() { + if (this._timeoutId) { + GLib.source_remove(this._timeoutId); + this._timeoutId = null; + return; + } + + if (!this.visible) + return; + + this.remove_all_transitions(); + this.ease({ + opacity: 0, + duration: 100, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this.hide(), + }); + } +}); + +var UIAreaIndicator = GObject.registerClass( +class UIAreaIndicator extends St.Widget { + _init(params) { + super._init(params); + + this._topRect = new St.Widget({ style_class: 'screenshot-ui-area-indicator-shade' }); + this._topRect.add_constraint(new Clutter.BindConstraint({ + source: this, + coordinate: Clutter.BindCoordinate.WIDTH, + })); + this._topRect.add_constraint(new Clutter.SnapConstraint({ + source: this, + from_edge: Clutter.SnapEdge.TOP, + to_edge: Clutter.SnapEdge.TOP, + })); + this._topRect.add_constraint(new Clutter.SnapConstraint({ + source: this, + from_edge: Clutter.SnapEdge.LEFT, + to_edge: Clutter.SnapEdge.LEFT, + })); + this.add_child(this._topRect); + + this._bottomRect = new St.Widget({ style_class: 'screenshot-ui-area-indicator-shade' }); + this._bottomRect.add_constraint(new Clutter.BindConstraint({ + source: this, + coordinate: Clutter.BindCoordinate.WIDTH, + })); + this._bottomRect.add_constraint(new Clutter.SnapConstraint({ + source: this, + from_edge: Clutter.SnapEdge.BOTTOM, + to_edge: Clutter.SnapEdge.BOTTOM, + })); + this._bottomRect.add_constraint(new Clutter.SnapConstraint({ + source: this, + from_edge: Clutter.SnapEdge.LEFT, + to_edge: Clutter.SnapEdge.LEFT, + })); + this.add_child(this._bottomRect); + + this._leftRect = new St.Widget({ style_class: 'screenshot-ui-area-indicator-shade' }); + this._leftRect.add_constraint(new Clutter.SnapConstraint({ + source: this, + from_edge: Clutter.SnapEdge.LEFT, + to_edge: Clutter.SnapEdge.LEFT, + })); + this._leftRect.add_constraint(new Clutter.SnapConstraint({ + source: this._topRect, + from_edge: Clutter.SnapEdge.TOP, + to_edge: Clutter.SnapEdge.BOTTOM, + })); + this._leftRect.add_constraint(new Clutter.SnapConstraint({ + source: this._bottomRect, + from_edge: Clutter.SnapEdge.BOTTOM, + to_edge: Clutter.SnapEdge.TOP, + })); + this.add_child(this._leftRect); + + this._rightRect = new St.Widget({ style_class: 'screenshot-ui-area-indicator-shade' }); + this._rightRect.add_constraint(new Clutter.SnapConstraint({ + source: this, + from_edge: Clutter.SnapEdge.RIGHT, + to_edge: Clutter.SnapEdge.RIGHT, + })); + this._rightRect.add_constraint(new Clutter.SnapConstraint({ + source: this._topRect, + from_edge: Clutter.SnapEdge.TOP, + to_edge: Clutter.SnapEdge.BOTTOM, + })); + this._rightRect.add_constraint(new Clutter.SnapConstraint({ + source: this._bottomRect, + from_edge: Clutter.SnapEdge.BOTTOM, + to_edge: Clutter.SnapEdge.TOP, + })); + this.add_child(this._rightRect); + + this._selectionRect = new St.Widget({ style_class: 'screenshot-ui-area-indicator-selection' }); + this.add_child(this._selectionRect); + + this._topRect.add_constraint(new Clutter.SnapConstraint({ + source: this._selectionRect, + from_edge: Clutter.SnapEdge.BOTTOM, + to_edge: Clutter.SnapEdge.TOP, + })); + + this._bottomRect.add_constraint(new Clutter.SnapConstraint({ + source: this._selectionRect, + from_edge: Clutter.SnapEdge.TOP, + to_edge: Clutter.SnapEdge.BOTTOM, + })); + + this._leftRect.add_constraint(new Clutter.SnapConstraint({ + source: this._selectionRect, + from_edge: Clutter.SnapEdge.RIGHT, + to_edge: Clutter.SnapEdge.LEFT, + })); + + this._rightRect.add_constraint(new Clutter.SnapConstraint({ + source: this._selectionRect, + from_edge: Clutter.SnapEdge.LEFT, + to_edge: Clutter.SnapEdge.RIGHT, + })); + } + + setSelectionRect(x, y, width, height) { + this._selectionRect.set_position(x, y); + this._selectionRect.set_size(width, height); + } +}); + +var UIAreaSelector = GObject.registerClass({ + Signals: { 'drag-started': {}, 'drag-ended': {} }, +}, class UIAreaSelector extends St.Widget { + _init(params) { + super._init(params); + + // During a drag, this can be Clutter.BUTTON_PRIMARY, + // Clutter.BUTTON_SECONDARY or the string "touch" to identify the source + // of the drag operation. + this._dragButton = 0; + this._dragSequence = null; + + this._areaIndicator = new UIAreaIndicator(); + this._areaIndicator.add_constraint(new Clutter.BindConstraint({ + source: this, + coordinate: Clutter.BindCoordinate.ALL, + })); + this.add_child(this._areaIndicator); + + this._topLeftHandle = new St.Widget({ style_class: 'screenshot-ui-area-selector-handle' }); + this.add_child(this._topLeftHandle); + this._topRightHandle = new St.Widget({ style_class: 'screenshot-ui-area-selector-handle' }); + this.add_child(this._topRightHandle); + this._bottomLeftHandle = new St.Widget({ style_class: 'screenshot-ui-area-selector-handle' }); + this.add_child(this._bottomLeftHandle); + this._bottomRightHandle = new St.Widget({ style_class: 'screenshot-ui-area-selector-handle' }); + this.add_child(this._bottomRightHandle); + + // This will be updated before the first drawn frame. + this._handleSize = 0; + this._topLeftHandle.connect('style-changed', widget => { + this._handleSize = widget.get_theme_node().get_width(); + this._updateSelectionRect(); + }); + + this.connect('notify::mapped', () => { + if (this.mapped) { + const [x, y] = global.get_pointer(); + this._updateCursor(x, y); + } + }); + + // Initialize area to out of bounds so reset() below resets it. + this._startX = -1; + this._startY = 0; + this._lastX = 0; + this._lastY = 0; + + this.reset(); + } + + reset() { + this.stopDrag(); + global.display.set_cursor(Meta.Cursor.DEFAULT); + + // Preserve area selection if possible. If the area goes out of bounds, + // the monitors might have changed, so reset the area. + const [x, y, w, h] = this.getGeometry(); + if (x < 0 || y < 0 || x + w > this.width || y + h > this.height) { + // Initialize area to out of bounds so if there's no monitor, + // the area will be reset once a monitor does appear. + this._startX = -1; + this._startY = 0; + this._lastX = 0; + this._lastY = 0; + + // This can happen when running headless without any monitors. + if (Main.layoutManager.primaryIndex !== -1) { + const monitor = + Main.layoutManager.monitors[Main.layoutManager.primaryIndex]; + + this._startX = monitor.x + Math.floor(monitor.width * 3 / 8); + this._startY = monitor.y + Math.floor(monitor.height * 3 / 8); + this._lastX = monitor.x + Math.floor(monitor.width * 5 / 8) - 1; + this._lastY = monitor.y + Math.floor(monitor.height * 5 / 8) - 1; + } + + this._updateSelectionRect(); + } + } + + getGeometry() { + const leftX = Math.min(this._startX, this._lastX); + const topY = Math.min(this._startY, this._lastY); + const rightX = Math.max(this._startX, this._lastX); + const bottomY = Math.max(this._startY, this._lastY); + + return [leftX, topY, rightX - leftX + 1, bottomY - topY + 1]; + } + + _updateSelectionRect() { + const [x, y, w, h] = this.getGeometry(); + this._areaIndicator.setSelectionRect(x, y, w, h); + + const offset = this._handleSize / 2; + this._topLeftHandle.set_position(x - offset, y - offset); + this._topRightHandle.set_position(x + w - 1 - offset, y - offset); + this._bottomLeftHandle.set_position(x - offset, y + h - 1 - offset); + this._bottomRightHandle.set_position(x + w - 1 - offset, y + h - 1 - offset); + } + + _computeCursorType(cursorX, cursorY) { + const [leftX, topY, width, height] = this.getGeometry(); + const [rightX, bottomY] = [leftX + width - 1, topY + height - 1]; + const [x, y] = [cursorX, cursorY]; + + // Check if the cursor overlaps the handles first. + const limit = (this._handleSize / 2) ** 2; + if ((leftX - x) ** 2 + (topY - y) ** 2 <= limit) + return Meta.Cursor.NW_RESIZE; + else if ((rightX - x) ** 2 + (topY - y) ** 2 <= limit) + return Meta.Cursor.NE_RESIZE; + else if ((leftX - x) ** 2 + (bottomY - y) ** 2 <= limit) + return Meta.Cursor.SW_RESIZE; + else if ((rightX - x) ** 2 + (bottomY - y) ** 2 <= limit) + return Meta.Cursor.SE_RESIZE; + + // Now check the rest of the rectangle. + const threshold = + 10 * St.ThemeContext.get_for_stage(global.stage).scaleFactor; + + if (leftX - x >= 0 && leftX - x <= threshold) { + if (topY - y >= 0 && topY - y <= threshold) + return Meta.Cursor.NW_RESIZE; + else if (y - bottomY >= 0 && y - bottomY <= threshold) + return Meta.Cursor.SW_RESIZE; + else if (topY - y < 0 && y - bottomY < 0) + return Meta.Cursor.WEST_RESIZE; + } else if (x - rightX >= 0 && x - rightX <= threshold) { + if (topY - y >= 0 && topY - y <= threshold) + return Meta.Cursor.NE_RESIZE; + else if (y - bottomY >= 0 && y - bottomY <= threshold) + return Meta.Cursor.SE_RESIZE; + else if (topY - y < 0 && y - bottomY < 0) + return Meta.Cursor.EAST_RESIZE; + } else if (leftX - x < 0 && x - rightX < 0) { + if (topY - y >= 0 && topY - y <= threshold) + return Meta.Cursor.NORTH_RESIZE; + else if (y - bottomY >= 0 && y - bottomY <= threshold) + return Meta.Cursor.SOUTH_RESIZE; + else if (topY - y < 0 && y - bottomY < 0) + return Meta.Cursor.MOVE_OR_RESIZE_WINDOW; + } + + return Meta.Cursor.CROSSHAIR; + } + + stopDrag() { + if (!this._dragButton) + return; + + if (this._dragGrab) { + this._dragGrab.dismiss(); + this._dragGrab = null; + } + + this._dragButton = 0; + this._dragSequence = null; + + if (this._dragCursor === Meta.Cursor.CROSSHAIR && + this._lastX === this._startX && this._lastY === this._startY) { + // The user clicked without dragging. Make up a larger selection + // to reduce confusion. + const offset = + 20 * St.ThemeContext.get_for_stage(global.stage).scaleFactor; + this._startX -= offset; + this._startY -= offset; + this._lastX += offset; + this._lastY += offset; + + // Keep the coordinates inside the stage. + if (this._startX < 0) { + this._lastX -= this._startX; + this._startX = 0; + } else if (this._lastX >= this.width) { + this._startX -= this._lastX - this.width + 1; + this._lastX = this.width - 1; + } + + if (this._startY < 0) { + this._lastY -= this._startY; + this._startY = 0; + } else if (this._lastY >= this.height) { + this._startY -= this._lastY - this.height + 1; + this._lastY = this.height - 1; + } + + this._updateSelectionRect(); + } + + this.emit('drag-ended'); + } + + _updateCursor(x, y) { + const cursor = this._computeCursorType(x, y); + global.display.set_cursor(cursor); + } + + _onPress(event, button, sequence) { + if (this._dragButton) + return Clutter.EVENT_PROPAGATE; + + const cursor = this._computeCursorType(event.x, event.y); + + // Clicking outside of the selection, or using the right mouse button, + // or with Ctrl results in dragging a new selection from scratch. + if (cursor === Meta.Cursor.CROSSHAIR || + button === Clutter.BUTTON_SECONDARY || + (event.modifier_state & Clutter.ModifierType.CONTROL_MASK)) { + this._dragButton = button; + + this._dragCursor = Meta.Cursor.CROSSHAIR; + global.display.set_cursor(Meta.Cursor.CROSSHAIR); + + [this._startX, this._startY] = [event.x, event.y]; + this._lastX = this._startX = Math.floor(this._startX); + this._lastY = this._startY = Math.floor(this._startY); + + this._updateSelectionRect(); + } else { + // This is a move or resize operation. + this._dragButton = button; + + this._dragCursor = cursor; + this._dragStartX = event.x; + this._dragStartY = event.y; + + const [leftX, topY, width, height] = this.getGeometry(); + const rightX = leftX + width - 1; + const bottomY = topY + height - 1; + + // For moving, start X and Y are the top left corner, while + // last X and Y are the bottom right corner. + if (cursor === Meta.Cursor.MOVE_OR_RESIZE_WINDOW) { + this._startX = leftX; + this._startY = topY; + this._lastX = rightX; + this._lastY = bottomY; + } + + // Start X and Y are set to the stationary sides, while last X + // and Y are set to the moving sides. + if (cursor === Meta.Cursor.NW_RESIZE || + cursor === Meta.Cursor.WEST_RESIZE || + cursor === Meta.Cursor.SW_RESIZE) { + this._startX = rightX; + this._lastX = leftX; + } + if (cursor === Meta.Cursor.NE_RESIZE || + cursor === Meta.Cursor.EAST_RESIZE || + cursor === Meta.Cursor.SE_RESIZE) { + this._startX = leftX; + this._lastX = rightX; + } + if (cursor === Meta.Cursor.NW_RESIZE || + cursor === Meta.Cursor.NORTH_RESIZE || + cursor === Meta.Cursor.NE_RESIZE) { + this._startY = bottomY; + this._lastY = topY; + } + if (cursor === Meta.Cursor.SW_RESIZE || + cursor === Meta.Cursor.SOUTH_RESIZE || + cursor === Meta.Cursor.SE_RESIZE) { + this._startY = topY; + this._lastY = bottomY; + } + } + + if (this._dragButton) { + this._dragGrab = global.stage.grab(this); + this._dragSequence = sequence; + + this.emit('drag-started'); + + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_PROPAGATE; + } + + _onRelease(event, button, sequence) { + if (this._dragButton !== button || + this._dragSequence?.get_slot() !== sequence?.get_slot()) + return Clutter.EVENT_PROPAGATE; + + this.stopDrag(); + + // We might have finished creating a new selection, so we need to + // update the cursor. + this._updateCursor(event.x, event.y); + + return Clutter.EVENT_STOP; + } + + _onMotion(event, sequence) { + if (!this._dragButton) { + this._updateCursor(event.x, event.y); + return Clutter.EVENT_PROPAGATE; + } + + if (sequence?.get_slot() !== this._dragSequence?.get_slot()) + return Clutter.EVENT_PROPAGATE; + + if (this._dragCursor === Meta.Cursor.CROSSHAIR) { + [this._lastX, this._lastY] = [event.x, event.y]; + this._lastX = Math.floor(this._lastX); + this._lastY = Math.floor(this._lastY); + } else { + let dx = Math.round(event.x - this._dragStartX); + let dy = Math.round(event.y - this._dragStartY); + + if (this._dragCursor === Meta.Cursor.MOVE_OR_RESIZE_WINDOW) { + const [,, selectionWidth, selectionHeight] = this.getGeometry(); + + let newStartX = this._startX + dx; + let newStartY = this._startY + dy; + let newLastX = this._lastX + dx; + let newLastY = this._lastY + dy; + + let overshootX = 0; + let overshootY = 0; + + // Keep the size intact if we bumped into the stage edge. + if (newStartX < 0) { + overshootX = 0 - newStartX; + newStartX = 0; + newLastX = newStartX + (selectionWidth - 1); + } else if (newLastX > this.width - 1) { + overshootX = (this.width - 1) - newLastX; + newLastX = this.width - 1; + newStartX = newLastX - (selectionWidth - 1); + } + + if (newStartY < 0) { + overshootY = 0 - newStartY; + newStartY = 0; + newLastY = newStartY + (selectionHeight - 1); + } else if (newLastY > this.height - 1) { + overshootY = (this.height - 1) - newLastY; + newLastY = this.height - 1; + newStartY = newLastY - (selectionHeight - 1); + } + + // Add the overshoot to the delta to create a "rubberbanding" + // behavior of the pointer when dragging. + dx += overshootX; + dy += overshootY; + + this._startX = newStartX; + this._startY = newStartY; + this._lastX = newLastX; + this._lastY = newLastY; + } else { + if (this._dragCursor === Meta.Cursor.WEST_RESIZE || + this._dragCursor === Meta.Cursor.EAST_RESIZE) + dy = 0; + if (this._dragCursor === Meta.Cursor.NORTH_RESIZE || + this._dragCursor === Meta.Cursor.SOUTH_RESIZE) + dx = 0; + + // Make sure last X and Y are clamped between 0 and size - 1, + // while always preserving the cursor dragging position relative + // to the selection rectangle. + this._lastX += dx; + if (this._lastX >= this.width) { + dx -= this._lastX - this.width + 1; + this._lastX = this.width - 1; + } else if (this._lastX < 0) { + dx -= this._lastX; + this._lastX = 0; + } + + this._lastY += dy; + if (this._lastY >= this.height) { + dy -= this._lastY - this.height + 1; + this._lastY = this.height - 1; + } else if (this._lastY < 0) { + dy -= this._lastY; + this._lastY = 0; + } + + // If we drag the handle past a selection side, update which + // handles are which. + if (this._lastX > this._startX) { + if (this._dragCursor === Meta.Cursor.NW_RESIZE) + this._dragCursor = Meta.Cursor.NE_RESIZE; + else if (this._dragCursor === Meta.Cursor.SW_RESIZE) + this._dragCursor = Meta.Cursor.SE_RESIZE; + else if (this._dragCursor === Meta.Cursor.WEST_RESIZE) + this._dragCursor = Meta.Cursor.EAST_RESIZE; + } else { + // eslint-disable-next-line no-lonely-if + if (this._dragCursor === Meta.Cursor.NE_RESIZE) + this._dragCursor = Meta.Cursor.NW_RESIZE; + else if (this._dragCursor === Meta.Cursor.SE_RESIZE) + this._dragCursor = Meta.Cursor.SW_RESIZE; + else if (this._dragCursor === Meta.Cursor.EAST_RESIZE) + this._dragCursor = Meta.Cursor.WEST_RESIZE; + } + + if (this._lastY > this._startY) { + if (this._dragCursor === Meta.Cursor.NW_RESIZE) + this._dragCursor = Meta.Cursor.SW_RESIZE; + else if (this._dragCursor === Meta.Cursor.NE_RESIZE) + this._dragCursor = Meta.Cursor.SE_RESIZE; + else if (this._dragCursor === Meta.Cursor.NORTH_RESIZE) + this._dragCursor = Meta.Cursor.SOUTH_RESIZE; + } else { + // eslint-disable-next-line no-lonely-if + if (this._dragCursor === Meta.Cursor.SW_RESIZE) + this._dragCursor = Meta.Cursor.NW_RESIZE; + else if (this._dragCursor === Meta.Cursor.SE_RESIZE) + this._dragCursor = Meta.Cursor.NE_RESIZE; + else if (this._dragCursor === Meta.Cursor.SOUTH_RESIZE) + this._dragCursor = Meta.Cursor.NORTH_RESIZE; + } + + global.display.set_cursor(this._dragCursor); + } + + this._dragStartX += dx; + this._dragStartY += dy; + } + + this._updateSelectionRect(); + + return Clutter.EVENT_STOP; + } + + vfunc_button_press_event(event) { + if (event.button === Clutter.BUTTON_PRIMARY || + event.button === Clutter.BUTTON_SECONDARY) + return this._onPress(event, event.button, null); + + return Clutter.EVENT_PROPAGATE; + } + + vfunc_button_release_event(event) { + if (event.button === Clutter.BUTTON_PRIMARY || + event.button === Clutter.BUTTON_SECONDARY) + return this._onRelease(event, event.button, null); + + return Clutter.EVENT_PROPAGATE; + } + + vfunc_motion_event(event) { + return this._onMotion(event, null); + } + + vfunc_touch_event(event) { + if (event.type === Clutter.EventType.TOUCH_BEGIN) + return this._onPress(event, 'touch', event.sequence); + else if (event.type === Clutter.EventType.TOUCH_END) + return this._onRelease(event, 'touch', event.sequence); + else if (event.type === Clutter.EventType.TOUCH_UPDATE) + return this._onMotion(event, event.sequence); + + return Clutter.EVENT_PROPAGATE; + } + + vfunc_leave_event(event) { + // If we're dragging and go over the panel we still get a leave event + // for some reason, even though we have a grab. We don't want to switch + // the cursor when we're dragging. + if (!this._dragButton) + global.display.set_cursor(Meta.Cursor.DEFAULT); + + return super.vfunc_leave_event(event); + } +}); + +var UIWindowSelectorLayout = GObject.registerClass( +class UIWindowSelectorLayout extends Workspace.WorkspaceLayout { + _init(monitorIndex) { + super._init(null, monitorIndex, null); + } + + vfunc_set_container(container) { + this._container = container; + this._syncWorkareaTracking(); + } + + vfunc_allocate(container, box) { + const containerBox = container.allocation; + const containerAllocationChanged = + this._lastBox === null || !this._lastBox.equal(containerBox); + this._lastBox = containerBox.copy(); + + let layoutChanged = false; + if (this._layout === null) { + this._layout = this._createBestLayout(this._workarea); + layoutChanged = true; + } + + if (layoutChanged || containerAllocationChanged) + this._windowSlots = this._getWindowSlots(box.copy()); + + const childBox = new Clutter.ActorBox(); + + const nSlots = this._windowSlots.length; + for (let i = 0; i < nSlots; i++) { + let [x, y, width, height, child] = this._windowSlots[i]; + + childBox.set_origin(x, y); + childBox.set_size(width, height); + + child.allocate(childBox); + } + } + + addWindow(window) { + if (this._sortedWindows.includes(window)) + return; + + this._sortedWindows.push(window); + + this._container.add_child(window); + + this._layout = null; + this.layout_changed(); + } + + reset() { + for (const window of this._sortedWindows) + window.destroy(); + + this._sortedWindows = []; + this._windowSlots = []; + this._layout = null; + } + + get windows() { + return this._sortedWindows; + } +}); + +var UIWindowSelectorWindow = GObject.registerClass( +class UIWindowSelectorWindow extends St.Button { + _init(actor, params) { + super._init(params); + + const window = actor.metaWindow; + this._boundingBox = window.get_frame_rect(); + this._bufferRect = window.get_buffer_rect(); + this._bufferScale = actor.get_resource_scale(); + this._actor = new Clutter.Actor({ + content: actor.paint_to_content(null), + }); + this.add_child(this._actor); + + this._border = new St.Bin({ style_class: 'screenshot-ui-window-selector-window-border' }); + this._border.connect('style-changed', () => { + this._borderSize = + this._border.get_theme_node().get_border_width(St.Side.TOP); + }); + this.add_child(this._border); + + this._border.child = new St.Icon({ + icon_name: 'object-select-symbolic', + style_class: 'screenshot-ui-window-selector-check', + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + + this._cursor = null; + this._cursorPoint = { x: 0, y: 0 }; + this._shouldShowCursor = window.has_pointer && window.has_pointer(); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + get boundingBox() { + return this._boundingBox; + } + + get windowCenter() { + const boundingBox = this.boundingBox; + return { + x: boundingBox.x + boundingBox.width / 2, + y: boundingBox.y + boundingBox.height / 2, + }; + } + + chromeHeights() { + return [0, 0]; + } + + chromeWidths() { + return [0, 0]; + } + + overlapHeights() { + return [0, 0]; + } + + get cursorPoint() { + return { + x: this._cursorPoint.x + this._boundingBox.x - this._bufferRect.x, + y: this._cursorPoint.y + this._boundingBox.y - this._bufferRect.y, + }; + } + + get bufferScale() { + return this._bufferScale; + } + + get windowContent() { + return this._actor.content; + } + + _onDestroy() { + this.remove_child(this._actor); + this._actor.destroy(); + this._actor = null; + this.remove_child(this._border); + this._border.destroy(); + this._border = null; + + if (this._cursor) { + this.remove_child(this._cursor); + this._cursor.destroy(); + this._cursor = null; + } + } + + vfunc_allocate(box) { + this.set_allocation(box); + + // Border goes around the window. + const borderBox = box.copy(); + borderBox.set_origin(0, 0); + borderBox.x1 -= this._borderSize; + borderBox.y1 -= this._borderSize; + borderBox.x2 += this._borderSize; + borderBox.y2 += this._borderSize; + this._border.allocate(borderBox); + + // box should contain this._boundingBox worth of window. Compute + // origin and size for the actor box to satisfy that. + const xScale = box.get_width() / this._boundingBox.width; + const yScale = box.get_height() / this._boundingBox.height; + + const [, windowW, windowH] = this._actor.content.get_preferred_size(); + + const actorBox = new Clutter.ActorBox(); + actorBox.set_origin( + (this._bufferRect.x - this._boundingBox.x) * xScale, + (this._bufferRect.y - this._boundingBox.y) * yScale + ); + actorBox.set_size( + windowW * xScale / this._bufferScale, + windowH * yScale / this._bufferScale + ); + this._actor.allocate(actorBox); + + // Allocate the cursor if we have one. + if (!this._cursor) + return; + + let [, , w, h] = this._cursor.get_preferred_size(); + w *= this._cursorScale; + h *= this._cursorScale; + + const cursorBox = new Clutter.ActorBox({ + x1: this._cursorPoint.x, + y1: this._cursorPoint.y, + x2: this._cursorPoint.x + w, + y2: this._cursorPoint.y + h, + }); + cursorBox.x1 *= xScale; + cursorBox.x2 *= xScale; + cursorBox.y1 *= yScale; + cursorBox.y2 *= yScale; + + this._cursor.allocate(cursorBox); + } + + addCursorTexture(content, point, scale) { + if (!this._shouldShowCursor) + return; + + // Add the cursor. + this._cursor = new St.Widget({ + content, + request_mode: Clutter.RequestMode.CONTENT_SIZE, + }); + + this._cursorPoint = { + x: point.x - this._boundingBox.x, + y: point.y - this._boundingBox.y, + }; + this._cursorScale = scale; + + this.insert_child_below(this._cursor, this._border); + } + + getCursorTexture() { + return this._cursor?.content; + } + + setCursorVisible(visible) { + if (!this._cursor) + return; + + this._cursor.visible = visible; + } +}); + +var UIWindowSelector = GObject.registerClass( +class UIWindowSelector extends St.Widget { + _init(monitorIndex, params) { + super._init(params); + super.layout_manager = new Clutter.BinLayout(); + + this._monitorIndex = monitorIndex; + + this._layoutManager = new UIWindowSelectorLayout(monitorIndex); + + // Window screenshots + this._container = new St.Widget({ + style_class: 'screenshot-ui-window-selector-window-container', + x_expand: true, + y_expand: true, + }); + this._container.layout_manager = this._layoutManager; + this.add_child(this._container); + } + + capture() { + for (const actor of global.get_window_actors()) { + let window = actor.metaWindow; + let workspaceManager = global.workspace_manager; + let activeWorkspace = workspaceManager.get_active_workspace(); + if (window.is_override_redirect() || + !window.located_on_workspace(activeWorkspace) || + window.get_monitor() !== this._monitorIndex) + continue; + + const widget = new UIWindowSelectorWindow( + actor, + { + style_class: 'screenshot-ui-window-selector-window', + reactive: true, + can_focus: true, + toggle_mode: true, + } + ); + + widget.connect('key-focus-in', win => { + Main.screenshotUI.grab_key_focus(); + win.checked = true; + }); + + if (window.has_focus()) { + widget.checked = true; + widget.toggle_mode = false; + } + + this._layoutManager.addWindow(widget); + } + } + + reset() { + this._layoutManager.reset(); + } + + windows() { + return this._layoutManager.windows; + } +}); + +const UIMode = { + SCREENSHOT: 0, + SCREENCAST: 1, +}; + +var ScreenshotUI = GObject.registerClass({ + Properties: { + 'screencast-in-progress': GObject.ParamSpec.boolean( + 'screencast-in-progress', + 'screencast-in-progress', + 'screencast-in-progress', + GObject.ParamFlags.READABLE, + false), + }, +}, class ScreenshotUI extends St.Widget { + _init() { + super._init({ + name: 'screenshot-ui', + constraints: new Clutter.BindConstraint({ + source: global.stage, + coordinate: Clutter.BindCoordinate.ALL, + }), + layout_manager: new Clutter.BinLayout(), + opacity: 0, + visible: false, + reactive: true, + }); + + this._screencastInProgress = false; + this._screencastSupported = false; + + this._screencastProxy = new ScreencastProxy( + Gio.DBus.session, + 'org.gnome.Shell.Screencast', + '/org/gnome/Shell/Screencast', + (object, error) => { + if (error !== null) { + log('Error connecting to the screencast service'); + return; + } + + this._screencastSupported = this._screencastProxy.ScreencastSupported; + this._castButton.visible = this._screencastSupported; + }); + + this._lockdownSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.lockdown' }); + + // The full-screen screenshot has a separate container so that we can + // show it without the screenshot UI fade-in for a nicer animation. + this._stageScreenshotContainer = new St.Widget({ visible: false }); + this._stageScreenshotContainer.add_constraint(new Clutter.BindConstraint({ + source: global.stage, + coordinate: Clutter.BindCoordinate.ALL, + })); + Main.layoutManager.screenshotUIGroup.add_child( + this._stageScreenshotContainer); + + this._screencastAreaIndicator = new UIAreaIndicator({ + style_class: 'screenshot-ui-screencast-area-indicator', + visible: false, + }); + this._screencastAreaIndicator.add_constraint(new Clutter.BindConstraint({ + source: global.stage, + coordinate: Clutter.BindCoordinate.ALL, + })); + this.bind_property( + 'screencast-in-progress', + this._screencastAreaIndicator, + 'visible', + GObject.BindingFlags.DEFAULT); + // Add it directly to the stage so that it's above popup menus. + global.stage.add_child(this._screencastAreaIndicator); + Shell.util_set_hidden_from_pick(this._screencastAreaIndicator, true); + + Main.layoutManager.screenshotUIGroup.add_child(this); + + this._stageScreenshot = new St.Widget({ style_class: 'screenshot-ui-screen-screenshot' }); + this._stageScreenshot.add_constraint(new Clutter.BindConstraint({ + source: global.stage, + coordinate: Clutter.BindCoordinate.ALL, + })); + this._stageScreenshotContainer.add_child(this._stageScreenshot); + + this._cursor = new St.Widget(); + this._stageScreenshotContainer.add_child(this._cursor); + + this._openingCoroutineInProgress = false; + this._grabHelper = new GrabHelper.GrabHelper(this, { + actionMode: Shell.ActionMode.POPUP, + }); + + this._areaSelector = new UIAreaSelector({ + style_class: 'screenshot-ui-area-selector', + x_expand: true, + y_expand: true, + reactive: true, + }); + this.add_child(this._areaSelector); + + this._primaryMonitorBin = new St.Widget({ layout_manager: new Clutter.BinLayout() }); + this._primaryMonitorBin.add_constraint( + new Layout.MonitorConstraint({ 'primary': true })); + this.add_child(this._primaryMonitorBin); + + this._panel = new St.BoxLayout({ + style_class: 'screenshot-ui-panel', + y_align: Clutter.ActorAlign.END, + y_expand: true, + vertical: true, + offscreen_redirect: Clutter.OffscreenRedirect.AUTOMATIC_FOR_OPACITY, + }); + this._primaryMonitorBin.add_child(this._panel); + + this._closeButton = new St.Button({ + style_class: 'screenshot-ui-close-button', + icon_name: 'preview-close-symbolic', + }); + this._closeButton.add_constraint(new Clutter.BindConstraint({ + source: this._panel, + coordinate: Clutter.BindCoordinate.POSITION, + })); + this._closeButton.add_constraint(new Clutter.AlignConstraint({ + source: this._panel, + align_axis: Clutter.AlignAxis.Y_AXIS, + pivot_point: new Graphene.Point({ x: -1, y: 0.5 }), + factor: 0, + })); + this._closeButtonXAlignConstraint = new Clutter.AlignConstraint({ + source: this._panel, + align_axis: Clutter.AlignAxis.X_AXIS, + pivot_point: new Graphene.Point({ x: 0.5, y: -1 }), + }); + this._closeButton.add_constraint(this._closeButtonXAlignConstraint); + this._closeButton.connect('clicked', () => this.close()); + this._primaryMonitorBin.add_child(this._closeButton); + + this._areaSelector.connect('drag-started', () => { + this._panel.ease({ + opacity: 100, + duration: 200, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + this._closeButton.ease({ + opacity: 100, + duration: 200, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + }); + this._areaSelector.connect('drag-ended', () => { + this._panel.ease({ + opacity: 255, + duration: 200, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + this._closeButton.ease({ + opacity: 255, + duration: 200, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + }); + + this._typeButtonContainer = new St.Widget({ + style_class: 'screenshot-ui-type-button-container', + layout_manager: new Clutter.BoxLayout({ + spacing: 12, + homogeneous: true, + }), + }); + this._panel.add_child(this._typeButtonContainer); + + this._selectionButton = new IconLabelButton('screenshot-ui-area-symbolic', _('Selection'), { + style_class: 'screenshot-ui-type-button', + checked: true, + x_expand: true, + }); + this._selectionButton.connect('notify::checked', + this._onSelectionButtonToggled.bind(this)); + this._typeButtonContainer.add_child(this._selectionButton); + + this.add_child(new Tooltip(this._selectionButton, { + text: _('Area Selection'), + style_class: 'screenshot-ui-tooltip', + visible: false, + })); + + this._screenButton = new IconLabelButton('screenshot-ui-display-symbolic', _('Screen'), { + style_class: 'screenshot-ui-type-button', + toggle_mode: true, + x_expand: true, + }); + this._screenButton.connect('notify::checked', + this._onScreenButtonToggled.bind(this)); + this._typeButtonContainer.add_child(this._screenButton); + + this.add_child(new Tooltip(this._screenButton, { + text: _('Screen Selection'), + style_class: 'screenshot-ui-tooltip', + visible: false, + })); + + this._windowButton = new IconLabelButton('screenshot-ui-window-symbolic', _('Window'), { + style_class: 'screenshot-ui-type-button', + toggle_mode: true, + x_expand: true, + }); + this._windowButton.connect('notify::checked', + this._onWindowButtonToggled.bind(this)); + this._typeButtonContainer.add_child(this._windowButton); + + this.add_child(new Tooltip(this._windowButton, { + text: _('Window Selection'), + style_class: 'screenshot-ui-tooltip', + visible: false, + })); + + this._bottomRowContainer = new St.Widget({ layout_manager: new Clutter.BinLayout() }); + this._panel.add_child(this._bottomRowContainer); + + this._shotCastContainer = new St.BoxLayout({ + style_class: 'screenshot-ui-shot-cast-container', + x_align: Clutter.ActorAlign.START, + x_expand: true, + }); + this._bottomRowContainer.add_child(this._shotCastContainer); + + this._shotButton = new St.Button({ + style_class: 'screenshot-ui-shot-cast-button', + icon_name: 'camera-photo-symbolic', + checked: true, + }); + this._shotButton.connect('notify::checked', + this._onShotButtonToggled.bind(this)); + this._shotCastContainer.add_child(this._shotButton); + + this._castButton = new St.Button({ + style_class: 'screenshot-ui-shot-cast-button', + icon_name: 'camera-web-symbolic', + toggle_mode: true, + visible: false, + }); + this._castButton.connect('notify::checked', + this._onCastButtonToggled.bind(this)); + this._shotCastContainer.add_child(this._castButton); + + this._shotButton.bind_property('checked', this._castButton, 'checked', + GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.INVERT_BOOLEAN); + + this._shotCastTooltip = new Tooltip(this._shotCastContainer, { + text: _('Screenshot / Screencast'), + style_class: 'screenshot-ui-tooltip', + visible: false, + }); + const shotCastCallback = () => { + if (this._shotButton.hover || this._castButton.hover) + this._shotCastTooltip.open(); + else + this._shotCastTooltip.close(); + }; + this._shotButton.connect('notify::hover', shotCastCallback); + this._castButton.connect('notify::hover', shotCastCallback); + this.add_child(this._shotCastTooltip); + + this._captureButton = new St.Button({ style_class: 'screenshot-ui-capture-button' }); + this._captureButton.set_child(new St.Widget({ + style_class: 'screenshot-ui-capture-button-circle', + })); + this._captureButton.connect('clicked', + this._onCaptureButtonClicked.bind(this)); + this._bottomRowContainer.add_child(this._captureButton); + + this._showPointerButtonContainer = new St.BoxLayout({ + x_align: Clutter.ActorAlign.END, + x_expand: true, + }); + this._bottomRowContainer.add_child(this._showPointerButtonContainer); + + this._showPointerButton = new St.Button({ + style_class: 'screenshot-ui-show-pointer-button', + icon_name: 'screenshot-ui-show-pointer-symbolic', + toggle_mode: true, + }); + this._showPointerButtonContainer.add_child(this._showPointerButton); + + this.add_child(new Tooltip(this._showPointerButton, { + text: _('Show Pointer'), + style_class: 'screenshot-ui-tooltip', + visible: false, + })); + + this._showPointerButton.connect('notify::checked', () => { + const state = this._showPointerButton.checked; + this._cursor.visible = state; + + const windows = + this._windowSelectors.flatMap(selector => selector.windows()); + for (const window of windows) + window.setCursorVisible(state); + }); + this._cursor.visible = false; + + this._monitorBins = []; + this._windowSelectors = []; + this._rebuildMonitorBins(); + + Main.layoutManager.connect('monitors-changed', () => { + // Nope, not dealing with monitor changes. + this.close(true); + this._rebuildMonitorBins(); + }); + + const uiModes = + Shell.ActionMode.ALL & ~Shell.ActionMode.LOGIN_SCREEN; + const restrictedModes = + uiModes & + ~(Shell.ActionMode.LOCK_SCREEN | Shell.ActionMode.UNLOCK_SCREEN); + + Main.wm.addKeybinding( + 'show-screenshot-ui', + new Gio.Settings({ schema_id: 'org.gnome.shell.keybindings' }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + uiModes, + showScreenshotUI + ); + + Main.wm.addKeybinding( + 'show-screen-recording-ui', + new Gio.Settings({ schema_id: 'org.gnome.shell.keybindings' }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + restrictedModes, + showScreenRecordingUI + ); + + Main.wm.addKeybinding( + 'screenshot-window', + new Gio.Settings({ schema_id: 'org.gnome.shell.keybindings' }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT | Meta.KeyBindingFlags.PER_WINDOW, + restrictedModes, + async (_display, window, _binding) => { + try { + const actor = window.get_compositor_private(); + const content = actor.paint_to_content(null); + const texture = content.get_texture(); + + await captureScreenshot(texture, null, 1, null); + } catch (e) { + logError(e, 'Error capturing screenshot'); + } + } + ); + + Main.wm.addKeybinding( + 'screenshot', + new Gio.Settings({ schema_id: 'org.gnome.shell.keybindings' }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + uiModes, + async () => { + try { + const shooter = new Shell.Screenshot(); + const [content] = await shooter.screenshot_stage_to_content(); + const texture = content.get_texture(); + + await captureScreenshot(texture, null, 1, null); + } catch (e) { + logError(e, 'Error capturing screenshot'); + } + } + ); + + Main.sessionMode.connect('updated', + () => this._sessionUpdated()); + this._sessionUpdated(); + } + + _sessionUpdated() { + this.close(true); + this._castButton.reactive = Main.sessionMode.allowScreencast; + } + + _syncWindowButtonSensitivity() { + const windows = + this._windowSelectors.flatMap(selector => selector.windows()); + + this._windowButton.reactive = + Main.sessionMode.hasWindows && + windows.length > 0 && + !this._castButton.checked; + } + + _refreshButtonLayout() { + const buttonLayout = Meta.prefs_get_button_layout(); + + this._closeButton.remove_style_class_name('left'); + this._closeButton.remove_style_class_name('right'); + + if (buttonLayout.left_buttons.includes(Meta.ButtonFunction.CLOSE)) { + this._closeButton.add_style_class_name('left'); + this._closeButtonXAlignConstraint.factor = 0; + } else { + this._closeButton.add_style_class_name('right'); + this._closeButtonXAlignConstraint.factor = 1; + } + } + + _rebuildMonitorBins() { + for (const bin of this._monitorBins) + bin.destroy(); + + this._monitorBins = []; + this._windowSelectors = []; + this._screenSelectors = []; + + for (let i = 0; i < Main.layoutManager.monitors.length; i++) { + const bin = new St.Widget({ + layout_manager: new Clutter.BinLayout(), + }); + bin.add_constraint(new Layout.MonitorConstraint({ 'index': i })); + this.insert_child_below(bin, this._primaryMonitorBin); + this._monitorBins.push(bin); + + const windowSelector = new UIWindowSelector(i, { + style_class: 'screenshot-ui-window-selector', + x_expand: true, + y_expand: true, + visible: this._windowButton.checked, + }); + if (i === Main.layoutManager.primaryIndex) + windowSelector.add_style_pseudo_class('primary-monitor'); + + bin.add_child(windowSelector); + this._windowSelectors.push(windowSelector); + + const screenSelector = new St.Button({ + style_class: 'screenshot-ui-screen-selector', + x_expand: true, + y_expand: true, + visible: this._screenButton.checked, + reactive: true, + can_focus: true, + toggle_mode: true, + }); + screenSelector.connect('key-focus-in', () => { + this.grab_key_focus(); + screenSelector.checked = true; + }); + bin.add_child(screenSelector); + this._screenSelectors.push(screenSelector); + + screenSelector.connect('notify::checked', () => { + if (!screenSelector.checked) + return; + + screenSelector.toggle_mode = false; + + for (const otherSelector of this._screenSelectors) { + if (screenSelector === otherSelector) + continue; + + otherSelector.toggle_mode = true; + otherSelector.checked = false; + } + }); + } + + if (Main.layoutManager.primaryIndex !== -1) + this._screenSelectors[Main.layoutManager.primaryIndex].checked = true; + } + + async open(mode = UIMode.SCREENSHOT) { + if (this._openingCoroutineInProgress) + return; + + if (this._screencastInProgress) + return; + + if (mode === UIMode.SCREENCAST && !this._screencastSupported) + return; + + this._castButton.checked = mode === UIMode.SCREENCAST; + + if (!this.visible) { + // Screenshot UI is opening from completely closed state + // (rather than opening back from in process of closing). + for (const selector of this._windowSelectors) + selector.capture(); + + const windows = + this._windowSelectors.flatMap(selector => selector.windows()); + for (const window of windows) { + window.connect('notify::checked', () => { + if (!window.checked) + return; + + window.toggle_mode = false; + + for (const otherWindow of windows) { + if (window === otherWindow) + continue; + + otherWindow.toggle_mode = true; + otherWindow.checked = false; + } + }); + } + + this._syncWindowButtonSensitivity(); + if (!this._windowButton.reactive) + this._selectionButton.checked = true; + + this._shooter = new Shell.Screenshot(); + + this._openingCoroutineInProgress = true; + try { + const [content, scale, cursorContent, cursorPoint, cursorScale] = + await this._shooter.screenshot_stage_to_content(); + this._stageScreenshot.set_content(content); + this._scale = scale; + + if (cursorContent !== null) { + this._cursor.set_content(cursorContent); + this._cursor.set_position(cursorPoint.x, cursorPoint.y); + + let [, w, h] = cursorContent.get_preferred_size(); + w *= cursorScale; + h *= cursorScale; + this._cursor.set_size(w, h); + + this._cursorScale = cursorScale; + + for (const window of windows) { + window.addCursorTexture(cursorContent, cursorPoint, cursorScale); + window.setCursorVisible(this._showPointerButton.checked); + } + } + + this._stageScreenshotContainer.show(); + } catch (e) { + log(`Error capturing screenshot: ${e.message}`); + } + this._openingCoroutineInProgress = false; + } + + // Get rid of any popup menus. + // We already have them captured on the screenshot anyway. + // + // This needs to happen before the grab below as closing menus will + // pop their grabs. + Main.layoutManager.emit('system-modal-opened'); + + const { screenshotUIGroup } = Main.layoutManager; + screenshotUIGroup.get_parent().set_child_above_sibling( + screenshotUIGroup, null); + + const grabResult = this._grabHelper.grab({ + actor: this, + onUngrab: () => this.close(), + }); + if (!grabResult) { + this.close(true); + return; + } + + this._refreshButtonLayout(); + + this.remove_all_transitions(); + this.visible = true; + this.ease({ + opacity: 255, + duration: 200, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._stageScreenshotContainer.get_parent().remove_child( + this._stageScreenshotContainer); + this.insert_child_at_index(this._stageScreenshotContainer, 0); + }, + }); + } + + _finishClosing() { + this.hide(); + + this._shooter = null; + + // Switch back to screenshot mode. + this._shotButton.checked = true; + + this._stageScreenshotContainer.get_parent().remove_child( + this._stageScreenshotContainer); + Main.layoutManager.screenshotUIGroup.insert_child_at_index( + this._stageScreenshotContainer, 0); + this._stageScreenshotContainer.hide(); + + this._stageScreenshot.set_content(null); + this._cursor.set_content(null); + + this._areaSelector.reset(); + for (const selector of this._windowSelectors) + selector.reset(); + } + + close(instantly = false) { + this._grabHelper.ungrab(); + + if (instantly) { + this._finishClosing(); + return; + } + + this.remove_all_transitions(); + this.ease({ + opacity: 0, + duration: 200, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: this._finishClosing.bind(this), + }); + } + + _onSelectionButtonToggled() { + if (this._selectionButton.checked) { + this._selectionButton.toggle_mode = false; + this._windowButton.checked = false; + this._screenButton.checked = false; + + this._areaSelector.show(); + this._areaSelector.remove_all_transitions(); + this._areaSelector.reactive = true; + this._areaSelector.ease({ + opacity: 255, + duration: 200, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } else { + this._selectionButton.toggle_mode = true; + + this._areaSelector.stopDrag(); + global.display.set_cursor(Meta.Cursor.DEFAULT); + + this._areaSelector.remove_all_transitions(); + this._areaSelector.reactive = false; + this._areaSelector.ease({ + opacity: 0, + duration: 200, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._areaSelector.hide(), + }); + } + } + + _onScreenButtonToggled() { + if (this._screenButton.checked) { + this._screenButton.toggle_mode = false; + this._selectionButton.checked = false; + this._windowButton.checked = false; + + for (const selector of this._screenSelectors) { + selector.show(); + selector.remove_all_transitions(); + selector.ease({ + opacity: 255, + duration: 200, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + } else { + this._screenButton.toggle_mode = true; + + for (const selector of this._screenSelectors) { + selector.remove_all_transitions(); + selector.ease({ + opacity: 0, + duration: 200, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => selector.hide(), + }); + } + } + } + + _onWindowButtonToggled() { + if (this._windowButton.checked) { + this._windowButton.toggle_mode = false; + this._selectionButton.checked = false; + this._screenButton.checked = false; + + for (const selector of this._windowSelectors) { + selector.show(); + selector.remove_all_transitions(); + selector.ease({ + opacity: 255, + duration: 200, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + } else { + this._windowButton.toggle_mode = true; + + for (const selector of this._windowSelectors) { + selector.remove_all_transitions(); + selector.ease({ + opacity: 0, + duration: 200, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => selector.hide(), + }); + } + } + } + + _onShotButtonToggled() { + if (this._shotButton.checked) { + this._shotButton.toggle_mode = false; + + this._stageScreenshotContainer.show(); + this._stageScreenshotContainer.remove_all_transitions(); + this._stageScreenshotContainer.ease({ + opacity: 255, + duration: 200, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } else { + this._shotButton.toggle_mode = true; + } + } + + _onCastButtonToggled() { + if (this._castButton.checked) { + this._castButton.toggle_mode = false; + + this._captureButton.add_style_pseudo_class('cast'); + + this._stageScreenshotContainer.remove_all_transitions(); + this._stageScreenshotContainer.ease({ + opacity: 0, + duration: 200, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._stageScreenshotContainer.hide(), + }); + + // Screen recording doesn't support window selection yet. + if (this._windowButton.checked) + this._selectionButton.checked = true; + + this._windowButton.reactive = false; + } else { + this._castButton.toggle_mode = true; + + this._captureButton.remove_style_pseudo_class('cast'); + + this._syncWindowButtonSensitivity(); + } + } + + _getSelectedGeometry(rescale) { + let x, y, w, h; + + if (this._selectionButton.checked) { + [x, y, w, h] = this._areaSelector.getGeometry(); + } else if (this._screenButton.checked) { + const index = + this._screenSelectors.findIndex(screen => screen.checked); + const monitor = Main.layoutManager.monitors[index]; + + x = monitor.x; + y = monitor.y; + w = monitor.width; + h = monitor.height; + } + + if (rescale) { + x *= this._scale; + y *= this._scale; + w *= this._scale; + h *= this._scale; + } + + return [x, y, w, h]; + } + + _onCaptureButtonClicked() { + if (this._shotButton.checked) { + this._saveScreenshot(); + this.close(); + } else { + // Screencast closes the UI on its own. + this._startScreencast(); + } + } + + _saveScreenshot() { + if (this._selectionButton.checked || this._screenButton.checked) { + const content = this._stageScreenshot.get_content(); + if (!content) + return; // Failed to capture the screenshot for some reason. + + const texture = content.get_texture(); + const geometry = this._getSelectedGeometry(true); + + let cursorTexture = this._cursor.content?.get_texture(); + if (!this._cursor.visible) + cursorTexture = null; + + captureScreenshot( + texture, geometry, this._scale, + { + texture: cursorTexture ?? null, + x: this._cursor.x * this._scale, + y: this._cursor.y * this._scale, + scale: this._cursorScale, + } + ).catch(e => logError(e, 'Error capturing screenshot')); + } else if (this._windowButton.checked) { + const window = + this._windowSelectors.flatMap(selector => selector.windows()) + .find(win => win.checked); + if (!window) + return; + + const content = window.windowContent; + if (!content) + return; + + const texture = content.get_texture(); + + let cursorTexture = window.getCursorTexture()?.get_texture(); + if (!this._cursor.visible) + cursorTexture = null; + + captureScreenshot( + texture, + null, + window.bufferScale, + { + texture: cursorTexture ?? null, + x: window.cursorPoint.x * window.bufferScale, + y: window.cursorPoint.y * window.bufferScale, + scale: this._cursorScale, + } + ).catch(e => logError(e, 'Error capturing screenshot')); + } + } + + async _startScreencast() { + if (this._windowButton.checked) + return; // TODO + + const [x, y, w, h] = this._getSelectedGeometry(false); + const drawCursor = this._cursor.visible; + + // Set up the screencast indicator rect. + if (this._selectionButton.checked) { + this._screencastAreaIndicator.setSelectionRect( + ...this._areaSelector.getGeometry()); + } else if (this._screenButton.checked) { + const index = + this._screenSelectors.findIndex(screen => screen.checked); + const monitor = Main.layoutManager.monitors[index]; + + this._screencastAreaIndicator.setSelectionRect( + monitor.x, monitor.y, monitor.width, monitor.height); + } + + // Close instantly so the fade-out doesn't get recorded. + this.close(true); + + // This is a bit awkward because creating a proxy synchronously hangs Shell. + let method = + this._screencastProxy.ScreencastAsync.bind(this._screencastProxy); + if (w !== -1) { + method = this._screencastProxy.ScreencastAreaAsync.bind( + this._screencastProxy, x, y, w, h); + } + + // Set this before calling the method as the screen recording indicator + // will check it before the success callback fires. + this._setScreencastInProgress(true); + + try { + const [success, path] = await method( + GLib.build_filenamev([ + /* Translators: this is the folder where recorded + screencasts are stored. */ + _('Screencasts'), + /* Translators: this is a filename used for screencast + * recording, where "%d" and "%t" date and time, e.g. + * "Screencast from 07-17-2013 10:00:46 PM.webm" */ + /* xgettext:no-c-format */ + _('Screencast from %d %t.webm'), + ]), + {'draw-cursor': new GLib.Variant('b', drawCursor)}); + if (!success) + throw new Error(); + this._screencastPath = path; + } catch (error) { + this._setScreencastInProgress(false); + const {message} = error; + if (message) + log(`Error starting screencast: ${message}`); + else + log('Error starting screencast'); + } + } + + async stopScreencast() { + if (!this._screencastInProgress) + return; + + // Set this before calling the method as the screen recording indicator + // will check it before the success callback fires. + this._setScreencastInProgress(false); + + try { + const [success] = await this._screencastProxy.StopScreencastAsync(); + if (!success) + throw new Error(); + } catch (error) { + const {message} = error; + if (message) + log(`Error stopping screencast: ${message}`); + else + log('Error stopping screencast'); + return; + } + + // Show a notification. + const file = Gio.file_new_for_path(this._screencastPath); + + const source = new MessageTray.Source( + // Translators: notification source name. + _('Screenshot'), + 'screencast-recorded-symbolic' + ); + const notification = new MessageTray.Notification( + source, + // Translators: notification title. + _('Screencast recorded'), + // Translators: notification body when a screencast was recorded. + _('Click here to view the video.') + ); + // Translators: button on the screencast notification. + notification.addAction(_('Show in Files'), () => { + const app = + Gio.app_info_get_default_for_type('inode/directory', false); + + if (app === null) { + // It may be null e.g. in a toolbox without nautilus. + log('Error showing in files: no default app set for inode/directory'); + return; + } + + app.launch([file], global.create_app_launch_context(0, -1)); + }); + notification.connect('activated', () => { + try { + Gio.app_info_launch_default_for_uri( + file.get_uri(), global.create_app_launch_context(0, -1)); + } catch (err) { + logError(err, 'Error opening screencast'); + } + }); + notification.setTransient(true); + + Main.messageTray.add(source); + source.showNotification(notification); + } + + get screencast_in_progress() { + if (!('_screencastInProgress' in this)) + return false; + + return this._screencastInProgress; + } + + _setScreencastInProgress(inProgress) { + if (this._screencastInProgress === inProgress) + return; + + this._screencastInProgress = inProgress; + this.notify('screencast-in-progress'); + } + + vfunc_key_press_event(event) { + const symbol = event.keyval; + if (symbol === Clutter.KEY_Return || symbol === Clutter.KEY_space || + ((event.modifier_state & Clutter.ModifierType.CONTROL_MASK) && + (symbol === Clutter.KEY_c || symbol === Clutter.KEY_C))) { + this._onCaptureButtonClicked(); + return Clutter.EVENT_STOP; + } + + if (symbol === Clutter.KEY_s || symbol === Clutter.KEY_S) { + this._selectionButton.checked = true; + return Clutter.EVENT_STOP; + } + + if (symbol === Clutter.KEY_c || symbol === Clutter.KEY_C) { + this._screenButton.checked = true; + return Clutter.EVENT_STOP; + } + + if (this._windowButton.reactive && + (symbol === Clutter.KEY_w || symbol === Clutter.KEY_W)) { + this._windowButton.checked = true; + return Clutter.EVENT_STOP; + } + + if (symbol === Clutter.KEY_p || symbol === Clutter.KEY_P) { + this._showPointerButton.checked = !this._showPointerButton.checked; + return Clutter.EVENT_STOP; + } + + if (this._castButton.reactive && + (symbol === Clutter.KEY_v || symbol === Clutter.KEY_V)) { + this._castButton.checked = !this._castButton.checked; + return Clutter.EVENT_STOP; + } + + if (symbol === Clutter.KEY_Left || symbol === Clutter.KEY_Right || + symbol === Clutter.KEY_Up || symbol === Clutter.KEY_Down) { + let direction; + if (symbol === Clutter.KEY_Left) + direction = St.DirectionType.LEFT; + else if (symbol === Clutter.KEY_Right) + direction = St.DirectionType.RIGHT; + else if (symbol === Clutter.KEY_Up) + direction = St.DirectionType.UP; + else if (symbol === Clutter.KEY_Down) + direction = St.DirectionType.DOWN; + + if (this._windowButton.checked) { + const window = + this._windowSelectors.flatMap(selector => selector.windows()) + .find(win => win.checked) ?? null; + this.navigate_focus(window, direction, false); + } else if (this._screenButton.checked) { + const screen = + this._screenSelectors.find(selector => selector.checked) ?? null; + this.navigate_focus(screen, direction, false); + } + + return Clutter.EVENT_STOP; + } + + return super.vfunc_key_press_event(event); + } +}); + +/** + * Stores a PNG-encoded screenshot into the clipboard and a file, and shows a + * notification. + * + * @param {GLib.Bytes} bytes - The PNG-encoded screenshot. + * @param {GdkPixbuf.Pixbuf} pixbuf - The Pixbuf with the screenshot. + */ +function _storeScreenshot(bytes, pixbuf) { + // Store to the clipboard first in case storing to file fails. + const clipboard = St.Clipboard.get_default(); + clipboard.set_content(St.ClipboardType.CLIPBOARD, 'image/png', bytes); + + const time = GLib.DateTime.new_now_local(); + + // This will be set in the first save to disk branch and then accessed + // in the second save to disk branch, so we need to declare it outside. + let file; + + // The function is declared here rather than inside the condition to + // satisfy eslint. + + /** + * Returns a filename suffix with an increasingly large index. + * + * @returns {Generator<string|*, void, *>} suffix string + */ + function* suffixes() { + yield ''; + + for (let i = 1; ; i++) + yield `-${i}`; + } + + const lockdownSettings = + new Gio.Settings({ schema_id: 'org.gnome.desktop.lockdown' }); + const disableSaveToDisk = + lockdownSettings.get_boolean('disable-save-to-disk'); + + if (!disableSaveToDisk) { + const dir = Gio.File.new_for_path(GLib.build_filenamev([ + GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_PICTURES) || GLib.get_home_dir(), + // Translators: name of the folder under ~/Pictures for screenshots. + _('Screenshots'), + ])); + + try { + dir.make_directory_with_parents(null); + } catch (e) { + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS)) + throw e; + } + + const timestamp = time.format('%Y-%m-%d %H-%M-%S'); + // Translators: this is the name of the file that the screenshot is + // saved to. The placeholder is a timestamp, e.g. "2017-05-21 12-24-03". + const name = _('Screenshot from %s').format(timestamp); + + // If the target file already exists, try appending a suffix with an + // increasing number to it. + for (const suffix of suffixes()) { + file = Gio.File.new_for_path(GLib.build_filenamev([ + dir.get_path(), `${name}${suffix}.png`, + ])); + + try { + const stream = file.create(Gio.FileCreateFlags.NONE, null); + stream.write_bytes(bytes, null); + break; + } catch (e) { + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS)) + throw e; + } + } + + // Add it to recent files. + Gtk.RecentManager.get_default().add_item(file.get_uri()); + } + + // Create a St.ImageContent icon for the notification. We want + // St.ImageContent specifically because it preserves the aspect ratio when + // shown in a notification. + const pixels = pixbuf.read_pixel_bytes(); + const content = + St.ImageContent.new_with_preferred_size(pixbuf.width, pixbuf.height); + content.set_bytes( + pixels, + Cogl.PixelFormat.RGBA_8888, + pixbuf.width, + pixbuf.height, + pixbuf.rowstride + ); + + // Show a notification. + const source = new MessageTray.Source( + // Translators: notification source name. + _('Screenshot'), + 'screenshot-recorded-symbolic' + ); + const notification = new MessageTray.Notification( + source, + // Translators: notification title. + _('Screenshot captured'), + // Translators: notification body when a screenshot was captured. + _('You can paste the image from the clipboard.'), + { datetime: time, gicon: content } + ); + + if (!disableSaveToDisk) { + // Translators: button on the screenshot notification. + notification.addAction(_('Show in Files'), () => { + const app = + Gio.app_info_get_default_for_type('inode/directory', false); + + if (app === null) { + // It may be null e.g. in a toolbox without nautilus. + log('Error showing in files: no default app set for inode/directory'); + return; + } + + app.launch([file], global.create_app_launch_context(0, -1)); + }); + notification.connect('activated', () => { + try { + Gio.app_info_launch_default_for_uri( + file.get_uri(), global.create_app_launch_context(0, -1)); + } catch (err) { + logError(err, 'Error opening screenshot'); + } + }); + } + + notification.setTransient(true); + Main.messageTray.add(source); + source.showNotification(notification); +} + +/** + * Captures a screenshot from a texture, given a region, scale and optional + * cursor data. + * + * @param {Cogl.Texture} texture - The texture to take the screenshot from. + * @param {number[4]} [geometry] - The region to use: x, y, width and height. + * @param {number} scale - The texture scale. + * @param {Object} [cursor] - Cursor data to include in the screenshot. + * @param {Cogl.Texture} cursor.texture - The cursor texture. + * @param {number} cursor.x - The cursor x coordinate. + * @param {number} cursor.y - The cursor y coordinate. + * @param {number} cursor.scale - The cursor texture scale. + */ +async function captureScreenshot(texture, geometry, scale, cursor) { + const stream = Gio.MemoryOutputStream.new_resizable(); + const [x, y, w, h] = geometry ?? [0, 0, -1, -1]; + if (cursor === null) + cursor = { texture: null, x: 0, y: 0, scale: 1 }; + + global.display.get_sound_player().play_from_theme( + 'screen-capture', _('Screenshot taken'), null); + + const pixbuf = await Shell.Screenshot.composite_to_stream( + texture, + x, y, w, h, + scale, + cursor.texture, cursor.x, cursor.y, cursor.scale, + stream + ); + + stream.close(null); + _storeScreenshot(stream.steal_as_bytes(), pixbuf); +} + +/** + * Shows the screenshot UI. + */ +function showScreenshotUI() { + Main.screenshotUI.open().catch(err => { + logError(err, 'Error opening the screenshot UI'); + }); +} + +/** + * Shows the screen recording UI. + */ +function showScreenRecordingUI() { + Main.screenshotUI.open(UIMode.SCREENCAST).catch(err => { + logError(err, 'Error opening the screenshot UI'); + }); +} + +var ScreenshotService = class { + constructor() { + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(ScreenshotIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell/Screenshot'); + + this._screenShooter = new Map(); + this._senderChecker = new DBusSenderChecker([ + 'org.gnome.SettingsDaemon.MediaKeys', + 'org.freedesktop.impl.portal.desktop.gtk', + 'org.freedesktop.impl.portal.desktop.gnome', + 'org.gnome.Screenshot', + ]); + + this._lockdownSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.lockdown' }); + + Gio.DBus.session.own_name('org.gnome.Shell.Screenshot', Gio.BusNameOwnerFlags.REPLACE, null, null); + } + + async _createScreenshot(invocation, needsDisk = true, restrictCallers = true) { + let lockedDown = false; + if (needsDisk) + lockedDown = this._lockdownSettings.get_boolean('disable-save-to-disk'); + + let sender = invocation.get_sender(); + if (this._screenShooter.has(sender)) { + invocation.return_error_literal( + Gio.IOErrorEnum, Gio.IOErrorEnum.BUSY, + 'There is an ongoing operation for this sender'); + return null; + } else if (lockedDown) { + invocation.return_error_literal( + Gio.IOErrorEnum, Gio.IOErrorEnum.PERMISSION_DENIED, + 'Saving to disk is disabled'); + return null; + } else if (restrictCallers) { + try { + await this._senderChecker.checkInvocation(invocation); + } catch (e) { + invocation.return_gerror(e); + return null; + } + } + + let shooter = new Shell.Screenshot(); + shooter._watchNameId = + Gio.bus_watch_name(Gio.BusType.SESSION, sender, 0, null, + this._onNameVanished.bind(this)); + + this._screenShooter.set(sender, shooter); + + return shooter; + } + + _onNameVanished(connection, name) { + this._removeShooterForSender(name); + } + + _removeShooterForSender(sender) { + let shooter = this._screenShooter.get(sender); + if (!shooter) + return; + + Gio.bus_unwatch_name(shooter._watchNameId); + this._screenShooter.delete(sender); + } + + _checkArea(x, y, width, height) { + return x >= 0 && y >= 0 && + width > 0 && height > 0 && + x + width <= global.screen_width && + y + height <= global.screen_height; + } + + *_resolveRelativeFilename(filename) { + filename = filename.replace(/\.png$/, ''); + + let path = [ + GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_PICTURES), + GLib.get_home_dir(), + ].find(p => p && GLib.file_test(p, GLib.FileTest.EXISTS)); + + if (!path) + return null; + + yield Gio.File.new_for_path( + GLib.build_filenamev([path, `${filename}.png`])); + + for (let idx = 1; ; idx++) { + yield Gio.File.new_for_path( + GLib.build_filenamev([path, `${filename}-${idx}.png`])); + } + } + + _createStream(filename, invocation) { + if (filename == '') + return [Gio.MemoryOutputStream.new_resizable(), null]; + + if (GLib.path_is_absolute(filename)) { + try { + let file = Gio.File.new_for_path(filename); + let stream = file.replace(null, false, Gio.FileCreateFlags.NONE, null); + return [stream, file]; + } catch (e) { + invocation.return_gerror(e); + this._removeShooterForSender(invocation.get_sender()); + return [null, null]; + } + } + + let err; + for (let file of this._resolveRelativeFilename(filename)) { + try { + let stream = file.create(Gio.FileCreateFlags.NONE, null); + return [stream, file]; + } catch (e) { + err = e; + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS)) + break; + } + } + + invocation.return_gerror(err); + this._removeShooterForSender(invocation.get_sender()); + return [null, null]; + } + + _flashAsync(shooter) { + return new Promise((resolve, _reject) => { + shooter.connect('screenshot_taken', (s, area) => { + const flashspot = new Flashspot(area); + flashspot.fire(resolve); + + global.display.get_sound_player().play_from_theme( + 'screen-capture', _('Screenshot taken'), null); + }); + }); + } + + _onScreenshotComplete(stream, file, invocation) { + stream.close(null); + + let filenameUsed = ''; + if (file) { + filenameUsed = file.get_path(); + } else { + let bytes = stream.steal_as_bytes(); + let clipboard = St.Clipboard.get_default(); + clipboard.set_content(St.ClipboardType.CLIPBOARD, 'image/png', bytes); + } + + let retval = GLib.Variant.new('(bs)', [true, filenameUsed]); + invocation.return_value(retval); + } + + _scaleArea(x, y, width, height) { + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + x *= scaleFactor; + y *= scaleFactor; + width *= scaleFactor; + height *= scaleFactor; + return [x, y, width, height]; + } + + _unscaleArea(x, y, width, height) { + let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor; + x /= scaleFactor; + y /= scaleFactor; + width /= scaleFactor; + height /= scaleFactor; + return [x, y, width, height]; + } + + async ScreenshotAreaAsync(params, invocation) { + let [x, y, width, height, flash, filename] = params; + [x, y, width, height] = this._scaleArea(x, y, width, height); + if (!this._checkArea(x, y, width, height)) { + invocation.return_error_literal(Gio.IOErrorEnum, + Gio.IOErrorEnum.CANCELLED, + "Invalid params"); + return; + } + let screenshot = await this._createScreenshot(invocation); + if (!screenshot) + return; + + let [stream, file] = this._createStream(filename, invocation); + if (!stream) + return; + + try { + await Promise.all([ + flash ? this._flashAsync(screenshot) : null, + screenshot.screenshot_area(x, y, width, height, stream), + ]); + this._onScreenshotComplete(stream, file, invocation); + } catch (e) { + invocation.return_value(new GLib.Variant('(bs)', [false, ''])); + } finally { + this._removeShooterForSender(invocation.get_sender()); + } + } + + async ScreenshotWindowAsync(params, invocation) { + let [includeFrame, includeCursor, flash, filename] = params; + let screenshot = await this._createScreenshot(invocation); + if (!screenshot) + return; + + let [stream, file] = this._createStream(filename, invocation); + if (!stream) + return; + + try { + await Promise.all([ + flash ? this._flashAsync(screenshot) : null, + screenshot.screenshot_window(includeFrame, includeCursor, stream), + ]); + this._onScreenshotComplete(stream, file, invocation); + } catch (e) { + invocation.return_value(new GLib.Variant('(bs)', [false, ''])); + } finally { + this._removeShooterForSender(invocation.get_sender()); + } + } + + async ScreenshotAsync(params, invocation) { + let [includeCursor, flash, filename] = params; + let screenshot = await this._createScreenshot(invocation); + if (!screenshot) + return; + + let [stream, file] = this._createStream(filename, invocation); + if (!stream) + return; + + try { + await Promise.all([ + flash ? this._flashAsync(screenshot) : null, + screenshot.screenshot(includeCursor, stream), + ]); + this._onScreenshotComplete(stream, file, invocation); + } catch (e) { + invocation.return_value(new GLib.Variant('(bs)', [false, ''])); + } finally { + this._removeShooterForSender(invocation.get_sender()); + } + } + + async SelectAreaAsync(params, invocation) { + try { + await this._senderChecker.checkInvocation(invocation); + } catch (e) { + invocation.return_gerror(e); + return; + } + + let selectArea = new SelectArea(); + try { + let areaRectangle = await selectArea.selectAsync(); + let retRectangle = this._unscaleArea( + areaRectangle.x, areaRectangle.y, + areaRectangle.width, areaRectangle.height); + invocation.return_value(GLib.Variant.new('(iiii)', retRectangle)); + } catch (e) { + invocation.return_error_literal( + Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED, + 'Operation was cancelled'); + } + } + + async FlashAreaAsync(params, invocation) { + try { + await this._senderChecker.checkInvocation(invocation); + } catch (e) { + invocation.return_gerror(e); + return; + } + + let [x, y, width, height] = params; + [x, y, width, height] = this._scaleArea(x, y, width, height); + if (!this._checkArea(x, y, width, height)) { + invocation.return_error_literal(Gio.IOErrorEnum, + Gio.IOErrorEnum.CANCELLED, + "Invalid params"); + return; + } + let flashspot = new Flashspot({ x, y, width, height }); + flashspot.fire(); + invocation.return_value(null); + } + + async PickColorAsync(params, invocation) { + const screenshot = await this._createScreenshot(invocation, false, false); + if (!screenshot) + return; + + const pickPixel = new PickPixel(screenshot); + try { + const color = await pickPixel.pickAsync(); + const { red, green, blue } = color; + const retval = GLib.Variant.new('(a{sv})', [{ + color: GLib.Variant.new('(ddd)', [ + red / 255.0, + green / 255.0, + blue / 255.0, + ]), + }]); + invocation.return_value(retval); + } catch (e) { + invocation.return_error_literal( + Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED, + 'Operation was cancelled'); + } finally { + this._removeShooterForSender(invocation.get_sender()); + } + } +}; + +var SelectArea = GObject.registerClass( +class SelectArea extends St.Widget { + _init() { + this._startX = -1; + this._startY = -1; + this._lastX = 0; + this._lastY = 0; + this._result = null; + + super._init({ + visible: false, + reactive: true, + x: 0, + y: 0, + }); + Main.uiGroup.add_actor(this); + + this._grabHelper = new GrabHelper.GrabHelper(this); + + const constraint = new Clutter.BindConstraint({ + source: global.stage, + coordinate: Clutter.BindCoordinate.ALL, + }); + this.add_constraint(constraint); + + this._rubberband = new St.Widget({ + style_class: 'select-area-rubberband', + visible: false, + }); + this.add_actor(this._rubberband); + } + + async selectAsync() { + global.display.set_cursor(Meta.Cursor.CROSSHAIR); + Main.uiGroup.set_child_above_sibling(this, null); + this.show(); + + try { + await this._grabHelper.grabAsync({ actor: this }); + } finally { + global.display.set_cursor(Meta.Cursor.DEFAULT); + + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this.destroy(); + return GLib.SOURCE_REMOVE; + }); + } + + return this._result; + } + + _getGeometry() { + return new Meta.Rectangle({ + x: Math.min(this._startX, this._lastX), + y: Math.min(this._startY, this._lastY), + width: Math.abs(this._startX - this._lastX) + 1, + height: Math.abs(this._startY - this._lastY) + 1, + }); + } + + vfunc_motion_event(motionEvent) { + if (this._startX == -1 || this._startY == -1 || this._result) + return Clutter.EVENT_PROPAGATE; + + [this._lastX, this._lastY] = [motionEvent.x, motionEvent.y]; + this._lastX = Math.floor(this._lastX); + this._lastY = Math.floor(this._lastY); + let geometry = this._getGeometry(); + + this._rubberband.set_position(geometry.x, geometry.y); + this._rubberband.set_size(geometry.width, geometry.height); + this._rubberband.show(); + + return Clutter.EVENT_PROPAGATE; + } + + vfunc_button_press_event(buttonEvent) { + if (this._result) + return Clutter.EVENT_PROPAGATE; + + [this._startX, this._startY] = [buttonEvent.x, buttonEvent.y]; + this._startX = Math.floor(this._startX); + this._startY = Math.floor(this._startY); + this._rubberband.set_position(this._startX, this._startY); + + return Clutter.EVENT_PROPAGATE; + } + + vfunc_button_release_event() { + if (this._startX === -1 || this._startY === -1 || this._result) + return Clutter.EVENT_PROPAGATE; + + this._result = this._getGeometry(); + this.ease({ + opacity: 0, + duration: 200, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._grabHelper.ungrab(), + }); + return Clutter.EVENT_PROPAGATE; + } +}); + +var RecolorEffect = GObject.registerClass({ + Properties: { + color: GObject.ParamSpec.boxed( + 'color', 'color', 'replacement color', + GObject.ParamFlags.WRITABLE, + Clutter.Color.$gtype), + chroma: GObject.ParamSpec.boxed( + 'chroma', 'chroma', 'color to replace', + GObject.ParamFlags.WRITABLE, + Clutter.Color.$gtype), + threshold: GObject.ParamSpec.float( + 'threshold', 'threshold', 'threshold', + GObject.ParamFlags.WRITABLE, + 0.0, 1.0, 0.0), + smoothing: GObject.ParamSpec.float( + 'smoothing', 'smoothing', 'smoothing', + GObject.ParamFlags.WRITABLE, + 0.0, 1.0, 0.0), + }, +}, class RecolorEffect extends Shell.GLSLEffect { + _init(params) { + this._color = new Clutter.Color(); + this._chroma = new Clutter.Color(); + this._threshold = 0; + this._smoothing = 0; + + this._colorLocation = null; + this._chromaLocation = null; + this._thresholdLocation = null; + this._smoothingLocation = null; + + super._init(params); + + this._colorLocation = this.get_uniform_location('recolor_color'); + this._chromaLocation = this.get_uniform_location('chroma_color'); + this._thresholdLocation = this.get_uniform_location('threshold'); + this._smoothingLocation = this.get_uniform_location('smoothing'); + + this._updateColorUniform(this._colorLocation, this._color); + this._updateColorUniform(this._chromaLocation, this._chroma); + this._updateFloatUniform(this._thresholdLocation, this._threshold); + this._updateFloatUniform(this._smoothingLocation, this._smoothing); + } + + _updateColorUniform(location, color) { + if (!location) + return; + + this.set_uniform_float(location, + 3, [color.red / 255, color.green / 255, color.blue / 255]); + this.queue_repaint(); + } + + _updateFloatUniform(location, value) { + if (!location) + return; + + this.set_uniform_float(location, 1, [value]); + this.queue_repaint(); + } + + set color(c) { + if (this._color.equal(c)) + return; + + this._color = c; + this.notify('color'); + + this._updateColorUniform(this._colorLocation, this._color); + } + + set chroma(c) { + if (this._chroma.equal(c)) + return; + + this._chroma = c; + this.notify('chroma'); + + this._updateColorUniform(this._chromaLocation, this._chroma); + } + + set threshold(value) { + if (this._threshold === value) + return; + + this._threshold = value; + this.notify('threshold'); + + this._updateFloatUniform(this._thresholdLocation, this._threshold); + } + + set smoothing(value) { + if (this._smoothing === value) + return; + + this._smoothing = value; + this.notify('smoothing'); + + this._updateFloatUniform(this._smoothingLocation, this._smoothing); + } + + vfunc_build_pipeline() { + // Conversion parameters from https://en.wikipedia.org/wiki/YCbCr + const decl = ` + vec3 rgb2yCrCb(vec3 c) { \n + float y = 0.299 * c.r + 0.587 * c.g + 0.114 * c.b; \n + float cr = 0.7133 * (c.r - y); \n + float cb = 0.5643 * (c.b - y); \n + return vec3(y, cr, cb); \n + } \n + \n + uniform vec3 chroma_color; \n + uniform vec3 recolor_color; \n + uniform float threshold; \n + uniform float smoothing; \n`; + const src = ` + vec3 mask = rgb2yCrCb(chroma_color.rgb); \n + vec3 yCrCb = rgb2yCrCb(cogl_color_out.rgb); \n + float blend = \n + smoothstep(threshold, \n + threshold + smoothing, \n + distance(yCrCb.gb, mask.gb)); \n + cogl_color_out.rgb = \n + mix(recolor_color, cogl_color_out.rgb, blend); \n`; + + this.add_glsl_snippet(Shell.SnippetHook.FRAGMENT, decl, src, false); + } +}); + +var PickPixel = GObject.registerClass( +class PickPixel extends St.Widget { + _init(screenshot) { + super._init({ visible: false, reactive: true }); + + this._screenshot = screenshot; + + this._result = null; + this._color = null; + this._inPick = false; + + Main.uiGroup.add_actor(this); + + this._grabHelper = new GrabHelper.GrabHelper(this); + + const constraint = new Clutter.BindConstraint({ + source: global.stage, + coordinate: Clutter.BindCoordinate.ALL, + }); + this.add_constraint(constraint); + + const action = new Clutter.ClickAction(); + action.connect('clicked', async () => { + await this._pickColor(...action.get_coords()); + this._result = this._color; + this._grabHelper.ungrab(); + }); + this.add_action(action); + + this._recolorEffect = new RecolorEffect({ + chroma: new Clutter.Color({ + red: 80, + green: 219, + blue: 181, + }), + threshold: 0.04, + smoothing: 0.07, + }); + this._previewCursor = new St.Icon({ + icon_name: 'color-pick', + icon_size: Meta.prefs_get_cursor_size(), + effect: this._recolorEffect, + visible: false, + }); + Main.uiGroup.add_actor(this._previewCursor); + } + + async pickAsync() { + global.display.set_cursor(Meta.Cursor.BLANK); + Main.uiGroup.set_child_above_sibling(this, null); + this.show(); + + this._pickColor(...global.get_pointer()); + + try { + await this._grabHelper.grabAsync({ actor: this }); + } finally { + global.display.set_cursor(Meta.Cursor.DEFAULT); + this._previewCursor.destroy(); + + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this.destroy(); + return GLib.SOURCE_REMOVE; + }); + } + + return this._result; + } + + async _pickColor(x, y) { + if (this._inPick) + return; + + this._inPick = true; + this._previewCursor.set_position(x, y); + [this._color] = await this._screenshot.pick_color(x, y); + this._inPick = false; + + if (!this._color) + return; + + this._recolorEffect.color = this._color; + this._previewCursor.show(); + } + + vfunc_motion_event(motionEvent) { + const { x, y } = motionEvent; + this._pickColor(x, y); + return Clutter.EVENT_PROPAGATE; + } +}); + +var FLASHSPOT_ANIMATION_OUT_TIME = 500; // milliseconds + +var Flashspot = GObject.registerClass( +class Flashspot extends Lightbox.Lightbox { + _init(area) { + super._init(Main.uiGroup, { + inhibitEvents: true, + width: area.width, + height: area.height, + }); + this.style_class = 'flashspot'; + this.set_position(area.x, area.y); + } + + fire(doneCallback) { + this.set({ visible: true, opacity: 255 }); + this.ease({ + opacity: 0, + duration: FLASHSPOT_ANIMATION_OUT_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + if (doneCallback) + doneCallback(); + this.destroy(); + }, + }); + } +}); diff --git a/js/ui/scripting.js b/js/ui/scripting.js new file mode 100644 index 0000000..a4d80d7 --- /dev/null +++ b/js/ui/scripting.js @@ -0,0 +1,340 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported sleep, waitLeisure, createTestWindow, waitTestWindows, + destroyTestWindows, defineScriptEvent, scriptEvent, + collectStatistics, runPerfScript */ + +const { Gio, GLib, Meta, Shell } = imports.gi; + +const Config = imports.misc.config; +const Main = imports.ui.main; +const Params = imports.misc.params; +const Util = imports.misc.util; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +// This module provides functionality for driving the shell user interface +// in an automated fashion. The primary current use case for this is +// automated performance testing (see runPerfScript()), but it could +// be applied to other forms of automation, such as testing for +// correctness as well. +// +// When scripting an automated test we want to make a series of calls +// in a linear fashion, but we also want to be able to let the main +// loop run so actions can finish. For this reason we write the script +// as an async function that uses await when it wants to let the main +// loop run. +// +// await Scripting.sleep(1000); +// main.overview.show(); +// await Scripting.waitLeisure(); +// + +/** + * sleep: + * @param {number} milliseconds - number of milliseconds to wait + * @returns {Promise} that resolves after @milliseconds ms + * + * Used within an automation script to pause the the execution of the + * current script for the specified amount of time. Use as + * 'yield Scripting.sleep(500);' + */ +function sleep(milliseconds) { + return new Promise(resolve => { + let id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, milliseconds, () => { + resolve(); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] sleep'); + }); +} + +/** + * waitLeisure: + * @returns {Promise} that resolves when the shell is idle + * + * Used within an automation script to pause the the execution of the + * current script until the shell is completely idle. Use as + * 'yield Scripting.waitLeisure();' + */ +function waitLeisure() { + return new Promise(resolve => { + global.run_at_leisure(resolve); + }); +} + +const PerfHelperIface = loadInterfaceXML('org.gnome.Shell.PerfHelper'); +var PerfHelperProxy = Gio.DBusProxy.makeProxyWrapper(PerfHelperIface); +function PerfHelper() { + return new PerfHelperProxy(Gio.DBus.session, 'org.gnome.Shell.PerfHelper', '/org/gnome/Shell/PerfHelper'); +} + +let _perfHelper = null; +function _getPerfHelper() { + if (_perfHelper == null) + _perfHelper = new PerfHelper(); + + return _perfHelper; +} + +function _spawnPerfHelper() { + let path = Config.LIBEXECDIR; + let command = `${path}/gnome-shell-perf-helper`; + Util.trySpawnCommandLine(command); +} + +/** + * createTestWindow: + * @param {Object} params: options for window creation. + * {number} [params.width=640] - width of window, in pixels + * {number} [params.height=480] - height of window, in pixels + * {bool} [params.alpha=false] - whether the window should have an alpha channel + * {bool} [params.maximized=false] - whether the window should be created maximized + * {bool} [params.redraws=false] - whether the window should continually redraw itself + * @returns {Promise} + * + * Creates a window using gnome-shell-perf-helper for testing purposes. + * While this function can be used with yield in an automation + * script to pause until the D-Bus call to the helper process returns, + * because of the normal X asynchronous mapping process, to actually wait + * until the window has been mapped and exposed, use waitTestWindows(). + */ +function createTestWindow(params) { + params = Params.parse(params, { + width: 640, + height: 480, + alpha: false, + maximized: false, + redraws: false, + }); + + let perfHelper = _getPerfHelper(); + perfHelper.CreateWindowAsync( + params.width, params.height, + params.alpha, params.maximized, params.redraws).catch(logError); +} + +/** + * waitTestWindows: + * @returns {Promise} + * + * Used within an automation script to pause until all windows previously + * created with createTestWindow have been mapped and exposed. + */ +function waitTestWindows() { + let perfHelper = _getPerfHelper(); + perfHelper.WaitWindowsAsync().catch(logError); +} + +/** + * destroyTestWindows: + * @returns {Promise} + * + * Destroys all windows previously created with createTestWindow(). + * While this function can be used with yield in an automation + * script to pause until the D-Bus call to the helper process returns, + * this doesn't guarantee that Mutter has actually finished the destroy + * process because of normal X asynchronicity. + */ +function destroyTestWindows() { + let perfHelper = _getPerfHelper(); + perfHelper.DestroyWindowsAsync().catch(logError); +} + +/** + * defineScriptEvent + * @param {string} name: The event will be called script.<name> + * @param {string} description: Short human-readable description of the event + * + * Convenience function to define a zero-argument performance event + * within the 'script' namespace that is reserved for events defined locally + * within a performance automation script + */ +function defineScriptEvent(name, description) { + Shell.PerfLog.get_default().define_event(`script.${name}`, + description, + ""); +} + +/** + * scriptEvent + * @param {string} name: Name registered with defineScriptEvent() + * + * Convenience function to record a script-local performance event + * previously defined with defineScriptEvent + */ +function scriptEvent(name) { + Shell.PerfLog.get_default().event(`script.${name}`); +} + +/** + * collectStatistics + * + * Convenience function to trigger statistics collection + */ +function collectStatistics() { + Shell.PerfLog.get_default().collect_statistics(); +} + +function _collect(scriptModule, outputFile) { + let eventHandlers = {}; + + for (let f in scriptModule) { + let m = /([A-Za-z]+)_([A-Za-z]+)/.exec(f); + if (m) + eventHandlers[`${m[1]}.${m[2]}`] = scriptModule[f]; + } + + Shell.PerfLog.get_default().replay( + (time, eventName, signature, arg) => { + if (eventName in eventHandlers) + eventHandlers[eventName](time, arg); + }); + + if ('finish' in scriptModule) + scriptModule.finish(); + + if (outputFile) { + let f = Gio.file_new_for_path(outputFile); + let raw = f.replace(null, false, + Gio.FileCreateFlags.NONE, + null); + let out = Gio.BufferedOutputStream.new_sized(raw, 4096); + Shell.write_string_to_stream(out, "{\n"); + + Shell.write_string_to_stream(out, '"events":\n'); + Shell.PerfLog.get_default().dump_events(out); + + let monitors = Main.layoutManager.monitors; + let primary = Main.layoutManager.primaryIndex; + Shell.write_string_to_stream(out, ',\n"monitors":\n['); + for (let i = 0; i < monitors.length; i++) { + let monitor = monitors[i]; + if (i != 0) + Shell.write_string_to_stream(out, ', '); + const prefix = i === primary ? '*' : ''; + Shell.write_string_to_stream(out, + `"${prefix}${monitor.width}x${monitor.height}+${monitor.x}+${monitor.y}"`); + } + Shell.write_string_to_stream(out, ' ]'); + + Shell.write_string_to_stream(out, ',\n"metrics":\n[ '); + let first = true; + for (let name in scriptModule.METRICS) { + let metric = scriptModule.METRICS[name]; + // Extra checks here because JSON.stringify generates + // invalid JSON for undefined values + if (metric.description == null) { + log(`Error: No description found for metric ${name}`); + continue; + } + if (metric.units == null) { + log(`Error: No units found for metric ${name}`); + continue; + } + if (metric.value == null) { + log(`Error: No value found for metric ${name}`); + continue; + } + + if (!first) + Shell.write_string_to_stream(out, ',\n '); + first = false; + + Shell.write_string_to_stream(out, + `{ "name": ${JSON.stringify(name)},\n` + + ` "description": ${JSON.stringify(metric.description)},\n` + + ` "units": ${JSON.stringify(metric.units)},\n` + + ` "value": ${JSON.stringify(metric.value)} }`); + } + Shell.write_string_to_stream(out, ' ]'); + + Shell.write_string_to_stream(out, ',\n"log":\n'); + Shell.PerfLog.get_default().dump_log(out); + + Shell.write_string_to_stream(out, '\n}\n'); + out.close(null); + } else { + let metrics = []; + for (let metric in scriptModule.METRICS) + metrics.push(metric); + + metrics.sort(); + + print('------------------------------------------------------------'); + for (let i = 0; i < metrics.length; i++) { + let metric = metrics[i]; + print(`# ${scriptModule.METRICS[metric].description}`); + print(`${metric}: ${scriptModule.METRICS[metric].value}${scriptModule.METRICS[metric].units}`); + } + print('------------------------------------------------------------'); + } +} + +async function _runPerfScript(scriptModule, outputFile) { + try { + await scriptModule.run(); + } catch (err) { + log(`Script failed: ${err}\n${err.stack}`); + Meta.exit(Meta.ExitCode.ERROR); + } + + try { + _collect(scriptModule, outputFile); + } catch (err) { + log(`Script failed: ${err}\n${err.stack}`); + Meta.exit(Meta.ExitCode.ERROR); + } + Meta.exit(Meta.ExitCode.SUCCESS); +} + +/** + * runPerfScript + * @param {Object} scriptModule: module object with run and finish + * functions and event handlers + * @param {string} outputFile: path to write output to + * + * Runs a script for automated collection of performance data. The + * script is defined as a Javascript module with specified contents. + * + * First the run() function within the module will be called as a + * generator to automate a series of actions. These actions will + * trigger performance events and the script can also record its + * own performance events. + * + * Then the recorded event log is replayed using handler functions + * within the module. The handler for the event 'foo.bar' is called + * foo_bar(). + * + * Finally if the module has a function called finish(), that will + * be called. + * + * The event handler and finish functions are expected to fill in + * metrics to an object within the module called METRICS. Each + * property of this object represents an individual metric. The + * name of the property is the name of the metric, the value + * of the property is an object with the following properties: + * + * description: human readable description of the metric + * units: a string representing the units of the metric. It has + * the form '<unit> <unit> ... / <unit> / <unit> ...'. Certain + * unit values are recognized: s, ms, us, B, KiB, MiB. Other + * values can appear but are uninterpreted. Examples 's', + * '/ s', 'frames', 'frames / s', 'MiB / s / frame' + * value: computed value of the metric + * + * The resulting metrics will be written to @outputFile as JSON, or, + * if @outputFile is not provided, logged. + * + * After running the script and collecting statistics from the + * event log, GNOME Shell will exit. + **/ +function runPerfScript(scriptModule, outputFile) { + Shell.PerfLog.get_default().set_enabled(true); + _spawnPerfHelper(); + + Gio.bus_watch_name(Gio.BusType.SESSION, + 'org.gnome.Shell.PerfHelper', + Gio.BusNameWatcherFlags.NONE, + () => _runPerfScript(scriptModule, outputFile), + null); +} diff --git a/js/ui/search.js b/js/ui/search.js new file mode 100644 index 0000000..1029f31 --- /dev/null +++ b/js/ui/search.js @@ -0,0 +1,945 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported SearchResultsView */ + +const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; + +const AppDisplay = imports.ui.appDisplay; +const IconGrid = imports.ui.iconGrid; +const Main = imports.ui.main; +const ParentalControlsManager = imports.misc.parentalControlsManager; +const RemoteSearch = imports.ui.remoteSearch; +const Util = imports.misc.util; + +const { Highlighter } = imports.misc.util; + +const SEARCH_PROVIDERS_SCHEMA = 'org.gnome.desktop.search-providers'; + +var MAX_LIST_SEARCH_RESULTS_ROWS = 5; + +var MaxWidthBox = GObject.registerClass( +class MaxWidthBox extends St.BoxLayout { + vfunc_allocate(box) { + let themeNode = this.get_theme_node(); + let maxWidth = themeNode.get_max_width(); + let availWidth = box.x2 - box.x1; + let adjustedBox = box; + + if (availWidth > maxWidth) { + let excessWidth = availWidth - maxWidth; + adjustedBox.x1 += Math.floor(excessWidth / 2); + adjustedBox.x2 -= Math.floor(excessWidth / 2); + } + + super.vfunc_allocate(adjustedBox); + } +}); + +var SearchResult = GObject.registerClass( +class SearchResult extends St.Button { + _init(provider, metaInfo, resultsView) { + this.provider = provider; + this.metaInfo = metaInfo; + this._resultsView = resultsView; + + super._init({ + reactive: true, + can_focus: true, + track_hover: true, + }); + } + + vfunc_clicked() { + this.activate(); + } + + activate() { + this.provider.activateResult(this.metaInfo.id, this._resultsView.terms); + + if (this.metaInfo.clipboardText) { + St.Clipboard.get_default().set_text( + St.ClipboardType.CLIPBOARD, this.metaInfo.clipboardText); + } + Main.overview.toggle(); + } +}); + +var ListSearchResult = GObject.registerClass( +class ListSearchResult extends SearchResult { + _init(provider, metaInfo, resultsView) { + super._init(provider, metaInfo, resultsView); + + this.style_class = 'list-search-result'; + + let content = new St.BoxLayout({ + style_class: 'list-search-result-content', + vertical: false, + x_align: Clutter.ActorAlign.START, + x_expand: true, + y_expand: true, + }); + this.set_child(content); + + let titleBox = new St.BoxLayout({ + style_class: 'list-search-result-title', + y_align: Clutter.ActorAlign.CENTER, + }); + + content.add_child(titleBox); + + // An icon for, or thumbnail of, content + let icon = this.metaInfo['createIcon'](this.ICON_SIZE); + if (icon) + titleBox.add(icon); + + let title = new St.Label({ + text: this.metaInfo['name'], + y_align: Clutter.ActorAlign.CENTER, + }); + titleBox.add_child(title); + + this.label_actor = title; + + if (this.metaInfo['description']) { + this._descriptionLabel = new St.Label({ + style_class: 'list-search-result-description', + y_align: Clutter.ActorAlign.CENTER, + }); + content.add_child(this._descriptionLabel); + + this._resultsView.connectObject( + 'terms-changed', this._highlightTerms.bind(this), this); + + this._highlightTerms(); + } + } + + get ICON_SIZE() { + return 24; + } + + _highlightTerms() { + let markup = this._resultsView.highlightTerms(this.metaInfo['description'].split('\n')[0]); + this._descriptionLabel.clutter_text.set_markup(markup); + } +}); + +var GridSearchResult = GObject.registerClass( +class GridSearchResult extends SearchResult { + _init(provider, metaInfo, resultsView) { + super._init(provider, metaInfo, resultsView); + + this.style_class = 'grid-search-result'; + + this.icon = new IconGrid.BaseIcon(this.metaInfo['name'], + { createIcon: this.metaInfo['createIcon'] }); + let content = new St.Bin({ + child: this.icon, + x_align: Clutter.ActorAlign.START, + x_expand: true, + y_expand: true, + }); + this.set_child(content); + this.label_actor = this.icon.label; + } +}); + +var SearchResultsBase = GObject.registerClass({ + GTypeFlags: GObject.TypeFlags.ABSTRACT, + Properties: { + 'focus-child': GObject.ParamSpec.object( + 'focus-child', 'focus-child', 'focus-child', + GObject.ParamFlags.READABLE, + Clutter.Actor.$gtype), + }, +}, class SearchResultsBase extends St.BoxLayout { + _init(provider, resultsView) { + super._init({ style_class: 'search-section', vertical: true }); + + this.provider = provider; + this._resultsView = resultsView; + + this._terms = []; + this._focusChild = null; + + this._resultDisplayBin = new St.Bin(); + this.add_child(this._resultDisplayBin); + + let separator = new St.Widget({ style_class: 'search-section-separator' }); + this.add(separator); + + this._resultDisplays = {}; + + this._cancellable = new Gio.Cancellable(); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + _onDestroy() { + this._terms = []; + } + + _createResultDisplay(meta) { + if (this.provider.createResultObject) + return this.provider.createResultObject(meta, this._resultsView); + + return null; + } + + clear() { + this._cancellable.cancel(); + for (let resultId in this._resultDisplays) + this._resultDisplays[resultId].destroy(); + this._resultDisplays = {}; + this._clearResultDisplay(); + this.hide(); + } + + get focusChild() { + return this._focusChild; + } + + _keyFocusIn(actor) { + if (this._focusChild == actor) + return; + this._focusChild = actor; + this.notify('focus-child'); + } + + _setMoreCount(_count) { + } + + async _ensureResultActors(results) { + let metasNeeded = results.filter( + resultId => this._resultDisplays[resultId] === undefined); + + if (metasNeeded.length === 0) + return; + + this._cancellable.cancel(); + this._cancellable.reset(); + + const metas = await this.provider.getResultMetas(metasNeeded, this._cancellable); + + if (this._cancellable.is_cancelled()) { + if (metas.length > 0) + throw new Error(`Search provider ${this.provider.id} returned results after the request was canceled`); + } + + if (metas.length !== metasNeeded.length) { + throw new Error(`Wrong number of result metas returned by search provider ${this.provider.id}: ` + + `expected ${metasNeeded.length} but got ${metas.length}`); + } + + if (metas.some(meta => !meta.name || !meta.id)) + throw new Error(`Invalid result meta returned from search provider ${this.provider.id}`); + + metasNeeded.forEach((resultId, i) => { + let meta = metas[i]; + let display = this._createResultDisplay(meta); + display.connect('key-focus-in', this._keyFocusIn.bind(this)); + this._resultDisplays[resultId] = display; + }); + } + + async updateSearch(providerResults, terms, callback) { + this._terms = terms; + if (providerResults.length == 0) { + this._clearResultDisplay(); + this.hide(); + callback(); + } else { + let maxResults = this._getMaxDisplayedResults(); + let results = maxResults > -1 + ? this.provider.filterResults(providerResults, maxResults) + : providerResults; + let moreCount = Math.max(providerResults.length - results.length, 0); + + try { + await this._ensureResultActors(results); + + // To avoid CSS transitions causing flickering when + // the first search result stays the same, we hide the + // content while filling in the results. + this.hide(); + this._clearResultDisplay(); + results.forEach( + resultId => this._addItem(this._resultDisplays[resultId])); + this._setMoreCount(this.provider.canLaunchSearch ? moreCount : 0); + this.show(); + callback(); + } catch (e) { + this._clearResultDisplay(); + callback(); + } + } + } +}); + +var ListSearchResults = GObject.registerClass( +class ListSearchResults extends SearchResultsBase { + _init(provider, resultsView) { + super._init(provider, resultsView); + + this._container = new St.BoxLayout({ style_class: 'search-section-content' }); + this.providerInfo = new ProviderInfo(provider); + this.providerInfo.connect('key-focus-in', this._keyFocusIn.bind(this)); + this.providerInfo.connect('clicked', () => { + this.providerInfo.animateLaunch(); + provider.launchSearch(this._terms); + Main.overview.toggle(); + }); + + this._container.add_child(this.providerInfo); + + this._content = new St.BoxLayout({ + style_class: 'list-search-results', + vertical: true, + x_expand: true, + }); + this._container.add_child(this._content); + + this._resultDisplayBin.set_child(this._container); + } + + _setMoreCount(count) { + this.providerInfo.setMoreCount(count); + } + + _getMaxDisplayedResults() { + return MAX_LIST_SEARCH_RESULTS_ROWS; + } + + _clearResultDisplay() { + this._content.remove_all_children(); + } + + _createResultDisplay(meta) { + return super._createResultDisplay(meta) || + new ListSearchResult(this.provider, meta, this._resultsView); + } + + _addItem(display) { + this._content.add_actor(display); + } + + getFirstResult() { + if (this._content.get_n_children() > 0) + return this._content.get_child_at_index(0); + else + return null; + } +}); + +var GridSearchResultsLayout = GObject.registerClass({ + Properties: { + 'spacing': GObject.ParamSpec.int('spacing', 'Spacing', 'Spacing', + GObject.ParamFlags.READWRITE, 0, GLib.MAXINT32, 0), + }, +}, class GridSearchResultsLayout extends Clutter.LayoutManager { + _init() { + super._init(); + this._spacing = 0; + } + + vfunc_set_container(container) { + this._container = container; + } + + vfunc_get_preferred_width(container, forHeight) { + let minWidth = 0; + let natWidth = 0; + let first = true; + + for (let child of container) { + if (!child.visible) + continue; + + const [childMinWidth, childNatWidth] = child.get_preferred_width(forHeight); + + minWidth = Math.max(minWidth, childMinWidth); + natWidth += childNatWidth; + + if (first) + first = false; + else + natWidth += this._spacing; + } + + return [minWidth, natWidth]; + } + + vfunc_get_preferred_height(container, forWidth) { + let minHeight = 0; + let natHeight = 0; + + for (let child of container) { + if (!child.visible) + continue; + + const [childMinHeight, childNatHeight] = child.get_preferred_height(forWidth); + + minHeight = Math.max(minHeight, childMinHeight); + natHeight = Math.max(natHeight, childNatHeight); + } + + return [minHeight, natHeight]; + } + + vfunc_allocate(container, box) { + const width = box.get_width(); + + const childBox = new Clutter.ActorBox(); + childBox.x1 = 0; + childBox.y1 = 0; + + let first = true; + for (let child of container) { + if (!child.visible) + continue; + + if (first) + first = false; + else + childBox.x1 += this._spacing; + + const [childWidth] = child.get_preferred_width(-1); + const [childHeight] = child.get_preferred_height(-1); + + if (childBox.x1 + childWidth <= width) + childBox.set_size(childWidth, childHeight); + else + childBox.set_size(0, 0); + + child.allocate(childBox); + child.can_focus = childBox.get_area() > 0; + + childBox.x1 += childWidth; + } + } + + columnsForWidth(width) { + if (!this._container) + return -1; + + const [minWidth] = this.get_preferred_width(this._container, -1); + + if (minWidth === 0) + return -1; + + let nCols = 0; + while (width > minWidth) { + width -= minWidth; + if (nCols > 0) + width -= this._spacing; + nCols++; + } + + return nCols; + } + + get spacing() { + return this._spacing; + } + + set spacing(v) { + if (this._spacing === v) + return; + this._spacing = v; + this.layout_changed(); + } +}); + +var GridSearchResults = GObject.registerClass( +class GridSearchResults extends SearchResultsBase { + _init(provider, resultsView) { + super._init(provider, resultsView); + + this._grid = new St.Widget({ style_class: 'grid-search-results' }); + this._grid.layout_manager = new GridSearchResultsLayout(); + + this._grid.connect('style-changed', () => { + const node = this._grid.get_theme_node(); + this._grid.layout_manager.spacing = node.get_length('spacing'); + }); + + this._resultDisplayBin.set_child(new St.Bin({ + child: this._grid, + x_align: Clutter.ActorAlign.CENTER, + })); + } + + _onDestroy() { + if (this._updateSearchLater) { + Meta.later_remove(this._updateSearchLater); + delete this._updateSearchLater; + } + + super._onDestroy(); + } + + updateSearch(...args) { + if (this._notifyAllocationId) + this.disconnect(this._notifyAllocationId); + if (this._updateSearchLater) { + Meta.later_remove(this._updateSearchLater); + delete this._updateSearchLater; + } + + // Make sure the maximum number of results calculated by + // _getMaxDisplayedResults() is updated after width changes. + this._notifyAllocationId = this.connect('notify::allocation', () => { + if (this._updateSearchLater) + return; + this._updateSearchLater = Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + delete this._updateSearchLater; + super.updateSearch(...args); + return GLib.SOURCE_REMOVE; + }); + }); + + super.updateSearch(...args); + } + + _getMaxDisplayedResults() { + let width = this.allocation.get_width(); + if (width == 0) + return -1; + + return this._grid.layout_manager.columnsForWidth(width); + } + + _clearResultDisplay() { + this._grid.remove_all_children(); + } + + _createResultDisplay(meta) { + return super._createResultDisplay(meta) || + new GridSearchResult(this.provider, meta, this._resultsView); + } + + _addItem(display) { + this._grid.add_child(display); + } + + getFirstResult() { + for (let child of this._grid) { + if (child.visible) + return child; + } + return null; + } +}); + +var SearchResultsView = GObject.registerClass({ + Signals: { 'terms-changed': {} }, +}, class SearchResultsView extends St.BoxLayout { + _init() { + super._init({ + name: 'searchResults', + vertical: true, + x_expand: true, + y_expand: true, + }); + + this._parentalControlsManager = ParentalControlsManager.getDefault(); + this._parentalControlsManager.connect('app-filter-changed', this._reloadRemoteProviders.bind(this)); + + this._content = new MaxWidthBox({ + name: 'searchResultsContent', + vertical: true, + x_expand: true, + }); + + this._scrollView = new St.ScrollView({ + overlay_scrollbars: true, + style_class: 'search-display vfade', + x_expand: true, + y_expand: true, + }); + this._scrollView.set_policy(St.PolicyType.NEVER, St.PolicyType.AUTOMATIC); + this._scrollView.add_actor(this._content); + + let action = new Clutter.PanAction({ interpolate: true }); + action.connect('pan', this._onPan.bind(this)); + this._scrollView.add_action(action); + + this.add_child(this._scrollView); + + this._statusText = new St.Label({ + style_class: 'search-statustext', + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + this._statusBin = new St.Bin({ y_expand: true }); + this.add_child(this._statusBin); + this._statusBin.add_actor(this._statusText); + + this._highlightDefault = false; + this._defaultResult = null; + this._startingSearch = false; + + this._terms = []; + this._results = {}; + + this._providers = []; + + this._highlighter = new Highlighter(); + + this._searchSettings = new Gio.Settings({ schema_id: SEARCH_PROVIDERS_SCHEMA }); + this._searchSettings.connect('changed::disabled', this._reloadRemoteProviders.bind(this)); + this._searchSettings.connect('changed::enabled', this._reloadRemoteProviders.bind(this)); + this._searchSettings.connect('changed::disable-external', this._reloadRemoteProviders.bind(this)); + this._searchSettings.connect('changed::sort-order', this._reloadRemoteProviders.bind(this)); + + this._searchTimeoutId = 0; + this._cancellable = new Gio.Cancellable(); + + this._registerProvider(new AppDisplay.AppSearchProvider()); + + let appSystem = Shell.AppSystem.get_default(); + appSystem.connect('installed-changed', this._reloadRemoteProviders.bind(this)); + this._reloadRemoteProviders(); + } + + get terms() { + return this._terms; + } + + _reloadRemoteProviders() { + let remoteProviders = this._providers.filter(p => p.isRemoteProvider); + remoteProviders.forEach(provider => { + this._unregisterProvider(provider); + }); + + const providers = RemoteSearch.loadRemoteSearchProviders(this._searchSettings); + providers.forEach(this._registerProvider.bind(this)); + } + + _registerProvider(provider) { + provider.searchInProgress = false; + + // Filter out unwanted providers. + if (provider.appInfo && !this._parentalControlsManager.shouldShowApp(provider.appInfo)) + return; + + this._providers.push(provider); + this._ensureProviderDisplay(provider); + } + + _unregisterProvider(provider) { + let index = this._providers.indexOf(provider); + this._providers.splice(index, 1); + + if (provider.display) + provider.display.destroy(); + } + + _clearSearchTimeout() { + if (this._searchTimeoutId > 0) { + GLib.source_remove(this._searchTimeoutId); + this._searchTimeoutId = 0; + } + } + + _reset() { + this._terms = []; + this._results = {}; + this._clearDisplay(); + this._clearSearchTimeout(); + this._defaultResult = null; + this._startingSearch = false; + + this._updateSearchProgress(); + } + + async _doProviderSearch(provider, previousResults) { + provider.searchInProgress = true; + + let results; + if (this._isSubSearch && previousResults) { + results = await provider.getSubsearchResultSet( + previousResults, + this._terms, + this._cancellable); + } else { + results = await provider.getInitialResultSet( + this._terms, + this._cancellable); + } + + this._results[provider.id] = results; + this._updateResults(provider, results); + } + + _doSearch() { + this._startingSearch = false; + + let previousResults = this._results; + this._results = {}; + + this._providers.forEach(provider => { + let previousProviderResults = previousResults[provider.id]; + this._doProviderSearch(provider, previousProviderResults); + }); + + this._updateSearchProgress(); + + this._clearSearchTimeout(); + } + + _onSearchTimeout() { + this._searchTimeoutId = 0; + this._doSearch(); + return GLib.SOURCE_REMOVE; + } + + setTerms(terms) { + // Check for the case of making a duplicate previous search before + // setting state of the current search or cancelling the search. + // This will prevent incorrect state being as a result of a duplicate + // search while the previous search is still active. + let searchString = terms.join(' '); + let previousSearchString = this._terms.join(' '); + if (searchString == previousSearchString) + return; + + this._startingSearch = true; + + this._cancellable.cancel(); + this._cancellable.reset(); + + if (terms.length == 0) { + this._reset(); + return; + } + + let isSubSearch = false; + if (this._terms.length > 0) + isSubSearch = searchString.indexOf(previousSearchString) == 0; + + this._terms = terms; + this._isSubSearch = isSubSearch; + this._updateSearchProgress(); + + if (this._searchTimeoutId == 0) + this._searchTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 150, this._onSearchTimeout.bind(this)); + + this._highlighter = new Highlighter(this._terms); + + this.emit('terms-changed'); + } + + _onPan(action) { + let [dist_, dx_, dy] = action.get_motion_delta(0); + let adjustment = this._scrollView.vscroll.adjustment; + adjustment.value -= (dy / this.height) * adjustment.page_size; + return false; + } + + _focusChildChanged(provider) { + Util.ensureActorVisibleInScrollView(this._scrollView, provider.focusChild); + } + + _ensureProviderDisplay(provider) { + if (provider.display) + return; + + let providerDisplay; + if (provider.appInfo) + providerDisplay = new ListSearchResults(provider, this); + else + providerDisplay = new GridSearchResults(provider, this); + + providerDisplay.connect('notify::focus-child', this._focusChildChanged.bind(this)); + providerDisplay.hide(); + this._content.add(providerDisplay); + provider.display = providerDisplay; + } + + _clearDisplay() { + this._providers.forEach(provider => { + provider.display.clear(); + }); + } + + _maybeSetInitialSelection() { + let newDefaultResult = null; + + let providers = this._providers; + for (let i = 0; i < providers.length; i++) { + let provider = providers[i]; + let display = provider.display; + + if (!display.visible) + continue; + + let firstResult = display.getFirstResult(); + if (firstResult) { + newDefaultResult = firstResult; + break; // select this one! + } + } + + if (newDefaultResult != this._defaultResult) { + this._setSelected(this._defaultResult, false); + this._setSelected(newDefaultResult, this._highlightDefault); + + this._defaultResult = newDefaultResult; + } + } + + get searchInProgress() { + if (this._startingSearch) + return true; + + return this._providers.some(p => p.searchInProgress); + } + + _updateSearchProgress() { + let haveResults = this._providers.some(provider => { + let display = provider.display; + return display.getFirstResult() != null; + }); + + this._scrollView.visible = haveResults; + this._statusBin.visible = !haveResults; + + if (!haveResults) { + if (this.searchInProgress) + this._statusText.set_text(_("Searching…")); + else + this._statusText.set_text(_("No results.")); + } + } + + _updateResults(provider, results) { + let terms = this._terms; + let display = provider.display; + + display.updateSearch(results, terms, () => { + provider.searchInProgress = false; + + this._maybeSetInitialSelection(); + this._updateSearchProgress(); + }); + } + + activateDefault() { + // If we have a search queued up, force the search now. + if (this._searchTimeoutId > 0) + this._doSearch(); + + if (this._defaultResult) + this._defaultResult.activate(); + } + + highlightDefault(highlight) { + this._highlightDefault = highlight; + this._setSelected(this._defaultResult, highlight); + } + + popupMenuDefault() { + // If we have a search queued up, force the search now. + if (this._searchTimeoutId > 0) + this._doSearch(); + + if (this._defaultResult) + this._defaultResult.popup_menu(); + } + + navigateFocus(direction) { + let rtl = this.get_text_direction() == Clutter.TextDirection.RTL; + if (direction == St.DirectionType.TAB_BACKWARD || + direction == (rtl + ? St.DirectionType.RIGHT + : St.DirectionType.LEFT) || + direction == St.DirectionType.UP) { + this.navigate_focus(null, direction, false); + return; + } + + const from = this._defaultResult ?? null; + this.navigate_focus(from, direction, false); + } + + _setSelected(result, selected) { + if (!result) + return; + + if (selected) { + result.add_style_pseudo_class('selected'); + Util.ensureActorVisibleInScrollView(this._scrollView, result); + } else { + result.remove_style_pseudo_class('selected'); + } + } + + highlightTerms(description) { + if (!description) + return ''; + + return this._highlighter.highlight(description); + } +}); + +var ProviderInfo = GObject.registerClass( +class ProviderInfo extends St.Button { + _init(provider) { + this.provider = provider; + super._init({ + style_class: 'search-provider-icon', + reactive: true, + can_focus: true, + accessible_name: provider.appInfo.get_name(), + track_hover: true, + y_align: Clutter.ActorAlign.START, + }); + + this._content = new St.BoxLayout({ + vertical: false, + style_class: 'list-search-provider-content', + }); + this.set_child(this._content); + + const icon = new St.Icon({ + icon_size: this.PROVIDER_ICON_SIZE, + gicon: provider.appInfo.get_icon(), + }); + + const detailsBox = new St.BoxLayout({ + style_class: 'list-search-provider-details', + vertical: true, + x_expand: true, + }); + + const nameLabel = new St.Label({ + text: provider.appInfo.get_name(), + x_align: Clutter.ActorAlign.START, + }); + + this._moreLabel = new St.Label({ x_align: Clutter.ActorAlign.START }); + + detailsBox.add_actor(nameLabel); + detailsBox.add_actor(this._moreLabel); + + + this._content.add_actor(icon); + this._content.add_actor(detailsBox); + } + + get PROVIDER_ICON_SIZE() { + return 32; + } + + animateLaunch() { + let appSys = Shell.AppSystem.get_default(); + let app = appSys.lookup_app(this.provider.appInfo.get_id()); + if (app.state == Shell.AppState.STOPPED) + IconGrid.zoomOutActor(this._content); + } + + setMoreCount(count) { + this._moreLabel.text = ngettext("%d more", "%d more", count).format(count); + this._moreLabel.visible = count > 0; + } +}); diff --git a/js/ui/searchController.js b/js/ui/searchController.js new file mode 100644 index 0000000..ba743a9 --- /dev/null +++ b/js/ui/searchController.js @@ -0,0 +1,325 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported SearchController */ + +const { Clutter, GObject, St } = imports.gi; + +const Main = imports.ui.main; +const Search = imports.ui.search; +const ShellEntry = imports.ui.shellEntry; + +var FocusTrap = GObject.registerClass( +class FocusTrap extends St.Widget { + vfunc_navigate_focus(from, direction) { + if (direction === St.DirectionType.TAB_FORWARD || + direction === St.DirectionType.TAB_BACKWARD) + return super.vfunc_navigate_focus(from, direction); + return false; + } +}); + +function getTermsForSearchString(searchString) { + searchString = searchString.replace(/^\s+/g, '').replace(/\s+$/g, ''); + if (searchString === '') + return []; + return searchString.split(/\s+/); +} + +var SearchController = GObject.registerClass({ + Properties: { + 'search-active': GObject.ParamSpec.boolean( + 'search-active', 'search-active', 'search-active', + GObject.ParamFlags.READABLE, + false), + }, +}, class SearchController extends St.Widget { + _init(searchEntry, showAppsButton) { + super._init({ + name: 'searchController', + layout_manager: new Clutter.BinLayout(), + x_expand: true, + y_expand: true, + visible: false, + }); + + this._showAppsButton = showAppsButton; + this._showAppsButton.connect('notify::checked', this._onShowAppsButtonToggled.bind(this)); + + this._activePage = null; + + this._searchActive = false; + + this._entry = searchEntry; + ShellEntry.addContextMenu(this._entry); + + this._text = this._entry.clutter_text; + this._text.connect('text-changed', this._onTextChanged.bind(this)); + this._text.connect('key-press-event', this._onKeyPress.bind(this)); + this._text.connect('key-focus-in', () => { + this._searchResults.highlightDefault(true); + }); + this._text.connect('key-focus-out', () => { + this._searchResults.highlightDefault(false); + }); + this._entry.connect('popup-menu', () => { + if (!this._searchActive) + return; + + this._entry.menu.close(); + this._searchResults.popupMenuDefault(); + }); + this._entry.connect('notify::mapped', this._onMapped.bind(this)); + global.stage.connect('notify::key-focus', this._onStageKeyFocusChanged.bind(this)); + + this._entry.set_primary_icon(new St.Icon({ + style_class: 'search-entry-icon', + icon_name: 'edit-find-symbolic', + })); + this._clearIcon = new St.Icon({ + style_class: 'search-entry-icon', + icon_name: 'edit-clear-symbolic', + }); + + this._iconClickedId = 0; + this._capturedEventId = 0; + + this._searchResults = new Search.SearchResultsView(); + this.add_child(this._searchResults); + Main.ctrlAltTabManager.addGroup(this._entry, _('Search'), 'edit-find-symbolic'); + + // Since the entry isn't inside the results container we install this + // dummy widget as the last results container child so that we can + // include the entry in the keynav tab path + this._focusTrap = new FocusTrap({ can_focus: true }); + this._focusTrap.connect('key-focus-in', () => { + this._entry.grab_key_focus(); + }); + this._searchResults.add_actor(this._focusTrap); + + global.focus_manager.add_group(this._searchResults); + + this._stageKeyPressId = 0; + Main.overview.connect('showing', () => { + this._stageKeyPressId = + global.stage.connect('key-press-event', this._onStageKeyPress.bind(this)); + }); + Main.overview.connect('hiding', () => { + if (this._stageKeyPressId !== 0) { + global.stage.disconnect(this._stageKeyPressId); + this._stageKeyPressId = 0; + } + }); + } + + prepareToEnterOverview() { + this.reset(); + this._setSearchActive(false); + } + + vfunc_unmap() { + this.reset(); + + super.vfunc_unmap(); + } + + _setSearchActive(searchActive) { + if (this._searchActive === searchActive) + return; + + this._searchActive = searchActive; + this.notify('search-active'); + } + + _onShowAppsButtonToggled() { + this._setSearchActive(false); + } + + _onStageKeyPress(actor, event) { + // Ignore events while anything but the overview has + // pushed a modal (system modals, looking glass, ...) + if (Main.modalCount > 1) + return Clutter.EVENT_PROPAGATE; + + let symbol = event.get_key_symbol(); + + if (symbol === Clutter.KEY_Escape) { + if (this._searchActive) + this.reset(); + else if (this._showAppsButton.checked) + this._showAppsButton.checked = false; + else + Main.overview.hide(); + return Clutter.EVENT_STOP; + } else if (this._shouldTriggerSearch(symbol)) { + this.startSearch(event); + } + return Clutter.EVENT_PROPAGATE; + } + + _searchCancelled() { + this._setSearchActive(false); + + // Leave the entry focused when it doesn't have any text; + // when replacing a selected search term, Clutter emits + // two 'text-changed' signals, one for deleting the previous + // text and one for the new one - the second one is handled + // incorrectly when we remove focus + // (https://bugzilla.gnome.org/show_bug.cgi?id=636341) */ + if (this._text.text !== '') + this.reset(); + } + + reset() { + // Don't drop the key focus on Clutter's side if anything but the + // overview has pushed a modal (e.g. system modals when activated using + // the overview). + if (Main.modalCount <= 1) + global.stage.set_key_focus(null); + + this._entry.text = ''; + + this._text.set_cursor_visible(true); + this._text.set_selection(0, 0); + } + + _onStageKeyFocusChanged() { + let focus = global.stage.get_key_focus(); + let appearFocused = this._entry.contains(focus) || + this._searchResults.contains(focus); + + this._text.set_cursor_visible(appearFocused); + + if (appearFocused) + this._entry.add_style_pseudo_class('focus'); + else + this._entry.remove_style_pseudo_class('focus'); + } + + _onMapped() { + if (this._entry.mapped) { + // Enable 'find-as-you-type' + this._capturedEventId = + global.stage.connect('captured-event', this._onCapturedEvent.bind(this)); + this._text.set_cursor_visible(true); + this._text.set_selection(0, 0); + } else { + // Disable 'find-as-you-type' + if (this._capturedEventId > 0) + global.stage.disconnect(this._capturedEventId); + this._capturedEventId = 0; + } + } + + _shouldTriggerSearch(symbol) { + if (symbol === Clutter.KEY_Multi_key) + return true; + + if (symbol === Clutter.KEY_BackSpace && this._searchActive) + return true; + + let unicode = Clutter.keysym_to_unicode(symbol); + if (unicode === 0) + return false; + + if (getTermsForSearchString(String.fromCharCode(unicode)).length > 0) + return true; + + return false; + } + + startSearch(event) { + global.stage.set_key_focus(this._text); + this._text.event(event, false); + } + + // the entry does not show the hint + _isActivated() { + return this._text.text === this._entry.get_text(); + } + + _onTextChanged() { + let terms = getTermsForSearchString(this._entry.get_text()); + + const searchActive = terms.length > 0; + this._searchResults.setTerms(terms); + + if (searchActive) { + this._setSearchActive(true); + + this._entry.set_secondary_icon(this._clearIcon); + + if (this._iconClickedId === 0) { + this._iconClickedId = + this._entry.connect('secondary-icon-clicked', this.reset.bind(this)); + } + } else { + if (this._iconClickedId > 0) { + this._entry.disconnect(this._iconClickedId); + this._iconClickedId = 0; + } + + this._entry.set_secondary_icon(null); + this._searchCancelled(); + } + } + + _onKeyPress(entry, event) { + let symbol = event.get_key_symbol(); + if (symbol === Clutter.KEY_Escape) { + if (this._isActivated()) { + this.reset(); + return Clutter.EVENT_STOP; + } + } else if (this._searchActive) { + let arrowNext, nextDirection; + if (entry.get_text_direction() === Clutter.TextDirection.RTL) { + arrowNext = Clutter.KEY_Left; + nextDirection = St.DirectionType.LEFT; + } else { + arrowNext = Clutter.KEY_Right; + nextDirection = St.DirectionType.RIGHT; + } + + if (symbol === Clutter.KEY_Tab) { + this._searchResults.navigateFocus(St.DirectionType.TAB_FORWARD); + return Clutter.EVENT_STOP; + } else if (symbol === Clutter.KEY_ISO_Left_Tab) { + this._focusTrap.can_focus = false; + this._searchResults.navigateFocus(St.DirectionType.TAB_BACKWARD); + this._focusTrap.can_focus = true; + return Clutter.EVENT_STOP; + } else if (symbol === Clutter.KEY_Down) { + this._searchResults.navigateFocus(St.DirectionType.DOWN); + return Clutter.EVENT_STOP; + } else if (symbol === arrowNext && this._text.position === -1) { + this._searchResults.navigateFocus(nextDirection); + return Clutter.EVENT_STOP; + } else if (symbol === Clutter.KEY_Return || symbol === Clutter.KEY_KP_Enter) { + this._searchResults.activateDefault(); + return Clutter.EVENT_STOP; + } + } + return Clutter.EVENT_PROPAGATE; + } + + _onCapturedEvent(actor, event) { + if (event.type() === Clutter.EventType.BUTTON_PRESS) { + const targetActor = global.stage.get_event_actor(event); + if (targetActor !== this._text && + this._text.has_key_focus() && + this._text.text === '' && + !this._text.has_preedit() && + !Main.layoutManager.keyboardBox.contains(targetActor)) { + // the user clicked outside after activating the entry, but + // with no search term entered and no keyboard button pressed + // - cancel the search + this.reset(); + } + } + + return Clutter.EVENT_PROPAGATE; + } + + get searchActive() { + return this._searchActive; + } +}); diff --git a/js/ui/sessionMode.js b/js/ui/sessionMode.js new file mode 100644 index 0000000..b38bcdf --- /dev/null +++ b/js/ui/sessionMode.js @@ -0,0 +1,206 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported SessionMode, listModes */ + +const GLib = imports.gi.GLib; +const Signals = imports.misc.signals; + +const FileUtils = imports.misc.fileUtils; +const Params = imports.misc.params; + +const Config = imports.misc.config; + +const DEFAULT_MODE = 'restrictive'; + +const USER_SESSION_COMPONENTS = [ + 'polkitAgent', 'telepathyClient', 'keyring', + 'autorunManager', 'automountManager', +]; + +if (Config.HAVE_NETWORKMANAGER) + USER_SESSION_COMPONENTS.push('networkAgent'); + +const _modes = { + 'restrictive': { + parentMode: null, + stylesheetName: 'gnome-shell.css', + themeResourceName: 'gnome-shell-theme.gresource', + hasOverview: false, + showCalendarEvents: false, + showWelcomeDialog: false, + allowSettings: false, + allowScreencast: false, + enabledExtensions: [], + hasRunDialog: false, + hasWorkspaces: false, + hasWindows: false, + hasNotifications: false, + hasWmMenus: false, + isLocked: false, + isGreeter: false, + isPrimary: false, + unlockDialog: null, + components: [], + panel: { + left: [], + center: [], + right: [], + }, + panelStyle: null, + }, + + 'gdm': { + hasNotifications: true, + isGreeter: true, + isPrimary: true, + unlockDialog: imports.gdm.loginDialog.LoginDialog, + components: Config.HAVE_NETWORKMANAGER + ? ['networkAgent', 'polkitAgent'] + : ['polkitAgent'], + panel: { + left: [], + center: ['dateMenu'], + right: ['dwellClick', 'a11y', 'keyboard', 'quickSettings'], + }, + panelStyle: 'login-screen', + }, + + 'unlock-dialog': { + isLocked: true, + unlockDialog: undefined, + components: ['polkitAgent', 'telepathyClient'], + panel: { + left: [], + center: [], + right: ['dwellClick', 'a11y', 'keyboard', 'quickSettings'], + }, + panelStyle: 'unlock-screen', + }, + + 'user': { + hasOverview: true, + showCalendarEvents: true, + showWelcomeDialog: true, + allowSettings: true, + allowScreencast: true, + hasRunDialog: true, + hasWorkspaces: true, + hasWindows: true, + hasWmMenus: true, + hasNotifications: true, + isLocked: false, + isPrimary: true, + unlockDialog: imports.ui.unlockDialog.UnlockDialog, + components: USER_SESSION_COMPONENTS, + panel: { + left: ['activities', 'appMenu'], + center: ['dateMenu'], + right: ['screenRecording', 'screenSharing', 'dwellClick', 'a11y', 'keyboard', 'quickSettings'], + }, + }, +}; + +function _loadMode(file, info) { + let name = info.get_name(); + let suffix = name.indexOf('.json'); + let modeName = suffix == -1 ? name : name.slice(name, suffix); + + if (Object.prototype.hasOwnProperty.call(_modes, modeName)) + return; + + let fileContent, success_, newMode; + try { + [success_, fileContent] = file.load_contents(null); + const decoder = new TextDecoder(); + newMode = JSON.parse(decoder.decode(fileContent)); + } catch (e) { + return; + } + + _modes[modeName] = {}; + const excludedProps = ['unlockDialog']; + for (let prop in _modes[DEFAULT_MODE]) { + if (newMode[prop] !== undefined && + !excludedProps.includes(prop)) + _modes[modeName][prop] = newMode[prop]; + } + _modes[modeName]['isPrimary'] = true; +} + +function _loadModes() { + FileUtils.collectFromDatadirs('modes', false, _loadMode); +} + +function listModes() { + _loadModes(); + let loop = new GLib.MainLoop(null, false); + let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + let names = Object.getOwnPropertyNames(_modes); + for (let i = 0; i < names.length; i++) { + if (_modes[names[i]].isPrimary) + print(names[i]); + } + loop.quit(); + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] listModes'); + loop.run(); +} + +var SessionMode = class extends Signals.EventEmitter { + constructor() { + super(); + + _loadModes(); + let isPrimary = _modes[global.session_mode] && + _modes[global.session_mode].isPrimary; + let mode = isPrimary ? global.session_mode : 'user'; + this._modeStack = [mode]; + this._sync(); + } + + pushMode(mode) { + console.debug(`sessionMode: Pushing mode ${mode}`); + this._modeStack.push(mode); + this._sync(); + } + + popMode(mode) { + if (this.currentMode != mode || this._modeStack.length === 1) + throw new Error("Invalid SessionMode.popMode"); + + console.debug(`sessionMode: Popping mode ${mode}`); + this._modeStack.pop(); + this._sync(); + } + + switchMode(to) { + if (this.currentMode == to) + return; + this._modeStack[this._modeStack.length - 1] = to; + this._sync(); + } + + get currentMode() { + return this._modeStack[this._modeStack.length - 1]; + } + + _sync() { + let params = _modes[this.currentMode]; + let defaults; + if (params.parentMode) { + defaults = Params.parse(_modes[params.parentMode], + _modes[DEFAULT_MODE]); + } else { + defaults = _modes[DEFAULT_MODE]; + } + params = Params.parse(params, defaults); + + // A simplified version of Lang.copyProperties, handles + // undefined as a special case for "no change / inherit from previous mode" + for (let prop in params) { + if (params[prop] !== undefined) + this[prop] = params[prop]; + } + + this.emit('updated'); + } +}; diff --git a/js/ui/shellDBus.js b/js/ui/shellDBus.js new file mode 100644 index 0000000..284d92b --- /dev/null +++ b/js/ui/shellDBus.js @@ -0,0 +1,540 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported GnomeShell, ScreenSaverDBus */ + +const { Gio, GLib, Meta, Shell } = imports.gi; + +const Config = imports.misc.config; +const ExtensionDownloader = imports.ui.extensionDownloader; +const ExtensionUtils = imports.misc.extensionUtils; +const Main = imports.ui.main; +const Screenshot = imports.ui.screenshot; + +const { loadInterfaceXML } = imports.misc.fileUtils; +const { DBusSenderChecker } = imports.misc.util; +const { ControlsState } = imports.ui.overviewControls; + +const GnomeShellIface = loadInterfaceXML('org.gnome.Shell'); +const ScreenSaverIface = loadInterfaceXML('org.gnome.ScreenSaver'); + +var GnomeShell = class { + constructor() { + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(GnomeShellIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell'); + + this._senderChecker = new DBusSenderChecker([ + 'org.gnome.Settings', + 'org.gnome.SettingsDaemon.MediaKeys', + ]); + + this._extensionsService = new GnomeShellExtensions(); + this._screenshotService = new Screenshot.ScreenshotService(); + + this._grabbedAccelerators = new Map(); + this._grabbers = new Map(); + + global.display.connect('accelerator-activated', + (display, action, device, timestamp) => { + this._emitAcceleratorActivated(action, device, timestamp); + }); + + this._cachedOverviewVisible = false; + Main.overview.connect('showing', + this._checkOverviewVisibleChanged.bind(this)); + Main.overview.connect('hidden', + this._checkOverviewVisibleChanged.bind(this)); + } + + /** + * Eval: + * @param {string} code: A string containing JavaScript code + * @returns {Array} + * + * This function executes arbitrary code in the main + * loop, and returns a boolean success and + * JSON representation of the object as a string. + * + * If evaluation completes without throwing an exception, + * then the return value will be [true, JSON.stringify(result)]. + * If evaluation fails, then the return value will be + * [false, JSON.stringify(exception)]; + * + */ + Eval(code) { + if (!global.context.unsafe_mode) + return [false, '']; + + let returnValue; + let success; + try { + returnValue = JSON.stringify(eval(code)); + // A hack; DBus doesn't have null/undefined + if (returnValue == undefined) + returnValue = ''; + success = true; + } catch (e) { + returnValue = `${e}`; + success = false; + } + return [success, returnValue]; + } + + /** + * Focus the overview's search entry + * + * @async + * @param {...any} params - method parameters + * @param {Gio.DBusMethodInvocation} invocation - the invocation + * @returns {void} + */ + async FocusSearchAsync(params, invocation) { + try { + await this._senderChecker.checkInvocation(invocation); + } catch (e) { + invocation.return_gerror(e); + return; + } + + Main.overview.focusSearch(); + invocation.return_value(null); + } + + /** + * Show OSD with the specified parameters + * + * @async + * @param {...any} params - method parameters + * @param {Gio.DBusMethodInvocation} invocation - the invocation + * @returns {void} + */ + async ShowOSDAsync([params], invocation) { + try { + await this._senderChecker.checkInvocation(invocation); + } catch (e) { + invocation.return_gerror(e); + return; + } + + for (let param in params) + params[param] = params[param].deepUnpack(); + + const { + connector, + label, + level, + max_level: maxLevel, + icon: serializedIcon, + } = params; + + let monitorIndex = -1; + if (connector) { + let monitorManager = Meta.MonitorManager.get(); + monitorIndex = monitorManager.get_monitor_for_connector(connector); + } + + let icon = null; + if (serializedIcon) + icon = Gio.Icon.new_for_string(serializedIcon); + + Main.osdWindowManager.show(monitorIndex, icon, label, level, maxLevel); + invocation.return_value(null); + } + + /** + * Focus specified app in the overview's app grid + * + * @async + * @param {string} id - an application ID + * @param {Gio.DBusMethodInvocation} invocation - the invocation + * @returns {void} + */ + async FocusAppAsync([id], invocation) { + try { + await this._senderChecker.checkInvocation(invocation); + } catch (e) { + invocation.return_gerror(e); + return; + } + + const appSys = Shell.AppSystem.get_default(); + if (appSys.lookup_app(id) === null) { + invocation.return_error_literal( + Gio.DBusError, + Gio.DBusError.FILE_NOT_FOUND, + `No application with ID ${id}`); + return; + } + + Main.overview.selectApp(id); + invocation.return_value(null); + } + + /** + * Show the overview's app grid + * + * @async + * @param {...any} params - method parameters + * @param {Gio.DBusMethodInvocation} invocation - the invocation + * @returns {void} + */ + async ShowApplicationsAsync(params, invocation) { + try { + await this._senderChecker.checkInvocation(invocation); + } catch (e) { + invocation.return_gerror(e); + return; + } + + Main.overview.show(ControlsState.APP_GRID); + invocation.return_value(null); + } + + async GrabAcceleratorAsync(params, invocation) { + try { + await this._senderChecker.checkInvocation(invocation); + } catch (e) { + invocation.return_gerror(e); + return; + } + + let [accel, modeFlags, grabFlags] = params; + let sender = invocation.get_sender(); + let bindingAction = this._grabAcceleratorForSender(accel, modeFlags, grabFlags, sender); + invocation.return_value(GLib.Variant.new('(u)', [bindingAction])); + } + + async GrabAcceleratorsAsync(params, invocation) { + try { + await this._senderChecker.checkInvocation(invocation); + } catch (e) { + invocation.return_gerror(e); + return; + } + + let [accels] = params; + let sender = invocation.get_sender(); + let bindingActions = []; + for (let i = 0; i < accels.length; i++) { + let [accel, modeFlags, grabFlags] = accels[i]; + bindingActions.push(this._grabAcceleratorForSender(accel, modeFlags, grabFlags, sender)); + } + invocation.return_value(GLib.Variant.new('(au)', [bindingActions])); + } + + async UngrabAcceleratorAsync(params, invocation) { + try { + await this._senderChecker.checkInvocation(invocation); + } catch (e) { + invocation.return_gerror(e); + return; + } + + let [action] = params; + let sender = invocation.get_sender(); + let ungrabSucceeded = this._ungrabAcceleratorForSender(action, sender); + + invocation.return_value(GLib.Variant.new('(b)', [ungrabSucceeded])); + } + + async UngrabAcceleratorsAsync(params, invocation) { + try { + await this._senderChecker.checkInvocation(invocation); + } catch (e) { + invocation.return_gerror(e); + return; + } + + let [actions] = params; + let sender = invocation.get_sender(); + let ungrabSucceeded = true; + + for (let i = 0; i < actions.length; i++) + ungrabSucceeded &= this._ungrabAcceleratorForSender(actions[i], sender); + + invocation.return_value(GLib.Variant.new('(b)', [ungrabSucceeded])); + } + + async ScreenTransitionAsync(params, invocation) { + try { + await this._senderChecker.checkInvocation(invocation); + } catch (e) { + invocation.return_gerror(e); + return; + } + + Main.layoutManager.screenTransition.run(); + + invocation.return_value(null); + } + + _emitAcceleratorActivated(action, device, timestamp) { + let destination = this._grabbedAccelerators.get(action); + if (!destination) + return; + + let connection = this._dbusImpl.get_connection(); + let info = this._dbusImpl.get_info(); + let params = { + 'timestamp': GLib.Variant.new('u', timestamp), + 'action-mode': GLib.Variant.new('u', Main.actionMode), + }; + + let deviceNode = device.get_device_node(); + if (deviceNode) + params['device-node'] = GLib.Variant.new('s', deviceNode); + + connection.emit_signal( + destination, + this._dbusImpl.get_object_path(), + info?.name ?? null, + 'AcceleratorActivated', + GLib.Variant.new('(ua{sv})', [action, params])); + } + + _grabAcceleratorForSender(accelerator, modeFlags, grabFlags, sender) { + let bindingAction = global.display.grab_accelerator(accelerator, grabFlags); + if (bindingAction == Meta.KeyBindingAction.NONE) + return Meta.KeyBindingAction.NONE; + + let bindingName = Meta.external_binding_name_for_action(bindingAction); + Main.wm.allowKeybinding(bindingName, modeFlags); + + this._grabbedAccelerators.set(bindingAction, sender); + + if (!this._grabbers.has(sender)) { + let id = Gio.bus_watch_name(Gio.BusType.SESSION, sender, 0, null, + this._onGrabberBusNameVanished.bind(this)); + this._grabbers.set(sender, id); + } + + return bindingAction; + } + + _ungrabAccelerator(action) { + let ungrabSucceeded = global.display.ungrab_accelerator(action); + if (ungrabSucceeded) + this._grabbedAccelerators.delete(action); + + return ungrabSucceeded; + } + + _ungrabAcceleratorForSender(action, sender) { + let grabbedBy = this._grabbedAccelerators.get(action); + if (sender != grabbedBy) + return false; + + return this._ungrabAccelerator(action); + } + + _onGrabberBusNameVanished(connection, name) { + let grabs = this._grabbedAccelerators.entries(); + for (let [action, sender] of grabs) { + if (sender == name) + this._ungrabAccelerator(action); + } + Gio.bus_unwatch_name(this._grabbers.get(name)); + this._grabbers.delete(name); + } + + async ShowMonitorLabelsAsync(params, invocation) { + try { + await this._senderChecker.checkInvocation(invocation); + } catch (e) { + invocation.return_gerror(e); + return; + } + + let sender = invocation.get_sender(); + let [dict] = params; + Main.osdMonitorLabeler.show(sender, dict); + invocation.return_value(null); + } + + async HideMonitorLabelsAsync(params, invocation) { + try { + await this._senderChecker.checkInvocation(invocation); + } catch (e) { + invocation.return_gerror(e); + return; + } + + let sender = invocation.get_sender(); + Main.osdMonitorLabeler.hide(sender); + invocation.return_value(null); + } + + _checkOverviewVisibleChanged() { + if (Main.overview.visible !== this._cachedOverviewVisible) { + this._cachedOverviewVisible = Main.overview.visible; + this._dbusImpl.emit_property_changed('OverviewActive', new GLib.Variant('b', this._cachedOverviewVisible)); + } + } + + get Mode() { + return global.session_mode; + } + + get OverviewActive() { + return this._cachedOverviewVisible; + } + + set OverviewActive(visible) { + if (visible) + Main.overview.show(); + else + Main.overview.hide(); + } + + get ShellVersion() { + return Config.PACKAGE_VERSION; + } +}; + +const GnomeShellExtensionsIface = loadInterfaceXML('org.gnome.Shell.Extensions'); + +var GnomeShellExtensions = class { + constructor() { + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(GnomeShellExtensionsIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell'); + + this._userExtensionsEnabled = this.UserExtensionsEnabled; + global.settings.connect('changed::disable-user-extensions', () => { + if (this._userExtensionsEnabled === this.UserExtensionsEnabled) + return; + + this._userExtensionsEnabled = this.UserExtensionsEnabled; + this._dbusImpl.emit_property_changed('UserExtensionsEnabled', + new GLib.Variant('b', this._userExtensionsEnabled)); + }); + + Main.extensionManager.connect('extension-state-changed', + this._extensionStateChanged.bind(this)); + } + + ListExtensions() { + let out = {}; + Main.extensionManager.getUuids().forEach(uuid => { + let dbusObj = this.GetExtensionInfo(uuid); + out[uuid] = dbusObj; + }); + return out; + } + + GetExtensionInfo(uuid) { + let extension = Main.extensionManager.lookup(uuid) || {}; + return ExtensionUtils.serializeExtension(extension); + } + + GetExtensionErrors(uuid) { + let extension = Main.extensionManager.lookup(uuid); + if (!extension) + return []; + + if (!extension.errors) + return []; + + return extension.errors; + } + + InstallRemoteExtensionAsync([uuid], invocation) { + return ExtensionDownloader.installExtension(uuid, invocation); + } + + UninstallExtension(uuid) { + return ExtensionDownloader.uninstallExtension(uuid); + } + + EnableExtension(uuid) { + return Main.extensionManager.enableExtension(uuid); + } + + DisableExtension(uuid) { + return Main.extensionManager.disableExtension(uuid); + } + + LaunchExtensionPrefs(uuid) { + this.OpenExtensionPrefs(uuid, '', {}); + } + + OpenExtensionPrefs(uuid, parentWindow, options) { + Main.extensionManager.openExtensionPrefs(uuid, parentWindow, options); + } + + ReloadExtensionAsync(params, invocation) { + invocation.return_error_literal( + Gio.DBusError, + Gio.DBusError.NOT_SUPPORTED, + 'ReloadExtension is deprecated and does not work'); + } + + CheckForUpdates() { + ExtensionDownloader.checkForUpdates(); + } + + get ShellVersion() { + return Config.PACKAGE_VERSION; + } + + get UserExtensionsEnabled() { + return !global.settings.get_boolean('disable-user-extensions'); + } + + set UserExtensionsEnabled(enable) { + global.settings.set_boolean('disable-user-extensions', !enable); + } + + _extensionStateChanged(_, newState) { + let state = ExtensionUtils.serializeExtension(newState); + this._dbusImpl.emit_signal('ExtensionStateChanged', + new GLib.Variant('(sa{sv})', [newState.uuid, state])); + + this._dbusImpl.emit_signal('ExtensionStatusChanged', + GLib.Variant.new('(sis)', [newState.uuid, newState.state, newState.error])); + } +}; + +var ScreenSaverDBus = class { + constructor(screenShield) { + this._screenShield = screenShield; + screenShield.connect('active-changed', shield => { + this._dbusImpl.emit_signal('ActiveChanged', GLib.Variant.new('(b)', [shield.active])); + }); + screenShield.connect('wake-up-screen', () => { + this._dbusImpl.emit_signal('WakeUpScreen', null); + }); + + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(ScreenSaverIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/gnome/ScreenSaver'); + + Gio.DBus.session.own_name('org.gnome.Shell.ScreenShield', + Gio.BusNameOwnerFlags.NONE, null, null); + } + + LockAsync(parameters, invocation) { + let tmpId = this._screenShield.connect('lock-screen-shown', () => { + this._screenShield.disconnect(tmpId); + + invocation.return_value(null); + }); + + this._screenShield.lock(true); + } + + SetActive(active) { + if (active) + this._screenShield.activate(true); + else + this._screenShield.deactivate(false); + } + + GetActive() { + return this._screenShield.active; + } + + GetActiveTime() { + let started = this._screenShield.activationTime; + if (started > 0) + return Math.floor((GLib.get_monotonic_time() - started) / 1000000); + else + return 0; + } +}; diff --git a/js/ui/shellEntry.js b/js/ui/shellEntry.js new file mode 100644 index 0000000..e6c1f37 --- /dev/null +++ b/js/ui/shellEntry.js @@ -0,0 +1,206 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported addContextMenu CapsLockWarning */ + +const { Clutter, GObject, Pango, Shell, St } = imports.gi; + +const BoxPointer = imports.ui.boxpointer; +const Main = imports.ui.main; +const Params = imports.misc.params; +const PopupMenu = imports.ui.popupMenu; + +var EntryMenu = class extends PopupMenu.PopupMenu { + constructor(entry) { + super(entry, 0, St.Side.TOP); + + this._entry = entry; + this._clipboard = St.Clipboard.get_default(); + + // Populate menu + let item; + item = new PopupMenu.PopupMenuItem(_("Copy")); + item.connect('activate', this._onCopyActivated.bind(this)); + this.addMenuItem(item); + this._copyItem = item; + + item = new PopupMenu.PopupMenuItem(_("Paste")); + item.connect('activate', this._onPasteActivated.bind(this)); + this.addMenuItem(item); + this._pasteItem = item; + + if (entry instanceof St.PasswordEntry) + this._makePasswordItem(); + + Main.uiGroup.add_actor(this.actor); + this.actor.hide(); + } + + _makePasswordItem() { + let item = new PopupMenu.PopupMenuItem(''); + item.connect('activate', this._onPasswordActivated.bind(this)); + this.addMenuItem(item); + this._passwordItem = item; + + this._entry.bind_property('show-peek-icon', + this._passwordItem, 'visible', + GObject.BindingFlags.SYNC_CREATE); + } + + open(animate) { + this._updatePasteItem(); + this._updateCopyItem(); + if (this._passwordItem) + this._updatePasswordItem(); + + super.open(animate); + this._entry.add_style_pseudo_class('focus'); + + let direction = St.DirectionType.TAB_FORWARD; + if (!this.actor.navigate_focus(null, direction, false)) + this.actor.grab_key_focus(); + } + + _updateCopyItem() { + let selection = this._entry.clutter_text.get_selection(); + this._copyItem.setSensitive(!this._entry.clutter_text.password_char && + selection && selection != ''); + } + + _updatePasteItem() { + this._clipboard.get_text(St.ClipboardType.CLIPBOARD, + (clipboard, text) => { + this._pasteItem.setSensitive(text && text != ''); + }); + } + + _updatePasswordItem() { + if (!this._entry.password_visible) + this._passwordItem.label.set_text(_("Show Text")); + else + this._passwordItem.label.set_text(_("Hide Text")); + } + + _onCopyActivated() { + let selection = this._entry.clutter_text.get_selection(); + this._clipboard.set_text(St.ClipboardType.CLIPBOARD, selection); + } + + _onPasteActivated() { + this._clipboard.get_text(St.ClipboardType.CLIPBOARD, + (clipboard, text) => { + if (!text) + return; + this._entry.clutter_text.delete_selection(); + let pos = this._entry.clutter_text.get_cursor_position(); + this._entry.clutter_text.insert_text(text, pos); + }); + } + + _onPasswordActivated() { + this._entry.password_visible = !this._entry.password_visible; + } +}; + +function _setMenuAlignment(entry, stageX) { + let [success, entryX] = entry.transform_stage_point(stageX, 0); + if (success) + entry.menu.setSourceAlignment(entryX / entry.width); +} + +function _onButtonPressEvent(actor, event, entry) { + if (entry.menu.isOpen) { + entry.menu.close(BoxPointer.PopupAnimation.FULL); + return Clutter.EVENT_STOP; + } else if (event.get_button() == 3) { + let [stageX] = event.get_coords(); + _setMenuAlignment(entry, stageX); + entry.menu.open(BoxPointer.PopupAnimation.FULL); + return Clutter.EVENT_STOP; + } + return Clutter.EVENT_PROPAGATE; +} + +function _onPopup(actor, entry) { + let cursorPosition = entry.clutter_text.get_cursor_position(); + let [success, textX, textY_, lineHeight_] = entry.clutter_text.position_to_coords(cursorPosition); + if (success) + entry.menu.setSourceAlignment(textX / entry.width); + entry.menu.open(BoxPointer.PopupAnimation.FULL); +} + +function addContextMenu(entry, params) { + if (entry.menu) + return; + + params = Params.parse(params, { actionMode: Shell.ActionMode.POPUP }); + + entry.menu = new EntryMenu(entry); + entry._menuManager = new PopupMenu.PopupMenuManager(entry, + { actionMode: params.actionMode }); + entry._menuManager.addMenu(entry.menu); + + // Add an event handler to both the entry and its clutter_text; the former + // so padding is included in the clickable area, the latter because the + // event processing of ClutterText prevents event-bubbling. + entry.clutter_text.connect('button-press-event', (actor, event) => { + _onButtonPressEvent(actor, event, entry); + }); + entry.connect('button-press-event', (actor, event) => { + _onButtonPressEvent(actor, event, entry); + }); + + entry.connect('popup-menu', actor => _onPopup(actor, entry)); + + entry.connect('destroy', () => { + entry.menu.destroy(); + entry.menu = null; + entry._menuManager = null; + }); +} + +var CapsLockWarning = GObject.registerClass( +class CapsLockWarning extends St.Label { + _init(params) { + let defaultParams = { style_class: 'caps-lock-warning-label' }; + super._init(Object.assign(defaultParams, params)); + + this.text = _('Caps lock is on.'); + + this.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this.clutter_text.line_wrap = true; + + let seat = Clutter.get_default_backend().get_default_seat(); + this._keymap = seat.get_keymap(); + + this.connect('notify::mapped', () => { + if (this.is_mapped()) { + this._keymap.connectObject( + 'state-changed', () => this._sync(true), this); + } else { + this._keymap.disconnectObject(this); + } + + this._sync(false); + }); + } + + _sync(animate) { + let capsLockOn = this._keymap.get_caps_lock_state(); + + this.remove_all_transitions(); + + const { naturalHeightSet } = this; + this.natural_height_set = false; + let [, height] = this.get_preferred_height(-1); + this.natural_height_set = naturalHeightSet; + + this.ease({ + height: capsLockOn ? height : 0, + opacity: capsLockOn ? 255 : 0, + duration: animate ? 200 : 0, + onComplete: () => { + if (capsLockOn) + this.height = -1; + }, + }); + } +}); diff --git a/js/ui/shellMountOperation.js b/js/ui/shellMountOperation.js new file mode 100644 index 0000000..b04156d --- /dev/null +++ b/js/ui/shellMountOperation.js @@ -0,0 +1,752 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported ShellMountOperation, GnomeShellMountOpHandler */ + +const { Clutter, Gio, GLib, GObject, Pango, Shell, St } = imports.gi; + +const Animation = imports.ui.animation; +const CheckBox = imports.ui.checkBox; +const Dialog = imports.ui.dialog; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; +const ModalDialog = imports.ui.modalDialog; +const Params = imports.misc.params; +const ShellEntry = imports.ui.shellEntry; + +const { loadInterfaceXML } = imports.misc.fileUtils; +const Util = imports.misc.util; + +var LIST_ITEM_ICON_SIZE = 48; +var WORK_SPINNER_ICON_SIZE = 16; + +const REMEMBER_MOUNT_PASSWORD_KEY = 'remember-mount-password'; + +/* ------ Common Utils ------- */ +function _setButtonsForChoices(dialog, oldChoices, choices) { + let buttons = []; + let buttonsChanged = oldChoices.length !== choices.length; + + for (let idx = 0; idx < choices.length; idx++) { + let button = idx; + + buttonsChanged ||= oldChoices[idx] !== choices[idx]; + + buttons.unshift({ + label: choices[idx], + action: () => dialog.emit('response', button), + }); + } + + if (buttonsChanged) + dialog.setButtons(buttons); +} + +function _setLabelsForMessage(content, message) { + let labels = message.split('\n'); + + content.title = labels.shift(); + content.description = labels.join('\n'); +} + +/* -------------------------------------------------------- */ + +var ShellMountOperation = class { + constructor(source, params) { + params = Params.parse(params, { existingDialog: null }); + + this._dialog = null; + this._existingDialog = params.existingDialog; + this._processesDialog = null; + + this.mountOp = new Shell.MountOperation(); + + this.mountOp.connect('ask-question', + this._onAskQuestion.bind(this)); + this.mountOp.connect('ask-password', + this._onAskPassword.bind(this)); + this.mountOp.connect('show-processes-2', + this._onShowProcesses2.bind(this)); + this.mountOp.connect('aborted', + this.close.bind(this)); + this.mountOp.connect('show-unmount-progress', + this._onShowUnmountProgress.bind(this)); + } + + _closeExistingDialog() { + if (!this._existingDialog) + return; + + this._existingDialog.close(); + this._existingDialog = null; + } + + _onAskQuestion(op, message, choices) { + this._closeExistingDialog(); + this._dialog = new ShellMountQuestionDialog(); + + this._dialog.connectObject('response', + (object, choice) => { + this.mountOp.set_choice(choice); + this.mountOp.reply(Gio.MountOperationResult.HANDLED); + + this.close(); + }, this); + + this._dialog.update(message, choices); + this._dialog.open(); + } + + _onAskPassword(op, message, defaultUser, defaultDomain, flags) { + if (this._existingDialog) { + this._dialog = this._existingDialog; + this._dialog.reaskPassword(); + } else { + this._dialog = new ShellMountPasswordDialog(message, flags); + } + + this._dialog.connectObject('response', + (object, choice, password, remember, hiddenVolume, systemVolume, pim) => { + if (choice == -1) { + this.mountOp.reply(Gio.MountOperationResult.ABORTED); + } else { + if (remember) + this.mountOp.set_password_save(Gio.PasswordSave.PERMANENTLY); + else + this.mountOp.set_password_save(Gio.PasswordSave.NEVER); + + this.mountOp.set_password(password); + this.mountOp.set_is_tcrypt_hidden_volume(hiddenVolume); + this.mountOp.set_is_tcrypt_system_volume(systemVolume); + this.mountOp.set_pim(pim); + this.mountOp.reply(Gio.MountOperationResult.HANDLED); + } + }, this); + this._dialog.open(); + } + + close(_op) { + this._closeExistingDialog(); + this._processesDialog = null; + + if (this._dialog) { + this._dialog.close(); + this._dialog = null; + } + + if (this._notifier) { + this._notifier.done(); + this._notifier = null; + } + } + + _onShowProcesses2(op) { + this._closeExistingDialog(); + + let processes = op.get_show_processes_pids(); + let choices = op.get_show_processes_choices(); + let message = op.get_show_processes_message(); + + if (!this._processesDialog) { + this._processesDialog = new ShellProcessesDialog(); + this._dialog = this._processesDialog; + + this._processesDialog.connectObject('response', + (object, choice) => { + if (choice == -1) { + this.mountOp.reply(Gio.MountOperationResult.ABORTED); + } else { + this.mountOp.set_choice(choice); + this.mountOp.reply(Gio.MountOperationResult.HANDLED); + } + + this.close(); + }, this); + this._processesDialog.open(); + } + + this._processesDialog.update(message, processes, choices); + } + + _onShowUnmountProgress(op, message, timeLeft, bytesLeft) { + if (!this._notifier) + this._notifier = new ShellUnmountNotifier(); + + if (bytesLeft == 0) + this._notifier.done(message); + else + this._notifier.show(message); + } + + borrowDialog() { + this._dialog?.disconnectObject(this); + return this._dialog; + } +}; + +var ShellUnmountNotifier = GObject.registerClass( +class ShellUnmountNotifier extends MessageTray.Source { + _init() { + super._init('', 'media-removable'); + + this._notification = null; + Main.messageTray.add(this); + } + + show(message) { + let [header, text] = message.split('\n', 2); + + if (!this._notification) { + this._notification = new MessageTray.Notification(this, header, text); + this._notification.setTransient(true); + this._notification.setUrgency(MessageTray.Urgency.CRITICAL); + } else { + this._notification.update(header, text); + } + + this.showNotification(this._notification); + } + + done(message) { + if (this._notification) { + this._notification.destroy(); + this._notification = null; + } + + if (message) { + let notification = new MessageTray.Notification(this, message, null); + notification.setTransient(true); + + this.showNotification(notification); + } + } +}); + +var ShellMountQuestionDialog = GObject.registerClass({ + Signals: { 'response': { param_types: [GObject.TYPE_INT] } }, +}, class ShellMountQuestionDialog extends ModalDialog.ModalDialog { + _init() { + super._init({ styleClass: 'mount-question-dialog' }); + + this._oldChoices = []; + + this._content = new Dialog.MessageDialogContent(); + this.contentLayout.add_child(this._content); + } + + vfunc_key_release_event(event) { + if (event.keyval === Clutter.KEY_Escape) { + this.emit('response', -1); + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_PROPAGATE; + } + + update(message, choices) { + _setLabelsForMessage(this._content, message); + _setButtonsForChoices(this, this._oldChoices, choices); + this._oldChoices = choices; + } +}); + +var ShellMountPasswordDialog = GObject.registerClass({ + Signals: { + 'response': { + param_types: [ + GObject.TYPE_INT, + GObject.TYPE_STRING, + GObject.TYPE_BOOLEAN, + GObject.TYPE_BOOLEAN, + GObject.TYPE_BOOLEAN, + GObject.TYPE_UINT, + ], + }, + }, +}, class ShellMountPasswordDialog extends ModalDialog.ModalDialog { + _init(message, flags) { + let strings = message.split('\n'); + let title = strings.shift() || null; + let description = strings.shift() || null; + super._init({ styleClass: 'prompt-dialog' }); + + let disksApp = Shell.AppSystem.get_default().lookup_app('org.gnome.DiskUtility.desktop'); + + let content = new Dialog.MessageDialogContent({ title, description }); + + let passwordGridLayout = new Clutter.GridLayout({ orientation: Clutter.Orientation.VERTICAL }); + let passwordGrid = new St.Widget({ + style_class: 'prompt-dialog-password-grid', + layout_manager: passwordGridLayout, + }); + passwordGridLayout.hookup_style(passwordGrid); + + let rtl = passwordGrid.get_text_direction() === Clutter.TextDirection.RTL; + let curGridRow = 0; + + if (flags & Gio.AskPasswordFlags.TCRYPT) { + this._hiddenVolume = new CheckBox.CheckBox(_("Hidden Volume")); + content.add_child(this._hiddenVolume); + + this._systemVolume = new CheckBox.CheckBox(_("Windows System Volume")); + content.add_child(this._systemVolume); + + this._keyfilesCheckbox = new CheckBox.CheckBox(_("Uses Keyfiles")); + this._keyfilesCheckbox.connect("clicked", this._onKeyfilesCheckboxClicked.bind(this)); + content.add_child(this._keyfilesCheckbox); + + this._keyfilesLabel = new St.Label({ visible: false }); + this._keyfilesLabel.clutter_text.set_markup( + /* Translators: %s is the Disks application */ + _('To unlock a volume that uses keyfiles, use the <i>%s</i> utility instead.') + .format(disksApp.get_name())); + this._keyfilesLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._keyfilesLabel.clutter_text.line_wrap = true; + content.add_child(this._keyfilesLabel); + + this._pimEntry = new St.PasswordEntry({ + style_class: 'prompt-dialog-password-entry', + hint_text: _('PIM Number'), + can_focus: true, + x_expand: true, + }); + this._pimEntry.clutter_text.connect('activate', this._onEntryActivate.bind(this)); + ShellEntry.addContextMenu(this._pimEntry); + + if (rtl) + passwordGridLayout.attach(this._pimEntry, 1, curGridRow, 1, 1); + else + passwordGridLayout.attach(this._pimEntry, 0, curGridRow, 1, 1); + curGridRow += 1; + } else { + this._hiddenVolume = null; + this._systemVolume = null; + this._pimEntry = null; + } + + this._passwordEntry = new St.PasswordEntry({ + style_class: 'prompt-dialog-password-entry', + hint_text: _('Password'), + can_focus: true, + x_expand: true, + }); + this._passwordEntry.clutter_text.connect('activate', this._onEntryActivate.bind(this)); + this.setInitialKeyFocus(this._passwordEntry); + ShellEntry.addContextMenu(this._passwordEntry); + + this._workSpinner = new Animation.Spinner(WORK_SPINNER_ICON_SIZE, { + animate: true, + }); + + if (rtl) { + passwordGridLayout.attach(this._workSpinner, 0, curGridRow, 1, 1); + passwordGridLayout.attach(this._passwordEntry, 1, curGridRow, 1, 1); + } else { + passwordGridLayout.attach(this._passwordEntry, 0, curGridRow, 1, 1); + passwordGridLayout.attach(this._workSpinner, 1, curGridRow, 1, 1); + } + curGridRow += 1; + + let warningBox = new St.BoxLayout({ vertical: true }); + + let capsLockWarning = new ShellEntry.CapsLockWarning(); + warningBox.add_child(capsLockWarning); + + this._errorMessageLabel = new St.Label({ + style_class: 'prompt-dialog-error-label', + opacity: 0, + }); + this._errorMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._errorMessageLabel.clutter_text.line_wrap = true; + warningBox.add_child(this._errorMessageLabel); + + passwordGridLayout.attach(warningBox, 0, curGridRow, 2, 1); + + content.add_child(passwordGrid); + + if (flags & Gio.AskPasswordFlags.SAVING_SUPPORTED) { + this._rememberChoice = new CheckBox.CheckBox(_("Remember Password")); + this._rememberChoice.checked = + global.settings.get_boolean(REMEMBER_MOUNT_PASSWORD_KEY); + content.add_child(this._rememberChoice); + } else { + this._rememberChoice = null; + } + + this.contentLayout.add_child(content); + + this._defaultButtons = [{ + label: _("Cancel"), + action: this._onCancelButton.bind(this), + key: Clutter.KEY_Escape, + }, { + label: _("Unlock"), + action: this._onUnlockButton.bind(this), + default: true, + }]; + + this._usesKeyfilesButtons = [{ + label: _("Cancel"), + action: this._onCancelButton.bind(this), + key: Clutter.KEY_Escape, + }, { + /* Translators: %s is the Disks application */ + label: _("Open %s").format(disksApp.get_name()), + action: this._onOpenDisksButton.bind(this), + default: true, + }]; + + this.setButtons(this._defaultButtons); + } + + reaskPassword() { + this._workSpinner.stop(); + this._passwordEntry.set_text(''); + this._errorMessageLabel.text = _('Sorry, that didn’t work. Please try again.'); + this._errorMessageLabel.opacity = 255; + + Util.wiggle(this._passwordEntry); + } + + _onCancelButton() { + this.emit('response', -1, '', false, false, false, 0); + } + + _onUnlockButton() { + this._onEntryActivate(); + } + + _onEntryActivate() { + let pim = 0; + if (this._pimEntry !== null) { + pim = this._pimEntry.get_text(); + + if (isNaN(pim)) { + this._pimEntry.set_text(''); + this._errorMessageLabel.text = _('The PIM must be a number or empty.'); + this._errorMessageLabel.opacity = 255; + return; + } + + this._errorMessageLabel.opacity = 0; + } + + global.settings.set_boolean(REMEMBER_MOUNT_PASSWORD_KEY, + this._rememberChoice && this._rememberChoice.checked); + + this._workSpinner.play(); + this.emit('response', 1, + this._passwordEntry.get_text(), + this._rememberChoice && + this._rememberChoice.checked, + this._hiddenVolume && + this._hiddenVolume.checked, + this._systemVolume && + this._systemVolume.checked, + parseInt(pim)); + } + + _onKeyfilesCheckboxClicked() { + let useKeyfiles = this._keyfilesCheckbox.checked; + this._passwordEntry.reactive = !useKeyfiles; + this._passwordEntry.can_focus = !useKeyfiles; + this._pimEntry.reactive = !useKeyfiles; + this._pimEntry.can_focus = !useKeyfiles; + this._rememberChoice.reactive = !useKeyfiles; + this._rememberChoice.can_focus = !useKeyfiles; + this._keyfilesLabel.visible = useKeyfiles; + this.setButtons(useKeyfiles ? this._usesKeyfilesButtons : this._defaultButtons); + } + + _onOpenDisksButton() { + let app = Shell.AppSystem.get_default().lookup_app('org.gnome.DiskUtility.desktop'); + if (app) { + app.activate(); + } else { + Main.notifyError( + /* Translators: %s is the Disks application */ + _("Unable to start %s").format(app.get_name()), + /* Translators: %s is the Disks application */ + _('Couldn’t find the %s application').format(app.get_name())); + } + this._onCancelButton(); + } +}); + +var ShellProcessesDialog = GObject.registerClass({ + Signals: { 'response': { param_types: [GObject.TYPE_INT] } }, +}, class ShellProcessesDialog extends ModalDialog.ModalDialog { + _init() { + super._init({ styleClass: 'processes-dialog' }); + + this._oldChoices = []; + + this._content = new Dialog.MessageDialogContent(); + this.contentLayout.add_child(this._content); + + this._applicationSection = new Dialog.ListSection(); + this._applicationSection.hide(); + this.contentLayout.add_child(this._applicationSection); + } + + vfunc_key_release_event(event) { + if (event.keyval === Clutter.KEY_Escape) { + this.emit('response', -1); + return Clutter.EVENT_STOP; + } + + return Clutter.EVENT_PROPAGATE; + } + + _setAppsForPids(pids) { + // remove all the items + this._applicationSection.list.destroy_all_children(); + + pids.forEach(pid => { + let tracker = Shell.WindowTracker.get_default(); + let app = tracker.get_app_from_pid(pid); + + if (!app) + return; + + let listItem = new Dialog.ListSectionItem({ + icon_actor: app.create_icon_texture(LIST_ITEM_ICON_SIZE), + title: app.get_name(), + }); + this._applicationSection.list.add_child(listItem); + }); + + this._applicationSection.visible = + this._applicationSection.list.get_n_children() > 0; + } + + update(message, processes, choices) { + this._setAppsForPids(processes); + _setLabelsForMessage(this._content, message); + _setButtonsForChoices(this, this._oldChoices, choices); + this._oldChoices = choices; + } +}); + +const GnomeShellMountOpIface = loadInterfaceXML('org.Gtk.MountOperationHandler'); + +var ShellMountOperationType = { + NONE: 0, + ASK_PASSWORD: 1, + ASK_QUESTION: 2, + SHOW_PROCESSES: 3, +}; + +var GnomeShellMountOpHandler = class { + constructor() { + this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(GnomeShellMountOpIface, this); + this._dbusImpl.export(Gio.DBus.session, '/org/gtk/MountOperationHandler'); + Gio.bus_own_name_on_connection(Gio.DBus.session, 'org.gtk.MountOperationHandler', + Gio.BusNameOwnerFlags.REPLACE, null, null); + + this._dialog = null; + + this._ensureEmptyRequest(); + } + + _ensureEmptyRequest() { + this._currentId = null; + this._currentInvocation = null; + this._currentType = ShellMountOperationType.NONE; + } + + _clearCurrentRequest(response, details) { + if (this._currentInvocation) { + this._currentInvocation.return_value( + GLib.Variant.new('(ua{sv})', [response, details])); + } + + this._ensureEmptyRequest(); + } + + _setCurrentRequest(invocation, id, type) { + let oldId = this._currentId; + let oldType = this._currentType; + let requestId = `${id}@${invocation.get_sender()}`; + + this._clearCurrentRequest(Gio.MountOperationResult.UNHANDLED, {}); + + this._currentInvocation = invocation; + this._currentId = requestId; + this._currentType = type; + + if (this._dialog && (oldId == requestId) && (oldType == type)) + return true; + + return false; + } + + _closeDialog() { + if (this._dialog) { + this._dialog.close(); + this._dialog = null; + } + } + + /** + * AskPassword: + * @param {Array} params + * {string} id: an opaque ID identifying the object for which + * the operation is requested + * {string} message: the message to display + * {string} icon_name: the name of an icon to display + * {string} default_user: the default username for display + * {string} default_domain: the default domain for display + * {Gio.AskPasswordFlags} flags: a set of GAskPasswordFlags + * {Gio.MountOperationResults} response: a GMountOperationResult + * {Object} response_details: a dictionary containing response details as + * entered by the user. The dictionary MAY contain the following + * properties: + * - "password" -> (s): a password to be used to complete the mount operation + * - "password_save" -> (u): a GPasswordSave + * @param {Gio.DBusMethodInvocation} invocation + * The ID must be unique in the context of the calling process. + * + * The dialog will stay visible until clients call the Close() method, or + * another dialog becomes visible. + * Calling AskPassword again for the same id will have the effect to clear + * the existing dialog and update it with a message indicating the previous + * attempt went wrong. + */ + AskPasswordAsync(params, invocation) { + let [id, message, iconName_, defaultUser_, defaultDomain_, flags] = params; + + if (this._setCurrentRequest(invocation, id, ShellMountOperationType.ASK_PASSWORD)) { + this._dialog.reaskPassword(); + return; + } + + this._closeDialog(); + + this._dialog = new ShellMountPasswordDialog(message, flags); + this._dialog.connect('response', + (object, choice, password, remember, hiddenVolume, systemVolume, pim) => { + let details = {}; + let response; + + if (choice == -1) { + response = Gio.MountOperationResult.ABORTED; + } else { + response = Gio.MountOperationResult.HANDLED; + + let passSave = remember ? Gio.PasswordSave.PERMANENTLY : Gio.PasswordSave.NEVER; + details['password_save'] = GLib.Variant.new('u', passSave); + details['password'] = GLib.Variant.new('s', password); + details['hidden_volume'] = GLib.Variant.new('b', hiddenVolume); + details['system_volume'] = GLib.Variant.new('b', systemVolume); + details['pim'] = GLib.Variant.new('u', pim); + } + + this._clearCurrentRequest(response, details); + }); + this._dialog.open(); + } + + /** + * AskQuestion: + * @param {Array} params - params + * {string} id: an opaque ID identifying the object for which + * the operation is requested + * The ID must be unique in the context of the calling process. + * {string} message: the message to display + * {string} icon_name: the name of an icon to display + * {string[]} choices: an array of choice strings + * @param {Gio.DBusMethodInvocation} invocation - invocation + * + * The dialog will stay visible until clients call the Close() method, or + * another dialog becomes visible. + * Calling AskQuestion again for the same id will have the effect to clear + * update the dialog with the new question. + */ + AskQuestionAsync(params, invocation) { + let [id, message, iconName_, choices] = params; + + if (this._setCurrentRequest(invocation, id, ShellMountOperationType.ASK_QUESTION)) { + this._dialog.update(message, choices); + return; + } + + this._closeDialog(); + + this._dialog = new ShellMountQuestionDialog(message); + this._dialog.connect('response', (object, choice) => { + let response; + let details = {}; + + if (choice == -1) { + response = Gio.MountOperationResult.ABORTED; + } else { + response = Gio.MountOperationResult.HANDLED; + details['choice'] = GLib.Variant.new('i', choice); + } + + this._clearCurrentRequest(response, details); + }); + + this._dialog.update(message, choices); + this._dialog.open(); + } + + /** + * ShowProcesses: + * @param {Array} params - params + * {string} id: an opaque ID identifying the object for which + * the operation is requested + * The ID must be unique in the context of the calling process. + * {string} message: the message to display + * {string} icon_name: the name of an icon to display + * {number[]} application_pids: the PIDs of the applications to display + * {string[]} choices: an array of choice strings + * @param {Gio.DBusMethodInvocation} invocation - invocation + * + * The dialog will stay visible until clients call the Close() method, or + * another dialog becomes visible. + * Calling ShowProcesses again for the same id will have the effect to clear + * the existing dialog and update it with the new message and the new list + * of processes. + */ + ShowProcessesAsync(params, invocation) { + let [id, message, iconName_, applicationPids, choices] = params; + + if (this._setCurrentRequest(invocation, id, ShellMountOperationType.SHOW_PROCESSES)) { + this._dialog.update(message, applicationPids, choices); + return; + } + + this._closeDialog(); + + this._dialog = new ShellProcessesDialog(); + this._dialog.connect('response', (object, choice) => { + let response; + let details = {}; + + if (choice == -1) { + response = Gio.MountOperationResult.ABORTED; + } else { + response = Gio.MountOperationResult.HANDLED; + details['choice'] = GLib.Variant.new('i', choice); + } + + this._clearCurrentRequest(response, details); + }); + + this._dialog.update(message, applicationPids, choices); + this._dialog.open(); + } + + /** + * Close: + * @param {Array} _params - params + * @param {Gio.DBusMethodInvocation} _invocation - invocation + * + * Closes a dialog previously opened by AskPassword, AskQuestion or ShowProcesses. + * If no dialog is open, does nothing. + */ + Close(_params, _invocation) { + this._clearCurrentRequest(Gio.MountOperationResult.UNHANDLED, {}); + this._closeDialog(); + } +}; diff --git a/js/ui/slider.js b/js/ui/slider.js new file mode 100644 index 0000000..849599d --- /dev/null +++ b/js/ui/slider.js @@ -0,0 +1,218 @@ +/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */ +/* exported Slider */ + +const { Atk, Clutter, GObject } = imports.gi; + +const BarLevel = imports.ui.barLevel; + +var SLIDER_SCROLL_STEP = 0.02; /* Slider scrolling step in % */ + +var Slider = GObject.registerClass({ + Signals: { + 'drag-begin': {}, + 'drag-end': {}, + }, +}, class Slider extends BarLevel.BarLevel { + _init(value) { + super._init({ + value, + style_class: 'slider', + can_focus: true, + reactive: true, + accessible_role: Atk.Role.SLIDER, + x_expand: true, + }); + + this._releaseId = 0; + this._dragging = false; + + this._customAccessible.connect('get-minimum-increment', this._getMinimumIncrement.bind(this)); + } + + vfunc_repaint() { + super.vfunc_repaint(); + + // Add handle + let cr = this.get_context(); + let themeNode = this.get_theme_node(); + let [width, height] = this.get_surface_size(); + const rtl = this.get_text_direction() === Clutter.TextDirection.RTL; + + let handleRadius = themeNode.get_length('-slider-handle-radius'); + + let handleBorderWidth = themeNode.get_length('-slider-handle-border-width'); + let [hasHandleColor, handleBorderColor] = + themeNode.lookup_color('-slider-handle-border-color', false); + + const ceiledHandleRadius = Math.ceil(handleRadius + handleBorderWidth); + const handleY = height / 2; + + let handleX = ceiledHandleRadius + + (width - 2 * ceiledHandleRadius) * this._value / this._maxValue; + if (rtl) + handleX = width - handleX; + + let color = themeNode.get_foreground_color(); + Clutter.cairo_set_source_color(cr, color); + cr.arc(handleX, handleY, handleRadius, 0, 2 * Math.PI); + cr.fillPreserve(); + if (hasHandleColor && handleBorderWidth) { + Clutter.cairo_set_source_color(cr, handleBorderColor); + cr.setLineWidth(handleBorderWidth); + cr.stroke(); + } + cr.$dispose(); + } + + vfunc_button_press_event() { + return this.startDragging(Clutter.get_current_event()); + } + + startDragging(event) { + if (this._dragging) + return Clutter.EVENT_PROPAGATE; + + this._dragging = true; + + let device = event.get_device(); + let sequence = event.get_event_sequence(); + + this._grab = global.stage.grab(this); + + this._grabbedDevice = device; + this._grabbedSequence = sequence; + + // We need to emit 'drag-begin' before moving the handle to make + // sure that no 'notify::value' signal is emitted before this one. + this.emit('drag-begin'); + + let absX, absY; + [absX, absY] = event.get_coords(); + this._moveHandle(absX, absY); + return Clutter.EVENT_STOP; + } + + _endDragging() { + if (this._dragging) { + if (this._releaseId) { + this.disconnect(this._releaseId); + this._releaseId = 0; + } + + if (this._grab) { + this._grab.dismiss(); + this._grab = null; + } + + this._grabbedSequence = null; + this._grabbedDevice = null; + this._dragging = false; + + this.emit('drag-end'); + } + return Clutter.EVENT_STOP; + } + + vfunc_button_release_event() { + if (this._dragging && !this._grabbedSequence) + return this._endDragging(); + + return Clutter.EVENT_PROPAGATE; + } + + vfunc_touch_event() { + let event = Clutter.get_current_event(); + let sequence = event.get_event_sequence(); + + if (!this._dragging && + event.type() == Clutter.EventType.TOUCH_BEGIN) { + this.startDragging(event); + return Clutter.EVENT_STOP; + } else if (this._grabbedSequence && + sequence.get_slot() === this._grabbedSequence.get_slot()) { + if (event.type() == Clutter.EventType.TOUCH_UPDATE) + return this._motionEvent(this, event); + else if (event.type() == Clutter.EventType.TOUCH_END) + return this._endDragging(); + } + + return Clutter.EVENT_PROPAGATE; + } + + scroll(event) { + let direction = event.get_scroll_direction(); + let delta; + + if (event.is_pointer_emulated()) + return Clutter.EVENT_PROPAGATE; + + if (direction == Clutter.ScrollDirection.DOWN) { + delta = -SLIDER_SCROLL_STEP; + } else if (direction == Clutter.ScrollDirection.UP) { + delta = SLIDER_SCROLL_STEP; + } else if (direction == Clutter.ScrollDirection.SMOOTH) { + let [, dy] = event.get_scroll_delta(); + // Even though the slider is horizontal, use dy to match + // the UP/DOWN above. + delta = -dy * SLIDER_SCROLL_STEP; + } + + this.value = Math.min(Math.max(0, this._value + delta), this._maxValue); + + return Clutter.EVENT_STOP; + } + + vfunc_scroll_event() { + return this.scroll(Clutter.get_current_event()); + } + + vfunc_motion_event() { + if (this._dragging && !this._grabbedSequence) + return this._motionEvent(this, Clutter.get_current_event()); + + return Clutter.EVENT_PROPAGATE; + } + + _motionEvent(actor, event) { + let absX, absY; + [absX, absY] = event.get_coords(); + this._moveHandle(absX, absY); + return Clutter.EVENT_STOP; + } + + vfunc_key_press_event(keyPressEvent) { + let key = keyPressEvent.keyval; + if (key == Clutter.KEY_Right || key == Clutter.KEY_Left) { + let delta = key == Clutter.KEY_Right ? 0.1 : -0.1; + this.value = Math.max(0, Math.min(this._value + delta, this._maxValue)); + return Clutter.EVENT_STOP; + } + return super.vfunc_key_press_event(keyPressEvent); + } + + _moveHandle(absX, _absY) { + let relX, sliderX; + [sliderX] = this.get_transformed_position(); + const rtl = this.get_text_direction() === Clutter.TextDirection.RTL; + let width = this._barLevelWidth; + + relX = absX - sliderX; + if (rtl) + relX = width - relX; + + let handleRadius = this.get_theme_node().get_length('-slider-handle-radius'); + + let newvalue; + if (relX < handleRadius) + newvalue = 0; + else if (relX > width - handleRadius) + newvalue = 1; + else + newvalue = (relX - handleRadius) / (width - 2 * handleRadius); + this.value = newvalue * this._maxValue; + } + + _getMinimumIncrement() { + return 0.1; + } +}); diff --git a/js/ui/status/accessibility.js b/js/ui/status/accessibility.js new file mode 100644 index 0000000..a4bad14 --- /dev/null +++ b/js/ui/status/accessibility.js @@ -0,0 +1,153 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported ATIndicator */ + +const { Gio, GLib, GObject, St } = imports.gi; + +const PanelMenu = imports.ui.panelMenu; +const PopupMenu = imports.ui.popupMenu; + +const A11Y_SCHEMA = 'org.gnome.desktop.a11y'; +const KEY_ALWAYS_SHOW = 'always-show-universal-access-status'; + +const A11Y_KEYBOARD_SCHEMA = 'org.gnome.desktop.a11y.keyboard'; +const KEY_STICKY_KEYS_ENABLED = 'stickykeys-enable'; +const KEY_BOUNCE_KEYS_ENABLED = 'bouncekeys-enable'; +const KEY_SLOW_KEYS_ENABLED = 'slowkeys-enable'; +const KEY_MOUSE_KEYS_ENABLED = 'mousekeys-enable'; + +const APPLICATIONS_SCHEMA = 'org.gnome.desktop.a11y.applications'; + +var DPI_FACTOR_LARGE = 1.25; + +const WM_SCHEMA = 'org.gnome.desktop.wm.preferences'; +const KEY_VISUAL_BELL = 'visual-bell'; + +const DESKTOP_INTERFACE_SCHEMA = 'org.gnome.desktop.interface'; +const KEY_TEXT_SCALING_FACTOR = 'text-scaling-factor'; + +const A11Y_INTERFACE_SCHEMA = 'org.gnome.desktop.a11y.interface'; +const KEY_HIGH_CONTRAST = 'high-contrast'; + +var ATIndicator = GObject.registerClass( +class ATIndicator extends PanelMenu.Button { + _init() { + super._init(0.5, _("Accessibility")); + + this.add_child(new St.Icon({ + style_class: 'system-status-icon', + icon_name: 'org.gnome.Settings-accessibility-symbolic', + })); + + this._a11ySettings = new Gio.Settings({ schema_id: A11Y_SCHEMA }); + this._a11ySettings.connect(`changed::${KEY_ALWAYS_SHOW}`, this._queueSyncMenuVisibility.bind(this)); + + let highContrast = this._buildItem(_('High Contrast'), A11Y_INTERFACE_SCHEMA, KEY_HIGH_CONTRAST); + this.menu.addMenuItem(highContrast); + + let magnifier = this._buildItem(_("Zoom"), APPLICATIONS_SCHEMA, + 'screen-magnifier-enabled'); + this.menu.addMenuItem(magnifier); + + let textZoom = this._buildFontItem(); + this.menu.addMenuItem(textZoom); + + let screenReader = this._buildItem(_("Screen Reader"), APPLICATIONS_SCHEMA, + 'screen-reader-enabled'); + this.menu.addMenuItem(screenReader); + + let screenKeyboard = this._buildItem(_("Screen Keyboard"), APPLICATIONS_SCHEMA, + 'screen-keyboard-enabled'); + this.menu.addMenuItem(screenKeyboard); + + let visualBell = this._buildItem(_("Visual Alerts"), WM_SCHEMA, KEY_VISUAL_BELL); + this.menu.addMenuItem(visualBell); + + let stickyKeys = this._buildItem(_("Sticky Keys"), A11Y_KEYBOARD_SCHEMA, KEY_STICKY_KEYS_ENABLED); + this.menu.addMenuItem(stickyKeys); + + let slowKeys = this._buildItem(_("Slow Keys"), A11Y_KEYBOARD_SCHEMA, KEY_SLOW_KEYS_ENABLED); + this.menu.addMenuItem(slowKeys); + + let bounceKeys = this._buildItem(_("Bounce Keys"), A11Y_KEYBOARD_SCHEMA, KEY_BOUNCE_KEYS_ENABLED); + this.menu.addMenuItem(bounceKeys); + + let mouseKeys = this._buildItem(_("Mouse Keys"), A11Y_KEYBOARD_SCHEMA, KEY_MOUSE_KEYS_ENABLED); + this.menu.addMenuItem(mouseKeys); + + this._syncMenuVisibility(); + } + + _syncMenuVisibility() { + this._syncMenuVisibilityIdle = 0; + + let alwaysShow = this._a11ySettings.get_boolean(KEY_ALWAYS_SHOW); + let items = this.menu._getMenuItems(); + + this.visible = alwaysShow || items.some(f => !!f.state); + + return GLib.SOURCE_REMOVE; + } + + _queueSyncMenuVisibility() { + if (this._syncMenuVisibilityIdle) + return; + + this._syncMenuVisibilityIdle = GLib.idle_add(GLib.PRIORITY_DEFAULT, this._syncMenuVisibility.bind(this)); + GLib.Source.set_name_by_id(this._syncMenuVisibilityIdle, '[gnome-shell] this._syncMenuVisibility'); + } + + _buildItemExtended(string, initialValue, writable, onSet) { + let widget = new PopupMenu.PopupSwitchMenuItem(string, initialValue); + if (!writable) { + widget.reactive = false; + } else { + widget.connect('toggled', item => { + onSet(item.state); + }); + } + return widget; + } + + _buildItem(string, schema, key) { + let settings = new Gio.Settings({ schema_id: schema }); + let widget = this._buildItemExtended(string, + settings.get_boolean(key), + settings.is_writable(key), + enabled => settings.set_boolean(key, enabled)); + + settings.connect(`changed::${key}`, () => { + widget.setToggleState(settings.get_boolean(key)); + + this._queueSyncMenuVisibility(); + }); + + return widget; + } + + _buildFontItem() { + let settings = new Gio.Settings({ schema_id: DESKTOP_INTERFACE_SCHEMA }); + let factor = settings.get_double(KEY_TEXT_SCALING_FACTOR); + let initialSetting = factor > 1.0; + let widget = this._buildItemExtended(_("Large Text"), + initialSetting, + settings.is_writable(KEY_TEXT_SCALING_FACTOR), + enabled => { + if (enabled) { + settings.set_double( + KEY_TEXT_SCALING_FACTOR, DPI_FACTOR_LARGE); + } else { + settings.reset(KEY_TEXT_SCALING_FACTOR); + } + }); + + settings.connect(`changed::${KEY_TEXT_SCALING_FACTOR}`, () => { + factor = settings.get_double(KEY_TEXT_SCALING_FACTOR); + let active = factor > 1.0; + widget.setToggleState(active); + + this._queueSyncMenuVisibility(); + }); + + return widget; + } +}); diff --git a/js/ui/status/autoRotate.js b/js/ui/status/autoRotate.js new file mode 100644 index 0000000..bde3b80 --- /dev/null +++ b/js/ui/status/autoRotate.js @@ -0,0 +1,45 @@ +/* exported Indicator */ +const {Gio, GObject} = imports.gi; + +const SystemActions = imports.misc.systemActions; + +const {QuickToggle, SystemIndicator} = imports.ui.quickSettings; + +const RotationToggle = GObject.registerClass( +class RotationToggle extends QuickToggle { + _init() { + this._systemActions = new SystemActions.getDefault(); + + super._init({ + label: _('Auto Rotate'), + }); + + this._systemActions.bind_property('can-lock-orientation', + this, 'visible', + GObject.BindingFlags.DEFAULT | + GObject.BindingFlags.SYNC_CREATE); + this._systemActions.bind_property('orientation-lock-icon', + this, 'icon-name', + GObject.BindingFlags.DEFAULT | + GObject.BindingFlags.SYNC_CREATE); + + this._settings = new Gio.Settings({ + schema_id: 'org.gnome.settings-daemon.peripherals.touchscreen', + }); + this._settings.bind('orientation-lock', + this, 'checked', + Gio.SettingsBindFlags.INVERT_BOOLEAN); + + this.connect('clicked', + () => this._systemActions.activateLockOrientation()); + } +}); + +var Indicator = GObject.registerClass( +class Indicator extends SystemIndicator { + _init() { + super._init(); + + this.quickSettingsItems.push(new RotationToggle()); + } +}); diff --git a/js/ui/status/bluetooth.js b/js/ui/status/bluetooth.js new file mode 100644 index 0000000..bbff62d --- /dev/null +++ b/js/ui/status/bluetooth.js @@ -0,0 +1,211 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const {Gio, GLib, GnomeBluetooth, GObject} = imports.gi; + +const {QuickToggle, SystemIndicator} = imports.ui.quickSettings; + +const {loadInterfaceXML} = imports.misc.fileUtils; + +const {AdapterState} = GnomeBluetooth; + +const BUS_NAME = 'org.gnome.SettingsDaemon.Rfkill'; +const OBJECT_PATH = '/org/gnome/SettingsDaemon/Rfkill'; + +const RfkillManagerInterface = loadInterfaceXML('org.gnome.SettingsDaemon.Rfkill'); +const rfkillManagerInfo = Gio.DBusInterfaceInfo.new_for_xml(RfkillManagerInterface); + +const BtClient = GObject.registerClass({ + Properties: { + 'available': GObject.ParamSpec.boolean('available', '', '', + GObject.ParamFlags.READABLE, + false), + 'active': GObject.ParamSpec.boolean('active', '', '', + GObject.ParamFlags.READABLE, + false), + 'adapter-state': GObject.ParamSpec.enum('adapter-state', '', '', + GObject.ParamFlags.READABLE, + AdapterState, AdapterState.ABSENT), + }, + Signals: { + 'devices-changed': {}, + }, +}, class BtClient extends GObject.Object { + _init() { + super._init(); + + this._client = new GnomeBluetooth.Client(); + this._client.connect('notify::default-adapter-powered', () => { + this.notify('active'); + this.notify('available'); + }); + this._client.connect('notify::default-adapter-state', + () => this.notify('adapter-state')); + this._client.connect('notify::default-adapter', () => { + const newAdapter = this._client.default_adapter ?? null; + + this._adapter = newAdapter; + this._deviceNotifyConnected.clear(); + this.emit('devices-changed'); + + this.notify('active'); + this.notify('available'); + }); + + this._proxy = new Gio.DBusProxy({ + g_connection: Gio.DBus.session, + g_name: BUS_NAME, + g_object_path: OBJECT_PATH, + g_interface_name: rfkillManagerInfo.name, + g_interface_info: rfkillManagerInfo, + }); + this._proxy.connect('g-properties-changed', (p, properties) => { + const changedProperties = properties.unpack(); + if ('BluetoothHardwareAirplaneMode' in changedProperties) + this.notify('available'); + else if ('BluetoothHasAirplaneMode' in changedProperties) + this.notify('available'); + }); + this._proxy.init_async(GLib.PRIORITY_DEFAULT, null) + .catch(e => console.error(e.message)); + + this._adapter = null; + + this._deviceNotifyConnected = new Set(); + + const deviceStore = this._client.get_devices(); + for (let i = 0; i < deviceStore.get_n_items(); i++) + this._connectDeviceNotify(deviceStore.get_item(i)); + + this._client.connect('device-removed', (c, path) => { + this._deviceNotifyConnected.delete(path); + this.emit('devices-changed'); + }); + this._client.connect('device-added', (c, device) => { + this._connectDeviceNotify(device); + this.emit('devices-changed'); + }); + } + + get available() { + // If we have an rfkill switch, make sure it's not a hardware + // one as we can't get out of it in software + return this._proxy.BluetoothHasAirplaneMode + ? !this._proxy.BluetoothHardwareAirplaneMode + : this.active; + } + + get active() { + return this._client.default_adapter_powered; + } + + get adapter_state() { + return this._client.default_adapter_state; + } + + toggleActive() { + this._proxy.BluetoothAirplaneMode = this.active; + if (!this._client.default_adapter_powered) + this._client.default_adapter_powered = true; + } + + *getDevices() { + const deviceStore = this._client.get_devices(); + + for (let i = 0; i < deviceStore.get_n_items(); i++) { + const device = deviceStore.get_item(i); + + if (device.paired || device.trusted) + yield device; + } + } + + _queueDevicesChanged() { + if (this._devicesChangedId) + return; + this._devicesChangedId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + delete this._devicesChangedId; + this.emit('devices-changed'); + return GLib.SOURCE_REMOVE; + }); + } + + _connectDeviceNotify(device) { + const path = device.get_object_path(); + + if (this._deviceNotifyConnected.has(path)) + return; + + device.connect('notify::alias', () => this._queueDevicesChanged()); + device.connect('notify::paired', () => this._queueDevicesChanged()); + device.connect('notify::trusted', () => this._queueDevicesChanged()); + device.connect('notify::connected', () => this._queueDevicesChanged()); + + this._deviceNotifyConnected.add(path); + } +}); + +const BluetoothToggle = GObject.registerClass( +class BluetoothToggle extends QuickToggle { + _init(client) { + super._init({label: _('Bluetooth')}); + + this._client = client; + + this._client.bind_property('available', + this, 'visible', + GObject.BindingFlags.SYNC_CREATE); + this._client.bind_property('active', + this, 'checked', + GObject.BindingFlags.SYNC_CREATE); + this._client.bind_property_full('adapter-state', + this, 'icon-name', + GObject.BindingFlags.SYNC_CREATE, + (bind, source) => [true, this._getIconNameFromState(source)], + null); + + this.connect('clicked', () => this._client.toggleActive()); + } + + _getIconNameFromState(state) { + switch (state) { + case AdapterState.ON: + return 'bluetooth-active-symbolic'; + case AdapterState.OFF: + case AdapterState.ABSENT: + return 'bluetooth-disabled-symbolic'; + case AdapterState.TURNING_ON: + case AdapterState.TURNING_OFF: + return 'bluetooth-acquiring-symbolic'; + default: + console.warn(`Unexpected state ${ + GObject.enum_to_string(AdapterState, state)}`); + return ''; + } + } +}); + +var Indicator = GObject.registerClass( +class Indicator extends SystemIndicator { + _init() { + super._init(); + + this._client = new BtClient(); + this._client.connect('devices-changed', () => this._sync()); + + this._indicator = this._addIndicator(); + this._indicator.icon_name = 'bluetooth-active-symbolic'; + + this.quickSettingsItems.push(new BluetoothToggle(this._client)); + + this._sync(); + } + + _sync() { + const devices = [...this._client.getDevices()]; + const connectedDevices = devices.filter(dev => dev.connected); + const nConnectedDevices = connectedDevices.length; + + this._indicator.visible = nConnectedDevices > 0; + } +}); diff --git a/js/ui/status/brightness.js b/js/ui/status/brightness.js new file mode 100644 index 0000000..4c0da67 --- /dev/null +++ b/js/ui/status/brightness.js @@ -0,0 +1,64 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const {Gio, GObject} = imports.gi; + +const {QuickSlider, SystemIndicator} = imports.ui.quickSettings; + +const {loadInterfaceXML} = imports.misc.fileUtils; + +const BUS_NAME = 'org.gnome.SettingsDaemon.Power'; +const OBJECT_PATH = '/org/gnome/SettingsDaemon/Power'; + +const BrightnessInterface = loadInterfaceXML('org.gnome.SettingsDaemon.Power.Screen'); +const BrightnessProxy = Gio.DBusProxy.makeProxyWrapper(BrightnessInterface); + +const BrightnessItem = GObject.registerClass( +class BrightnessItem extends QuickSlider { + _init() { + super._init({ + iconName: 'display-brightness-symbolic', + }); + + this._proxy = new BrightnessProxy(Gio.DBus.session, BUS_NAME, OBJECT_PATH, + (proxy, error) => { + if (error) + console.error(error.message); + else + this._proxy.connect('g-properties-changed', () => this._sync()); + this._sync(); + }); + + this._sliderChangedId = this.slider.connect('notify::value', + this._sliderChanged.bind(this)); + this.slider.accessible_name = _('Brightness'); + } + + _sliderChanged() { + const percent = this.slider.value * 100; + this._proxy.Brightness = percent; + } + + _changeSlider(value) { + this.slider.block_signal_handler(this._sliderChangedId); + this.slider.value = value; + this.slider.unblock_signal_handler(this._sliderChangedId); + } + + _sync() { + const brightness = this._proxy.Brightness; + const visible = Number.isInteger(brightness) && brightness >= 0; + this.visible = visible; + if (visible) + this._changeSlider(this._proxy.Brightness / 100.0); + } +}); + +var Indicator = GObject.registerClass( +class Indicator extends SystemIndicator { + _init() { + super._init(); + + this.quickSettingsItems.push(new BrightnessItem()); + } +}); diff --git a/js/ui/status/darkMode.js b/js/ui/status/darkMode.js new file mode 100644 index 0000000..d1ec2bd --- /dev/null +++ b/js/ui/status/darkMode.js @@ -0,0 +1,49 @@ +/* exported Indicator */ +const {Gio, GObject} = imports.gi; + +const Main = imports.ui.main; +const {QuickToggle, SystemIndicator} = imports.ui.quickSettings; + +const DarkModeToggle = GObject.registerClass( +class DarkModeToggle extends QuickToggle { + _init() { + super._init({ + label: _('Dark Mode'), + iconName: 'dark-mode-symbolic', + }); + + this._settings = new Gio.Settings({ + schema_id: 'org.gnome.desktop.interface', + }); + this._changedId = this._settings.connect('changed::color-scheme', + () => this._sync()); + + this.connectObject( + 'destroy', () => this._settings.run_dispose(), + 'clicked', () => this._toggleMode(), + this); + this._sync(); + } + + _toggleMode() { + Main.layoutManager.screenTransition.run(); + this._settings.set_string('color-scheme', + this.checked ? 'default' : 'prefer-dark'); + } + + _sync() { + const colorScheme = this._settings.get_string('color-scheme'); + const checked = colorScheme === 'prefer-dark'; + if (this.checked !== checked) + this.set({checked}); + } +}); + +var Indicator = GObject.registerClass( +class Indicator extends SystemIndicator { + _init() { + super._init(); + + this.quickSettingsItems.push(new DarkModeToggle()); + } +}); diff --git a/js/ui/status/dwellClick.js b/js/ui/status/dwellClick.js new file mode 100644 index 0000000..82726e5 --- /dev/null +++ b/js/ui/status/dwellClick.js @@ -0,0 +1,83 @@ +/* exported DwellClickIndicator */ +const { Clutter, Gio, GLib, GObject, St } = imports.gi; + +const PanelMenu = imports.ui.panelMenu; + +const MOUSE_A11Y_SCHEMA = 'org.gnome.desktop.a11y.mouse'; +const KEY_DWELL_CLICK_ENABLED = 'dwell-click-enabled'; +const KEY_DWELL_MODE = 'dwell-mode'; +const DWELL_MODE_WINDOW = 'window'; +const DWELL_CLICK_MODES = { + primary: { + name: _("Single Click"), + icon: 'pointer-primary-click-symbolic', + type: Clutter.PointerA11yDwellClickType.PRIMARY, + }, + double: { + name: _("Double Click"), + icon: 'pointer-double-click-symbolic', + type: Clutter.PointerA11yDwellClickType.DOUBLE, + }, + drag: { + name: _("Drag"), + icon: 'pointer-drag-symbolic', + type: Clutter.PointerA11yDwellClickType.DRAG, + }, + secondary: { + name: _("Secondary Click"), + icon: 'pointer-secondary-click-symbolic', + type: Clutter.PointerA11yDwellClickType.SECONDARY, + }, +}; + +var DwellClickIndicator = GObject.registerClass( +class DwellClickIndicator extends PanelMenu.Button { + _init() { + super._init(0.5, _("Dwell Click")); + + this._icon = new St.Icon({ + style_class: 'system-status-icon', + icon_name: 'pointer-primary-click-symbolic', + }); + this.add_child(this._icon); + + this._a11ySettings = new Gio.Settings({ schema_id: MOUSE_A11Y_SCHEMA }); + this._a11ySettings.connect(`changed::${KEY_DWELL_CLICK_ENABLED}`, this._syncMenuVisibility.bind(this)); + this._a11ySettings.connect(`changed::${KEY_DWELL_MODE}`, this._syncMenuVisibility.bind(this)); + + this._seat = Clutter.get_default_backend().get_default_seat(); + this._seat.connect('ptr-a11y-dwell-click-type-changed', this._updateClickType.bind(this)); + + this._addDwellAction(DWELL_CLICK_MODES.primary); + this._addDwellAction(DWELL_CLICK_MODES.double); + this._addDwellAction(DWELL_CLICK_MODES.drag); + this._addDwellAction(DWELL_CLICK_MODES.secondary); + + this._setClickType(DWELL_CLICK_MODES.primary); + this._syncMenuVisibility(); + } + + _syncMenuVisibility() { + this.visible = + this._a11ySettings.get_boolean(KEY_DWELL_CLICK_ENABLED) && + this._a11ySettings.get_string(KEY_DWELL_MODE) == DWELL_MODE_WINDOW; + + return GLib.SOURCE_REMOVE; + } + + _addDwellAction(mode) { + this.menu.addAction(mode.name, this._setClickType.bind(this, mode), mode.icon); + } + + _updateClickType(manager, clickType) { + for (let mode in DWELL_CLICK_MODES) { + if (DWELL_CLICK_MODES[mode].type == clickType) + this._icon.icon_name = DWELL_CLICK_MODES[mode].icon; + } + } + + _setClickType(mode) { + this._seat.set_pointer_a11y_dwell_click_type(mode.type); + this._icon.icon_name = mode.icon; + } +}); diff --git a/js/ui/status/keyboard.js b/js/ui/status/keyboard.js new file mode 100644 index 0000000..b47375d --- /dev/null +++ b/js/ui/status/keyboard.js @@ -0,0 +1,1095 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported InputSourceIndicator */ + +const { Clutter, Gio, GLib, GObject, IBus, Meta, Shell, St } = imports.gi; +const Gettext = imports.gettext; +const Signals = imports.misc.signals; + +const IBusManager = imports.misc.ibusManager; +const KeyboardManager = imports.misc.keyboardManager; +const Main = imports.ui.main; +const PopupMenu = imports.ui.popupMenu; +const PanelMenu = imports.ui.panelMenu; +const SwitcherPopup = imports.ui.switcherPopup; +const Util = imports.misc.util; + +var INPUT_SOURCE_TYPE_XKB = 'xkb'; +var INPUT_SOURCE_TYPE_IBUS = 'ibus'; + +var LayoutMenuItem = GObject.registerClass( +class LayoutMenuItem extends PopupMenu.PopupBaseMenuItem { + _init(displayName, shortName) { + super._init(); + + this.label = new St.Label({ + text: displayName, + x_expand: true, + }); + this.indicator = new St.Label({ text: shortName }); + this.add_child(this.label); + this.add(this.indicator); + this.label_actor = this.label; + } +}); + +var InputSource = class extends Signals.EventEmitter { + constructor(type, id, displayName, shortName, index) { + super(); + + this.type = type; + this.id = id; + this.displayName = displayName; + this._shortName = shortName; + this.index = index; + + this.properties = null; + + this.xkbId = this._getXkbId(); + } + + get shortName() { + return this._shortName; + } + + set shortName(v) { + this._shortName = v; + this.emit('changed'); + } + + activate(interactive) { + this.emit('activate', !!interactive); + } + + _getXkbId() { + let engineDesc = IBusManager.getIBusManager().getEngineDesc(this.id); + if (!engineDesc) + return this.id; + + if (engineDesc.variant && engineDesc.variant.length > 0) + return `${engineDesc.layout}+${engineDesc.variant}`; + else + return engineDesc.layout; + } +}; + +var InputSourcePopup = GObject.registerClass( +class InputSourcePopup extends SwitcherPopup.SwitcherPopup { + _init(items, action, actionBackward) { + super._init(items); + + this._action = action; + this._actionBackward = actionBackward; + + this._switcherList = new InputSourceSwitcher(this._items); + } + + _keyPressHandler(keysym, action) { + if (action == this._action) + this._select(this._next()); + else if (action == this._actionBackward) + this._select(this._previous()); + else if (keysym == Clutter.KEY_Left) + this._select(this._previous()); + else if (keysym == Clutter.KEY_Right) + this._select(this._next()); + else + return Clutter.EVENT_PROPAGATE; + + return Clutter.EVENT_STOP; + } + + _finish() { + super._finish(); + + this._items[this._selectedIndex].activate(true); + } +}); + +var InputSourceSwitcher = GObject.registerClass( +class InputSourceSwitcher extends SwitcherPopup.SwitcherList { + _init(items) { + super._init(true); + + for (let i = 0; i < items.length; i++) + this._addIcon(items[i]); + } + + _addIcon(item) { + let box = new St.BoxLayout({ vertical: true }); + + let bin = new St.Bin({ style_class: 'input-source-switcher-symbol' }); + let symbol = new St.Label({ + text: item.shortName, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + bin.set_child(symbol); + box.add_child(bin); + + let text = new St.Label({ + text: item.displayName, + x_align: Clutter.ActorAlign.CENTER, + }); + box.add_child(text); + + this.addItem(box, text); + } +}); + +var InputSourceSettings = class extends Signals.EventEmitter { + constructor() { + super(); + + if (this.constructor === InputSourceSettings) + throw new TypeError(`Cannot instantiate abstract class ${this.constructor.name}`); + } + + _emitInputSourcesChanged() { + this.emit('input-sources-changed'); + } + + _emitKeyboardOptionsChanged() { + this.emit('keyboard-options-changed'); + } + + _emitPerWindowChanged() { + this.emit('per-window-changed'); + } + + get inputSources() { + return []; + } + + get mruSources() { + return []; + } + + set mruSources(sourcesList) { + // do nothing + } + + get keyboardOptions() { + return []; + } + + get perWindow() { + return false; + } +}; + +var InputSourceSystemSettings = class extends InputSourceSettings { + constructor() { + super(); + + this._BUS_NAME = 'org.freedesktop.locale1'; + this._BUS_PATH = '/org/freedesktop/locale1'; + this._BUS_IFACE = 'org.freedesktop.locale1'; + this._BUS_PROPS_IFACE = 'org.freedesktop.DBus.Properties'; + + this._layouts = ''; + this._variants = ''; + this._options = ''; + + this._reload(); + + Gio.DBus.system.signal_subscribe(this._BUS_NAME, + this._BUS_PROPS_IFACE, + 'PropertiesChanged', + this._BUS_PATH, + null, + Gio.DBusSignalFlags.NONE, + this._reload.bind(this)); + } + + async _reload() { + let props; + try { + const result = await Gio.DBus.system.call( + this._BUS_NAME, + this._BUS_PATH, + this._BUS_PROPS_IFACE, + 'GetAll', + new GLib.Variant('(s)', [this._BUS_IFACE]), + null, Gio.DBusCallFlags.NONE, -1, null); + [props] = result.deepUnpack(); + } catch (e) { + log(`Could not get properties from ${this._BUS_NAME}`); + return; + } + + const layouts = props['X11Layout'].unpack(); + const variants = props['X11Variant'].unpack(); + const options = props['X11Options'].unpack(); + + if (layouts !== this._layouts || + variants !== this._variants) { + this._layouts = layouts; + this._variants = variants; + this._emitInputSourcesChanged(); + } + if (options !== this._options) { + this._options = options; + this._emitKeyboardOptionsChanged(); + } + } + + get inputSources() { + let sourcesList = []; + let layouts = this._layouts.split(','); + let variants = this._variants.split(','); + + for (let i = 0; i < layouts.length && !!layouts[i]; i++) { + let id = layouts[i]; + if (variants[i]) + id += `+${variants[i]}`; + sourcesList.push({ type: INPUT_SOURCE_TYPE_XKB, id }); + } + return sourcesList; + } + + get keyboardOptions() { + return this._options.split(','); + } +}; + +var InputSourceSessionSettings = class extends InputSourceSettings { + constructor() { + super(); + + this._DESKTOP_INPUT_SOURCES_SCHEMA = 'org.gnome.desktop.input-sources'; + this._KEY_INPUT_SOURCES = 'sources'; + this._KEY_MRU_SOURCES = 'mru-sources'; + this._KEY_KEYBOARD_OPTIONS = 'xkb-options'; + this._KEY_PER_WINDOW = 'per-window'; + + this._settings = new Gio.Settings({ schema_id: this._DESKTOP_INPUT_SOURCES_SCHEMA }); + this._settings.connect(`changed::${this._KEY_INPUT_SOURCES}`, this._emitInputSourcesChanged.bind(this)); + this._settings.connect(`changed::${this._KEY_KEYBOARD_OPTIONS}`, this._emitKeyboardOptionsChanged.bind(this)); + this._settings.connect(`changed::${this._KEY_PER_WINDOW}`, this._emitPerWindowChanged.bind(this)); + } + + _getSourcesList(key) { + let sourcesList = []; + let sources = this._settings.get_value(key); + let nSources = sources.n_children(); + + for (let i = 0; i < nSources; i++) { + let [type, id] = sources.get_child_value(i).deepUnpack(); + sourcesList.push({ type, id }); + } + return sourcesList; + } + + get inputSources() { + return this._getSourcesList(this._KEY_INPUT_SOURCES); + } + + get mruSources() { + return this._getSourcesList(this._KEY_MRU_SOURCES); + } + + set mruSources(sourcesList) { + let sources = GLib.Variant.new('a(ss)', sourcesList); + this._settings.set_value(this._KEY_MRU_SOURCES, sources); + } + + get keyboardOptions() { + return this._settings.get_strv(this._KEY_KEYBOARD_OPTIONS); + } + + get perWindow() { + return this._settings.get_boolean(this._KEY_PER_WINDOW); + } +}; + +var InputSourceManager = class extends Signals.EventEmitter { + constructor() { + super(); + + // All valid input sources currently in the gsettings + // KEY_INPUT_SOURCES list indexed by their index there + this._inputSources = {}; + // All valid input sources currently in the gsettings + // KEY_INPUT_SOURCES list of type INPUT_SOURCE_TYPE_IBUS + // indexed by the IBus ID + this._ibusSources = {}; + + this._currentSource = null; + + // All valid input sources currently in the gsettings + // KEY_INPUT_SOURCES list ordered by most recently used + this._mruSources = []; + this._mruSourcesBackup = null; + this._keybindingAction = + Main.wm.addKeybinding('switch-input-source', + new Gio.Settings({ schema_id: "org.gnome.desktop.wm.keybindings" }), + Meta.KeyBindingFlags.NONE, + Shell.ActionMode.ALL, + this._switchInputSource.bind(this)); + this._keybindingActionBackward = + Main.wm.addKeybinding('switch-input-source-backward', + new Gio.Settings({ schema_id: "org.gnome.desktop.wm.keybindings" }), + Meta.KeyBindingFlags.IS_REVERSED, + Shell.ActionMode.ALL, + this._switchInputSource.bind(this)); + if (Main.sessionMode.isGreeter) + this._settings = new InputSourceSystemSettings(); + else + this._settings = new InputSourceSessionSettings(); + this._settings.connect('input-sources-changed', this._inputSourcesChanged.bind(this)); + this._settings.connect('keyboard-options-changed', this._keyboardOptionsChanged.bind(this)); + + this._xkbInfo = KeyboardManager.getXkbInfo(); + this._keyboardManager = KeyboardManager.getKeyboardManager(); + + this._ibusReady = false; + this._ibusManager = IBusManager.getIBusManager(); + this._ibusManager.connect('ready', this._ibusReadyCallback.bind(this)); + this._ibusManager.connect('properties-registered', this._ibusPropertiesRegistered.bind(this)); + this._ibusManager.connect('property-updated', this._ibusPropertyUpdated.bind(this)); + this._ibusManager.connect('set-content-type', this._ibusSetContentType.bind(this)); + + global.display.connect('modifiers-accelerator-activated', this._modifiersSwitcher.bind(this)); + + this._sourcesPerWindow = false; + this._focusWindowNotifyId = 0; + this._settings.connect('per-window-changed', this._sourcesPerWindowChanged.bind(this)); + this._sourcesPerWindowChanged(); + this._disableIBus = false; + this._reloading = false; + } + + reload() { + this._reloading = true; + this._keyboardManager.setKeyboardOptions(this._settings.keyboardOptions); + this._inputSourcesChanged(); + this._reloading = false; + } + + _ibusReadyCallback(im, ready) { + if (this._ibusReady == ready) + return; + + this._ibusReady = ready; + this._mruSources = []; + this._inputSourcesChanged(); + } + + _modifiersSwitcher() { + let sourceIndexes = Object.keys(this._inputSources); + if (sourceIndexes.length == 0) { + KeyboardManager.releaseKeyboard(); + return true; + } + + let is = this._currentSource; + if (!is) + is = this._inputSources[sourceIndexes[0]]; + + let nextIndex = is.index + 1; + if (nextIndex > sourceIndexes[sourceIndexes.length - 1]) + nextIndex = 0; + + while (!(is = this._inputSources[nextIndex])) + nextIndex += 1; + + is.activate(true); + return true; + } + + _switchInputSource(display, window, binding) { + if (this._mruSources.length < 2) + return; + + // HACK: Fall back on simple input source switching since we + // can't show a popup switcher while a GrabHelper grab is in + // effect without considerable work to consolidate the usage + // of pushModal/popModal and grabHelper. See + // https://bugzilla.gnome.org/show_bug.cgi?id=695143 . + if (Main.actionMode == Shell.ActionMode.POPUP) { + this._modifiersSwitcher(); + return; + } + + this._switcherPopup = new InputSourcePopup( + this._mruSources, this._keybindingAction, this._keybindingActionBackward); + this._switcherPopup.connect('destroy', () => { + this._switcherPopup = null; + }); + if (!this._switcherPopup.show( + binding.is_reversed(), binding.get_name(), binding.get_mask())) + this._switcherPopup.fadeAndDestroy(); + } + + _keyboardOptionsChanged() { + this._keyboardManager.setKeyboardOptions(this._settings.keyboardOptions); + this._keyboardManager.reapply(); + } + + _updateMruSettings() { + // If IBus is not ready we don't have a full picture of all + // the available sources, so don't update the setting + if (!this._ibusReady) + return; + + // If IBus is temporarily disabled, don't update the setting + if (this._disableIBus) + return; + + let sourcesList = []; + for (let i = 0; i < this._mruSources.length; ++i) { + let source = this._mruSources[i]; + sourcesList.push([source.type, source.id]); + } + + this._settings.mruSources = sourcesList; + } + + _currentInputSourceChanged(newSource) { + let oldSource; + [oldSource, this._currentSource] = [this._currentSource, newSource]; + + this.emit('current-source-changed', oldSource); + + for (let i = 1; i < this._mruSources.length; ++i) { + if (this._mruSources[i] == newSource) { + let currentSource = this._mruSources.splice(i, 1); + this._mruSources = currentSource.concat(this._mruSources); + break; + } + } + this._changePerWindowSource(); + } + + activateInputSource(is, interactive) { + // The focus changes during holdKeyboard/releaseKeyboard may trick + // the client into hiding UI containing the currently focused entry. + // So holdKeyboard/releaseKeyboard are not called when + // 'set-content-type' signal is received. + // E.g. Focusing on a password entry in a popup in Xorg Firefox + // will emit 'set-content-type' signal. + // https://gitlab.gnome.org/GNOME/gnome-shell/issues/391 + if (!this._reloading) + KeyboardManager.holdKeyboard(); + this._keyboardManager.apply(is.xkbId); + + // All the "xkb:..." IBus engines simply "echo" back symbols, + // despite their naming implying differently, so we always set + // one in order for XIM applications to work given that we set + // XMODIFIERS=@im=ibus in the first place so that they can + // work without restarting when/if the user adds an IBus input + // source. + let engine; + if (is.type == INPUT_SOURCE_TYPE_IBUS) + engine = is.id; + else + engine = 'xkb:us::eng'; + + if (!this._reloading) + this._ibusManager.setEngine(engine, KeyboardManager.releaseKeyboard); + else + this._ibusManager.setEngine(engine); + this._currentInputSourceChanged(is); + + if (interactive) + this._updateMruSettings(); + } + + _updateMruSources() { + let sourcesList = []; + for (let i of Object.keys(this._inputSources).sort((a, b) => a - b)) + sourcesList.push(this._inputSources[i]); + + this._keyboardManager.setUserLayouts(sourcesList.map(x => x.xkbId)); + + if (!this._disableIBus && this._mruSourcesBackup) { + this._mruSources = this._mruSourcesBackup; + this._mruSourcesBackup = null; + } + + // Initialize from settings when we have no MRU sources list + if (this._mruSources.length == 0) { + let mruSettings = this._settings.mruSources; + for (let i = 0; i < mruSettings.length; i++) { + let mruSettingSource = mruSettings[i]; + let mruSource = null; + + for (let j = 0; j < sourcesList.length; j++) { + let source = sourcesList[j]; + if (source.type == mruSettingSource.type && + source.id == mruSettingSource.id) { + mruSource = source; + break; + } + } + + if (mruSource) + this._mruSources.push(mruSource); + } + } + + let mruSources = []; + if (this._mruSources.length > 1) { + for (let i = 0; i < this._mruSources.length; i++) { + for (let j = 0; j < sourcesList.length; j++) { + if (this._mruSources[i].type === sourcesList[j].type && + this._mruSources[i].id === sourcesList[j].id) { + mruSources = mruSources.concat(sourcesList.splice(j, 1)); + break; + } + } + } + } + + this._mruSources = mruSources.concat(sourcesList); + } + + _inputSourcesChanged() { + let sources = this._settings.inputSources; + let nSources = sources.length; + + this._currentSource = null; + this._inputSources = {}; + this._ibusSources = {}; + + let infosList = []; + for (let i = 0; i < nSources; i++) { + let displayName; + let shortName; + let type = sources[i].type; + let id = sources[i].id; + let exists = false; + + if (type == INPUT_SOURCE_TYPE_XKB) { + [exists, displayName, shortName] = + this._xkbInfo.get_layout_info(id); + } else if (type == INPUT_SOURCE_TYPE_IBUS) { + if (this._disableIBus) + continue; + let engineDesc = this._ibusManager.getEngineDesc(id); + if (engineDesc) { + let language = IBus.get_language_name(engineDesc.get_language()); + let longName = engineDesc.get_longname(); + let textdomain = engineDesc.get_textdomain(); + if (textdomain != '') + longName = Gettext.dgettext(textdomain, longName); + exists = true; + displayName = `${language} (${longName})`; + shortName = this._makeEngineShortName(engineDesc); + } + } + + if (exists) + infosList.push({ type, id, displayName, shortName }); + } + + if (infosList.length == 0) { + let type = INPUT_SOURCE_TYPE_XKB; + let id = KeyboardManager.DEFAULT_LAYOUT; + let [, displayName, shortName] = this._xkbInfo.get_layout_info(id); + infosList.push({ type, id, displayName, shortName }); + } + + let inputSourcesByShortName = {}; + for (let i = 0; i < infosList.length; i++) { + let is = new InputSource(infosList[i].type, + infosList[i].id, + infosList[i].displayName, + infosList[i].shortName, + i); + is.connect('activate', this.activateInputSource.bind(this)); + + if (!(is.shortName in inputSourcesByShortName)) + inputSourcesByShortName[is.shortName] = []; + inputSourcesByShortName[is.shortName].push(is); + + this._inputSources[is.index] = is; + + if (is.type == INPUT_SOURCE_TYPE_IBUS) + this._ibusSources[is.id] = is; + } + + for (let i in this._inputSources) { + let is = this._inputSources[i]; + if (inputSourcesByShortName[is.shortName].length > 1) { + let sub = inputSourcesByShortName[is.shortName].indexOf(is) + 1; + is.shortName += String.fromCharCode(0x2080 + sub); + } + } + + this.emit('sources-changed'); + + this._updateMruSources(); + + if (this._mruSources.length > 0) + this._mruSources[0].activate(false); + + // All ibus engines are preloaded here to reduce the launching time + // when users switch the input sources. + this._ibusManager.preloadEngines(Object.keys(this._ibusSources)); + } + + _makeEngineShortName(engineDesc) { + let symbol = engineDesc.get_symbol(); + if (symbol && symbol[0]) + return symbol; + + let langCode = engineDesc.get_language().split('_', 1)[0]; + if (langCode.length == 2 || langCode.length == 3) + return langCode.toLowerCase(); + + return String.fromCharCode(0x2328); // keyboard glyph + } + + _ibusPropertiesRegistered(im, engineName, props) { + let source = this._ibusSources[engineName]; + if (!source) + return; + + source.properties = props; + + if (source == this._currentSource) + this.emit('current-source-changed', null); + } + + _ibusPropertyUpdated(im, engineName, prop) { + let source = this._ibusSources[engineName]; + if (!source) + return; + + if (this._updateSubProperty(source.properties, prop) && + source == this._currentSource) + this.emit('current-source-changed', null); + } + + _updateSubProperty(props, prop) { + if (!props) + return false; + + let p; + for (let i = 0; (p = props.get(i)) != null; ++i) { + if (p.get_key() == prop.get_key() && p.get_prop_type() == prop.get_prop_type()) { + p.update(prop); + return true; + } else if (p.get_prop_type() == IBus.PropType.MENU) { + if (this._updateSubProperty(p.get_sub_props(), prop)) + return true; + } + } + return false; + } + + _ibusSetContentType(im, purpose, _hints) { + // Avoid purpose changes while the switcher popup is shown, likely due to + // the focus change caused by the switcher popup causing this purpose change. + if (this._switcherPopup) + return; + if (purpose == IBus.InputPurpose.PASSWORD) { + if (Object.keys(this._inputSources).length == Object.keys(this._ibusSources).length) + return; + + if (this._disableIBus) + return; + this._disableIBus = true; + this._mruSourcesBackup = this._mruSources.slice(); + } else { + if (!this._disableIBus) + return; + this._disableIBus = false; + } + this.reload(); + } + + _getNewInputSource(current) { + let sourceIndexes = Object.keys(this._inputSources); + if (sourceIndexes.length == 0) + return null; + + if (current) { + for (let i in this._inputSources) { + let is = this._inputSources[i]; + if (is.type == current.type && + is.id == current.id) + return is; + } + } + + return this._inputSources[sourceIndexes[0]]; + } + + _getCurrentWindow() { + if (Main.overview.visible) + return Main.overview; + else + return global.display.focus_window; + } + + _setPerWindowInputSource() { + let window = this._getCurrentWindow(); + if (!window) + return; + + if (!window._inputSources || + window._inputSources !== this._inputSources) { + window._inputSources = this._inputSources; + window._currentSource = this._getNewInputSource(window._currentSource); + } + + if (window._currentSource) + window._currentSource.activate(false); + } + + _sourcesPerWindowChanged() { + this._sourcesPerWindow = this._settings.perWindow; + + if (this._sourcesPerWindow && this._focusWindowNotifyId == 0) { + this._focusWindowNotifyId = global.display.connect('notify::focus-window', + this._setPerWindowInputSource.bind(this)); + Main.overview.connectObject( + 'showing', this._setPerWindowInputSource.bind(this), + 'hidden', this._setPerWindowInputSource.bind(this), this); + } else if (!this._sourcesPerWindow && this._focusWindowNotifyId != 0) { + global.display.disconnect(this._focusWindowNotifyId); + this._focusWindowNotifyId = 0; + Main.overview.disconnectObject(this); + + let windows = global.get_window_actors().map(w => w.meta_window); + for (let i = 0; i < windows.length; ++i) { + delete windows[i]._inputSources; + delete windows[i]._currentSource; + } + delete Main.overview._inputSources; + delete Main.overview._currentSource; + } + } + + _changePerWindowSource() { + if (!this._sourcesPerWindow) + return; + + let window = this._getCurrentWindow(); + if (!window) + return; + + window._inputSources = this._inputSources; + window._currentSource = this._currentSource; + } + + get currentSource() { + return this._currentSource; + } + + get inputSources() { + return this._inputSources; + } + + get keyboardManager() { + return this._keyboardManager; + } +}; + +let _inputSourceManager = null; + +function getInputSourceManager() { + if (_inputSourceManager == null) + _inputSourceManager = new InputSourceManager(); + return _inputSourceManager; +} + +var InputSourceIndicatorContainer = GObject.registerClass( +class InputSourceIndicatorContainer extends St.Widget { + vfunc_get_preferred_width(forHeight) { + // Here, and in vfunc_get_preferred_height, we need to query + // for the height of all children, but we ignore the results + // for those we don't actually display. + return this.get_children().reduce((maxWidth, child) => { + let width = child.get_preferred_width(forHeight); + return [ + Math.max(maxWidth[0], width[0]), + Math.max(maxWidth[1], width[1]), + ]; + }, [0, 0]); + } + + vfunc_get_preferred_height(forWidth) { + return this.get_children().reduce((maxHeight, child) => { + let height = child.get_preferred_height(forWidth); + return [ + Math.max(maxHeight[0], height[0]), + Math.max(maxHeight[1], height[1]), + ]; + }, [0, 0]); + } + + vfunc_allocate(box) { + this.set_allocation(box); + + // translate box to (0, 0) + box.x2 -= box.x1; + box.x1 = 0; + box.y2 -= box.y1; + box.y1 = 0; + + this.get_children().forEach(c => { + c.allocate_align_fill(box, 0.5, 0.5, false, false); + }); + } +}); + +var InputSourceIndicator = GObject.registerClass( +class InputSourceIndicator extends PanelMenu.Button { + _init() { + super._init(0.5, _("Keyboard")); + + this.connect('destroy', this._onDestroy.bind(this)); + + this._menuItems = {}; + this._indicatorLabels = {}; + + this._container = new InputSourceIndicatorContainer({ style_class: 'system-status-icon' }); + this.add_child(this._container); + + this._propSeparator = new PopupMenu.PopupSeparatorMenuItem(); + this.menu.addMenuItem(this._propSeparator); + this._propSection = new PopupMenu.PopupMenuSection(); + this.menu.addMenuItem(this._propSection); + this._propSection.actor.hide(); + + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + this._showLayoutItem = this.menu.addAction(_("Show Keyboard Layout"), this._showLayout.bind(this)); + + Main.sessionMode.connect('updated', this._sessionUpdated.bind(this)); + this._sessionUpdated(); + + this._inputSourceManager = getInputSourceManager(); + this._inputSourceManager.connectObject( + 'sources-changed', this._sourcesChanged.bind(this), + 'current-source-changed', this._currentSourceChanged.bind(this), this); + this._inputSourceManager.reload(); + } + + _onDestroy() { + this._inputSourceManager = null; + } + + _sessionUpdated() { + // re-using "allowSettings" for the keyboard layout is a bit shady, + // but at least for now it is used as "allow popping up windows + // from shell menus"; we can always add a separate sessionMode + // option if need arises. + this._showLayoutItem.visible = Main.sessionMode.allowSettings; + } + + _sourcesChanged() { + for (let i in this._menuItems) + this._menuItems[i].destroy(); + for (let i in this._indicatorLabels) + this._indicatorLabels[i].destroy(); + + this._menuItems = {}; + this._indicatorLabels = {}; + + let menuIndex = 0; + for (let i in this._inputSourceManager.inputSources) { + let is = this._inputSourceManager.inputSources[i]; + + let menuItem = new LayoutMenuItem(is.displayName, is.shortName); + menuItem.connect('activate', () => is.activate(true)); + + const indicatorLabel = new St.Label({ + text: is.shortName, + visible: false, + }); + + this._menuItems[i] = menuItem; + this._indicatorLabels[i] = indicatorLabel; + is.connect('changed', () => { + menuItem.indicator.set_text(is.shortName); + indicatorLabel.set_text(is.shortName); + }); + + this.menu.addMenuItem(menuItem, menuIndex++); + this._container.add_actor(indicatorLabel); + } + } + + _currentSourceChanged(manager, oldSource) { + let nVisibleSources = Object.keys(this._inputSourceManager.inputSources).length; + let newSource = this._inputSourceManager.currentSource; + + if (oldSource) { + this._menuItems[oldSource.index].setOrnament(PopupMenu.Ornament.NONE); + this._indicatorLabels[oldSource.index].hide(); + } + + if (!newSource || (nVisibleSources < 2 && !newSource.properties)) { + // This source index might be invalid if we weren't able + // to build a menu item for it, so we hide ourselves since + // we can't fix it here. *shrug* + + // We also hide if we have only one visible source unless + // it's an IBus source with properties. + this.menu.close(); + this.hide(); + return; + } + + this.show(); + + this._buildPropSection(newSource.properties); + + this._menuItems[newSource.index].setOrnament(PopupMenu.Ornament.DOT); + this._indicatorLabels[newSource.index].show(); + } + + _buildPropSection(properties) { + this._propSeparator.hide(); + this._propSection.actor.hide(); + this._propSection.removeAll(); + + this._buildPropSubMenu(this._propSection, properties); + + if (!this._propSection.isEmpty()) { + this._propSection.actor.show(); + this._propSeparator.show(); + } + } + + _buildPropSubMenu(menu, props) { + if (!props) + return; + + let ibusManager = IBusManager.getIBusManager(); + let radioGroup = []; + let p; + for (let i = 0; (p = props.get(i)) != null; ++i) { + let prop = p; + + if (!prop.get_visible()) + continue; + + if (prop.get_key() == 'InputMode') { + let text; + if (prop.get_symbol) + text = prop.get_symbol().get_text(); + else + text = prop.get_label().get_text(); + + let currentSource = this._inputSourceManager.currentSource; + if (currentSource) { + let indicatorLabel = this._indicatorLabels[currentSource.index]; + if (text && text.length > 0 && text.length < 3) + indicatorLabel.set_text(text); + } + } + + let item; + let type = prop.get_prop_type(); + switch (type) { + case IBus.PropType.MENU: + item = new PopupMenu.PopupSubMenuMenuItem(prop.get_label().get_text()); + this._buildPropSubMenu(item.menu, prop.get_sub_props()); + break; + + case IBus.PropType.RADIO: + item = new PopupMenu.PopupMenuItem(prop.get_label().get_text()); + item.prop = prop; + radioGroup.push(item); + item.radioGroup = radioGroup; + item.setOrnament(prop.get_state() == IBus.PropState.CHECKED + ? PopupMenu.Ornament.DOT : PopupMenu.Ornament.NONE); + item.connect('activate', () => { + if (item.prop.get_state() == IBus.PropState.CHECKED) + return; + + let group = item.radioGroup; + for (let j = 0; j < group.length; ++j) { + if (group[j] == item) { + item.setOrnament(PopupMenu.Ornament.DOT); + item.prop.set_state(IBus.PropState.CHECKED); + ibusManager.activateProperty(item.prop.get_key(), + IBus.PropState.CHECKED); + } else { + group[j].setOrnament(PopupMenu.Ornament.NONE); + group[j].prop.set_state(IBus.PropState.UNCHECKED); + ibusManager.activateProperty(group[j].prop.get_key(), + IBus.PropState.UNCHECKED); + } + } + }); + break; + + case IBus.PropType.TOGGLE: + item = new PopupMenu.PopupSwitchMenuItem(prop.get_label().get_text(), prop.get_state() == IBus.PropState.CHECKED); + item.prop = prop; + item.connect('toggled', () => { + if (item.state) { + item.prop.set_state(IBus.PropState.CHECKED); + ibusManager.activateProperty(item.prop.get_key(), + IBus.PropState.CHECKED); + } else { + item.prop.set_state(IBus.PropState.UNCHECKED); + ibusManager.activateProperty(item.prop.get_key(), + IBus.PropState.UNCHECKED); + } + }); + break; + + case IBus.PropType.NORMAL: + item = new PopupMenu.PopupMenuItem(prop.get_label().get_text()); + item.prop = prop; + item.connect('activate', () => { + ibusManager.activateProperty(item.prop.get_key(), + item.prop.get_state()); + }); + break; + + case IBus.PropType.SEPARATOR: + item = new PopupMenu.PopupSeparatorMenuItem(); + break; + + default: + log(`IBus property ${prop.get_key()} has invalid type ${type}`); + continue; + } + + item.setSensitive(prop.get_sensitive()); + menu.addMenuItem(item); + } + } + + _showLayout() { + Main.overview.hide(); + + let source = this._inputSourceManager.currentSource; + let xkbLayout = ''; + let xkbVariant = ''; + + if (source.type == INPUT_SOURCE_TYPE_XKB) { + [, , , xkbLayout, xkbVariant] = KeyboardManager.getXkbInfo().get_layout_info(source.id); + } else if (source.type == INPUT_SOURCE_TYPE_IBUS) { + let engineDesc = IBusManager.getIBusManager().getEngineDesc(source.id); + if (engineDesc) { + xkbLayout = engineDesc.get_layout(); + xkbVariant = engineDesc.get_layout_variant(); + } + + // The `default` layout from ibus engine means to + // use the current keyboard layout. + if (xkbLayout === 'default') { + const current = this._inputSourceManager.keyboardManager.currentLayout; + xkbLayout = current.layout; + xkbVariant = current.variant; + } + } + + if (!xkbLayout || xkbLayout.length == 0) + return; + + let description = xkbLayout; + if (xkbVariant.length > 0) + description = `${description}\t${xkbVariant}`; + + Util.spawn(['gkbd-keyboard-display', '-l', description]); + } +}); diff --git a/js/ui/status/location.js b/js/ui/status/location.js new file mode 100644 index 0000000..45f6b7a --- /dev/null +++ b/js/ui/status/location.js @@ -0,0 +1,371 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const { Clutter, Gio, GLib, GObject, Shell, St } = imports.gi; + +const Dialog = imports.ui.dialog; +const ModalDialog = imports.ui.modalDialog; +const PermissionStore = imports.misc.permissionStore; +const {SystemIndicator} = imports.ui.quickSettings; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +const LOCATION_SCHEMA = 'org.gnome.system.location'; +const MAX_ACCURACY_LEVEL = 'max-accuracy-level'; +const ENABLED = 'enabled'; + +const APP_PERMISSIONS_TABLE = 'location'; +const APP_PERMISSIONS_ID = 'location'; + +var GeoclueAccuracyLevel = { + NONE: 0, + COUNTRY: 1, + CITY: 4, + NEIGHBORHOOD: 5, + STREET: 6, + EXACT: 8, +}; + +function accuracyLevelToString(accuracyLevel) { + for (let key in GeoclueAccuracyLevel) { + if (GeoclueAccuracyLevel[key] == accuracyLevel) + return key; + } + + return 'NONE'; +} + +var GeoclueIface = loadInterfaceXML('org.freedesktop.GeoClue2.Manager'); +const GeoclueManager = Gio.DBusProxy.makeProxyWrapper(GeoclueIface); + +var AgentIface = loadInterfaceXML('org.freedesktop.GeoClue2.Agent'); + +let _geoclueAgent = null; +function _getGeoclueAgent() { + if (_geoclueAgent === null) + _geoclueAgent = new GeoclueAgent(); + return _geoclueAgent; +} + +var GeoclueAgent = GObject.registerClass({ + Properties: { + 'enabled': GObject.ParamSpec.boolean( + 'enabled', 'Enabled', 'Enabled', + GObject.ParamFlags.READWRITE, + false), + 'in-use': GObject.ParamSpec.boolean( + 'in-use', 'In use', 'In use', + GObject.ParamFlags.READABLE, + false), + 'max-accuracy-level': GObject.ParamSpec.int( + 'max-accuracy-level', 'Max accuracy level', 'Max accuracy level', + GObject.ParamFlags.READABLE, + 0, 8, 0), + }, +}, class GeoclueAgent extends GObject.Object { + _init() { + super._init(); + + this._settings = new Gio.Settings({ schema_id: LOCATION_SCHEMA }); + this._settings.connectObject( + `changed::${ENABLED}`, () => this.notify('enabled'), + `changed::${MAX_ACCURACY_LEVEL}`, () => this._onMaxAccuracyLevelChanged(), + this); + + this._agent = Gio.DBusExportedObject.wrapJSObject(AgentIface, this); + this._agent.export(Gio.DBus.system, '/org/freedesktop/GeoClue2/Agent'); + + this.connect('notify::enabled', this._onMaxAccuracyLevelChanged.bind(this)); + + this._watchId = Gio.bus_watch_name(Gio.BusType.SYSTEM, + 'org.freedesktop.GeoClue2', + 0, + this._connectToGeoclue.bind(this), + this._onGeoclueVanished.bind(this)); + this._onMaxAccuracyLevelChanged(); + this._connectToGeoclue(); + this._connectToPermissionStore(); + } + + get enabled() { + return this._settings.get_boolean(ENABLED); + } + + set enabled(value) { + this._settings.set_boolean(ENABLED, value); + } + + get inUse() { + return this._managerProxy?.InUse ?? false; + } + + get maxAccuracyLevel() { + if (this.enabled) { + let level = this._settings.get_string(MAX_ACCURACY_LEVEL); + + return GeoclueAccuracyLevel[level.toUpperCase()] || + GeoclueAccuracyLevel.NONE; + } else { + return GeoclueAccuracyLevel.NONE; + } + } + + async AuthorizeAppAsync(params, invocation) { + let [desktopId, reqAccuracyLevel] = params; + + let authorizer = new AppAuthorizer(desktopId, + reqAccuracyLevel, this._permStoreProxy, this.maxAccuracyLevel); + + const accuracyLevel = await authorizer.authorize(); + const ret = accuracyLevel !== GeoclueAccuracyLevel.NONE; + invocation.return_value(GLib.Variant.new('(bu)', [ret, accuracyLevel])); + } + + get MaxAccuracyLevel() { + return this.maxAccuracyLevel; + } + + _connectToGeoclue() { + if (this._managerProxy != null || this._connecting) + return false; + + this._connecting = true; + new GeoclueManager(Gio.DBus.system, + 'org.freedesktop.GeoClue2', + '/org/freedesktop/GeoClue2/Manager', + this._onManagerProxyReady.bind(this)); + return true; + } + + async _onManagerProxyReady(proxy, error) { + if (error != null) { + log(error.message); + this._connecting = false; + return; + } + + this._managerProxy = proxy; + this._managerProxy.connectObject('g-properties-changed', + this._onGeocluePropsChanged.bind(this), this); + + this.notify('in-use'); + + try { + await this._managerProxy.AddAgentAsync('gnome-shell'); + this._connecting = false; + this._notifyMaxAccuracyLevel(); + } catch (e) { + log(e.message); + } + } + + _onGeoclueVanished() { + this._managerProxy?.disconnectObject(this); + this._managerProxy = null; + + this.notify('in-use'); + } + + _onMaxAccuracyLevelChanged() { + this.notify('max-accuracy-level'); + + // Gotta ensure geoclue is up and we are registered as agent to it + // before we emit the notify for this property change. + if (!this._connectToGeoclue()) + this._notifyMaxAccuracyLevel(); + } + + _notifyMaxAccuracyLevel() { + let variant = new GLib.Variant('u', this.maxAccuracyLevel); + this._agent.emit_property_changed('MaxAccuracyLevel', variant); + } + + _onGeocluePropsChanged(proxy, properties) { + const inUseChanged = !!properties.lookup_value('InUse', null); + if (inUseChanged) + this.notify('in-use'); + } + + _connectToPermissionStore() { + this._permStoreProxy = null; + new PermissionStore.PermissionStore(this._onPermStoreProxyReady.bind(this)); + } + + _onPermStoreProxyReady(proxy, error) { + if (error != null) { + log(error.message); + return; + } + + this._permStoreProxy = proxy; + } +}); + +var Indicator = GObject.registerClass( +class Indicator extends SystemIndicator { + _init() { + super._init(); + + this._agent = _getGeoclueAgent(); + + this._indicator = this._addIndicator(); + this._indicator.icon_name = 'location-services-active-symbolic'; + this._agent.bind_property('in-use', + this._indicator, + 'visible', + GObject.BindingFlags.SYNC_CREATE); + } +}); + +var AppAuthorizer = class { + constructor(desktopId, reqAccuracyLevel, permStoreProxy, maxAccuracyLevel) { + this.desktopId = desktopId; + this.reqAccuracyLevel = reqAccuracyLevel; + this._permStoreProxy = permStoreProxy; + this._maxAccuracyLevel = maxAccuracyLevel; + this._permissions = {}; + + this._accuracyLevel = GeoclueAccuracyLevel.NONE; + } + + async authorize() { + let appSystem = Shell.AppSystem.get_default(); + this._app = appSystem.lookup_app(`${this.desktopId}.desktop`); + if (this._app == null || this._permStoreProxy == null) + return this._completeAuth(); + + try { + [this._permissions] = await this._permStoreProxy.LookupAsync( + APP_PERMISSIONS_TABLE, + APP_PERMISSIONS_ID); + } catch (error) { + if (error.domain === Gio.DBusError) { + // Likely no xdg-app installed, just authorize the app + this._accuracyLevel = this.reqAccuracyLevel; + this._permStoreProxy = null; + return this._completeAuth(); + } else { + // Currently xdg-app throws an error if we lookup for + // unknown ID (which would be the case first time this code + // runs) so we continue with user authorization as normal + // and ID is added to the store if user says "yes". + log(error.message); + this._permissions = {}; + } + } + + let permission = this._permissions[this.desktopId]; + + if (permission == null) { + await this._userAuthorizeApp(); + } else { + let [levelStr] = permission || ['NONE']; + this._accuracyLevel = GeoclueAccuracyLevel[levelStr] || + GeoclueAccuracyLevel.NONE; + } + + return this._completeAuth(); + } + + _userAuthorizeApp() { + let name = this._app.get_name(); + let appInfo = this._app.get_app_info(); + let reason = appInfo.get_locale_string("X-Geoclue-Reason"); + + this._dialog = + new GeolocationDialog(name, reason, this.reqAccuracyLevel); + + return new Promise(resolve => { + const responseId = this._dialog.connect('response', + (dialog, level) => { + this._dialog.disconnect(responseId); + this._accuracyLevel = level; + resolve(); + }); + this._dialog.open(); + }); + } + + _completeAuth() { + if (this._accuracyLevel != GeoclueAccuracyLevel.NONE) { + this._accuracyLevel = Math.clamp(this._accuracyLevel, + 0, this._maxAccuracyLevel); + } + this._saveToPermissionStore(); + + return this._accuracyLevel; + } + + async _saveToPermissionStore() { + if (this._permStoreProxy == null) + return; + + let levelStr = accuracyLevelToString(this._accuracyLevel); + let dateStr = Math.round(Date.now() / 1000).toString(); + this._permissions[this.desktopId] = [levelStr, dateStr]; + + let data = GLib.Variant.new('av', {}); + + try { + await this._permStoreProxy.SetAsync( + APP_PERMISSIONS_TABLE, + true, + APP_PERMISSIONS_ID, + this._permissions, + data); + } catch (error) { + log(error.message); + } + } +}; + +var GeolocationDialog = GObject.registerClass({ + Signals: { 'response': { param_types: [GObject.TYPE_UINT] } }, +}, class GeolocationDialog extends ModalDialog.ModalDialog { + _init(name, reason, reqAccuracyLevel) { + super._init({ styleClass: 'geolocation-dialog' }); + this.reqAccuracyLevel = reqAccuracyLevel; + + let content = new Dialog.MessageDialogContent({ + title: _('Allow location access'), + /* Translators: %s is an application name */ + description: _('The app %s wants to access your location').format(name), + }); + + let reasonLabel = new St.Label({ + text: reason, + style_class: 'message-dialog-description', + }); + content.add_child(reasonLabel); + + let infoLabel = new St.Label({ + text: _('Location access can be changed at any time from the privacy settings.'), + style_class: 'message-dialog-description', + }); + content.add_child(infoLabel); + + this.contentLayout.add_child(content); + + const button = this.addButton({ + label: _('Deny Access'), + action: this._onDenyClicked.bind(this), + key: Clutter.KEY_Escape, + }); + this.addButton({ + label: _('Grant Access'), + action: this._onGrantClicked.bind(this), + }); + + this.setInitialKeyFocus(button); + } + + _onGrantClicked() { + this.emit('response', this.reqAccuracyLevel); + this.close(); + } + + _onDenyClicked() { + this.emit('response', GeoclueAccuracyLevel.NONE); + this.close(); + } +}); diff --git a/js/ui/status/network.js b/js/ui/status/network.js new file mode 100644 index 0000000..d9755a3 --- /dev/null +++ b/js/ui/status/network.js @@ -0,0 +1,2095 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ +const {Atk, Clutter, Gio, GLib, GObject, NM, Polkit, St} = imports.gi; + +const Main = imports.ui.main; +const PopupMenu = imports.ui.popupMenu; +const MessageTray = imports.ui.messageTray; +const ModemManager = imports.misc.modemManager; +const Util = imports.misc.util; + +const {Spinner} = imports.ui.animation; +const {QuickMenuToggle, SystemIndicator} = imports.ui.quickSettings; + +const {loadInterfaceXML} = imports.misc.fileUtils; +const {registerDestroyableType} = imports.misc.signalTracker; + +Gio._promisify(Gio.DBusConnection.prototype, 'call'); +Gio._promisify(NM.Client, 'new_async'); +Gio._promisify(NM.Client.prototype, 'check_connectivity_async'); +Gio._promisify(NM.DeviceWifi.prototype, 'request_scan_async'); + +const WIFI_SCAN_FREQUENCY = 15; +const MAX_VISIBLE_NETWORKS = 8; + +// small optimization, to avoid using [] all the time +const NM80211Mode = NM['80211Mode']; + +var PortalHelperResult = { + CANCELLED: 0, + COMPLETED: 1, + RECHECK: 2, +}; + +const PortalHelperIface = loadInterfaceXML('org.gnome.Shell.PortalHelper'); +const PortalHelperInfo = Gio.DBusInterfaceInfo.new_for_xml(PortalHelperIface); + +function signalToIcon(value) { + if (value < 20) + return 'none'; + else if (value < 40) + return 'weak'; + else if (value < 50) + return 'ok'; + else if (value < 80) + return 'good'; + else + return 'excellent'; +} + +function ssidToLabel(ssid) { + let label = NM.utils_ssid_to_utf8(ssid.get_data()); + if (!label) + label = _("<unknown>"); + return label; +} + +function launchSettingsPanel(panel, ...args) { + const param = new GLib.Variant('(sav)', + [panel, args.map(s => new GLib.Variant('s', s))]); + const platformData = { + 'desktop-startup-id': new GLib.Variant('s', + `_TIME${global.get_current_time()}`), + }; + try { + Gio.DBus.session.call( + 'org.gnome.Settings', + '/org/gnome/Settings', + 'org.freedesktop.Application', + 'ActivateAction', + new GLib.Variant('(sava{sv})', + ['launch-panel', [param], platformData]), + null, + Gio.DBusCallFlags.NONE, + -1, + null); + } catch (e) { + log(`Failed to launch Settings panel: ${e.message}`); + } +} + +class ItemSorter { + [Symbol.iterator] = this.items; + + /** + * Maintains a list of sorted items. By default, items are + * assumed to be objects with a name property. + * + * Optionally items can have a secondary sort order by + * recency. If used, items must by objects with a timestamp + * property that can be used in substraction, and "bigger" + * must mean "more recent". Number and Date both qualify. + * + * @param {object=} options - property object with options + * @param {Function} options.sortFunc - a custom sort function + * @param {bool} options.trackMru - whether to track MRU order as well + **/ + constructor(options = {}) { + const {sortFunc, trackMru} = { + sortFunc: this._sortByName.bind(this), + trackMru: false, + ...options, + }; + + this._trackMru = trackMru; + this._sortFunc = sortFunc; + this._sortFuncMru = this._sortByMru.bind(this); + + this._itemsOrder = []; + this._itemsMruOrder = []; + } + + *items() { + yield* this._itemsOrder; + } + + *itemsByMru() { + console.assert(this._trackMru, 'itemsByMru: MRU tracking is disabled'); + yield* this._itemsMruOrder; + } + + _sortByName(one, two) { + return GLib.utf8_collate(one.name, two.name); + } + + _sortByMru(one, two) { + return two.timestamp - one.timestamp; + } + + _upsert(array, item, sortFunc) { + this._delete(array, item); + return Util.insertSorted(array, item, sortFunc); + } + + _delete(array, item) { + const pos = array.indexOf(item); + if (pos >= 0) + array.splice(pos, 1); + } + + /** + * Insert or update item. + * + * @param {any} item - the item to upsert + * @returns {number} - the sorted position of item + */ + upsert(item) { + if (this._trackMru) + this._upsert(this._itemsMruOrder, item, this._sortFuncMru); + + return this._upsert(this._itemsOrder, item, this._sortFunc); + } + + /** + * @param {any} item - item to remove + */ + delete(item) { + if (this._trackMru) + this._delete(this._itemsMruOrder, item); + this._delete(this._itemsOrder, item); + } +} + +const NMMenuItem = GObject.registerClass({ + Properties: { + 'radio-mode': GObject.ParamSpec.boolean('radio-mode', '', '', + GObject.ParamFlags.READWRITE, + false), + 'is-active': GObject.ParamSpec.boolean('is-active', '', '', + GObject.ParamFlags.READABLE, + false), + 'name': GObject.ParamSpec.string('name', '', '', + GObject.ParamFlags.READWRITE, + ''), + 'icon-name': GObject.ParamSpec.string('icon-name', '', '', + GObject.ParamFlags.READWRITE, + ''), + }, +}, class NMMenuItem extends PopupMenu.PopupBaseMenuItem { + get state() { + return this._activeConnection?.state ?? + NM.ActiveConnectionState.DEACTIVATED; + } + + get is_active() { + return this.state <= NM.ActiveConnectionState.ACTIVATED; + } + + get timestamp() { + return 0; + } + + activate() { + super.activate(Clutter.get_current_event()); + } + + _activeConnectionStateChanged() { + this.notify('is-active'); + this.notify('icon-name'); + + this._sync(); + } + + _setActiveConnection(activeConnection) { + this._activeConnection?.disconnectObject(this); + + this._activeConnection = activeConnection; + + this._activeConnection?.connectObject( + 'notify::state', () => this._activeConnectionStateChanged(), + this); + this._activeConnectionStateChanged(); + } + + _sync() { + // Overridden by subclasses + } +}); + +/** + * Item that contains a section, and can be collapsed + * into a submenu + */ +const NMSectionItem = GObject.registerClass({ + Properties: { + 'use-submenu': GObject.ParamSpec.boolean('use-submenu', '', '', + GObject.ParamFlags.READWRITE, + false), + }, +}, class NMSectionItem extends NMMenuItem { + constructor() { + super({ + activate: false, + can_focus: false, + }); + + this._useSubmenu = false; + + // Turn into an empty container with no padding + this.styleClass = ''; + this.setOrnament(PopupMenu.Ornament.HIDDEN); + + // Add intermediate section; we need this for submenu support + this._mainSection = new PopupMenu.PopupMenuSection(); + this.add_child(this._mainSection.actor); + + this._submenuItem = new PopupMenu.PopupSubMenuMenuItem('', true); + this._mainSection.addMenuItem(this._submenuItem); + this._submenuItem.hide(); + + this.section = new PopupMenu.PopupMenuSection(); + this._mainSection.addMenuItem(this.section); + + // Represents the item as a whole when shown + this.bind_property('name', + this._submenuItem.label, 'text', + GObject.BindingFlags.DEFAULT); + this.bind_property('icon-name', + this._submenuItem.icon, 'icon-name', + GObject.BindingFlags.DEFAULT); + } + + _setParent(parent) { + super._setParent(parent); + this._mainSection._setParent(parent); + + parent?.connect('menu-closed', + () => this._mainSection.emit('menu-closed')); + } + + get use_submenu() { + return this._useSubmenu; + } + + set use_submenu(useSubmenu) { + if (this._useSubmenu === useSubmenu) + return; + + this._useSubmenu = useSubmenu; + this._submenuItem.visible = useSubmenu; + + if (useSubmenu) { + this._mainSection.box.remove_child(this.section.actor); + this._submenuItem.menu.box.add_child(this.section.actor); + } else { + this._submenuItem.menu.box.remove_child(this.section.actor); + this._mainSection.box.add_child(this.section.actor); + } + } +}); + +const NMConnectionItem = GObject.registerClass( +class NMConnectionItem extends NMMenuItem { + constructor(section, connection) { + super(); + + this._section = section; + this._connection = connection; + this._activeConnection = null; + + this._icon = new St.Icon({ + style_class: 'popup-menu-icon', + x_align: Clutter.ActorAlign.END, + visible: !this.radio_mode, + }); + this.add_child(this._icon); + + this._label = new St.Label({ + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + this.add_child(this._label); + this.label_actor = this._label; + + this.bind_property('icon-name', + this._icon, 'icon-name', + GObject.BindingFlags.DEFAULT); + this.bind_property('radio-mode', + this._icon, 'visible', + GObject.BindingFlags.INVERT_BOOLEAN); + + this.connectObject( + 'notify::radio-mode', () => this._sync(), + 'notify::name', () => this._sync(), + this); + this._sync(); + } + + get name() { + return this._connection.get_id(); + } + + get timestamp() { + return this._connection.get_setting_connection()?.get_timestamp() ?? 0; + } + + updateForConnection(connection) { + // connection should always be the same object + // (and object path) as this._connection, but + // this can be false if NetworkManager was restarted + // and picked up connections in a different order + // Just to be safe, we set it here again + + this._connection = connection; + this.notify('name'); + this._sync(); + } + + _updateOrnament() { + this.setOrnament(this.radio_mode && this.is_active + ? PopupMenu.Ornament.DOT : PopupMenu.Ornament.NONE); + } + + _getRegularLabel() { + return this.is_active + // Translators: %s is a device name like "MyPhone" + ? _('Disconnect %s').format(this.name) + // Translators: %s is a device name like "MyPhone" + : _('Connect to %s').format(this.name); + } + + _sync() { + if (this.radioMode) { + this._label.text = this.name; + this.accessible_role = Atk.Role.CHECK_MENU_ITEM; + } else { + this._label.text = this._getRegularLabel(); + this.accessible_role = Atk.Role.MENU_ITEM; + } + this._updateOrnament(); + } + + activate() { + super.activate(); + + if (this.radio_mode && this._activeConnection != null) + return; // only activate in radio mode + + if (this._activeConnection == null) + this._section.activateConnection(this._connection); + else + this._section.deactivateConnection(this._activeConnection); + + this._sync(); + } + + setActiveConnection(connection) { + this._setActiveConnection(connection); + } +}); + +const NMDeviceConnectionItem = GObject.registerClass({ + Properties: { + 'device-name': GObject.ParamSpec.string('device-name', '', '', + GObject.ParamFlags.READWRITE, + ''), + }, +}, class NMDeviceConnectionItem extends NMConnectionItem { + constructor(section, connection) { + super(section, connection); + + this.connectObject( + 'notify::radio-mode', () => this.notify('name'), + 'notify::device-name', () => this.notify('name'), + this); + } + + get name() { + return this.radioMode + ? this._connection.get_id() + : this.deviceName; + } +}); + +const NMDeviceItem = GObject.registerClass({ + Properties: { + 'single-device-mode': GObject.ParamSpec.boolean('single-device-mode', '', '', + GObject.ParamFlags.READWRITE, + false), + }, +}, class NMDeviceItem extends NMSectionItem { + constructor(client, device) { + super(); + + if (this.constructor === NMDeviceItem) + throw new TypeError(`Cannot instantiate abstract type ${this.constructor.name}`); + + this._client = client; + this._device = device; + this._deviceName = ''; + + this._connectionItems = new Map(); + this._itemSorter = new ItemSorter({trackMru: true}); + + // Item shown in the 0-connections case + this._autoConnectItem = + this.section.addAction(_('Connect'), () => this._autoConnect(), ''); + + // Represents the device as a whole when shown + this.bind_property('name', + this._autoConnectItem.label, 'text', + GObject.BindingFlags.SYNC_CREATE); + this.bind_property('icon-name', + this._autoConnectItem._icon, 'icon-name', + GObject.BindingFlags.SYNC_CREATE); + + this._deactivateItem = + this.section.addAction(_('Turn Off'), () => this.deactivateConnection()); + + this._client.connectObject( + 'notify::connectivity', () => this.notify('icon-name'), + 'notify::primary-connection', () => this.notify('icon-name'), + this); + + this._device.connectObject( + 'notify::available-connections', () => this._syncConnections(), + 'notify::active-connection', () => this._activeConnectionChanged(), + this); + + this.connect('notify::single-device-mode', () => this._sync()); + + this._syncConnections(); + this._activeConnectionChanged(); + } + + get timestamp() { + const [item] = this._itemSorter.itemsByMru(); + return item?.timestamp ?? 0; + } + + _canReachInternet() { + if (this._client.primary_connection !== this._device.active_connection) + return true; + + return this._client.connectivity === NM.ConnectivityState.FULL; + } + + _autoConnect() { + let connection = new NM.SimpleConnection(); + this._client.add_and_activate_connection_async(connection, this._device, null, null, null); + } + + _activeConnectionChanged() { + const oldItem = this._connectionItems.get( + this._activeConnection?.connection); + oldItem?.setActiveConnection(null); + + this._setActiveConnection(this._device.active_connection); + + const newItem = this._connectionItems.get( + this._activeConnection?.connection); + newItem?.setActiveConnection(this._activeConnection); + } + + _syncConnections() { + const available = this._device.get_available_connections(); + const removed = [...this._connectionItems.keys()] + .filter(conn => !available.includes(conn)); + + for (const conn of removed) + this._removeConnection(conn); + + for (const conn of available) + this._addConnection(conn); + } + + _getActivatableItem() { + const [lastUsed] = this._itemSorter.itemsByMru(); + if (lastUsed?.timestamp > 0) + return lastUsed; + + const [firstItem] = this._itemSorter; + if (firstItem) + return firstItem; + + console.assert(this._autoConnectItem.visible, + `${this}'s autoConnect item should be visible when otherwise empty`); + return this._autoConnectItem; + } + + activate() { + super.activate(); + + if (this._activeConnection) + this.deactivateConnection(); + else + this._getActivatableItem()?.activate(); + } + + activateConnection(connection) { + this._client.activate_connection_async(connection, this._device, null, null, null); + } + + deactivateConnection(_activeConnection) { + this._device.disconnect(null); + } + + _onConnectionChanged(connection) { + const item = this._connectionItems.get(connection); + item.updateForConnection(connection); + } + + _resortItem(item) { + const pos = this._itemSorter.upsert(item); + this.section.moveMenuItem(item, pos); + } + + _addConnection(connection) { + if (this._connectionItems.has(connection)) + return; + + connection.connectObject( + 'changed', this._onConnectionChanged.bind(this), + this); + + const item = new NMDeviceConnectionItem(this, connection); + + this.bind_property('radio-mode', + item, 'radio-mode', + GObject.BindingFlags.SYNC_CREATE); + this.bind_property('name', + item, 'device-name', + GObject.BindingFlags.SYNC_CREATE); + this.bind_property('icon-name', + item, 'icon-name', + GObject.BindingFlags.SYNC_CREATE); + item.connectObject( + 'notify::name', () => this._resortItem(item), + this); + + const pos = this._itemSorter.upsert(item); + this.section.addMenuItem(item, pos); + this._connectionItems.set(connection, item); + this._sync(); + } + + _removeConnection(connection) { + const item = this._connectionItems.get(connection); + if (!item) + return; + + this._itemSorter.delete(item); + this._connectionItems.delete(connection); + item.destroy(); + + this._sync(); + } + + setDeviceName(name) { + this._deviceName = name; + this.notify('name'); + } + + _sync() { + const nItems = this._connectionItems.size; + this.radio_mode = nItems > 1; + this.useSubmenu = this.radioMode && !this.singleDeviceMode; + this._autoConnectItem.visible = nItems === 0; + this._deactivateItem.visible = this.radioMode && this.isActive; + } +}); + +const NMWiredDeviceItem = GObject.registerClass( +class NMWiredDeviceItem extends NMDeviceItem { + get icon_name() { + switch (this.state) { + case NM.ActiveConnectionState.ACTIVATING: + return 'network-wired-acquiring-symbolic'; + case NM.ActiveConnectionState.ACTIVATED: + return this._canReachInternet() + ? 'network-wired-symbolic' + : 'network-wired-no-route-symbolic'; + default: + return 'network-wired-disconnected-symbolic'; + } + } + + get name() { + return this._deviceName; + } + + _hasCarrier() { + if (this._device instanceof NM.DeviceEthernet) + return this._device.carrier; + else + return true; + } + + _sync() { + this.visible = this._hasCarrier(); + super._sync(); + } +}); + +const NMModemDeviceItem = GObject.registerClass( +class NMModemDeviceItem extends NMDeviceItem { + constructor(client, device) { + super(client, device); + + this._mobileDevice = null; + + let capabilities = device.current_capabilities; + if (device.udi.indexOf('/org/freedesktop/ModemManager1/Modem') == 0) + this._mobileDevice = new ModemManager.BroadbandModem(device.udi, capabilities); + else if (capabilities & NM.DeviceModemCapabilities.GSM_UMTS) + this._mobileDevice = new ModemManager.ModemGsm(device.udi); + else if (capabilities & NM.DeviceModemCapabilities.CDMA_EVDO) + this._mobileDevice = new ModemManager.ModemCdma(device.udi); + else if (capabilities & NM.DeviceModemCapabilities.LTE) + this._mobileDevice = new ModemManager.ModemGsm(device.udi); + + this._mobileDevice?.connectObject( + 'notify::operator-name', this._sync.bind(this), + 'notify::signal-quality', () => this.notify('icon-name'), this); + + Main.sessionMode.connectObject('updated', + this._sessionUpdated.bind(this), this); + this._sessionUpdated(); + } + + get icon_name() { + switch (this.state) { + case NM.ActiveConnectionState.ACTIVATING: + return 'network-cellular-acquiring-symbolic'; + case NM.ActiveConnectionState.ACTIVATED: { + const qualityString = signalToIcon(this._mobileDevice.signal_quality); + return `network-cellular-signal-${qualityString}-symbolic`; + } + default: + return this._activeConnection + ? 'network-cellular-signal-none-symbolic' + : 'network-cellular-disabled-symbolic'; + } + } + + get name() { + return this._mobileDevice?.operator_name || this._deviceName; + } + + get wwanPanelSupported() { + // Currently, wwan panel doesn't support CDMA_EVDO modems + const supportedCaps = + NM.DeviceModemCapabilities.GSM_UMTS | + NM.DeviceModemCapabilities.LTE; + return this._device.current_capabilities & supportedCaps; + } + + _autoConnect() { + if (this.wwanPanelSupported) + launchSettingsPanel('wwan', 'show-device', this._device.udi); + else + launchSettingsPanel('network', 'connect-3g', this._device.get_path()); + } + + _sessionUpdated() { + this._autoConnectItem.sensitive = Main.sessionMode.hasWindows; + } +}); + +const NMBluetoothDeviceItem = GObject.registerClass( +class NMBluetoothDeviceItem extends NMDeviceItem { + constructor(client, device) { + super(client, device); + + this._device.bind_property('name', + this, 'name', + GObject.BindingFlags.SYNC_CREATE); + } + + get icon_name() { + switch (this.state) { + case NM.ActiveConnectionState.ACTIVATING: + return 'network-cellular-acquiring-symbolic'; + case NM.ActiveConnectionState.ACTIVATED: + return 'network-cellular-connected-symbolic'; + default: + return this._activeConnection + ? 'network-cellular-signal-none-symbolic' + : 'network-cellular-disabled-symbolic'; + } + } + + get name() { + return this._device.name; + } +}); + +const WirelessNetwork = GObject.registerClass({ + Properties: { + 'name': GObject.ParamSpec.string( + 'name', '', '', + GObject.ParamFlags.READABLE, + ''), + 'icon-name': GObject.ParamSpec.string( + 'icon-name', '', '', + GObject.ParamFlags.READABLE, + ''), + 'secure': GObject.ParamSpec.boolean( + 'secure', '', '', + GObject.ParamFlags.READABLE, + false), + 'is-active': GObject.ParamSpec.boolean( + 'is-active', '', '', + GObject.ParamFlags.READABLE, + false), + }, + Signals: { + 'destroy': {}, + }, +}, class WirelessNetwork extends GObject.Object { + static _securityTypes = + Object.values(NM.UtilsSecurityType).sort((a, b) => b - a); + + _init(device) { + super._init(); + + this._device = device; + + this._device.connectObject( + 'notify::active-access-point', () => this.notify('is-active'), + this); + + this._accessPoints = new Set(); + this._connections = []; + this._name = ''; + this._ssid = null; + this._bestAp = null; + this._mode = 0; + this._securityType = NM.UtilsSecurityType.NONE; + } + + get _strength() { + return this._bestAp?.strength ?? 0; + } + + get name() { + return this._name; + } + + get icon_name() { + if (this._mode === NM80211Mode.ADHOC) + return 'network-workgroup-symbolic'; + + if (!this._bestAp) + return ''; + + return `network-wireless-signal-${signalToIcon(this._bestAp.strength)}-symbolic`; + } + + get secure() { + return this._securityType !== NM.UtilsSecurityType.NONE; + } + + get is_active() { + return this._accessPoints.has(this._device.activeAccessPoint); + } + + hasAccessPoint(ap) { + return this._accessPoints.has(ap); + } + + hasAccessPoints() { + return this._accessPoints.size > 0; + } + + checkAccessPoint(ap) { + if (!ap.get_ssid()) + return false; + + const secType = this._getApSecurityType(ap); + if (secType === NM.UtilsSecurityType.INVALID) + return false; + + if (this._accessPoints.size === 0) + return true; + + return this._ssid.equal(ap.ssid) && + this._mode === ap.mode && + this._securityType === secType; + } + + /** + * @param {NM.AccessPoint} ap - an access point + * @returns {bool} - whether the access point was added + */ + addAccessPoint(ap) { + if (!this.checkAccessPoint(ap)) + return false; + + if (this._accessPoints.size === 0) { + this._ssid = ap.get_ssid(); + this._mode = ap.mode; + this._securityType = this._getApSecurityType(ap); + this._name = NM.utils_ssid_to_utf8(this._ssid.get_data()) || '<unknown>'; + + this.notify('name'); + this.notify('secure'); + } + + const wasActive = this.is_active; + this._accessPoints.add(ap); + + ap.connectObject( + 'notify::strength', () => { + this.notify('icon-name'); + this._updateBestAp(); + }, this); + this._updateBestAp(); + + if (wasActive !== this.is_active) + this.notify('is-active'); + + return true; + } + + /** + * @param {NM.AccessPoint} ap - an access point + * @returns {bool} - whether the access point was removed + */ + removeAccessPoint(ap) { + const wasActive = this.is_active; + if (!this._accessPoints.delete(ap)) + return false; + + ap.disconnectObject(this); + this._updateBestAp(); + + if (wasActive !== this.is_active) + this.notify('is-active'); + + return true; + } + + /** + * @param {WirelessNetwork} other - network to compare with + * @returns {number} - the sort order + */ + compare(other) { + // place known connections first + const cmpConnections = other.hasConnections() - this.hasConnections(); + if (cmpConnections !== 0) + return cmpConnections; + + const cmpAps = other.hasAccessPoints() - this.hasAccessPoints(); + if (cmpAps !== 0) + return cmpAps; + + // place stronger connections first + const cmpStrength = other._strength - this._strength; + if (cmpStrength !== 0) + return cmpStrength; + + // place secure connections first + const cmpSec = other.secure - this.secure; + if (cmpSec !== 0) + return cmpSec; + + // sort alphabetically + return GLib.utf8_collate(this._name, other._name); + } + + hasConnections() { + return this._connections.length > 0; + } + + checkConnections(connections) { + const aps = [...this._accessPoints]; + this._connections = connections.filter( + c => aps.some(ap => ap.connection_valid(c))); + } + + canAutoconnect() { + const canAutoconnect = + this._securityTypes !== NM.UtilsSecurityType.WPA_ENTERPRISE && + this._securityTypes !== NM.UtilsSecurityType.WPA2_ENTERPRISE; + return canAutoconnect; + } + + activate() { + const [ap] = this._accessPoints; + let [conn] = this._connections; + if (conn) { + this._device.client.activate_connection_async(conn, this._device, null, null, null); + } else if (!this.canAutoconnect()) { + launchSettingsPanel('wifi', 'connect-8021x-wifi', + this._getDeviceDBusPath(), ap.get_path()); + } else { + conn = new NM.SimpleConnection(); + this._device.client.add_and_activate_connection_async( + conn, this._device, ap.get_path(), null, null); + } + } + + destroy() { + this.emit('destroy'); + } + + _getDeviceDBusPath() { + // nm_object_get_path() is shadowed by nm_device_get_path() + return NM.Object.prototype.get_path.call(this._device); + } + + _getApSecurityType(ap) { + const {wirelessCapabilities: caps} = this._device; + const {flags, wpaFlags, rsnFlags} = ap; + const haveAp = true; + const adHoc = ap.mode === NM80211Mode.ADHOC; + const bestType = WirelessNetwork._securityTypes + .find(t => NM.utils_security_valid(t, caps, haveAp, adHoc, flags, wpaFlags, rsnFlags)); + return bestType ?? NM.UtilsSecurityType.INVALID; + } + + _updateBestAp() { + const [bestAp] = + [...this._accessPoints].sort((a, b) => b.strength - a.strength); + + if (this._bestAp === bestAp) + return; + + this._bestAp = bestAp; + this.notify('icon-name'); + } +}); +registerDestroyableType(WirelessNetwork); + +const NMWirelessNetworkItem = GObject.registerClass( +class NMWirelessNetworkItem extends PopupMenu.PopupBaseMenuItem { + _init(network) { + super._init({style_class: 'nm-network-item'}); + + this._network = network; + + const icons = new St.BoxLayout(); + this.add_child(icons); + + this._signalIcon = new St.Icon({style_class: 'popup-menu-icon'}); + icons.add_child(this._signalIcon); + + this._secureIcon = new St.Icon({ + style_class: 'wireless-secure-icon', + y_align: Clutter.ActorAlign.END, + }); + icons.add_actor(this._secureIcon); + + this._label = new St.Label(); + this.label_actor = this._label; + this.add_child(this._label); + + this._selectedIcon = new St.Icon({ + style_class: 'popup-menu-icon', + icon_name: 'object-select-symbolic', + }); + this.add(this._selectedIcon); + + this._network.bind_property('icon-name', + this._signalIcon, 'icon-name', + GObject.BindingFlags.SYNC_CREATE); + this._network.bind_property('name', + this._label, 'text', + GObject.BindingFlags.SYNC_CREATE); + this._network.bind_property('is-active', + this._selectedIcon, 'visible', + GObject.BindingFlags.SYNC_CREATE); + this._network.bind_property_full('secure', + this._secureIcon, 'icon-name', + GObject.BindingFlags.SYNC_CREATE, + (bind, source) => [true, source ? 'network-wireless-encrypted-symbolic' : ''], + null); + } + + get network() { + return this._network; + } +}); + +const NMWirelessDeviceItem = GObject.registerClass({ + Properties: { + 'is-hotspot': GObject.ParamSpec.boolean('is-hotspot', '', '', + GObject.ParamFlags.READABLE, + false), + 'single-device-mode': GObject.ParamSpec.boolean('single-device-mode', '', '', + GObject.ParamFlags.READWRITE, + false), + }, +}, class NMWirelessDeviceItem extends NMSectionItem { + constructor(client, device) { + super(); + + this._client = client; + this._device = device; + + this._deviceName = ''; + + this._networkItems = new Map(); + this._itemSorter = new ItemSorter({ + sortFunc: (one, two) => one.network.compare(two.network), + }); + + this._client.connectObject( + 'notify::wireless-enabled', () => this.notify('icon-name'), + 'notify::connectivity', () => this.notify('icon-name'), + 'notify::primary-connection', () => this.notify('icon-name'), + this); + + this._device.connectObject( + 'notify::active-access-point', this._activeApChanged.bind(this), + 'notify::active-connection', () => this._activeConnectionChanged(), + 'notify::available-connections', () => this._availableConnectionsChanged(), + 'state-changed', () => this.notify('is-hotspot'), + 'access-point-added', (d, ap) => { + this._addAccessPoint(ap); + this._updateItemsVisibility(); + }, + 'access-point-removed', (d, ap) => { + this._removeAccessPoint(ap); + this._updateItemsVisibility(); + }, this); + + this.bind_property('single-device-mode', + this, 'use-submenu', + GObject.BindingFlags.INVERT_BOOLEAN); + + Main.sessionMode.connectObject('updated', + () => this._updateItemsVisibility(), + this); + + for (const ap of this._device.get_access_points()) + this._addAccessPoint(ap); + + this._activeApChanged(); + this._activeConnectionChanged(); + this._availableConnectionsChanged(); + this._updateItemsVisibility(); + + this.connect('destroy', () => { + for (const net of this._networkItems.keys()) + net.destroy(); + }); + } + + get icon_name() { + if (!this._device.client.wireless_enabled) + return 'network-wireless-disabled-symbolic'; + + switch (this.state) { + case NM.ActiveConnectionState.ACTIVATING: + return 'network-wireless-acquiring-symbolic'; + + case NM.ActiveConnectionState.ACTIVATED: { + if (this.is_hotspot) + return 'network-wireless-hotspot-symbolic'; + + if (!this._canReachInternet()) + return 'network-wireless-no-route-symbolic'; + + if (!this._activeAccessPoint) { + if (this._device.mode !== NM80211Mode.ADHOC) + console.info('An active wireless connection, in infrastructure mode, involves no access point?'); + + return 'network-wireless-connected-symbolic'; + } + + const {strength} = this._activeAccessPoint; + return `network-wireless-signal-${signalToIcon(strength)}-symbolic`; + } + default: + return 'network-wireless-signal-none-symbolic'; + } + } + + get name() { + if (this.is_hotspot) + /* Translators: %s is a network identifier */ + return _('%s Hotspot').format(this._deviceName); + + const {ssid} = this._activeAccessPoint ?? {}; + if (ssid) + return ssidToLabel(ssid); + + return this._deviceName; + } + + get is_hotspot() { + if (!this._device.active_connection) + return false; + + const {connection} = this._device.active_connection; + if (!connection) + return false; + + let ip4config = connection.get_setting_ip4_config(); + if (!ip4config) + return false; + + return ip4config.get_method() === NM.SETTING_IP4_CONFIG_METHOD_SHARED; + } + + activate() { + if (!this.is_hotspot) + return; + + const {activeConnection} = this._device; + this._client.deactivate_connection_async(activeConnection, null, null); + } + + _activeApChanged() { + this._activeAccessPoint?.disconnectObject(this); + this._activeAccessPoint = this._device.active_access_point; + this._activeAccessPoint?.connectObject( + 'notify::strength', () => this.notify('icon-name'), + 'notify::ssid', () => this.notify('name'), + this); + + this.notify('icon-name'); + this.notify('name'); + } + + _activeConnectionChanged() { + this._setActiveConnection(this._device.active_connection); + } + + _availableConnectionsChanged() { + const connections = this._device.get_available_connections(); + for (const net of this._networkItems.keys()) + net.checkConnections(connections); + } + + _addAccessPoint(ap) { + if (ap.get_ssid() == null) { + // This access point is not visible yet + // Wait for it to get a ssid + ap.connectObject('notify::ssid', () => { + if (!ap.ssid) + return; + ap.disconnectObject(this); + this._addAccessPoint(ap); + }, this); + return; + } + + let network = [...this._networkItems.keys()] + .find(n => n.checkAccessPoint(ap)); + + if (!network) { + network = new WirelessNetwork(this._device); + + const item = new NMWirelessNetworkItem(network); + item.connect('activate', () => network.activate()); + + network.connectObject( + 'notify::icon-name', () => this._resortItem(item), + 'notify::is-active', () => this._resortItem(item), + this); + + const pos = this._itemSorter.upsert(item); + this.section.addMenuItem(item, pos); + this._networkItems.set(network, item); + } + + network.addAccessPoint(ap); + } + + _removeAccessPoint(ap) { + const network = [...this._networkItems.keys()] + .find(n => n.removeAccessPoint(ap)); + + if (!network || network.hasAccessPoints()) + return; + + const item = this._networkItems.get(network); + this._itemSorter.delete(item); + this._networkItems.delete(network); + + item?.destroy(); + network.destroy(); + } + + _resortItem(item) { + const pos = this._itemSorter.upsert(item); + this.section.moveMenuItem(item, pos); + + this._updateItemsVisibility(); + } + + _updateItemsVisibility() { + const {hasWindows} = Main.sessionMode; + + let nVisible = 0; + for (const item of this._itemSorter) { + const {network: net} = item; + item.visible = + (hasWindows || net.hasConnections() || net.canAutoconnect()) && + nVisible < MAX_VISIBLE_NETWORKS; + if (item.visible) + nVisible++; + } + } + + setDeviceName(name) { + this._deviceName = name; + this.notify('name'); + } + + _canReachInternet() { + if (this._client.primary_connection !== this._device.active_connection) + return true; + + return this._client.connectivity === NM.ConnectivityState.FULL; + } +}); + +const NMVpnConnectionItem = GObject.registerClass({ + Signals: { + 'activation-failed': {}, + }, +}, class NMVpnConnectionItem extends NMConnectionItem { + constructor(section, connection) { + super(section, connection); + + this._label.x_expand = true; + this.accessible_role = Atk.Role.CHECK_MENU_ITEM; + this._icon.hide(); + + this._switch = new PopupMenu.Switch(this.is_active); + this.add_child(this._switch); + + this.bind_property('is-active', + this._switch, 'state', + GObject.BindingFlags.SYNC_CREATE); + this.bind_property('name', + this._label, 'text', + GObject.BindingFlags.SYNC_CREATE); + } + + _sync() { + if (this.is_active) + this.add_accessible_state(Atk.StateType.CHECKED); + else + this.remove_accessible_state(Atk.StateType.CHECKED); + } + + _activeConnectionStateChanged() { + const state = this._activeConnection?.get_state(); + const reason = this._activeConnection?.get_state_reason(); + + if (state === NM.ActiveConnectionState.DEACTIVATED && + reason !== NM.ActiveConnectionStateReason.NO_SECRETS && + reason !== NM.ActiveConnectionStateReason.USER_DISCONNECTED) + this.emit('activation-failed'); + + super._activeConnectionStateChanged(); + } + + get icon_name() { + switch (this.state) { + case NM.ActiveConnectionState.ACTIVATING: + return 'network-vpn-acquiring-symbolic'; + case NM.ActiveConnectionState.ACTIVATED: + return 'network-vpn-symbolic'; + default: + return 'network-vpn-disabled-symbolic'; + } + } + + set icon_name(_ignored) { + } +}); + +const NMToggle = GObject.registerClass({ + Signals: { + 'activation-failed': {}, + }, +}, class NMToggle extends QuickMenuToggle { + constructor() { + super(); + + this._items = new Map(); + this._itemSorter = new ItemSorter({trackMru: true}); + + this._itemsSection = new PopupMenu.PopupMenuSection(); + this.menu.addMenuItem(this._itemsSection); + + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + this._itemBinding = new GObject.BindingGroup(); + this._itemBinding.bind('icon-name', + this, 'icon-name', GObject.BindingFlags.DEFAULT); + this._itemBinding.bind_full('name', + this, 'label', GObject.BindingFlags.DEFAULT, + (bind, source) => [true, this._transformLabel(source)], + null); + + this.connect('clicked', () => this.activate()); + } + + setClient(client) { + if (this._client === client) + return; + + this._client?.disconnectObject(this); + this._client = client; + this._client?.connectObject( + 'notify::networking-enabled', () => this._sync(), + this); + + this._items.forEach(item => item.destroy()); + this._items.clear(); + + if (this._client) + this._loadInitialItems(); + this._sync(); + } + + activate() { + const activeItems = [...this._getActiveItems()]; + + if (activeItems.length > 0) + activeItems.forEach(i => i.activate()); + else + this._itemBinding.source?.activate(); + } + + _loadInitialItems() { + throw new GObject.NotImplementedError(); + } + + // transform function for property binding: + // Ignore the provided label if there are multiple active + // items, and replace it with something like "VPN (2)" + _transformLabel(source) { + const nActive = this.checked + ? [...this._getActiveItems()].length + : 0; + if (nActive > 1) + return `${this._getDefaultName()} (${nActive})`; + return source; + } + + _updateItemsVisibility() { + [...this._itemSorter.itemsByMru()].forEach( + (item, i) => (item.visible = i < MAX_VISIBLE_NETWORKS)); + } + + _itemActiveChanged() { + // force an update in case we changed + // from or to multiple active items + this._itemBinding.source?.notify('name'); + this._sync(); + } + + _updateChecked() { + const [firstActive] = this._getActiveItems(); + this.checked = !!firstActive; + } + + _resortItem(item) { + const pos = this._itemSorter.upsert(item); + this._itemsSection.moveMenuItem(item, pos); + } + + _addItem(key, item) { + console.assert(!this._items.has(key), + `${this} already has an item for ${key}`); + + item.connectObject( + 'notify::is-active', () => this._itemActiveChanged(), + 'notify::name', () => this._resortItem(item), + 'destroy', () => this._removeItem(key), + this); + + this._items.set(key, item); + const pos = this._itemSorter.upsert(item); + this._itemsSection.addMenuItem(item, pos); + this._sync(); + } + + _removeItem(key) { + const item = this._items.get(key); + if (!item) + return; + + this._itemSorter.delete(item); + this._items.delete(key); + + item.destroy(); + this._sync(); + } + + *_getActiveItems() { + for (const item of this._itemSorter) { + if (item.is_active) + yield item; + } + } + + _getPrimaryItem() { + // prefer active items + const [firstActive] = this._getActiveItems(); + if (firstActive) + return firstActive; + + // otherwise prefer the most-recently used + const [lastUsed] = this._itemSorter.itemsByMru(); + if (lastUsed?.timestamp > 0) + return lastUsed; + + // as a last resort, return the top-most visible item + for (const item of this._itemSorter) { + if (item.visible) + return item; + } + + console.assert(!this.visible, + `${this} should not be visible when empty`); + + return null; + } + + _sync() { + this.visible = + this._client?.networking_enabled && this._items.size > 0; + this._updateItemsVisibility(); + this._updateChecked(); + this._itemBinding.source = this._getPrimaryItem(); + } +}); + +const NMVpnToggle = GObject.registerClass( +class NMVpnToggle extends NMToggle { + constructor() { + super(); + + this.menu.setHeader('network-vpn-symbolic', _('VPN')); + this.menu.addSettingsAction(_('VPN Settings'), + 'gnome-network-panel.desktop'); + } + + setClient(client) { + super.setClient(client); + + this._client?.connectObject( + 'connection-added', (c, conn) => this._addConnection(conn), + 'connection-removed', (c, conn) => this._removeConnection(conn), + 'notify::active-connections', () => this._syncActiveConnections(), + this); + } + + _getDefaultName() { + return _('VPN'); + } + + _loadInitialItems() { + const connections = this._client.get_connections(); + for (const conn of connections) + this._addConnection(conn); + + this._syncActiveConnections(); + } + + _syncActiveConnections() { + const activeConnections = + this._client.get_active_connections().filter( + c => this._shouldHandleConnection(c.connection)); + + for (const item of this._items.values()) + item.setActiveConnection(null); + + for (const a of activeConnections) + this._items.get(a.connection)?.setActiveConnection(a); + } + + _shouldHandleConnection(connection) { + const setting = connection.get_setting_connection(); + if (!setting) + return false; + + // Ignore slave connection + if (setting.get_master()) + return false; + + const handledTypes = [ + NM.SETTING_VPN_SETTING_NAME, + NM.SETTING_WIREGUARD_SETTING_NAME, + ]; + return handledTypes.includes(setting.type); + } + + _onConnectionChanged(connection) { + const item = this._items.get(connection); + item.updateForConnection(connection); + } + + _addConnection(connection) { + if (this._items.has(connection)) + return; + + if (!this._shouldHandleConnection(connection)) + return; + + connection.connectObject( + 'changed', this._onConnectionChanged.bind(this), + this); + + const item = new NMVpnConnectionItem(this, connection); + item.connectObject( + 'activation-failed', () => this.emit('activation-failed'), + this); + this._addItem(connection, item); + } + + _removeConnection(connection) { + this._removeItem(connection); + } + + activateConnection(connection) { + this._client.activate_connection_async(connection, null, null, null, null); + } + + deactivateConnection(activeConnection) { + this._client.deactivate_connection(activeConnection, null); + } +}); + +const NMDeviceToggle = GObject.registerClass( +class NMDeviceToggle extends NMToggle { + constructor(deviceType) { + super(); + + this._deviceType = deviceType; + this._nmDevices = new Set(); + this._deviceNames = new Map(); + } + + setClient(client) { + this._nmDevices.clear(); + + super.setClient(client); + + this._client?.connectObject( + 'device-added', (c, dev) => { + this._addDevice(dev); + this._syncDeviceNames(); + }, + 'device-removed', (c, dev) => { + this._removeDevice(dev); + this._syncDeviceNames(); + }, this); + } + + _getDefaultName() { + const [dev] = this._nmDevices; + const [name] = NM.Device.disambiguate_names([dev]); + return name; + } + + _loadInitialItems() { + const devices = this._client.get_devices(); + for (const dev of devices) + this._addDevice(dev); + this._syncDeviceNames(); + } + + _shouldShowDevice(device) { + switch (device.state) { + case NM.DeviceState.DISCONNECTED: + case NM.DeviceState.ACTIVATED: + case NM.DeviceState.DEACTIVATING: + case NM.DeviceState.PREPARE: + case NM.DeviceState.CONFIG: + case NM.DeviceState.IP_CONFIG: + case NM.DeviceState.IP_CHECK: + case NM.DeviceState.SECONDARIES: + case NM.DeviceState.NEED_AUTH: + case NM.DeviceState.FAILED: + return true; + case NM.DeviceState.UNMANAGED: + case NM.DeviceState.UNAVAILABLE: + default: + return false; + } + } + + _syncDeviceNames() { + const devices = [...this._nmDevices]; + const names = NM.Device.disambiguate_names(devices); + this._deviceNames.clear(); + devices.forEach( + (dev, i) => { + this._deviceNames.set(dev, names[i]); + this._items.get(dev)?.setDeviceName(names[i]); + }); + } + + _syncDeviceItem(device) { + if (this._shouldShowDevice(device)) + this._ensureDeviceItem(device); + else + this._removeDeviceItem(device); + } + + _deviceStateChanged(device, newState, oldState, reason) { + if (newState === oldState) { + console.info(`${device} emitted state-changed without actually changing state`); + return; + } + + /* Emit a notification if activation fails, but don't do it + if the reason is no secrets, as that indicates the user + cancelled the agent dialog */ + if (newState === NM.DeviceState.FAILED && + reason !== NM.DeviceStateReason.NO_SECRETS) + this.emit('activation-failed'); + } + + _createDeviceMenuItem(_device) { + throw new GObject.NotImplementedError(); + } + + _ensureDeviceItem(device) { + if (this._items.has(device)) + return; + + const item = this._createDeviceMenuItem(device); + item.setDeviceName(this._deviceNames.get(device) ?? ''); + this._addItem(device, item); + } + + _removeDeviceItem(device) { + this._removeItem(device); + } + + _addDevice(device) { + if (this._nmDevices.has(device)) + return; + + if (device.get_device_type() !== this._deviceType) + return; + + device.connectObject( + 'state-changed', this._deviceStateChanged.bind(this), + 'notify::interface', () => this._syncDeviceNames(), + 'notify::state', () => this._syncDeviceItem(device), + this); + + this._nmDevices.add(device); + this._syncDeviceItem(device); + } + + _removeDevice(device) { + if (!this._nmDevices.delete(device)) + return; + + device.disconnectObject(this); + this._removeDeviceItem(device); + } + + _sync() { + super._sync(); + + const nItems = this._items.size; + this._items.forEach(item => (item.singleDeviceMode = nItems === 1)); + } +}); + +const NMWirelessToggle = GObject.registerClass( +class NMWirelessToggle extends NMDeviceToggle { + constructor() { + super(NM.DeviceType.WIFI); + + this._itemBinding.bind('is-hotspot', + this, 'menu-enabled', + GObject.BindingFlags.INVERT_BOOLEAN); + + this._scanningSpinner = new Spinner(16); + + this.menu.connectObject('open-state-changed', (m, isOpen) => { + if (isOpen) + this._startScanning(); + else + this._stopScanning(); + }); + + this.menu.setHeader('network-wireless-symbolic', _('Wi–Fi')); + this.menu.addHeaderSuffix(this._scanningSpinner); + this.menu.addSettingsAction(_('All Networks'), + 'gnome-wifi-panel.desktop'); + } + + setClient(client) { + super.setClient(client); + + this._client?.bind_property('wireless-enabled', + this, 'checked', + GObject.BindingFlags.SYNC_CREATE); + this._client?.bind_property('wireless-hardware-enabled', + this, 'reactive', + GObject.BindingFlags.SYNC_CREATE); + } + + activate() { + const primaryItem = this._itemBinding.source; + if (primaryItem?.is_hotspot) + primaryItem.activate(); + else + this._client.wireless_enabled = !this._client.wireless_enabled; + } + + async _scanDevice(device) { + const {lastScan} = device; + await device.request_scan_async(null); + + // Wait for the lastScan property to update, which + // indicates the end of the scan + return new Promise(resolve => { + GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1500, () => { + if (device.lastScan === lastScan) + return GLib.SOURCE_CONTINUE; + + resolve(); + return GLib.SOURCE_REMOVE; + }); + }); + } + + async _scanDevices() { + if (!this._client.wireless_enabled) + return; + + this._scanningSpinner.play(); + + const devices = [...this._items.keys()]; + await Promise.all( + devices.map(d => this._scanDevice(d))); + + this._scanningSpinner.stop(); + } + + _startScanning() { + this._scanTimeoutId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, WIFI_SCAN_FREQUENCY, () => { + this._scanDevices().catch(logError); + return GLib.SOURCE_CONTINUE; + }); + this._scanDevices().catch(logError); + } + + _stopScanning() { + if (this._scanTimeoutId) + GLib.source_remove(this._scanTimeoutId); + delete this._scanTimeoutId; + } + + _createDeviceMenuItem(device) { + return new NMWirelessDeviceItem(this._client, device); + } + + _updateChecked() { + // handled via a property binding + } + + _getPrimaryItem() { + const hotspot = [...this._items.values()].find(i => i.is_hotspot); + if (hotspot) + return hotspot; + + return super._getPrimaryItem(); + } + + _shouldShowDevice(device) { + // don't disappear if wireless-enabled is false + if (device.state === NM.DeviceState.UNAVAILABLE) + return true; + return super._shouldShowDevice(device); + } +}); + +const NMWiredToggle = GObject.registerClass( +class NMWiredToggle extends NMDeviceToggle { + constructor() { + super(NM.DeviceType.ETHERNET); + + this.menu.setHeader('network-wired-symbolic', _('Wired Connections')); + this.menu.addSettingsAction(_('Wired Settings'), + 'gnome-network-panel.desktop'); + } + + _createDeviceMenuItem(device) { + return new NMWiredDeviceItem(this._client, device); + } +}); + +const NMBluetoothToggle = GObject.registerClass( +class NMBluetoothToggle extends NMDeviceToggle { + constructor() { + super(NM.DeviceType.BT); + + this.menu.setHeader('network-cellular-symbolic', _('Bluetooth Tethers')); + this.menu.addSettingsAction(_('Bluetooth Settings'), + 'gnome-network-panel.desktop'); + } + + _createDeviceMenuItem(device) { + return new NMBluetoothDeviceItem(this._client, device); + } +}); + +const NMModemToggle = GObject.registerClass( +class NMModemToggle extends NMDeviceToggle { + constructor() { + super(NM.DeviceType.MODEM); + + this.menu.setHeader('network-cellular-symbolic', _('Mobile Connections')); + + const settingsLabel = _('Mobile Broadband Settings'); + this._wwanSettings = this.menu.addSettingsAction(settingsLabel, + 'gnome-wwan-panel.desktop'); + this._legacySettings = this.menu.addSettingsAction(settingsLabel, + 'gnome-network-panel.desktop'); + } + + _createDeviceMenuItem(device) { + return new NMModemDeviceItem(this._client, device); + } + + _sync() { + super._sync(); + + const useWwanPanel = + [...this._items.values()].some(i => i.wwanPanelSupported); + this._wwanSettings.visible = useWwanPanel; + this._legacySettings.visible = !useWwanPanel; + } +}); + +var Indicator = GObject.registerClass( +class Indicator extends SystemIndicator { + _init() { + super._init(); + + this._connectivityQueue = new Set(); + + this._mainConnection = null; + + this._notification = null; + + this._wiredToggle = new NMWiredToggle(); + this._wirelessToggle = new NMWirelessToggle(); + this._modemToggle = new NMModemToggle(); + this._btToggle = new NMBluetoothToggle(); + this._vpnToggle = new NMVpnToggle(); + + this._deviceToggles = new Map([ + [NM.DeviceType.ETHERNET, this._wiredToggle], + [NM.DeviceType.WIFI, this._wirelessToggle], + [NM.DeviceType.MODEM, this._modemToggle], + [NM.DeviceType.BT, this._btToggle], + ]); + this.quickSettingsItems.push(...this._deviceToggles.values()); + this.quickSettingsItems.push(this._vpnToggle); + + this.quickSettingsItems.forEach(toggle => { + toggle.connectObject( + 'activation-failed', () => this._onActivationFailed(), + this); + }); + + this._primaryIndicator = this._addIndicator(); + this._vpnIndicator = this._addIndicator(); + + this._primaryIndicatorBinding = new GObject.BindingGroup(); + this._primaryIndicatorBinding.bind('icon-name', + this._primaryIndicator, 'icon-name', + GObject.BindingFlags.DEFAULT); + + this._vpnToggle.bind_property('checked', + this._vpnIndicator, 'visible', + GObject.BindingFlags.SYNC_CREATE); + this._vpnToggle.bind_property('icon-name', + this._vpnIndicator, 'icon-name', + GObject.BindingFlags.SYNC_CREATE); + + this._getClient().catch(logError); + } + + async _getClient() { + this._client = await NM.Client.new_async(null); + + this.quickSettingsItems.forEach( + toggle => toggle.setClient(this._client)); + + this._client.bind_property('nm-running', + this, 'visible', + GObject.BindingFlags.SYNC_CREATE); + + this._client.connectObject( + 'notify::primary-connection', () => this._syncMainConnection(), + 'notify::activating-connection', () => this._syncMainConnection(), + 'notify::connectivity', () => this._syncConnectivity(), + this); + this._syncMainConnection(); + + try { + this._configPermission = await Polkit.Permission.new( + 'org.freedesktop.NetworkManager.network-control', null, null); + + this.quickSettingsItems.forEach(toggle => { + this._configPermission.bind_property('allowed', + toggle, 'reactive', + GObject.BindingFlags.SYNC_CREATE); + }); + } catch (e) { + log(`No permission to control network connections: ${e}`); + this._configPermission = null; + } + } + + _onActivationFailed() { + this._notification?.destroy(); + + const source = new MessageTray.Source( + _('Network Manager'), 'network-error-symbolic'); + source.policy = + new MessageTray.NotificationApplicationPolicy('gnome-network-panel'); + + this._notification = new MessageTray.Notification(source, + _('Connection failed'), + _('Activation of network connection failed')); + this._notification.setUrgency(MessageTray.Urgency.HIGH); + this._notification.setTransient(true); + this._notification.connect('destroy', + () => (this._notification = null)); + + Main.messageTray.add(source); + source.showNotification(this._notification); + } + + _syncMainConnection() { + this._mainConnection?.disconnectObject(this); + + this._mainConnection = + this._client.get_primary_connection() || + this._client.get_activating_connection(); + + if (this._mainConnection) { + this._mainConnection.connectObject('notify::state', + this._mainConnectionStateChanged.bind(this), this); + this._mainConnectionStateChanged(); + } + + this._updateIcon(); + this._syncConnectivity(); + } + + _mainConnectionStateChanged() { + if (this._mainConnection.state === NM.ActiveConnectionState.ACTIVATED) + this._notification?.destroy(); + } + + _flushConnectivityQueue() { + for (let item of this._connectivityQueue) + this._portalHelperProxy?.CloseAsync(item); + this._connectivityQueue.clear(); + } + + _closeConnectivityCheck(path) { + if (this._connectivityQueue.delete(path)) + this._portalHelperProxy?.CloseAsync(path); + } + + async _portalHelperDone(parameters) { + let [path, result] = parameters; + + if (result == PortalHelperResult.CANCELLED) { + // Keep the connection in the queue, so the user is not + // spammed with more logins until we next flush the queue, + // which will happen once they choose a better connection + // or we get to full connectivity through other means + } else if (result == PortalHelperResult.COMPLETED) { + this._closeConnectivityCheck(path); + } else if (result == PortalHelperResult.RECHECK) { + try { + const state = await this._client.check_connectivity_async(null); + if (state >= NM.ConnectivityState.FULL) + this._closeConnectivityCheck(path); + } catch (e) { } + } else { + log(`Invalid result from portal helper: ${result}`); + } + } + + async _syncConnectivity() { + if (this._mainConnection == null || + this._mainConnection.state != NM.ActiveConnectionState.ACTIVATED) { + this._flushConnectivityQueue(); + return; + } + + let isPortal = this._client.connectivity == NM.ConnectivityState.PORTAL; + // For testing, allow interpreting any value != FULL as PORTAL, because + // LIMITED (no upstream route after the default gateway) is easy to obtain + // with a tethered phone + // NONE is also possible, with a connection configured to force no default route + // (but in general we should only prompt a portal if we know there is a portal) + if (GLib.getenv('GNOME_SHELL_CONNECTIVITY_TEST') != null) + isPortal ||= this._client.connectivity < NM.ConnectivityState.FULL; + if (!isPortal || Main.sessionMode.isGreeter) + return; + + let path = this._mainConnection.get_path(); + if (this._connectivityQueue.has(path)) + return; + + let timestamp = global.get_current_time(); + if (!this._portalHelperProxy) { + this._portalHelperProxy = new Gio.DBusProxy({ + g_connection: Gio.DBus.session, + g_name: 'org.gnome.Shell.PortalHelper', + g_object_path: '/org/gnome/Shell/PortalHelper', + g_interface_name: PortalHelperInfo.name, + g_interface_info: PortalHelperInfo, + }); + this._portalHelperProxy.connectSignal('Done', + (proxy, emitter, params) => { + this._portalHelperDone(params).catch(logError); + }); + + try { + await this._portalHelperProxy.init_async( + GLib.PRIORITY_DEFAULT, null); + } catch (e) { + console.error(`Error launching the portal helper: ${e.message}`); + } + } + + this._portalHelperProxy?.AuthenticateAsync(path, this._client.connectivity_check_uri, timestamp).catch(logError); + + this._connectivityQueue.add(path); + } + + _updateIcon() { + const [dev] = this._mainConnection?.get_devices() ?? []; + const primaryToggle = this._deviceToggles.get(dev?.device_type) ?? null; + this._primaryIndicatorBinding.source = primaryToggle; + + if (!primaryToggle) { + if (this._client.connectivity === NM.ConnectivityState.FULL) + this._primaryIndicator.icon_name = 'network-wired-symbolic'; + else + this._primaryIndicator.icon_name = 'network-wired-no-route-symbolic'; + } + + const state = this._client.get_state(); + const connected = state === NM.State.CONNECTED_GLOBAL; + this._primaryIndicator.visible = (primaryToggle != null) || connected; + } +}); diff --git a/js/ui/status/nightLight.js b/js/ui/status/nightLight.js new file mode 100644 index 0000000..0d148e3 --- /dev/null +++ b/js/ui/status/nightLight.js @@ -0,0 +1,70 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const {Gio, GLib, GObject} = imports.gi; + +const {QuickToggle, SystemIndicator} = imports.ui.quickSettings; + +const {loadInterfaceXML} = imports.misc.fileUtils; + +const BUS_NAME = 'org.gnome.SettingsDaemon.Color'; +const OBJECT_PATH = '/org/gnome/SettingsDaemon/Color'; + +const ColorInterface = loadInterfaceXML('org.gnome.SettingsDaemon.Color'); +const colorInfo = Gio.DBusInterfaceInfo.new_for_xml(ColorInterface); + +const NightLightToggle = GObject.registerClass( +class NightLightToggle extends QuickToggle { + _init() { + super._init({ + label: _('Night Light'), + iconName: 'night-light-symbolic', + toggleMode: true, + }); + + const monitorManager = global.backend.get_monitor_manager(); + monitorManager.bind_property('night-light-supported', + this, 'visible', + GObject.BindingFlags.SYNC_CREATE); + + this._settings = new Gio.Settings({ + schema_id: 'org.gnome.settings-daemon.plugins.color', + }); + this._settings.bind('night-light-enabled', + this, 'checked', + Gio.SettingsBindFlags.DEFAULT); + } +}); + +var Indicator = GObject.registerClass( +class Indicator extends SystemIndicator { + _init() { + super._init(); + + this._indicator = this._addIndicator(); + this._indicator.icon_name = 'night-light-symbolic'; + + this.quickSettingsItems.push(new NightLightToggle()); + + this._proxy = new Gio.DBusProxy({ + g_connection: Gio.DBus.session, + g_name: BUS_NAME, + g_object_path: OBJECT_PATH, + g_interface_name: colorInfo.name, + g_interface_info: colorInfo, + }); + this._proxy.connect('g-properties-changed', (p, properties) => { + const nightLightActiveChanged = !!properties.lookup_value('NightLightActive', null); + if (nightLightActiveChanged) + this._sync(); + }); + this._proxy.init_async(GLib.PRIORITY_DEFAULT, null) + .catch(e => console.error(e.message)); + + this._sync(); + } + + _sync() { + this._indicator.visible = this._proxy.NightLightActive; + } +}); diff --git a/js/ui/status/powerProfiles.js b/js/ui/status/powerProfiles.js new file mode 100644 index 0000000..e15208d --- /dev/null +++ b/js/ui/status/powerProfiles.js @@ -0,0 +1,126 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const {Gio, GObject} = imports.gi; + +const {QuickMenuToggle, SystemIndicator} = imports.ui.quickSettings; + +const PopupMenu = imports.ui.popupMenu; + +const {loadInterfaceXML} = imports.misc.fileUtils; + +const BUS_NAME = 'net.hadess.PowerProfiles'; +const OBJECT_PATH = '/net/hadess/PowerProfiles'; + +const PowerProfilesIface = loadInterfaceXML('net.hadess.PowerProfiles'); +const PowerProfilesProxy = Gio.DBusProxy.makeProxyWrapper(PowerProfilesIface); + +const PROFILE_PARAMS = { + 'performance': { + label: C_('Power profile', 'Performance'), + iconName: 'power-profile-performance-symbolic', + }, + + 'balanced': { + label: C_('Power profile', 'Balanced'), + iconName: 'power-profile-balanced-symbolic', + }, + + 'power-saver': { + label: C_('Power profile', 'Power Saver'), + iconName: 'power-profile-power-saver-symbolic', + }, +}; + +const LAST_PROFILE_KEY = 'last-selected-power-profile'; + +const PowerProfilesToggle = GObject.registerClass( +class PowerProfilesToggle extends QuickMenuToggle { + _init() { + super._init(); + + this._profileItems = new Map(); + + this.connect('clicked', () => { + this._proxy.ActiveProfile = this.checked + ? 'balanced' + : global.settings.get_string(LAST_PROFILE_KEY); + }); + + this._proxy = new PowerProfilesProxy(Gio.DBus.system, BUS_NAME, OBJECT_PATH, + (proxy, error) => { + if (error) { + log(error.message); + } else { + this._proxy.connect('g-properties-changed', (p, properties) => { + const profilesChanged = !!properties.lookup_value('Profiles', null); + if (profilesChanged) + this._syncProfiles(); + this._sync(); + }); + + if (this._proxy.g_name_owner) + this._syncProfiles(); + } + this._sync(); + }); + + this._profileSection = new PopupMenu.PopupMenuSection(); + this.menu.addMenuItem(this._profileSection); + this.menu.setHeader('power-profile-balanced-symbolic', _('Power Profiles')); + + this._sync(); + } + + _syncProfiles() { + this._profileSection.removeAll(); + this._profileItems.clear(); + + const profiles = this._proxy.Profiles + .map(p => p.Profile.unpack()) + .reverse(); + for (const profile of profiles) { + const {label, iconName} = PROFILE_PARAMS[profile]; + if (!label) + continue; + + const item = new PopupMenu.PopupImageMenuItem(label, iconName); + item.connect('activate', + () => (this._proxy.ActiveProfile = profile)); + this._profileItems.set(profile, item); + this._profileSection.addMenuItem(item); + } + + this.menuEnabled = this._profileItems.size > 2; + } + + _sync() { + this.visible = this._proxy.g_name_owner !== null; + + if (!this.visible) + return; + + const {ActiveProfile: activeProfile} = this._proxy; + + for (const [profile, item] of this._profileItems) { + item.setOrnament(profile === activeProfile + ? PopupMenu.Ornament.CHECK + : PopupMenu.Ornament.NONE); + } + + this.set(PROFILE_PARAMS[activeProfile]); + this.checked = activeProfile !== 'balanced'; + + if (this.checked) + global.settings.set_string(LAST_PROFILE_KEY, activeProfile); + } +}); + +var Indicator = GObject.registerClass( +class Indicator extends SystemIndicator { + _init() { + super._init(); + + this.quickSettingsItems.push(new PowerProfilesToggle()); + } +}); diff --git a/js/ui/status/remoteAccess.js b/js/ui/status/remoteAccess.js new file mode 100644 index 0000000..1ed8793 --- /dev/null +++ b/js/ui/status/remoteAccess.js @@ -0,0 +1,230 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported RemoteAccessApplet, ScreenRecordingIndicator, ScreenSharingIndicator */ + +const { Atk, Clutter, GLib, GObject, Meta, St } = imports.gi; + +const Main = imports.ui.main; +const PanelMenu = imports.ui.panelMenu; +const {SystemIndicator} = imports.ui.quickSettings; + +// Minimum amount of time the shared indicator is visible (in micro seconds) +const MIN_SHARED_INDICATOR_VISIBLE_TIME_US = 5 * GLib.TIME_SPAN_SECOND; + +var RemoteAccessApplet = GObject.registerClass( +class RemoteAccessApplet extends SystemIndicator { + _init() { + super._init(); + + let controller = global.backend.get_remote_access_controller(); + + if (!controller) + return; + + this._handles = new Set(); + + this._indicator = this._addIndicator(); + this._indicator.icon_name = 'media-record-symbolic'; + this._indicator.add_style_class_name('screencast-indicator'); + + controller.connect('new-handle', (o, handle) => { + this._onNewHandle(handle); + }); + this._sync(); + } + + _isRecording() { + // Screenshot UI screencasts have their own panel, so don't show this + // indicator if there's only a screenshot UI screencast. + if (Main.screenshotUI.screencast_in_progress) + return this._handles.size > 1; + + return this._handles.size > 0; + } + + _sync() { + this._indicator.visible = this._isRecording(); + } + + _onStopped(handle) { + this._handles.delete(handle); + this._sync(); + } + + _onNewHandle(handle) { + if (!handle.is_recording) + return; + + this._handles.add(handle); + handle.connect('stopped', this._onStopped.bind(this)); + + this._sync(); + } +}); + +var ScreenRecordingIndicator = GObject.registerClass({ + Signals: { 'menu-set': {} }, +}, class ScreenRecordingIndicator extends PanelMenu.ButtonBox { + _init() { + super._init({ + reactive: true, + can_focus: true, + track_hover: true, + accessible_name: _('Stop Screencast'), + accessible_role: Atk.Role.PUSH_BUTTON, + }); + this.add_style_class_name('screen-recording-indicator'); + + this._box = new St.BoxLayout(); + this.add_child(this._box); + + this._label = new St.Label({ + text: '0:00', + y_align: Clutter.ActorAlign.CENTER, + }); + this._box.add_child(this._label); + + this._icon = new St.Icon({ icon_name: 'stop-symbolic' }); + this._box.add_child(this._icon); + + this.hide(); + Main.screenshotUI.connect( + 'notify::screencast-in-progress', + this._onScreencastInProgressChanged.bind(this)); + } + + vfunc_event(event) { + if (event.type() === Clutter.EventType.TOUCH_BEGIN || + event.type() === Clutter.EventType.BUTTON_PRESS) + Main.screenshotUI.stopScreencast(); + + return Clutter.EVENT_PROPAGATE; + } + + _updateLabel() { + const minutes = this._secondsPassed / 60; + const seconds = this._secondsPassed % 60; + this._label.text = '%d:%02d'.format(minutes, seconds); + } + + _onScreencastInProgressChanged() { + if (Main.screenshotUI.screencast_in_progress) { + this.show(); + + this._secondsPassed = 0; + this._updateLabel(); + + this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1000, () => { + this._secondsPassed += 1; + this._updateLabel(); + return GLib.SOURCE_CONTINUE; + }); + GLib.Source.set_name_by_id( + this._timeoutId, '[gnome-shell] screen recording indicator tick'); + } else { + this.hide(); + + GLib.source_remove(this._timeoutId); + delete this._timeoutId; + + delete this._secondsPassed; + } + } +}); + +var ScreenSharingIndicator = GObject.registerClass({ + Signals: {'menu-set': {}}, +}, class ScreenSharingIndicator extends PanelMenu.ButtonBox { + _init() { + super._init({ + reactive: true, + can_focus: true, + track_hover: true, + accessible_name: _('Stop Screen Sharing'), + accessible_role: Atk.Role.PUSH_BUTTON, + }); + this.add_style_class_name('screen-sharing-indicator'); + + this._box = new St.BoxLayout(); + this.add_child(this._box); + + let icon = new St.Icon({icon_name: 'screen-shared-symbolic'}); + this._box.add_child(icon); + + icon = new St.Icon({icon_name: 'window-close-symbolic'}); + this._box.add_child(icon); + + this._controller = global.backend.get_remote_access_controller(); + + this._handles = new Set(); + + this._controller?.connect('new-handle', + (o, handle) => this._onNewHandle(handle)); + + this._sync(); + } + + _onNewHandle(handle) { + // We can't possibly know about all types of screen sharing on X11, so + // showing these controls on X11 might give a false sense of security. + // Thus, only enable these controls when using Wayland, where we are + // in control of sharing. + if (!Meta.is_wayland_compositor()) + return; + + if (handle.isRecording) + return; + + this._handles.add(handle); + handle.connect('stopped', () => { + this._handles.delete(handle); + this._sync(); + }); + this._sync(); + } + + vfunc_event(event) { + if (event.type() === Clutter.EventType.TOUCH_BEGIN || + event.type() === Clutter.EventType.BUTTON_PRESS) + this._stopSharing(); + + return Clutter.EVENT_PROPAGATE; + } + + _stopSharing() { + for (const handle of this._handles) + handle.stop(); + } + + _hideIndicator() { + this.hide(); + delete this._hideIndicatorId; + return GLib.SOURCE_REMOVE; + } + + _sync() { + if (this._hideIndicatorId) { + GLib.source_remove(this._hideIndicatorId); + delete this._hideIndicatorId; + } + + if (this._handles.size > 0) { + if (!this.visible) + this._visibleTimeUs = GLib.get_monotonic_time(); + this.show(); + } else if (this.visible) { + const currentTimeUs = GLib.get_monotonic_time(); + const timeSinceVisibleUs = currentTimeUs - this._visibleTimeUs; + + if (timeSinceVisibleUs >= MIN_SHARED_INDICATOR_VISIBLE_TIME_US) { + this._hideIndicator(); + } else { + const timeUntilHideUs = + MIN_SHARED_INDICATOR_VISIBLE_TIME_US - timeSinceVisibleUs; + this._hideIndicatorId = + GLib.timeout_add(GLib.PRIORITY_DEFAULT, + timeUntilHideUs / GLib.TIME_SPAN_MILLISECOND, + () => this._hideIndicator()); + } + } + } +}); diff --git a/js/ui/status/rfkill.js b/js/ui/status/rfkill.js new file mode 100644 index 0000000..2e1f98f --- /dev/null +++ b/js/ui/status/rfkill.js @@ -0,0 +1,136 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const {Gio, GLib, GObject} = imports.gi; + +const {QuickToggle, SystemIndicator} = imports.ui.quickSettings; + +const {loadInterfaceXML} = imports.misc.fileUtils; + +const BUS_NAME = 'org.gnome.SettingsDaemon.Rfkill'; +const OBJECT_PATH = '/org/gnome/SettingsDaemon/Rfkill'; + +const RfkillManagerInterface = loadInterfaceXML('org.gnome.SettingsDaemon.Rfkill'); +const rfkillManagerInfo = Gio.DBusInterfaceInfo.new_for_xml(RfkillManagerInterface); + +const RfkillManager = GObject.registerClass({ + Properties: { + 'airplane-mode': GObject.ParamSpec.boolean( + 'airplane-mode', '', '', + GObject.ParamFlags.READWRITE, + false), + 'hw-airplane-mode': GObject.ParamSpec.boolean( + 'hw-airplane-mode', '', '', + GObject.ParamFlags.READABLE, + false), + 'show-airplane-mode': GObject.ParamSpec.boolean( + 'show-airplane-mode', '', '', + GObject.ParamFlags.READABLE, + false), + }, +}, class RfkillManager extends GObject.Object { + constructor() { + super(); + + this._proxy = new Gio.DBusProxy({ + g_connection: Gio.DBus.session, + g_name: BUS_NAME, + g_object_path: OBJECT_PATH, + g_interface_name: rfkillManagerInfo.name, + g_interface_info: rfkillManagerInfo, + }); + this._proxy.connect('g-properties-changed', this._changed.bind(this)); + this._proxy.init_async(GLib.PRIORITY_DEFAULT, null) + .catch(e => console.error(e.message)); + } + + /* eslint-disable camelcase */ + get airplane_mode() { + return this._proxy.AirplaneMode; + } + + set airplane_mode(v) { + this._proxy.AirplaneMode = v; + } + + get hw_airplane_mode() { + return this._proxy.HardwareAirplaneMode; + } + + get show_airplane_mode() { + return this._proxy.HasAirplaneMode && this._proxy.ShouldShowAirplaneMode; + } + /* eslint-enable camelcase */ + + _changed(proxy, properties) { + for (const prop in properties.deepUnpack()) { + switch (prop) { + case 'AirplaneMode': + this.notify('airplane-mode'); + break; + case 'HardwareAirplaneMode': + this.notify('hw-airplane-mode'); + break; + case 'HasAirplaneMode': + case 'ShouldShowAirplaneMode': + this.notify('show-airplane-mode'); + break; + } + } + } +}); + +var _manager; +function getRfkillManager() { + if (_manager != null) + return _manager; + + _manager = new RfkillManager(); + return _manager; +} + +const RfkillToggle = GObject.registerClass( +class RfkillToggle extends QuickToggle { + _init() { + super._init({ + label: _('Airplane Mode'), + iconName: 'airplane-mode-symbolic', + }); + + this._manager = getRfkillManager(); + this._manager.bind_property('show-airplane-mode', + this, 'visible', + GObject.BindingFlags.SYNC_CREATE); + this._manager.bind_property('airplane-mode', + this, 'checked', + GObject.BindingFlags.SYNC_CREATE); + + this.connect('clicked', + () => (this._manager.airplaneMode = !this._manager.airplaneMode)); + } +}); + +var Indicator = GObject.registerClass( +class Indicator extends SystemIndicator { + _init() { + super._init(); + + this._indicator = this._addIndicator(); + this._indicator.icon_name = 'airplane-mode-symbolic'; + + this._rfkillToggle = new RfkillToggle(); + this._rfkillToggle.connectObject( + 'notify::visible', () => this._sync(), + 'notify::checked', () => this._sync(), + this); + this.quickSettingsItems.push(this._rfkillToggle); + + this._sync(); + } + + _sync() { + // Only show indicator when airplane mode is on + const {visible, checked} = this._rfkillToggle; + this._indicator.visible = visible && checked; + } +}); diff --git a/js/ui/status/system.js b/js/ui/status/system.js new file mode 100644 index 0000000..5a2d92c --- /dev/null +++ b/js/ui/status/system.js @@ -0,0 +1,348 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const {Atk, Clutter, Gio, GLib, GObject, Meta, Shell, St, UPowerGlib: UPower} = imports.gi; + +const SystemActions = imports.misc.systemActions; +const Main = imports.ui.main; +const PopupMenu = imports.ui.popupMenu; +const {PopupAnimation} = imports.ui.boxpointer; + +const {QuickSettingsItem, QuickToggle, SystemIndicator} = imports.ui.quickSettings; +const {loadInterfaceXML} = imports.misc.fileUtils; + +const BUS_NAME = 'org.freedesktop.UPower'; +const OBJECT_PATH = '/org/freedesktop/UPower/devices/DisplayDevice'; + +const DisplayDeviceInterface = loadInterfaceXML('org.freedesktop.UPower.Device'); +const PowerManagerProxy = Gio.DBusProxy.makeProxyWrapper(DisplayDeviceInterface); + +const SHOW_BATTERY_PERCENTAGE = 'show-battery-percentage'; + +const PowerToggle = GObject.registerClass({ + Properties: { + 'fallback-icon-name': GObject.ParamSpec.string('fallback-icon-name', '', '', + GObject.ParamFlags.READWRITE, + ''), + }, +}, class PowerToggle extends QuickToggle { + _init() { + super._init({ + accessible_role: Atk.Role.PUSH_BUTTON, + }); + + this.add_style_class_name('power-item'); + + this._proxy = new PowerManagerProxy(Gio.DBus.system, BUS_NAME, OBJECT_PATH, + (proxy, error) => { + if (error) + console.error(error.message); + else + this._proxy.connect('g-properties-changed', () => this._sync()); + this._sync(); + }); + + this.bind_property('fallback-icon-name', + this._icon, 'fallback-icon-name', + GObject.BindingFlags.SYNC_CREATE); + + this.connect('clicked', () => { + const app = Shell.AppSystem.get_default().lookup_app('gnome-power-panel.desktop'); + Main.overview.hide(); + Main.panel.closeQuickSettings(); + app.activate(); + }); + + Main.sessionMode.connect('updated', () => this._sessionUpdated()); + this._sessionUpdated(); + this._sync(); + } + + _sessionUpdated() { + this.reactive = Main.sessionMode.allowSettings; + } + + _sync() { + // Do we have batteries or a UPS? + this.visible = this._proxy.IsPresent; + if (!this.visible) + return; + + // The icons + let chargingState = this._proxy.State === UPower.DeviceState.CHARGING + ? '-charging' : ''; + let fillLevel = 10 * Math.floor(this._proxy.Percentage / 10); + const charged = + this._proxy.State === UPower.DeviceState.FULLY_CHARGED || + (this._proxy.State === UPower.DeviceState.CHARGING && fillLevel === 100); + const icon = charged + ? 'battery-level-100-charged-symbolic' + : `battery-level-${fillLevel}${chargingState}-symbolic`; + + // Make sure we fall back to fallback-icon-name and not GThemedIcon's + // default fallbacks + const gicon = new Gio.ThemedIcon({ + name: icon, + use_default_fallbacks: false, + }); + + this.set({ + label: _('%d\u2009%%').format(this._proxy.Percentage), + fallback_icon_name: this._proxy.IconName, + gicon, + }); + } +}); + +const ScreenshotItem = GObject.registerClass( +class ScreenshotItem extends QuickSettingsItem { + _init() { + super._init({ + style_class: 'icon-button', + can_focus: true, + icon_name: 'camera-photo-symbolic', + visible: !Main.sessionMode.isGreeter, + accessible_name: _('Take Screenshot'), + }); + + this.connect('clicked', () => { + const topMenu = Main.panel.statusArea.quickSettings.menu; + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + Main.screenshotUI.open().catch(logError); + return GLib.SOURCE_REMOVE; + }); + topMenu.close(PopupAnimation.NONE); + }); + } +}); + +const SettingsItem = GObject.registerClass( +class SettingsItem extends QuickSettingsItem { + _init() { + super._init({ + style_class: 'icon-button', + can_focus: true, + child: new St.Icon(), + }); + + this._settingsApp = Shell.AppSystem.get_default().lookup_app( + 'org.gnome.Settings.desktop'); + + if (!this._settingsApp) + console.warn('Missing required core component Settings, expect trouble…'); + + this.child.gicon = this._settingsApp?.get_icon() ?? null; + this.accessible_name = this._settingsApp?.get_name() ?? null; + + this.connect('clicked', () => { + Main.overview.hide(); + Main.panel.closeQuickSettings(); + this._settingsApp.activate(); + }); + + Main.sessionMode.connectObject('updated', () => this._sync(), this); + this._sync(); + } + + _sync() { + this.visible = + this._settingsApp != null && Main.sessionMode.allowSettings; + } +}); + +const ShutdownItem = GObject.registerClass( +class ShutdownItem extends QuickSettingsItem { + _init() { + super._init({ + style_class: 'icon-button', + hasMenu: true, + canFocus: true, + icon_name: 'system-shutdown-symbolic', + accessible_name: _('Power Off Menu'), + }); + + this._systemActions = new SystemActions.getDefault(); + this._items = []; + + this.menu.setHeader('system-shutdown-symbolic', C_('title', 'Power Off')); + + this._addSystemAction(_('Suspend'), 'can-suspend', () => { + this._systemActions.activateSuspend(); + Main.panel.closeQuickSettings(); + }); + + this._addSystemAction(_('Restart…'), 'can-restart', () => { + this._systemActions.activateRestart(); + Main.panel.closeQuickSettings(); + }); + + this._addSystemAction(_('Power Off…'), 'can-power-off', () => { + this._systemActions.activatePowerOff(); + Main.panel.closeQuickSettings(); + }); + + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + this._addSystemAction(_('Log Out…'), 'can-logout', () => { + this._systemActions.activateLogout(); + Main.panel.closeQuickSettings(); + }); + + this._addSystemAction(_('Switch User…'), 'can-switch-user', () => { + this._systemActions.activateSwitchUser(); + Main.panel.closeQuickSettings(); + }); + + // Whether shutdown is available or not depends on both lockdown + // settings (disable-log-out) and Polkit policy - the latter doesn't + // notify, so we update the item each time we become visible or + // the lockdown setting changes, which should be close enough. + this.connect('notify::mapped', () => { + if (!this.mapped) + return; + + this._systemActions.forceUpdate(); + }); + + this.connect('clicked', () => this.menu.open()); + this.connect('popup-menu', () => this.menu.open()); + } + + _addSystemAction(label, propName, callback) { + const item = this.menu.addAction(label, callback); + this._items.push(item); + + this._systemActions.bind_property(propName, + item, 'visible', + GObject.BindingFlags.DEFAULT | GObject.BindingFlags.SYNC_CREATE); + item.connect('notify::visible', () => this._sync()); + } + + _sync() { + this.visible = this._items.some(i => i.visible); + } +}); + +const LockItem = GObject.registerClass( +class LockItem extends QuickSettingsItem { + _init() { + this._systemActions = new SystemActions.getDefault(); + + super._init({ + style_class: 'icon-button', + can_focus: true, + icon_name: 'system-lock-screen-symbolic', + accessible_name: C_('action', 'Lock Screen'), + }); + + this._systemActions.bind_property('can-lock-screen', + this, 'visible', + GObject.BindingFlags.DEFAULT | + GObject.BindingFlags.SYNC_CREATE); + + this.connect('clicked', + () => this._systemActions.activateLockScreen()); + } +}); + + +const SystemItem = GObject.registerClass( +class SystemItem extends QuickSettingsItem { + _init() { + super._init({ + style_class: 'quick-settings-system-item', + reactive: false, + }); + + this.child = new St.BoxLayout(); + + this._powerToggle = new PowerToggle(); + this.child.add_child(this._powerToggle); + + this._laptopSpacer = new Clutter.Actor({x_expand: true}); + this._powerToggle.bind_property('visible', + this._laptopSpacer, 'visible', + GObject.BindingFlags.SYNC_CREATE); + this.child.add_child(this._laptopSpacer); + + const screenshotItem = new ScreenshotItem(); + this.child.add_child(screenshotItem); + + const settingsItem = new SettingsItem(); + this.child.add_child(settingsItem); + + this._desktopSpacer = new Clutter.Actor({x_expand: true}); + this._powerToggle.bind_property('visible', + this._desktopSpacer, 'visible', + GObject.BindingFlags.INVERT_BOOLEAN | + GObject.BindingFlags.SYNC_CREATE); + this.child.add_child(this._desktopSpacer); + + const lockItem = new LockItem(); + this.child.add_child(lockItem); + + const shutdownItem = new ShutdownItem(); + this.child.add_child(shutdownItem); + + this.menu = shutdownItem.menu; + } + + get powerToggle() { + return this._powerToggle; + } +}); + +var Indicator = GObject.registerClass( +class Indicator extends SystemIndicator { + _init() { + super._init(); + + this._desktopSettings = new Gio.Settings({ + schema_id: 'org.gnome.desktop.interface', + }); + this._desktopSettings.connectObject( + `changed::${SHOW_BATTERY_PERCENTAGE}`, () => this._sync(), this); + + this._indicator = this._addIndicator(); + this._percentageLabel = new St.Label({ + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + this.add_child(this._percentageLabel); + this.add_style_class_name('power-status'); + + this._systemItem = new SystemItem(); + + const {powerToggle} = this._systemItem; + + powerToggle.bind_property('label', + this._percentageLabel, 'text', + GObject.BindingFlags.SYNC_CREATE); + + powerToggle.connectObject( + 'notify::visible', () => this._sync(), + 'notify::gicon', () => this._sync(), + 'notify::fallback-icon-name', () => this._sync(), + this); + + this.quickSettingsItems.push(this._systemItem); + + this._sync(); + } + + _sync() { + const {powerToggle} = this._systemItem; + if (powerToggle.visible) { + this._indicator.set({ + gicon: powerToggle.gicon, + fallback_icon_name: powerToggle.fallback_icon_name, + }); + this._percentageLabel.visible = + this._desktopSettings.get_boolean(SHOW_BATTERY_PERCENTAGE); + } else { + // If there's no battery, then we use the power icon. + this._indicator.icon_name = 'system-shutdown-symbolic'; + this._percentageLabel.hide(); + } + } +}); diff --git a/js/ui/status/thunderbolt.js b/js/ui/status/thunderbolt.js new file mode 100644 index 0000000..2e1236e --- /dev/null +++ b/js/ui/status/thunderbolt.js @@ -0,0 +1,332 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +// the following is a modified version of bolt/contrib/js/client.js + +const { Gio, GLib, GObject, Polkit, Shell } = imports.gi; +const Signals = imports.misc.signals; + +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; +const {SystemIndicator} = imports.ui.quickSettings; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +/* Keep in sync with data/org.freedesktop.bolt.xml */ + +const BoltClientInterface = loadInterfaceXML('org.freedesktop.bolt1.Manager'); +const BoltDeviceInterface = loadInterfaceXML('org.freedesktop.bolt1.Device'); + +const BoltDeviceProxy = Gio.DBusProxy.makeProxyWrapper(BoltDeviceInterface); + +/* */ + +var Status = { + DISCONNECTED: 'disconnected', + CONNECTING: 'connecting', + CONNECTED: 'connected', + AUTHORIZING: 'authorizing', + AUTH_ERROR: 'auth-error', + AUTHORIZED: 'authorized', +}; + +var Policy = { + DEFAULT: 'default', + MANUAL: 'manual', + AUTO: 'auto', +}; + +var AuthCtrl = { + NONE: 'none', +}; + +var AuthMode = { + DISABLED: 'disabled', + ENABLED: 'enabled', +}; + +const BOLT_DBUS_CLIENT_IFACE = 'org.freedesktop.bolt1.Manager'; +const BOLT_DBUS_NAME = 'org.freedesktop.bolt'; +const BOLT_DBUS_PATH = '/org/freedesktop/bolt'; + +var Client = class extends Signals.EventEmitter { + constructor() { + super(); + + this._proxy = null; + this.probing = false; + this._getProxy(); + } + + async _getProxy() { + let nodeInfo = Gio.DBusNodeInfo.new_for_xml(BoltClientInterface); + try { + this._proxy = await Gio.DBusProxy.new( + Gio.DBus.system, + Gio.DBusProxyFlags.DO_NOT_AUTO_START, + nodeInfo.lookup_interface(BOLT_DBUS_CLIENT_IFACE), + BOLT_DBUS_NAME, + BOLT_DBUS_PATH, + BOLT_DBUS_CLIENT_IFACE, + null); + } catch (e) { + log(`error creating bolt proxy: ${e.message}`); + return; + } + this._proxy.connectObject('g-properties-changed', + this._onPropertiesChanged.bind(this), this); + this._deviceAddedId = this._proxy.connectSignal('DeviceAdded', this._onDeviceAdded.bind(this)); + + this.probing = this._proxy.Probing; + if (this.probing) + this.emit('probing-changed', this.probing); + } + + _onPropertiesChanged(proxy, properties) { + const probingChanged = !!properties.lookup_value('Probing', null); + if (probingChanged) { + this.probing = this._proxy.Probing; + this.emit('probing-changed', this.probing); + } + } + + _onDeviceAdded(proxy, emitter, params) { + let [path] = params; + let device = new BoltDeviceProxy(Gio.DBus.system, + BOLT_DBUS_NAME, + path); + this.emit('device-added', device); + } + + /* public methods */ + close() { + if (!this._proxy) + return; + + this._proxy.disconnectSignal(this._deviceAddedId); + this._proxy.disconnectObject(this); + this._proxy = null; + } + + async enrollDevice(id, policy) { + try { + const [path] = await this._proxy.EnrollDeviceAsync(id, policy, AuthCtrl.NONE); + const device = new BoltDeviceProxy(Gio.DBus.system, BOLT_DBUS_NAME, path); + return device; + } catch (error) { + Gio.DBusError.strip_remote_error(error); + throw error; + } + } + + get authMode() { + return this._proxy.AuthMode; + } +}; + +/* helper class to automatically authorize new devices */ +var AuthRobot = class extends Signals.EventEmitter { + constructor(client) { + super(); + + this._client = client; + + this._devicesToEnroll = []; + this._enrolling = false; + + this._client.connect('device-added', this._onDeviceAdded.bind(this)); + } + + close() { + this.disconnectAll(); + this._client = null; + } + + /* the "device-added" signal will be emitted by boltd for every + * device that is not currently stored in the database. We are + * only interested in those devices, because all known devices + * will be handled by the user himself */ + _onDeviceAdded(cli, dev) { + if (dev.Status !== Status.CONNECTED) + return; + + /* check if authorization is enabled in the daemon. if not + * we won't even bother authorizing, because we will only + * get an error back. The exact contents of AuthMode might + * change in the future, but must contain AuthMode.ENABLED + * if it is enabled. */ + if (!cli.authMode.split('|').includes(AuthMode.ENABLED)) + return; + + /* check if we should enroll the device */ + let res = [false]; + this.emit('enroll-device', dev, res); + if (res[0] !== true) + return; + + /* ok, we should authorize the device, add it to the back + * of the list */ + this._devicesToEnroll.push(dev); + this._enrollDevices(); + } + + /* The enrollment queue: + * - new devices will be added to the end of the array. + * - an idle callback will be scheduled that will keep + * calling itself as long as there a devices to be + * enrolled. + */ + _enrollDevices() { + if (this._enrolling) + return; + + this._enrolling = true; + GLib.idle_add(GLib.PRIORITY_DEFAULT, + this._enrollDevicesIdle.bind(this)); + } + + async _enrollDevicesIdle() { + let devices = this._devicesToEnroll; + + let dev = devices.shift(); + if (dev === undefined) + return GLib.SOURCE_REMOVE; + + try { + await this._client.enrollDevice(dev.Uid, Policy.DEFAULT); + + /* TODO: scan the list of devices to be authorized for children + * of this device and remove them (and their children and + * their children and ....) from the device queue + */ + this._enrolling = this._devicesToEnroll.length > 0; + + if (this._enrolling) { + GLib.idle_add(GLib.PRIORITY_DEFAULT, + this._enrollDevicesIdle.bind(this)); + } + } catch (error) { + this.emit('enroll-failed', null, error); + } + return GLib.SOURCE_REMOVE; + } +}; + +/* eof client.js */ + +var Indicator = GObject.registerClass( +class Indicator extends SystemIndicator { + _init() { + super._init(); + + this._indicator = this._addIndicator(); + this._indicator.icon_name = 'thunderbolt-symbolic'; + + this._client = new Client(); + this._client.connect('probing-changed', this._onProbing.bind(this)); + + this._robot = new AuthRobot(this._client); + + this._robot.connect('enroll-device', this._onEnrollDevice.bind(this)); + this._robot.connect('enroll-failed', this._onEnrollFailed.bind(this)); + + Main.sessionMode.connect('updated', this._sync.bind(this)); + this._sync(); + + this._source = null; + this._perm = null; + this._createPermission(); + } + + async _createPermission() { + try { + this._perm = await Polkit.Permission.new('org.freedesktop.bolt.enroll', null, null); + } catch (e) { + log(`Failed to get PolKit permission: ${e}`); + } + } + + _onDestroy() { + this._robot.close(); + this._client.close(); + } + + _ensureSource() { + if (!this._source) { + this._source = new MessageTray.Source(_("Thunderbolt"), + 'thunderbolt-symbolic'); + this._source.connect('destroy', () => (this._source = null)); + + Main.messageTray.add(this._source); + } + + return this._source; + } + + _notify(title, body) { + if (this._notification) + this._notification.destroy(); + + let source = this._ensureSource(); + + this._notification = new MessageTray.Notification(source, title, body); + this._notification.setUrgency(MessageTray.Urgency.HIGH); + this._notification.connect('destroy', () => { + this._notification = null; + }); + this._notification.connect('activated', () => { + let app = Shell.AppSystem.get_default().lookup_app('gnome-thunderbolt-panel.desktop'); + if (app) + app.activate(); + }); + this._source.showNotification(this._notification); + } + + /* Session callbacks */ + _sync() { + let active = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter; + this._indicator.visible = active && this._client.probing; + } + + /* Bolt.Client callbacks */ + _onProbing(cli, probing) { + if (probing) + this._indicator.icon_name = 'thunderbolt-acquiring-symbolic'; + else + this._indicator.icon_name = 'thunderbolt-symbolic'; + + this._sync(); + } + + /* AuthRobot callbacks */ + _onEnrollDevice(obj, device, policy) { + /* only authorize new devices when in an unlocked user session */ + let unlocked = !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter; + /* and if we have the permission to do so, otherwise we trigger a PolKit dialog */ + let allowed = this._perm && this._perm.allowed; + + let auth = unlocked && allowed; + policy[0] = auth; + + log(`thunderbolt: [${device.Name}] auto enrollment: ${auth ? 'yes' : 'no'} (allowed: ${allowed ? 'yes' : 'no'})`); + + if (auth) + return; /* we are done */ + + if (!unlocked) { + const title = _("Unknown Thunderbolt device"); + const body = _("New device has been detected while you were away. Please disconnect and reconnect the device to start using it."); + this._notify(title, body); + } else { + const title = _("Unauthorized Thunderbolt device"); + const body = _("New device has been detected and needs to be authorized by an administrator."); + this._notify(title, body); + } + } + + _onEnrollFailed(obj, device, error) { + const title = _("Thunderbolt authorization error"); + const body = _("Could not authorize the Thunderbolt device: %s").format(error.message); + this._notify(title, body); + } +}); diff --git a/js/ui/status/volume.js b/js/ui/status/volume.js new file mode 100644 index 0000000..bd49cc3 --- /dev/null +++ b/js/ui/status/volume.js @@ -0,0 +1,458 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Indicator */ + +const {Clutter, Gio, GLib, GObject, Gvc} = imports.gi; + +const Main = imports.ui.main; +const PopupMenu = imports.ui.popupMenu; + +const {QuickSlider, SystemIndicator} = imports.ui.quickSettings; + +const ALLOW_AMPLIFIED_VOLUME_KEY = 'allow-volume-above-100-percent'; + +// Each Gvc.MixerControl is a connection to PulseAudio, +// so it's better to make it a singleton +let _mixerControl; +/** + * @returns {Gvc.MixerControl} - the mixer control singleton + */ +function getMixerControl() { + if (_mixerControl) + return _mixerControl; + + _mixerControl = new Gvc.MixerControl({ name: 'GNOME Shell Volume Control' }); + _mixerControl.open(); + + return _mixerControl; +} + +const StreamSlider = GObject.registerClass({ + Signals: { + 'stream-updated': {}, + }, +}, class StreamSlider extends QuickSlider { + _init(control) { + super._init(); + + this._control = control; + + this._inDrag = false; + this._notifyVolumeChangeId = 0; + + this._soundSettings = new Gio.Settings({ + schema_id: 'org.gnome.desktop.sound', + }); + this._soundSettings.connect(`changed::${ALLOW_AMPLIFIED_VOLUME_KEY}`, + () => this._amplifySettingsChanged()); + this._amplifySettingsChanged(); + + this._sliderChangedId = this.slider.connect('notify::value', + () => this._sliderChanged()); + this.slider.connect('drag-begin', () => (this._inDrag = true)); + this.slider.connect('drag-end', () => { + this._inDrag = false; + this._notifyVolumeChange(); + }); + + this._deviceItems = new Map(); + + this._deviceSection = new PopupMenu.PopupMenuSection(); + this.menu.addMenuItem(this._deviceSection); + + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + this.menu.addSettingsAction(_('Sound Settings'), + 'gnome-sound-panel.desktop'); + + this._stream = null; + this._volumeCancellable = null; + this._icons = []; + + this._sync(); + } + + get stream() { + return this._stream; + } + + set stream(stream) { + this._stream?.disconnectObject(this); + + this._stream = stream; + + if (this._stream) { + this._connectStream(this._stream); + this._updateVolume(); + } else { + this.emit('stream-updated'); + } + + this._sync(); + } + + _connectStream(stream) { + stream.connectObject( + 'notify::is-muted', this._updateVolume.bind(this), + 'notify::volume', this._updateVolume.bind(this), this); + } + + _lookupDevice(_id) { + throw new GObject.NotImplementedError( + `_lookupDevice in ${this.constructor.name}`); + } + + _activateDevice(_device) { + throw new GObject.NotImplementedError( + `_activateDevice in ${this.constructor.name}`); + } + + _addDevice(id) { + if (this._deviceItems.has(id)) + return; + + const device = this._lookupDevice(id); + if (!device) + return; + + const {description, origin} = device; + const name = origin + ? `${description} – ${origin}` + : description; + const item = new PopupMenu.PopupImageMenuItem(name, device.get_gicon()); + item.connect('activate', () => this._activateDevice(device)); + + this._deviceSection.addMenuItem(item); + this._deviceItems.set(id, item); + + this._sync(); + } + + _removeDevice(id) { + this._deviceItems.get(id)?.destroy(); + if (this._deviceItems.delete(id)) + this._sync(); + } + + _setActiveDevice(activeId) { + for (const [id, item] of this._deviceItems) { + item.setOrnament(id === activeId + ? PopupMenu.Ornament.CHECK + : PopupMenu.Ornament.NONE); + } + } + + _shouldBeVisible() { + return this._stream != null; + } + + _sync() { + this.visible = this._shouldBeVisible(); + this.menuEnabled = this._deviceItems.size > 1; + } + + _sliderChanged() { + if (!this._stream) + return; + + let value = this.slider.value; + let volume = value * this._control.get_vol_max_norm(); + let prevMuted = this._stream.is_muted; + let prevVolume = this._stream.volume; + if (volume < 1) { + this._stream.volume = 0; + if (!prevMuted) + this._stream.change_is_muted(true); + } else { + this._stream.volume = volume; + if (prevMuted) + this._stream.change_is_muted(false); + } + this._stream.push_volume(); + + let volumeChanged = this._stream.volume !== prevVolume; + if (volumeChanged && !this._notifyVolumeChangeId && !this._inDrag) { + this._notifyVolumeChangeId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 30, () => { + this._notifyVolumeChange(); + this._notifyVolumeChangeId = 0; + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._notifyVolumeChangeId, + '[gnome-shell] this._notifyVolumeChangeId'); + } + } + + _notifyVolumeChange() { + if (this._volumeCancellable) + this._volumeCancellable.cancel(); + this._volumeCancellable = null; + + if (this._stream.state === Gvc.MixerStreamState.RUNNING) + return; // feedback not necessary while playing + + this._volumeCancellable = new Gio.Cancellable(); + let player = global.display.get_sound_player(); + player.play_from_theme('audio-volume-change', + _('Volume changed'), this._volumeCancellable); + } + + _changeSlider(value) { + this.slider.block_signal_handler(this._sliderChangedId); + this.slider.value = value; + this.slider.unblock_signal_handler(this._sliderChangedId); + } + + _updateVolume() { + let muted = this._stream.is_muted; + this._changeSlider(muted + ? 0 : this._stream.volume / this._control.get_vol_max_norm()); + this.emit('stream-updated'); + } + + _amplifySettingsChanged() { + this._allowAmplified = this._soundSettings.get_boolean(ALLOW_AMPLIFIED_VOLUME_KEY); + + this.slider.maximum_value = this._allowAmplified + ? this.getMaxLevel() : 1; + + if (this._stream) + this._updateVolume(); + } + + getIcon() { + if (!this._stream) + return null; + + let volume = this._stream.volume; + let n; + if (this._stream.is_muted || volume <= 0) { + n = 0; + } else { + n = Math.ceil(3 * volume / this._control.get_vol_max_norm()); + n = Math.clamp(n, 1, this._icons.length - 1); + } + return this._icons[n]; + } + + getLevel() { + if (!this._stream) + return null; + + return this._stream.volume / this._control.get_vol_max_norm(); + } + + getMaxLevel() { + let maxVolume = this._control.get_vol_max_norm(); + if (this._allowAmplified) + maxVolume = this._control.get_vol_max_amplified(); + + return maxVolume / this._control.get_vol_max_norm(); + } +}); + +const OutputStreamSlider = GObject.registerClass( +class OutputStreamSlider extends StreamSlider { + _init(control) { + super._init(control); + + this.slider.accessible_name = _('Volume'); + + this._control.connectObject( + 'output-added', (c, id) => this._addDevice(id), + 'output-removed', (c, id) => this._removeDevice(id), + 'active-output-update', (c, id) => this._setActiveDevice(id), + this); + + this._icons = [ + 'audio-volume-muted-symbolic', + 'audio-volume-low-symbolic', + 'audio-volume-medium-symbolic', + 'audio-volume-high-symbolic', + 'audio-volume-overamplified-symbolic', + ]; + + this.menu.setHeader('audio-headphones-symbolic', _('Sound Output')); + } + + _connectStream(stream) { + super._connectStream(stream); + stream.connectObject('notify::port', + this._portChanged.bind(this), this); + this._portChanged(); + } + + _lookupDevice(id) { + return this._control.lookup_output_id(id); + } + + _activateDevice(device) { + this._control.change_output(device); + } + + _findHeadphones(sink) { + // This only works for external headphones (e.g. bluetooth) + if (sink.get_form_factor() == 'headset' || + sink.get_form_factor() == 'headphone') + return true; + + // a bit hackish, but ALSA/PulseAudio have a number + // of different identifiers for headphones, and I could + // not find the complete list + if (sink.get_ports().length > 0) + return sink.get_port().port.includes('headphone'); + + return false; + } + + _portChanged() { + const hasHeadphones = this._findHeadphones(this._stream); + if (hasHeadphones === this._hasHeadphones) + return; + + this._hasHeadphones = hasHeadphones; + this.iconName = this._hasHeadphones + ? 'audio-headphones-symbolic' + : 'audio-speakers-symbolic'; + } +}); + +const InputStreamSlider = GObject.registerClass( +class InputStreamSlider extends StreamSlider { + _init(control) { + super._init(control); + + this.slider.accessible_name = _('Microphone'); + + this._control.connectObject( + 'input-added', (c, id) => this._addDevice(id), + 'input-removed', (c, id) => this._removeDevice(id), + 'active-input-update', (c, id) => this._setActiveDevice(id), + 'stream-added', () => this._maybeShowInput(), + 'stream-removed', () => this._maybeShowInput(), + this); + + this.iconName = 'audio-input-microphone-symbolic'; + this._icons = [ + 'microphone-sensitivity-muted-symbolic', + 'microphone-sensitivity-low-symbolic', + 'microphone-sensitivity-medium-symbolic', + 'microphone-sensitivity-high-symbolic', + ]; + + this.menu.setHeader('audio-input-microphone-symbolic', _('Sound Input')); + } + + _connectStream(stream) { + super._connectStream(stream); + this._maybeShowInput(); + } + + _lookupDevice(id) { + return this._control.lookup_input_id(id); + } + + _activateDevice(device) { + this._control.change_input(device); + } + + _maybeShowInput() { + // only show input widgets if any application is recording audio + let showInput = false; + if (this._stream) { + // skip gnome-volume-control and pavucontrol which appear + // as recording because they show the input level + let skippedApps = [ + 'org.gnome.VolumeControl', + 'org.PulseAudio.pavucontrol', + ]; + + showInput = this._control.get_source_outputs().some( + output => !skippedApps.includes(output.get_application_id())); + } + + this._showInput = showInput; + this._sync(); + } + + _shouldBeVisible() { + return super._shouldBeVisible() && this._showInput; + } +}); + +var Indicator = GObject.registerClass( +class Indicator extends SystemIndicator { + _init() { + super._init(); + + this._primaryIndicator = this._addIndicator(); + this._inputIndicator = this._addIndicator(); + + this._primaryIndicator.reactive = true; + this._inputIndicator.reactive = true; + + this._primaryIndicator.connect('scroll-event', + (actor, event) => this._handleScrollEvent(this._output, event)); + this._inputIndicator.connect('scroll-event', + (actor, event) => this._handleScrollEvent(this._input, event)); + + this._control = getMixerControl(); + this._control.connectObject( + 'state-changed', () => this._onControlStateChanged(), + 'default-sink-changed', () => this._readOutput(), + 'default-source-changed', () => this._readInput(), + this); + + this._output = new OutputStreamSlider(this._control); + this._output.connect('stream-updated', () => { + const icon = this._output.getIcon(); + + if (icon) + this._primaryIndicator.icon_name = icon; + this._primaryIndicator.visible = icon !== null; + }); + + this._input = new InputStreamSlider(this._control); + this._input.connect('stream-updated', () => { + const icon = this._input.getIcon(); + + if (icon) + this._inputIndicator.icon_name = icon; + }); + + this._input.bind_property('visible', + this._inputIndicator, 'visible', + GObject.BindingFlags.SYNC_CREATE); + + this.quickSettingsItems.push(this._output); + this.quickSettingsItems.push(this._input); + + this._onControlStateChanged(); + } + + _onControlStateChanged() { + if (this._control.get_state() === Gvc.MixerControlState.READY) { + this._readInput(); + this._readOutput(); + } else { + this._primaryIndicator.hide(); + } + } + + _readOutput() { + this._output.stream = this._control.get_default_sink(); + } + + _readInput() { + this._input.stream = this._control.get_default_source(); + } + + _handleScrollEvent(item, event) { + const result = item.slider.scroll(event); + if (result === Clutter.EVENT_PROPAGATE || item.mapped) + return result; + + const gicon = new Gio.ThemedIcon({name: item.getIcon()}); + const level = item.getLevel(); + const maxLevel = item.getMaxLevel(); + Main.osdWindowManager.show(-1, gicon, null, level, maxLevel); + return result; + } +}); diff --git a/js/ui/swipeTracker.js b/js/ui/swipeTracker.js new file mode 100644 index 0000000..869f977 --- /dev/null +++ b/js/ui/swipeTracker.js @@ -0,0 +1,787 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported SwipeTracker */ + +const { Clutter, Gio, GObject, Meta } = imports.gi; + +const Main = imports.ui.main; +const Params = imports.misc.params; + +// FIXME: ideally these values matches physical touchpad size. We can get the +// correct values for gnome-shell specifically, since mutter uses libinput +// directly, but GTK apps cannot get it, so use an arbitrary value so that +// it's consistent with apps. +const TOUCHPAD_BASE_HEIGHT = 300; +const TOUCHPAD_BASE_WIDTH = 400; + +const EVENT_HISTORY_THRESHOLD_MS = 150; + +const SCROLL_MULTIPLIER = 10; + +const MIN_ANIMATION_DURATION = 100; +const MAX_ANIMATION_DURATION = 400; +const VELOCITY_THRESHOLD_TOUCH = 0.3; +const VELOCITY_THRESHOLD_TOUCHPAD = 0.6; +const DECELERATION_TOUCH = 0.998; +const DECELERATION_TOUCHPAD = 0.997; +const VELOCITY_CURVE_THRESHOLD = 2; +const DECELERATION_PARABOLA_MULTIPLIER = 0.35; +const DRAG_THRESHOLD_DISTANCE = 16; + +// Derivative of easeOutCubic at t=0 +const DURATION_MULTIPLIER = 3; +const ANIMATION_BASE_VELOCITY = 0.002; +const EPSILON = 0.005; + +const GESTURE_FINGER_COUNT = 3; + +const State = { + NONE: 0, + SCROLLING: 1, +}; + +const TouchpadState = { + NONE: 0, + PENDING: 1, + HANDLING: 2, + IGNORED: 3, +}; + +const EventHistory = class { + constructor() { + this.reset(); + } + + reset() { + this._data = []; + } + + trim(time) { + const thresholdTime = time - EVENT_HISTORY_THRESHOLD_MS; + const index = this._data.findIndex(r => r.time >= thresholdTime); + + this._data.splice(0, index); + } + + append(time, delta) { + this.trim(time); + + this._data.push({ time, delta }); + } + + calculateVelocity() { + if (this._data.length < 2) + return 0; + + const firstTime = this._data[0].time; + const lastTime = this._data[this._data.length - 1].time; + + if (firstTime === lastTime) + return 0; + + const totalDelta = this._data.slice(1).map(a => a.delta).reduce((a, b) => a + b); + const period = lastTime - firstTime; + + return totalDelta / period; + } +}; + +const TouchpadSwipeGesture = GObject.registerClass({ + Properties: { + 'enabled': GObject.ParamSpec.boolean( + 'enabled', 'enabled', 'enabled', + GObject.ParamFlags.READWRITE, + true), + 'orientation': GObject.ParamSpec.enum( + 'orientation', 'orientation', 'orientation', + GObject.ParamFlags.READWRITE, + Clutter.Orientation, Clutter.Orientation.HORIZONTAL), + }, + Signals: { + 'begin': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] }, + 'update': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] }, + 'end': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE] }, + }, +}, class TouchpadSwipeGesture extends GObject.Object { + _init(allowedModes) { + super._init(); + this._allowedModes = allowedModes; + this._state = TouchpadState.NONE; + this._cumulativeX = 0; + this._cumulativeY = 0; + this._touchpadSettings = new Gio.Settings({ + schema_id: 'org.gnome.desktop.peripherals.touchpad', + }); + + global.stage.connectObject( + 'captured-event::touchpad', this._handleEvent.bind(this), this); + } + + _handleEvent(actor, event) { + if (event.type() !== Clutter.EventType.TOUCHPAD_SWIPE) + return Clutter.EVENT_PROPAGATE; + + if (event.get_gesture_phase() === Clutter.TouchpadGesturePhase.BEGIN) + this._state = TouchpadState.NONE; + + if (event.get_touchpad_gesture_finger_count() !== GESTURE_FINGER_COUNT) + return Clutter.EVENT_PROPAGATE; + + if ((this._allowedModes & Main.actionMode) === 0) + return Clutter.EVENT_PROPAGATE; + + if (!this.enabled) + return Clutter.EVENT_PROPAGATE; + + if (this._state === TouchpadState.IGNORED) + return Clutter.EVENT_PROPAGATE; + + let time = event.get_time(); + + const [x, y] = event.get_coords(); + const [dx, dy] = event.get_gesture_motion_delta_unaccelerated(); + + if (this._state === TouchpadState.NONE) { + if (dx === 0 && dy === 0) + return Clutter.EVENT_PROPAGATE; + + this._cumulativeX = 0; + this._cumulativeY = 0; + this._state = TouchpadState.PENDING; + } + + if (this._state === TouchpadState.PENDING) { + this._cumulativeX += dx; + this._cumulativeY += dy; + + const cdx = this._cumulativeX; + const cdy = this._cumulativeY; + const distance = Math.sqrt(cdx * cdx + cdy * cdy); + + if (distance >= DRAG_THRESHOLD_DISTANCE) { + const gestureOrientation = Math.abs(cdx) > Math.abs(cdy) + ? Clutter.Orientation.HORIZONTAL + : Clutter.Orientation.VERTICAL; + + this._cumulativeX = 0; + this._cumulativeY = 0; + + if (gestureOrientation === this.orientation) { + this._state = TouchpadState.HANDLING; + this.emit('begin', time, x, y); + } else { + this._state = TouchpadState.IGNORED; + return Clutter.EVENT_PROPAGATE; + } + } else { + return Clutter.EVENT_PROPAGATE; + } + } + + const vertical = this.orientation === Clutter.Orientation.VERTICAL; + let delta = vertical ? dy : dx; + const distance = vertical ? TOUCHPAD_BASE_HEIGHT : TOUCHPAD_BASE_WIDTH; + + switch (event.get_gesture_phase()) { + case Clutter.TouchpadGesturePhase.BEGIN: + case Clutter.TouchpadGesturePhase.UPDATE: + if (this._touchpadSettings.get_boolean('natural-scroll')) + delta = -delta; + + this.emit('update', time, delta, distance); + break; + + case Clutter.TouchpadGesturePhase.END: + case Clutter.TouchpadGesturePhase.CANCEL: + this.emit('end', time, distance); + this._state = TouchpadState.NONE; + break; + } + + return this._state === TouchpadState.HANDLING + ? Clutter.EVENT_STOP + : Clutter.EVENT_PROPAGATE; + } + + destroy() { + global.stage.disconnectObject(this); + } +}); + +const TouchSwipeGesture = GObject.registerClass({ + Properties: { + 'distance': GObject.ParamSpec.double( + 'distance', 'distance', 'distance', + GObject.ParamFlags.READWRITE, + 0, Infinity, 0), + 'orientation': GObject.ParamSpec.enum( + 'orientation', 'orientation', 'orientation', + GObject.ParamFlags.READWRITE, + Clutter.Orientation, Clutter.Orientation.HORIZONTAL), + }, + Signals: { + 'begin': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] }, + 'update': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] }, + 'end': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE] }, + 'cancel': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE] }, + }, +}, class TouchSwipeGesture extends Clutter.GestureAction { + _init(allowedModes, nTouchPoints, thresholdTriggerEdge) { + super._init(); + this.set_n_touch_points(nTouchPoints); + this.set_threshold_trigger_edge(thresholdTriggerEdge); + + this._allowedModes = allowedModes; + this._distance = global.screen_height; + + global.display.connect('grab-op-begin', () => { + this.cancel(); + }); + + this._lastPosition = 0; + } + + get distance() { + return this._distance; + } + + set distance(distance) { + if (this._distance === distance) + return; + + this._distance = distance; + this.notify('distance'); + } + + vfunc_gesture_prepare(actor) { + if (!super.vfunc_gesture_prepare(actor)) + return false; + + if ((this._allowedModes & Main.actionMode) === 0) + return false; + + let time = this.get_last_event(0).get_time(); + let [xPress, yPress] = this.get_press_coords(0); + let [x, y] = this.get_motion_coords(0); + const [xDelta, yDelta] = [x - xPress, y - yPress]; + const swipeOrientation = Math.abs(xDelta) > Math.abs(yDelta) + ? Clutter.Orientation.HORIZONTAL : Clutter.Orientation.VERTICAL; + + if (swipeOrientation !== this.orientation) + return false; + + this._lastPosition = + this.orientation === Clutter.Orientation.VERTICAL ? y : x; + + this.emit('begin', time, xPress, yPress); + return true; + } + + vfunc_gesture_progress(_actor) { + let [x, y] = this.get_motion_coords(0); + let pos = this.orientation === Clutter.Orientation.VERTICAL ? y : x; + + let delta = pos - this._lastPosition; + this._lastPosition = pos; + + let time = this.get_last_event(0).get_time(); + + this.emit('update', time, -delta, this._distance); + + return true; + } + + vfunc_gesture_end(_actor) { + let time = this.get_last_event(0).get_time(); + + this.emit('end', time, this._distance); + } + + vfunc_gesture_cancel(_actor) { + let time = Clutter.get_current_event_time(); + + this.emit('cancel', time, this._distance); + } +}); + +const ScrollGesture = GObject.registerClass({ + Properties: { + 'enabled': GObject.ParamSpec.boolean( + 'enabled', 'enabled', 'enabled', + GObject.ParamFlags.READWRITE, + true), + 'orientation': GObject.ParamSpec.enum( + 'orientation', 'orientation', 'orientation', + GObject.ParamFlags.READWRITE, + Clutter.Orientation, Clutter.Orientation.HORIZONTAL), + 'scroll-modifiers': GObject.ParamSpec.flags( + 'scroll-modifiers', 'scroll-modifiers', 'scroll-modifiers', + GObject.ParamFlags.READWRITE, + Clutter.ModifierType, 0), + }, + Signals: { + 'begin': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] }, + 'update': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE] }, + 'end': { param_types: [GObject.TYPE_UINT, GObject.TYPE_DOUBLE] }, + }, +}, class ScrollGesture extends GObject.Object { + _init(actor, allowedModes) { + super._init(); + this._allowedModes = allowedModes; + this._began = false; + this._enabled = true; + + actor.connect('scroll-event', this._handleEvent.bind(this)); + } + + get enabled() { + return this._enabled; + } + + set enabled(enabled) { + if (this._enabled === enabled) + return; + + this._enabled = enabled; + this._began = false; + + this.notify('enabled'); + } + + canHandleEvent(event) { + if (event.type() !== Clutter.EventType.SCROLL) + return false; + + if (event.get_scroll_source() !== Clutter.ScrollSource.FINGER && + event.get_source_device().get_device_type() !== Clutter.InputDeviceType.TOUCHPAD_DEVICE) + return false; + + if (!this.enabled) + return false; + + if ((this._allowedModes & Main.actionMode) === 0) + return false; + + if (!this._began && this.scrollModifiers !== 0 && + (event.get_state() & this.scrollModifiers) === 0) + return false; + + return true; + } + + _handleEvent(actor, event) { + if (!this.canHandleEvent(event)) + return Clutter.EVENT_PROPAGATE; + + if (event.get_scroll_direction() !== Clutter.ScrollDirection.SMOOTH) + return Clutter.EVENT_PROPAGATE; + + const vertical = this.orientation === Clutter.Orientation.VERTICAL; + const distance = vertical ? TOUCHPAD_BASE_HEIGHT : TOUCHPAD_BASE_WIDTH; + + let time = event.get_time(); + let [dx, dy] = event.get_scroll_delta(); + if (dx === 0 && dy === 0) { + this.emit('end', time, distance); + this._began = false; + return Clutter.EVENT_STOP; + } + + if (!this._began) { + let [x, y] = event.get_coords(); + this.emit('begin', time, x, y); + this._began = true; + } + + const delta = (vertical ? dy : dx) * SCROLL_MULTIPLIER; + + this.emit('update', time, delta, distance); + + return Clutter.EVENT_STOP; + } +}); + +// USAGE: +// +// To correctly implement the gesture, there must be handlers for the following +// signals: +// +// begin(tracker, monitor) +// The handler should check whether a deceleration animation is currently +// running. If it is, it should stop the animation (without resetting +// progress). Then it should call: +// tracker.confirmSwipe(distance, snapPoints, currentProgress, cancelProgress) +// If it's not called, the swipe would be ignored. +// The parameters are: +// * distance: the page size; +// * snapPoints: an (sorted with ascending order) array of snap points; +// * currentProgress: the current progress; +// * cancelprogress: a non-transient value that would be used if the gesture +// is cancelled. +// If no animation was running, currentProgress and cancelProgress should be +// same. The handler may set 'orientation' property here. +// +// update(tracker, progress) +// The handler should set the progress to the given value. +// +// end(tracker, duration, endProgress) +// The handler should animate the progress to endProgress. If endProgress is +// 0, it should do nothing after the animation, otherwise it should change the +// state, e.g. change the current page or switch workspace. +// NOTE: duration can be 0 in some cases, in this case it should finish +// instantly. + +/** A class for handling swipe gestures */ +var SwipeTracker = GObject.registerClass({ + Properties: { + 'enabled': GObject.ParamSpec.boolean( + 'enabled', 'enabled', 'enabled', + GObject.ParamFlags.READWRITE, + true), + 'orientation': GObject.ParamSpec.enum( + 'orientation', 'orientation', 'orientation', + GObject.ParamFlags.READWRITE, + Clutter.Orientation, Clutter.Orientation.HORIZONTAL), + 'distance': GObject.ParamSpec.double( + 'distance', 'distance', 'distance', + GObject.ParamFlags.READWRITE, + 0, Infinity, 0), + 'allow-long-swipes': GObject.ParamSpec.boolean( + 'allow-long-swipes', 'allow-long-swipes', 'allow-long-swipes', + GObject.ParamFlags.READWRITE, + false), + 'scroll-modifiers': GObject.ParamSpec.flags( + 'scroll-modifiers', 'scroll-modifiers', 'scroll-modifiers', + GObject.ParamFlags.READWRITE, + Clutter.ModifierType, 0), + }, + Signals: { + 'begin': { param_types: [GObject.TYPE_UINT] }, + 'update': { param_types: [GObject.TYPE_DOUBLE] }, + 'end': { param_types: [GObject.TYPE_UINT64, GObject.TYPE_DOUBLE] }, + }, +}, class SwipeTracker extends GObject.Object { + _init(actor, orientation, allowedModes, params) { + super._init(); + params = Params.parse(params, { allowDrag: true, allowScroll: true }); + + this.orientation = orientation; + this._allowedModes = allowedModes; + this._enabled = true; + this._distance = global.screen_height; + this._history = new EventHistory(); + this._reset(); + + this._touchpadGesture = new TouchpadSwipeGesture(allowedModes); + this._touchpadGesture.connect('begin', this._beginGesture.bind(this)); + this._touchpadGesture.connect('update', this._updateGesture.bind(this)); + this._touchpadGesture.connect('end', this._endTouchpadGesture.bind(this)); + this.bind_property('enabled', this._touchpadGesture, 'enabled', 0); + this.bind_property('orientation', this._touchpadGesture, 'orientation', + GObject.BindingFlags.SYNC_CREATE); + + this._touchGesture = new TouchSwipeGesture(allowedModes, + GESTURE_FINGER_COUNT, + Clutter.GestureTriggerEdge.AFTER); + this._touchGesture.connect('begin', this._beginTouchSwipe.bind(this)); + this._touchGesture.connect('update', this._updateGesture.bind(this)); + this._touchGesture.connect('end', this._endTouchGesture.bind(this)); + this._touchGesture.connect('cancel', this._cancelTouchGesture.bind(this)); + this.bind_property('enabled', this._touchGesture, 'enabled', 0); + this.bind_property('orientation', this._touchGesture, 'orientation', + GObject.BindingFlags.SYNC_CREATE); + this.bind_property('distance', this._touchGesture, 'distance', 0); + global.stage.add_action_full('swipe', Clutter.EventPhase.CAPTURE, this._touchGesture); + + if (params.allowDrag) { + this._dragGesture = new TouchSwipeGesture(allowedModes, 1, + Clutter.GestureTriggerEdge.AFTER); + this._dragGesture.connect('begin', this._beginGesture.bind(this)); + this._dragGesture.connect('update', this._updateGesture.bind(this)); + this._dragGesture.connect('end', this._endTouchGesture.bind(this)); + this._dragGesture.connect('cancel', this._cancelTouchGesture.bind(this)); + this.bind_property('enabled', this._dragGesture, 'enabled', 0); + this.bind_property('orientation', this._dragGesture, 'orientation', + GObject.BindingFlags.SYNC_CREATE); + this.bind_property('distance', this._dragGesture, 'distance', 0); + actor.add_action_full('drag', Clutter.EventPhase.CAPTURE, this._dragGesture); + } else { + this._dragGesture = null; + } + + if (params.allowScroll) { + this._scrollGesture = new ScrollGesture(actor, allowedModes); + this._scrollGesture.connect('begin', this._beginGesture.bind(this)); + this._scrollGesture.connect('update', this._updateGesture.bind(this)); + this._scrollGesture.connect('end', this._endTouchpadGesture.bind(this)); + this.bind_property('enabled', this._scrollGesture, 'enabled', 0); + this.bind_property('orientation', this._scrollGesture, 'orientation', + GObject.BindingFlags.SYNC_CREATE); + this.bind_property('scroll-modifiers', + this._scrollGesture, 'scroll-modifiers', 0); + } else { + this._scrollGesture = null; + } + } + + /** + * canHandleScrollEvent: + * @param {Clutter.Event} scrollEvent: an event to check + * @returns {bool} whether the event can be handled by the tracker + * + * This function can be used to combine swipe gesture and mouse + * scrolling. + */ + canHandleScrollEvent(scrollEvent) { + if (!this.enabled || this._scrollGesture === null) + return false; + + return this._scrollGesture.canHandleEvent(scrollEvent); + } + + get enabled() { + return this._enabled; + } + + set enabled(enabled) { + if (this._enabled === enabled) + return; + + this._enabled = enabled; + if (!enabled && this._state === State.SCROLLING) + this._interrupt(); + this.notify('enabled'); + } + + get distance() { + return this._distance; + } + + set distance(distance) { + if (this._distance === distance) + return; + + this._distance = distance; + this.notify('distance'); + } + + _reset() { + this._state = State.NONE; + + this._snapPoints = []; + this._initialProgress = 0; + this._cancelProgress = 0; + + this._prevOffset = 0; + this._progress = 0; + + this._cancelled = false; + + this._history.reset(); + } + + _interrupt() { + this.emit('end', 0, this._cancelProgress); + this._reset(); + } + + _beginTouchSwipe(gesture, time, x, y) { + if (this._dragGesture) + this._dragGesture.cancel(); + + this._beginGesture(gesture, time, x, y); + } + + _beginGesture(gesture, time, x, y) { + if (this._state === State.SCROLLING) + return; + + this._history.append(time, 0); + + let rect = new Meta.Rectangle({ x, y, width: 1, height: 1 }); + let monitor = global.display.get_monitor_index_for_rect(rect); + + this.emit('begin', monitor); + } + + _findClosestPoint(pos) { + const distances = this._snapPoints.map(x => Math.abs(x - pos)); + const min = Math.min(...distances); + return distances.indexOf(min); + } + + _findNextPoint(pos) { + return this._snapPoints.findIndex(p => p >= pos); + } + + _findPreviousPoint(pos) { + const reversedIndex = this._snapPoints.slice().reverse().findIndex(p => p <= pos); + return this._snapPoints.length - 1 - reversedIndex; + } + + _findPointForProjection(pos, velocity) { + const initial = this._findClosestPoint(this._initialProgress); + const prev = this._findPreviousPoint(pos); + const next = this._findNextPoint(pos); + + if ((velocity > 0 ? prev : next) === initial) + return velocity > 0 ? next : prev; + + return this._findClosestPoint(pos); + } + + _getBounds(pos) { + if (this.allowLongSwipes) + return [this._snapPoints[0], this._snapPoints[this._snapPoints.length - 1]]; + + const closest = this._findClosestPoint(pos); + + let prev, next; + if (Math.abs(this._snapPoints[closest] - pos) < EPSILON) { + prev = next = closest; + } else { + prev = this._findPreviousPoint(pos); + next = this._findNextPoint(pos); + } + + const lowerIndex = Math.max(prev - 1, 0); + const upperIndex = Math.min(next + 1, this._snapPoints.length - 1); + + return [this._snapPoints[lowerIndex], this._snapPoints[upperIndex]]; + } + + _updateGesture(gesture, time, delta, distance) { + if (this._state !== State.SCROLLING) + return; + + if ((this._allowedModes & Main.actionMode) === 0 || !this.enabled) { + this._interrupt(); + return; + } + + if (this.orientation === Clutter.Orientation.HORIZONTAL && + Clutter.get_default_text_direction() === Clutter.TextDirection.RTL) + delta = -delta; + + this._progress += delta / distance; + this._history.append(time, delta); + + this._progress = Math.clamp(this._progress, ...this._getBounds(this._initialProgress)); + + this.emit('update', this._progress); + } + + _getEndProgress(velocity, distance, isTouchpad) { + if (this._cancelled) + return this._cancelProgress; + + const threshold = isTouchpad ? VELOCITY_THRESHOLD_TOUCHPAD : VELOCITY_THRESHOLD_TOUCH; + + if (Math.abs(velocity) < threshold) + return this._snapPoints[this._findClosestPoint(this._progress)]; + + const decel = isTouchpad ? DECELERATION_TOUCHPAD : DECELERATION_TOUCH; + const slope = decel / (1.0 - decel) / 1000.0; + + let pos; + if (Math.abs(velocity) > VELOCITY_CURVE_THRESHOLD) { + const c = slope / 2 / DECELERATION_PARABOLA_MULTIPLIER; + const x = Math.abs(velocity) - VELOCITY_CURVE_THRESHOLD + c; + + pos = slope * VELOCITY_CURVE_THRESHOLD + + DECELERATION_PARABOLA_MULTIPLIER * x * x - + DECELERATION_PARABOLA_MULTIPLIER * c * c; + } else { + pos = Math.abs(velocity) * slope; + } + + pos = pos * Math.sign(velocity) + this._progress; + pos = Math.clamp(pos, ...this._getBounds(this._initialProgress)); + + const index = this._findPointForProjection(pos, velocity); + + return this._snapPoints[index]; + } + + _endTouchGesture(_gesture, time, distance) { + this._endGesture(time, distance, false); + } + + _endTouchpadGesture(_gesture, time, distance) { + this._endGesture(time, distance, true); + } + + _endGesture(time, distance, isTouchpad) { + if (this._state !== State.SCROLLING) + return; + + if ((this._allowedModes & Main.actionMode) === 0 || !this.enabled) { + this._interrupt(); + return; + } + + this._history.trim(time); + + let velocity = this._history.calculateVelocity(); + const endProgress = this._getEndProgress(velocity, distance, isTouchpad); + + velocity /= distance; + + if ((endProgress - this._progress) * velocity <= 0) + velocity = ANIMATION_BASE_VELOCITY; + + const nPoints = Math.max(1, Math.ceil(Math.abs(this._progress - endProgress))); + const maxDuration = MAX_ANIMATION_DURATION * Math.log2(1 + nPoints); + + let duration = Math.abs((this._progress - endProgress) / velocity * DURATION_MULTIPLIER); + if (duration > 0) + duration = Math.clamp(duration, MIN_ANIMATION_DURATION, maxDuration); + + this._reset(); + this.emit('end', duration, endProgress); + } + + _cancelTouchGesture(_gesture, time, distance) { + if (this._state !== State.SCROLLING) + return; + + this._cancelled = true; + this._endGesture(time, distance, false); + } + + /** + * confirmSwipe: + * @param {number} distance: swipe distance in pixels + * @param {number[]} snapPoints: + * An array of snap points, sorted in ascending order + * @param {number} currentProgress: initial progress value + * @param {number} cancelProgress: the value to be used on cancelling + * + * Confirms a swipe. User has to call this in 'begin' signal handler, + * otherwise the swipe wouldn't start. If there's an animation running, + * it should be stopped first. + * + * @cancel_progress must always be a snap point, or a value matching + * some other non-transient state. + */ + confirmSwipe(distance, snapPoints, currentProgress, cancelProgress) { + this.distance = distance; + this._snapPoints = snapPoints; + this._initialProgress = currentProgress; + this._progress = currentProgress; + this._cancelProgress = cancelProgress; + + this._state = State.SCROLLING; + } + + destroy() { + if (this._touchpadGesture) { + this._touchpadGesture.destroy(); + delete this._touchpadGesture; + } + + if (this._touchGesture) { + global.stage.remove_action(this._touchGesture); + delete this._touchGesture; + } + } +}); diff --git a/js/ui/switchMonitor.js b/js/ui/switchMonitor.js new file mode 100644 index 0000000..a95c5fa --- /dev/null +++ b/js/ui/switchMonitor.js @@ -0,0 +1,122 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported SwitchMonitorPopup */ + +const { Clutter, GObject, Meta, St } = imports.gi; + +const SwitcherPopup = imports.ui.switcherPopup; + +var APP_ICON_SIZE = 96; + +var SwitchMonitorPopup = GObject.registerClass( +class SwitchMonitorPopup extends SwitcherPopup.SwitcherPopup { + _init() { + let items = []; + + items.push({ + icon: 'view-mirror-symbolic', + /* Translators: this is for display mirroring i.e. cloning. + * Try to keep it under around 15 characters. + */ + label: _('Mirror'), + configType: Meta.MonitorSwitchConfigType.ALL_MIRROR, + }); + + items.push({ + icon: 'video-joined-displays-symbolic', + /* Translators: this is for the desktop spanning displays. + * Try to keep it under around 15 characters. + */ + label: _('Join Displays'), + configType: Meta.MonitorSwitchConfigType.ALL_LINEAR, + }); + + if (global.backend.get_monitor_manager().has_builtin_panel) { + items.push({ + icon: 'video-single-display-symbolic', + /* Translators: this is for using only an external display. + * Try to keep it under around 15 characters. + */ + label: _('External Only'), + configType: Meta.MonitorSwitchConfigType.EXTERNAL, + }); + items.push({ + icon: 'computer-symbolic', + /* Translators: this is for using only the laptop display. + * Try to keep it under around 15 characters. + */ + label: _('Built-in Only'), + configType: Meta.MonitorSwitchConfigType.BUILTIN, + }); + } + + super._init(items); + + this._switcherList = new SwitchMonitorSwitcher(items); + } + + show(backward, binding, mask) { + if (!Meta.MonitorManager.get().can_switch_config()) + return false; + + return super.show(backward, binding, mask); + } + + _initialSelection() { + let currentConfig = Meta.MonitorManager.get().get_switch_config(); + let selectConfig = (currentConfig + 1) % this._items.length; + this._select(selectConfig); + } + + _keyPressHandler(keysym, action) { + if (action == Meta.KeyBindingAction.SWITCH_MONITOR) + this._select(this._next()); + else if (keysym == Clutter.KEY_Left) + this._select(this._previous()); + else if (keysym == Clutter.KEY_Right) + this._select(this._next()); + else + return Clutter.EVENT_PROPAGATE; + + return Clutter.EVENT_STOP; + } + + _finish() { + super._finish(); + + const monitorManager = global.backend.get_monitor_manager(); + const item = this._items[this._selectedIndex]; + + monitorManager.switch_config(item.configType); + } +}); + +var SwitchMonitorSwitcher = GObject.registerClass( +class SwitchMonitorSwitcher extends SwitcherPopup.SwitcherList { + _init(items) { + super._init(true); + + for (let i = 0; i < items.length; i++) + this._addIcon(items[i]); + } + + _addIcon(item) { + const box = new St.BoxLayout({ + style_class: 'alt-tab-app', + vertical: true, + }); + + const icon = new St.Icon({ + icon_name: item.icon, + icon_size: APP_ICON_SIZE, + }); + box.add_child(icon); + + let text = new St.Label({ + text: item.label, + x_align: Clutter.ActorAlign.CENTER, + }); + box.add_child(text); + + this.addItem(box, text); + } +}); diff --git a/js/ui/switcherPopup.js b/js/ui/switcherPopup.js new file mode 100644 index 0000000..4b0479b --- /dev/null +++ b/js/ui/switcherPopup.js @@ -0,0 +1,688 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported SwitcherPopup, SwitcherList */ + +const { Clutter, GLib, GObject, St } = imports.gi; + +const Main = imports.ui.main; + +var POPUP_DELAY_TIMEOUT = 150; // milliseconds + +var POPUP_SCROLL_TIME = 100; // milliseconds +var POPUP_FADE_OUT_TIME = 100; // milliseconds + +var DISABLE_HOVER_TIMEOUT = 500; // milliseconds +var NO_MODS_TIMEOUT = 1500; // milliseconds + +function mod(a, b) { + return (a + b) % b; +} + +function primaryModifier(mask) { + if (mask == 0) + return 0; + + let primary = 1; + while (mask > 1) { + mask >>= 1; + primary <<= 1; + } + return primary; +} + +var SwitcherPopup = GObject.registerClass({ + GTypeFlags: GObject.TypeFlags.ABSTRACT, +}, class SwitcherPopup extends St.Widget { + _init(items) { + super._init({ + style_class: 'switcher-popup', + reactive: true, + visible: false, + }); + + this._switcherList = null; + + this._items = items || []; + this._selectedIndex = 0; + + this.connect('destroy', this._onDestroy.bind(this)); + + Main.uiGroup.add_actor(this); + + Main.layoutManager.connectObject( + 'system-modal-opened', () => this.destroy(), this); + + this._haveModal = false; + this._modifierMask = 0; + + this._motionTimeoutId = 0; + this._initialDelayTimeoutId = 0; + this._noModsTimeoutId = 0; + + this.add_constraint(new Clutter.BindConstraint({ + source: global.stage, + coordinate: Clutter.BindCoordinate.ALL, + })); + + // Initially disable hover so we ignore the enter-event if + // the switcher appears underneath the current pointer location + this._disableHover(); + } + + vfunc_allocate(box) { + this.set_allocation(box); + + let childBox = new Clutter.ActorBox(); + let primary = Main.layoutManager.primaryMonitor; + + let leftPadding = this.get_theme_node().get_padding(St.Side.LEFT); + let rightPadding = this.get_theme_node().get_padding(St.Side.RIGHT); + let hPadding = leftPadding + rightPadding; + + // Allocate the switcherList + // We select a size based on an icon size that does not overflow the screen + let [, childNaturalHeight] = this._switcherList.get_preferred_height(primary.width - hPadding); + let [, childNaturalWidth] = this._switcherList.get_preferred_width(childNaturalHeight); + childBox.x1 = Math.max(primary.x + leftPadding, primary.x + Math.floor((primary.width - childNaturalWidth) / 2)); + childBox.x2 = Math.min(primary.x + primary.width - rightPadding, childBox.x1 + childNaturalWidth); + childBox.y1 = primary.y + Math.floor((primary.height - childNaturalHeight) / 2); + childBox.y2 = childBox.y1 + childNaturalHeight; + this._switcherList.allocate(childBox); + } + + _initialSelection(backward, _binding) { + if (backward) + this._select(this._items.length - 1); + else if (this._items.length == 1) + this._select(0); + else + this._select(1); + } + + show(backward, binding, mask) { + if (this._items.length == 0) + return false; + + let grab = Main.pushModal(this); + // We expect at least a keyboard grab here + if ((grab.get_seat_state() & Clutter.GrabState.KEYBOARD) === 0) { + Main.popModal(grab); + return false; + } + this._grab = grab; + this._haveModal = true; + this._modifierMask = primaryModifier(mask); + + this.add_actor(this._switcherList); + this._switcherList.connect('item-activated', this._itemActivated.bind(this)); + this._switcherList.connect('item-entered', this._itemEntered.bind(this)); + this._switcherList.connect('item-removed', this._itemRemoved.bind(this)); + + // Need to force an allocation so we can figure out whether we + // need to scroll when selecting + this.opacity = 0; + this.visible = true; + this.get_allocation_box(); + + this._initialSelection(backward, binding); + + // There's a race condition; if the user released Alt before + // we got the grab, then we won't be notified. (See + // https://bugzilla.gnome.org/show_bug.cgi?id=596695 for + // details.) So we check now. (Have to do this after updating + // selection.) + if (this._modifierMask) { + let [x_, y_, mods] = global.get_pointer(); + if (!(mods & this._modifierMask)) { + this._finish(global.get_current_time()); + return true; + } + } else { + this._resetNoModsTimeout(); + } + + // We delay showing the popup so that fast Alt+Tab users aren't + // disturbed by the popup briefly flashing. + this._initialDelayTimeoutId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + POPUP_DELAY_TIMEOUT, + () => { + this._showImmediately(); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(this._initialDelayTimeoutId, '[gnome-shell] Main.osdWindow.cancel'); + return true; + } + + _showImmediately() { + if (this._initialDelayTimeoutId === 0) + return; + + GLib.source_remove(this._initialDelayTimeoutId); + this._initialDelayTimeoutId = 0; + + Main.osdWindowManager.hideAll(); + this.opacity = 255; + } + + _next() { + return mod(this._selectedIndex + 1, this._items.length); + } + + _previous() { + return mod(this._selectedIndex - 1, this._items.length); + } + + _keyPressHandler(_keysym, _action) { + throw new GObject.NotImplementedError(`_keyPressHandler in ${this.constructor.name}`); + } + + vfunc_key_press_event(keyEvent) { + let keysym = keyEvent.keyval; + let action = global.display.get_keybinding_action( + keyEvent.hardware_keycode, keyEvent.modifier_state); + + this._disableHover(); + + if (this._keyPressHandler(keysym, action) != Clutter.EVENT_PROPAGATE) { + this._showImmediately(); + return Clutter.EVENT_STOP; + } + + // Note: pressing one of the below keys will destroy the popup only if + // that key is not used by the active popup's keyboard shortcut + if (keysym === Clutter.KEY_Escape || keysym === Clutter.KEY_Tab) + this.fadeAndDestroy(); + + // Allow to explicitly select the current item; this is particularly + // useful for no-modifier popups + if (keysym === Clutter.KEY_space || + keysym === Clutter.KEY_Return || + keysym === Clutter.KEY_KP_Enter || + keysym === Clutter.KEY_ISO_Enter) + this._finish(keyEvent.time); + + return Clutter.EVENT_STOP; + } + + vfunc_key_release_event(keyEvent) { + if (this._modifierMask) { + let [x_, y_, mods] = global.get_pointer(); + let state = mods & this._modifierMask; + + if (state == 0) + this._finish(keyEvent.time); + } else { + this._resetNoModsTimeout(); + } + + return Clutter.EVENT_STOP; + } + + vfunc_button_press_event() { + /* We clicked outside */ + this.fadeAndDestroy(); + return Clutter.EVENT_PROPAGATE; + } + + _scrollHandler(direction) { + if (direction == Clutter.ScrollDirection.UP) + this._select(this._previous()); + else if (direction == Clutter.ScrollDirection.DOWN) + this._select(this._next()); + } + + vfunc_scroll_event(scrollEvent) { + this._disableHover(); + + this._scrollHandler(scrollEvent.direction); + return Clutter.EVENT_PROPAGATE; + } + + _itemActivatedHandler(n) { + this._select(n); + } + + _itemActivated(switcher, n) { + this._itemActivatedHandler(n); + this._finish(global.get_current_time()); + } + + _itemEnteredHandler(n) { + this._select(n); + } + + _itemEntered(switcher, n) { + if (!this.mouseActive) + return; + this._itemEnteredHandler(n); + } + + _itemRemovedHandler(n) { + if (this._items.length > 0) { + let newIndex; + + if (n < this._selectedIndex) + newIndex = this._selectedIndex - 1; + else if (n === this._selectedIndex) + newIndex = Math.min(n, this._items.length - 1); + else if (n > this._selectedIndex) + return; // No need to select something new in this case + + this._select(newIndex); + } else { + this.fadeAndDestroy(); + } + } + + _itemRemoved(switcher, n) { + this._itemRemovedHandler(n); + } + + _disableHover() { + this.mouseActive = false; + + if (this._motionTimeoutId != 0) + GLib.source_remove(this._motionTimeoutId); + + this._motionTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, DISABLE_HOVER_TIMEOUT, this._mouseTimedOut.bind(this)); + GLib.Source.set_name_by_id(this._motionTimeoutId, '[gnome-shell] this._mouseTimedOut'); + } + + _mouseTimedOut() { + this._motionTimeoutId = 0; + this.mouseActive = true; + return GLib.SOURCE_REMOVE; + } + + _resetNoModsTimeout() { + if (this._noModsTimeoutId != 0) + GLib.source_remove(this._noModsTimeoutId); + + this._noModsTimeoutId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + NO_MODS_TIMEOUT, + () => { + this._finish(global.display.get_current_time_roundtrip()); + this._noModsTimeoutId = 0; + return GLib.SOURCE_REMOVE; + }); + } + + _popModal() { + if (this._haveModal) { + Main.popModal(this._grab); + this._grab = null; + this._haveModal = false; + } + } + + fadeAndDestroy() { + this._popModal(); + if (this.opacity > 0) { + this.ease({ + opacity: 0, + duration: POPUP_FADE_OUT_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this.destroy(), + }); + } else { + this.destroy(); + } + } + + _finish(_timestamp) { + this.fadeAndDestroy(); + } + + _onDestroy() { + this._popModal(); + + if (this._motionTimeoutId != 0) + GLib.source_remove(this._motionTimeoutId); + if (this._initialDelayTimeoutId != 0) + GLib.source_remove(this._initialDelayTimeoutId); + if (this._noModsTimeoutId != 0) + GLib.source_remove(this._noModsTimeoutId); + + // Make sure the SwitcherList is always destroyed, it may not be + // a child of the actor at this point. + if (this._switcherList) + this._switcherList.destroy(); + } + + _select(num) { + this._selectedIndex = num; + this._switcherList.highlight(num); + } +}); + +var SwitcherButton = GObject.registerClass( +class SwitcherButton extends St.Button { + _init(square) { + super._init({ + style_class: 'item-box', + reactive: true, + }); + + this._square = square; + } + + vfunc_get_preferred_width(forHeight) { + if (this._square) + return this.get_preferred_height(-1); + else + return super.vfunc_get_preferred_width(forHeight); + } +}); + +var SwitcherList = GObject.registerClass({ + Signals: { + 'item-activated': { param_types: [GObject.TYPE_INT] }, + 'item-entered': { param_types: [GObject.TYPE_INT] }, + 'item-removed': { param_types: [GObject.TYPE_INT] }, + }, +}, class SwitcherList extends St.Widget { + _init(squareItems) { + super._init({ style_class: 'switcher-list' }); + + this._list = new St.BoxLayout({ + style_class: 'switcher-list-item-container', + vertical: false, + x_expand: true, + y_expand: true, + }); + + let layoutManager = this._list.get_layout_manager(); + + this._list.spacing = 0; + this._list.connect('style-changed', () => { + this._list.spacing = this._list.get_theme_node().get_length('spacing'); + }); + + this._scrollView = new St.ScrollView({ + style_class: 'hfade', + enable_mouse_scrolling: false, + }); + this._scrollView.set_policy(St.PolicyType.NEVER, St.PolicyType.NEVER); + + this._scrollView.add_actor(this._list); + this.add_actor(this._scrollView); + + // Those arrows indicate whether scrolling in one direction is possible + this._leftArrow = new St.DrawingArea({ + style_class: 'switcher-arrow', + pseudo_class: 'highlighted', + }); + this._leftArrow.connect('repaint', () => { + drawArrow(this._leftArrow, St.Side.LEFT); + }); + this._rightArrow = new St.DrawingArea({ + style_class: 'switcher-arrow', + pseudo_class: 'highlighted', + }); + this._rightArrow.connect('repaint', () => { + drawArrow(this._rightArrow, St.Side.RIGHT); + }); + + this.add_actor(this._leftArrow); + this.add_actor(this._rightArrow); + + this._items = []; + this._highlighted = -1; + this._squareItems = squareItems; + this._scrollableRight = true; + this._scrollableLeft = false; + + layoutManager.homogeneous = squareItems; + } + + addItem(item, label) { + let bbox = new SwitcherButton(this._squareItems); + + bbox.set_child(item); + this._list.add_actor(bbox); + + bbox.connect('clicked', () => this._onItemClicked(bbox)); + bbox.connect('motion-event', () => this._onItemMotion(bbox)); + + bbox.label_actor = label; + + this._items.push(bbox); + + return bbox; + } + + removeItem(index) { + let item = this._items.splice(index, 1); + item[0].destroy(); + this.emit('item-removed', index); + } + + addAccessibleState(index, state) { + this._items[index].add_accessible_state(state); + } + + removeAccessibleState(index, state) { + this._items[index].remove_accessible_state(state); + } + + _onItemClicked(item) { + this._itemActivated(this._items.indexOf(item)); + } + + _onItemMotion(item) { + // Avoid reentrancy + if (item !== this._items[this._highlighted]) + this._itemEntered(this._items.indexOf(item)); + + return Clutter.EVENT_PROPAGATE; + } + + highlight(index, justOutline) { + if (this._items[this._highlighted]) { + this._items[this._highlighted].remove_style_pseudo_class('outlined'); + this._items[this._highlighted].remove_style_pseudo_class('selected'); + } + + if (this._items[index]) { + if (justOutline) + this._items[index].add_style_pseudo_class('outlined'); + else + this._items[index].add_style_pseudo_class('selected'); + } + + this._highlighted = index; + + let adjustment = this._scrollView.hscroll.adjustment; + let [value] = adjustment.get_values(); + let [absItemX] = this._items[index].get_transformed_position(); + let [result_, posX, posY_] = this.transform_stage_point(absItemX, 0); + let [containerWidth] = this.get_transformed_size(); + if (posX + this._items[index].get_width() > containerWidth) + this._scrollToRight(index); + else if (this._items[index].allocation.x1 - value < 0) + this._scrollToLeft(index); + } + + _scrollToLeft(index) { + let adjustment = this._scrollView.hscroll.adjustment; + let [value, lower_, upper, stepIncrement_, pageIncrement_, pageSize] = adjustment.get_values(); + + let item = this._items[index]; + + if (item.allocation.x1 < value) + value = Math.max(0, item.allocation.x1); + else if (item.allocation.x2 > value + pageSize) + value = Math.min(upper, item.allocation.x2 - pageSize); + + this._scrollableRight = true; + adjustment.ease(value, { + progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: POPUP_SCROLL_TIME, + onComplete: () => { + if (index === 0) + this._scrollableLeft = false; + this.queue_relayout(); + }, + }); + } + + _scrollToRight(index) { + let adjustment = this._scrollView.hscroll.adjustment; + let [value, lower_, upper, stepIncrement_, pageIncrement_, pageSize] = adjustment.get_values(); + + let item = this._items[index]; + + if (item.allocation.x1 < value) + value = Math.max(0, item.allocation.x1); + else if (item.allocation.x2 > value + pageSize) + value = Math.min(upper, item.allocation.x2 - pageSize); + + this._scrollableLeft = true; + adjustment.ease(value, { + progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: POPUP_SCROLL_TIME, + onComplete: () => { + if (index === this._items.length - 1) + this._scrollableRight = false; + this.queue_relayout(); + }, + }); + } + + _itemActivated(n) { + this.emit('item-activated', n); + } + + _itemEntered(n) { + this.emit('item-entered', n); + } + + _maxChildWidth(forHeight) { + let maxChildMin = 0; + let maxChildNat = 0; + + for (let i = 0; i < this._items.length; i++) { + let [childMin, childNat] = this._items[i].get_preferred_width(forHeight); + maxChildMin = Math.max(childMin, maxChildMin); + maxChildNat = Math.max(childNat, maxChildNat); + + if (this._squareItems) { + [childMin, childNat] = this._items[i].get_preferred_height(-1); + maxChildMin = Math.max(childMin, maxChildMin); + maxChildNat = Math.max(childNat, maxChildNat); + } + } + + return [maxChildMin, maxChildNat]; + } + + vfunc_get_preferred_width(forHeight) { + let themeNode = this.get_theme_node(); + let [maxChildMin] = this._maxChildWidth(forHeight); + let [minListWidth] = this._list.get_preferred_width(forHeight); + + return themeNode.adjust_preferred_width(maxChildMin, minListWidth); + } + + vfunc_get_preferred_height(_forWidth) { + let maxChildMin = 0; + let maxChildNat = 0; + + for (let i = 0; i < this._items.length; i++) { + let [childMin, childNat] = this._items[i].get_preferred_height(-1); + maxChildMin = Math.max(childMin, maxChildMin); + maxChildNat = Math.max(childNat, maxChildNat); + } + + if (this._squareItems) { + let [childMin] = this._maxChildWidth(-1); + maxChildMin = Math.max(childMin, maxChildMin); + maxChildNat = maxChildMin; + } + + let themeNode = this.get_theme_node(); + return themeNode.adjust_preferred_height(maxChildMin, maxChildNat); + } + + vfunc_allocate(box) { + this.set_allocation(box); + + let contentBox = this.get_theme_node().get_content_box(box); + let width = contentBox.x2 - contentBox.x1; + let height = contentBox.y2 - contentBox.y1; + + let leftPadding = this.get_theme_node().get_padding(St.Side.LEFT); + let rightPadding = this.get_theme_node().get_padding(St.Side.RIGHT); + + let [minListWidth] = this._list.get_preferred_width(height); + + let childBox = new Clutter.ActorBox(); + let scrollable = minListWidth > width; + + this._scrollView.allocate(contentBox); + + let arrowWidth = Math.floor(leftPadding / 3); + let arrowHeight = arrowWidth * 2; + childBox.x1 = leftPadding / 2; + childBox.y1 = this.height / 2 - arrowWidth; + childBox.x2 = childBox.x1 + arrowWidth; + childBox.y2 = childBox.y1 + arrowHeight; + this._leftArrow.allocate(childBox); + this._leftArrow.opacity = this._scrollableLeft && scrollable ? 255 : 0; + + arrowWidth = Math.floor(rightPadding / 3); + arrowHeight = arrowWidth * 2; + childBox.x1 = this.width - arrowWidth - rightPadding / 2; + childBox.y1 = this.height / 2 - arrowWidth; + childBox.x2 = childBox.x1 + arrowWidth; + childBox.y2 = childBox.y1 + arrowHeight; + this._rightArrow.allocate(childBox); + this._rightArrow.opacity = this._scrollableRight && scrollable ? 255 : 0; + } +}); + +function drawArrow(area, side) { + let themeNode = area.get_theme_node(); + let borderColor = themeNode.get_border_color(side); + let bodyColor = themeNode.get_foreground_color(); + + let [width, height] = area.get_surface_size(); + let cr = area.get_context(); + + cr.setLineWidth(1.0); + Clutter.cairo_set_source_color(cr, borderColor); + + switch (side) { + case St.Side.TOP: + cr.moveTo(0, height); + cr.lineTo(Math.floor(width * 0.5), 0); + cr.lineTo(width, height); + break; + + case St.Side.BOTTOM: + cr.moveTo(width, 0); + cr.lineTo(Math.floor(width * 0.5), height); + cr.lineTo(0, 0); + break; + + case St.Side.LEFT: + cr.moveTo(width, height); + cr.lineTo(0, Math.floor(height * 0.5)); + cr.lineTo(width, 0); + break; + + case St.Side.RIGHT: + cr.moveTo(0, 0); + cr.lineTo(width, Math.floor(height * 0.5)); + cr.lineTo(0, height); + break; + } + + cr.strokePreserve(); + + Clutter.cairo_set_source_color(cr, bodyColor); + cr.fill(); + cr.$dispose(); +} + diff --git a/js/ui/unlockDialog.js b/js/ui/unlockDialog.js new file mode 100644 index 0000000..24c8160 --- /dev/null +++ b/js/ui/unlockDialog.js @@ -0,0 +1,899 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported UnlockDialog */ + +const { + AccountsService, Atk, Clutter, Gdm, Gio, + GnomeDesktop, GLib, GObject, Meta, Shell, St, +} = imports.gi; + +const Background = imports.ui.background; +const Layout = imports.ui.layout; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; +const SwipeTracker = imports.ui.swipeTracker; + +const AuthPrompt = imports.gdm.authPrompt; + +// The timeout before going back automatically to the lock screen (in seconds) +const IDLE_TIMEOUT = 2 * 60; + +// The timeout before showing the unlock hint (in seconds) +const HINT_TIMEOUT = 4; + +const CROSSFADE_TIME = 300; +const FADE_OUT_TRANSLATION = 200; +const FADE_OUT_SCALE = 0.3; + +const BLUR_BRIGHTNESS = 0.55; +const BLUR_SIGMA = 60; + +const SUMMARY_ICON_SIZE = 32; + +var NotificationsBox = GObject.registerClass({ + Signals: { 'wake-up-screen': {} }, +}, class NotificationsBox extends St.BoxLayout { + _init() { + super._init({ + vertical: true, + name: 'unlockDialogNotifications', + style_class: 'unlock-dialog-notifications-container', + }); + + this._scrollView = new St.ScrollView({ hscrollbar_policy: St.PolicyType.NEVER }); + this._notificationBox = new St.BoxLayout({ + vertical: true, + style_class: 'unlock-dialog-notifications-container', + }); + this._scrollView.add_actor(this._notificationBox); + + this.add_child(this._scrollView); + + this._settings = new Gio.Settings({ + schema_id: 'org.gnome.desktop.notifications', + }); + + this._sources = new Map(); + Main.messageTray.getSources().forEach(source => { + this._sourceAdded(Main.messageTray, source, true); + }); + this._updateVisibility(); + + Main.messageTray.connectObject('source-added', + this._sourceAdded.bind(this), this); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + _onDestroy() { + let items = this._sources.entries(); + for (let [source, obj] of items) + this._removeSource(source, obj); + } + + _updateVisibility() { + this._notificationBox.visible = + this._notificationBox.get_children().some(a => a.visible); + + this.visible = this._notificationBox.visible; + } + + _makeNotificationSource(source, box) { + let sourceActor = new MessageTray.SourceActor(source, SUMMARY_ICON_SIZE); + box.add_child(sourceActor); + + let textBox = new St.BoxLayout({ + x_expand: true, + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + box.add_child(textBox); + + let title = new St.Label({ + text: source.title, + style_class: 'unlock-dialog-notification-label', + x_expand: true, + x_align: Clutter.ActorAlign.START, + }); + textBox.add(title); + + let count = source.unseenCount; + let countLabel = new St.Label({ + text: `${count}`, + visible: count > 1, + style_class: 'unlock-dialog-notification-count-text', + }); + textBox.add(countLabel); + + box.visible = count !== 0; + return [title, countLabel]; + } + + _makeNotificationDetailedSource(source, box) { + let sourceActor = new MessageTray.SourceActor(source, SUMMARY_ICON_SIZE); + let sourceBin = new St.Bin({ child: sourceActor }); + box.add(sourceBin); + + let textBox = new St.BoxLayout({ vertical: true }); + box.add_child(textBox); + + let title = new St.Label({ + text: source.title.replace(/\n/g, ' '), + style_class: 'unlock-dialog-notification-label', + }); + textBox.add(title); + + let visible = false; + for (let i = 0; i < source.notifications.length; i++) { + let n = source.notifications[i]; + + if (n.acknowledged) + continue; + + let body = ''; + if (n.bannerBodyText) { + const bodyText = n.bannerBodyText.replace(/\n/g, ' '); + body = n.bannerBodyMarkup + ? bodyText + : GLib.markup_escape_text(bodyText, -1); + } + + let label = new St.Label({ style_class: 'unlock-dialog-notification-count-text' }); + label.clutter_text.set_markup(`<b>${n.title}</b> ${body}`); + textBox.add(label); + + visible = true; + } + + box.visible = visible; + return [title, null]; + } + + _shouldShowDetails(source) { + return source.policy.detailsInLockScreen || + source.narrowestPrivacyScope === MessageTray.PrivacyScope.SYSTEM; + } + + _updateSourceBoxStyle(source, obj, box) { + let hasCriticalNotification = + source.notifications.some(n => n.urgency === MessageTray.Urgency.CRITICAL); + + if (hasCriticalNotification !== obj.hasCriticalNotification) { + obj.hasCriticalNotification = hasCriticalNotification; + + if (hasCriticalNotification) + box.add_style_class_name('critical'); + else + box.remove_style_class_name('critical'); + } + } + + _showSource(source, obj, box) { + if (obj.detailed) + [obj.titleLabel, obj.countLabel] = this._makeNotificationDetailedSource(source, box); + else + [obj.titleLabel, obj.countLabel] = this._makeNotificationSource(source, box); + + box.visible = obj.visible && (source.unseenCount > 0); + + this._updateSourceBoxStyle(source, obj, box); + } + + _wakeUpScreenForSource(source) { + if (!this._settings.get_boolean('show-banners')) + return; + const obj = this._sources.get(source); + if (obj?.sourceBox.visible) + this.emit('wake-up-screen'); + } + + _sourceAdded(tray, source, initial) { + let obj = { + visible: source.policy.showInLockScreen, + detailed: this._shouldShowDetails(source), + sourceBox: null, + titleLabel: null, + countLabel: null, + hasCriticalNotification: false, + }; + + obj.sourceBox = new St.BoxLayout({ + style_class: 'unlock-dialog-notification-source', + x_expand: true, + }); + this._showSource(source, obj, obj.sourceBox); + this._notificationBox.add_child(obj.sourceBox); + + source.connectObject( + 'notify::count', () => this._countChanged(source, obj), + 'notify::title', () => this._titleChanged(source, obj), + 'destroy', () => { + this._removeSource(source, obj); + this._updateVisibility(); + }, this); + obj.policyChangedId = source.policy.connect('notify', (policy, pspec) => { + if (pspec.name === 'show-in-lock-screen') + this._visibleChanged(source, obj); + else + this._detailedChanged(source, obj); + }); + + this._sources.set(source, obj); + + if (!initial) { + // block scrollbars while animating, if they're not needed now + let boxHeight = this._notificationBox.height; + if (this._scrollView.height >= boxHeight) + this._scrollView.vscrollbar_policy = St.PolicyType.NEVER; + + let widget = obj.sourceBox; + let [, natHeight] = widget.get_preferred_height(-1); + widget.height = 0; + widget.ease({ + height: natHeight, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: 250, + onComplete: () => { + this._scrollView.vscrollbar_policy = St.PolicyType.AUTOMATIC; + widget.set_height(-1); + }, + }); + + this._updateVisibility(); + this._wakeUpScreenForSource(source); + } + } + + _titleChanged(source, obj) { + obj.titleLabel.text = source.title; + } + + _countChanged(source, obj) { + // A change in the number of notifications may change whether we show + // details. + let newDetailed = this._shouldShowDetails(source); + let oldDetailed = obj.detailed; + + obj.detailed = newDetailed; + + if (obj.detailed || oldDetailed !== newDetailed) { + // A new notification was pushed, or a previous notification was destroyed. + // Give up, and build the list again. + + obj.sourceBox.destroy_all_children(); + obj.titleLabel = obj.countLabel = null; + this._showSource(source, obj, obj.sourceBox); + } else { + let count = source.unseenCount; + obj.countLabel.text = `${count}`; + obj.countLabel.visible = count > 1; + } + + obj.sourceBox.visible = obj.visible && (source.unseenCount > 0); + + this._updateVisibility(); + this._wakeUpScreenForSource(source); + } + + _visibleChanged(source, obj) { + if (obj.visible === source.policy.showInLockScreen) + return; + + obj.visible = source.policy.showInLockScreen; + obj.sourceBox.visible = obj.visible && source.unseenCount > 0; + + this._updateVisibility(); + this._wakeUpScreenForSource(source); + } + + _detailedChanged(source, obj) { + let newDetailed = this._shouldShowDetails(source); + if (obj.detailed === newDetailed) + return; + + obj.detailed = newDetailed; + + obj.sourceBox.destroy_all_children(); + obj.titleLabel = obj.countLabel = null; + this._showSource(source, obj, obj.sourceBox); + } + + _removeSource(source, obj) { + obj.sourceBox.destroy(); + obj.sourceBox = obj.titleLabel = obj.countLabel = null; + + source.policy.disconnect(obj.policyChangedId); + + this._sources.delete(source); + } +}); + +var Clock = GObject.registerClass( +class UnlockDialogClock extends St.BoxLayout { + _init() { + super._init({ style_class: 'unlock-dialog-clock', vertical: true }); + + this._time = new St.Label({ + style_class: 'unlock-dialog-clock-time', + x_align: Clutter.ActorAlign.CENTER, + }); + this._date = new St.Label({ + style_class: 'unlock-dialog-clock-date', + x_align: Clutter.ActorAlign.CENTER, + }); + this._hint = new St.Label({ + style_class: 'unlock-dialog-clock-hint', + x_align: Clutter.ActorAlign.CENTER, + opacity: 0, + }); + + this.add_child(this._time); + this.add_child(this._date); + this.add_child(this._hint); + + this._wallClock = new GnomeDesktop.WallClock({ time_only: true }); + this._wallClock.connect('notify::clock', this._updateClock.bind(this)); + + this._seat = Clutter.get_default_backend().get_default_seat(); + this._seat.connectObject('notify::touch-mode', + this._updateHint.bind(this), this); + + this._monitorManager = Meta.MonitorManager.get(); + this._monitorManager.connectObject('power-save-mode-changed', + () => (this._hint.opacity = 0), this); + + this._idleMonitor = global.backend.get_core_idle_monitor(); + this._idleWatchId = this._idleMonitor.add_idle_watch(HINT_TIMEOUT * 1000, () => { + this._hint.ease({ + opacity: 255, + duration: CROSSFADE_TIME, + }); + }); + + this._updateClock(); + this._updateHint(); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + _updateClock() { + this._time.text = this._wallClock.clock; + + let date = new Date(); + /* Translators: This is a time format for a date in + long format */ + let dateFormat = Shell.util_translate_time_string(N_('%A %B %-d')); + this._date.text = date.toLocaleFormat(dateFormat); + } + + _updateHint() { + this._hint.text = this._seat.touch_mode + ? _('Swipe up to unlock') + : _('Click or press a key to unlock'); + } + + _onDestroy() { + this._wallClock.run_dispose(); + + this._idleMonitor.remove_watch(this._idleWatchId); + } +}); + +var UnlockDialogLayout = GObject.registerClass( +class UnlockDialogLayout extends Clutter.LayoutManager { + _init(stack, notifications, switchUserButton) { + super._init(); + + this._stack = stack; + this._notifications = notifications; + this._switchUserButton = switchUserButton; + } + + vfunc_get_preferred_width(container, forHeight) { + return this._stack.get_preferred_width(forHeight); + } + + vfunc_get_preferred_height(container, forWidth) { + return this._stack.get_preferred_height(forWidth); + } + + vfunc_allocate(container, box) { + let [width, height] = box.get_size(); + + let tenthOfHeight = height / 10.0; + let thirdOfHeight = height / 3.0; + + let [, , stackWidth, stackHeight] = + this._stack.get_preferred_size(); + + let [, , notificationsWidth, notificationsHeight] = + this._notifications.get_preferred_size(); + + let columnWidth = Math.max(stackWidth, notificationsWidth); + + let columnX1 = Math.floor((width - columnWidth) / 2.0); + let actorBox = new Clutter.ActorBox(); + + // Notifications + let maxNotificationsHeight = Math.min( + notificationsHeight, + height - tenthOfHeight - stackHeight); + + actorBox.x1 = columnX1; + actorBox.y1 = height - maxNotificationsHeight; + actorBox.x2 = columnX1 + columnWidth; + actorBox.y2 = actorBox.y1 + maxNotificationsHeight; + + this._notifications.allocate(actorBox); + + // Authentication Box + let stackY = Math.min( + thirdOfHeight, + height - stackHeight - maxNotificationsHeight); + + actorBox.x1 = columnX1; + actorBox.y1 = stackY; + actorBox.x2 = columnX1 + columnWidth; + actorBox.y2 = stackY + stackHeight; + + this._stack.allocate(actorBox); + + // Switch User button + if (this._switchUserButton.visible) { + let [, , natWidth, natHeight] = + this._switchUserButton.get_preferred_size(); + + const textDirection = this._switchUserButton.get_text_direction(); + if (textDirection === Clutter.TextDirection.RTL) + actorBox.x1 = box.x1 + natWidth; + else + actorBox.x1 = box.x2 - (natWidth * 2); + + actorBox.y1 = box.y2 - (natHeight * 2); + actorBox.x2 = actorBox.x1 + natWidth; + actorBox.y2 = actorBox.y1 + natHeight; + + this._switchUserButton.allocate(actorBox); + } + } +}); + +var UnlockDialog = GObject.registerClass({ + Signals: { + 'failed': {}, + 'wake-up-screen': {}, + }, +}, class UnlockDialog extends St.Widget { + _init(parentActor) { + super._init({ + accessible_role: Atk.Role.WINDOW, + style_class: 'unlock-dialog', + visible: false, + reactive: true, + }); + + parentActor.add_child(this); + + this._gdmClient = new Gdm.Client(); + + try { + this._gdmClient.set_enabled_extensions([ + Gdm.UserVerifierChoiceList.interface_info().name, + ]); + } catch (e) { + } + + this._adjustment = new St.Adjustment({ + actor: this, + lower: 0, + upper: 2, + page_size: 1, + page_increment: 1, + }); + this._adjustment.connect('notify::value', () => { + this._setTransitionProgress(this._adjustment.value); + }); + + this._swipeTracker = new SwipeTracker.SwipeTracker(this, + Clutter.Orientation.VERTICAL, + Shell.ActionMode.UNLOCK_SCREEN); + this._swipeTracker.connect('begin', this._swipeBegin.bind(this)); + this._swipeTracker.connect('update', this._swipeUpdate.bind(this)); + this._swipeTracker.connect('end', this._swipeEnd.bind(this)); + + this.connect('scroll-event', (o, event) => { + if (this._swipeTracker.canHandleScrollEvent(event)) + return Clutter.EVENT_PROPAGATE; + + let direction = event.get_scroll_direction(); + if (direction === Clutter.ScrollDirection.UP) + this._showClock(); + else if (direction === Clutter.ScrollDirection.DOWN) + this._showPrompt(); + return Clutter.EVENT_STOP; + }); + + this._activePage = null; + + let tapAction = new Clutter.TapAction(); + tapAction.connect('tap', this._showPrompt.bind(this)); + this.add_action(tapAction); + + // Background + this._backgroundGroup = new Clutter.Actor(); + this.add_child(this._backgroundGroup); + + this._bgManagers = []; + + const themeContext = St.ThemeContext.get_for_stage(global.stage); + themeContext.connectObject('notify::scale-factor', + () => this._updateBackgroundEffects(), this); + + this._updateBackgrounds(); + Main.layoutManager.connectObject('monitors-changed', + this._updateBackgrounds.bind(this), this); + + this._userManager = AccountsService.UserManager.get_default(); + this._userName = GLib.get_user_name(); + this._user = this._userManager.get_user(this._userName); + + // Authentication & Clock stack + this._stack = new Shell.Stack(); + + this._promptBox = new St.BoxLayout({ vertical: true }); + this._promptBox.set_pivot_point(0.5, 0.5); + this._promptBox.hide(); + this._stack.add_child(this._promptBox); + + this._clock = new Clock(); + this._clock.set_pivot_point(0.5, 0.5); + this._stack.add_child(this._clock); + this._showClock(); + + this.allowCancel = false; + + Main.ctrlAltTabManager.addGroup(this, _('Unlock Window'), 'dialog-password-symbolic'); + + // Notifications + this._notificationsBox = new NotificationsBox(); + this._notificationsBox.connect('wake-up-screen', () => this.emit('wake-up-screen')); + + // Switch User button + this._otherUserButton = new St.Button({ + style_class: 'modal-dialog-button button switch-user-button', + accessible_name: _('Log in as another user'), + button_mask: St.ButtonMask.ONE | St.ButtonMask.THREE, + reactive: false, + opacity: 0, + x_align: Clutter.ActorAlign.END, + y_align: Clutter.ActorAlign.END, + icon_name: 'system-users-symbolic', + }); + this._otherUserButton.set_pivot_point(0.5, 0.5); + this._otherUserButton.connect('clicked', this._otherUserClicked.bind(this)); + + this._screenSaverSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.screensaver' }); + + this._screenSaverSettings.connectObject('changed::user-switch-enabled', + this._updateUserSwitchVisibility.bind(this), this); + + this._lockdownSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.lockdown' }); + this._lockdownSettings.connect('changed::disable-user-switching', + this._updateUserSwitchVisibility.bind(this)); + + this._user.connectObject('notify::is-loaded', + this._updateUserSwitchVisibility.bind(this), this); + + this._updateUserSwitchVisibility(); + + // Main Box + let mainBox = new St.Widget(); + mainBox.add_constraint(new Layout.MonitorConstraint({ primary: true })); + mainBox.add_child(this._stack); + mainBox.add_child(this._notificationsBox); + mainBox.add_child(this._otherUserButton); + mainBox.layout_manager = new UnlockDialogLayout( + this._stack, + this._notificationsBox, + this._otherUserButton); + this.add_child(mainBox); + + this._idleMonitor = global.backend.get_core_idle_monitor(); + this._idleWatchId = this._idleMonitor.add_idle_watch(IDLE_TIMEOUT * 1000, this._escape.bind(this)); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + vfunc_key_press_event(keyEvent) { + if (this._activePage === this._promptBox || + (this._promptBox && this._promptBox.visible)) + return Clutter.EVENT_PROPAGATE; + + const { keyval } = keyEvent; + if (keyval === Clutter.KEY_Shift_L || + keyval === Clutter.KEY_Shift_R || + keyval === Clutter.KEY_Shift_Lock || + keyval === Clutter.KEY_Caps_Lock) + return Clutter.EVENT_PROPAGATE; + + let unichar = keyEvent.unicode_value; + + this._showPrompt(); + + if (GLib.unichar_isgraph(unichar)) + this._authPrompt.addCharacter(unichar); + + return Clutter.EVENT_PROPAGATE; + } + + vfunc_captured_event(event) { + if (Main.keyboard.maybeHandleEvent(event)) + return Clutter.EVENT_STOP; + + return Clutter.EVENT_PROPAGATE; + } + + _createBackground(monitorIndex) { + let monitor = Main.layoutManager.monitors[monitorIndex]; + let widget = new St.Widget({ + style_class: 'screen-shield-background', + x: monitor.x, + y: monitor.y, + width: monitor.width, + height: monitor.height, + effect: new Shell.BlurEffect({ name: 'blur' }), + }); + + let bgManager = new Background.BackgroundManager({ + container: widget, + monitorIndex, + controlPosition: false, + }); + + this._bgManagers.push(bgManager); + + this._backgroundGroup.add_child(widget); + } + + _updateBackgroundEffects() { + const themeContext = St.ThemeContext.get_for_stage(global.stage); + + for (const widget of this._backgroundGroup) { + const effect = widget.get_effect('blur'); + + if (effect) { + effect.set({ + brightness: BLUR_BRIGHTNESS, + sigma: BLUR_SIGMA * themeContext.scale_factor, + }); + } + } + } + + _updateBackgrounds() { + for (let i = 0; i < this._bgManagers.length; i++) + this._bgManagers[i].destroy(); + + this._bgManagers = []; + this._backgroundGroup.destroy_all_children(); + + for (let i = 0; i < Main.layoutManager.monitors.length; i++) + this._createBackground(i); + this._updateBackgroundEffects(); + } + + _ensureAuthPrompt() { + if (!this._authPrompt) { + this._authPrompt = new AuthPrompt.AuthPrompt(this._gdmClient, + AuthPrompt.AuthPromptMode.UNLOCK_ONLY); + this._authPrompt.connect('failed', this._fail.bind(this)); + this._authPrompt.connect('cancelled', this._fail.bind(this)); + this._authPrompt.connect('reset', this._onReset.bind(this)); + this._promptBox.add_child(this._authPrompt); + } + + this._authPrompt.reset(); + this._authPrompt.updateSensitivity(true); + } + + _maybeDestroyAuthPrompt() { + let focus = global.stage.key_focus; + if (focus === null || + (this._authPrompt && this._authPrompt.contains(focus)) || + (this._otherUserButton && focus === this._otherUserButton)) + this.grab_key_focus(); + + if (this._authPrompt) { + this._authPrompt.destroy(); + this._authPrompt = null; + } + } + + _showClock() { + if (this._activePage === this._clock) + return; + + this._activePage = this._clock; + + this._adjustment.ease(0, { + duration: CROSSFADE_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._maybeDestroyAuthPrompt(), + }); + } + + _showPrompt() { + this._ensureAuthPrompt(); + + if (this._activePage === this._promptBox) + return; + + this._activePage = this._promptBox; + + this._adjustment.ease(1, { + duration: CROSSFADE_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + _setTransitionProgress(progress) { + this._promptBox.visible = progress > 0; + this._clock.visible = progress < 1; + + this._otherUserButton.set({ + reactive: progress > 0, + can_focus: progress > 0, + }); + + const { scaleFactor } = St.ThemeContext.get_for_stage(global.stage); + + this._promptBox.set({ + opacity: 255 * progress, + scale_x: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * progress, + scale_y: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * progress, + translation_y: FADE_OUT_TRANSLATION * (1 - progress) * scaleFactor, + }); + + this._clock.set({ + opacity: 255 * (1 - progress), + scale_x: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * (1 - progress), + scale_y: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * (1 - progress), + translation_y: -FADE_OUT_TRANSLATION * progress * scaleFactor, + }); + + this._otherUserButton.set({ + opacity: 255 * progress, + scale_x: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * progress, + scale_y: FADE_OUT_SCALE + (1 - FADE_OUT_SCALE) * progress, + }); + } + + _fail() { + this._showClock(); + this.emit('failed'); + } + + _onReset(authPrompt, beginRequest) { + let userName; + if (beginRequest == AuthPrompt.BeginRequestType.PROVIDE_USERNAME) { + this._authPrompt.setUser(this._user); + userName = this._userName; + } else { + userName = null; + } + + this._authPrompt.begin({ userName }); + } + + _escape() { + if (this._authPrompt && this.allowCancel) + this._authPrompt.cancel(); + } + + _swipeBegin(tracker, monitor) { + if (monitor !== Main.layoutManager.primaryIndex) + return; + + this._adjustment.remove_transition('value'); + + this._ensureAuthPrompt(); + + let progress = this._adjustment.value; + tracker.confirmSwipe(this._stack.height, + [0, 1], + progress, + Math.round(progress)); + } + + _swipeUpdate(tracker, progress) { + this._adjustment.value = progress; + } + + _swipeEnd(tracker, duration, endProgress) { + this._activePage = endProgress + ? this._promptBox + : this._clock; + + this._adjustment.ease(endProgress, { + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + duration, + onComplete: () => { + if (this._activePage === this._clock) + this._maybeDestroyAuthPrompt(); + }, + }); + } + + _otherUserClicked() { + Gdm.goto_login_session_sync(null); + + this._authPrompt.cancel(); + } + + _onDestroy() { + this.popModal(); + + if (this._idleWatchId) { + this._idleMonitor.remove_watch(this._idleWatchId); + this._idleWatchId = 0; + } + + if (this._gdmClient) { + this._gdmClient = null; + delete this._gdmClient; + } + } + + _updateUserSwitchVisibility() { + this._otherUserButton.visible = this._userManager.can_switch() && + this._screenSaverSettings.get_boolean('user-switch-enabled') && + !this._lockdownSettings.get_boolean('disable-user-switching'); + } + + cancel() { + if (this._authPrompt) + this._authPrompt.cancel(); + } + + finish(onComplete) { + if (!this._authPrompt) { + onComplete(); + return; + } + + this._authPrompt.finish(onComplete); + } + + open(timestamp) { + this.show(); + + if (this._isModal) + return true; + + let modalParams = { + timestamp, + actionMode: Shell.ActionMode.UNLOCK_SCREEN, + }; + let grab = Main.pushModal(Main.uiGroup, modalParams); + if (grab.get_seat_state() !== Clutter.GrabState.ALL) { + Main.popModal(grab); + return false; + } + + this._grab = grab; + this._isModal = true; + + return true; + } + + activate() { + this._showPrompt(); + } + + popModal(timestamp) { + if (this._isModal) { + Main.popModal(this._grab, timestamp); + this._grab = null; + this._isModal = false; + } + } +}); diff --git a/js/ui/userWidget.js b/js/ui/userWidget.js new file mode 100644 index 0000000..76139e1 --- /dev/null +++ b/js/ui/userWidget.js @@ -0,0 +1,212 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +// +// A widget showing the user avatar and name +/* exported UserWidget */ + +const { Clutter, GLib, GObject, St } = imports.gi; + +const Params = imports.misc.params; + +var AVATAR_ICON_SIZE = 64; + +// Adapted from gdm/gui/user-switch-applet/applet.c +// +// Copyright (C) 2004-2005 James M. Cape <jcape@ignore-your.tv>. +// Copyright (C) 2008,2009 Red Hat, Inc. + +var Avatar = GObject.registerClass( +class Avatar extends St.Bin { + _init(user, params) { + let themeContext = St.ThemeContext.get_for_stage(global.stage); + params = Params.parse(params, { + styleClass: 'user-icon', + reactive: false, + iconSize: AVATAR_ICON_SIZE, + }); + + super._init({ + style_class: params.styleClass, + reactive: params.reactive, + width: params.iconSize * themeContext.scaleFactor, + height: params.iconSize * themeContext.scaleFactor, + }); + + this._iconSize = params.iconSize; + this._user = user; + + this.bind_property('reactive', this, 'track-hover', + GObject.BindingFlags.SYNC_CREATE); + this.bind_property('reactive', this, 'can-focus', + GObject.BindingFlags.SYNC_CREATE); + + // Monitor the scaling factor to make sure we recreate the avatar when needed. + themeContext.connectObject('notify::scale-factor', this.update.bind(this), this); + } + + vfunc_style_changed() { + super.vfunc_style_changed(); + + let node = this.get_theme_node(); + let [found, iconSize] = node.lookup_length('icon-size', false); + + if (!found) + return; + + let themeContext = St.ThemeContext.get_for_stage(global.stage); + + // node.lookup_length() returns a scaled value, but we + // need unscaled + this._iconSize = iconSize / themeContext.scaleFactor; + this.update(); + } + + setSensitive(sensitive) { + this.reactive = sensitive; + } + + update() { + let iconFile = null; + if (this._user) { + iconFile = this._user.get_icon_file(); + if (iconFile && !GLib.file_test(iconFile, GLib.FileTest.EXISTS)) + iconFile = null; + } + + let { scaleFactor } = St.ThemeContext.get_for_stage(global.stage); + this.set_size( + this._iconSize * scaleFactor, + this._iconSize * scaleFactor); + + if (iconFile) { + this.child = null; + this.add_style_class_name('user-avatar'); + this.style = ` + background-image: url("${iconFile}"); + background-size: cover;`; + } else { + this.style = null; + this.child = new St.Icon({ + icon_name: 'avatar-default-symbolic', + icon_size: this._iconSize, + }); + } + } +}); + +var UserWidgetLabel = GObject.registerClass( +class UserWidgetLabel extends St.Widget { + _init(user) { + super._init({ layout_manager: new Clutter.BinLayout() }); + + this._user = user; + + this._realNameLabel = new St.Label({ + style_class: 'user-widget-label', + y_align: Clutter.ActorAlign.CENTER, + }); + this.add_child(this._realNameLabel); + + this._userNameLabel = new St.Label({ + style_class: 'user-widget-label', + y_align: Clutter.ActorAlign.CENTER, + }); + this.add_child(this._userNameLabel); + + this._currentLabel = null; + + this._user.connectObject( + 'notify::is-loaded', this._updateUser.bind(this), + 'changed', this._updateUser.bind(this), this); + this._updateUser(); + } + + vfunc_allocate(box) { + this.set_allocation(box); + + let availWidth = box.x2 - box.x1; + let availHeight = box.y2 - box.y1; + + let [, , natRealNameWidth] = this._realNameLabel.get_preferred_size(); + + let childBox = new Clutter.ActorBox(); + + let hiddenLabel; + if (natRealNameWidth <= availWidth) { + this._currentLabel = this._realNameLabel; + hiddenLabel = this._userNameLabel; + } else { + this._currentLabel = this._userNameLabel; + hiddenLabel = this._realNameLabel; + } + this.label_actor = this._currentLabel; + + hiddenLabel.allocate(childBox); + + childBox.set_size(availWidth, availHeight); + + this._currentLabel.allocate(childBox); + } + + vfunc_paint(paintContext) { + this._currentLabel.paint(paintContext); + } + + _updateUser() { + if (this._user.is_loaded) { + this._realNameLabel.text = this._user.get_real_name(); + this._userNameLabel.text = this._user.get_user_name(); + } else { + this._realNameLabel.text = ''; + this._userNameLabel.text = ''; + } + } +}); + +var UserWidget = GObject.registerClass( +class UserWidget extends St.BoxLayout { + _init(user, orientation = Clutter.Orientation.HORIZONTAL) { + // If user is null, that implies a username-based login authorization. + this._user = user; + + let vertical = orientation == Clutter.Orientation.VERTICAL; + let xAlign = vertical ? Clutter.ActorAlign.CENTER : Clutter.ActorAlign.START; + let styleClass = vertical ? 'user-widget vertical' : 'user-widget horizontal'; + + super._init({ + styleClass, + vertical, + xAlign, + }); + + this._avatar = new Avatar(user); + this._avatar.x_align = Clutter.ActorAlign.CENTER; + this.add_child(this._avatar); + + this._userLoadedId = 0; + this._userChangedId = 0; + if (user) { + this._label = new UserWidgetLabel(user); + this.add_child(this._label); + + this._label.bind_property('label-actor', this, 'label-actor', + GObject.BindingFlags.SYNC_CREATE); + + this._user.connectObject( + 'notify::is-loaded', this._updateUser.bind(this), + 'changed', this._updateUser.bind(this), this); + } else { + this._label = new St.Label({ + style_class: 'user-widget-label', + text: 'Empty User', + opacity: 0, + }); + this.add_child(this._label); + } + + this._updateUser(); + } + + _updateUser() { + this._avatar.update(); + } +}); diff --git a/js/ui/welcomeDialog.js b/js/ui/welcomeDialog.js new file mode 100644 index 0000000..63c6d90 --- /dev/null +++ b/js/ui/welcomeDialog.js @@ -0,0 +1,64 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported WelcomeDialog */ + +const { Clutter, GObject, Shell, St } = imports.gi; + +const Config = imports.misc.config; +const Dialog = imports.ui.dialog; +const Main = imports.ui.main; +const ModalDialog = imports.ui.modalDialog; + +var DialogResponse = { + NO_THANKS: 0, + TAKE_TOUR: 1, +}; + +var WelcomeDialog = GObject.registerClass( +class WelcomeDialog extends ModalDialog.ModalDialog { + _init() { + super._init({ styleClass: 'welcome-dialog' }); + + const appSystem = Shell.AppSystem.get_default(); + this._tourAppInfo = appSystem.lookup_app('org.gnome.Tour.desktop'); + + this._buildLayout(); + } + + open() { + if (!this._tourAppInfo) + return false; + + return super.open(); + } + + _buildLayout() { + const [majorVersion] = Config.PACKAGE_VERSION.split('.'); + const title = _('Welcome to GNOME %s').format(majorVersion); + const description = _('If you want to learn your way around, check out the tour.'); + const content = new Dialog.MessageDialogContent({ title, description }); + + const icon = new St.Widget({ style_class: 'welcome-dialog-image' }); + content.insert_child_at_index(icon, 0); + + this.contentLayout.add_child(content); + + this.addButton({ + label: _('No Thanks'), + action: () => this._sendResponse(DialogResponse.NO_THANKS), + key: Clutter.KEY_Escape, + }); + this.addButton({ + label: _('Take Tour'), + action: () => this._sendResponse(DialogResponse.TAKE_TOUR), + }); + } + + _sendResponse(response) { + if (response === DialogResponse.TAKE_TOUR) { + this._tourAppInfo.launch(0, -1, Shell.AppLaunchGpu.APP_PREF); + Main.overview.hide(); + } + + this.close(); + } +}); diff --git a/js/ui/windowAttentionHandler.js b/js/ui/windowAttentionHandler.js new file mode 100644 index 0000000..8da3049 --- /dev/null +++ b/js/ui/windowAttentionHandler.js @@ -0,0 +1,100 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported WindowAttentionHandler */ + +const { GObject, Shell } = imports.gi; + +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; + +var WindowAttentionHandler = class { + constructor() { + this._tracker = Shell.WindowTracker.get_default(); + global.display.connectObject( + 'window-demands-attention', this._onWindowDemandsAttention.bind(this), + 'window-marked-urgent', this._onWindowDemandsAttention.bind(this), + this); + } + + _getTitleAndBanner(app, window) { + let title = app.get_name(); + let banner = _("“%s” is ready").format(window.get_title()); + return [title, banner]; + } + + _onWindowDemandsAttention(display, window) { + // We don't want to show the notification when the window is already focused, + // because this is rather pointless. + // Some apps (like GIMP) do things like setting the urgency hint on the + // toolbar windows which would result into a notification even though GIMP itself is + // focused. + // We are just ignoring the hint on skip_taskbar windows for now. + // (Which is the same behaviour as with metacity + panel) + + if (!window || window.has_focus() || window.is_skip_taskbar()) + return; + + let app = this._tracker.get_window_app(window); + let source = new WindowAttentionSource(app, window); + Main.messageTray.add(source); + + let [title, banner] = this._getTitleAndBanner(app, window); + + let notification = new MessageTray.Notification(source, title, banner); + notification.connect('activated', () => { + source.open(); + }); + notification.setForFeedback(true); + + source.showNotification(notification); + + window.connectObject('notify::title', () => { + [title, banner] = this._getTitleAndBanner(app, window); + notification.update(title, banner); + }, source); + } +}; + +var WindowAttentionSource = GObject.registerClass( +class WindowAttentionSource extends MessageTray.Source { + _init(app, window) { + this._window = window; + this._app = app; + + super._init(app.get_name()); + + this._window.connectObject( + 'notify::demands-attention', this._sync.bind(this), + 'notify::urgent', this._sync.bind(this), + 'focus', () => this.destroy(), + 'unmanaged', () => this.destroy(), this); + } + + _sync() { + if (this._window.demands_attention || this._window.urgent) + return; + this.destroy(); + } + + _createPolicy() { + if (this._app && this._app.get_app_info()) { + let id = this._app.get_id().replace(/\.desktop$/, ''); + return new MessageTray.NotificationApplicationPolicy(id); + } else { + return new MessageTray.NotificationGenericPolicy(); + } + } + + createIcon(size) { + return this._app.create_icon_texture(size); + } + + destroy(params) { + this._window.disconnectObject(this); + + super.destroy(params); + } + + open() { + Main.activateWindow(this._window); + } +}); diff --git a/js/ui/windowManager.js b/js/ui/windowManager.js new file mode 100644 index 0000000..d415412 --- /dev/null +++ b/js/ui/windowManager.js @@ -0,0 +1,1927 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported WindowManager */ + +const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi; + +const AltTab = imports.ui.altTab; +const AppFavorites = imports.ui.appFavorites; +const Dialog = imports.ui.dialog; +const WorkspaceSwitcherPopup = imports.ui.workspaceSwitcherPopup; +const InhibitShortcutsDialog = imports.ui.inhibitShortcutsDialog; +const Main = imports.ui.main; +const ModalDialog = imports.ui.modalDialog; +const WindowMenu = imports.ui.windowMenu; +const PadOsd = imports.ui.padOsd; +const EdgeDragAction = imports.ui.edgeDragAction; +const CloseDialog = imports.ui.closeDialog; +const SwitchMonitor = imports.ui.switchMonitor; +const IBusManager = imports.misc.ibusManager; +const WorkspaceAnimation = imports.ui.workspaceAnimation; + +const { loadInterfaceXML } = imports.misc.fileUtils; + +var SHELL_KEYBINDINGS_SCHEMA = 'org.gnome.shell.keybindings'; +var MINIMIZE_WINDOW_ANIMATION_TIME = 400; +var MINIMIZE_WINDOW_ANIMATION_MODE = Clutter.AnimationMode.EASE_OUT_EXPO; +var SHOW_WINDOW_ANIMATION_TIME = 150; +var DIALOG_SHOW_WINDOW_ANIMATION_TIME = 100; +var DESTROY_WINDOW_ANIMATION_TIME = 150; +var DIALOG_DESTROY_WINDOW_ANIMATION_TIME = 100; +var WINDOW_ANIMATION_TIME = 250; +var SCROLL_TIMEOUT_TIME = 150; +var DIM_BRIGHTNESS = -0.3; +var DIM_TIME = 500; +var UNDIM_TIME = 250; +var APP_MOTION_THRESHOLD = 30; + +var ONE_SECOND = 1000; // in ms + +var MIN_NUM_WORKSPACES = 2; + +const GSD_WACOM_BUS_NAME = 'org.gnome.SettingsDaemon.Wacom'; +const GSD_WACOM_OBJECT_PATH = '/org/gnome/SettingsDaemon/Wacom'; + +const GsdWacomIface = loadInterfaceXML('org.gnome.SettingsDaemon.Wacom'); +const GsdWacomProxy = Gio.DBusProxy.makeProxyWrapper(GsdWacomIface); + +const WINDOW_DIMMER_EFFECT_NAME = "gnome-shell-window-dimmer"; + +Gio._promisify(Shell, 'util_start_systemd_unit'); +Gio._promisify(Shell, 'util_stop_systemd_unit'); + +var DisplayChangeDialog = GObject.registerClass( +class DisplayChangeDialog extends ModalDialog.ModalDialog { + _init(wm) { + super._init(); + + this._wm = wm; + + this._countDown = Meta.MonitorManager.get_display_configuration_timeout(); + + // Translators: This string should be shorter than 30 characters + let title = _('Keep these display settings?'); + let description = this._formatCountDown(); + + this._content = new Dialog.MessageDialogContent({ title, description }); + this.contentLayout.add_child(this._content); + + /* Translators: this and the following message should be limited in length, + to avoid ellipsizing the labels. + */ + this._cancelButton = this.addButton({ + label: _('Revert Settings'), + action: this._onFailure.bind(this), + key: Clutter.KEY_Escape, + }); + this._okButton = this.addButton({ + label: _('Keep Changes'), + action: this._onSuccess.bind(this), + default: true, + }); + + this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, ONE_SECOND, this._tick.bind(this)); + GLib.Source.set_name_by_id(this._timeoutId, '[gnome-shell] this._tick'); + } + + close(timestamp) { + if (this._timeoutId > 0) { + GLib.source_remove(this._timeoutId); + this._timeoutId = 0; + } + + super.close(timestamp); + } + + _formatCountDown() { + const fmt = ngettext( + 'Settings changes will revert in %d second', + 'Settings changes will revert in %d seconds', + this._countDown); + return fmt.format(this._countDown); + } + + _tick() { + this._countDown--; + + if (this._countDown == 0) { + /* mutter already takes care of failing at timeout */ + this._timeoutId = 0; + this.close(); + return GLib.SOURCE_REMOVE; + } + + this._content.description = this._formatCountDown(); + return GLib.SOURCE_CONTINUE; + } + + _onFailure() { + this._wm.complete_display_change(false); + this.close(); + } + + _onSuccess() { + this._wm.complete_display_change(true); + this.close(); + } +}); + +var WindowDimmer = GObject.registerClass( +class WindowDimmer extends Clutter.BrightnessContrastEffect { + _init() { + super._init({ + name: WINDOW_DIMMER_EFFECT_NAME, + enabled: false, + }); + } + + _syncEnabled(dimmed) { + let animating = this.actor.get_transition(`@effects.${this.name}.brightness`) !== null; + + this.enabled = Meta.prefs_get_attach_modal_dialogs() && (animating || dimmed); + } + + setDimmed(dimmed, animate) { + let val = 127 * (1 + (dimmed ? 1 : 0) * DIM_BRIGHTNESS); + let color = Clutter.Color.new(val, val, val, 255); + + this.actor.ease_property(`@effects.${this.name}.brightness`, color, { + mode: Clutter.AnimationMode.LINEAR, + duration: (dimmed ? DIM_TIME : UNDIM_TIME) * (animate ? 1 : 0), + onStopped: () => this._syncEnabled(dimmed), + }); + + this._syncEnabled(dimmed); + } +}); + +function getWindowDimmer(actor) { + let effect = actor.get_effect(WINDOW_DIMMER_EFFECT_NAME); + + if (!effect) { + effect = new WindowDimmer(); + actor.add_effect(effect); + } + return effect; +} + +/* + * When the last window closed on a workspace is a dialog or splash + * screen, we assume that it might be an initial window shown before + * the main window of an application, and give the app a grace period + * where it can map another window before we remove the workspace. + */ +var LAST_WINDOW_GRACE_TIME = 1000; + +var WorkspaceTracker = class { + constructor(wm) { + this._wm = wm; + + this._workspaces = []; + this._checkWorkspacesId = 0; + + this._pauseWorkspaceCheck = false; + + let tracker = Shell.WindowTracker.get_default(); + tracker.connect('startup-sequence-changed', this._queueCheckWorkspaces.bind(this)); + + let workspaceManager = global.workspace_manager; + workspaceManager.connect('notify::n-workspaces', + this._nWorkspacesChanged.bind(this)); + workspaceManager.connect('workspaces-reordered', () => { + this._workspaces.sort((a, b) => a.index() - b.index()); + }); + global.window_manager.connect('switch-workspace', + this._queueCheckWorkspaces.bind(this)); + + global.display.connect('window-entered-monitor', + this._windowEnteredMonitor.bind(this)); + global.display.connect('window-left-monitor', + this._windowLeftMonitor.bind(this)); + + this._workspaceSettings = new Gio.Settings({ schema_id: 'org.gnome.mutter' }); + this._workspaceSettings.connect('changed::dynamic-workspaces', this._queueCheckWorkspaces.bind(this)); + + this._nWorkspacesChanged(); + } + + blockUpdates() { + this._pauseWorkspaceCheck = true; + } + + unblockUpdates() { + this._pauseWorkspaceCheck = false; + } + + _checkWorkspaces() { + let workspaceManager = global.workspace_manager; + let i; + let emptyWorkspaces = []; + + if (!Meta.prefs_get_dynamic_workspaces()) { + this._checkWorkspacesId = 0; + return false; + } + + // Update workspaces only if Dynamic Workspace Management has not been paused by some other function + if (this._pauseWorkspaceCheck) + return true; + + for (i = 0; i < this._workspaces.length; i++) { + let lastRemoved = this._workspaces[i]._lastRemovedWindow; + if ((lastRemoved && + (lastRemoved.get_window_type() == Meta.WindowType.SPLASHSCREEN || + lastRemoved.get_window_type() == Meta.WindowType.DIALOG || + lastRemoved.get_window_type() == Meta.WindowType.MODAL_DIALOG)) || + this._workspaces[i]._keepAliveId) + emptyWorkspaces[i] = false; + else + emptyWorkspaces[i] = true; + } + + let sequences = Shell.WindowTracker.get_default().get_startup_sequences(); + for (i = 0; i < sequences.length; i++) { + let index = sequences[i].get_workspace(); + if (index >= 0 && index <= workspaceManager.n_workspaces) + emptyWorkspaces[index] = false; + } + + let windows = global.get_window_actors(); + for (i = 0; i < windows.length; i++) { + let actor = windows[i]; + let win = actor.get_meta_window(); + + if (win.is_on_all_workspaces()) + continue; + + let workspaceIndex = win.get_workspace().index(); + emptyWorkspaces[workspaceIndex] = false; + } + + // If we don't have an empty workspace at the end, add one + if (!emptyWorkspaces[emptyWorkspaces.length - 1]) { + workspaceManager.append_new_workspace(false, global.get_current_time()); + emptyWorkspaces.push(true); + } + + // Enforce minimum number of workspaces + while (emptyWorkspaces.length < MIN_NUM_WORKSPACES) { + workspaceManager.append_new_workspace(false, global.get_current_time()); + emptyWorkspaces.push(true); + } + + let lastIndex = emptyWorkspaces.length - 1; + let lastEmptyIndex = emptyWorkspaces.lastIndexOf(false) + 1; + let activeWorkspaceIndex = workspaceManager.get_active_workspace_index(); + emptyWorkspaces[activeWorkspaceIndex] = false; + + // Delete empty workspaces except for the last one; do it from the end + // to avoid index changes + for (i = lastIndex; i >= 0; i--) { + if (workspaceManager.n_workspaces === MIN_NUM_WORKSPACES) + break; + if (emptyWorkspaces[i] && i != lastEmptyIndex) + workspaceManager.remove_workspace(this._workspaces[i], global.get_current_time()); + } + + this._checkWorkspacesId = 0; + return false; + } + + keepWorkspaceAlive(workspace, duration) { + if (workspace._keepAliveId) + GLib.source_remove(workspace._keepAliveId); + + workspace._keepAliveId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, duration, () => { + workspace._keepAliveId = 0; + this._queueCheckWorkspaces(); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(workspace._keepAliveId, '[gnome-shell] this._queueCheckWorkspaces'); + } + + _windowRemoved(workspace, window) { + workspace._lastRemovedWindow = window; + this._queueCheckWorkspaces(); + let id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, LAST_WINDOW_GRACE_TIME, () => { + if (workspace._lastRemovedWindow == window) { + workspace._lastRemovedWindow = null; + this._queueCheckWorkspaces(); + } + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] this._queueCheckWorkspaces'); + } + + _windowLeftMonitor(metaDisplay, monitorIndex, _metaWin) { + // If the window left the primary monitor, that + // might make that workspace empty + if (monitorIndex == Main.layoutManager.primaryIndex) + this._queueCheckWorkspaces(); + } + + _windowEnteredMonitor(metaDisplay, monitorIndex, _metaWin) { + // If the window entered the primary monitor, that + // might make that workspace non-empty + if (monitorIndex == Main.layoutManager.primaryIndex) + this._queueCheckWorkspaces(); + } + + _queueCheckWorkspaces() { + if (this._checkWorkspacesId == 0) + this._checkWorkspacesId = Meta.later_add(Meta.LaterType.BEFORE_REDRAW, this._checkWorkspaces.bind(this)); + } + + _nWorkspacesChanged() { + let workspaceManager = global.workspace_manager; + let oldNumWorkspaces = this._workspaces.length; + let newNumWorkspaces = workspaceManager.n_workspaces; + + if (oldNumWorkspaces == newNumWorkspaces) + return false; + + if (newNumWorkspaces > oldNumWorkspaces) { + let w; + + // Assume workspaces are only added at the end + for (w = oldNumWorkspaces; w < newNumWorkspaces; w++) + this._workspaces[w] = workspaceManager.get_workspace_by_index(w); + + for (w = oldNumWorkspaces; w < newNumWorkspaces; w++) { + this._workspaces[w].connectObject( + 'window-added', this._queueCheckWorkspaces.bind(this), + 'window-removed', this._windowRemoved.bind(this), this); + } + } else { + // Assume workspaces are only removed sequentially + // (e.g. 2,3,4 - not 2,4,7) + let removedIndex; + let removedNum = oldNumWorkspaces - newNumWorkspaces; + for (let w = 0; w < oldNumWorkspaces; w++) { + let workspace = workspaceManager.get_workspace_by_index(w); + if (this._workspaces[w] != workspace) { + removedIndex = w; + break; + } + } + + let lostWorkspaces = this._workspaces.splice(removedIndex, removedNum); + lostWorkspaces.forEach(workspace => workspace.disconnectObject(this)); + } + + this._queueCheckWorkspaces(); + + return false; + } +}; + +var TilePreview = GObject.registerClass( +class TilePreview extends St.Widget { + _init() { + super._init(); + global.window_group.add_actor(this); + + this._reset(); + this._showing = false; + } + + open(window, tileRect, monitorIndex) { + let windowActor = window.get_compositor_private(); + if (!windowActor) + return; + + global.window_group.set_child_below_sibling(this, windowActor); + + if (this._rect && this._rect.equal(tileRect)) + return; + + let changeMonitor = this._monitorIndex == -1 || + this._monitorIndex != monitorIndex; + + this._monitorIndex = monitorIndex; + this._rect = tileRect; + + let monitor = Main.layoutManager.monitors[monitorIndex]; + + this._updateStyle(monitor); + + if (!this._showing || changeMonitor) { + const monitorRect = new Meta.Rectangle({ + x: monitor.x, + y: monitor.y, + width: monitor.width, + height: monitor.height, + }); + let [, rect] = window.get_frame_rect().intersect(monitorRect); + this.set_size(rect.width, rect.height); + this.set_position(rect.x, rect.y); + this.opacity = 0; + } + + this._showing = true; + this.show(); + this.ease({ + x: tileRect.x, + y: tileRect.y, + width: tileRect.width, + height: tileRect.height, + opacity: 255, + duration: WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + close() { + if (!this._showing) + return; + + this._showing = false; + this.ease({ + opacity: 0, + duration: WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._reset(), + }); + } + + _reset() { + this.hide(); + this._rect = null; + this._monitorIndex = -1; + } + + _updateStyle(monitor) { + let styles = ['tile-preview']; + if (this._monitorIndex == Main.layoutManager.primaryIndex) + styles.push('on-primary'); + if (this._rect.x == monitor.x) + styles.push('tile-preview-left'); + if (this._rect.x + this._rect.width == monitor.x + monitor.width) + styles.push('tile-preview-right'); + + this.style_class = styles.join(' '); + } +}); + +var AppSwitchAction = GObject.registerClass({ + Signals: { 'activated': {} }, +}, class AppSwitchAction extends Clutter.GestureAction { + _init() { + super._init(); + this.set_n_touch_points(3); + + global.display.connect('grab-op-begin', () => { + this.cancel(); + }); + } + + vfunc_gesture_prepare(_actor) { + if (Main.actionMode != Shell.ActionMode.NORMAL) { + this.cancel(); + return false; + } + + return this.get_n_current_points() <= 4; + } + + vfunc_gesture_begin(_actor) { + // in milliseconds + const LONG_PRESS_TIMEOUT = 250; + + let nPoints = this.get_n_current_points(); + let event = this.get_last_event(nPoints - 1); + + if (nPoints == 3) { + this._longPressStartTime = event.get_time(); + } else if (nPoints == 4) { + // Check whether the 4th finger press happens after a 3-finger long press, + // this only needs to be checked on the first 4th finger press + if (this._longPressStartTime != null && + event.get_time() < this._longPressStartTime + LONG_PRESS_TIMEOUT) { + this.cancel(); + } else { + this._longPressStartTime = null; + this.emit('activated'); + } + } + + return this.get_n_current_points() <= 4; + } + + vfunc_gesture_progress(_actor) { + if (this.get_n_current_points() == 3) { + for (let i = 0; i < this.get_n_current_points(); i++) { + let [startX, startY] = this.get_press_coords(i); + let [x, y] = this.get_motion_coords(i); + + if (Math.abs(x - startX) > APP_MOTION_THRESHOLD || + Math.abs(y - startY) > APP_MOTION_THRESHOLD) + return false; + } + } + + return true; + } +}); + +var ResizePopup = GObject.registerClass( +class ResizePopup extends St.Widget { + _init() { + super._init({ layout_manager: new Clutter.BinLayout() }); + this._label = new St.Label({ + style_class: 'resize-popup', + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + x_expand: true, + y_expand: true, + }); + this.add_child(this._label); + Main.uiGroup.add_actor(this); + } + + set(rect, displayW, displayH) { + /* Translators: This represents the size of a window. The first number is + * the width of the window and the second is the height. */ + let text = _("%d × %d").format(displayW, displayH); + this._label.set_text(text); + + this.set_position(rect.x, rect.y); + this.set_size(rect.width, rect.height); + } +}); + +var WindowManager = class { + constructor() { + this._shellwm = global.window_manager; + + this._minimizing = new Set(); + this._unminimizing = new Set(); + this._mapping = new Set(); + this._resizing = new Set(); + this._resizePending = new Set(); + this._destroying = new Set(); + + this._dimmedWindows = []; + + this._skippedActors = new Set(); + + this._allowedKeybindings = {}; + + this._isWorkspacePrepended = false; + this._canScroll = true; // limiting scrolling speed + + this._shellwm.connect('kill-window-effects', (shellwm, actor) => { + this._minimizeWindowDone(shellwm, actor); + this._mapWindowDone(shellwm, actor); + this._destroyWindowDone(shellwm, actor); + this._sizeChangeWindowDone(shellwm, actor); + }); + + this._shellwm.connect('switch-workspace', this._switchWorkspace.bind(this)); + this._shellwm.connect('show-tile-preview', this._showTilePreview.bind(this)); + this._shellwm.connect('hide-tile-preview', this._hideTilePreview.bind(this)); + this._shellwm.connect('show-window-menu', this._showWindowMenu.bind(this)); + this._shellwm.connect('minimize', this._minimizeWindow.bind(this)); + this._shellwm.connect('unminimize', this._unminimizeWindow.bind(this)); + this._shellwm.connect('size-change', this._sizeChangeWindow.bind(this)); + this._shellwm.connect('size-changed', this._sizeChangedWindow.bind(this)); + this._shellwm.connect('map', this._mapWindow.bind(this)); + this._shellwm.connect('destroy', this._destroyWindow.bind(this)); + this._shellwm.connect('filter-keybinding', this._filterKeybinding.bind(this)); + this._shellwm.connect('confirm-display-change', this._confirmDisplayChange.bind(this)); + this._shellwm.connect('create-close-dialog', this._createCloseDialog.bind(this)); + this._shellwm.connect('create-inhibit-shortcuts-dialog', this._createInhibitShortcutsDialog.bind(this)); + + this._workspaceSwitcherPopup = null; + this._tilePreview = null; + + this.allowKeybinding('switch-to-session-1', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-2', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-3', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-4', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-5', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-6', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-7', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-8', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-9', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-10', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-11', Shell.ActionMode.ALL); + this.allowKeybinding('switch-to-session-12', Shell.ActionMode.ALL); + + this.setCustomKeybindingHandler('switch-to-workspace-left', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-right', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-up', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-down', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-last', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-left', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-right', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-up', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-down', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-1', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-2', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-3', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-4', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-5', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-6', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-7', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-8', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-9', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-10', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-11', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-to-workspace-12', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-1', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-2', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-3', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-4', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-5', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-6', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-7', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-8', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-9', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-10', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-11', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-12', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('move-to-workspace-last', + Shell.ActionMode.NORMAL, + this._showWorkspaceSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-applications', + Shell.ActionMode.NORMAL, + this._startSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-group', + Shell.ActionMode.NORMAL, + this._startSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-applications-backward', + Shell.ActionMode.NORMAL, + this._startSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-group-backward', + Shell.ActionMode.NORMAL, + this._startSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-windows', + Shell.ActionMode.NORMAL, + this._startSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-windows-backward', + Shell.ActionMode.NORMAL, + this._startSwitcher.bind(this)); + this.setCustomKeybindingHandler('cycle-windows', + Shell.ActionMode.NORMAL, + this._startSwitcher.bind(this)); + this.setCustomKeybindingHandler('cycle-windows-backward', + Shell.ActionMode.NORMAL, + this._startSwitcher.bind(this)); + this.setCustomKeybindingHandler('cycle-group', + Shell.ActionMode.NORMAL, + this._startSwitcher.bind(this)); + this.setCustomKeybindingHandler('cycle-group-backward', + Shell.ActionMode.NORMAL, + this._startSwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-panels', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW | + Shell.ActionMode.LOCK_SCREEN | + Shell.ActionMode.UNLOCK_SCREEN | + Shell.ActionMode.LOGIN_SCREEN, + this._startA11ySwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-panels-backward', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW | + Shell.ActionMode.LOCK_SCREEN | + Shell.ActionMode.UNLOCK_SCREEN | + Shell.ActionMode.LOGIN_SCREEN, + this._startA11ySwitcher.bind(this)); + this.setCustomKeybindingHandler('switch-monitor', + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._startSwitcher.bind(this)); + + this.addKeybinding('open-application-menu', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.POPUP, + this._toggleAppMenu.bind(this)); + + this.addKeybinding('toggle-message-tray', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW | + Shell.ActionMode.POPUP, + this._toggleCalendar.bind(this)); + + this.addKeybinding('switch-to-application-1', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._switchToApplication.bind(this)); + + this.addKeybinding('switch-to-application-2', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._switchToApplication.bind(this)); + + this.addKeybinding('switch-to-application-3', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._switchToApplication.bind(this)); + + this.addKeybinding('switch-to-application-4', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._switchToApplication.bind(this)); + + this.addKeybinding('switch-to-application-5', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._switchToApplication.bind(this)); + + this.addKeybinding('switch-to-application-6', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._switchToApplication.bind(this)); + + this.addKeybinding('switch-to-application-7', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._switchToApplication.bind(this)); + + this.addKeybinding('switch-to-application-8', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._switchToApplication.bind(this)); + + this.addKeybinding('switch-to-application-9', + new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }), + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.NORMAL | + Shell.ActionMode.OVERVIEW, + this._switchToApplication.bind(this)); + + global.stage.connect('scroll-event', (stage, event) => { + const allowedModes = Shell.ActionMode.NORMAL; + if ((allowedModes & Main.actionMode) === 0) + return Clutter.EVENT_PROPAGATE; + + if (this._workspaceAnimation.canHandleScrollEvent(event)) + return Clutter.EVENT_PROPAGATE; + + if ((event.get_state() & global.display.compositor_modifiers) === 0) + return Clutter.EVENT_PROPAGATE; + + return this.handleWorkspaceScroll(event); + }); + + global.display.connect('show-resize-popup', this._showResizePopup.bind(this)); + global.display.connect('show-pad-osd', this._showPadOsd.bind(this)); + global.display.connect('show-osd', (display, monitorIndex, iconName, label) => { + let icon = Gio.Icon.new_for_string(iconName); + Main.osdWindowManager.show(monitorIndex, icon, label, null); + }); + + this._gsdWacomProxy = new GsdWacomProxy(Gio.DBus.session, GSD_WACOM_BUS_NAME, + GSD_WACOM_OBJECT_PATH, + (proxy, error) => { + if (error) + log(error.message); + }); + + global.display.connect('pad-mode-switch', (display, pad, _group, _mode) => { + let labels = []; + + // FIXME: Fix num buttons + for (let i = 0; i < 50; i++) { + let str = display.get_pad_action_label(pad, Meta.PadActionType.BUTTON, i); + labels.push(str ?? ''); + } + + this._gsdWacomProxy?.SetOLEDLabelsAsync( + pad.get_device_node(), labels).catch(logError); + }); + + global.display.connect('init-xserver', (display, task) => { + IBusManager.getIBusManager().restartDaemon(['--xim']); + + this._startX11Services(task); + + return true; + }); + global.display.connect('x11-display-closing', () => { + if (!Meta.is_wayland_compositor()) + return; + + this._stopX11Services(null); + + IBusManager.getIBusManager().restartDaemon(); + }); + + Main.overview.connect('showing', () => { + for (let i = 0; i < this._dimmedWindows.length; i++) + this._undimWindow(this._dimmedWindows[i]); + }); + Main.overview.connect('hiding', () => { + for (let i = 0; i < this._dimmedWindows.length; i++) + this._dimWindow(this._dimmedWindows[i]); + }); + + this._windowMenuManager = new WindowMenu.WindowMenuManager(); + + if (Main.sessionMode.hasWorkspaces) + this._workspaceTracker = new WorkspaceTracker(this); + + let appSwitchAction = new AppSwitchAction(); + appSwitchAction.connect('activated', this._switchApp.bind(this)); + global.stage.add_action_full('app-switch', Clutter.EventPhase.CAPTURE, appSwitchAction); + + let mode = Shell.ActionMode.NORMAL; + let topDragAction = new EdgeDragAction.EdgeDragAction(St.Side.TOP, mode); + topDragAction.connect('activated', () => { + let currentWindow = global.display.focus_window; + if (currentWindow) + currentWindow.unmake_fullscreen(); + }); + + let updateUnfullscreenGesture = () => { + let currentWindow = global.display.focus_window; + topDragAction.enabled = currentWindow && currentWindow.is_fullscreen(); + }; + + global.display.connect('notify::focus-window', updateUnfullscreenGesture); + global.display.connect('in-fullscreen-changed', updateUnfullscreenGesture); + updateUnfullscreenGesture(); + + global.stage.add_action_full('unfullscreen', Clutter.EventPhase.CAPTURE, topDragAction); + + this._workspaceAnimation = + new WorkspaceAnimation.WorkspaceAnimationController(); + + this._shellwm.connect('kill-switch-workspace', () => { + this._workspaceAnimation.cancelSwitchAnimation(); + this._switchWorkspaceDone(); + }); + } + + async _startX11Services(task) { + let status = true; + try { + await Shell.util_start_systemd_unit( + 'gnome-session-x11-services-ready.target', 'fail', null); + } catch (e) { + // Ignore NOT_SUPPORTED error, which indicates we are not systemd + // managed and gnome-session will have taken care of everything + // already. + // Note that we do log cancellation from here. + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_SUPPORTED)) { + log(`Error starting X11 services: ${e.message}`); + status = false; + } + } finally { + task.return_boolean(status); + } + } + + async _stopX11Services(cancellable) { + try { + await Shell.util_stop_systemd_unit( + 'gnome-session-x11-services.target', 'fail', cancellable); + } catch (e) { + // Ignore NOT_SUPPORTED error, which indicates we are not systemd + // managed and gnome-session will have taken care of everything + // already. + // Note that we do log cancellation from here. + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_SUPPORTED)) + log(`Error stopping X11 services: ${e.message}`); + } + } + + _showPadOsd(display, device, settings, imagePath, editionMode, monitorIndex) { + this._currentPadOsd = new PadOsd.PadOsd(device, settings, imagePath, editionMode, monitorIndex); + this._currentPadOsd.connect('closed', () => (this._currentPadOsd = null)); + + return this._currentPadOsd; + } + + _lookupIndex(windows, metaWindow) { + for (let i = 0; i < windows.length; i++) { + if (windows[i].metaWindow == metaWindow) + return i; + } + return -1; + } + + _switchApp() { + let windows = global.get_window_actors().filter(actor => { + let win = actor.metaWindow; + let workspaceManager = global.workspace_manager; + let activeWorkspace = workspaceManager.get_active_workspace(); + return !win.is_override_redirect() && + win.located_on_workspace(activeWorkspace); + }); + + if (windows.length == 0) + return; + + let focusWindow = global.display.focus_window; + let nextWindow; + + if (focusWindow == null) { + nextWindow = windows[0].metaWindow; + } else { + let index = this._lookupIndex(windows, focusWindow) + 1; + + if (index >= windows.length) + index = 0; + + nextWindow = windows[index].metaWindow; + } + + Main.activateWindow(nextWindow); + } + + insertWorkspace(pos) { + let workspaceManager = global.workspace_manager; + + if (!Meta.prefs_get_dynamic_workspaces()) + return; + + workspaceManager.append_new_workspace(false, global.get_current_time()); + + let windows = global.get_window_actors().map(a => a.meta_window); + + // To create a new workspace, we slide all the windows on workspaces + // below us to the next workspace, leaving a blank workspace for us + // to recycle. + windows.forEach(window => { + // If the window is attached to an ancestor, we don't need/want + // to move it + if (window.get_transient_for() != null) + return; + // Same for OR windows + if (window.is_override_redirect()) + return; + // Sticky windows don't need moving, in fact moving would + // unstick them + if (window.on_all_workspaces) + return; + // Windows on workspaces below pos don't need moving + let index = window.get_workspace().index(); + if (index < pos) + return; + window.change_workspace_by_index(index + 1, true); + }); + + // If the new workspace was inserted before the active workspace, + // activate the workspace to which its windows went + let activeIndex = workspaceManager.get_active_workspace_index(); + if (activeIndex >= pos) { + let newWs = workspaceManager.get_workspace_by_index(activeIndex + 1); + this._blockAnimations = true; + newWs.activate(global.get_current_time()); + this._blockAnimations = false; + } + } + + keepWorkspaceAlive(workspace, duration) { + if (!this._workspaceTracker) + return; + + this._workspaceTracker.keepWorkspaceAlive(workspace, duration); + } + + skipNextEffect(actor) { + this._skippedActors.add(actor); + } + + setCustomKeybindingHandler(name, modes, handler) { + if (Meta.keybindings_set_custom_handler(name, handler)) + this.allowKeybinding(name, modes); + } + + addKeybinding(name, settings, flags, modes, handler) { + let action = global.display.add_keybinding(name, settings, flags, handler); + if (action != Meta.KeyBindingAction.NONE) + this.allowKeybinding(name, modes); + return action; + } + + removeKeybinding(name) { + if (global.display.remove_keybinding(name)) + this.allowKeybinding(name, Shell.ActionMode.NONE); + } + + allowKeybinding(name, modes) { + this._allowedKeybindings[name] = modes; + } + + _shouldAnimate() { + const overviewOpen = Main.overview.visible && !Main.overview.closing; + return !(overviewOpen || this._workspaceAnimation.gestureActive); + } + + _shouldAnimateActor(actor, types) { + if (this._skippedActors.delete(actor)) + return false; + + if (!this._shouldAnimate()) + return false; + + if (!actor.get_texture()) + return false; + + let type = actor.meta_window.get_window_type(); + return types.includes(type); + } + + _minimizeWindow(shellwm, actor) { + const types = [ + Meta.WindowType.NORMAL, + Meta.WindowType.MODAL_DIALOG, + Meta.WindowType.DIALOG, + ]; + if (!this._shouldAnimateActor(actor, types)) { + shellwm.completed_minimize(actor); + return; + } + + actor.set_scale(1.0, 1.0); + + this._minimizing.add(actor); + + if (actor.meta_window.is_monitor_sized()) { + actor.ease({ + opacity: 0, + duration: MINIMIZE_WINDOW_ANIMATION_TIME, + mode: MINIMIZE_WINDOW_ANIMATION_MODE, + onStopped: () => this._minimizeWindowDone(shellwm, actor), + }); + } else { + let xDest, yDest, xScale, yScale; + let [success, geom] = actor.meta_window.get_icon_geometry(); + if (success) { + xDest = geom.x; + yDest = geom.y; + xScale = geom.width / actor.width; + yScale = geom.height / actor.height; + } else { + let monitor = Main.layoutManager.monitors[actor.meta_window.get_monitor()]; + if (!monitor) { + this._minimizeWindowDone(); + return; + } + xDest = monitor.x; + yDest = monitor.y; + if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) + xDest += monitor.width; + xScale = 0; + yScale = 0; + } + + actor.ease({ + scale_x: xScale, + scale_y: yScale, + x: xDest, + y: yDest, + duration: MINIMIZE_WINDOW_ANIMATION_TIME, + mode: MINIMIZE_WINDOW_ANIMATION_MODE, + onStopped: () => this._minimizeWindowDone(shellwm, actor), + }); + } + } + + _minimizeWindowDone(shellwm, actor) { + if (this._minimizing.delete(actor)) { + actor.remove_all_transitions(); + actor.set_scale(1.0, 1.0); + actor.set_opacity(255); + actor.set_pivot_point(0, 0); + + shellwm.completed_minimize(actor); + } + } + + _unminimizeWindow(shellwm, actor) { + const types = [ + Meta.WindowType.NORMAL, + Meta.WindowType.MODAL_DIALOG, + Meta.WindowType.DIALOG, + ]; + if (!this._shouldAnimateActor(actor, types)) { + shellwm.completed_unminimize(actor); + return; + } + + this._unminimizing.add(actor); + + if (actor.meta_window.is_monitor_sized()) { + actor.opacity = 0; + actor.set_scale(1.0, 1.0); + actor.ease({ + opacity: 255, + duration: MINIMIZE_WINDOW_ANIMATION_TIME, + mode: MINIMIZE_WINDOW_ANIMATION_MODE, + onStopped: () => this._unminimizeWindowDone(shellwm, actor), + }); + } else { + let [success, geom] = actor.meta_window.get_icon_geometry(); + if (success) { + actor.set_position(geom.x, geom.y); + actor.set_scale(geom.width / actor.width, + geom.height / actor.height); + } else { + let monitor = Main.layoutManager.monitors[actor.meta_window.get_monitor()]; + if (!monitor) { + actor.show(); + this._unminimizeWindowDone(); + return; + } + actor.set_position(monitor.x, monitor.y); + if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) + actor.x += monitor.width; + actor.set_scale(0, 0); + } + + let rect = actor.meta_window.get_buffer_rect(); + let [xDest, yDest] = [rect.x, rect.y]; + + actor.show(); + actor.ease({ + scale_x: 1, + scale_y: 1, + x: xDest, + y: yDest, + duration: MINIMIZE_WINDOW_ANIMATION_TIME, + mode: MINIMIZE_WINDOW_ANIMATION_MODE, + onStopped: () => this._unminimizeWindowDone(shellwm, actor), + }); + } + } + + _unminimizeWindowDone(shellwm, actor) { + if (this._unminimizing.delete(actor)) { + actor.remove_all_transitions(); + actor.set_scale(1.0, 1.0); + actor.set_opacity(255); + actor.set_pivot_point(0, 0); + + shellwm.completed_unminimize(actor); + } + } + + _sizeChangeWindow(shellwm, actor, whichChange, oldFrameRect, _oldBufferRect) { + const types = [Meta.WindowType.NORMAL]; + const shouldAnimate = + this._shouldAnimateActor(actor, types) && + oldFrameRect.width > 0 && + oldFrameRect.height > 0; + + if (shouldAnimate) + this._prepareAnimationInfo(shellwm, actor, oldFrameRect, whichChange); + else + shellwm.completed_size_change(actor); + } + + _prepareAnimationInfo(shellwm, actor, oldFrameRect, _change) { + // Position a clone of the window on top of the old position, + // while actor updates are frozen. + let actorContent = actor.paint_to_content(oldFrameRect); + let actorClone = new St.Widget({ content: actorContent }); + actorClone.set_offscreen_redirect(Clutter.OffscreenRedirect.ALWAYS); + actorClone.set_position(oldFrameRect.x, oldFrameRect.y); + actorClone.set_size(oldFrameRect.width, oldFrameRect.height); + + actor.freeze(); + + if (this._clearAnimationInfo(actor)) { + log(`Old animationInfo removed from actor ${actor}`); + this._shellwm.completed_size_change(actor); + } + + actor.connectObject('destroy', + () => this._clearAnimationInfo(actor), actorClone); + + this._resizePending.add(actor); + actor.__animationInfo = { + clone: actorClone, + oldRect: oldFrameRect, + frozen: true, + }; + } + + _sizeChangedWindow(shellwm, actor) { + if (!actor.__animationInfo) + return; + if (this._resizing.has(actor)) + return; + + let actorClone = actor.__animationInfo.clone; + let targetRect = actor.meta_window.get_frame_rect(); + let sourceRect = actor.__animationInfo.oldRect; + + let scaleX = targetRect.width / sourceRect.width; + let scaleY = targetRect.height / sourceRect.height; + + this._resizePending.delete(actor); + this._resizing.add(actor); + + Main.uiGroup.add_child(actorClone); + + // Now scale and fade out the clone + actorClone.ease({ + x: targetRect.x, + y: targetRect.y, + scale_x: scaleX, + scale_y: scaleY, + opacity: 0, + duration: WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + + actor.translation_x = -targetRect.x + sourceRect.x; + actor.translation_y = -targetRect.y + sourceRect.y; + + // Now set scale the actor to size it as the clone. + actor.scale_x = 1 / scaleX; + actor.scale_y = 1 / scaleY; + + // Scale it to its actual new size + actor.ease({ + scale_x: 1, + scale_y: 1, + translation_x: 0, + translation_y: 0, + duration: WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onStopped: () => this._sizeChangeWindowDone(shellwm, actor), + }); + + // ease didn't animate and cleared the info, we are done + if (!actor.__animationInfo) + return; + + // Now unfreeze actor updates, to get it to the new size. + // It's important that we don't wait until the animation is completed to + // do this, otherwise our scale will be applied to the old texture size. + actor.thaw(); + actor.__animationInfo.frozen = false; + } + + _clearAnimationInfo(actor) { + if (actor.__animationInfo) { + actor.__animationInfo.clone.destroy(); + if (actor.__animationInfo.frozen) + actor.thaw(); + + delete actor.__animationInfo; + return true; + } + return false; + } + + _sizeChangeWindowDone(shellwm, actor) { + if (this._resizing.delete(actor)) { + actor.remove_all_transitions(); + actor.scale_x = 1.0; + actor.scale_y = 1.0; + actor.translation_x = 0; + actor.translation_y = 0; + this._clearAnimationInfo(actor); + this._shellwm.completed_size_change(actor); + } + + if (this._resizePending.delete(actor)) { + this._clearAnimationInfo(actor); + this._shellwm.completed_size_change(actor); + } + } + + _checkDimming(window) { + const shouldDim = window.has_attached_dialogs(); + + if (shouldDim && !window._dimmed) { + window._dimmed = true; + this._dimmedWindows.push(window); + this._dimWindow(window); + } else if (!shouldDim && window._dimmed) { + window._dimmed = false; + this._dimmedWindows = + this._dimmedWindows.filter(win => win != window); + this._undimWindow(window); + } + } + + _dimWindow(window) { + let actor = window.get_compositor_private(); + if (!actor) + return; + let dimmer = getWindowDimmer(actor); + if (!dimmer) + return; + dimmer.setDimmed(true, this._shouldAnimate()); + } + + _undimWindow(window) { + let actor = window.get_compositor_private(); + if (!actor) + return; + let dimmer = getWindowDimmer(actor); + if (!dimmer) + return; + dimmer.setDimmed(false, this._shouldAnimate()); + } + + _waitForOverviewToHide() { + if (!Main.overview.visible) + return Promise.resolve(); + + return new Promise(resolve => { + const id = Main.overview.connect('hidden', () => { + Main.overview.disconnect(id); + resolve(); + }); + }); + } + + async _mapWindow(shellwm, actor) { + actor._windowType = actor.meta_window.get_window_type(); + actor.meta_window.connectObject('notify::window-type', () => { + let type = actor.meta_window.get_window_type(); + if (type === actor._windowType) + return; + if (type === Meta.WindowType.MODAL_DIALOG || + actor._windowType === Meta.WindowType.MODAL_DIALOG) { + let parent = actor.get_meta_window().get_transient_for(); + if (parent) + this._checkDimming(parent); + } + + actor._windowType = type; + }, actor); + actor.meta_window.connect('unmanaged', window => { + let parent = window.get_transient_for(); + if (parent) + this._checkDimming(parent); + }); + + if (actor.meta_window.is_attached_dialog()) + this._checkDimming(actor.get_meta_window().get_transient_for()); + + const types = [ + Meta.WindowType.NORMAL, + Meta.WindowType.DIALOG, + Meta.WindowType.MODAL_DIALOG, + ]; + if (!this._shouldAnimateActor(actor, types)) { + shellwm.completed_map(actor); + return; + } + + switch (actor._windowType) { + case Meta.WindowType.NORMAL: + actor.set_pivot_point(0.5, 1.0); + actor.scale_x = 0.01; + actor.scale_y = 0.05; + actor.opacity = 0; + actor.show(); + this._mapping.add(actor); + + await this._waitForOverviewToHide(); + actor.ease({ + opacity: 255, + scale_x: 1, + scale_y: 1, + duration: SHOW_WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_EXPO, + onStopped: () => this._mapWindowDone(shellwm, actor), + }); + break; + case Meta.WindowType.MODAL_DIALOG: + case Meta.WindowType.DIALOG: + actor.set_pivot_point(0.5, 0.5); + actor.scale_y = 0; + actor.opacity = 0; + actor.show(); + this._mapping.add(actor); + + await this._waitForOverviewToHide(); + actor.ease({ + opacity: 255, + scale_x: 1, + scale_y: 1, + duration: DIALOG_SHOW_WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onStopped: () => this._mapWindowDone(shellwm, actor), + }); + break; + default: + shellwm.completed_map(actor); + } + } + + _mapWindowDone(shellwm, actor) { + if (this._mapping.delete(actor)) { + actor.remove_all_transitions(); + actor.opacity = 255; + actor.set_pivot_point(0, 0); + actor.scale_y = 1; + actor.scale_x = 1; + actor.translation_y = 0; + actor.translation_x = 0; + shellwm.completed_map(actor); + } + } + + _destroyWindow(shellwm, actor) { + let window = actor.meta_window; + window.disconnectObject(actor); + if (window._dimmed) { + this._dimmedWindows = + this._dimmedWindows.filter(win => win != window); + } + + if (window.is_attached_dialog()) + this._checkDimming(window.get_transient_for()); + + const types = [ + Meta.WindowType.NORMAL, + Meta.WindowType.DIALOG, + Meta.WindowType.MODAL_DIALOG, + ]; + if (!this._shouldAnimateActor(actor, types)) { + shellwm.completed_destroy(actor); + return; + } + + switch (actor.meta_window.window_type) { + case Meta.WindowType.NORMAL: + actor.set_pivot_point(0.5, 0.5); + this._destroying.add(actor); + + actor.ease({ + opacity: 0, + scale_x: 0.8, + scale_y: 0.8, + duration: DESTROY_WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onStopped: () => this._destroyWindowDone(shellwm, actor), + }); + break; + case Meta.WindowType.MODAL_DIALOG: + case Meta.WindowType.DIALOG: + actor.set_pivot_point(0.5, 0.5); + this._destroying.add(actor); + + if (window.is_attached_dialog()) { + let parent = window.get_transient_for(); + parent.connectObject('unmanaged', () => { + actor.remove_all_transitions(); + this._destroyWindowDone(shellwm, actor); + }, actor); + } + + actor.ease({ + scale_y: 0, + duration: DIALOG_DESTROY_WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onStopped: () => this._destroyWindowDone(shellwm, actor), + }); + break; + default: + shellwm.completed_destroy(actor); + } + } + + _destroyWindowDone(shellwm, actor) { + if (this._destroying.delete(actor)) { + const parent = actor.get_meta_window()?.get_transient_for(); + parent?.disconnectObject(actor); + shellwm.completed_destroy(actor); + } + } + + _filterKeybinding(shellwm, binding) { + if (Main.actionMode == Shell.ActionMode.NONE) + return true; + + // There's little sense in implementing a keybinding in mutter and + // not having it work in NORMAL mode; handle this case generically + // so we don't have to explicitly allow all builtin keybindings in + // NORMAL mode. + if (Main.actionMode == Shell.ActionMode.NORMAL && + binding.is_builtin()) + return false; + + return !(this._allowedKeybindings[binding.get_name()] & Main.actionMode); + } + + _switchWorkspace(shellwm, from, to, direction) { + if (!Main.sessionMode.hasWorkspaces || !this._shouldAnimate()) { + shellwm.completed_switch_workspace(); + return; + } + + this._switchInProgress = true; + + this._workspaceAnimation.animateSwitch(from, to, direction, () => { + this._shellwm.completed_switch_workspace(); + this._switchInProgress = false; + }); + } + + _switchWorkspaceDone() { + if (!this._switchInProgress) + return; + + this._shellwm.completed_switch_workspace(); + this._switchInProgress = false; + } + + _showTilePreview(shellwm, window, tileRect, monitorIndex) { + if (!this._tilePreview) + this._tilePreview = new TilePreview(); + this._tilePreview.open(window, tileRect, monitorIndex); + } + + _hideTilePreview() { + if (!this._tilePreview) + return; + this._tilePreview.close(); + } + + _showWindowMenu(shellwm, window, menu, rect) { + this._windowMenuManager.showWindowMenuForWindow(window, menu, rect); + } + + _startSwitcher(display, window, binding) { + let constructor = null; + switch (binding.get_name()) { + case 'switch-applications': + case 'switch-applications-backward': + case 'switch-group': + case 'switch-group-backward': + constructor = AltTab.AppSwitcherPopup; + break; + case 'switch-windows': + case 'switch-windows-backward': + constructor = AltTab.WindowSwitcherPopup; + break; + case 'cycle-windows': + case 'cycle-windows-backward': + constructor = AltTab.WindowCyclerPopup; + break; + case 'cycle-group': + case 'cycle-group-backward': + constructor = AltTab.GroupCyclerPopup; + break; + case 'switch-monitor': + constructor = SwitchMonitor.SwitchMonitorPopup; + break; + } + + if (!constructor) + return; + + /* prevent a corner case where both popups show up at once */ + if (this._workspaceSwitcherPopup != null) + this._workspaceSwitcherPopup.destroy(); + + let tabPopup = new constructor(); + + if (!tabPopup.show(binding.is_reversed(), binding.get_name(), binding.get_mask())) + tabPopup.destroy(); + } + + _startA11ySwitcher(display, window, binding) { + Main.ctrlAltTabManager.popup(binding.is_reversed(), binding.get_name(), binding.get_mask()); + } + + _allowFavoriteShortcuts() { + return Main.sessionMode.hasOverview; + } + + _switchToApplication(display, window, binding) { + if (!this._allowFavoriteShortcuts()) + return; + + let [, , , target] = binding.get_name().split('-'); + let apps = AppFavorites.getAppFavorites().getFavorites(); + let app = apps[target - 1]; + if (app) { + Main.overview.hide(); + app.activate(); + } + } + + _toggleAppMenu() { + Main.panel.toggleAppMenu(); + } + + _toggleCalendar() { + Main.panel.toggleCalendar(); + } + + _showWorkspaceSwitcher(display, window, binding) { + let workspaceManager = display.get_workspace_manager(); + + if (!Main.sessionMode.hasWorkspaces) + return; + + if (workspaceManager.n_workspaces == 1) + return; + + let [action,,, target] = binding.get_name().split('-'); + let newWs; + let direction; + let vertical = workspaceManager.layout_rows == -1; + let rtl = Clutter.get_default_text_direction() == Clutter.TextDirection.RTL; + + if (action == 'move') { + // "Moving" a window to another workspace doesn't make sense when + // it cannot be unstuck, and is potentially confusing if a new + // workspaces is added at the start/end + if (window.is_always_on_all_workspaces() || + (Meta.prefs_get_workspaces_only_on_primary() && + window.get_monitor() != Main.layoutManager.primaryIndex)) + return; + } + + if (target == 'last') { + if (vertical) + direction = Meta.MotionDirection.DOWN; + else if (rtl) + direction = Meta.MotionDirection.LEFT; + else + direction = Meta.MotionDirection.RIGHT; + newWs = workspaceManager.get_workspace_by_index(workspaceManager.n_workspaces - 1); + } else if (isNaN(target)) { + // Prepend a new workspace dynamically + let prependTarget; + if (vertical) + prependTarget = 'up'; + else if (rtl) + prependTarget = 'right'; + else + prependTarget = 'left'; + if (workspaceManager.get_active_workspace_index() === 0 && + action === 'move' && target === prependTarget && + this._isWorkspacePrepended === false) { + this.insertWorkspace(0); + this._isWorkspacePrepended = true; + } + + direction = Meta.MotionDirection[target.toUpperCase()]; + newWs = workspaceManager.get_active_workspace().get_neighbor(direction); + } else if ((target > 0) && (target <= workspaceManager.n_workspaces)) { + target--; + newWs = workspaceManager.get_workspace_by_index(target); + + if (workspaceManager.get_active_workspace().index() > target) { + if (vertical) + direction = Meta.MotionDirection.UP; + else if (rtl) + direction = Meta.MotionDirection.RIGHT; + else + direction = Meta.MotionDirection.LEFT; + } else { + if (vertical) // eslint-disable-line no-lonely-if + direction = Meta.MotionDirection.DOWN; + else if (rtl) + direction = Meta.MotionDirection.LEFT; + else + direction = Meta.MotionDirection.RIGHT; + } + } + + if (workspaceManager.layout_rows == -1 && + direction != Meta.MotionDirection.UP && + direction != Meta.MotionDirection.DOWN) + return; + + if (workspaceManager.layout_columns == -1 && + direction != Meta.MotionDirection.LEFT && + direction != Meta.MotionDirection.RIGHT) + return; + + if (action == 'switch') + this.actionMoveWorkspace(newWs); + else + this.actionMoveWindow(window, newWs); + + if (!Main.overview.visible) { + if (this._workspaceSwitcherPopup == null) { + this._workspaceTracker.blockUpdates(); + this._workspaceSwitcherPopup = new WorkspaceSwitcherPopup.WorkspaceSwitcherPopup(); + this._workspaceSwitcherPopup.connect('destroy', () => { + this._workspaceTracker.unblockUpdates(); + this._workspaceSwitcherPopup = null; + this._isWorkspacePrepended = false; + }); + } + this._workspaceSwitcherPopup.display(newWs.index()); + } + } + + actionMoveWorkspace(workspace) { + if (!Main.sessionMode.hasWorkspaces) + return; + + if (!workspace.active) + workspace.activate(global.get_current_time()); + } + + actionMoveWindow(window, workspace) { + if (!Main.sessionMode.hasWorkspaces) + return; + + if (!workspace.active) { + // This won't have any effect for "always sticky" windows + // (like desktop windows or docks) + + this._workspaceAnimation.movingWindow = window; + window.change_workspace(workspace); + + global.display.clear_mouse_mode(); + workspace.activate_with_focus(window, global.get_current_time()); + } + } + + handleWorkspaceScroll(event) { + if (!this._canScroll) + return Clutter.EVENT_PROPAGATE; + + if (event.type() !== Clutter.EventType.SCROLL) + return Clutter.EVENT_PROPAGATE; + + const direction = event.get_scroll_direction(); + if (direction === Clutter.ScrollDirection.SMOOTH) + return Clutter.EVENT_PROPAGATE; + + const workspaceManager = global.workspace_manager; + const vertical = workspaceManager.layout_rows === -1; + const rtl = Clutter.get_default_text_direction() === Clutter.TextDirection.RTL; + const activeWs = workspaceManager.get_active_workspace(); + let ws; + switch (direction) { + case Clutter.ScrollDirection.UP: + if (vertical) + ws = activeWs.get_neighbor(Meta.MotionDirection.UP); + else if (rtl) + ws = activeWs.get_neighbor(Meta.MotionDirection.RIGHT); + else + ws = activeWs.get_neighbor(Meta.MotionDirection.LEFT); + break; + case Clutter.ScrollDirection.DOWN: + if (vertical) + ws = activeWs.get_neighbor(Meta.MotionDirection.DOWN); + else if (rtl) + ws = activeWs.get_neighbor(Meta.MotionDirection.LEFT); + else + ws = activeWs.get_neighbor(Meta.MotionDirection.RIGHT); + break; + case Clutter.ScrollDirection.LEFT: + ws = activeWs.get_neighbor(Meta.MotionDirection.LEFT); + break; + case Clutter.ScrollDirection.RIGHT: + ws = activeWs.get_neighbor(Meta.MotionDirection.RIGHT); + break; + default: + return Clutter.EVENT_PROPAGATE; + } + this.actionMoveWorkspace(ws); + + this._canScroll = false; + GLib.timeout_add(GLib.PRIORITY_DEFAULT, + SCROLL_TIMEOUT_TIME, () => { + this._canScroll = true; + return GLib.SOURCE_REMOVE; + }); + + return Clutter.EVENT_STOP; + } + + _confirmDisplayChange() { + let dialog = new DisplayChangeDialog(this._shellwm); + dialog.open(); + } + + _createCloseDialog(shellwm, window) { + return new CloseDialog.CloseDialog(window); + } + + _createInhibitShortcutsDialog(shellwm, window) { + return new InhibitShortcutsDialog.InhibitShortcutsDialog(window); + } + + _showResizePopup(display, show, rect, displayW, displayH) { + if (show) { + if (!this._resizePopup) + this._resizePopup = new ResizePopup(); + + this._resizePopup.set(rect, displayW, displayH); + } else { + if (!this._resizePopup) + return; + + this._resizePopup.destroy(); + this._resizePopup = null; + } + } +}; diff --git a/js/ui/windowMenu.js b/js/ui/windowMenu.js new file mode 100644 index 0000000..081645f --- /dev/null +++ b/js/ui/windowMenu.js @@ -0,0 +1,252 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -* +/* exported WindowMenuManager */ + +const { GLib, Meta, St } = imports.gi; + +const BoxPointer = imports.ui.boxpointer; +const Main = imports.ui.main; +const PopupMenu = imports.ui.popupMenu; +const Screenshot = imports.ui.screenshot; + +var WindowMenu = class extends PopupMenu.PopupMenu { + constructor(window, sourceActor) { + super(sourceActor, 0, St.Side.TOP); + + this.actor.add_style_class_name('window-menu'); + + Main.layoutManager.uiGroup.add_actor(this.actor); + this.actor.hide(); + + this._buildMenu(window); + } + + _buildMenu(window) { + let type = window.get_window_type(); + + let item; + + // Translators: entry in the window right click menu. + item = this.addAction(_('Take Screenshot'), async () => { + try { + const actor = window.get_compositor_private(); + const content = actor.paint_to_content(null); + const texture = content.get_texture(); + + await Screenshot.captureScreenshot(texture, null, 1, null); + } catch (e) { + logError(e, 'Error capturing screenshot'); + } + }); + + item = this.addAction(_('Hide'), () => { + window.minimize(); + }); + if (!window.can_minimize()) + item.setSensitive(false); + + if (window.get_maximized()) { + item = this.addAction(_('Restore'), () => { + window.unmaximize(Meta.MaximizeFlags.BOTH); + }); + } else { + item = this.addAction(_("Maximize"), () => { + window.maximize(Meta.MaximizeFlags.BOTH); + }); + } + if (!window.can_maximize()) + item.setSensitive(false); + + item = this.addAction(_("Move"), event => { + this._grabAction(window, Meta.GrabOp.KEYBOARD_MOVING, event.get_time()); + }); + if (!window.allows_move()) + item.setSensitive(false); + + item = this.addAction(_("Resize"), event => { + this._grabAction(window, Meta.GrabOp.KEYBOARD_RESIZING_UNKNOWN, event.get_time()); + }); + if (!window.allows_resize()) + item.setSensitive(false); + + if (!window.titlebar_is_onscreen() && type != Meta.WindowType.DOCK && type != Meta.WindowType.DESKTOP) { + this.addAction(_("Move Titlebar Onscreen"), () => { + window.shove_titlebar_onscreen(); + }); + } + + item = this.addAction(_("Always on Top"), () => { + if (window.is_above()) + window.unmake_above(); + else + window.make_above(); + }); + if (window.is_above()) + item.setOrnament(PopupMenu.Ornament.CHECK); + if (window.get_maximized() == Meta.MaximizeFlags.BOTH || + type == Meta.WindowType.DOCK || + type == Meta.WindowType.DESKTOP || + type == Meta.WindowType.SPLASHSCREEN) + item.setSensitive(false); + + if (Main.sessionMode.hasWorkspaces && + (!Meta.prefs_get_workspaces_only_on_primary() || + window.is_on_primary_monitor())) { + let isSticky = window.is_on_all_workspaces(); + + item = this.addAction(_("Always on Visible Workspace"), () => { + if (isSticky) + window.unstick(); + else + window.stick(); + }); + if (isSticky) + item.setOrnament(PopupMenu.Ornament.CHECK); + if (window.is_always_on_all_workspaces()) + item.setSensitive(false); + + if (!isSticky) { + let workspace = window.get_workspace(); + if (workspace != workspace.get_neighbor(Meta.MotionDirection.LEFT)) { + this.addAction(_("Move to Workspace Left"), () => { + let dir = Meta.MotionDirection.LEFT; + window.change_workspace(workspace.get_neighbor(dir)); + }); + } + if (workspace != workspace.get_neighbor(Meta.MotionDirection.RIGHT)) { + this.addAction(_("Move to Workspace Right"), () => { + let dir = Meta.MotionDirection.RIGHT; + window.change_workspace(workspace.get_neighbor(dir)); + }); + } + if (workspace != workspace.get_neighbor(Meta.MotionDirection.UP)) { + this.addAction(_("Move to Workspace Up"), () => { + let dir = Meta.MotionDirection.UP; + window.change_workspace(workspace.get_neighbor(dir)); + }); + } + if (workspace != workspace.get_neighbor(Meta.MotionDirection.DOWN)) { + this.addAction(_("Move to Workspace Down"), () => { + let dir = Meta.MotionDirection.DOWN; + window.change_workspace(workspace.get_neighbor(dir)); + }); + } + } + } + + let display = global.display; + let nMonitors = display.get_n_monitors(); + let monitorIndex = window.get_monitor(); + if (nMonitors > 1 && monitorIndex >= 0) { + this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + let dir = Meta.DisplayDirection.UP; + let upMonitorIndex = + display.get_monitor_neighbor_index(monitorIndex, dir); + if (upMonitorIndex != -1) { + this.addAction(_("Move to Monitor Up"), () => { + window.move_to_monitor(upMonitorIndex); + }); + } + + dir = Meta.DisplayDirection.DOWN; + let downMonitorIndex = + display.get_monitor_neighbor_index(monitorIndex, dir); + if (downMonitorIndex != -1) { + this.addAction(_("Move to Monitor Down"), () => { + window.move_to_monitor(downMonitorIndex); + }); + } + + dir = Meta.DisplayDirection.LEFT; + let leftMonitorIndex = + display.get_monitor_neighbor_index(monitorIndex, dir); + if (leftMonitorIndex != -1) { + this.addAction(_("Move to Monitor Left"), () => { + window.move_to_monitor(leftMonitorIndex); + }); + } + + dir = Meta.DisplayDirection.RIGHT; + let rightMonitorIndex = + display.get_monitor_neighbor_index(monitorIndex, dir); + if (rightMonitorIndex != -1) { + this.addAction(_("Move to Monitor Right"), () => { + window.move_to_monitor(rightMonitorIndex); + }); + } + } + + this.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + item = this.addAction(_("Close"), event => { + window.delete(event.get_time()); + }); + if (!window.can_close()) + item.setSensitive(false); + } + + _grabAction(window, grabOp, time) { + if (global.display.get_grab_op() == Meta.GrabOp.NONE) { + window.begin_grab_op(grabOp, true, time); + return; + } + + let waitId = 0; + let id = global.display.connect('grab-op-end', display => { + display.disconnect(id); + GLib.source_remove(waitId); + + window.begin_grab_op(grabOp, true, time); + }); + + waitId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, () => { + global.display.disconnect(id); + return GLib.SOURCE_REMOVE; + }); + } +}; + +var WindowMenuManager = class { + constructor() { + this._manager = new PopupMenu.PopupMenuManager(Main.layoutManager.dummyCursor); + + this._sourceActor = new St.Widget({ reactive: true, visible: false }); + this._sourceActor.connect('button-press-event', () => { + this._manager.activeMenu.toggle(); + }); + Main.uiGroup.add_actor(this._sourceActor); + } + + showWindowMenuForWindow(window, type, rect) { + if (!Main.sessionMode.hasWmMenus) + return; + + if (type != Meta.WindowMenuType.WM) + throw new Error('Unsupported window menu type'); + let menu = new WindowMenu(window, this._sourceActor); + + this._manager.addMenu(menu); + + menu.connect('activate', () => { + window.check_alive(global.get_current_time()); + }); + let destroyId = window.connect('unmanaged', () => { + menu.close(); + }); + + this._sourceActor.set_size(Math.max(1, rect.width), Math.max(1, rect.height)); + this._sourceActor.set_position(rect.x, rect.y); + this._sourceActor.show(); + + menu.open(BoxPointer.PopupAnimation.FADE); + menu.actor.navigate_focus(null, St.DirectionType.TAB_FORWARD, false); + menu.connect('open-state-changed', (menu_, isOpen) => { + if (isOpen) + return; + + this._sourceActor.hide(); + menu.destroy(); + window.disconnect(destroyId); + }); + } +}; diff --git a/js/ui/windowPreview.js b/js/ui/windowPreview.js new file mode 100644 index 0000000..c650414 --- /dev/null +++ b/js/ui/windowPreview.js @@ -0,0 +1,681 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported WindowPreview */ + +const { + Atk, Clutter, GLib, GObject, Graphene, Meta, Pango, Shell, St, +} = imports.gi; + +const DND = imports.ui.dnd; +const OverviewControls = imports.ui.overviewControls; + +var WINDOW_DND_SIZE = 256; + +var WINDOW_OVERLAY_IDLE_HIDE_TIMEOUT = 750; +var WINDOW_OVERLAY_FADE_TIME = 200; + +var WINDOW_SCALE_TIME = 200; +var WINDOW_ACTIVE_SIZE_INC = 5; // in each direction + +var DRAGGING_WINDOW_OPACITY = 100; + +const ICON_SIZE = 64; +const ICON_OVERLAP = 0.7; + +const ICON_TITLE_SPACING = 6; + +var WindowPreview = GObject.registerClass({ + Properties: { + 'overlay-enabled': GObject.ParamSpec.boolean( + 'overlay-enabled', 'overlay-enabled', 'overlay-enabled', + GObject.ParamFlags.READWRITE, + true), + }, + Signals: { + 'drag-begin': {}, + 'drag-cancelled': {}, + 'drag-end': {}, + 'selected': { param_types: [GObject.TYPE_UINT] }, + 'show-chrome': {}, + 'size-changed': {}, + }, +}, class WindowPreview extends Shell.WindowPreview { + _init(metaWindow, workspace, overviewAdjustment) { + this.metaWindow = metaWindow; + this.metaWindow._delegate = this; + this._windowActor = metaWindow.get_compositor_private(); + this._workspace = workspace; + this._overviewAdjustment = overviewAdjustment; + + super._init({ + reactive: true, + can_focus: true, + accessible_role: Atk.Role.PUSH_BUTTON, + offscreen_redirect: Clutter.OffscreenRedirect.AUTOMATIC_FOR_OPACITY, + }); + + const windowContainer = new Clutter.Actor({ + pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }), + }); + this.window_container = windowContainer; + + windowContainer.connect('notify::scale-x', + () => this._adjustOverlayOffsets()); + // gjs currently can't handle setting an actors layout manager during + // the initialization of the actor if that layout manager keeps track + // of its container, so set the layout manager after creating the + // container + windowContainer.layout_manager = new Shell.WindowPreviewLayout(); + this.add_child(windowContainer); + + this._addWindow(metaWindow); + + this._delegate = this; + + this._stackAbove = null; + + this._cachedBoundingBox = { + x: windowContainer.layout_manager.bounding_box.x1, + y: windowContainer.layout_manager.bounding_box.y1, + width: windowContainer.layout_manager.bounding_box.get_width(), + height: windowContainer.layout_manager.bounding_box.get_height(), + }; + + windowContainer.layout_manager.connect( + 'notify::bounding-box', layout => { + this._cachedBoundingBox = { + x: layout.bounding_box.x1, + y: layout.bounding_box.y1, + width: layout.bounding_box.get_width(), + height: layout.bounding_box.get_height(), + }; + + // A bounding box of 0x0 means all windows were removed + if (layout.bounding_box.get_area() > 0) + this.emit('size-changed'); + }); + + this._windowActor.connectObject('destroy', () => this.destroy(), this); + + this._updateAttachedDialogs(); + + let clickAction = new Clutter.ClickAction(); + clickAction.connect('clicked', () => this._activate()); + clickAction.connect('long-press', this._onLongPress.bind(this)); + this.add_action(clickAction); + this.connect('destroy', this._onDestroy.bind(this)); + + this._draggable = DND.makeDraggable(this, { + restoreOnSuccess: true, + manualMode: true, + dragActorMaxSize: WINDOW_DND_SIZE, + dragActorOpacity: DRAGGING_WINDOW_OPACITY, + }); + this._draggable.connect('drag-begin', this._onDragBegin.bind(this)); + this._draggable.connect('drag-cancelled', this._onDragCancelled.bind(this)); + this._draggable.connect('drag-end', this._onDragEnd.bind(this)); + this.inDrag = false; + + this._selected = false; + this._overlayEnabled = true; + this._overlayShown = false; + this._closeRequested = false; + this._idleHideOverlayId = 0; + + const tracker = Shell.WindowTracker.get_default(); + const app = tracker.get_window_app(this.metaWindow); + this._icon = app.create_icon_texture(ICON_SIZE); + this._icon.add_style_class_name('icon-dropshadow'); + this._icon.set({ + reactive: true, + pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }), + }); + this._icon.add_constraint(new Clutter.BindConstraint({ + source: windowContainer, + coordinate: Clutter.BindCoordinate.POSITION, + })); + this._icon.add_constraint(new Clutter.AlignConstraint({ + source: windowContainer, + align_axis: Clutter.AlignAxis.X_AXIS, + factor: 0.5, + })); + this._icon.add_constraint(new Clutter.AlignConstraint({ + source: windowContainer, + align_axis: Clutter.AlignAxis.Y_AXIS, + pivot_point: new Graphene.Point({ x: -1, y: ICON_OVERLAP }), + factor: 1, + })); + + const { scaleFactor } = St.ThemeContext.get_for_stage(global.stage); + this._title = new St.Label({ + visible: false, + style_class: 'window-caption', + text: this._getCaption(), + reactive: true, + }); + this._title.clutter_text.single_line_mode = true; + this._title.add_constraint(new Clutter.BindConstraint({ + source: windowContainer, + coordinate: Clutter.BindCoordinate.X, + })); + const iconBottomOverlap = ICON_SIZE * (1 - ICON_OVERLAP); + this._title.add_constraint(new Clutter.BindConstraint({ + source: windowContainer, + coordinate: Clutter.BindCoordinate.Y, + offset: scaleFactor * (iconBottomOverlap + ICON_TITLE_SPACING), + })); + this._title.add_constraint(new Clutter.AlignConstraint({ + source: windowContainer, + align_axis: Clutter.AlignAxis.X_AXIS, + factor: 0.5, + })); + this._title.add_constraint(new Clutter.AlignConstraint({ + source: windowContainer, + align_axis: Clutter.AlignAxis.Y_AXIS, + pivot_point: new Graphene.Point({ x: -1, y: 0 }), + factor: 1, + })); + this._title.clutter_text.ellipsize = Pango.EllipsizeMode.END; + this.label_actor = this._title; + this.metaWindow.connectObject( + 'notify::title', () => (this._title.text = this._getCaption()), + this); + + const layout = Meta.prefs_get_button_layout(); + this._closeButtonSide = + layout.left_buttons.includes(Meta.ButtonFunction.CLOSE) + ? St.Side.LEFT : St.Side.RIGHT; + + this._closeButton = new St.Button({ + visible: false, + style_class: 'window-close', + icon_name: 'preview-close-symbolic', + }); + this._closeButton.add_constraint(new Clutter.BindConstraint({ + source: windowContainer, + coordinate: Clutter.BindCoordinate.POSITION, + })); + this._closeButton.add_constraint(new Clutter.AlignConstraint({ + source: windowContainer, + align_axis: Clutter.AlignAxis.X_AXIS, + pivot_point: new Graphene.Point({ x: 0.5, y: -1 }), + factor: this._closeButtonSide === St.Side.LEFT ? 0 : 1, + })); + this._closeButton.add_constraint(new Clutter.AlignConstraint({ + source: windowContainer, + align_axis: Clutter.AlignAxis.Y_AXIS, + pivot_point: new Graphene.Point({ x: -1, y: 0.5 }), + factor: 0, + })); + this._closeButton.connect('clicked', () => this._deleteAll()); + + this.add_child(this._title); + this.add_child(this._icon); + this.add_child(this._closeButton); + + this._overviewAdjustment.connectObject( + 'notify::value', () => this._updateIconScale(), this); + this._updateIconScale(); + + this.connect('notify::realized', () => { + if (!this.realized) + return; + + this._title.ensure_style(); + this._icon.ensure_style(); + }); + } + + _updateIconScale() { + const { ControlsState } = OverviewControls; + const { currentState, initialState, finalState } = + this._overviewAdjustment.getStateTransitionParams(); + const visible = + initialState === ControlsState.WINDOW_PICKER || + finalState === ControlsState.WINDOW_PICKER; + const scale = visible + ? 1 - Math.abs(ControlsState.WINDOW_PICKER - currentState) : 0; + + this._icon.set({ + scale_x: scale, + scale_y: scale, + }); + } + + _windowCanClose() { + return this.metaWindow.can_close() && + !this._hasAttachedDialogs(); + } + + _getCaption() { + if (this.metaWindow.title) + return this.metaWindow.title; + + let tracker = Shell.WindowTracker.get_default(); + let app = tracker.get_window_app(this.metaWindow); + return app.get_name(); + } + + overlapHeights() { + const [, titleHeight] = this._title.get_preferred_height(-1); + + const topOverlap = 0; + const bottomOverlap = ICON_TITLE_SPACING + titleHeight; + + return [topOverlap, bottomOverlap]; + } + + chromeHeights() { + const [, closeButtonHeight] = this._closeButton.get_preferred_height(-1); + const [, iconHeight] = this._icon.get_preferred_height(-1); + const { scaleFactor } = St.ThemeContext.get_for_stage(global.stage); + const activeExtraSize = WINDOW_ACTIVE_SIZE_INC * scaleFactor; + + const topOversize = closeButtonHeight / 2; + const bottomOversize = (1 - ICON_OVERLAP) * iconHeight; + + return [ + topOversize + activeExtraSize, + bottomOversize + activeExtraSize, + ]; + } + + chromeWidths() { + const [, closeButtonWidth] = this._closeButton.get_preferred_width(-1); + const { scaleFactor } = St.ThemeContext.get_for_stage(global.stage); + const activeExtraSize = WINDOW_ACTIVE_SIZE_INC * scaleFactor; + + const leftOversize = this._closeButtonSide === St.Side.LEFT + ? closeButtonWidth / 2 + : 0; + const rightOversize = this._closeButtonSide === St.Side.LEFT + ? 0 + : closeButtonWidth / 2; + + return [ + leftOversize + activeExtraSize, + rightOversize + activeExtraSize, + ]; + } + + showOverlay(animate) { + if (!this._overlayEnabled) + return; + + if (this._overlayShown) + return; + + this._overlayShown = true; + this._restack(); + + // If we're supposed to animate and an animation in our direction + // is already happening, let that one continue + const ongoingTransition = this._title.get_transition('opacity'); + if (animate && + ongoingTransition && + ongoingTransition.get_interval().peek_final_value() === 255) + return; + + const toShow = this._windowCanClose() + ? [this._title, this._closeButton] + : [this._title]; + + toShow.forEach(a => { + a.opacity = 0; + a.show(); + a.ease({ + opacity: 255, + duration: animate ? WINDOW_OVERLAY_FADE_TIME : 0, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + }); + + const [width, height] = this.window_container.get_size(); + const { scaleFactor } = St.ThemeContext.get_for_stage(global.stage); + const activeExtraSize = WINDOW_ACTIVE_SIZE_INC * 2 * scaleFactor; + const origSize = Math.max(width, height); + const scale = (origSize + activeExtraSize) / origSize; + + this.window_container.ease({ + scale_x: scale, + scale_y: scale, + duration: animate ? WINDOW_SCALE_TIME : 0, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + + this.emit('show-chrome'); + } + + hideOverlay(animate) { + if (!this._overlayShown) + return; + + this._overlayShown = false; + this._restack(); + + // If we're supposed to animate and an animation in our direction + // is already happening, let that one continue + const ongoingTransition = this._title.get_transition('opacity'); + if (animate && + ongoingTransition && + ongoingTransition.get_interval().peek_final_value() === 0) + return; + + [this._title, this._closeButton].forEach(a => { + a.opacity = 255; + a.ease({ + opacity: 0, + duration: animate ? WINDOW_OVERLAY_FADE_TIME : 0, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => a.hide(), + }); + }); + + this.window_container.ease({ + scale_x: 1, + scale_y: 1, + duration: animate ? WINDOW_SCALE_TIME : 0, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + _adjustOverlayOffsets() { + // Assume that scale-x and scale-y update always set + // in lock-step; that allows us to not use separate + // handlers for horizontal and vertical offsets + const previewScale = this.window_container.scale_x; + const [previewWidth, previewHeight] = + this.window_container.allocation.get_size(); + + const heightIncrease = + Math.floor(previewHeight * (previewScale - 1) / 2); + const widthIncrease = + Math.floor(previewWidth * (previewScale - 1) / 2); + + const closeAlign = this._closeButtonSide === St.Side.LEFT ? -1 : 1; + + this._icon.translation_y = heightIncrease; + this._title.translation_y = heightIncrease; + this._closeButton.set({ + translation_x: closeAlign * widthIncrease, + translation_y: -heightIncrease, + }); + } + + _addWindow(metaWindow) { + const clone = this.window_container.layout_manager.add_window(metaWindow); + if (!clone) + return; + + // We expect this to be used for all interaction rather than + // the ClutterClone; as the former is reactive and the latter + // is not, this just works for most cases. However, for DND all + // actors are picked, so DND operations would operate on the clone. + // To avoid this, we hide it from pick. + Shell.util_set_hidden_from_pick(clone, true); + } + + vfunc_has_overlaps() { + return this._hasAttachedDialogs() || + this._icon.visible || + this._closeButton.visible; + } + + _deleteAll() { + const windows = this.window_container.layout_manager.get_windows(); + + // Delete all windows, starting from the bottom-most (most-modal) one + for (const window of windows.reverse()) + window.delete(global.get_current_time()); + + this._closeRequested = true; + } + + addDialog(win) { + let parent = win.get_transient_for(); + while (parent.is_attached_dialog()) + parent = parent.get_transient_for(); + + // Display dialog if it is attached to our metaWindow + if (win.is_attached_dialog() && parent == this.metaWindow) + this._addWindow(win); + + // The dialog popped up after the user tried to close the window, + // assume it's a close confirmation and leave the overview + if (this._closeRequested) + this._activate(); + } + + _hasAttachedDialogs() { + return this.window_container.layout_manager.get_windows().length > 1; + } + + _updateAttachedDialogs() { + let iter = win => { + let actor = win.get_compositor_private(); + + if (!actor) + return false; + if (!win.is_attached_dialog()) + return false; + + this._addWindow(win); + win.foreach_transient(iter); + return true; + }; + this.metaWindow.foreach_transient(iter); + } + + get boundingBox() { + return { ...this._cachedBoundingBox }; + } + + get windowCenter() { + return { + x: this._cachedBoundingBox.x + this._cachedBoundingBox.width / 2, + y: this._cachedBoundingBox.y + this._cachedBoundingBox.height / 2, + }; + } + + get overlayEnabled() { + return this._overlayEnabled; + } + + set overlayEnabled(enabled) { + if (this._overlayEnabled === enabled) + return; + + this._overlayEnabled = enabled; + this.notify('overlay-enabled'); + + if (!enabled) + this.hideOverlay(false); + else if (this['has-pointer'] || global.stage.key_focus === this) + this.showOverlay(true); + } + + // Find the actor just below us, respecting reparenting done by DND code + _getActualStackAbove() { + if (this._stackAbove == null) + return null; + + if (this.inDrag) { + if (this._stackAbove._delegate) + return this._stackAbove._delegate._getActualStackAbove(); + else + return null; + } else { + return this._stackAbove; + } + } + + setStackAbove(actor) { + this._stackAbove = actor; + if (this.inDrag) + // We'll fix up the stack after the drag + return; + + let parent = this.get_parent(); + let actualAbove = this._getActualStackAbove(); + if (actualAbove == null) + parent.set_child_below_sibling(this, null); + else + parent.set_child_above_sibling(this, actualAbove); + } + + _onDestroy() { + this.metaWindow._delegate = null; + this._delegate = null; + this._destroyed = true; + + if (this._longPressLater) { + Meta.later_remove(this._longPressLater); + delete this._longPressLater; + } + + if (this._idleHideOverlayId > 0) { + GLib.source_remove(this._idleHideOverlayId); + this._idleHideOverlayId = 0; + } + + if (this.inDrag) { + this.emit('drag-end'); + this.inDrag = false; + } + } + + _activate() { + this._selected = true; + this.emit('selected', global.get_current_time()); + } + + vfunc_enter_event(crossingEvent) { + this.showOverlay(true); + return super.vfunc_enter_event(crossingEvent); + } + + vfunc_leave_event(crossingEvent) { + if (this._destroyed) + return super.vfunc_leave_event(crossingEvent); + + if ((crossingEvent.flags & Clutter.EventFlags.FLAG_GRAB_NOTIFY) !== 0 && + global.stage.get_grab_actor() === this._closeButton) + return super.vfunc_leave_event(crossingEvent); + + if (this._idleHideOverlayId > 0) + GLib.source_remove(this._idleHideOverlayId); + + this._idleHideOverlayId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + WINDOW_OVERLAY_IDLE_HIDE_TIMEOUT, () => { + if (this._closeButton['has-pointer'] || + this._title['has-pointer']) + return GLib.SOURCE_CONTINUE; + + if (!this['has-pointer']) + this.hideOverlay(true); + + this._idleHideOverlayId = 0; + return GLib.SOURCE_REMOVE; + }); + + GLib.Source.set_name_by_id(this._idleHideOverlayId, '[gnome-shell] this._idleHideOverlayId'); + + return super.vfunc_leave_event(crossingEvent); + } + + vfunc_key_focus_in() { + super.vfunc_key_focus_in(); + this.showOverlay(true); + } + + vfunc_key_focus_out() { + super.vfunc_key_focus_out(); + + if (global.stage.get_grab_actor() !== this._closeButton) + this.hideOverlay(true); + } + + vfunc_key_press_event(keyEvent) { + let symbol = keyEvent.keyval; + let isEnter = symbol == Clutter.KEY_Return || symbol == Clutter.KEY_KP_Enter; + if (isEnter) { + this._activate(); + return true; + } + + return super.vfunc_key_press_event(keyEvent); + } + + _onLongPress(action, actor, state) { + // Take advantage of the Clutter policy to consider + // a long-press canceled when the pointer movement + // exceeds dnd-drag-threshold to manually start the drag + if (state == Clutter.LongPressState.CANCEL) { + let event = Clutter.get_current_event(); + this._dragTouchSequence = event.get_event_sequence(); + + if (this._longPressLater) + return true; + + // A click cancels a long-press before any click handler is + // run - make sure to not start a drag in that case + this._longPressLater = Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + delete this._longPressLater; + if (this._selected) { + this._selected = false; + return; + } + let [x, y] = action.get_coords(); + action.release(); + this._draggable.startDrag(x, y, global.get_current_time(), this._dragTouchSequence, event.get_device()); + }); + } else { + this.showOverlay(true); + } + return true; + } + + _restack() { + // We may not have a parent if DnD completed successfully, in + // which case our clone will shortly be destroyed and replaced + // with a new one on the target workspace. + const parent = this.get_parent(); + if (parent !== null) { + if (this._overlayShown) + parent.set_child_above_sibling(this, null); + else if (this._stackAbove === null) + parent.set_child_below_sibling(this, null); + else if (!this._stackAbove._overlayShown) + parent.set_child_above_sibling(this, this._stackAbove); + } + } + + _onDragBegin(_draggable, _time) { + this.inDrag = true; + this.hideOverlay(false); + this.emit('drag-begin'); + } + + handleDragOver(source, actor, x, y, time) { + return this._workspace.handleDragOver(source, actor, x, y, time); + } + + acceptDrop(source, actor, x, y, time) { + return this._workspace.acceptDrop(source, actor, x, y, time); + } + + _onDragCancelled(_draggable, _time) { + this.emit('drag-cancelled'); + } + + _onDragEnd(_draggable, _time, _snapback) { + this.inDrag = false; + + this._restack(); + + if (this['has-pointer']) + this.showOverlay(true); + + this.emit('drag-end'); + } +}); diff --git a/js/ui/workspace.js b/js/ui/workspace.js new file mode 100644 index 0000000..bf631a5 --- /dev/null +++ b/js/ui/workspace.js @@ -0,0 +1,1457 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported Workspace */ + +const {Clutter, GLib, GObject, Graphene, Meta, Shell, St} = imports.gi; + +const Background = imports.ui.background; +const DND = imports.ui.dnd; +const Main = imports.ui.main; +const OverviewControls = imports.ui.overviewControls; +const Params = imports.misc.params; +const Util = imports.misc.util; +const { WindowPreview } = imports.ui.windowPreview; + +var WINDOW_PREVIEW_MAXIMUM_SCALE = 0.95; + +var WINDOW_REPOSITIONING_DELAY = 750; + +// When calculating a layout, we calculate the scale of windows and the percent +// of the available area the new layout uses. If the values for the new layout, +// when weighted with the values as below, are worse than the previous layout's, +// we stop looking for a new layout and use the previous layout. +// Otherwise, we keep looking for a new layout. +var LAYOUT_SCALE_WEIGHT = 1; +var LAYOUT_SPACE_WEIGHT = 0.1; + +const BACKGROUND_CORNER_RADIUS_PIXELS = 30; + +// Window Thumbnail Layout Algorithm +// ================================= +// +// General overview +// ---------------- +// +// The window thumbnail layout algorithm calculates some optimal layout +// by computing layouts with some number of rows, calculating how good +// each layout is, and stopping iterating when it finds one that is worse +// than the previous layout. A layout consists of which windows are in +// which rows, row sizes and other general state tracking that would make +// calculating window positions from this information fairly easy. +// +// After a layout is computed that's considered the best layout, we +// compute the layout scale to fit it in the area, and then compute +// slots (sizes and positions) for each thumbnail. +// +// Layout generation +// ----------------- +// +// Layout generation is naive and simple: we simply add windows to a row +// until we've added too many windows to a row, and then make a new row, +// until we have our required N rows. The potential issue with this strategy +// is that we may have too many windows at the bottom in some pathological +// cases, which tends to make the thumbnails have the shape of a pile of +// sand with a peak, with one window at the top. +// +// Scaling factors +// --------------- +// +// Thumbnail position is mostly straightforward -- the main issue is +// computing an optimal scale for each window that fits the constraints, +// and doesn't make the thumbnail too small to see. There are two factors +// involved in thumbnail scale to make sure that these two goals are met: +// the window scale (calculated by _computeWindowScale) and the layout +// scale (calculated by computeSizeAndScale). +// +// The calculation logic becomes slightly more complicated because row +// and column spacing are not scaled, they're constant, so we can't +// simply generate a bunch of window positions and then scale it. In +// practice, it's not too bad -- we can simply try to fit the layout +// in the input area minus whatever spacing we have, and then add +// it back afterwards. +// +// The window scale is constant for the window's size regardless of the +// input area or the layout scale or rows or anything else, and right +// now just enlarges the window if it's too small. The fact that this +// factor is stable makes it easy to calculate, so there's no sense +// in not applying it in most calculations. +// +// The layout scale depends on the input area, the rows, etc, but is the +// same for the entire layout, rather than being per-window. After +// generating the rows of windows, we basically do some basic math to +// fit the full, unscaled layout to the input area, as described above. +// +// With these two factors combined, the final scale of each thumbnail is +// simply windowScale * layoutScale... almost. +// +// There's one additional constraint: the thumbnail scale must never be +// larger than WINDOW_PREVIEW_MAXIMUM_SCALE, which means that the inequality: +// +// windowScale * layoutScale <= WINDOW_PREVIEW_MAXIMUM_SCALE +// +// must always be true. This is for each individual window -- while we +// could adjust layoutScale to make the largest thumbnail smaller than +// WINDOW_PREVIEW_MAXIMUM_SCALE, it would shrink windows which are already +// under the inequality. To solve this, we simply cheat: we simply keep +// each window's "cell" area to be the same, but we shrink the thumbnail +// and center it horizontally, and align it to the bottom vertically. + +var LayoutStrategy = class { + constructor(params) { + params = Params.parse(params, { + monitor: null, + rowSpacing: 0, + columnSpacing: 0, + }); + + if (!params.monitor) + throw new Error(`No monitor param passed to ${this.constructor.name}`); + + this._monitor = params.monitor; + this._rowSpacing = params.rowSpacing; + this._columnSpacing = params.columnSpacing; + } + + // Compute a strategy-specific overall layout given a list of WindowPreviews + // @windows and the strategy-specific @layoutParams. + // + // Returns a strategy-specific layout object that is opaque to the user. + computeLayout(_windows, _layoutParams) { + throw new GObject.NotImplementedError(`computeLayout in ${this.constructor.name}`); + } + + // Given @layout and @area, compute the overall scale of the layout and + // space occupied by the layout. + // + // This method returns an array where the first element is the scale and + // the second element is the space. + // + // This method must be called before calling computeWindowSlots(), as it + // sets the fixed overall scale of the layout. + computeScaleAndSpace(_layout, _area) { + throw new GObject.NotImplementedError(`computeScaleAndSpace in ${this.constructor.name}`); + } + + // Returns an array with final position and size information for each + // window of the layout, given a bounding area that it will be inside of. + computeWindowSlots(_layout, _area) { + throw new GObject.NotImplementedError(`computeWindowSlots in ${this.constructor.name}`); + } +}; + +var UnalignedLayoutStrategy = class extends LayoutStrategy { + _newRow() { + // Row properties: + // + // * x, y are the position of row, relative to area + // + // * width, height are the scaled versions of fullWidth, fullHeight + // + // * width also has the spacing in between windows. It's not in + // fullWidth, as the spacing is constant, whereas fullWidth is + // meant to be scaled + // + // * neither height/fullHeight have any sort of spacing or padding + return { + x: 0, y: 0, + width: 0, height: 0, + fullWidth: 0, fullHeight: 0, + windows: [], + }; + } + + // Computes and returns an individual scaling factor for @window, + // to be applied in addition to the overall layout scale. + _computeWindowScale(window) { + // Since we align windows next to each other, the height of the + // thumbnails is much more important to preserve than the width of + // them, so two windows with equal height, but maybe differering + // widths line up. + let ratio = window.boundingBox.height / this._monitor.height; + + // The purpose of this manipulation here is to prevent windows + // from getting too small. For something like a calculator window, + // we need to bump up the size just a bit to make sure it looks + // good. We'll use a multiplier of 1.5 for this. + + // Map from [0, 1] to [1.5, 1] + return Util.lerp(1.5, 1, ratio); + } + + _computeRowSizes(layout) { + let { rows, scale } = layout; + for (let i = 0; i < rows.length; i++) { + let row = rows[i]; + row.width = row.fullWidth * scale + (row.windows.length - 1) * this._columnSpacing; + row.height = row.fullHeight * scale; + } + } + + _keepSameRow(row, window, width, idealRowWidth) { + if (row.fullWidth + width <= idealRowWidth) + return true; + + let oldRatio = row.fullWidth / idealRowWidth; + let newRatio = (row.fullWidth + width) / idealRowWidth; + + if (Math.abs(1 - newRatio) < Math.abs(1 - oldRatio)) + return true; + + return false; + } + + _sortRow(row) { + // Sort windows horizontally to minimize travel distance. + // This affects in what order the windows end up in a row. + row.windows.sort((a, b) => a.windowCenter.x - b.windowCenter.x); + } + + computeLayout(windows, layoutParams) { + layoutParams = Params.parse(layoutParams, { + numRows: 0, + }); + + if (layoutParams.numRows === 0) + throw new Error(`${this.constructor.name}: No numRows given in layout params`); + + const numRows = layoutParams.numRows; + + let rows = []; + let totalWidth = 0; + for (let i = 0; i < windows.length; i++) { + let window = windows[i]; + let s = this._computeWindowScale(window); + totalWidth += window.boundingBox.width * s; + } + + let idealRowWidth = totalWidth / numRows; + + // Sort windows vertically to minimize travel distance. + // This affects what rows the windows get placed in. + let sortedWindows = windows.slice(); + sortedWindows.sort((a, b) => a.windowCenter.y - b.windowCenter.y); + + let windowIdx = 0; + for (let i = 0; i < numRows; i++) { + let row = this._newRow(); + rows.push(row); + + for (; windowIdx < sortedWindows.length; windowIdx++) { + let window = sortedWindows[windowIdx]; + let s = this._computeWindowScale(window); + let width = window.boundingBox.width * s; + let height = window.boundingBox.height * s; + row.fullHeight = Math.max(row.fullHeight, height); + + // either new width is < idealWidth or new width is nearer from idealWidth then oldWidth + if (this._keepSameRow(row, window, width, idealRowWidth) || (i === numRows - 1)) { + row.windows.push(window); + row.fullWidth += width; + } else { + break; + } + } + } + + let gridHeight = 0; + let maxRow; + for (let i = 0; i < numRows; i++) { + let row = rows[i]; + this._sortRow(row); + + if (!maxRow || row.fullWidth > maxRow.fullWidth) + maxRow = row; + gridHeight += row.fullHeight; + } + + return { + numRows, + rows, + maxColumns: maxRow.windows.length, + gridWidth: maxRow.fullWidth, + gridHeight, + }; + } + + computeScaleAndSpace(layout, area) { + let hspacing = (layout.maxColumns - 1) * this._columnSpacing; + let vspacing = (layout.numRows - 1) * this._rowSpacing; + + let spacedWidth = area.width - hspacing; + let spacedHeight = area.height - vspacing; + + let horizontalScale = spacedWidth / layout.gridWidth; + let verticalScale = spacedHeight / layout.gridHeight; + + // Thumbnails should be less than 70% of the original size + let scale = Math.min( + horizontalScale, verticalScale, WINDOW_PREVIEW_MAXIMUM_SCALE); + + let scaledLayoutWidth = layout.gridWidth * scale + hspacing; + let scaledLayoutHeight = layout.gridHeight * scale + vspacing; + let space = (scaledLayoutWidth * scaledLayoutHeight) / (area.width * area.height); + + layout.scale = scale; + + return [scale, space]; + } + + computeWindowSlots(layout, area) { + this._computeRowSizes(layout); + + let { rows, scale } = layout; + + let slots = []; + + // Do this in three parts. + let heightWithoutSpacing = 0; + for (let i = 0; i < rows.length; i++) { + let row = rows[i]; + heightWithoutSpacing += row.height; + } + + let verticalSpacing = (rows.length - 1) * this._rowSpacing; + let additionalVerticalScale = Math.min(1, (area.height - verticalSpacing) / heightWithoutSpacing); + + // keep track how much smaller the grid becomes due to scaling + // so it can be centered again + let compensation = 0; + let y = 0; + + for (let i = 0; i < rows.length; i++) { + let row = rows[i]; + + // If this window layout row doesn't fit in the actual + // geometry, then apply an additional scale to it. + let horizontalSpacing = (row.windows.length - 1) * this._columnSpacing; + let widthWithoutSpacing = row.width - horizontalSpacing; + let additionalHorizontalScale = Math.min(1, (area.width - horizontalSpacing) / widthWithoutSpacing); + + if (additionalHorizontalScale < additionalVerticalScale) { + row.additionalScale = additionalHorizontalScale; + // Only consider the scaling in addition to the vertical scaling for centering. + compensation += (additionalVerticalScale - additionalHorizontalScale) * row.height; + } else { + row.additionalScale = additionalVerticalScale; + // No compensation when scaling vertically since centering based on a too large + // height would undo what vertical scaling is trying to achieve. + } + + row.x = area.x + (Math.max(area.width - (widthWithoutSpacing * row.additionalScale + horizontalSpacing), 0) / 2); + row.y = area.y + (Math.max(area.height - (heightWithoutSpacing + verticalSpacing), 0) / 2) + y; + y += row.height * row.additionalScale + this._rowSpacing; + } + + compensation /= 2; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const rowY = row.y + compensation; + const rowHeight = row.height * row.additionalScale; + + let x = row.x; + for (let j = 0; j < row.windows.length; j++) { + let window = row.windows[j]; + + let s = scale * this._computeWindowScale(window) * row.additionalScale; + let cellWidth = window.boundingBox.width * s; + let cellHeight = window.boundingBox.height * s; + + s = Math.min(s, WINDOW_PREVIEW_MAXIMUM_SCALE); + let cloneWidth = window.boundingBox.width * s; + const cloneHeight = window.boundingBox.height * s; + + let cloneX = x + (cellWidth - cloneWidth) / 2; + let cloneY; + + // If there's only one row, align windows vertically centered inside the row + if (rows.length === 1) + cloneY = rowY + (rowHeight - cloneHeight) / 2; + // If there are multiple rows, align windows to the bottom edge of the row + else + cloneY = rowY + rowHeight - cellHeight; + + // Align with the pixel grid to prevent blurry windows at scale = 1 + cloneX = Math.floor(cloneX); + cloneY = Math.floor(cloneY); + + slots.push([cloneX, cloneY, cloneWidth, cloneHeight, window]); + x += cellWidth + this._columnSpacing; + } + } + return slots; + } +}; + +function animateAllocation(actor, box) { + actor.save_easing_state(); + actor.set_easing_mode(Clutter.AnimationMode.EASE_OUT_QUAD); + actor.set_easing_duration(200); + + actor.allocate(box); + + actor.restore_easing_state(); + + return actor.get_transition('allocation'); +} + +var WorkspaceLayout = GObject.registerClass({ + Properties: { + 'spacing': GObject.ParamSpec.double( + 'spacing', 'Spacing', 'Spacing', + GObject.ParamFlags.READWRITE, + 0, Infinity, 20), + 'layout-frozen': GObject.ParamSpec.boolean( + 'layout-frozen', 'Layout frozen', 'Layout frozen', + GObject.ParamFlags.READWRITE, + false), + }, +}, class WorkspaceLayout extends Clutter.LayoutManager { + _init(metaWorkspace, monitorIndex, overviewAdjustment) { + super._init(); + + this._spacing = 20; + this._layoutFrozen = false; + + this._metaWorkspace = metaWorkspace; + this._monitorIndex = monitorIndex; + this._overviewAdjustment = overviewAdjustment; + + this._container = null; + this._windows = new Map(); + this._sortedWindows = []; + this._lastBox = null; + this._windowSlots = []; + this._layout = null; + + this._needsLayout = true; + + this._stateAdjustment = new St.Adjustment({ + value: 0, + lower: 0, + upper: 1, + }); + + this._stateAdjustment.connect('notify::value', () => { + this._syncOpacities(); + this.syncOverlays(); + this.layout_changed(); + }); + + this._workarea = null; + this._workareasChangedId = 0; + } + + _syncOpacity(actor, metaWindow) { + if (!metaWindow.showing_on_its_workspace()) + actor.opacity = this._stateAdjustment.value * 255; + } + + _syncOpacities() { + this._windows.forEach(({ metaWindow }, actor) => { + this._syncOpacity(actor, metaWindow); + }); + } + + _isBetterScaleAndSpace(oldScale, oldSpace, scale, space) { + let spacePower = (space - oldSpace) * LAYOUT_SPACE_WEIGHT; + let scalePower = (scale - oldScale) * LAYOUT_SCALE_WEIGHT; + + if (scale > oldScale && space > oldSpace) { + // Win win -- better scale and better space + return true; + } else if (scale > oldScale && space <= oldSpace) { + // Keep new layout only if scale gain outweighs aspect space loss + return scalePower > spacePower; + } else if (scale <= oldScale && space > oldSpace) { + // Keep new layout only if aspect space gain outweighs scale loss + return spacePower > scalePower; + } else { + // Lose -- worse scale and space + return false; + } + } + + _adjustSpacingAndPadding(rowSpacing, colSpacing, containerBox) { + if (this._sortedWindows.length === 0) + return [rowSpacing, colSpacing, containerBox]; + + // All of the overlays have the same chrome sizes, + // so just pick the first one. + const window = this._sortedWindows[0]; + + const [topOversize, bottomOversize] = window.chromeHeights(); + const [leftOversize, rightOversize] = window.chromeWidths(); + + const oversize = + Math.max(topOversize, bottomOversize, leftOversize, rightOversize); + + if (rowSpacing !== null) + rowSpacing += oversize; + if (colSpacing !== null) + colSpacing += oversize; + + if (containerBox) { + const monitor = Main.layoutManager.monitors[this._monitorIndex]; + + const bottomPoint = new Graphene.Point3D({ y: containerBox.y2 }); + const transformedBottomPoint = + this._container.apply_transform_to_point(bottomPoint); + const bottomFreeSpace = + (monitor.y + monitor.height) - transformedBottomPoint.y; + + const [, bottomOverlap] = window.overlapHeights(); + + if ((bottomOverlap + oversize) > bottomFreeSpace) + containerBox.y2 -= (bottomOverlap + oversize) - bottomFreeSpace; + } + + return [rowSpacing, colSpacing, containerBox]; + } + + _createBestLayout(area) { + const [rowSpacing, columnSpacing] = + this._adjustSpacingAndPadding(this._spacing, this._spacing, null); + + // We look for the largest scale that allows us to fit the + // largest row/tallest column on the workspace. + this._layoutStrategy = new UnalignedLayoutStrategy({ + monitor: Main.layoutManager.monitors[this._monitorIndex], + rowSpacing, + columnSpacing, + }); + + let lastLayout = null; + let lastNumColumns = -1; + let lastScale = 0; + let lastSpace = 0; + + for (let numRows = 1; ; numRows++) { + const numColumns = Math.ceil(this._sortedWindows.length / numRows); + + // If adding a new row does not change column count just stop + // (for instance: 9 windows, with 3 rows -> 3 columns, 4 rows -> + // 3 columns as well => just use 3 rows then) + if (numColumns === lastNumColumns) + break; + + const layout = this._layoutStrategy.computeLayout(this._sortedWindows, { + numRows, + }); + + const [scale, space] = this._layoutStrategy.computeScaleAndSpace(layout, area); + + if (lastLayout && !this._isBetterScaleAndSpace(lastScale, lastSpace, scale, space)) + break; + + lastLayout = layout; + lastNumColumns = numColumns; + lastScale = scale; + lastSpace = space; + } + + return lastLayout; + } + + _getWindowSlots(containerBox) { + [, , containerBox] = + this._adjustSpacingAndPadding(null, null, containerBox); + + const availArea = { + x: parseInt(containerBox.x1), + y: parseInt(containerBox.y1), + width: parseInt(containerBox.get_width()), + height: parseInt(containerBox.get_height()), + }; + + return this._layoutStrategy.computeWindowSlots(this._layout, availArea); + } + + _getAdjustedWorkarea(container) { + const workarea = this._workarea.copy(); + + if (container instanceof St.Widget) { + const themeNode = container.get_theme_node(); + workarea.width -= themeNode.get_horizontal_padding(); + workarea.height -= themeNode.get_vertical_padding(); + } + + return workarea; + } + + _syncWorkareaTracking() { + if (this._container) { + if (this._workAreaChangedId) + return; + this._workarea = Main.layoutManager.getWorkAreaForMonitor(this._monitorIndex); + this._workareasChangedId = + global.display.connect('workareas-changed', () => { + this._workarea = Main.layoutManager.getWorkAreaForMonitor(this._monitorIndex); + this.layout_changed(); + }); + } else if (this._workareasChangedId) { + global.display.disconnect(this._workareasChangedId); + this._workareasChangedId = 0; + } + } + + vfunc_set_container(container) { + this._container = container; + this._syncWorkareaTracking(); + this._stateAdjustment.actor = container; + } + + vfunc_get_preferred_width(container, forHeight) { + const workarea = this._getAdjustedWorkarea(container); + if (forHeight === -1) + return [0, workarea.width]; + + const workAreaAspectRatio = workarea.width / workarea.height; + const widthPreservingAspectRatio = forHeight * workAreaAspectRatio; + + return [0, widthPreservingAspectRatio]; + } + + vfunc_get_preferred_height(container, forWidth) { + const workarea = this._getAdjustedWorkarea(container); + if (forWidth === -1) + return [0, workarea.height]; + + const workAreaAspectRatio = workarea.width / workarea.height; + const heightPreservingAspectRatio = forWidth / workAreaAspectRatio; + + return [0, heightPreservingAspectRatio]; + } + + vfunc_allocate(container, box) { + const containerBox = container.allocation; + const [containerWidth, containerHeight] = containerBox.get_size(); + const containerAllocationChanged = + this._lastBox === null || !this._lastBox.equal(containerBox); + + // If the containers size changed, we can no longer keep around + // the old windowSlots, so we must unfreeze the layout. + // + // However, if the overview animation is in progress, don't unfreeze + // the layout. This is needed to prevent windows "snapping" to their + // new positions during the overview closing animation when the + // allocation subtly expands every frame. + if (this._layoutFrozen && containerAllocationChanged && !Main.overview.animationInProgress) { + this._layoutFrozen = false; + this.notify('layout-frozen'); + } + + const { ControlsState } = OverviewControls; + const { currentState } = + this._overviewAdjustment.getStateTransitionParams(); + const inSessionTransition = currentState <= ControlsState.WINDOW_PICKER; + + const window = this._sortedWindows[0]; + + if (inSessionTransition || !window) { + container.remove_clip(); + } else { + const [, bottomOversize] = window.chromeHeights(); + const [containerX, containerY] = containerBox.get_origin(); + + const extraHeightProgress = + currentState - OverviewControls.ControlsState.WINDOW_PICKER; + + const extraClipHeight = bottomOversize * (1 - extraHeightProgress); + + container.set_clip(containerX, containerY, + containerWidth, containerHeight + extraClipHeight); + } + + let layoutChanged = false; + if (!this._layoutFrozen || !this._lastBox) { + if (this._needsLayout) { + this._layout = this._createBestLayout(this._workarea); + this._needsLayout = false; + layoutChanged = true; + } + + if (layoutChanged || containerAllocationChanged) { + this._windowSlotsBox = box.copy(); + this._windowSlots = this._getWindowSlots(this._windowSlotsBox); + } + } + + const slotsScale = box.get_width() / this._windowSlotsBox.get_width(); + const workareaX = this._workarea.x; + const workareaY = this._workarea.y; + const workareaWidth = this._workarea.width; + const stateAdjustementValue = this._stateAdjustment.value; + + const allocationScale = containerWidth / workareaWidth; + + const childBox = new Clutter.ActorBox(); + + const nSlots = this._windowSlots.length; + for (let i = 0; i < nSlots; i++) { + let [x, y, width, height, child] = this._windowSlots[i]; + if (!child.visible) + continue; + + x *= slotsScale; + y *= slotsScale; + width *= slotsScale; + height *= slotsScale; + + const windowInfo = this._windows.get(child); + + let workspaceBoxX, workspaceBoxY; + let workspaceBoxWidth, workspaceBoxHeight; + + if (windowInfo.metaWindow.showing_on_its_workspace()) { + workspaceBoxX = (child.boundingBox.x - workareaX) * allocationScale; + workspaceBoxY = (child.boundingBox.y - workareaY) * allocationScale; + workspaceBoxWidth = child.boundingBox.width * allocationScale; + workspaceBoxHeight = child.boundingBox.height * allocationScale; + } else { + workspaceBoxX = workareaX * allocationScale; + workspaceBoxY = workareaY * allocationScale; + workspaceBoxWidth = 0; + workspaceBoxHeight = 0; + } + + // Don't allow the scaled floating size to drop below + // the target layout size. + // We only want to apply this when the scaled floating size is + // actually larger than the target layout size, that is while + // animating between the session and the window picker. + if (inSessionTransition) { + workspaceBoxWidth = Math.max(workspaceBoxWidth, width); + workspaceBoxHeight = Math.max(workspaceBoxHeight, height); + } + + x = Util.lerp(workspaceBoxX, x, stateAdjustementValue); + y = Util.lerp(workspaceBoxY, y, stateAdjustementValue); + width = Util.lerp(workspaceBoxWidth, width, stateAdjustementValue); + height = Util.lerp(workspaceBoxHeight, height, stateAdjustementValue); + + childBox.set_origin(x, y); + childBox.set_size(width, height); + + if (windowInfo.currentTransition) { + windowInfo.currentTransition.get_interval().set_final(childBox); + + // The timeline of the transition might not have been updated + // before this allocation cycle, so make sure the child + // still updates needs_allocation to FALSE. + // Unfortunately, this relies on the fast paths in + // clutter_actor_allocate(), otherwise we'd start a new + // transition on the child, replacing the current one. + child.allocate(child.allocation); + continue; + } + + // We want layout changes (ie. larger changes to the layout like + // reshuffling the window order) to be animated, but small changes + // like changes to the container size to happen immediately (for + // example if the container height is being animated, we want to + // avoid animating the children allocations to make sure they + // don't "lag behind" the other animation). + if (layoutChanged && !Main.overview.animationInProgress) { + const transition = animateAllocation(child, childBox); + if (transition) { + windowInfo.currentTransition = transition; + windowInfo.currentTransition.connect('stopped', () => { + windowInfo.currentTransition = null; + }); + } + } else { + child.allocate(childBox); + } + } + + this._lastBox = containerBox.copy(); + } + + _syncOverlay(preview) { + const active = this._metaWorkspace?.active ?? true; + preview.overlayEnabled = active && this._stateAdjustment.value === 1; + } + + /** + * syncOverlays: + * + * Synchronizes the overlay state of all window previews. + */ + syncOverlays() { + [...this._windows.keys()].forEach(preview => this._syncOverlay(preview)); + } + + /** + * addWindow: + * @param {WindowPreview} window: the window to add + * @param {Meta.Window} metaWindow: the MetaWindow of the window + * + * Adds @window to the workspace, it will be shown immediately if + * the layout isn't frozen using the layout-frozen property. + * + * If @window is already part of the workspace, nothing will happen. + */ + addWindow(window, metaWindow) { + if (this._windows.has(window)) + return; + + this._windows.set(window, { + metaWindow, + sizeChangedId: metaWindow.connect('size-changed', () => { + this._needsLayout = true; + this.layout_changed(); + }), + destroyId: window.connect('destroy', () => + this.removeWindow(window)), + currentTransition: null, + }); + + this._sortedWindows.push(window); + this._sortedWindows.sort((a, b) => { + const winA = this._windows.get(a).metaWindow; + const winB = this._windows.get(b).metaWindow; + + return winA.get_stable_sequence() - winB.get_stable_sequence(); + }); + + this._syncOpacity(window, metaWindow); + this._syncOverlay(window); + this._container.add_child(window); + + this._needsLayout = true; + this.layout_changed(); + } + + /** + * removeWindow: + * @param {WindowPreview} window: the window to remove + * + * Removes @window from the workspace if @window is a part of the + * workspace. If the layout-frozen property is set to true, the + * window will still be visible until the property is set to false. + */ + removeWindow(window) { + const windowInfo = this._windows.get(window); + if (!windowInfo) + return; + + windowInfo.metaWindow.disconnect(windowInfo.sizeChangedId); + window.disconnect(windowInfo.destroyId); + if (windowInfo.currentTransition) + window.remove_transition('allocation'); + + this._windows.delete(window); + this._sortedWindows.splice(this._sortedWindows.indexOf(window), 1); + + // The layout might be frozen and we might not update the windowSlots + // on the next allocation, so remove the slot now already + const index = this._windowSlots.findIndex(s => s[4] === window); + if (index !== -1) + this._windowSlots.splice(index, 1); + + // The window might have been reparented by DND + if (window.get_parent() === this._container) + this._container.remove_child(window); + + this._needsLayout = true; + this.layout_changed(); + } + + syncStacking(stackIndices) { + const windows = [...this._windows.keys()]; + windows.sort((a, b) => { + const seqA = this._windows.get(a).metaWindow.get_stable_sequence(); + const seqB = this._windows.get(b).metaWindow.get_stable_sequence(); + + return stackIndices[seqA] - stackIndices[seqB]; + }); + + let lastWindow = null; + for (const window of windows) { + window.setStackAbove(lastWindow); + lastWindow = window; + } + + this._needsLayout = true; + this.layout_changed(); + } + + /** + * getFocusChain: + * + * Gets the focus chain of the workspace. This function will return + * an empty array if the floating window layout is used. + * + * @returns {Array} an array of {Clutter.Actor}s + */ + getFocusChain() { + if (this._stateAdjustment.value === 0) + return []; + + // The fifth element in the slot array is the WindowPreview + return this._windowSlots.map(s => s[4]); + } + + /** + * An StAdjustment for controlling and transitioning between + * the alignment of windows using the layout strategy and the + * floating window layout. + * + * A value of 0 of the adjustment completely uses the floating + * window layout while a value of 1 completely aligns windows using + * the layout strategy. + * + * @type {St.Adjustment} + */ + get stateAdjustment() { + return this._stateAdjustment; + } + + get spacing() { + return this._spacing; + } + + set spacing(s) { + if (this._spacing === s) + return; + + this._spacing = s; + + this._needsLayout = true; + this.notify('spacing'); + this.layout_changed(); + } + + get layoutFrozen() { + return this._layoutFrozen; + } + + set layoutFrozen(f) { + if (this._layoutFrozen === f) + return; + + this._layoutFrozen = f; + + this.notify('layout-frozen'); + if (!this._layoutFrozen) + this.layout_changed(); + } +}); + +var WorkspaceBackground = GObject.registerClass( +class WorkspaceBackground extends Shell.WorkspaceBackground { + _init(monitorIndex, stateAdjustment) { + super._init({ + style_class: 'workspace-background', + x_expand: true, + y_expand: true, + monitor_index: monitorIndex, + }); + + this._monitorIndex = monitorIndex; + this._workarea = Main.layoutManager.getWorkAreaForMonitor(monitorIndex); + + this._stateAdjustment = stateAdjustment; + this._stateAdjustment.connectObject('notify::value', () => { + this._updateBorderRadius(); + this.queue_relayout(); + }, this); + this._stateAdjustment.bind_property( + 'value', this, 'state-adjustment-value', + GObject.BindingFlags.SYNC_CREATE + ); + + this._bin = new Clutter.Actor({ + layout_manager: new Clutter.BinLayout(), + clip_to_allocation: true, + }); + + this._backgroundGroup = new Meta.BackgroundGroup({ + layout_manager: new Clutter.BinLayout(), + x_expand: true, + y_expand: true, + }); + this._bin.add_child(this._backgroundGroup); + this.add_child(this._bin); + + this._bgManager = new Background.BackgroundManager({ + container: this._backgroundGroup, + monitorIndex: this._monitorIndex, + controlPosition: false, + useContentSize: false, + }); + + this._bgManager.connect('changed', () => { + this._updateRoundedClipBounds(); + this._updateBorderRadius(); + }); + + global.display.connectObject('workareas-changed', () => { + this._workarea = Main.layoutManager.getWorkAreaForMonitor(monitorIndex); + this._updateRoundedClipBounds(); + this.queue_relayout(); + }, this); + this._updateRoundedClipBounds(); + + this._updateBorderRadius(); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + _updateBorderRadius() { + const { scaleFactor } = St.ThemeContext.get_for_stage(global.stage); + const cornerRadius = scaleFactor * BACKGROUND_CORNER_RADIUS_PIXELS; + + const backgroundContent = this._bgManager.backgroundActor.content; + backgroundContent.rounded_clip_radius = + Util.lerp(0, cornerRadius, this._stateAdjustment.value); + } + + _updateRoundedClipBounds() { + const monitor = Main.layoutManager.monitors[this._monitorIndex]; + + const rect = new Graphene.Rect(); + rect.origin.x = this._workarea.x - monitor.x; + rect.origin.y = this._workarea.y - monitor.y; + rect.size.width = this._workarea.width; + rect.size.height = this._workarea.height; + + this._bgManager.backgroundActor.content.set_rounded_clip_bounds(rect); + } + + _onDestroy() { + if (this._bgManager) { + this._bgManager.destroy(); + this._bgManager = null; + } + } +}); + +/** + * @metaWorkspace: a #Meta.Workspace, or null + */ +var Workspace = GObject.registerClass( +class Workspace extends St.Widget { + _init(metaWorkspace, monitorIndex, overviewAdjustment) { + super._init({ + style_class: 'window-picker', + pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }), + layout_manager: new Clutter.BinLayout(), + }); + + const layoutManager = new WorkspaceLayout(metaWorkspace, monitorIndex, + overviewAdjustment); + + // Background + this._background = + new WorkspaceBackground(monitorIndex, layoutManager.stateAdjustment); + this.add_child(this._background); + + // Window previews + this._container = new Clutter.Actor({ + reactive: true, + x_expand: true, + y_expand: true, + }); + this._container.layout_manager = layoutManager; + this.add_child(this._container); + + this.metaWorkspace = metaWorkspace; + + this._overviewAdjustment = overviewAdjustment; + + this.monitorIndex = monitorIndex; + this._monitor = Main.layoutManager.monitors[this.monitorIndex]; + + if (monitorIndex != Main.layoutManager.primaryIndex) + this.add_style_class_name('external-monitor'); + + const clickAction = new Clutter.ClickAction(); + clickAction.connect('clicked', action => { + // Switch to the workspace when not the active one, leave the + // overview otherwise. + if (action.get_button() === 1 || action.get_button() === 0) { + const leaveOverview = this._shouldLeaveOverview(); + + this.metaWorkspace?.activate(global.get_current_time()); + if (leaveOverview) + Main.overview.hide(); + } + }); + this.bind_property('mapped', clickAction, 'enabled', GObject.BindingFlags.SYNC_CREATE); + this._container.add_action(clickAction); + + this.connect('style-changed', this._onStyleChanged.bind(this)); + this.connect('destroy', this._onDestroy.bind(this)); + + this._skipTaskbarSignals = new Map(); + const windows = global.get_window_actors().map(a => a.meta_window) + .filter(this._isMyWindow, this); + + // Create clones for windows that should be + // visible in the Overview + this._windows = []; + for (let i = 0; i < windows.length; i++) { + if (this._isOverviewWindow(windows[i])) + this._addWindowClone(windows[i]); + } + + // Track window changes, but let the window tracker process them first + this.metaWorkspace?.connectObject( + 'window-added', this._windowAdded.bind(this), GObject.ConnectFlags.AFTER, + 'window-removed', this._windowRemoved.bind(this), GObject.ConnectFlags.AFTER, + 'notify::active', () => layoutManager.syncOverlays(), this); + global.display.connectObject( + 'window-entered-monitor', this._windowEnteredMonitor.bind(this), GObject.ConnectFlags.AFTER, + 'window-left-monitor', this._windowLeftMonitor.bind(this), GObject.ConnectFlags.AFTER, + this); + this._layoutFrozenId = 0; + + // DND requires this to be set + this._delegate = this; + } + + _shouldLeaveOverview() { + if (!this.metaWorkspace || this.metaWorkspace.active) + return true; + + const overviewState = this._overviewAdjustment.value; + return overviewState > OverviewControls.ControlsState.WINDOW_PICKER; + } + + vfunc_get_focus_chain() { + return this._container.layout_manager.getFocusChain(); + } + + _lookupIndex(metaWindow) { + return this._windows.findIndex(w => w.metaWindow == metaWindow); + } + + containsMetaWindow(metaWindow) { + return this._lookupIndex(metaWindow) >= 0; + } + + isEmpty() { + return this._windows.length == 0; + } + + syncStacking(stackIndices) { + this._container.layout_manager.syncStacking(stackIndices); + } + + _doRemoveWindow(metaWin) { + let clone = this._removeWindowClone(metaWin); + + if (!clone) + return; + + clone.destroy(); + + // We need to reposition the windows; to avoid shuffling windows + // around while the user is interacting with the workspace, we delay + // the positioning until the pointer remains still for at least 750 ms + // or is moved outside the workspace + this._container.layout_manager.layout_frozen = true; + + if (this._layoutFrozenId > 0) { + GLib.source_remove(this._layoutFrozenId); + this._layoutFrozenId = 0; + } + + let [oldX, oldY] = global.get_pointer(); + + this._layoutFrozenId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + WINDOW_REPOSITIONING_DELAY, + () => { + const [newX, newY] = global.get_pointer(); + const pointerHasMoved = oldX !== newX || oldY !== newY; + const actorUnderPointer = global.stage.get_actor_at_pos( + Clutter.PickMode.REACTIVE, newX, newY); + + if ((pointerHasMoved && this.contains(actorUnderPointer)) || + this._windows.some(w => w.contains(actorUnderPointer))) { + oldX = newX; + oldY = newY; + return GLib.SOURCE_CONTINUE; + } + + this._container.layout_manager.layout_frozen = false; + this._layoutFrozenId = 0; + return GLib.SOURCE_REMOVE; + }); + + GLib.Source.set_name_by_id(this._layoutFrozenId, + '[gnome-shell] this._layoutFrozenId'); + } + + _doAddWindow(metaWin) { + let win = metaWin.get_compositor_private(); + + if (!win) { + // Newly-created windows are added to a workspace before + // the compositor finds out about them... + let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + if (metaWin.get_compositor_private() && + metaWin.get_workspace() == this.metaWorkspace) + this._doAddWindow(metaWin); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] this._doAddWindow'); + return; + } + + // We might have the window in our list already if it was on all workspaces and + // now was moved to this workspace + if (this._lookupIndex(metaWin) != -1) + return; + + if (!this._isMyWindow(metaWin)) + return; + + this._skipTaskbarSignals.set(metaWin, + metaWin.connect('notify::skip-taskbar', () => { + if (metaWin.skip_taskbar) + this._doRemoveWindow(metaWin); + else + this._doAddWindow(metaWin); + })); + + if (!this._isOverviewWindow(metaWin)) { + if (metaWin.get_transient_for() == null) + return; + + // Let the top-most ancestor handle all transients + let parent = metaWin.find_root_ancestor(); + let clone = this._windows.find(c => c.metaWindow == parent); + + // If no clone was found, the parent hasn't been created yet + // and will take care of the dialog when added + if (clone) + clone.addDialog(metaWin); + + return; + } + + const clone = this._addWindowClone(metaWin); + + clone.set_pivot_point(0.5, 0.5); + clone.scale_x = 0; + clone.scale_y = 0; + clone.ease({ + scale_x: 1, + scale_y: 1, + duration: 250, + onStopped: () => clone.set_pivot_point(0, 0), + }); + + if (this._layoutFrozenId > 0) { + // If a window was closed before, unfreeze the layout to ensure + // the new window is immediately shown + this._container.layout_manager.layout_frozen = false; + + GLib.source_remove(this._layoutFrozenId); + this._layoutFrozenId = 0; + } + } + + _windowAdded(metaWorkspace, metaWin) { + if (!Main.overview.closing) + this._doAddWindow(metaWin); + } + + _windowRemoved(metaWorkspace, metaWin) { + this._doRemoveWindow(metaWin); + } + + _windowEnteredMonitor(metaDisplay, monitorIndex, metaWin) { + if (monitorIndex === this.monitorIndex && !Main.overview.closing) + this._doAddWindow(metaWin); + } + + _windowLeftMonitor(metaDisplay, monitorIndex, metaWin) { + if (monitorIndex == this.monitorIndex) + this._doRemoveWindow(metaWin); + } + + // check for maximized windows on the workspace + hasMaximizedWindows() { + for (let i = 0; i < this._windows.length; i++) { + let metaWindow = this._windows[i].metaWindow; + if (metaWindow.showing_on_its_workspace() && + metaWindow.maximized_horizontally && + metaWindow.maximized_vertically) + return true; + } + return false; + } + + _clearSkipTaskbarSignals() { + for (const [metaWin, id] of this._skipTaskbarSignals) + metaWin.disconnect(id); + this._skipTaskbarSignals.clear(); + } + + prepareToLeaveOverview() { + this._clearSkipTaskbarSignals(); + + for (let i = 0; i < this._windows.length; i++) + this._windows[i].remove_all_transitions(); + + if (this._layoutFrozenId > 0) { + GLib.source_remove(this._layoutFrozenId); + this._layoutFrozenId = 0; + } + + this._container.layout_manager.layout_frozen = true; + Main.overview.connectObject( + 'hidden', this._doneLeavingOverview.bind(this), this); + } + + _onDestroy() { + this._clearSkipTaskbarSignals(); + + if (this._layoutFrozenId > 0) { + GLib.source_remove(this._layoutFrozenId); + this._layoutFrozenId = 0; + } + + this._windows = []; + } + + _doneLeavingOverview() { + this._container.layout_manager.layout_frozen = false; + } + + _doneShowingOverview() { + this._container.layout_manager.layout_frozen = false; + } + + _isMyWindow(window) { + const isOnWorkspace = this.metaWorkspace === null || + window.located_on_workspace(this.metaWorkspace); + const isOnMonitor = window.get_monitor() === this.monitorIndex; + + return isOnWorkspace && isOnMonitor; + } + + _isOverviewWindow(window) { + return !window.skip_taskbar; + } + + // Create a clone of a (non-desktop) window and add it to the window list + _addWindowClone(metaWindow) { + let clone = new WindowPreview(metaWindow, this, this._overviewAdjustment); + + clone.connect('selected', + this._onCloneSelected.bind(this)); + clone.connect('drag-begin', () => { + Main.overview.beginWindowDrag(metaWindow); + }); + clone.connect('drag-cancelled', () => { + Main.overview.cancelledWindowDrag(metaWindow); + }); + clone.connect('drag-end', () => { + Main.overview.endWindowDrag(metaWindow); + }); + clone.connect('show-chrome', () => { + let focus = global.stage.key_focus; + if (focus == null || this.contains(focus)) + clone.grab_key_focus(); + + this._windows.forEach(c => { + if (c !== clone) + c.hideOverlay(true); + }); + }); + clone.connect('destroy', () => { + this._doRemoveWindow(metaWindow); + }); + + this._container.layout_manager.addWindow(clone, metaWindow); + + if (this._windows.length == 0) + clone.setStackAbove(null); + else + clone.setStackAbove(this._windows[this._windows.length - 1]); + + this._windows.push(clone); + + return clone; + } + + _removeWindowClone(metaWin) { + // find the position of the window in our list + let index = this._lookupIndex(metaWin); + + if (index == -1) + return null; + + this._container.layout_manager.removeWindow(this._windows[index]); + + return this._windows.splice(index, 1).pop(); + } + + _onStyleChanged() { + const themeNode = this.get_theme_node(); + this._container.layout_manager.spacing = themeNode.get_length('spacing'); + } + + _onCloneSelected(clone, time) { + const wsIndex = this.metaWorkspace?.index(); + + if (this._shouldLeaveOverview()) + Main.activateWindow(clone.metaWindow, time, wsIndex); + else + this.metaWorkspace?.activate(time); + } + + // Draggable target interface + handleDragOver(source, _actor, _x, _y, _time) { + if (source.metaWindow && !this._isMyWindow(source.metaWindow)) + return DND.DragMotionResult.MOVE_DROP; + if (source.app && source.app.can_open_new_window()) + return DND.DragMotionResult.COPY_DROP; + if (!source.app && source.shellWorkspaceLaunch) + return DND.DragMotionResult.COPY_DROP; + + return DND.DragMotionResult.CONTINUE; + } + + acceptDrop(source, actor, x, y, time) { + let workspaceManager = global.workspace_manager; + let workspaceIndex = this.metaWorkspace + ? this.metaWorkspace.index() + : workspaceManager.get_active_workspace_index(); + + if (source.metaWindow) { + const window = source.metaWindow; + if (this._isMyWindow(window)) + return false; + + Main.moveWindowToMonitorAndWorkspace(window, + this.monitorIndex, workspaceIndex); + return true; + } else if (source.app && source.app.can_open_new_window()) { + if (source.animateLaunchAtPos) + source.animateLaunchAtPos(actor.x, actor.y); + + source.app.open_new_window(workspaceIndex); + return true; + } else if (!source.app && source.shellWorkspaceLaunch) { + // While unused in our own drag sources, shellWorkspaceLaunch allows + // extensions to define custom actions for their drag sources. + source.shellWorkspaceLaunch({ + workspace: workspaceIndex, + timestamp: time, + }); + return true; + } + + return false; + } + + get stateAdjustment() { + return this._container.layout_manager.stateAdjustment; + } +}); diff --git a/js/ui/workspaceAnimation.js b/js/ui/workspaceAnimation.js new file mode 100644 index 0000000..432044f --- /dev/null +++ b/js/ui/workspaceAnimation.js @@ -0,0 +1,496 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported WorkspaceAnimationController, WorkspaceGroup */ + +const { Clutter, GObject, Meta, Shell, St } = imports.gi; + +const Background = imports.ui.background; +const Layout = imports.ui.layout; +const Main = imports.ui.main; +const SwipeTracker = imports.ui.swipeTracker; + +const WINDOW_ANIMATION_TIME = 250; +const WORKSPACE_SPACING = 100; + +var WorkspaceGroup = GObject.registerClass( +class WorkspaceGroup extends Clutter.Actor { + _init(workspace, monitor, movingWindow) { + super._init(); + + this._workspace = workspace; + this._monitor = monitor; + this._movingWindow = movingWindow; + this._windowRecords = []; + + if (this._workspace) { + this._background = new Meta.BackgroundGroup(); + + this.add_actor(this._background); + + this._bgManager = new Background.BackgroundManager({ + container: this._background, + monitorIndex: this._monitor.index, + controlPosition: false, + }); + } + + this.width = monitor.width; + this.height = monitor.height; + this.clip_to_allocation = true; + + this._createWindows(); + + this.connect('destroy', this._onDestroy.bind(this)); + global.display.connectObject('restacked', + this._syncStacking.bind(this), this); + } + + get workspace() { + return this._workspace; + } + + _shouldShowWindow(window) { + if (!window.showing_on_its_workspace()) + return false; + + const geometry = global.display.get_monitor_geometry(this._monitor.index); + const [intersects] = window.get_frame_rect().intersect(geometry); + if (!intersects) + return false; + + const isSticky = + window.is_on_all_workspaces() || window === this._movingWindow; + + // No workspace means we should show windows that are on all workspaces + if (!this._workspace) + return isSticky; + + // Otherwise only show windows that are (only) on that workspace + return !isSticky && window.located_on_workspace(this._workspace); + } + + _syncStacking() { + const windowActors = global.get_window_actors().filter(w => + this._shouldShowWindow(w.meta_window)); + + let lastRecord; + const bottomActor = this._background ?? null; + + for (const windowActor of windowActors) { + const record = this._windowRecords.find(r => r.windowActor === windowActor); + + this.set_child_above_sibling(record.clone, + lastRecord ? lastRecord.clone : bottomActor); + lastRecord = record; + } + } + + _createWindows() { + const windowActors = global.get_window_actors().filter(w => + this._shouldShowWindow(w.meta_window)); + + for (const windowActor of windowActors) { + const clone = new Clutter.Clone({ + source: windowActor, + x: windowActor.x - this._monitor.x, + y: windowActor.y - this._monitor.y, + }); + + this.add_child(clone); + + const record = { windowActor, clone }; + + windowActor.connectObject('destroy', () => { + clone.destroy(); + this._windowRecords.splice(this._windowRecords.indexOf(record), 1); + }, this); + + this._windowRecords.push(record); + } + } + + _removeWindows() { + for (const record of this._windowRecords) + record.clone.destroy(); + + this._windowRecords = []; + } + + _onDestroy() { + this._removeWindows(); + + if (this._workspace) + this._bgManager.destroy(); + } +}); + +const MonitorGroup = GObject.registerClass({ + Properties: { + 'progress': GObject.ParamSpec.double( + 'progress', 'progress', 'progress', + GObject.ParamFlags.READWRITE, + -Infinity, Infinity, 0), + }, +}, class MonitorGroup extends St.Widget { + _init(monitor, workspaceIndices, movingWindow) { + super._init({ + clip_to_allocation: true, + style_class: 'workspace-animation', + }); + + this._monitor = monitor; + + const constraint = new Layout.MonitorConstraint({ index: monitor.index }); + this.add_constraint(constraint); + + this._container = new Clutter.Actor(); + this.add_child(this._container); + + const stickyGroup = new WorkspaceGroup(null, monitor, movingWindow); + this.add_child(stickyGroup); + + this._workspaceGroups = []; + + const workspaceManager = global.workspace_manager; + const vertical = workspaceManager.layout_rows === -1; + const activeWorkspace = workspaceManager.get_active_workspace(); + + let x = 0; + let y = 0; + + for (const i of workspaceIndices) { + const ws = workspaceManager.get_workspace_by_index(i); + const fullscreen = ws.list_windows().some(w => w.get_monitor() === monitor.index && w.is_fullscreen()); + + if (i > 0 && vertical && !fullscreen && monitor.index === Main.layoutManager.primaryIndex) { + // We have to shift windows up or down by the height of the panel to prevent having a + // visible gap between the windows while switching workspaces. Since fullscreen windows + // hide the panel, they don't need to be shifted up or down. + y -= Main.panel.height; + } + + const group = new WorkspaceGroup(ws, monitor, movingWindow); + + this._workspaceGroups.push(group); + this._container.add_child(group); + group.set_position(x, y); + + if (vertical) + y += this.baseDistance; + else if (Clutter.get_default_text_direction() === Clutter.TextDirection.RTL) + x -= this.baseDistance; + else + x += this.baseDistance; + } + + this.progress = this.getWorkspaceProgress(activeWorkspace); + } + + get baseDistance() { + const spacing = WORKSPACE_SPACING * St.ThemeContext.get_for_stage(global.stage).scale_factor; + + if (global.workspace_manager.layout_rows === -1) + return this._monitor.height + spacing; + else + return this._monitor.width + spacing; + } + + get progress() { + if (global.workspace_manager.layout_rows === -1) + return -this._container.y / this.baseDistance; + else if (this.get_text_direction() === Clutter.TextDirection.RTL) + return this._container.x / this.baseDistance; + else + return -this._container.x / this.baseDistance; + } + + set progress(p) { + if (global.workspace_manager.layout_rows === -1) + this._container.y = -Math.round(p * this.baseDistance); + else if (this.get_text_direction() === Clutter.TextDirection.RTL) + this._container.x = Math.round(p * this.baseDistance); + else + this._container.x = -Math.round(p * this.baseDistance); + } + + get index() { + return this._monitor.index; + } + + getWorkspaceProgress(workspace) { + const group = this._workspaceGroups.find(g => + g.workspace.index() === workspace.index()); + return this._getWorkspaceGroupProgress(group); + } + + _getWorkspaceGroupProgress(group) { + if (global.workspace_manager.layout_rows === -1) + return group.y / this.baseDistance; + else if (this.get_text_direction() === Clutter.TextDirection.RTL) + return -group.x / this.baseDistance; + else + return group.x / this.baseDistance; + } + + getSnapPoints() { + return this._workspaceGroups.map(g => + this._getWorkspaceGroupProgress(g)); + } + + findClosestWorkspace(progress) { + const distances = this.getSnapPoints().map(p => + Math.abs(p - progress)); + const index = distances.indexOf(Math.min(...distances)); + return this._workspaceGroups[index].workspace; + } + + _interpolateProgress(progress, monitorGroup) { + if (this.index === monitorGroup.index) + return progress; + + const points1 = monitorGroup.getSnapPoints(); + const points2 = this.getSnapPoints(); + + const upper = points1.indexOf(points1.find(p => p >= progress)); + const lower = points1.indexOf(points1.slice().reverse().find(p => p <= progress)); + + if (points1[upper] === points1[lower]) + return points2[upper]; + + const t = (progress - points1[lower]) / (points1[upper] - points1[lower]); + + return points2[lower] + (points2[upper] - points2[lower]) * t; + } + + updateSwipeForMonitor(progress, monitorGroup) { + this.progress = this._interpolateProgress(progress, monitorGroup); + } +}); + +var WorkspaceAnimationController = class { + constructor() { + this._movingWindow = null; + this._switchData = null; + + Main.overview.connect('showing', () => { + if (this._switchData) { + if (this._switchData.gestureActivated) + this._finishWorkspaceSwitch(this._switchData); + this._swipeTracker.enabled = false; + } + }); + Main.overview.connect('hiding', () => { + this._swipeTracker.enabled = true; + }); + + const swipeTracker = new SwipeTracker.SwipeTracker(global.stage, + Clutter.Orientation.HORIZONTAL, + Shell.ActionMode.NORMAL, + { allowDrag: false }); + swipeTracker.connect('begin', this._switchWorkspaceBegin.bind(this)); + swipeTracker.connect('update', this._switchWorkspaceUpdate.bind(this)); + swipeTracker.connect('end', this._switchWorkspaceEnd.bind(this)); + this._swipeTracker = swipeTracker; + + global.display.bind_property('compositor-modifiers', + this._swipeTracker, 'scroll-modifiers', + GObject.BindingFlags.SYNC_CREATE); + } + + _prepareWorkspaceSwitch(workspaceIndices) { + if (this._switchData) + return; + + const workspaceManager = global.workspace_manager; + const nWorkspaces = workspaceManager.get_n_workspaces(); + + const switchData = {}; + + this._switchData = switchData; + switchData.monitors = []; + + switchData.gestureActivated = false; + switchData.inProgress = false; + + if (!workspaceIndices) + workspaceIndices = [...Array(nWorkspaces).keys()]; + + const monitors = Meta.prefs_get_workspaces_only_on_primary() + ? [Main.layoutManager.primaryMonitor] : Main.layoutManager.monitors; + + for (const monitor of monitors) { + if (Meta.prefs_get_workspaces_only_on_primary() && + monitor.index !== Main.layoutManager.primaryIndex) + continue; + + const group = new MonitorGroup(monitor, workspaceIndices, this.movingWindow); + + Main.uiGroup.insert_child_above(group, global.window_group); + + switchData.monitors.push(group); + } + + Meta.disable_unredirect_for_display(global.display); + } + + _finishWorkspaceSwitch(switchData) { + Meta.enable_unredirect_for_display(global.display); + + this._switchData = null; + + switchData.monitors.forEach(m => m.destroy()); + + this.movingWindow = null; + } + + animateSwitch(from, to, direction, onComplete) { + this._swipeTracker.enabled = false; + + let workspaceIndices = []; + + switch (direction) { + case Meta.MotionDirection.UP: + case Meta.MotionDirection.LEFT: + case Meta.MotionDirection.UP_LEFT: + case Meta.MotionDirection.UP_RIGHT: + workspaceIndices = [to, from]; + break; + + case Meta.MotionDirection.DOWN: + case Meta.MotionDirection.RIGHT: + case Meta.MotionDirection.DOWN_LEFT: + case Meta.MotionDirection.DOWN_RIGHT: + workspaceIndices = [from, to]; + break; + } + + if (Clutter.get_default_text_direction() === Clutter.TextDirection.RTL && + direction !== Meta.MotionDirection.UP && + direction !== Meta.MotionDirection.DOWN) + workspaceIndices.reverse(); + + this._prepareWorkspaceSwitch(workspaceIndices); + this._switchData.inProgress = true; + + const fromWs = global.workspace_manager.get_workspace_by_index(from); + const toWs = global.workspace_manager.get_workspace_by_index(to); + + for (const monitorGroup of this._switchData.monitors) { + monitorGroup.progress = monitorGroup.getWorkspaceProgress(fromWs); + const progress = monitorGroup.getWorkspaceProgress(toWs); + + const params = { + duration: WINDOW_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + }; + + if (monitorGroup.index === Main.layoutManager.primaryIndex) { + params.onComplete = () => { + this._finishWorkspaceSwitch(this._switchData); + onComplete(); + this._swipeTracker.enabled = true; + }; + } + + monitorGroup.ease_property('progress', progress, params); + } + } + + canHandleScrollEvent(event) { + return this._swipeTracker.canHandleScrollEvent(event); + } + + _findMonitorGroup(monitorIndex) { + return this._switchData.monitors.find(m => m.index === monitorIndex); + } + + _switchWorkspaceBegin(tracker, monitor) { + if (Meta.prefs_get_workspaces_only_on_primary() && + monitor !== Main.layoutManager.primaryIndex) + return; + + const workspaceManager = global.workspace_manager; + const horiz = workspaceManager.layout_rows !== -1; + tracker.orientation = horiz + ? Clutter.Orientation.HORIZONTAL + : Clutter.Orientation.VERTICAL; + + if (this._switchData && this._switchData.gestureActivated) { + for (const group of this._switchData.monitors) + group.remove_all_transitions(); + } else { + this._prepareWorkspaceSwitch(); + } + + const monitorGroup = this._findMonitorGroup(monitor); + const baseDistance = monitorGroup.baseDistance; + const progress = monitorGroup.progress; + + const closestWs = monitorGroup.findClosestWorkspace(progress); + const cancelProgress = monitorGroup.getWorkspaceProgress(closestWs); + const points = monitorGroup.getSnapPoints(); + + this._switchData.baseMonitorGroup = monitorGroup; + + tracker.confirmSwipe(baseDistance, points, progress, cancelProgress); + } + + _switchWorkspaceUpdate(tracker, progress) { + if (!this._switchData) + return; + + for (const monitorGroup of this._switchData.monitors) + monitorGroup.updateSwipeForMonitor(progress, this._switchData.baseMonitorGroup); + } + + _switchWorkspaceEnd(tracker, duration, endProgress) { + if (!this._switchData) + return; + + const switchData = this._switchData; + switchData.gestureActivated = true; + + const newWs = switchData.baseMonitorGroup.findClosestWorkspace(endProgress); + const endTime = Clutter.get_current_event_time(); + + for (const monitorGroup of this._switchData.monitors) { + const progress = monitorGroup.getWorkspaceProgress(newWs); + + const params = { + duration, + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + }; + + if (monitorGroup.index === Main.layoutManager.primaryIndex) { + params.onComplete = () => { + if (!newWs.active) + newWs.activate(endTime); + this._finishWorkspaceSwitch(switchData); + }; + } + + monitorGroup.ease_property('progress', progress, params); + } + } + + get gestureActive() { + return this._switchData !== null && this._switchData.gestureActivated; + } + + cancelSwitchAnimation() { + if (!this._switchData) + return; + + if (this._switchData.gestureActivated) + return; + + this._finishWorkspaceSwitch(this._switchData); + } + + set movingWindow(movingWindow) { + this._movingWindow = movingWindow; + } + + get movingWindow() { + return this._movingWindow; + } +}; diff --git a/js/ui/workspaceSwitcherPopup.js b/js/ui/workspaceSwitcherPopup.js new file mode 100644 index 0000000..8744529 --- /dev/null +++ b/js/ui/workspaceSwitcherPopup.js @@ -0,0 +1,101 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported WorkspaceSwitcherPopup */ + +const { Clutter, GLib, GObject, St } = imports.gi; + +const Layout = imports.ui.layout; +const Main = imports.ui.main; + +var ANIMATION_TIME = 100; +var DISPLAY_TIMEOUT = 600; + + +var WorkspaceSwitcherPopup = GObject.registerClass( +class WorkspaceSwitcherPopup extends Clutter.Actor { + _init() { + super._init({ + offscreen_redirect: Clutter.OffscreenRedirect.ALWAYS, + x_expand: true, + y_expand: true, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.END, + }); + + const constraint = new Layout.MonitorConstraint({ primary: true }); + this.add_constraint(constraint); + + Main.uiGroup.add_actor(this); + + this._timeoutId = 0; + + this._list = new St.BoxLayout({ + style_class: 'workspace-switcher', + }); + this.add_child(this._list); + + this._redisplay(); + + this.hide(); + + let workspaceManager = global.workspace_manager; + workspaceManager.connectObject( + 'workspace-added', this._redisplay.bind(this), + 'workspace-removed', this._redisplay.bind(this), this); + + this.connect('destroy', this._onDestroy.bind(this)); + } + + _redisplay() { + let workspaceManager = global.workspace_manager; + + this._list.destroy_all_children(); + + for (let i = 0; i < workspaceManager.n_workspaces; i++) { + const indicator = new St.Bin({ + style_class: 'ws-switcher-indicator', + }); + + if (i === this._activeWorkspaceIndex) + indicator.add_style_pseudo_class('active'); + + this._list.add_actor(indicator); + } + } + + display(activeWorkspaceIndex) { + this._activeWorkspaceIndex = activeWorkspaceIndex; + + this._redisplay(); + if (this._timeoutId != 0) + GLib.source_remove(this._timeoutId); + this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, DISPLAY_TIMEOUT, this._onTimeout.bind(this)); + GLib.Source.set_name_by_id(this._timeoutId, '[gnome-shell] this._onTimeout'); + + const duration = this.visible ? 0 : ANIMATION_TIME; + this.show(); + this.opacity = 0; + this.ease({ + opacity: 255, + duration, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + _onTimeout() { + GLib.source_remove(this._timeoutId); + this._timeoutId = 0; + this.ease({ + opacity: 0.0, + duration: ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this.destroy(), + }); + return GLib.SOURCE_REMOVE; + } + + _onDestroy() { + if (this._timeoutId) + GLib.source_remove(this._timeoutId); + this._timeoutId = 0; + } +}); diff --git a/js/ui/workspaceThumbnail.js b/js/ui/workspaceThumbnail.js new file mode 100644 index 0000000..45b938f --- /dev/null +++ b/js/ui/workspaceThumbnail.js @@ -0,0 +1,1436 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported WorkspaceThumbnail, ThumbnailsBox */ + +const { Clutter, Gio, GLib, GObject, Graphene, Meta, Shell, St } = imports.gi; + +const DND = imports.ui.dnd; +const Main = imports.ui.main; +const { TransientSignalHolder } = imports.misc.signalTracker; +const Util = imports.misc.util; +const Workspace = imports.ui.workspace; + +const NUM_WORKSPACES_THRESHOLD = 2; + +// The maximum size of a thumbnail is 5% the width and height of the screen +var MAX_THUMBNAIL_SCALE = 0.05; + +var RESCALE_ANIMATION_TIME = 200; +var SLIDE_ANIMATION_TIME = 200; + +// When we create workspaces by dragging, we add a "cut" into the top and +// bottom of each workspace so that the user doesn't have to hit the +// placeholder exactly. +var WORKSPACE_CUT_SIZE = 10; + +var WORKSPACE_KEEP_ALIVE_TIME = 100; + +var MUTTER_SCHEMA = 'org.gnome.mutter'; + +/* A layout manager that requests size only for primary_actor, but then allocates + all using a fixed layout */ +var PrimaryActorLayout = GObject.registerClass( +class PrimaryActorLayout extends Clutter.FixedLayout { + _init(primaryActor) { + super._init(); + + this.primaryActor = primaryActor; + } + + vfunc_get_preferred_width(container, forHeight) { + return this.primaryActor.get_preferred_width(forHeight); + } + + vfunc_get_preferred_height(container, forWidth) { + return this.primaryActor.get_preferred_height(forWidth); + } +}); + +var WindowClone = GObject.registerClass({ + Signals: { + 'drag-begin': {}, + 'drag-cancelled': {}, + 'drag-end': {}, + 'selected': { param_types: [GObject.TYPE_UINT] }, + }, +}, class WindowClone extends Clutter.Actor { + _init(realWindow) { + let clone = new Clutter.Clone({ source: realWindow }); + super._init({ + layout_manager: new PrimaryActorLayout(clone), + reactive: true, + }); + this._delegate = this; + + this.add_child(clone); + this.realWindow = realWindow; + this.metaWindow = realWindow.meta_window; + + this.realWindow.connectObject( + 'notify::position', this._onPositionChanged.bind(this), + 'destroy', () => { + // First destroy the clone and then destroy everything + // This will ensure that we never see it in the _disconnectSignals loop + clone.destroy(); + this.destroy(); + }, this); + this._onPositionChanged(); + + this.connect('destroy', this._onDestroy.bind(this)); + + this._draggable = DND.makeDraggable(this, { + restoreOnSuccess: true, + dragActorMaxSize: Workspace.WINDOW_DND_SIZE, + dragActorOpacity: Workspace.DRAGGING_WINDOW_OPACITY, + }); + this._draggable.connect('drag-begin', this._onDragBegin.bind(this)); + this._draggable.connect('drag-cancelled', this._onDragCancelled.bind(this)); + this._draggable.connect('drag-end', this._onDragEnd.bind(this)); + this.inDrag = false; + + let iter = win => { + let actor = win.get_compositor_private(); + + if (!actor) + return false; + if (!win.is_attached_dialog()) + return false; + + this._doAddAttachedDialog(win, actor); + win.foreach_transient(iter); + + return true; + }; + this.metaWindow.foreach_transient(iter); + } + + // Find the actor just below us, respecting reparenting done + // by DND code + getActualStackAbove() { + if (this._stackAbove == null) + return null; + + if (this.inDrag) { + if (this._stackAbove._delegate) + return this._stackAbove._delegate.getActualStackAbove(); + else + return null; + } else { + return this._stackAbove; + } + } + + setStackAbove(actor) { + this._stackAbove = actor; + + // Don't apply the new stacking now, it will be applied + // when dragging ends and window are stacked again + if (actor.inDrag) + return; + + let parent = this.get_parent(); + let actualAbove = this.getActualStackAbove(); + if (actualAbove == null) + parent.set_child_below_sibling(this, null); + else + parent.set_child_above_sibling(this, actualAbove); + } + + addAttachedDialog(win) { + this._doAddAttachedDialog(win, win.get_compositor_private()); + } + + _doAddAttachedDialog(metaDialog, realDialog) { + let clone = new Clutter.Clone({ source: realDialog }); + this._updateDialogPosition(realDialog, clone); + + realDialog.connectObject( + 'notify::position', dialog => this._updateDialogPosition(dialog, clone), + 'destroy', () => clone.destroy(), this); + this.add_child(clone); + } + + _updateDialogPosition(realDialog, cloneDialog) { + let metaDialog = realDialog.meta_window; + let dialogRect = metaDialog.get_frame_rect(); + let rect = this.metaWindow.get_frame_rect(); + + cloneDialog.set_position(dialogRect.x - rect.x, dialogRect.y - rect.y); + } + + _onPositionChanged() { + this.set_position(this.realWindow.x, this.realWindow.y); + } + + _onDestroy() { + this._delegate = null; + + if (this.inDrag) { + this.emit('drag-end'); + this.inDrag = false; + } + } + + vfunc_button_press_event() { + return Clutter.EVENT_STOP; + } + + vfunc_button_release_event(buttonEvent) { + this.emit('selected', buttonEvent.time); + + return Clutter.EVENT_STOP; + } + + vfunc_touch_event(touchEvent) { + if (touchEvent.type != Clutter.EventType.TOUCH_END || + !global.display.is_pointer_emulating_sequence(touchEvent.sequence)) + return Clutter.EVENT_PROPAGATE; + + this.emit('selected', touchEvent.time); + return Clutter.EVENT_STOP; + } + + _onDragBegin(_draggable, _time) { + this.inDrag = true; + this.emit('drag-begin'); + } + + _onDragCancelled(_draggable, _time) { + this.emit('drag-cancelled'); + } + + _onDragEnd(_draggable, _time, _snapback) { + this.inDrag = false; + + // We may not have a parent if DnD completed successfully, in + // which case our clone will shortly be destroyed and replaced + // with a new one on the target workspace. + let parent = this.get_parent(); + if (parent !== null) { + if (this._stackAbove == null) + parent.set_child_below_sibling(this, null); + else + parent.set_child_above_sibling(this, this._stackAbove); + } + + + this.emit('drag-end'); + } +}); + + +var ThumbnailState = { + NEW: 0, + EXPANDING: 1, + EXPANDED: 2, + ANIMATING_IN: 3, + NORMAL: 4, + REMOVING: 5, + ANIMATING_OUT: 6, + ANIMATED_OUT: 7, + COLLAPSING: 8, + DESTROYED: 9, +}; + +/** + * @metaWorkspace: a #Meta.Workspace + */ +var WorkspaceThumbnail = GObject.registerClass({ + Properties: { + 'collapse-fraction': GObject.ParamSpec.double( + 'collapse-fraction', 'collapse-fraction', 'collapse-fraction', + GObject.ParamFlags.READWRITE, + 0, 1, 0), + 'slide-position': GObject.ParamSpec.double( + 'slide-position', 'slide-position', 'slide-position', + GObject.ParamFlags.READWRITE, + 0, 1, 0), + }, +}, class WorkspaceThumbnail extends St.Widget { + _init(metaWorkspace, monitorIndex) { + super._init({ + clip_to_allocation: true, + style_class: 'workspace-thumbnail', + pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }), + }); + this._delegate = this; + + this.metaWorkspace = metaWorkspace; + this.monitorIndex = monitorIndex; + + this._removed = false; + + this._viewport = new Clutter.Actor(); + this.add_child(this._viewport); + + this._contents = new Clutter.Actor(); + this._viewport.add_child(this._contents); + + this.connect('destroy', this._onDestroy.bind(this)); + + let workArea = Main.layoutManager.getWorkAreaForMonitor(this.monitorIndex); + this.setPorthole(workArea.x, workArea.y, workArea.width, workArea.height); + + let windows = global.get_window_actors().filter(actor => { + let win = actor.meta_window; + return win.located_on_workspace(metaWorkspace); + }); + + // Create clones for windows that should be visible in the Overview + this._windows = []; + this._allWindows = []; + for (let i = 0; i < windows.length; i++) { + windows[i].meta_window.connectObject('notify::minimized', + this._updateMinimized.bind(this), this); + this._allWindows.push(windows[i].meta_window); + + if (this._isMyWindow(windows[i]) && this._isOverviewWindow(windows[i])) + this._addWindowClone(windows[i]); + } + + // Track window changes + this.metaWorkspace.connectObject( + 'window-added', this._windowAdded.bind(this), + 'window-removed', this._windowRemoved.bind(this), this); + global.display.connectObject( + 'window-entered-monitor', this._windowEnteredMonitor.bind(this), + 'window-left-monitor', this._windowLeftMonitor.bind(this), this); + + this.state = ThumbnailState.NORMAL; + this._slidePosition = 0; // Fully slid in + this._collapseFraction = 0; // Not collapsed + } + + setPorthole(x, y, width, height) { + this._viewport.set_size(width, height); + this._contents.set_position(-x, -y); + } + + _lookupIndex(metaWindow) { + return this._windows.findIndex(w => w.metaWindow == metaWindow); + } + + syncStacking(stackIndices) { + this._windows.sort((a, b) => { + let indexA = stackIndices[a.metaWindow.get_stable_sequence()]; + let indexB = stackIndices[b.metaWindow.get_stable_sequence()]; + return indexA - indexB; + }); + + for (let i = 1; i < this._windows.length; i++) { + let clone = this._windows[i]; + const previousClone = this._windows[i - 1]; + clone.setStackAbove(previousClone); + } + } + + set slidePosition(slidePosition) { + if (this._slidePosition == slidePosition) + return; + + const scale = Util.lerp(1, 0.75, slidePosition); + this.set_scale(scale, scale); + this.opacity = Util.lerp(255, 0, slidePosition); + + this._slidePosition = slidePosition; + this.notify('slide-position'); + this.queue_relayout(); + } + + get slidePosition() { + return this._slidePosition; + } + + set collapseFraction(collapseFraction) { + if (this._collapseFraction == collapseFraction) + return; + this._collapseFraction = collapseFraction; + this.notify('collapse-fraction'); + this.queue_relayout(); + } + + get collapseFraction() { + return this._collapseFraction; + } + + _doRemoveWindow(metaWin) { + let clone = this._removeWindowClone(metaWin); + if (clone) + clone.destroy(); + } + + _doAddWindow(metaWin) { + if (this._removed) + return; + + let win = metaWin.get_compositor_private(); + + if (!win) { + // Newly-created windows are added to a workspace before + // the compositor finds out about them... + let id = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + if (!this._removed && + metaWin.get_compositor_private() && + metaWin.get_workspace() == this.metaWorkspace) + this._doAddWindow(metaWin); + return GLib.SOURCE_REMOVE; + }); + GLib.Source.set_name_by_id(id, '[gnome-shell] this._doAddWindow'); + return; + } + + if (!this._allWindows.includes(metaWin)) { + metaWin.connectObject('notify::minimized', + this._updateMinimized.bind(this), this); + this._allWindows.push(metaWin); + } + + // We might have the window in our list already if it was on all workspaces and + // now was moved to this workspace + if (this._lookupIndex(metaWin) != -1) + return; + + if (!this._isMyWindow(win)) + return; + + if (this._isOverviewWindow(win)) { + this._addWindowClone(win); + } else if (metaWin.is_attached_dialog()) { + let parent = metaWin.get_transient_for(); + while (parent.is_attached_dialog()) + parent = parent.get_transient_for(); + + let idx = this._lookupIndex(parent); + if (idx < 0) { + // parent was not created yet, it will take care + // of the dialog when created + return; + } + + let clone = this._windows[idx]; + clone.addAttachedDialog(metaWin); + } + } + + _windowAdded(metaWorkspace, metaWin) { + this._doAddWindow(metaWin); + } + + _windowRemoved(metaWorkspace, metaWin) { + let index = this._allWindows.indexOf(metaWin); + if (index != -1) { + metaWin.disconnectObject(this); + this._allWindows.splice(index, 1); + } + + this._doRemoveWindow(metaWin); + } + + _windowEnteredMonitor(metaDisplay, monitorIndex, metaWin) { + if (monitorIndex == this.monitorIndex) + this._doAddWindow(metaWin); + } + + _windowLeftMonitor(metaDisplay, monitorIndex, metaWin) { + if (monitorIndex == this.monitorIndex) + this._doRemoveWindow(metaWin); + } + + _updateMinimized(metaWin) { + if (metaWin.minimized) + this._doRemoveWindow(metaWin); + else + this._doAddWindow(metaWin); + } + + workspaceRemoved() { + if (this._removed) + return; + + this._removed = true; + + this.metaWorkspace.disconnectObject(this); + global.display.disconnectObject(this); + this._allWindows.forEach(w => w.disconnectObject(this)); + } + + _onDestroy() { + this.workspaceRemoved(); + this._windows = []; + } + + // Tests if @actor belongs to this workspace and monitor + _isMyWindow(actor) { + let win = actor.meta_window; + return win.located_on_workspace(this.metaWorkspace) && + (win.get_monitor() == this.monitorIndex); + } + + // Tests if @win should be shown in the Overview + _isOverviewWindow(win) { + return !win.get_meta_window().skip_taskbar && + win.get_meta_window().showing_on_its_workspace(); + } + + // Create a clone of a (non-desktop) window and add it to the window list + _addWindowClone(win) { + let clone = new WindowClone(win); + + clone.connect('selected', (o, time) => { + this.activate(time); + }); + clone.connect('drag-begin', () => { + Main.overview.beginWindowDrag(clone.metaWindow); + }); + clone.connect('drag-cancelled', () => { + Main.overview.cancelledWindowDrag(clone.metaWindow); + }); + clone.connect('drag-end', () => { + Main.overview.endWindowDrag(clone.metaWindow); + }); + clone.connect('destroy', () => { + this._removeWindowClone(clone.metaWindow); + }); + this._contents.add_actor(clone); + + if (this._windows.length > 0) + clone.setStackAbove(this._windows[this._windows.length - 1]); + + this._windows.push(clone); + + return clone; + } + + _removeWindowClone(metaWin) { + // find the position of the window in our list + let index = this._lookupIndex(metaWin); + + if (index == -1) + return null; + + return this._windows.splice(index, 1).pop(); + } + + activate(time) { + if (this.state > ThumbnailState.NORMAL) + return; + + // a click on the already current workspace should go back to the main view + if (this.metaWorkspace.active) + Main.overview.hide(); + else + this.metaWorkspace.activate(time); + } + + // Draggable target interface used only by ThumbnailsBox + handleDragOverInternal(source, actor, time) { + if (source == Main.xdndHandler) { + this.metaWorkspace.activate(time); + return DND.DragMotionResult.CONTINUE; + } + + if (this.state > ThumbnailState.NORMAL) + return DND.DragMotionResult.CONTINUE; + + if (source.metaWindow && + !this._isMyWindow(source.metaWindow.get_compositor_private())) + return DND.DragMotionResult.MOVE_DROP; + if (source.app && source.app.can_open_new_window()) + return DND.DragMotionResult.COPY_DROP; + if (!source.app && source.shellWorkspaceLaunch) + return DND.DragMotionResult.COPY_DROP; + + return DND.DragMotionResult.CONTINUE; + } + + acceptDropInternal(source, actor, time) { + if (this.state > ThumbnailState.NORMAL) + return false; + + if (source.metaWindow) { + let win = source.metaWindow.get_compositor_private(); + if (this._isMyWindow(win)) + return false; + + let metaWindow = win.get_meta_window(); + Main.moveWindowToMonitorAndWorkspace(metaWindow, + this.monitorIndex, this.metaWorkspace.index()); + return true; + } else if (source.app && source.app.can_open_new_window()) { + if (source.animateLaunchAtPos) + source.animateLaunchAtPos(actor.x, actor.y); + + source.app.open_new_window(this.metaWorkspace.index()); + return true; + } else if (!source.app && source.shellWorkspaceLaunch) { + // While unused in our own drag sources, shellWorkspaceLaunch allows + // extensions to define custom actions for their drag sources. + source.shellWorkspaceLaunch({ + workspace: this.metaWorkspace.index(), + timestamp: time, + }); + return true; + } + + return false; + } + + setScale(scaleX, scaleY) { + this._viewport.set_scale(scaleX, scaleY); + } +}); + + +var ThumbnailsBox = GObject.registerClass({ + Properties: { + 'expand-fraction': GObject.ParamSpec.double( + 'expand-fraction', 'expand-fraction', 'expand-fraction', + GObject.ParamFlags.READWRITE, + 0, 1, 1), + 'scale': GObject.ParamSpec.double( + 'scale', 'scale', 'scale', + GObject.ParamFlags.READWRITE, + 0, Infinity, 0), + 'should-show': GObject.ParamSpec.boolean( + 'should-show', 'should-show', 'should-show', + GObject.ParamFlags.READABLE, + true), + }, +}, class ThumbnailsBox extends St.Widget { + _init(scrollAdjustment, monitorIndex) { + super._init({ + style_class: 'workspace-thumbnails', + reactive: true, + x_align: Clutter.ActorAlign.CENTER, + pivot_point: new Graphene.Point({ x: 0.5, y: 0.5 }), + }); + + this._delegate = this; + + let indicator = new St.Bin({ style_class: 'workspace-thumbnail-indicator' }); + + // We don't want the indicator to affect drag-and-drop + Shell.util_set_hidden_from_pick(indicator, true); + + this._indicator = indicator; + this.add_actor(indicator); + + this._monitorIndex = monitorIndex; + + this._dropWorkspace = -1; + this._dropPlaceholderPos = -1; + this._dropPlaceholder = new St.Bin({ style_class: 'placeholder' }); + this.add_actor(this._dropPlaceholder); + this._spliceIndex = -1; + + this._targetScale = 0; + this._scale = 0; + this._expandFraction = 1; + this._updateStateId = 0; + this._pendingScaleUpdate = false; + this._animatingIndicator = false; + + this._shouldShow = true; + + this._stateCounts = {}; + for (let key in ThumbnailState) + this._stateCounts[ThumbnailState[key]] = 0; + + this._thumbnails = []; + + Main.overview.connectObject( + 'showing', () => this._createThumbnails(), + 'hidden', () => this._destroyThumbnails(), + 'item-drag-begin', () => this._onDragBegin(), + 'item-drag-end', () => this._onDragEnd(), + 'item-drag-cancelled', () => this._onDragCancelled(), + 'window-drag-begin', () => this._onDragBegin(), + 'window-drag-end', () => this._onDragEnd(), + 'window-drag-cancelled', () => this._onDragCancelled(), this); + + this._settings = new Gio.Settings({ schema_id: MUTTER_SCHEMA }); + this._settings.connect('changed::dynamic-workspaces', + () => this._updateShouldShow()); + this._updateShouldShow(); + + Main.layoutManager.connectObject('monitors-changed', () => { + this._destroyThumbnails(); + if (Main.overview.visible) + this._createThumbnails(); + }, this); + + // The porthole is the part of the screen we're showing in the thumbnails + global.display.connectObject('workareas-changed', + () => this._updatePorthole(), this); + this._updatePorthole(); + + this.connect('notify::visible', () => { + if (!this.visible) + this._queueUpdateStates(); + }); + this.connect('destroy', () => this._onDestroy()); + + this._scrollAdjustment = scrollAdjustment; + this._scrollAdjustment.connectObject('notify::value', + () => this._updateIndicator(), this); + } + + setMonitorIndex(monitorIndex) { + this._monitorIndex = monitorIndex; + } + + _onDestroy() { + this._destroyThumbnails(); + this._unqueueUpdateStates(); + + if (this._settings) + this._settings.run_dispose(); + this._settings = null; + } + + _updateShouldShow() { + const { nWorkspaces } = global.workspace_manager; + const shouldShow = this._settings.get_boolean('dynamic-workspaces') + ? nWorkspaces > NUM_WORKSPACES_THRESHOLD + : nWorkspaces > 1; + + if (this._shouldShow === shouldShow) + return; + + this._shouldShow = shouldShow; + this.notify('should-show'); + } + + _updateIndicator() { + const { value } = this._scrollAdjustment; + const { workspaceManager } = global; + const activeIndex = workspaceManager.get_active_workspace_index(); + + this._animatingIndicator = value !== activeIndex; + + if (!this._animatingIndicator) + this._queueUpdateStates(); + + this.queue_relayout(); + } + + _activateThumbnailAtPoint(stageX, stageY, time) { + const [r_, x] = this.transform_stage_point(stageX, stageY); + + const thumbnail = this._thumbnails.find(t => x >= t.x && x <= t.x + t.width); + if (thumbnail) + thumbnail.activate(time); + } + + vfunc_button_release_event(buttonEvent) { + let { x, y } = buttonEvent; + this._activateThumbnailAtPoint(x, y, buttonEvent.time); + return Clutter.EVENT_STOP; + } + + vfunc_touch_event(touchEvent) { + if (touchEvent.type == Clutter.EventType.TOUCH_END && + global.display.is_pointer_emulating_sequence(touchEvent.sequence)) { + let { x, y } = touchEvent; + this._activateThumbnailAtPoint(x, y, touchEvent.time); + } + + return Clutter.EVENT_STOP; + } + + _onDragBegin() { + this._dragCancelled = false; + this._dragMonitor = { + dragMotion: this._onDragMotion.bind(this), + }; + DND.addDragMonitor(this._dragMonitor); + } + + _onDragEnd() { + if (this._dragCancelled) + return; + + this._endDrag(); + } + + _onDragCancelled() { + this._dragCancelled = true; + this._endDrag(); + } + + _endDrag() { + this._clearDragPlaceholder(); + DND.removeDragMonitor(this._dragMonitor); + } + + _onDragMotion(dragEvent) { + if (!this.contains(dragEvent.targetActor)) + this._onLeave(); + return DND.DragMotionResult.CONTINUE; + } + + _onLeave() { + this._clearDragPlaceholder(); + } + + _clearDragPlaceholder() { + if (this._dropPlaceholderPos == -1) + return; + + this._dropPlaceholderPos = -1; + this.queue_relayout(); + } + + _getPlaceholderTarget(index, spacing, rtl) { + const workspace = this._thumbnails[index]; + + let targetX1; + let targetX2; + + if (rtl) { + const baseX = workspace.x + workspace.width; + targetX1 = baseX - WORKSPACE_CUT_SIZE; + targetX2 = baseX + spacing + WORKSPACE_CUT_SIZE; + } else { + targetX1 = workspace.x - spacing - WORKSPACE_CUT_SIZE; + targetX2 = workspace.x + WORKSPACE_CUT_SIZE; + } + + if (index === 0) { + if (rtl) + targetX2 -= spacing + WORKSPACE_CUT_SIZE; + else + targetX1 += spacing + WORKSPACE_CUT_SIZE; + } + + if (index === this._dropPlaceholderPos) { + const placeholderWidth = this._dropPlaceholder.get_width() + spacing; + if (rtl) + targetX2 += placeholderWidth; + else + targetX1 -= placeholderWidth; + } + + return [targetX1, targetX2]; + } + + _withinWorkspace(x, index, rtl) { + const length = this._thumbnails.length; + const workspace = this._thumbnails[index]; + + let workspaceX1 = workspace.x + WORKSPACE_CUT_SIZE; + let workspaceX2 = workspace.x + workspace.width - WORKSPACE_CUT_SIZE; + + if (index === length - 1) { + if (rtl) + workspaceX1 -= WORKSPACE_CUT_SIZE; + else + workspaceX2 += WORKSPACE_CUT_SIZE; + } + + return x > workspaceX1 && x <= workspaceX2; + } + + // Draggable target interface + handleDragOver(source, actor, x, y, time) { + if (!source.metaWindow && + (!source.app || !source.app.can_open_new_window()) && + (source.app || !source.shellWorkspaceLaunch) && + source != Main.xdndHandler) + return DND.DragMotionResult.CONTINUE; + + const rtl = Clutter.get_default_text_direction() === Clutter.TextDirection.RTL; + let canCreateWorkspaces = Meta.prefs_get_dynamic_workspaces(); + let spacing = this.get_theme_node().get_length('spacing'); + + this._dropWorkspace = -1; + let placeholderPos = -1; + let length = this._thumbnails.length; + for (let i = 0; i < length; i++) { + const index = rtl ? length - i - 1 : i; + + if (canCreateWorkspaces && source !== Main.xdndHandler) { + const [targetStart, targetEnd] = + this._getPlaceholderTarget(index, spacing, rtl); + + if (x > targetStart && x <= targetEnd) { + placeholderPos = index; + break; + } + } + + if (this._withinWorkspace(x, index, rtl)) { + this._dropWorkspace = index; + break; + } + } + + if (this._dropPlaceholderPos != placeholderPos) { + this._dropPlaceholderPos = placeholderPos; + this.queue_relayout(); + } + + if (this._dropWorkspace != -1) + return this._thumbnails[this._dropWorkspace].handleDragOverInternal(source, actor, time); + else if (this._dropPlaceholderPos != -1) + return source.metaWindow ? DND.DragMotionResult.MOVE_DROP : DND.DragMotionResult.COPY_DROP; + else + return DND.DragMotionResult.CONTINUE; + } + + acceptDrop(source, actor, x, y, time) { + if (this._dropWorkspace != -1) { + return this._thumbnails[this._dropWorkspace].acceptDropInternal(source, actor, time); + } else if (this._dropPlaceholderPos != -1) { + if (!source.metaWindow && + (!source.app || !source.app.can_open_new_window()) && + (source.app || !source.shellWorkspaceLaunch)) + return false; + + let isWindow = !!source.metaWindow; + + let newWorkspaceIndex; + [newWorkspaceIndex, this._dropPlaceholderPos] = [this._dropPlaceholderPos, -1]; + this._spliceIndex = newWorkspaceIndex; + + Main.wm.insertWorkspace(newWorkspaceIndex); + + if (isWindow) { + // Move the window to our monitor first if necessary. + let thumbMonitor = this._thumbnails[newWorkspaceIndex].monitorIndex; + Main.moveWindowToMonitorAndWorkspace(source.metaWindow, + thumbMonitor, newWorkspaceIndex, true); + } else if (source.app && source.app.can_open_new_window()) { + if (source.animateLaunchAtPos) + source.animateLaunchAtPos(actor.x, actor.y); + + source.app.open_new_window(newWorkspaceIndex); + } else if (!source.app && source.shellWorkspaceLaunch) { + // While unused in our own drag sources, shellWorkspaceLaunch allows + // extensions to define custom actions for their drag sources. + source.shellWorkspaceLaunch({ + workspace: newWorkspaceIndex, + timestamp: time, + }); + } + + if (source.app || (!source.app && source.shellWorkspaceLaunch)) { + // This new workspace will be automatically removed if the application fails + // to open its first window within some time, as tracked by Shell.WindowTracker. + // Here, we only add a very brief timeout to avoid the _immediate_ removal of the + // workspace while we wait for the startup sequence to load. + let workspaceManager = global.workspace_manager; + Main.wm.keepWorkspaceAlive(workspaceManager.get_workspace_by_index(newWorkspaceIndex), + WORKSPACE_KEEP_ALIVE_TIME); + } + + // Start the animation on the workspace (which is actually + // an old one which just became empty) + let thumbnail = this._thumbnails[newWorkspaceIndex]; + this._setThumbnailState(thumbnail, ThumbnailState.NEW); + thumbnail.slide_position = 1; + thumbnail.collapse_fraction = 1; + + this._queueUpdateStates(); + + return true; + } else { + return false; + } + } + + _createThumbnails() { + if (this._thumbnails.length > 0) + return; + + const { workspaceManager } = global; + this._transientSignalHolder = new TransientSignalHolder(this); + workspaceManager.connectObject( + 'notify::n-workspaces', this._workspacesChanged.bind(this), + 'active-workspace-changed', () => this._updateIndicator(), + 'workspaces-reordered', () => { + this._thumbnails.sort((a, b) => { + return a.metaWorkspace.index() - b.metaWorkspace.index(); + }); + this.queue_relayout(); + }, this._transientSignalHolder); + Main.overview.connectObject('windows-restacked', + this._syncStacking.bind(this), this._transientSignalHolder); + + this._targetScale = 0; + this._scale = 0; + this._pendingScaleUpdate = false; + this._unqueueUpdateStates(); + + this._stateCounts = {}; + for (let key in ThumbnailState) + this._stateCounts[ThumbnailState[key]] = 0; + + this.addThumbnails(0, workspaceManager.n_workspaces); + + this._updateShouldShow(); + } + + _destroyThumbnails() { + if (this._thumbnails.length == 0) + return; + + this._transientSignalHolder.destroy(); + delete this._transientSignalHolder; + + for (let w = 0; w < this._thumbnails.length; w++) + this._thumbnails[w].destroy(); + this._thumbnails = []; + } + + _workspacesChanged() { + let validThumbnails = + this._thumbnails.filter(t => t.state <= ThumbnailState.NORMAL); + let workspaceManager = global.workspace_manager; + let oldNumWorkspaces = validThumbnails.length; + let newNumWorkspaces = workspaceManager.n_workspaces; + + if (newNumWorkspaces > oldNumWorkspaces) { + this.addThumbnails(oldNumWorkspaces, newNumWorkspaces - oldNumWorkspaces); + } else { + let removedIndex; + let removedNum = oldNumWorkspaces - newNumWorkspaces; + for (let w = 0; w < oldNumWorkspaces; w++) { + let metaWorkspace = workspaceManager.get_workspace_by_index(w); + if (this._thumbnails[w].metaWorkspace != metaWorkspace) { + removedIndex = w; + break; + } + } + + this.removeThumbnails(removedIndex, removedNum); + } + + this._updateShouldShow(); + } + + addThumbnails(start, count) { + let workspaceManager = global.workspace_manager; + + for (let k = start; k < start + count; k++) { + let metaWorkspace = workspaceManager.get_workspace_by_index(k); + let thumbnail = new WorkspaceThumbnail(metaWorkspace, this._monitorIndex); + thumbnail.setPorthole(this._porthole.x, this._porthole.y, + this._porthole.width, this._porthole.height); + this._thumbnails.push(thumbnail); + this.add_actor(thumbnail); + + if (this._shouldShow && start > 0 && this._spliceIndex === -1) { + // not the initial fill, and not splicing via DND + thumbnail.state = ThumbnailState.NEW; + thumbnail.slide_position = 1; // start slid out + thumbnail.collapse_fraction = 1; // start fully collapsed + this._haveNewThumbnails = true; + } else { + thumbnail.state = ThumbnailState.NORMAL; + } + + this._stateCounts[thumbnail.state]++; + } + + this._queueUpdateStates(); + + // The thumbnails indicator actually needs to be on top of the thumbnails + this.set_child_above_sibling(this._indicator, null); + + // Clear the splice index, we got the message + this._spliceIndex = -1; + } + + removeThumbnails(start, count) { + let currentPos = 0; + for (let k = 0; k < this._thumbnails.length; k++) { + let thumbnail = this._thumbnails[k]; + + if (thumbnail.state > ThumbnailState.NORMAL) + continue; + + if (currentPos >= start && currentPos < start + count) { + thumbnail.workspaceRemoved(); + this._setThumbnailState(thumbnail, ThumbnailState.REMOVING); + } + + currentPos++; + } + + this._queueUpdateStates(); + } + + _syncStacking(overview, stackIndices) { + for (let i = 0; i < this._thumbnails.length; i++) + this._thumbnails[i].syncStacking(stackIndices); + } + + set scale(scale) { + if (this._scale == scale) + return; + + this._scale = scale; + this.notify('scale'); + this.queue_relayout(); + } + + get scale() { + return this._scale; + } + + _setThumbnailState(thumbnail, state) { + this._stateCounts[thumbnail.state]--; + thumbnail.state = state; + this._stateCounts[thumbnail.state]++; + } + + _iterateStateThumbnails(state, callback) { + if (this._stateCounts[state] == 0) + return; + + for (let i = 0; i < this._thumbnails.length; i++) { + if (this._thumbnails[i].state == state) + callback.call(this, this._thumbnails[i]); + } + } + + _updateStates() { + this._updateStateId = 0; + + // If we are animating the indicator, wait + if (this._animatingIndicator) + return; + + // Likewise if we are in the process of hiding + if (!this._shouldShow && this.visible) + return; + + // Then slide out any thumbnails that have been destroyed + this._iterateStateThumbnails(ThumbnailState.REMOVING, thumbnail => { + this._setThumbnailState(thumbnail, ThumbnailState.ANIMATING_OUT); + + thumbnail.ease_property('slide-position', 1, { + duration: SLIDE_ANIMATION_TIME, + mode: Clutter.AnimationMode.LINEAR, + onComplete: () => { + this._setThumbnailState(thumbnail, ThumbnailState.ANIMATED_OUT); + this._queueUpdateStates(); + }, + }); + }); + + // As long as things are sliding out, don't proceed + if (this._stateCounts[ThumbnailState.ANIMATING_OUT] > 0) + return; + + // Once that's complete, we can start scaling to the new size, + // collapse any removed thumbnails and expand added ones + this._iterateStateThumbnails(ThumbnailState.ANIMATED_OUT, thumbnail => { + this._setThumbnailState(thumbnail, ThumbnailState.COLLAPSING); + thumbnail.ease_property('collapse-fraction', 1, { + duration: RESCALE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._stateCounts[thumbnail.state]--; + thumbnail.state = ThumbnailState.DESTROYED; + + let index = this._thumbnails.indexOf(thumbnail); + this._thumbnails.splice(index, 1); + thumbnail.destroy(); + + this._queueUpdateStates(); + }, + }); + }); + + this._iterateStateThumbnails(ThumbnailState.NEW, thumbnail => { + this._setThumbnailState(thumbnail, ThumbnailState.EXPANDING); + thumbnail.ease_property('collapse-fraction', 0, { + duration: SLIDE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._setThumbnailState(thumbnail, ThumbnailState.EXPANDED); + this._queueUpdateStates(); + }, + }); + }); + + if (this._pendingScaleUpdate) { + this.ease_property('scale', this._targetScale, { + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + duration: RESCALE_ANIMATION_TIME, + onComplete: () => this._queueUpdateStates(), + }); + this._pendingScaleUpdate = false; + } + + // Wait until that's done + if (this._scale !== this._targetScale || + this._stateCounts[ThumbnailState.COLLAPSING] > 0 || + this._stateCounts[ThumbnailState.EXPANDING] > 0) + return; + + // And then slide in any new thumbnails + this._iterateStateThumbnails(ThumbnailState.EXPANDED, thumbnail => { + this._setThumbnailState(thumbnail, ThumbnailState.ANIMATING_IN); + thumbnail.ease_property('slide-position', 0, { + duration: SLIDE_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._setThumbnailState(thumbnail, ThumbnailState.NORMAL); + }, + }); + }); + } + + _queueUpdateStates() { + if (this._updateStateId > 0) + return; + + this._updateStateId = Meta.later_add( + Meta.LaterType.BEFORE_REDRAW, () => this._updateStates()); + } + + _unqueueUpdateStates() { + if (this._updateStateId) + Meta.later_remove(this._updateStateId); + this._updateStateId = 0; + } + + vfunc_get_preferred_height(forWidth) { + let themeNode = this.get_theme_node(); + + forWidth = themeNode.adjust_for_width(forWidth); + + let spacing = themeNode.get_length('spacing'); + let nWorkspaces = this._thumbnails.length; + let totalSpacing = (nWorkspaces - 1) * spacing; + + const avail = forWidth - totalSpacing; + + let scale = (avail / nWorkspaces) / this._porthole.width; + scale = Math.min(scale, MAX_THUMBNAIL_SCALE); + + const height = Math.round(this._porthole.height * scale); + return themeNode.adjust_preferred_height(height, height); + } + + vfunc_get_preferred_width(_forHeight) { + // Note that for getPreferredHeight/Width we cheat a bit and skip propagating + // the size request to our children because we know how big they are and know + // that the actors aren't depending on the virtual functions being called. + let themeNode = this.get_theme_node(); + + let spacing = themeNode.get_length('spacing'); + let nWorkspaces = this._thumbnails.length; + let totalSpacing = (nWorkspaces - 1) * spacing; + + const naturalWidth = this._thumbnails.reduce((accumulator, thumbnail, index) => { + let workspaceSpacing = 0; + + if (index > 0) + workspaceSpacing += spacing / 2; + if (index < this._thumbnails.length - 1) + workspaceSpacing += spacing / 2; + + const progress = 1 - thumbnail.collapse_fraction; + const width = (this._porthole.width * MAX_THUMBNAIL_SCALE + workspaceSpacing) * progress; + return accumulator + width; + }, 0); + + return themeNode.adjust_preferred_width(totalSpacing, naturalWidth); + } + + _updatePorthole() { + if (!Main.layoutManager.monitors[this._monitorIndex]) { + const { x, y, width, height } = global.stage; + this._porthole = { x, y, width, height }; + } else { + this._porthole = + Main.layoutManager.getWorkAreaForMonitor(this._monitorIndex); + } + + this.queue_relayout(); + } + + vfunc_allocate(box) { + this.set_allocation(box); + + let rtl = Clutter.get_default_text_direction() == Clutter.TextDirection.RTL; + + if (this._thumbnails.length == 0) // not visible + return; + + let themeNode = this.get_theme_node(); + box = themeNode.get_content_box(box); + + const portholeWidth = this._porthole.width; + const portholeHeight = this._porthole.height; + const spacing = themeNode.get_length('spacing'); + + const nWorkspaces = this._thumbnails.length; + + // Compute the scale we'll need once everything is updated, + // unless we are currently transitioning + if (this._expandFraction === 1) { + const totalSpacing = (nWorkspaces - 1) * spacing; + const availableWidth = (box.get_width() - totalSpacing) / nWorkspaces; + + const hScale = availableWidth / portholeWidth; + const vScale = box.get_height() / portholeHeight; + const newScale = Math.min(hScale, vScale); + + if (newScale !== this._targetScale) { + if (this._targetScale > 0) { + // We don't ease immediately because we need to observe the + // ordering in queueUpdateStates - if workspaces have been + // removed we need to slide them out as the first thing. + this._targetScale = newScale; + this._pendingScaleUpdate = true; + } else { + this._targetScale = this._scale = newScale; + } + + this._queueUpdateStates(); + } + } + + const ratio = portholeWidth / portholeHeight; + const thumbnailFullHeight = Math.round(portholeHeight * this._scale); + const thumbnailWidth = Math.round(thumbnailFullHeight * ratio); + const thumbnailHeight = thumbnailFullHeight * this._expandFraction; + const roundedVScale = thumbnailHeight / portholeHeight; + + // We always request size for MAX_THUMBNAIL_SCALE, distribute + // space evently if we use smaller thumbnails + const extraWidth = + (MAX_THUMBNAIL_SCALE * portholeWidth - thumbnailWidth) * nWorkspaces; + box.x1 += Math.round(extraWidth / 2); + box.x2 -= Math.round(extraWidth / 2); + + let indicatorValue = this._scrollAdjustment.value; + let indicatorUpperWs = Math.ceil(indicatorValue); + let indicatorLowerWs = Math.floor(indicatorValue); + + let indicatorLowerX1 = 0; + let indicatorLowerX2 = 0; + let indicatorUpperX1 = 0; + let indicatorUpperX2 = 0; + + let indicatorThemeNode = this._indicator.get_theme_node(); + let indicatorTopFullBorder = indicatorThemeNode.get_padding(St.Side.TOP) + indicatorThemeNode.get_border_width(St.Side.TOP); + let indicatorBottomFullBorder = indicatorThemeNode.get_padding(St.Side.BOTTOM) + indicatorThemeNode.get_border_width(St.Side.BOTTOM); + let indicatorLeftFullBorder = indicatorThemeNode.get_padding(St.Side.LEFT) + indicatorThemeNode.get_border_width(St.Side.LEFT); + let indicatorRightFullBorder = indicatorThemeNode.get_padding(St.Side.RIGHT) + indicatorThemeNode.get_border_width(St.Side.RIGHT); + + let x = box.x1; + + if (this._dropPlaceholderPos == -1) { + this._dropPlaceholder.allocate_preferred_size( + ...this._dropPlaceholder.get_position()); + + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + this._dropPlaceholder.hide(); + }); + } + + let childBox = new Clutter.ActorBox(); + + for (let i = 0; i < this._thumbnails.length; i++) { + const thumbnail = this._thumbnails[i]; + if (i > 0) + x += spacing - Math.round(thumbnail.collapse_fraction * spacing); + + const y1 = box.y1; + const y2 = y1 + thumbnailHeight; + + if (i === this._dropPlaceholderPos) { + const [, placeholderWidth] = this._dropPlaceholder.get_preferred_width(-1); + childBox.y1 = y1; + childBox.y2 = y2; + + if (rtl) { + childBox.x2 = box.x2 - Math.round(x); + childBox.x1 = box.x2 - Math.round(x + placeholderWidth); + } else { + childBox.x1 = Math.round(x); + childBox.x2 = Math.round(x + placeholderWidth); + } + + this._dropPlaceholder.allocate(childBox); + + Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => { + this._dropPlaceholder.show(); + }); + x += placeholderWidth + spacing; + } + + // We might end up with thumbnailWidth being something like 99.33 + // pixels. To make this work and not end up with a gap at the end, + // we need some thumbnails to be 99 pixels and some 100 pixels width; + // we compute an actual scale separately for each thumbnail. + const x1 = Math.round(x); + const x2 = Math.round(x + thumbnailWidth); + const roundedHScale = (x2 - x1) / portholeWidth; + + // Allocating a scaled actor is funny - x1/y1 correspond to the origin + // of the actor, but x2/y2 are increased by the *unscaled* size. + if (rtl) { + childBox.x2 = box.x2 - x1; + childBox.x1 = box.x2 - (x1 + thumbnailWidth); + } else { + childBox.x1 = x1; + childBox.x2 = x1 + thumbnailWidth; + } + childBox.y1 = y1; + childBox.y2 = y1 + thumbnailHeight; + + thumbnail.setScale(roundedHScale, roundedVScale); + thumbnail.allocate(childBox); + + if (i === indicatorUpperWs) { + indicatorUpperX1 = childBox.x1; + indicatorUpperX2 = childBox.x2; + } + if (i === indicatorLowerWs) { + indicatorLowerX1 = childBox.x1; + indicatorLowerX2 = childBox.x2; + } + + // We round the collapsing portion so that we don't get thumbnails resizing + // during an animation due to differences in rounded, but leave the uncollapsed + // portion unrounded so that non-animating we end up with the right total + x += thumbnailWidth - Math.round(thumbnailWidth * thumbnail.collapse_fraction); + } + + childBox.y1 = box.y1; + childBox.y2 = box.y1 + thumbnailHeight; + + const indicatorX1 = indicatorLowerX1 + + (indicatorUpperX1 - indicatorLowerX1) * (indicatorValue % 1); + const indicatorX2 = indicatorLowerX2 + + (indicatorUpperX2 - indicatorLowerX2) * (indicatorValue % 1); + + childBox.x1 = indicatorX1 - indicatorLeftFullBorder; + childBox.x2 = indicatorX2 + indicatorRightFullBorder; + childBox.y1 -= indicatorTopFullBorder; + childBox.y2 += indicatorBottomFullBorder; + this._indicator.allocate(childBox); + } + + get shouldShow() { + return this._shouldShow; + } + + set expandFraction(expandFraction) { + if (this._expandFraction === expandFraction) + return; + this._expandFraction = expandFraction; + this.notify('expand-fraction'); + this.queue_relayout(); + } + + get expandFraction() { + return this._expandFraction; + } +}); diff --git a/js/ui/workspacesView.js b/js/ui/workspacesView.js new file mode 100644 index 0000000..660fcf6 --- /dev/null +++ b/js/ui/workspacesView.js @@ -0,0 +1,1156 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported WorkspacesView, WorkspacesDisplay */ + +const { Clutter, Gio, GObject, Meta, Shell, St } = imports.gi; + +const Layout = imports.ui.layout; +const Main = imports.ui.main; +const OverviewControls = imports.ui.overviewControls; +const SwipeTracker = imports.ui.swipeTracker; +const Util = imports.misc.util; +const Workspace = imports.ui.workspace; +const { ThumbnailsBox, MAX_THUMBNAIL_SCALE } = imports.ui.workspaceThumbnail; + +var WORKSPACE_SWITCH_TIME = 250; + +const MUTTER_SCHEMA = 'org.gnome.mutter'; + +const WORKSPACE_MIN_SPACING = 24; +const WORKSPACE_MAX_SPACING = 80; + +const WORKSPACE_INACTIVE_SCALE = 0.94; + +const SECONDARY_WORKSPACE_SCALE = 0.80; + +var WorkspacesViewBase = GObject.registerClass({ + GTypeFlags: GObject.TypeFlags.ABSTRACT, +}, class WorkspacesViewBase extends St.Widget { + _init(monitorIndex, overviewAdjustment) { + super._init({ + style_class: 'workspaces-view', + x_expand: true, + y_expand: true, + }); + this.connect('destroy', this._onDestroy.bind(this)); + global.focus_manager.add_group(this); + + this._monitorIndex = monitorIndex; + + this._inDrag = false; + Main.overview.connectObject( + 'window-drag-begin', this._dragBegin.bind(this), + 'window-drag-end', this._dragEnd.bind(this), this); + + this._overviewAdjustment = overviewAdjustment; + overviewAdjustment.connectObject('notify::value', + () => this._updateWorkspaceMode(), this); + } + + _onDestroy() { + this._dragEnd(); + } + + _dragBegin() { + this._inDrag = true; + } + + _dragEnd() { + this._inDrag = false; + } + + _updateWorkspaceMode() { + } + + vfunc_allocate(box) { + this.set_allocation(box); + + for (const child of this) + child.allocate_available_size(0, 0, box.get_width(), box.get_height()); + } + + vfunc_get_preferred_width() { + return [0, 0]; + } + + vfunc_get_preferred_height() { + return [0, 0]; + } +}); + +var FitMode = { + SINGLE: 0, + ALL: 1, +}; + +var WorkspacesView = GObject.registerClass( +class WorkspacesView extends WorkspacesViewBase { + _init(monitorIndex, controls, scrollAdjustment, fitModeAdjustment, overviewAdjustment) { + let workspaceManager = global.workspace_manager; + + super._init(monitorIndex, overviewAdjustment); + + this._controls = controls; + this._fitModeAdjustment = fitModeAdjustment; + this._fitModeAdjustment.connectObject('notify::value', () => { + this._updateVisibility(); + this._updateWorkspacesState(); + this.queue_relayout(); + }, this); + + this._animating = false; // tweening + this._gestureActive = false; // touch(pad) gestures + + this._scrollAdjustment = scrollAdjustment; + this._scrollAdjustment.connectObject('notify::value', + this._onScrollAdjustmentChanged.bind(this), this); + + this._workspaces = []; + this._updateWorkspaces(); + workspaceManager.connectObject( + 'notify::n-workspaces', this._updateWorkspaces.bind(this), + 'workspaces-reordered', () => { + this._workspaces.sort((a, b) => { + return a.metaWorkspace.index() - b.metaWorkspace.index(); + }); + this._workspaces.forEach( + (ws, i) => this.set_child_at_index(ws, i)); + }, this); + + global.window_manager.connectObject('switch-workspace', + this._activeWorkspaceChanged.bind(this), this); + } + + _getFirstFitAllWorkspaceBox(box, spacing, vertical) { + const { nWorkspaces } = global.workspaceManager; + const [width, height] = box.get_size(); + const [workspace] = this._workspaces; + + const fitAllBox = new Clutter.ActorBox(); + + let [x1, y1] = box.get_origin(); + + // Spacing here is not only the space between workspaces, but also the + // space before the first workspace, and after the last one. This prevents + // workspaces from touching the edges of the allocation box. + if (vertical) { + const availableHeight = height - spacing * (nWorkspaces + 1); + let workspaceHeight = availableHeight / nWorkspaces; + let [, workspaceWidth] = + workspace.get_preferred_width(workspaceHeight); + + y1 = spacing; + if (workspaceWidth > width) { + [, workspaceHeight] = workspace.get_preferred_height(width); + y1 += Math.max((availableHeight - workspaceHeight * nWorkspaces) / 2, 0); + } + + fitAllBox.set_size(width, workspaceHeight); + } else { + const availableWidth = width - spacing * (nWorkspaces + 1); + let workspaceWidth = availableWidth / nWorkspaces; + let [, workspaceHeight] = + workspace.get_preferred_height(workspaceWidth); + + x1 = spacing; + if (workspaceHeight > height) { + [, workspaceWidth] = workspace.get_preferred_width(height); + x1 += Math.max((availableWidth - workspaceWidth * nWorkspaces) / 2, 0); + } + + fitAllBox.set_size(workspaceWidth, height); + } + + fitAllBox.set_origin(x1, y1); + + return fitAllBox; + } + + _getFirstFitSingleWorkspaceBox(box, spacing, vertical) { + const [width, height] = box.get_size(); + const [workspace] = this._workspaces; + + const rtl = this.text_direction === Clutter.TextDirection.RTL; + const adj = this._scrollAdjustment; + const currentWorkspace = vertical || !rtl + ? adj.value : adj.upper - adj.value - 1; + + // Single fit mode implies centered too + let [x1, y1] = box.get_origin(); + if (vertical) { + const [, workspaceHeight] = workspace.get_preferred_height(width); + y1 += (height - workspaceHeight) / 2; + y1 -= currentWorkspace * (workspaceHeight + spacing); + } else { + const [, workspaceWidth] = workspace.get_preferred_width(height); + x1 += (width - workspaceWidth) / 2; + x1 -= currentWorkspace * (workspaceWidth + spacing); + } + + const fitSingleBox = new Clutter.ActorBox({ x1, y1 }); + + if (vertical) { + const [, workspaceHeight] = workspace.get_preferred_height(width); + fitSingleBox.set_size(width, workspaceHeight); + } else { + const [, workspaceWidth] = workspace.get_preferred_width(height); + fitSingleBox.set_size(workspaceWidth, height); + } + + return fitSingleBox; + } + + _getSpacing(box, fitMode, vertical) { + const [width, height] = box.get_size(); + const [workspace] = this._workspaces; + + let availableSpace; + let workspaceSize; + if (vertical) { + [, workspaceSize] = workspace.get_preferred_height(width); + availableSpace = (height - workspaceSize) / 2; + } else { + [, workspaceSize] = workspace.get_preferred_width(height); + availableSpace = (width - workspaceSize) / 2; + } + + const spacing = (availableSpace - workspaceSize * 0.4) * (1 - fitMode); + const { scaleFactor } = St.ThemeContext.get_for_stage(global.stage); + + return Math.clamp(spacing, WORKSPACE_MIN_SPACING * scaleFactor, + WORKSPACE_MAX_SPACING * scaleFactor); + } + + _getWorkspaceModeForOverviewState(state) { + const { ControlsState } = OverviewControls; + + switch (state) { + case ControlsState.HIDDEN: + return 0; + case ControlsState.WINDOW_PICKER: + return 1; + case ControlsState.APP_GRID: + return 0; + } + + return 0; + } + + _updateWorkspacesState() { + const adj = this._scrollAdjustment; + const fitMode = this._fitModeAdjustment.value; + + const { initialState, finalState, progress } = + this._overviewAdjustment.getStateTransitionParams(); + + const workspaceMode = (1 - fitMode) * Util.lerp( + this._getWorkspaceModeForOverviewState(initialState), + this._getWorkspaceModeForOverviewState(finalState), + progress); + + // Fade and scale inactive workspaces + this._workspaces.forEach((w, index) => { + w.stateAdjustment.value = workspaceMode; + + const distanceToCurrentWorkspace = Math.abs(adj.value - index); + + const scaleProgress = 1 - Math.clamp(distanceToCurrentWorkspace, 0, 1); + + const scale = Util.lerp(WORKSPACE_INACTIVE_SCALE, 1, scaleProgress); + w.set_scale(scale, scale); + }); + } + + _getFitModeForState(state) { + const { ControlsState } = OverviewControls; + + switch (state) { + case ControlsState.HIDDEN: + case ControlsState.WINDOW_PICKER: + return FitMode.SINGLE; + case ControlsState.APP_GRID: + return FitMode.ALL; + default: + return FitMode.SINGLE; + } + } + + _getInitialBoxes(box) { + const offsetBox = new Clutter.ActorBox(); + offsetBox.set_size(...box.get_size()); + + let fitSingleBox = offsetBox; + let fitAllBox = offsetBox; + + const { transitioning, initialState, finalState } = + this._overviewAdjustment.getStateTransitionParams(); + + const isPrimary = Main.layoutManager.primaryIndex === this._monitorIndex; + + if (isPrimary && transitioning) { + const initialFitMode = this._getFitModeForState(initialState); + const finalFitMode = this._getFitModeForState(finalState); + + // Only use the relative boxes when the overview is in a state + // transition, and the corresponding fit modes are different. + if (initialFitMode !== finalFitMode) { + const initialBox = + this._controls.getWorkspacesBoxForState(initialState).copy(); + const finalBox = + this._controls.getWorkspacesBoxForState(finalState).copy(); + + // Boxes are relative to ControlsManager, transform them; + // this.apply_relative_transform_to_point(controls, + // new Graphene.Point3D()); + // would be more correct, but also more expensive + const [parentOffsetX, parentOffsetY] = + this.get_parent().allocation.get_origin(); + [initialBox, finalBox].forEach(b => { + b.set_origin(b.x1 - parentOffsetX, b.y1 - parentOffsetY); + }); + + if (initialFitMode === FitMode.SINGLE) + [fitSingleBox, fitAllBox] = [initialBox, finalBox]; + else + [fitAllBox, fitSingleBox] = [initialBox, finalBox]; + } + } + + return [fitSingleBox, fitAllBox]; + } + + _updateWorkspaceMode() { + this._updateWorkspacesState(); + } + + vfunc_allocate(box) { + this.set_allocation(box); + + if (this.get_n_children() === 0) + return; + + const vertical = global.workspaceManager.layout_rows === -1; + const rtl = this.text_direction === Clutter.TextDirection.RTL; + + const fitMode = this._fitModeAdjustment.value; + + let [fitSingleBox, fitAllBox] = this._getInitialBoxes(box); + const fitSingleSpacing = + this._getSpacing(fitSingleBox, FitMode.SINGLE, vertical); + fitSingleBox = + this._getFirstFitSingleWorkspaceBox(fitSingleBox, fitSingleSpacing, vertical); + + const fitAllSpacing = + this._getSpacing(fitAllBox, FitMode.ALL, vertical); + fitAllBox = + this._getFirstFitAllWorkspaceBox(fitAllBox, fitAllSpacing, vertical); + + // Account for RTL locales by reversing the list + const workspaces = this._workspaces.slice(); + if (rtl) + workspaces.reverse(); + + const [fitSingleX1, fitSingleY1] = fitSingleBox.get_origin(); + const [fitSingleWidth, fitSingleHeight] = fitSingleBox.get_size(); + const [fitAllX1, fitAllY1] = fitAllBox.get_origin(); + const [fitAllWidth, fitAllHeight] = fitAllBox.get_size(); + + workspaces.forEach(child => { + if (fitMode === FitMode.SINGLE) + box = fitSingleBox; + else if (fitMode === FitMode.ALL) + box = fitAllBox; + else + box = fitSingleBox.interpolate(fitAllBox, fitMode); + + child.allocate_align_fill(box, 0.5, 0.5, false, false); + + if (vertical) { + fitSingleBox.set_origin( + fitSingleX1, + fitSingleBox.y1 + fitSingleHeight + fitSingleSpacing); + fitAllBox.set_origin( + fitAllX1, + fitAllBox.y1 + fitAllHeight + fitAllSpacing); + } else { + fitSingleBox.set_origin( + fitSingleBox.x1 + fitSingleWidth + fitSingleSpacing, + fitSingleY1); + fitAllBox.set_origin( + fitAllBox.x1 + fitAllWidth + fitAllSpacing, + fitAllY1); + } + }); + } + + getActiveWorkspace() { + let workspaceManager = global.workspace_manager; + let active = workspaceManager.get_active_workspace_index(); + return this._workspaces[active]; + } + + prepareToLeaveOverview() { + for (let w = 0; w < this._workspaces.length; w++) + this._workspaces[w].prepareToLeaveOverview(); + } + + syncStacking(stackIndices) { + for (let i = 0; i < this._workspaces.length; i++) + this._workspaces[i].syncStacking(stackIndices); + } + + _scrollToActive() { + const { workspaceManager } = global; + const active = workspaceManager.get_active_workspace_index(); + + this._animating = true; + this._updateVisibility(); + + this._scrollAdjustment.remove_transition('value'); + this._scrollAdjustment.ease(active, { + duration: WORKSPACE_SWITCH_TIME, + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + onComplete: () => { + this._animating = false; + this._updateVisibility(); + }, + }); + } + + _updateVisibility() { + let workspaceManager = global.workspace_manager; + let active = workspaceManager.get_active_workspace_index(); + + const fitMode = this._fitModeAdjustment.value; + const singleFitMode = fitMode === FitMode.SINGLE; + + for (let w = 0; w < this._workspaces.length; w++) { + let workspace = this._workspaces[w]; + + if (this._animating || this._gestureActive || !singleFitMode) + workspace.show(); + else + workspace.visible = Math.abs(w - active) <= 1; + } + } + + _updateWorkspaces() { + let workspaceManager = global.workspace_manager; + let newNumWorkspaces = workspaceManager.n_workspaces; + + for (let j = 0; j < newNumWorkspaces; j++) { + let metaWorkspace = workspaceManager.get_workspace_by_index(j); + let workspace; + + if (j >= this._workspaces.length) { /* added */ + workspace = new Workspace.Workspace( + metaWorkspace, + this._monitorIndex, + this._overviewAdjustment); + this.add_actor(workspace); + this._workspaces[j] = workspace; + } else { + workspace = this._workspaces[j]; + + if (workspace.metaWorkspace != metaWorkspace) { /* removed */ + workspace.destroy(); + this._workspaces.splice(j, 1); + } /* else kept */ + } + } + + for (let j = this._workspaces.length - 1; j >= newNumWorkspaces; j--) { + this._workspaces[j].destroy(); + this._workspaces.splice(j, 1); + } + + this._updateWorkspacesState(); + this._updateVisibility(); + } + + _activeWorkspaceChanged(_wm, _from, _to, _direction) { + this._scrollToActive(); + } + + _onDestroy() { + super._onDestroy(); + + this._workspaces = []; + } + + startTouchGesture() { + this._gestureActive = true; + + this._updateVisibility(); + } + + endTouchGesture() { + this._gestureActive = false; + + // Make sure title captions etc are shown as necessary + this._scrollToActive(); + this._updateVisibility(); + } + + // sync the workspaces' positions to the value of the scroll adjustment + // and change the active workspace if appropriate + _onScrollAdjustmentChanged() { + if (!this.has_allocation()) + return; + + const adj = this._scrollAdjustment; + const allowSwitch = + adj.get_transition('value') === null && !this._gestureActive; + + let workspaceManager = global.workspace_manager; + let active = workspaceManager.get_active_workspace_index(); + let current = Math.round(adj.value); + + if (allowSwitch && active !== current) { + if (!this._workspaces[current]) { + // The current workspace was destroyed. This could happen + // when you are on the last empty workspace, and consolidate + // windows using the thumbnail bar. + // In that case, the intended behavior is to stay on the empty + // workspace, which is the last one, so pick it. + current = this._workspaces.length - 1; + } + + let metaWorkspace = this._workspaces[current].metaWorkspace; + metaWorkspace.activate(global.get_current_time()); + } + + this._updateWorkspacesState(); + this.queue_relayout(); + } +}); + +var ExtraWorkspaceView = GObject.registerClass( +class ExtraWorkspaceView extends WorkspacesViewBase { + _init(monitorIndex, overviewAdjustment) { + super._init(monitorIndex, overviewAdjustment); + this._workspace = + new Workspace.Workspace(null, monitorIndex, overviewAdjustment); + this.add_actor(this._workspace); + } + + _updateWorkspaceMode() { + const overviewState = this._overviewAdjustment.value; + + const progress = Math.clamp(overviewState, + OverviewControls.ControlsState.HIDDEN, + OverviewControls.ControlsState.WINDOW_PICKER); + + this._workspace.stateAdjustment.value = progress; + } + + vfunc_allocate(box) { + this.set_allocation(box); + + const [width, height] = box.get_size(); + const [, childWidth] = this._workspace.get_preferred_width(height); + + const childBox = new Clutter.ActorBox(); + childBox.set_origin(Math.round((width - childWidth) / 2), 0); + childBox.set_size(childWidth, height); + this._workspace.allocate(childBox); + } + + getActiveWorkspace() { + return this._workspace; + } + + prepareToLeaveOverview() { + this._workspace.prepareToLeaveOverview(); + } + + syncStacking(stackIndices) { + this._workspace.syncStacking(stackIndices); + } + + startTouchGesture() { + } + + endTouchGesture() { + } +}); + +const SecondaryMonitorDisplay = GObject.registerClass( +class SecondaryMonitorDisplay extends St.Widget { + _init(monitorIndex, controls, scrollAdjustment, fitModeAdjustment, overviewAdjustment) { + this._monitorIndex = monitorIndex; + this._controls = controls; + this._scrollAdjustment = scrollAdjustment; + this._fitModeAdjustment = fitModeAdjustment; + this._overviewAdjustment = overviewAdjustment; + + super._init({ + style_class: 'secondary-monitor-workspaces', + constraints: new Layout.MonitorConstraint({ + index: this._monitorIndex, + work_area: true, + }), + clip_to_allocation: true, + }); + + this.connect('destroy', () => this._onDestroy()); + + this._thumbnails = new ThumbnailsBox( + this._scrollAdjustment, monitorIndex); + this.add_child(this._thumbnails); + + this._thumbnails.connect('notify::should-show', + () => this._updateThumbnailVisibility()); + + this._overviewAdjustment.connectObject('notify::value', () => { + this._updateThumbnailParams(); + this.queue_relayout(); + }, this); + + this._settings = new Gio.Settings({ schema_id: MUTTER_SCHEMA }); + this._settings.connect('changed::workspaces-only-on-primary', + () => this._workspacesOnPrimaryChanged()); + this._workspacesOnPrimaryChanged(); + } + + _getThumbnailParamsForState(state) { + const { ControlsState } = OverviewControls; + + let opacity, scale; + switch (state) { + case ControlsState.HIDDEN: + case ControlsState.WINDOW_PICKER: + opacity = 255; + scale = 1; + break; + case ControlsState.APP_GRID: + opacity = 0; + scale = 0.5; + break; + default: + opacity = 255; + scale = 1; + break; + } + + return { opacity, scale }; + } + + _getThumbnailsHeight(box) { + if (!this._thumbnails.visible) + return 0; + + const [width, height] = box.get_size(); + const { expandFraction } = this._thumbnails; + const [thumbnailsHeight] = this._thumbnails.get_preferred_height(width); + return Math.min( + thumbnailsHeight * expandFraction, + height * MAX_THUMBNAIL_SCALE); + } + + _getWorkspacesBoxForState(state, box, padding, thumbnailsHeight, spacing) { + const { ControlsState } = OverviewControls; + const workspaceBox = box.copy(); + const [width, height] = workspaceBox.get_size(); + + switch (state) { + case ControlsState.HIDDEN: + break; + case ControlsState.WINDOW_PICKER: + workspaceBox.set_origin(0, padding + thumbnailsHeight + spacing); + workspaceBox.set_size( + width, + height - 2 * padding - thumbnailsHeight - spacing); + break; + case ControlsState.APP_GRID: + workspaceBox.set_origin(0, padding); + workspaceBox.set_size( + width, + height - 2 * padding); + break; + } + + return workspaceBox; + } + + vfunc_allocate(box) { + this.set_allocation(box); + + const themeNode = this.get_theme_node(); + const contentBox = themeNode.get_content_box(box); + const [width, height] = contentBox.get_size(); + const { expandFraction } = this._thumbnails; + const spacing = themeNode.get_length('spacing') * expandFraction; + const padding = + Math.round((1 - SECONDARY_WORKSPACE_SCALE) * height / 2); + + const thumbnailsHeight = this._getThumbnailsHeight(contentBox); + + if (this._thumbnails.visible) { + const childBox = new Clutter.ActorBox(); + childBox.set_origin(0, padding); + childBox.set_size(width, thumbnailsHeight); + this._thumbnails.allocate(childBox); + } + + const { + currentState, initialState, finalState, transitioning, progress, + } = this._overviewAdjustment.getStateTransitionParams(); + + let workspacesBox; + const workspaceParams = [contentBox, padding, thumbnailsHeight, spacing]; + if (!transitioning) { + workspacesBox = + this._getWorkspacesBoxForState(currentState, ...workspaceParams); + } else { + const initialBox = + this._getWorkspacesBoxForState(initialState, ...workspaceParams); + const finalBox = + this._getWorkspacesBoxForState(finalState, ...workspaceParams); + workspacesBox = initialBox.interpolate(finalBox, progress); + } + this._workspacesView.allocate(workspacesBox); + } + + _onDestroy() { + if (this._settings) + this._settings.run_dispose(); + this._settings = null; + } + + _workspacesOnPrimaryChanged() { + this._updateWorkspacesView(); + this._updateThumbnailVisibility(); + } + + _updateWorkspacesView() { + if (this._workspacesView) + this._workspacesView.destroy(); + + if (this._settings.get_boolean('workspaces-only-on-primary')) { + this._workspacesView = new ExtraWorkspaceView( + this._monitorIndex, + this._overviewAdjustment); + } else { + this._workspacesView = new WorkspacesView( + this._monitorIndex, + this._controls, + this._scrollAdjustment, + this._fitModeAdjustment, + this._overviewAdjustment); + } + this.add_child(this._workspacesView); + } + + _updateThumbnailVisibility() { + const visible = + this._thumbnails.should_show && + !this._settings.get_boolean('workspaces-only-on-primary'); + + if (this._thumbnails.visible === visible) + return; + + this._thumbnails.show(); + this._updateThumbnailParams(); + this._thumbnails.ease_property('expand-fraction', visible ? 1 : 0, { + duration: OverviewControls.SIDE_CONTROLS_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => (this._thumbnails.visible = visible), + }); + } + + _updateThumbnailParams() { + if (!this._thumbnails.visible) + return; + + const { initialState, finalState, progress } = + this._overviewAdjustment.getStateTransitionParams(); + + const initialParams = this._getThumbnailParamsForState(initialState); + const finalParams = this._getThumbnailParamsForState(finalState); + + const opacity = + Util.lerp(initialParams.opacity, finalParams.opacity, progress); + const scale = + Util.lerp(initialParams.scale, finalParams.scale, progress); + + this._thumbnails.set({ + opacity, + scale_x: scale, + scale_y: scale, + }); + } + + getActiveWorkspace() { + return this._workspacesView.getActiveWorkspace(); + } + + prepareToLeaveOverview() { + this._workspacesView.prepareToLeaveOverview(); + } + + syncStacking(stackIndices) { + this._workspacesView.syncStacking(stackIndices); + } + + startTouchGesture() { + this._workspacesView.startTouchGesture(); + } + + endTouchGesture() { + this._workspacesView.endTouchGesture(); + } +}); + +var WorkspacesDisplay = GObject.registerClass( +class WorkspacesDisplay extends St.Widget { + _init(controls, scrollAdjustment, overviewAdjustment) { + super._init({ + layout_manager: new Clutter.BinLayout(), + reactive: true, + }); + + this._controls = controls; + this._overviewAdjustment = overviewAdjustment; + this._fitModeAdjustment = new St.Adjustment({ + actor: this, + value: FitMode.SINGLE, + lower: FitMode.SINGLE, + upper: FitMode.ALL, + }); + + let workspaceManager = global.workspace_manager; + this._scrollAdjustment = scrollAdjustment; + + global.window_manager.connectObject('switch-workspace', + this._activeWorkspaceChanged.bind(this), this); + + this._swipeTracker = new SwipeTracker.SwipeTracker( + Main.layoutManager.overviewGroup, + Clutter.Orientation.HORIZONTAL, + Shell.ActionMode.OVERVIEW, + { allowDrag: false }); + this._swipeTracker.allowLongSwipes = true; + this._swipeTracker.connect('begin', this._switchWorkspaceBegin.bind(this)); + this._swipeTracker.connect('update', this._switchWorkspaceUpdate.bind(this)); + this._swipeTracker.connect('end', this._switchWorkspaceEnd.bind(this)); + this.connect('notify::mapped', this._updateSwipeTracker.bind(this)); + + workspaceManager.connectObject( + 'workspaces-reordered', this._workspacesReordered.bind(this), + 'notify::layout-rows', this._updateTrackerOrientation.bind(this), this); + this._updateTrackerOrientation(); + + Main.overview.connectObject( + 'window-drag-begin', this._windowDragBegin.bind(this), + 'window-drag-end', this._windowDragEnd.bind(this), this); + + this._primaryVisible = true; + this._primaryIndex = Main.layoutManager.primaryIndex; + this._workspacesViews = []; + + this._settings = new Gio.Settings({ schema_id: MUTTER_SCHEMA }); + + this._inWindowDrag = false; + this._leavingOverview = false; + + this._gestureActive = false; // touch(pad) gestures + + this.connect('destroy', this._onDestroy.bind(this)); + } + + _onDestroy() { + if (this._parentSetLater) { + Meta.later_remove(this._parentSetLater); + this._parentSetLater = 0; + } + } + + _windowDragBegin() { + this._inWindowDrag = true; + this._updateSwipeTracker(); + } + + _windowDragEnd() { + this._inWindowDrag = false; + this._updateSwipeTracker(); + } + + _updateSwipeTracker() { + this._swipeTracker.enabled = + this.mapped && + !this._inWindowDrag && + !this._leavingOverview; + } + + _workspacesReordered() { + let workspaceManager = global.workspace_manager; + + this._scrollAdjustment.value = + workspaceManager.get_active_workspace_index(); + } + + _activeWorkspaceChanged(_wm, _from, to, _direction) { + if (this._gestureActive) + return; + + this._scrollAdjustment.ease(to, { + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + duration: WORKSPACE_SWITCH_TIME, + }); + } + + _updateTrackerOrientation() { + const { layoutRows } = global.workspace_manager; + this._swipeTracker.orientation = layoutRows !== -1 + ? Clutter.Orientation.HORIZONTAL + : Clutter.Orientation.VERTICAL; + } + + _directionForProgress(progress) { + if (global.workspace_manager.layout_rows === -1) { + return progress > 0 + ? Meta.MotionDirection.DOWN + : Meta.MotionDirection.UP; + } else if (this.text_direction === Clutter.TextDirection.RTL) { + return progress > 0 + ? Meta.MotionDirection.LEFT + : Meta.MotionDirection.RIGHT; + } else { + return progress > 0 + ? Meta.MotionDirection.RIGHT + : Meta.MotionDirection.LEFT; + } + } + + _switchWorkspaceBegin(tracker, monitor) { + if (this._workspacesOnlyOnPrimary && monitor !== this._primaryIndex) + return; + + let workspaceManager = global.workspace_manager; + let adjustment = this._scrollAdjustment; + if (this._gestureActive) + adjustment.remove_transition('value'); + + const distance = global.workspace_manager.layout_rows === -1 + ? this.height : this.width; + + for (let i = 0; i < this._workspacesViews.length; i++) + this._workspacesViews[i].startTouchGesture(); + + let progress = adjustment.value / adjustment.page_size; + let points = Array.from( + { length: workspaceManager.n_workspaces }, (v, i) => i); + + tracker.confirmSwipe(distance, points, progress, Math.round(progress)); + + this._gestureActive = true; + } + + _switchWorkspaceUpdate(tracker, progress) { + let adjustment = this._scrollAdjustment; + adjustment.value = progress * adjustment.page_size; + } + + _switchWorkspaceEnd(tracker, duration, endProgress) { + let workspaceManager = global.workspace_manager; + let newWs = workspaceManager.get_workspace_by_index(endProgress); + + this._scrollAdjustment.ease(endProgress, { + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + duration, + onComplete: () => { + if (!newWs.active) + newWs.activate(global.get_current_time()); + this._endTouchGesture(); + }, + }); + } + + _endTouchGesture() { + for (let i = 0; i < this._workspacesViews.length; i++) + this._workspacesViews[i].endTouchGesture(); + this._gestureActive = false; + } + + vfunc_navigate_focus(from, direction) { + return this._getPrimaryView()?.navigate_focus(from, direction, false); + } + + setPrimaryWorkspaceVisible(visible) { + if (this._primaryVisible === visible) + return; + + this._primaryVisible = visible; + + const primaryIndex = Main.layoutManager.primaryIndex; + const primaryWorkspace = this._workspacesViews[primaryIndex]; + if (primaryWorkspace) + primaryWorkspace.visible = visible; + } + + prepareToEnterOverview() { + this.show(); + this._updateWorkspacesViews(); + + Main.overview.connectObject( + 'windows-restacked', this._onRestacked.bind(this), + 'scroll-event', this._onScrollEvent.bind(this), this); + + global.stage.connectObject( + 'key-press-event', this._onKeyPressEvent.bind(this), this); + } + + prepareToLeaveOverview() { + for (let i = 0; i < this._workspacesViews.length; i++) + this._workspacesViews[i].prepareToLeaveOverview(); + + this._leavingOverview = true; + this._updateSwipeTracker(); + } + + vfunc_hide() { + Main.overview.disconnectObject(this); + global.stage.disconnectObject(this); + + for (let i = 0; i < this._workspacesViews.length; i++) + this._workspacesViews[i].destroy(); + this._workspacesViews = []; + + this._leavingOverview = false; + + super.vfunc_hide(); + } + + _updateWorkspacesViews() { + for (let i = 0; i < this._workspacesViews.length; i++) + this._workspacesViews[i].destroy(); + + this._primaryIndex = Main.layoutManager.primaryIndex; + this._workspacesViews = []; + let monitors = Main.layoutManager.monitors; + for (let i = 0; i < monitors.length; i++) { + let view; + if (i === this._primaryIndex) { + view = new WorkspacesView(i, + this._controls, + this._scrollAdjustment, + this._fitModeAdjustment, + this._overviewAdjustment); + + view.visible = this._primaryVisible; + this.bind_property('opacity', view, 'opacity', GObject.BindingFlags.SYNC_CREATE); + this.add_child(view); + } else { + view = new SecondaryMonitorDisplay(i, + this._controls, + this._scrollAdjustment, + this._fitModeAdjustment, + this._overviewAdjustment); + Main.layoutManager.overviewGroup.add_actor(view); + } + + this._workspacesViews.push(view); + } + } + + _getMonitorIndexForEvent(event) { + let [x, y] = event.get_coords(); + let rect = new Meta.Rectangle({ x, y, width: 1, height: 1 }); + return global.display.get_monitor_index_for_rect(rect); + } + + _getPrimaryView() { + if (!this._workspacesViews.length) + return null; + return this._workspacesViews[this._primaryIndex]; + } + + activeWorkspaceHasMaximizedWindows() { + const primaryView = this._getPrimaryView(); + return primaryView + ? primaryView.getActiveWorkspace().hasMaximizedWindows() + : false; + } + + _onRestacked(overview, stackIndices) { + for (let i = 0; i < this._workspacesViews.length; i++) + this._workspacesViews[i].syncStacking(stackIndices); + } + + _onScrollEvent(actor, event) { + if (this._swipeTracker.canHandleScrollEvent(event)) + return Clutter.EVENT_PROPAGATE; + + if (!this.mapped) + return Clutter.EVENT_PROPAGATE; + + if (this._workspacesOnlyOnPrimary && + this._getMonitorIndexForEvent(event) != this._primaryIndex) + return Clutter.EVENT_PROPAGATE; + + return Main.wm.handleWorkspaceScroll(event); + } + + _onKeyPressEvent(actor, event) { + const { ControlsState } = OverviewControls; + if (this._overviewAdjustment.value !== ControlsState.WINDOW_PICKER) + return Clutter.EVENT_PROPAGATE; + + if (!this.reactive) + return Clutter.EVENT_PROPAGATE; + + const { workspaceManager } = global; + const vertical = workspaceManager.layout_rows === -1; + const rtl = this.get_text_direction() === Clutter.TextDirection.RTL; + + let which; + switch (event.get_key_symbol()) { + case Clutter.KEY_Page_Up: + if (vertical) + which = Meta.MotionDirection.UP; + else if (rtl) + which = Meta.MotionDirection.RIGHT; + else + which = Meta.MotionDirection.LEFT; + break; + case Clutter.KEY_Page_Down: + if (vertical) + which = Meta.MotionDirection.DOWN; + else if (rtl) + which = Meta.MotionDirection.LEFT; + else + which = Meta.MotionDirection.RIGHT; + break; + case Clutter.KEY_Home: + which = 0; + break; + case Clutter.KEY_End: + which = workspaceManager.n_workspaces - 1; + break; + default: + return Clutter.EVENT_PROPAGATE; + } + + let ws; + if (which < 0) + // Negative workspace numbers are directions + // with respect to the current workspace + ws = workspaceManager.get_active_workspace().get_neighbor(which); + else + // Otherwise it is a workspace index + ws = workspaceManager.get_workspace_by_index(which); + + if (ws) + Main.wm.actionMoveWorkspace(ws); + + return Clutter.EVENT_STOP; + } + + get _workspacesOnlyOnPrimary() { + return this._settings.get_boolean('workspaces-only-on-primary'); + } + + get fitModeAdjustment() { + return this._fitModeAdjustment; + } +}); diff --git a/js/ui/xdndHandler.js b/js/ui/xdndHandler.js new file mode 100644 index 0000000..3a4880b --- /dev/null +++ b/js/ui/xdndHandler.js @@ -0,0 +1,116 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported XdndHandler */ + +const { Clutter } = imports.gi; +const Signals = imports.misc.signals; + +const DND = imports.ui.dnd; +const Main = imports.ui.main; + +var XdndHandler = class extends Signals.EventEmitter { + constructor() { + super(); + + // Used to display a clone of the cursor window when the + // window group is hidden (like it happens in the overview) + this._cursorWindowClone = null; + + // Used as a drag actor in case we don't have a cursor window clone + this._dummy = new Clutter.Actor({ width: 1, height: 1, opacity: 0 }); + Main.uiGroup.add_actor(this._dummy); + this._dummy.hide(); + + var dnd = global.backend.get_dnd(); + dnd.connect('dnd-enter', this._onEnter.bind(this)); + dnd.connect('dnd-position-change', this._onPositionChanged.bind(this)); + dnd.connect('dnd-leave', this._onLeave.bind(this)); + } + + // Called when the user cancels the drag (i.e release the button) + _onLeave() { + global.window_group.disconnectObject(this); + if (this._cursorWindowClone) { + this._cursorWindowClone.destroy(); + this._cursorWindowClone = null; + } + + this.emit('drag-end'); + } + + _onEnter() { + global.window_group.connectObject('notify::visible', + this._onWindowGroupVisibilityChanged.bind(this), this); + + this.emit('drag-begin', global.get_current_time()); + } + + _onWindowGroupVisibilityChanged() { + if (!global.window_group.visible) { + if (this._cursorWindowClone) + return; + + let windows = global.get_window_actors(); + let cursorWindow = windows[windows.length - 1]; + + // FIXME: more reliable way? + if (!cursorWindow.get_meta_window().is_override_redirect()) + return; + + const constraintPosition = new Clutter.BindConstraint({ + coordinate: Clutter.BindCoordinate.POSITION, + source: cursorWindow, + }); + + this._cursorWindowClone = new Clutter.Clone({ source: cursorWindow }); + Main.uiGroup.add_actor(this._cursorWindowClone); + + // Make sure that the clone has the same position as the source + this._cursorWindowClone.add_constraint(constraintPosition); + } else { + if (!this._cursorWindowClone) + return; + + this._cursorWindowClone.destroy(); + this._cursorWindowClone = null; + } + } + + _onPositionChanged(obj, x, y) { + let pickedActor = global.stage.get_actor_at_pos(Clutter.PickMode.REACTIVE, x, y); + + // Make sure that the cursor window is on top + if (this._cursorWindowClone) + Main.uiGroup.set_child_above_sibling(this._cursorWindowClone, null); + + let dragEvent = { + x, + y, + dragActor: this._cursorWindowClone ?? this._dummy, + source: this, + targetActor: pickedActor, + }; + + for (let i = 0; i < DND.dragMonitors.length; i++) { + let motionFunc = DND.dragMonitors[i].dragMotion; + if (motionFunc) { + let result = motionFunc(dragEvent); + if (result != DND.DragMotionResult.CONTINUE) + return; + } + } + + while (pickedActor) { + if (pickedActor._delegate && pickedActor._delegate.handleDragOver) { + let [r_, targX, targY] = pickedActor.transform_stage_point(x, y); + let result = pickedActor._delegate.handleDragOver(this, + dragEvent.dragActor, + targX, + targY, + global.get_current_time()); + if (result != DND.DragMotionResult.CONTINUE) + return; + } + pickedActor = pickedActor.get_parent(); + } + } +}; |