gnome-shell/js/ui/search.js
Jonas Dreßler 21966afbc6 search: Defer updating results on allocation changes until redraw
Since the `notify::allocation` signal will obviously get emitted while
the actor is inside an allocation cycle and we might end up doing
changes to its allocation inside `updateSearch` by hiding or showing the
actor (which queues a relayout), we get a warning from Clutter.

Fix this by delaying the call to the parent method until the next
redraw, which should happen a few moments after the current relayout.

https://gitlab.gnome.org/GNOME/gnome-shell/merge_requests/672
2019-08-08 17:37:12 +00:00

777 lines
26 KiB
JavaScript

// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi;
const Signals = imports.signals;
const AppDisplay = imports.ui.appDisplay;
const IconGrid = imports.ui.iconGrid;
const Main = imports.ui.main;
const RemoteSearch = imports.ui.remoteSearch;
const Util = imports.misc.util;
const SEARCH_PROVIDERS_SCHEMA = 'org.gnome.desktop.search-providers';
var MAX_LIST_SEARCH_RESULTS_ROWS = 5;
var MAX_GRID_SEARCH_RESULTS_ROWS = 1;
var MaxWidthBox = GObject.registerClass(
class MaxWidthBox extends St.BoxLayout {
vfunc_allocate(box, flags) {
let themeNode = this.get_theme_node();
let maxWidth = themeNode.get_max_width();
let availWidth = box.x2 - box.x1;
let adjustedBox = box;
if (availWidth > maxWidth) {
let excessWidth = availWidth - maxWidth;
adjustedBox.x1 += Math.floor(excessWidth / 2);
adjustedBox.x2 -= Math.floor(excessWidth / 2);
}
super.vfunc_allocate(adjustedBox, flags);
}
});
var SearchResult = class {
constructor(provider, metaInfo, resultsView) {
this.provider = provider;
this.metaInfo = metaInfo;
this._resultsView = resultsView;
this.actor = new St.Button({ reactive: true,
can_focus: true,
track_hover: true,
x_align: St.Align.START,
y_fill: true });
this.actor._delegate = this;
this.actor.connect('clicked', this.activate.bind(this));
}
activate() {
this.emit('activate', this.metaInfo.id);
}
};
Signals.addSignalMethods(SearchResult.prototype);
var ListSearchResult = class extends SearchResult {
constructor(provider, metaInfo, resultsView) {
super(provider, metaInfo, resultsView);
this.actor.style_class = 'list-search-result';
this.actor.x_fill = true;
let content = new St.BoxLayout({ style_class: 'list-search-result-content',
vertical: false });
this.actor.set_child(content);
this._termsChangedId = 0;
let titleBox = new St.BoxLayout({ style_class: 'list-search-result-title' });
content.add(titleBox, { x_fill: true,
y_fill: false,
x_align: St.Align.START,
y_align: St.Align.MIDDLE });
// An icon for, or thumbnail of, content
let icon = this.metaInfo['createIcon'](this.ICON_SIZE);
if (icon) {
titleBox.add(icon);
}
let title = new St.Label({ text: this.metaInfo['name'] });
titleBox.add(title, { x_fill: false,
y_fill: false,
x_align: St.Align.START,
y_align: St.Align.MIDDLE });
this.actor.label_actor = title;
if (this.metaInfo['description']) {
this._descriptionLabel = new St.Label({ style_class: 'list-search-result-description' });
content.add(this._descriptionLabel, { x_fill: false,
y_fill: false,
x_align: St.Align.START,
y_align: St.Align.MIDDLE });
this._termsChangedId =
this._resultsView.connect('terms-changed',
this._highlightTerms.bind(this));
this._highlightTerms();
}
this.actor.connect('destroy', this._onDestroy.bind(this));
}
get ICON_SIZE() {
return 24;
}
_highlightTerms() {
let markup = this._resultsView.highlightTerms(this.metaInfo['description'].split('\n')[0]);
this._descriptionLabel.clutter_text.set_markup(markup);
}
_onDestroy() {
if (this._termsChangedId)
this._resultsView.disconnect(this._termsChangedId);
this._termsChangedId = 0;
}
};
var GridSearchResult = class extends SearchResult {
constructor(provider, metaInfo, resultsView) {
super(provider, metaInfo, resultsView);
this.actor.style_class = 'grid-search-result';
this.icon = new IconGrid.BaseIcon(this.metaInfo['name'],
{ createIcon: this.metaInfo['createIcon'] });
let content = new St.Bin({ child: this.icon });
this.actor.set_child(content);
this.actor.label_actor = this.icon.label;
}
};
var SearchResultsBase = class {
constructor(provider, resultsView) {
this.provider = provider;
this._resultsView = resultsView;
this._terms = [];
this.actor = new St.BoxLayout({ style_class: 'search-section',
vertical: true });
this._resultDisplayBin = new St.Bin({ x_fill: true,
y_fill: true });
this.actor.add(this._resultDisplayBin, { expand: true });
let separator = new St.Widget({ style_class: 'search-section-separator' });
this.actor.add(separator);
this._resultDisplays = {};
this._clipboard = St.Clipboard.get_default();
this._cancellable = new Gio.Cancellable();
}
destroy() {
this.actor.destroy();
this._terms = [];
}
_createResultDisplay(meta) {
if (this.provider.createResultObject)
return this.provider.createResultObject(meta, this._resultsView);
return null;
}
clear() {
this._cancellable.cancel();
for (let resultId in this._resultDisplays)
this._resultDisplays[resultId].actor.destroy();
this._resultDisplays = {};
this._clearResultDisplay();
this.actor.hide();
}
_keyFocusIn(actor) {
this.emit('key-focus-in', actor);
}
_activateResult(result, id) {
this.provider.activateResult(id, this._terms);
if (result.metaInfo.clipboardText)
this._clipboard.set_text(St.ClipboardType.CLIPBOARD, result.metaInfo.clipboardText);
Main.overview.toggle();
}
_setMoreCount(_count) {
}
_ensureResultActors(results, callback) {
let metasNeeded = results.filter(
resultId => this._resultDisplays[resultId] === undefined
);
if (metasNeeded.length === 0) {
callback(true);
} else {
this._cancellable.cancel();
this._cancellable.reset();
this.provider.getResultMetas(metasNeeded, metas => {
if (this._cancellable.is_cancelled()) {
if (metas.length > 0)
log(`Search provider ${this.provider.id} returned results after the request was canceled`);
callback(false);
return;
}
if (metas.length != metasNeeded.length) {
log(`Wrong number of result metas returned by search provider ${this.provider.id}: ` +
`expected ${metasNeeded.length} but got ${metas.length}`);
callback(false);
return;
}
if (metas.some(meta => !meta.name || !meta.id)) {
log(`Invalid result meta returned from search provider ${this.provider.id}`);
callback(false);
return;
}
metasNeeded.forEach((resultId, i) => {
let meta = metas[i];
let display = this._createResultDisplay(meta);
display.connect('activate', this._activateResult.bind(this));
display.actor.connect('key-focus-in', this._keyFocusIn.bind(this));
this._resultDisplays[resultId] = display;
});
callback(true);
}, this._cancellable);
}
}
updateSearch(providerResults, terms, callback) {
this._terms = terms;
if (providerResults.length == 0) {
this._clearResultDisplay();
this.actor.hide();
callback();
} else {
let maxResults = this._getMaxDisplayedResults();
let results = maxResults > -1
? this.provider.filterResults(providerResults, maxResults)
: providerResults;
let moreCount = Math.max(providerResults.length - results.length, 0);
this._ensureResultActors(results, successful => {
if (!successful) {
this._clearResultDisplay();
callback();
return;
}
// To avoid CSS transitions causing flickering when
// the first search result stays the same, we hide the
// content while filling in the results.
this.actor.hide();
this._clearResultDisplay();
results.forEach(resultId => {
this._addItem(this._resultDisplays[resultId]);
});
this._setMoreCount(this.provider.canLaunchSearch ? moreCount : 0);
this.actor.show();
callback();
});
}
}
};
var ListSearchResults = class extends SearchResultsBase {
constructor(provider, resultsView) {
super(provider, resultsView);
this._container = new St.BoxLayout({ style_class: 'search-section-content' });
this.providerInfo = new ProviderInfo(provider);
this.providerInfo.connect('key-focus-in', this._keyFocusIn.bind(this));
this.providerInfo.connect('clicked', () => {
this.providerInfo.animateLaunch();
provider.launchSearch(this._terms);
Main.overview.toggle();
});
this._container.add(this.providerInfo, { x_fill: false,
y_fill: false,
x_align: St.Align.START,
y_align: St.Align.START });
this._content = new St.BoxLayout({ style_class: 'list-search-results',
vertical: true });
this._container.add(this._content, { expand: true });
this._resultDisplayBin.set_child(this._container);
}
_setMoreCount(count) {
this.providerInfo.setMoreCount(count);
}
_getMaxDisplayedResults() {
return MAX_LIST_SEARCH_RESULTS_ROWS;
}
_clearResultDisplay() {
this._content.remove_all_children();
}
_createResultDisplay(meta) {
return super._createResultDisplay(meta) ||
new ListSearchResult(this.provider, meta, this._resultsView);
}
_addItem(display) {
this._content.add_actor(display.actor);
}
getFirstResult() {
if (this._content.get_n_children() > 0)
return this._content.get_child_at_index(0)._delegate;
else
return null;
}
};
Signals.addSignalMethods(ListSearchResults.prototype);
var GridSearchResults = class extends SearchResultsBase {
constructor(provider, resultsView) {
super(provider, resultsView);
this._grid = new IconGrid.IconGrid({ rowLimit: MAX_GRID_SEARCH_RESULTS_ROWS,
xAlign: St.Align.START });
this._bin = new St.Bin({ x_align: St.Align.MIDDLE });
this._bin.set_child(this._grid);
this._resultDisplayBin.set_child(this._bin);
}
updateSearch(...args) {
if (this._notifyAllocationId)
this.actor.disconnect(this._notifyAllocationId);
// Make sure the maximum number of results calculated by
// _getMaxDisplayedResults() is updated after width changes.
this._notifyAllocationId = this.actor.connect('notify::allocation', () => {
Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
super.updateSearch(...args);
return GLib.SOURCE_REMOVE;
});
});
super.updateSearch(...args);
}
_getMaxDisplayedResults() {
let width = this.actor.allocation.x2 - this.actor.allocation.x1;
if (width == 0)
return -1;
let nCols = this._grid.columnsForWidth(width);
return nCols * this._grid.getRowLimit();
}
_clearResultDisplay() {
this._grid.removeAll();
}
_createResultDisplay(meta) {
return super._createResultDisplay(meta) ||
new GridSearchResult(this.provider, meta, this._resultsView);
}
_addItem(display) {
this._grid.addItem(display);
}
getFirstResult() {
if (this._grid.visibleItemsCount() > 0)
return this._grid.getItemAtIndex(0)._delegate;
else
return null;
}
};
Signals.addSignalMethods(GridSearchResults.prototype);
var SearchResults = class {
constructor() {
this.actor = new St.BoxLayout({ name: 'searchResults',
vertical: true });
this._content = new MaxWidthBox({ name: 'searchResultsContent',
vertical: true });
this._scrollView = new St.ScrollView({ x_fill: true,
y_fill: false,
overlay_scrollbars: true,
style_class: 'search-display vfade' });
this._scrollView.set_policy(St.PolicyType.NEVER, St.PolicyType.AUTOMATIC);
this._scrollView.add_actor(this._content);
let action = new Clutter.PanAction({ interpolate: true });
action.connect('pan', this._onPan.bind(this));
this._scrollView.add_action(action);
this.actor.add(this._scrollView, { x_fill: true,
y_fill: true,
expand: true,
x_align: St.Align.START,
y_align: St.Align.START });
this._statusText = new St.Label({ style_class: 'search-statustext' });
this._statusBin = new St.Bin({ x_align: St.Align.MIDDLE,
y_align: St.Align.MIDDLE });
this.actor.add(this._statusBin, { expand: true });
this._statusBin.add_actor(this._statusText);
this._highlightDefault = false;
this._defaultResult = null;
this._startingSearch = false;
this._terms = [];
this._results = {};
this._providers = [];
this._highlightRegex = null;
this._searchSettings = new Gio.Settings({ schema_id: SEARCH_PROVIDERS_SCHEMA });
this._searchSettings.connect('changed::disabled', this._reloadRemoteProviders.bind(this));
this._searchSettings.connect('changed::enabled', this._reloadRemoteProviders.bind(this));
this._searchSettings.connect('changed::disable-external', this._reloadRemoteProviders.bind(this));
this._searchSettings.connect('changed::sort-order', this._reloadRemoteProviders.bind(this));
this._searchTimeoutId = 0;
this._cancellable = new Gio.Cancellable();
this._registerProvider(new AppDisplay.AppSearchProvider());
let appSystem = Shell.AppSystem.get_default();
appSystem.connect('installed-changed', this._reloadRemoteProviders.bind(this));
this._reloadRemoteProviders();
}
_reloadRemoteProviders() {
let remoteProviders = this._providers.filter(p => p.isRemoteProvider);
remoteProviders.forEach(provider => {
this._unregisterProvider(provider);
});
RemoteSearch.loadRemoteSearchProviders(this._searchSettings, providers => {
providers.forEach(this._registerProvider.bind(this));
});
}
_registerProvider(provider) {
provider.searchInProgress = false;
this._providers.push(provider);
this._ensureProviderDisplay(provider);
}
_unregisterProvider(provider) {
let index = this._providers.indexOf(provider);
this._providers.splice(index, 1);
if (provider.display)
provider.display.destroy();
}
_gotResults(results, provider) {
this._results[provider.id] = results;
this._updateResults(provider, results);
}
_clearSearchTimeout() {
if (this._searchTimeoutId > 0) {
GLib.source_remove(this._searchTimeoutId);
this._searchTimeoutId = 0;
}
}
_reset() {
this._terms = [];
this._results = {};
this._clearDisplay();
this._clearSearchTimeout();
this._defaultResult = null;
this._startingSearch = false;
this._updateSearchProgress();
}
_doSearch() {
this._startingSearch = false;
let previousResults = this._results;
this._results = {};
this._providers.forEach(provider => {
provider.searchInProgress = true;
let previousProviderResults = previousResults[provider.id];
if (this._isSubSearch && previousProviderResults)
provider.getSubsearchResultSet(previousProviderResults,
this._terms,
results => {
this._gotResults(results, provider);
},
this._cancellable);
else
provider.getInitialResultSet(this._terms,
results => {
this._gotResults(results, provider);
},
this._cancellable);
});
this._updateSearchProgress();
this._clearSearchTimeout();
}
_onSearchTimeout() {
this._searchTimeoutId = 0;
this._doSearch();
return GLib.SOURCE_REMOVE;
}
setTerms(terms) {
// Check for the case of making a duplicate previous search before
// setting state of the current search or cancelling the search.
// This will prevent incorrect state being as a result of a duplicate
// search while the previous search is still active.
let searchString = terms.join(' ');
let previousSearchString = this._terms.join(' ');
if (searchString == previousSearchString)
return;
this._startingSearch = true;
this._cancellable.cancel();
this._cancellable.reset();
if (terms.length == 0) {
this._reset();
return;
}
let isSubSearch = false;
if (this._terms.length > 0)
isSubSearch = searchString.indexOf(previousSearchString) == 0;
this._terms = terms;
this._isSubSearch = isSubSearch;
this._updateSearchProgress();
if (this._searchTimeoutId == 0)
this._searchTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 150, this._onSearchTimeout.bind(this));
let escapedTerms = this._terms.map(term => Shell.util_regex_escape(term));
this._highlightRegex = new RegExp(`(${escapedTerms.join('|')})`, 'gi');
this.emit('terms-changed');
}
_onPan(action) {
let [dist_, dx_, dy] = action.get_motion_delta(0);
let adjustment = this._scrollView.vscroll.adjustment;
adjustment.value -= (dy / this.actor.height) * adjustment.page_size;
return false;
}
_keyFocusIn(provider, actor) {
Util.ensureActorVisibleInScrollView(this._scrollView, actor);
}
_ensureProviderDisplay(provider) {
if (provider.display)
return;
let providerDisplay;
if (provider.appInfo)
providerDisplay = new ListSearchResults(provider, this);
else
providerDisplay = new GridSearchResults(provider, this);
providerDisplay.connect('key-focus-in', this._keyFocusIn.bind(this));
providerDisplay.actor.hide();
this._content.add(providerDisplay.actor);
provider.display = providerDisplay;
}
_clearDisplay() {
this._providers.forEach(provider => {
provider.display.clear();
});
}
_maybeSetInitialSelection() {
let newDefaultResult = null;
let providers = this._providers;
for (let i = 0; i < providers.length; i++) {
let provider = providers[i];
let display = provider.display;
if (!display.actor.visible)
continue;
let firstResult = display.getFirstResult();
if (firstResult) {
newDefaultResult = firstResult;
break; // select this one!
}
}
if (newDefaultResult != this._defaultResult) {
this._setSelected(this._defaultResult, false);
this._setSelected(newDefaultResult, this._highlightDefault);
this._defaultResult = newDefaultResult;
}
}
get searchInProgress() {
if (this._startingSearch)
return true;
return this._providers.some(p => p.searchInProgress);
}
_updateSearchProgress() {
let haveResults = this._providers.some(provider => {
let display = provider.display;
return (display.getFirstResult() != null);
});
this._scrollView.visible = haveResults;
this._statusBin.visible = !haveResults;
if (!haveResults) {
if (this.searchInProgress) {
this._statusText.set_text(_("Searching…"));
} else {
this._statusText.set_text(_("No results."));
}
}
}
_updateResults(provider, results) {
let terms = this._terms;
let display = provider.display;
display.updateSearch(results, terms, () => {
provider.searchInProgress = false;
this._maybeSetInitialSelection();
this._updateSearchProgress();
});
}
activateDefault() {
// If we have a search queued up, force the search now.
if (this._searchTimeoutId > 0)
this._doSearch();
if (this._defaultResult)
this._defaultResult.activate();
}
highlightDefault(highlight) {
this._highlightDefault = highlight;
this._setSelected(this._defaultResult, highlight);
}
popupMenuDefault() {
// If we have a search queued up, force the search now.
if (this._searchTimeoutId > 0)
this._doSearch();
if (this._defaultResult)
this._defaultResult.actor.popup_menu();
}
navigateFocus(direction) {
let rtl = this.actor.get_text_direction() == Clutter.TextDirection.RTL;
if (direction == St.DirectionType.TAB_BACKWARD ||
direction == (rtl ? St.DirectionType.RIGHT
: St.DirectionType.LEFT) ||
direction == St.DirectionType.UP) {
this.actor.navigate_focus(null, direction, false);
return;
}
let from = this._defaultResult ? this._defaultResult.actor : null;
this.actor.navigate_focus(from, direction, false);
}
_setSelected(result, selected) {
if (!result)
return;
if (selected) {
result.actor.add_style_pseudo_class('selected');
Util.ensureActorVisibleInScrollView(this._scrollView, result.actor);
} else {
result.actor.remove_style_pseudo_class('selected');
}
}
highlightTerms(description) {
if (!description)
return '';
if (!this._highlightRegex)
return description;
return description.replace(this._highlightRegex, '<b>$1</b>');
}
};
Signals.addSignalMethods(SearchResults.prototype);
var ProviderInfo = GObject.registerClass(
class ProviderInfo extends St.Button {
_init(provider) {
this.provider = provider;
super._init({ style_class: 'search-provider-icon',
reactive: true,
can_focus: true,
accessible_name: provider.appInfo.get_name(),
track_hover: true });
this._content = new St.BoxLayout({ vertical: false,
style_class: 'list-search-provider-content' });
this.set_child(this._content);
let icon = new St.Icon({ icon_size: this.PROVIDER_ICON_SIZE,
gicon: provider.appInfo.get_icon() });
let detailsBox = new St.BoxLayout({ style_class: 'list-search-provider-details',
vertical: true,
x_expand: true });
let nameLabel = new St.Label({ text: provider.appInfo.get_name(),
x_align: Clutter.ActorAlign.START });
this._moreLabel = new St.Label({ x_align: Clutter.ActorAlign.START });
detailsBox.add_actor(nameLabel);
detailsBox.add_actor(this._moreLabel);
this._content.add_actor(icon);
this._content.add_actor(detailsBox);
}
get PROVIDER_ICON_SIZE() {
return 32;
}
animateLaunch() {
let appSys = Shell.AppSystem.get_default();
let app = appSys.lookup_app(this.provider.appInfo.get_id());
if (app.state == Shell.AppState.STOPPED)
IconGrid.zoomOutActor(this._content);
}
setMoreCount(count) {
this._moreLabel.text = ngettext("%d more", "%d more", count).format(count);
this._moreLabel.visible = count > 0;
}
});