gnome-shell/js/ui/iconGrid.js

1474 lines
45 KiB
JavaScript
Raw Normal View History

// -*- 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 Meta from 'gi://Meta';
import Shell from 'gi://Shell';
import St from 'gi://St';
import * as Params from '../misc/params.js';
import * as Main from './main.js';
const ICON_SIZE = 96;
const PAGE_SWITCH_TIME = 300;
/** @enum {number} */
const IconSize = {
LARGE: 96,
MEDIUM: 64,
MEDIUM_SMALL: 48,
SMALL: 32,
SMALLER: 24,
TINY: 16,
};
const APPICON_ANIMATION_OUT_SCALE = 3;
const 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,
},
];
const LEFT_DIVIDER_LEEWAY = 20;
const RIGHT_DIVIDER_LEEWAY = 20;
/** @enum {number} */
export const DragLocation = {
INVALID: 0,
START_EDGE: 1,
ON_ICON: 2,
END_EDGE: 3,
EMPTY_SPACE: 4,
};
export const 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);
}
});
/**
* @param {Clutter.Actor} actor
*/
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;
}
export const 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) {
const laters = global.compositor.get_laters();
laters.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;
}
_findBestPageToAppend(startPage) {
const itemsPerPage = this.columnsPerPage * this.rowsPerPage;
for (let i = startPage; i < this._pages.length; i++) {
const visibleItems = this._pages[i].visibleChildren;
if (visibleItems.length < itemsPerPage)
return i;
}
return this._pages.length;
}
/**
* addItem:
*
* @param {Clutter.Actor} item item to append to the grid
* @param {number} page page number
* @param {number} 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;
if (page !== -1 && index === -1)
page = this._findBestPageToAppend(page);
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 {number} newPage new page of the item
* @param {number} 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);
if (newPage !== -1 && newPosition === -1)
newPage = this._findBestPageToAppend(newPage);
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 {number} 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:
*
* Retrieves the position of `item` is its page, or -1 if `item` is not
* part of the grid.
*
* @param {BaseIcon} item the item
* @returns {[number, number]} 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:
*
* Retrieves the item at @page and @position.
*
* @param {number} page the page
* @param {number} position the position in page
*
* @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:
*
* Retrieves the page `item` is in, or -1 if `item` is not part of the grid.
*
* @param {BaseIcon} item the item
*
* @returns {number} 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) {
const laters = global.compositor.get_laters();
this._updateIconSizesLaterId =
laters.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:
*
* Retrieves the item located at (`x`, `y`), as well as the drag location.
* Both `x` and `y` are relative to the grid.
*
* @param {number} x position of the horizontal axis
* @param {number} y position of the vertical axis
*
* @returns {[BaseIcon | null, 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 [0, 0, DragLocation.INVALID];
if (isRtl && this._orientation === Clutter.Orientation.HORIZONTAL)
page = swap(page, this._pages.length);
// Get page-relative coordinates
let adjX = x;
let adjY = y;
if (this._orientation === Clutter.Orientation.HORIZONTAL)
adjX %= this._pageWidth;
else
adjY %= this._pageHeight;
const gridWidth =
childSize * this.columnsPerPage +
hSpacing * (this.columnsPerPage - 1);
const gridHeight =
childSize * this.rowsPerPage +
vSpacing * (this.rowsPerPage - 1);
const inTopEmptySpace = adjY < topEmptySpace;
const inLeftEmptySpace = adjX < leftEmptySpace;
const inRightEmptySpace = adjX > leftEmptySpace + gridWidth;
const inBottomEmptySpace = adjY > topEmptySpace + gridHeight;
if (inTopEmptySpace || inBottomEmptySpace)
return [0, 0, DragLocation.INVALID];
const halfHSpacing = hSpacing / 2;
const halfVSpacing = vSpacing / 2;
const visibleItems = this._pages[page].visibleChildren;
for (let i = 0; i < visibleItems.length; i++) {
const item = visibleItems[i];
const childBox = item.allocation;
const firstInRow = i % this.columnsPerPage === 0;
const lastInRow = i % this.columnsPerPage === this.columnsPerPage - 1;
// Check icon boundaries
if ((inLeftEmptySpace && firstInRow) ||
(inRightEmptySpace && lastInRow)) {
if (y < childBox.y1 - halfVSpacing ||
y > childBox.y2 + halfVSpacing)
continue;
} else {
// eslint-disable-next-line no-lonely-if
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 [page, i, dragLocation];
}
return [page, -1, 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;
}
});
export const 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 {BaseItem} item item to append to the grid
* @param {number} page page number
* @param {number} 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:
*
* Moves `item` to the grid. `item` must be part of the grid.
*
* @param {Clutter.Actor} item item to move
* @param {number} newPage new page of the item
* @param {number} newPosition new page of the item
*/
moveItem(item, newPage, newPosition) {
this.layout_manager.moveItem(item, newPage, newPosition);
this.queue_relayout();
}
/**
* removeItem:
*
* Removes `item` to the grid. `item` must be part of the grid.
*
* @param {Clutter.Actor} item item to remove from 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:
*
* Moves the current page to `pageIndex`. `pageIndex` must be a valid page
* number.
*
* @param {number} pageIndex page index
* @param {boolean} animate animate the page transition
*/
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;
global.compositor.get_laters().add(
Meta.LaterType.BEFORE_REDRAW, () => {
adjustment.ease(newValue, {
mode: Clutter.AnimationMode.EASE_OUT_CUBIC,
duration: animate ? PAGE_SWITCH_TIME : 0,
});
return GLib.SOURCE_REMOVE;
});
}
/**
* getItemPage:
*
* Retrieves the page `item` is in, or -1 if `item` is not part of the grid.
*
* @param {BaseIcon} item the item
*
* @returns {number} the page where `item` is in
*/
getItemPage(item) {
return this.layout_manager.getItemPage(item);
}
/**
* getItemPosition:
*
* Retrieves the position of `item` is its page, or -1 if `item` is not
* part of the grid.
*
* @param {BaseIcon} item the item
*
* @returns {[number, number]} 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:
*
* Retrieves the item at `page` and `position`.
*
* @param {number} page the page
* @param {number} position the position in page
*
* @returns {BaseItem} the item at `page` and `position`, or null
*/
getItemAt(page, position) {
const layoutManager = this.layout_manager;
return layoutManager.getItemAt(page, position);
}
/**
* getItemsAtPage:
*
* Retrieves the children at page `page`, including invisible children.
*
* @param {number} page the page index
*
* @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;
}
});