/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */ const Clutter = imports.gi.Clutter; const Gio = imports.gi.Gio; const Gtk = imports.gi.Gtk; const Lang = imports.lang; const Pango = imports.gi.Pango; const Shell = imports.gi.Shell; const Signals = imports.signals; const St = imports.gi.St; const Mainloop = imports.mainloop; const Gettext = imports.gettext.domain('gnome-shell'); const _ = Gettext.gettext; const DocInfo = imports.misc.docInfo; const DND = imports.ui.dnd; const GenericDisplay = imports.ui.genericDisplay; const Main = imports.ui.main; const Search = imports.ui.search; const MAX_DASH_DOCS = 50; const DASH_DOCS_ICON_SIZE = 16; const DEFAULT_SPACING = 4; /* This class represents a single display item containing information about a document. * We take the current number of seconds in the constructor to avoid looking up the current * time for every item when they are created in a batch. * * docInfo - DocInfo object containing information about the document * currentSeconds - current number of seconds since the epoch */ function DocDisplayItem(docInfo, currentSecs) { this._init(docInfo, currentSecs); } DocDisplayItem.prototype = { __proto__: GenericDisplay.GenericDisplayItem.prototype, _init : function(docInfo, currentSecs) { GenericDisplay.GenericDisplayItem.prototype._init.call(this); this._docInfo = docInfo; this._setItemInfo(docInfo.name, ''); this._timeoutTime = -1; this._resetTimeDisplay(currentSecs); }, //// Public methods //// getUpdateTimeoutTime: function() { return this._timeoutTime; }, // Update any relative-time based displays for this item. redisplay: function(currentSecs) { this._resetTimeDisplay(currentSecs); }, //// Public method overrides //// // Opens a document represented by this display item. launch : function() { this._docInfo.launch(); }, //// Protected method overrides //// // Returns an icon for the item. _createIcon : function() { return this._docInfo.createIcon(GenericDisplay.ITEM_DISPLAY_ICON_SIZE); }, // Returns a preview icon for the item. _createPreviewIcon : function() { return this._docInfo.createIcon(GenericDisplay.PREVIEW_ICON_SIZE); }, // Creates and returns a large preview icon, but only if this._docInfo is an image file // and we were able to generate a pixbuf from it successfully. _createLargePreviewIcon : function() { if (this._docInfo.mimeType == null || this._docInfo.mimeType.indexOf('image/') != 0) return null; try { return St.TextureCache.get_default().load_uri_sync(St.TextureCachePolicy.NONE, this._docInfo.uri, -1, -1); } catch (e) { // An exception will be raised when the image format isn't know /* FIXME: http://bugzilla.gnome.org/show_bug.cgi?id=591480: should * only ignore GDK_PIXBUF_ERROR_UNKNOWN_TYPE. */ return null; } }, //// Drag and Drop //// shellWorkspaceLaunch: function() { this.launch(); }, //// Private Methods //// // Updates the last visited time displayed in the description text for the item. _resetTimeDisplay: function(currentSecs) { let lastSecs = this._docInfo.timestamp; let timeDelta = currentSecs - lastSecs; let [text, nextUpdate] = global.format_time_relative_pretty(timeDelta); this._timeoutTime = currentSecs + nextUpdate; this._setDescriptionText(text); } }; /* This class represents a display containing a collection of document items. * The documents are sorted by how recently they were last visited. */ function DocDisplay(flags) { this._init(flags); } DocDisplay.prototype = { __proto__: GenericDisplay.GenericDisplay.prototype, _init : function(flags) { GenericDisplay.GenericDisplay.prototype._init.call(this, flags); // We keep a single timeout callback for updating last visited times // for all the items in the display. This avoids creating individual // callbacks for each item in the display. So proper time updates // for individual items and item details depend on the item being // associated with one of the displays. this._updateTimeoutTargetTime = -1; this._updateTimeoutId = 0; this._docManager = DocInfo.getDocManager(); this._docsStale = true; this._docManager.connect('changed', Lang.bind(this, function(mgr, userData) { this._docsStale = true; // Changes in local recent files should not happen when we are in the Overview mode, // but redisplaying right away is cool when we use Zephyr. // Also, we might be displaying remote documents, like Google Docs, in the future // which might be edited by someone else. this._redisplay(GenericDisplay.RedisplayFlags.NONE); })); this.connect('destroy', Lang.bind(this, function (o) { if (this._updateTimeoutId > 0) Mainloop.source_remove(this._updateTimeoutId); })); }, //// Protected method overrides //// // Gets the list of recent items from the recent items manager. _refreshCache : function() { if (!this._docsStale) return true; this._allItems = {}; Lang.copyProperties(this._docManager.getInfosByUri(), this._allItems); this._docsStale = false; return false; }, // Sets the list of the displayed items based on how recently they were last visited. _setDefaultList : function() { // It seems to be an implementation detail of the Mozilla JavaScript that object // properties are returned during the iteration in the same order in which they were // defined, but it is not a guarantee according to this // https://developer.mozilla.org/en/Core_JavaScript_1.5_Reference/Statements/for...in // While this._allItems associative array seems to always be ordered by last added, // as the results of this._recentManager.get_items() based on which it is constructed are, // we should do the sorting manually because we want the order to be based on last visited. // // This function is called each time the search string is set back to '' or we display // the Overview, so we are doing the sorting over the same items multiple times if the list // of recent items didn't change. We could store an additional array of doc ids and sort // them once when they are returned by this._recentManager.get_items() to avoid having to do // this sorting each time, but the sorting seems to be very fast anyway, so there is no need // to introduce an additional class variable. this._matchedItems = {}; this._matchedItemKeys = []; let docIdsToRemove = []; for (docId in this._allItems) { this._matchedItems[docId] = 1; this._matchedItemKeys.push(docId); } for (docId in docIdsToRemove) { delete this._allItems[docId]; } this._matchedItemKeys.sort(Lang.bind(this, this._compareItems)); }, // Compares items associated with the item ids based on how recently the items // were last visited. // Returns an integer value indicating the result of the comparison. _compareItems : function(itemIdA, itemIdB) { let docA = this._allItems[itemIdA]; let docB = this._allItems[itemIdB]; return docB.timestamp - docA.timestamp; }, // Checks if the item info can be a match for the search string by checking // the name of the document. Item info is expected to be GtkRecentInfo. // Returns a boolean flag indicating if itemInfo is a match. _isInfoMatching : function(itemInfo, search) { if (!itemInfo.exists()) return false; if (search == null || search == '') return true; let name = itemInfo.name.toLowerCase(); if (name.indexOf(search) >= 0) return true; // TODO: we can also check doc URIs, so that // if you search for a directory name, we display recent files from it return false; }, // Creates a DocDisplayItem based on itemInfo, which is expected to be a DocInfo object. _createDisplayItem: function(itemInfo) { let currentSecs = new Date().getTime() / 1000; let docDisplayItem = new DocDisplayItem(itemInfo, currentSecs); this._updateTimeoutCallback(docDisplayItem, currentSecs); return docDisplayItem; }, //// Private Methods //// // A callback function that redisplays the items, updating their descriptions, // and sets up a new timeout callback. _docTimeout: function () { let currentSecs = new Date().getTime() / 1000; this._updateTimeoutId = 0; this._updateTimeoutTargetTime = -1; for (let docId in this._displayedItems) { let docDisplayItem = this._displayedItems[docId]; docDisplayItem.redisplay(currentSecs); this._updateTimeoutCallback(docDisplayItem, currentSecs); } return false; }, // Updates the timeout callback if the timeout time for the docDisplayItem // is earlier than the target time for the current timeout callback. _updateTimeoutCallback: function (docDisplayItem, currentSecs) { let timeoutTime = docDisplayItem.getUpdateTimeoutTime(); if (this._updateTimeoutTargetTime < 0 || timeoutTime < this._updateTimeoutTargetTime) { if (this._updateTimeoutId > 0) Mainloop.source_remove(this._updateTimeoutId); this._updateTimeoutId = Mainloop.timeout_add_seconds(timeoutTime - currentSecs, Lang.bind(this, this._docTimeout)); this._updateTimeoutTargetTime = timeoutTime; } } }; Signals.addSignalMethods(DocDisplay.prototype); function DashDocDisplayItem(docInfo) { this._init(docInfo); } DashDocDisplayItem.prototype = { _init: function(docInfo) { this._info = docInfo; this._icon = docInfo.createIcon(DASH_DOCS_ICON_SIZE); this.actor = new St.Clickable({ style_class: 'recent-docs-item', reactive: true, x_align: St.Align.START }); let box = new St.BoxLayout({ style_class: 'recent-docs-item-box' }); this.actor.set_child(box); box.add(this._icon); let text = new St.Label({ text: docInfo.name }); box.add(text); this.actor.connect('clicked', Lang.bind(this, function () { docInfo.launch(); Main.overview.hide(); })); this.actor._delegate = this; let draggable = DND.makeDraggable(this.actor); }, getUri: function() { return this._info.uri; }, getDragActorSource: function() { return this._icon; }, getDragActor: function(stageX, stageY) { this.dragActor = this._info.createIcon(DASH_DOCS_ICON_SIZE); return this.dragActor; }, //// Drag and drop functions //// shellWorkspaceLaunch: function () { this._info.launch(); } }; /** * Class used to display two column recent documents in the dash */ function DashDocDisplay() { this._init(); } DashDocDisplay.prototype = { _init: function() { this.actor = new Shell.GenericContainer(); this.actor.connect('get-preferred-width', Lang.bind(this, this._getPreferredWidth)); this.actor.connect('get-preferred-height', Lang.bind(this, this._getPreferredHeight)); this.actor.connect('allocate', Lang.bind(this, this._allocate)); this._workId = Main.initializeDeferredWork(this.actor, Lang.bind(this, this._redisplay)); this._actorsByUri = {}; this._docManager = DocInfo.getDocManager(); this._docManager.connect('changed', Lang.bind(this, this._onDocsChanged)); this._pendingDocsChange = true; this._checkDocExistence = false; }, _getPreferredWidth: function(actor, forHeight, alloc) { let children = actor.get_children(); // We use two columns maximum. Just take the min and natural size of the // first two items, even though strictly speaking it's not correct; we'd // need to calculate how many items we could fit for the height, then // take the biggest preferred width for each column. // In practice the dash gets a fixed width anyways. // If we have one child, add its minimum and natural size if (children.length > 0) { let [minSize, naturalSize] = children[0].get_preferred_width(forHeight); alloc.min_size += minSize; alloc.natural_size += naturalSize; } // If we have two, add its size, plus DEFAULT_SPACING if (children.length > 1) { let [minSize, naturalSize] = children[1].get_preferred_width(forHeight); alloc.min_size += DEFAULT_SPACING + minSize; alloc.natural_size += DEFAULT_SPACING + naturalSize; } }, _getPreferredHeight: function(actor, forWidth, alloc) { let children = actor.get_children(); // The width of an item is our allocated width, minus spacing, divided in half. this._itemWidth = Math.floor((forWidth - DEFAULT_SPACING) / 2); let maxNatural = 0; for (let i = 0; i < children.length; i++) { let child = children[i]; let [minSize, naturalSize] = child.get_preferred_height(this._itemWidth); maxNatural = Math.max(maxNatural, naturalSize); } this._itemHeight = maxNatural; let firstColumnChildren = Math.ceil(children.length / 2); alloc.natural_size = (firstColumnChildren * maxNatural + (firstColumnChildren - 1) * DEFAULT_SPACING); }, _allocate: function(actor, box, flags) { let width = box.x2 - box.x1; let height = box.y2 - box.y1; // Make sure this._itemWidth/Height have been computed, even // if the parent actor didn't check our size before allocating. // (Not clear if that is required or not as a Clutter // invariant; this is safe and cheap because of caching.) actor.get_preferred_height(width); let children = actor.get_children(); let x = 0; let y = 0; let columnIndex = 0; let i = 0; // Loop over the children, going vertically down first. When we run // out of vertical space (our y variable is bigger than box.y2), switch // to the second column. while (i < children.length) { let child = children[i]; if (y + this._itemHeight > box.y2) { // Is this the second column, or we're in // the first column and can't even fit one // item? In that case, break. if (columnIndex == 1 || i == 0) { break; } // Set x to the halfway point. columnIndex += 1; x = x + this._itemWidth + DEFAULT_SPACING; // And y is back to the top. y = 0; // Retry this same item, now that we're in the second column. // By looping back to the top here, we re-test the size // again for the second column. continue; } let childBox = new Clutter.ActorBox(); childBox.x1 = x; childBox.y1 = y; childBox.x2 = childBox.x1 + this._itemWidth; childBox.y2 = y + this._itemHeight; y = childBox.y2 + DEFAULT_SPACING; child.allocate(childBox, flags); this.actor.set_skip_paint(child, false); i++; } if (this._checkDocExistence) { // Now we know how many docs we are displaying, queue a check to see if any of them // have been deleted. If they are deleted, then we'll get a 'changed' signal; since // we'll now be displaying items we weren't previously, we'll check again to see // if they were deleted, and so forth and so on. // TODO: We should change this to ask for as many as we can fit in the given space: // https://bugzilla.gnome.org/show_bug.cgi?id=603522#c23 this._docManager.queueExistenceCheck(i); this._checkDocExistence = false; } for (; i < children.length; i++) this.actor.set_skip_paint(children[i], true); }, _onDocsChanged: function() { this._checkDocExistence = true; Main.queueDeferredWork(this._workId); }, _redisplay: function() { // Should be kept alive by the _actorsByUri this.actor.remove_all(); let docs = this._docManager.getTimestampOrderedInfos(); for (let i = 0; i < docs.length && i < MAX_DASH_DOCS; i++) { let doc = docs[i]; let display = this._actorsByUri[doc.uri]; if (display) { this.actor.add_actor(display.actor); } else { let display = new DashDocDisplayItem(doc); this.actor.add_actor(display.actor); this._actorsByUri[doc.uri] = display; } } // Any unparented actors must have been deleted for (let uri in this._actorsByUri) { let display = this._actorsByUri[uri]; if (display.actor.get_parent() == null) { display.actor.destroy(); delete this._actorsByUri[uri]; } } this.emit('changed'); } }; Signals.addSignalMethods(DashDocDisplay.prototype); function DocSearchProvider() { this._init(); } DocSearchProvider.prototype = { __proto__: Search.SearchProvider.prototype, _init: function(name) { Search.SearchProvider.prototype._init.call(this, _("RECENT ITEMS")); this._docManager = DocInfo.getDocManager(); }, getResultMeta: function(resultId) { let docInfo = this._docManager.lookupByUri(resultId); if (!docInfo) return null; return { 'id': resultId, 'name': docInfo.name, 'icon': docInfo.createIcon(Search.RESULT_ICON_SIZE)}; }, activateResult: function(id) { let docInfo = this._docManager.lookupByUri(id); docInfo.launch(); }, getInitialResultSet: function(terms) { return this._docManager.initialSearch(terms); }, getSubsearchResultSet: function(previousResults, terms) { return this._docManager.subsearch(previousResults, terms); }, expandSearch: function(terms) { log('TODO expand docs search'); } };