// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- const Clutter = imports.gi.Clutter; const Gtk = imports.gi.Gtk; const Meta = imports.gi.Meta; const Shell = imports.gi.Shell; const Signals = imports.signals; const St = imports.gi.St; const Lang = imports.lang; const Params = imports.misc.params; const Tweener = imports.ui.tweener; const ICON_SIZE = 96; const MIN_ICON_SIZE = 16; const EXTRA_SPACE_ANIMATION_TIME = 0.25; const BaseIcon = new Lang.Class({ Name: 'BaseIcon', _init : function(label, params) { params = Params.parse(params, { createIcon: null, setSizeManually: false, showLabel: true }); let styleClass = 'overview-icon'; if (params.showLabel) styleClass += ' overview-icon-with-label'; this.actor = new St.Bin({ style_class: styleClass, x_fill: true, y_fill: true }); this.actor._delegate = this; this.actor.connect('style-changed', Lang.bind(this, this._onStyleChanged)); this.actor.connect('destroy', Lang.bind(this, this._onDestroy)); this._spacing = 0; let box = new Shell.GenericContainer(); box.connect('allocate', Lang.bind(this, this._allocate)); box.connect('get-preferred-width', Lang.bind(this, this._getPreferredWidth)); box.connect('get-preferred-height', Lang.bind(this, this._getPreferredHeight)); this.actor.set_child(box); this.iconSize = ICON_SIZE; this._iconBin = new St.Bin({ x_align: St.Align.MIDDLE, y_align: St.Align.MIDDLE }); box.add_actor(this._iconBin); if (params.showLabel) { this.label = new St.Label({ text: label }); box.add_actor(this.label); } else { this.label = null; } if (params.createIcon) this.createIcon = params.createIcon; this._setSizeManually = params.setSizeManually; this.icon = null; let cache = St.TextureCache.get_default(); this._iconThemeChangedId = cache.connect('icon-theme-changed', Lang.bind(this, this._onIconThemeChanged)); }, _allocate: function(actor, box, flags) { let availWidth = box.x2 - box.x1; let availHeight = box.y2 - box.y1; let iconSize = availHeight; let [iconMinHeight, iconNatHeight] = this._iconBin.get_preferred_height(-1); let [iconMinWidth, iconNatWidth] = this._iconBin.get_preferred_width(-1); let preferredHeight = iconNatHeight; let childBox = new Clutter.ActorBox(); if (this.label) { let [labelMinHeight, labelNatHeight] = this.label.get_preferred_height(-1); preferredHeight += this._spacing + labelNatHeight; let labelHeight = availHeight >= preferredHeight ? labelNatHeight : labelMinHeight; iconSize -= this._spacing + labelHeight; childBox.x1 = 0; childBox.x2 = availWidth; childBox.y1 = iconSize + this._spacing; childBox.y2 = childBox.y1 + labelHeight; this.label.allocate(childBox, flags); } childBox.x1 = Math.floor((availWidth - iconNatWidth) / 2); childBox.y1 = Math.floor((iconSize - iconNatHeight) / 2); childBox.x2 = childBox.x1 + iconNatWidth; childBox.y2 = childBox.y1 + iconNatHeight; this._iconBin.allocate(childBox, flags); }, _getPreferredWidth: function(actor, forHeight, alloc) { this._getPreferredHeight(actor, -1, alloc); }, _getPreferredHeight: function(actor, forWidth, alloc) { let [iconMinHeight, iconNatHeight] = this._iconBin.get_preferred_height(forWidth); alloc.min_size = iconMinHeight; alloc.natural_size = iconNatHeight; if (this.label) { let [labelMinHeight, labelNatHeight] = this.label.get_preferred_height(forWidth); alloc.min_size += this._spacing + labelMinHeight; alloc.natural_size += this._spacing + labelNatHeight; } }, // This can be overridden by a subclass, or by the createIcon // parameter to _init() createIcon: function(size) { throw new Error('no implementation of createIcon in ' + this); }, setIconSize: function(size) { if (!this._setSizeManually) throw new Error('setSizeManually has to be set to use setIconsize'); if (size == this.iconSize) return; this._createIconTexture(size); }, _createIconTexture: function(size) { if (this.icon) this.icon.destroy(); this.iconSize = size; this.icon = this.createIcon(this.iconSize); this._iconBin.child = this.icon; }, _onStyleChanged: function() { let node = this.actor.get_theme_node(); this._spacing = node.get_length('spacing'); let size; if (this._setSizeManually) { size = this.iconSize; } else { let [found, len] = node.lookup_length('icon-size', false); size = found ? len : ICON_SIZE; } if (this.iconSize == size && this._iconBin.child) return; this._createIconTexture(size); }, _onDestroy: function() { if (this._iconThemeChangedId > 0) { let cache = St.TextureCache.get_default(); cache.disconnect(this._iconThemeChangedId); this._iconThemeChangedId = 0; } }, _onIconThemeChanged: function() { this._createIconTexture(this.iconSize); } }); const IconGrid = new Lang.Class({ Name: 'IconGrid', _init: function(params) { params = Params.parse(params, { rowLimit: null, columnLimit: null, minRows: 1, minColumns: 1, fillParent: false, xAlign: St.Align.MIDDLE, padWithSpacing: false }); this._rowLimit = params.rowLimit; this._colLimit = params.columnLimit; this._minRows = params.minRows; this._minColumns = params.minColumns; this._xAlign = params.xAlign; this._fillParent = params.fillParent; this._padWithSpacing = params.padWithSpacing; this.topPadding = 0; this.bottomPadding = 0; this.rightPadding = 0; this.leftPadding = 0; this.actor = new St.BoxLayout({ style_class: 'icon-grid', vertical: true }); this._items = []; // Pulled from CSS, but hardcode some defaults here this._spacing = 0; this._hItemSize = this._vItemSize = ICON_SIZE; this._fixedHItemSize = this._fixedVItemSize = undefined; this._grid = new Shell.GenericContainer(); this.actor.add(this._grid, { expand: true, y_align: St.Align.START }); this.actor.connect('style-changed', Lang.bind(this, this._onStyleChanged)); this._grid.connect('get-preferred-width', Lang.bind(this, this._getPreferredWidth)); this._grid.connect('get-preferred-height', Lang.bind(this, this._getPreferredHeight)); this._grid.connect('allocate', Lang.bind(this, this._allocate)); this._grid.connect('actor-added', Lang.bind(this, this._childAdded)); this._grid.connect('actor-removed', Lang.bind(this, this._childRemoved)); }, _keyFocusIn: function(actor) { this.emit('key-focus-in', actor); }, _childAdded: function(grid, child) { child._iconGridKeyFocusInId = child.connect('key-focus-in', Lang.bind(this, this._keyFocusIn)); }, _childRemoved: function(grid, child) { child.disconnect(child._iconGridKeyFocusInId); }, _getPreferredWidth: function (grid, forHeight, alloc) { if (this._fillParent) // Ignore all size requests of children and request a size of 0; // later we'll allocate as many children as fit the parent return; let nChildren = this._grid.get_n_children(); let nColumns = this._colLimit ? Math.min(this._colLimit, nChildren) : nChildren; let totalSpacing = Math.max(0, nColumns - 1) * this._getSpacing(); // Kind of a lie, but not really an issue right now. If // we wanted to support some sort of hidden/overflow that would // need higher level design alloc.min_size = this._getHItemSize() + this.leftPadding + this.rightPadding; alloc.natural_size = nColumns * this._getHItemSize() + totalSpacing + this.leftPadding + this.rightPadding; }, _getVisibleChildren: function() { let children = this._grid.get_children(); children = children.filter(function(actor) { return actor.visible; }); return children; }, _getPreferredHeight: function (grid, forWidth, alloc) { if (this._fillParent) // Ignore all size requests of children and request a size of 0; // later we'll allocate as many children as fit the parent return; let children = this._getVisibleChildren(); let nColumns; if (forWidth < 0) nColumns = children.length; else [nColumns, ] = this._computeLayout(forWidth); let nRows; if (nColumns > 0) nRows = Math.ceil(children.length / nColumns); else nRows = 0; if (this._rowLimit) nRows = Math.min(nRows, this._rowLimit); let totalSpacing = Math.max(0, nRows - 1) * this._getSpacing(); let height = nRows * this._getVItemSize() + totalSpacing + this.topPadding + this.bottomPadding; alloc.min_size = height; alloc.natural_size = height; }, _allocate: function (grid, box, flags) { if (this._fillParent) { // Reset the passed in box to fill the parent let parentBox = this.actor.get_parent().allocation; let gridBox = this.actor.get_theme_node().get_content_box(parentBox); box = this._grid.get_theme_node().get_content_box(gridBox); } let children = this._getVisibleChildren(); let availWidth = box.x2 - box.x1; let availHeight = box.y2 - box.y1; let spacing = this._getSpacing(); let [nColumns, usedWidth] = this._computeLayout(availWidth); let leftEmptySpace; switch(this._xAlign) { case St.Align.START: leftEmptySpace = 0; break; case St.Align.MIDDLE: leftEmptySpace = Math.floor((availWidth - usedWidth) / 2); break; case St.Align.END: leftEmptySpace = availWidth - usedWidth; } let x = box.x1 + leftEmptySpace + this.leftPadding; let y = box.y1 + this.topPadding; let columnIndex = 0; let rowIndex = 0; for (let i = 0; i < children.length; i++) { let childBox = this._calculateChildBox(children[i], x, y, box); if (this._rowLimit && rowIndex >= this._rowLimit || this._fillParent && childBox.y2 > availHeight - this.bottomPadding) { this._grid.set_skip_paint(children[i], true); } else { children[i].allocate(childBox, flags); this._grid.set_skip_paint(children[i], false); } columnIndex++; if (columnIndex == nColumns) { columnIndex = 0; rowIndex++; } if (columnIndex == 0) { y += this._getVItemSize() + spacing; x = box.x1 + leftEmptySpace + this.leftPadding; } else { x += this._getHItemSize() + spacing; } } }, _calculateChildBox: function(child, x, y, box) { let [childMinWidth, childMinHeight, childNaturalWidth, childNaturalHeight] = child.get_preferred_size(); /* Center the item in its allocation horizontally */ let width = Math.min(this._getHItemSize(), childNaturalWidth); let childXSpacing = Math.max(0, width - childNaturalWidth) / 2; let height = Math.min(this._getVItemSize(), childNaturalHeight); let childYSpacing = Math.max(0, height - childNaturalHeight) / 2; let childBox = new Clutter.ActorBox(); if (Clutter.get_default_text_direction() == Clutter.TextDirection.RTL) { let _x = box.x2 - (x + width); childBox.x1 = Math.floor(_x - childXSpacing); } else { childBox.x1 = Math.floor(x + childXSpacing); } childBox.y1 = Math.floor(y + childYSpacing); childBox.x2 = childBox.x1 + width; childBox.y2 = childBox.y1 + height; return childBox; }, columnsForWidth: function(rowWidth) { return this._computeLayout(rowWidth)[0]; }, getRowLimit: function() { return this._rowLimit; }, _computeLayout: function (forWidth) { let nColumns = 0; let usedWidth = this.leftPadding + this.rightPadding; let spacing = this._getSpacing(); while ((this._colLimit == null || nColumns < this._colLimit) && (usedWidth + this._getHItemSize() <= forWidth)) { usedWidth += this._getHItemSize() + spacing; nColumns += 1; } if (nColumns > 0) usedWidth -= spacing; return [nColumns, usedWidth]; }, _onStyleChanged: function() { let themeNode = this.actor.get_theme_node(); this._spacing = themeNode.get_length('spacing'); this._hItemSize = themeNode.get_length('-shell-grid-horizontal-item-size') || ICON_SIZE; this._vItemSize = themeNode.get_length('-shell-grid-vertical-item-size') || ICON_SIZE; this._grid.queue_relayout(); }, nRows: function(forWidth) { let children = this._getVisibleChildren(); let nColumns = (forWidth < 0) ? children.length : this._computeLayout(forWidth)[0]; let nRows = (nColumns > 0) ? Math.ceil(children.length / nColumns) : 0; if (this._rowLimit) nRows = Math.min(nRows, this._rowLimit); return nRows; }, rowsForHeight: function(forHeight) { return Math.floor((forHeight - (this.topPadding + this.bottomPadding) + this._getSpacing()) / (this._getVItemSize() + this._getSpacing())); }, usedHeightForNRows: function(nRows) { return (this._getVItemSize() + this._getSpacing()) * nRows - this._getSpacing() + this.topPadding + this.bottomPadding; }, usedWidth: function(forWidth) { return this.usedWidthForNColumns(this.columnsForWidth(forWidth)); }, usedWidthForNColumns: function(columns) { let usedWidth = columns * (this._getHItemSize() + this._getSpacing()); usedWidth -= this._getSpacing(); return usedWidth + this.leftPadding + this.rightPadding; }, removeAll: function() { this._items = []; this._grid.remove_all_children(); }, destroyAll: function() { this._items = []; this._grid.destroy_all_children(); }, addItem: function(item, index) { if (!item.icon instanceof BaseIcon) throw new Error('Only items with a BaseIcon icon property can be added to IconGrid'); this._items.push(item); if (index !== undefined) this._grid.insert_child_at_index(item.actor, index); else this._grid.add_actor(item.actor); }, removeItem: function(item) { this._grid.remove_child(item.actor); }, getItemAtIndex: function(index) { return this._grid.get_child_at_index(index); }, visibleItemsCount: function() { return this._grid.get_n_children() - this._grid.get_n_skip_paint(); }, setSpacing: function(spacing) { this._fixedSpacing = spacing; }, _getSpacing: function() { return this._fixedSpacing ? this._fixedSpacing : this._spacing; }, _getHItemSize: function() { return this._fixedHItemSize ? this._fixedHItemSize : this._hItemSize; }, _getVItemSize: function() { return this._fixedVItemSize ? this._fixedVItemSize : this._vItemSize; }, _updateSpacingForSize: function(availWidth, availHeight) { let maxEmptyVArea = availHeight - this._minRows * this._getVItemSize(); let maxEmptyHArea = availWidth - this._minColumns * this._getHItemSize(); let maxHSpacing, maxVSpacing; if (this._padWithSpacing) { // minRows + 1 because we want to put spacing before the first row, so it is like we have one more row // to divide the empty space maxVSpacing = Math.floor(maxEmptyVArea / (this._minRows +1)); maxHSpacing = Math.floor(maxEmptyHArea / (this._minColumns +1)); } else { if (this._minRows <= 1) maxVSpacing = maxEmptyVArea; else maxVSpacing = Math.floor(maxEmptyVArea / (this._minRows - 1)); if (this._minColumns <= 1) maxHSpacing = maxEmptyHArea; else maxHSpacing = Math.floor(maxEmptyHArea / (this._minColumns - 1)); } let maxSpacing = Math.min(maxHSpacing, maxVSpacing); // Limit spacing to the item size maxSpacing = Math.min(maxSpacing, Math.min(this._getVItemSize(), this._getHItemSize())); // The minimum spacing, regardless of whether it satisfies the row/columng minima, // is the spacing we get from CSS. let spacing = Math.max(this._spacing, maxSpacing); this.setSpacing(spacing); if (this._padWithSpacing) this.topPadding = this.rightPadding = this.bottomPadding = this.leftPadding = spacing; }, /** * This function must to be called before iconGrid allocation, * to know how much spacing can the grid has */ adaptToSize: function(availWidth, availHeight) { this._fixedHItemSize = this._hItemSize; this._fixedVItemSize = this._vItemSize; this._updateSpacingForSize(availWidth, availHeight); let spacing = this._getSpacing(); if (this.columnsForWidth(availWidth) < this._minColumns || this.rowsForHeight(availHeight) < this._minRows) { let neededWidth = this.usedWidthForNColumns(this._minColumns) - availWidth ; let neededHeight = this.usedHeightForNRows(this._minRows) - availHeight ; let neededSpacePerItem = (neededWidth > neededHeight) ? Math.ceil(neededWidth / this._minColumns) : Math.ceil(neededHeight / this._minRows); this._fixedHItemSize = Math.max(this._hItemSize - neededSpacePerItem, MIN_ICON_SIZE); this._fixedVItemSize = Math.max(this._vItemSize - neededSpacePerItem, MIN_ICON_SIZE); this._updateSpacingForSize(availWidth, availHeight); } Meta.later_add(Meta.LaterType.BEFORE_REDRAW, Lang.bind(this, this._updateIconSizes)); }, // Note that this is ICON_SIZE as used by BaseIcon, not elsewhere in IconGrid; it's a bit messed up _updateIconSizes: function() { let scale = Math.min(this._fixedHItemSize, this._fixedVItemSize) / Math.max(this._hItemSize, this._vItemSize); let newIconSize = Math.floor(ICON_SIZE * scale); for (let i in this._items) { this._items[i].icon.setIconSize(newIconSize); } } }); Signals.addSignalMethods(IconGrid.prototype); const PaginatedIconGrid = new Lang.Class({ Name: 'PaginatedIconGrid', Extends: IconGrid, _init: function(params) { this.parent(params); this._nPages = 0; this._rowsPerPage = 0; this._spaceBetweenPages = 0; this._childrenPerPage = 0; }, _getPreferredHeight: function (grid, forWidth, alloc) { alloc.min_size = (this._availableHeightPerPageForItems() + this.bottomPadding + this.topPadding) * this._nPages + this._spaceBetweenPages * this._nPages; alloc.natural_size = (this._availableHeightPerPageForItems() + this.bottomPadding + this.topPadding) * this._nPages + this._spaceBetweenPages * this._nPages; }, _allocate: function (grid, box, flags) { if (this._childrenPerPage == 0) log('computePages() must be called before allocate(); pagination will not work.'); if (this._fillParent) { // Reset the passed in box to fill the parent let parentBox = this.actor.get_parent().allocation; let gridBox = this.actor.get_theme_node().get_content_box(parentBox); box = this._grid.get_theme_node().get_content_box(gridBox); } let children = this._getVisibleChildren(); let availWidth = box.x2 - box.x1; let availHeight = box.y2 - box.y1; let spacing = this._getSpacing(); let [nColumns, usedWidth] = this._computeLayout(availWidth); let leftEmptySpace; switch(this._xAlign) { case St.Align.START: leftEmptySpace = 0; break; case St.Align.MIDDLE: leftEmptySpace = Math.floor((availWidth - usedWidth) / 2); break; case St.Align.END: leftEmptySpace = availWidth - usedWidth; } let x = box.x1 + leftEmptySpace + this.leftPadding; let y = box.y1 + this.topPadding; let columnIndex = 0; let rowIndex = 0; for (let i = 0; i < children.length; i++) { let childBox = this._calculateChildBox(children[i], x, y, box); children[i].allocate(childBox, flags); this._grid.set_skip_paint(children[i], false); columnIndex++; if (columnIndex == nColumns) { columnIndex = 0; rowIndex++; } if (columnIndex == 0) { y += this._getVItemSize() + spacing; if ((i + 1) % this._childrenPerPage == 0) y += this._spaceBetweenPages - spacing + this.bottomPadding + this.topPadding; x = box.x1 + leftEmptySpace + this.leftPadding; } else x += this._getHItemSize() + spacing; } }, _computePages: function (availWidthPerPage, availHeightPerPage) { let [nColumns, usedWidth] = this._computeLayout(availWidthPerPage); let nRows; let children = this._getVisibleChildren(); if (nColumns > 0) nRows = Math.ceil(children.length / nColumns); else nRows = 0; if (this._rowLimit) nRows = Math.min(nRows, this._rowLimit); let spacing = this._getSpacing(); // We want to contain the grid inside the parent box with padding this._rowsPerPage = this.rowsForHeight(availHeightPerPage); this._nPages = Math.ceil(nRows / this._rowsPerPage); this._spaceBetweenPages = availHeightPerPage - (this.topPadding + this.bottomPadding) - this._availableHeightPerPageForItems(); this._childrenPerPage = nColumns * this._rowsPerPage; }, adaptToSize: function(availWidth, availHeight) { this.parent(availWidth, availHeight); this._computePages(availWidth, availHeight); }, _availableHeightPerPageForItems: function() { return this.usedHeightForNRows(this._rowsPerPage) - (this.topPadding + this.bottomPadding); }, nPages: function() { return this._nPages; }, getPageHeight: function() { return this._availableHeightPerPageForItems(); }, getPageY: function(pageNumber) { if (!this._nPages) return 0; let firstPageItem = pageNumber * this._childrenPerPage let childBox = this._getVisibleChildren()[firstPageItem].get_allocation_box(); return childBox.y1 - this.topPadding; }, getItemPage: function(item) { let children = this._getVisibleChildren(); let index = children.indexOf(item); if (index == -1) { throw new Error('Item not found.'); return 0; } return Math.floor(index / this._childrenPerPage); }, /** * openExtraSpace: * @sourceItem: the item for which to create extra space * @side: where @sourceItem should be located relative to the created space * @nRows: the amount of space to create * * Pan view to create extra space for @nRows above or below @sourceItem. */ openExtraSpace: function(sourceItem, side, nRows) { let children = this._getVisibleChildren(); let index = children.indexOf(sourceItem.actor); if (index == -1) { throw new Error('Item not found.'); return; } let pageIndex = Math.floor(index / this._childrenPerPage); let pageOffset = pageIndex * this._childrenPerPage; let childrenPerRow = this._childrenPerPage / this._rowsPerPage; let sourceRow = Math.floor((index - pageOffset) / childrenPerRow); let nRowsAbove = (side == St.Side.TOP) ? sourceRow + 1 : sourceRow; let nRowsBelow = this._rowsPerPage - nRowsAbove; let nRowsUp, nRowsDown; if (side == St.Side.TOP) { nRowsDown = Math.min(nRowsBelow, nRows); nRowsUp = nRows - nRowsDown; } else { nRowsUp = Math.min(nRowsAbove, nRows); nRowsDown = nRows - nRowsUp; } let childrenDown = children.splice(pageOffset + nRowsAbove * childrenPerRow, nRowsBelow * childrenPerRow); let childrenUp = children.splice(pageOffset, nRowsAbove * childrenPerRow); // Special case: On the last row with no rows below the icon, // there's no need to move any rows either up or down if (childrenDown.length == 0 && nRowsUp == 0) { this._translatedChildren = []; this.emit('space-opened'); } else { this._translateChildren(childrenUp, Gtk.DirectionType.UP, nRowsUp); this._translateChildren(childrenDown, Gtk.DirectionType.DOWN, nRowsDown); this._translatedChildren = childrenUp.concat(childrenDown); } }, _translateChildren: function(children, direction, nRows) { let translationY = nRows * (this._getVItemSize() + this._getSpacing()); if (translationY == 0) return; if (direction == Gtk.DirectionType.UP) translationY *= -1; for (let i = 0; i < children.length; i++) { children[i].translation_y = 0; let params = { translation_y: translationY, time: EXTRA_SPACE_ANIMATION_TIME, transition: 'easeInOutQuad' }; if (i == (children.length - 1)) params.onComplete = Lang.bind(this, function() { this.emit('space-opened'); }); Tweener.addTween(children[i], params); } }, closeExtraSpace: function() { if (!this._translatedChildren || !this._translatedChildren.length) { this.emit('space-closed'); return; } for (let i = 0; i < this._translatedChildren.length; i++) { if (!this._translatedChildren[i].translation_y) continue; Tweener.addTween(this._translatedChildren[i], { translation_y: 0, time: EXTRA_SPACE_ANIMATION_TIME, transition: 'easeInOutQuad', onComplete: Lang.bind(this, function() { this.emit('space-closed'); }) }); } } }); Signals.addSignalMethods(PaginatedIconGrid.prototype);