gnome-shell/js/ui/switcherPopup.js
Carlos Garnacho 8423ba44fe js: Mass move to Clutter.Event getter methods in Clutter.Actor vfuncs
These traditionally got the various ClutterEvent subtype structs as their
argument, so it was not allowed to use ClutterEvent generic getter methods
in these vfuncs. These methods used direct access to struct fields instead.

This got spoiled with the move to make ClutterEvent opaque types, since
these are no longer public structs so GNOME Shell most silently failed to
fetch the expected values from event fields. But since they are not
ClutterEvents either, the getters could not be used on them.

Mutter is changing so that these vmethods all contain an alias to the
one and only Clutter.Event type, thus lifting those barriers, and making
it possible to use the ClutterEvent methods in these vfuncs.

Closes: https://gitlab.gnome.org/GNOME/mutter/-/issues/2950
Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/2872>
2023-08-09 13:46:08 +02:00

700 lines
21 KiB
JavaScript

// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
import Clutter from 'gi://Clutter';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import St from 'gi://St';
import * as Main from './main.js';
const POPUP_DELAY_TIMEOUT = 150; // milliseconds
const POPUP_SCROLL_TIME = 100; // milliseconds
const POPUP_FADE_OUT_TIME = 100; // milliseconds
const DISABLE_HOVER_TIMEOUT = 500; // milliseconds
const NO_MODS_TIMEOUT = 1500; // milliseconds
/**
* @param {number} a
* @param {number} b
* @returns {number}
*/
export 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;
}
export const 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(event) {
let keysym = event.get_key_symbol();
let action = global.display.get_keybinding_action(
event.get_key_code(), event.get_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(event.get_time());
return Clutter.EVENT_STOP;
}
vfunc_key_release_event(event) {
if (this._modifierMask) {
let [x_, y_, mods] = global.get_pointer();
let state = mods & this._modifierMask;
if (state == 0)
this._finish(event.get_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(event) {
this._disableHover();
this._scrollHandler(event.get_scroll_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);
}
});
const 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);
}
});
export const 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;
}
});
/**
* @param {St.DrawingArrow} area
* @param {St.Side} side
*/
export 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();
}