summaryrefslogtreecommitdiffstats
path: root/js/ui/switcherPopup.js
diff options
context:
space:
mode:
Diffstat (limited to 'js/ui/switcherPopup.js')
-rw-r--r--js/ui/switcherPopup.js688
1 files changed, 688 insertions, 0 deletions
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();
+}
+