2025-05-09 09:48:24 +02:00

831 lines
27 KiB
JavaScript

/*
* Credits: This file leverages the work from GNOME Shell search.js file
* (https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/master/js/ui/search.js)
*/
import Clutter from 'gi://Clutter';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Shell from 'gi://Shell';
import St from 'gi://St';
import {gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js';
import {Highlighter} from 'resource:///org/gnome/shell/misc/util.js';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import * as RemoteSearch from 'resource:///org/gnome/shell/ui/remoteSearch.js';
import {ApplicationMenuItem, BaseMenuItem} from './menuWidgets.js';
import {ArcMenuManager} from './arcmenuManager.js';
import * as Constants from './constants.js';
import {RecentFilesManager} from './recentFilesManager.js';
import {OpenWindowSearchProvider} from './searchProviders/openWindows.js';
import {RecentFilesSearchProvider} from './searchProviders/recentFiles.js';
import {getOrientationProp} from './utils.js';
const SEARCH_PROVIDERS_SCHEMA = 'org.gnome.desktop.search-providers';
const FILE_PROVIDERS = ['org.gnome.Nautilus.desktop', 'arcmenu.recent-files', 'nemo.desktop'];
class ListSearchResult extends ApplicationMenuItem {
static {
GObject.registerClass(this);
}
constructor(provider, metaInfo, resultsView) {
const menuLayout = resultsView._menuLayout;
metaInfo['provider-id'] = provider.id;
super(menuLayout, null, Constants.DisplayType.LIST, metaInfo);
this.searchType = this._menuLayout.search_display_type;
this.metaInfo = metaInfo;
this.provider = provider;
this.resultsView = resultsView;
this.layout = ArcMenuManager.settings.get_enum('menu-layout');
if (FILE_PROVIDERS.includes(this.provider.id))
this.folderPath = this.metaInfo['description'];
const highlightSearchResultTerms = ArcMenuManager.settings.get_boolean('highlight-search-result-terms');
if (highlightSearchResultTerms) {
this.resultsView.connectObject('terms-changed', this._highlightTerms.bind(this), this);
this._highlightTerms();
}
if (this.metaInfo['description'])
this.description = this.metaInfo['description'].split('\n')[0];
}
_highlightTerms() {
const showSearchResultDescriptions = ArcMenuManager.settings.get_boolean('show-search-result-details');
if (this.descriptionLabel && showSearchResultDescriptions) {
const descriptionMarkup = this.resultsView.highlightTerms(this.metaInfo['description'].split('\n')[0]);
this.descriptionLabel.clutter_text.set_markup(descriptionMarkup);
}
const labelMarkup = this.resultsView.highlightTerms(this.label.text.split('\n')[0]);
this.label.clutter_text.set_markup(labelMarkup);
}
}
class AppSearchResult extends ApplicationMenuItem {
static {
GObject.registerClass(this);
}
constructor(provider, metaInfo, resultsView) {
const menuLayout = resultsView._menuLayout;
const appSystem = Shell.AppSystem.get_default();
const app = appSystem.lookup_app(metaInfo['id']) || appSystem.lookup_app(provider.id);
const displayType = menuLayout.search_display_type;
super(menuLayout, app, displayType, metaInfo);
this.app = app;
this.provider = provider;
this.metaInfo = metaInfo;
this.resultsView = resultsView;
if (!this.app && this.metaInfo['description'])
this.description = this.metaInfo['description'].split('\n')[0];
const highlightSearchResultTerms = ArcMenuManager.settings.get_boolean('highlight-search-result-terms');
if (highlightSearchResultTerms) {
this.resultsView.connectObject('terms-changed', this._highlightTerms.bind(this), this);
this._highlightTerms();
}
}
_highlightTerms() {
const showSearchResultDescriptions = ArcMenuManager.settings.get_boolean('show-search-result-details');
if (this.descriptionLabel && showSearchResultDescriptions) {
const descriptionMarkup = this.resultsView.highlightTerms(this.descriptionLabel.text.split('\n')[0]);
this.descriptionLabel.clutter_text.set_markup(descriptionMarkup);
}
const labelMarkup = this.resultsView.highlightTerms(this.label.text.split('\n')[0]);
this.label.clutter_text.set_markup(labelMarkup);
}
}
class SearchResultsBase extends St.BoxLayout {
static [GObject.signals] = {
'terms-changed': {},
'no-results': {},
};
static {
GObject.registerClass(this);
}
constructor(provider, resultsView) {
super({...getOrientationProp(true)});
this.provider = provider;
this.resultsView = resultsView;
this._menuLayout = resultsView._menuLayout;
this._terms = [];
this._resultDisplayBin = new St.Bin({
x_expand: true,
y_expand: true,
});
this.add_child(this._resultDisplayBin);
this._resultDisplays = {};
this._clipboard = St.Clipboard.get_default();
this._cancellable = new Gio.Cancellable();
this.connect('destroy', () => this._onDestroy());
}
_onDestroy() {
this._cancellable.cancel();
this._cancellable = null;
for (const resultId in this._resultDisplays) {
if (Object.hasOwn(this._resultDisplays, resultId)) {
this._resultDisplays[resultId].destroy();
delete this._resultDisplays[resultId];
}
}
this._resultDisplays = null;
this._terms = [];
this._menuLayout = null;
}
_createResultDisplay(_meta) {
}
clear() {
this._cancellable.cancel();
for (const resultId in this._resultDisplays) {
if (Object.hasOwn(this._resultDisplays, resultId)) {
this._resultDisplays[resultId].destroy();
delete this._resultDisplays[resultId];
}
}
this._resultDisplays = {};
this._clearResultDisplay();
this.hide();
}
_setMoreCount(_count) {
}
async _ensureResultActors(results) {
const metasNeeded = results.filter(
resultId => this._resultDisplays[resultId] === undefined
);
if (metasNeeded.length === 0)
return;
this._cancellable.cancel();
this._cancellable.reset();
const metas = await this.provider.getResultMetas(metasNeeded, this._cancellable);
if (this._cancellable.is_cancelled()) {
if (metas.length > 0)
throw new Error(`Search provider ${this.provider.id} returned results after the request was canceled`);
}
if (metas.length !== metasNeeded.length)
throw new Error(`Wrong number of result metas returned by search provider ${this.provider.id}: expected ${metasNeeded.length} but got ${metas.length}`);
if (metas.some(meta => !meta.name || !meta.id))
throw new Error(`Invalid result meta returned from search provider ${this.provider.id}`);
metasNeeded.forEach((resultId, i) => {
const meta = metas[i];
const display = this._createResultDisplay(meta);
this._resultDisplays[resultId] = display;
});
}
async updateSearch(providerResults, terms, callback) {
this._terms = terms;
if (providerResults.length === 0) {
this._clearResultDisplay();
this.hide();
callback();
} else {
const maxResults = this._getMaxDisplayedResults();
const results = maxResults > -1
? this.provider.filterResults(providerResults, maxResults)
: providerResults;
const moreCount = Math.max(providerResults.length - results.length, 0);
try {
await this._ensureResultActors(results);
// To avoid CSS transitions causing flickering when
// the first search result stays the same, we hide the
// content while filling in the results.
this.hide();
this._clearResultDisplay();
results.forEach(
resultId => this._addItem(this._resultDisplays[resultId]));
this._setMoreCount(this.provider.canLaunchSearch ? moreCount : 0);
this.show();
callback();
} catch (e) {
this._clearResultDisplay();
callback();
}
}
}
}
class ListSearchResults extends SearchResultsBase {
static {
GObject.registerClass(this);
}
constructor(provider, resultsView) {
super(provider, resultsView);
this._menuLayout = resultsView._menuLayout;
this.searchType = this._menuLayout.search_display_type;
this.layout = ArcMenuManager.settings.get_enum('menu-layout');
const spacing = this._menuLayout.search_results_spacing;
this._container = new St.BoxLayout({
...getOrientationProp(true),
x_align: Clutter.ActorAlign.FILL,
y_align: Clutter.ActorAlign.FILL,
x_expand: true,
y_expand: true,
style: `margin-top: ${Math.max(spacing, 8)}px; spacing: ${Math.max(spacing, 2)}px;`,
});
this.providerInfo = new ArcSearchProviderInfo(provider, this._menuLayout);
this.providerInfo.connect('activate', () => {
if (provider.canLaunchSearch) {
provider.launchSearch(this._terms);
this._menuLayout.arcMenu.toggle();
}
});
this._container.add_child(this.providerInfo);
this._content = new St.BoxLayout({
...getOrientationProp(true),
x_expand: true,
y_expand: true,
x_align: Clutter.ActorAlign.FILL,
style: `spacing: ${spacing}px;`,
});
this._container.add_child(this._content);
this._resultDisplayBin.set_child(this._container);
}
_setMoreCount(count) {
this.providerInfo.setMoreCount(count);
}
_getMaxDisplayedResults() {
return ArcMenuManager.settings.get_int('max-search-results');
}
_clearResultDisplay() {
this._content.remove_all_children();
}
_createResultDisplay(meta) {
return new ListSearchResult(this.provider, meta, this.resultsView);
}
_addItem(display) {
if (display.get_parent())
display.get_parent().remove_child(display);
this._content.add_child(display);
}
getFirstResult() {
if (this._content.get_n_children() > 0)
return this._content.get_child_at_index(0)._delegate;
else
return null;
}
}
class AppSearchResults extends SearchResultsBase {
static {
GObject.registerClass(this);
}
constructor(provider, resultsView) {
super(provider, resultsView);
this._parentContainer = resultsView;
this._menuLayout = resultsView._menuLayout;
this.searchType = this._menuLayout.search_display_type;
this.layout = ArcMenuManager.settings.get_enum('menu-layout');
this.itemCount = 0;
this.gridTop = -1;
this.gridLeft = 0;
this.rtl = this._menuLayout.get_text_direction() === Clutter.TextDirection.RTL;
const layout = new Clutter.GridLayout({
orientation: Clutter.Orientation.VERTICAL,
column_spacing: this.searchType === Constants.DisplayType.GRID ? this._menuLayout.column_spacing : this._menuLayout.search_results_spacing,
row_spacing: this.searchType === Constants.DisplayType.GRID ? this._menuLayout.row_spacing : this._menuLayout.search_results_spacing,
});
this._grid = new St.Widget({
x_expand: true,
x_align: this.searchType === Constants.DisplayType.LIST ? Clutter.ActorAlign.FILL
: Clutter.ActorAlign.CENTER,
layout_manager: layout,
});
layout.hookup_style(this._grid);
if (this.searchType === Constants.DisplayType.GRID) {
const spacing = this._menuLayout.column_spacing;
this._grid.style = `spacing: ${spacing}px;`;
this._resultDisplayBin.x_align = Clutter.ActorAlign.CENTER;
}
this._resultDisplayBin.set_child(this._grid);
}
_getMaxDisplayedResults() {
let maxDisplayedResults;
if (this.searchType === Constants.DisplayType.GRID) {
const iconWidth = this._menuLayout.getIconWidthFromSetting();
maxDisplayedResults = this._menuLayout.getBestFitColumnsForGrid(iconWidth, this._grid);
} else {
maxDisplayedResults = ArcMenuManager.settings.get_int('max-search-results');
}
return maxDisplayedResults;
}
_clearResultDisplay() {
this.itemCount = 0;
this.gridTop = -1;
this.gridLeft = 0;
this._grid.remove_all_children();
}
_createResultDisplay(meta) {
return new AppSearchResult(this.provider, meta, this.resultsView);
}
_addItem(display) {
let colums;
if (this.searchType === Constants.DisplayType.LIST) {
colums = 1;
} else {
const iconWidth = this._menuLayout.getIconWidthFromSetting();
colums = this._menuLayout.getBestFitColumnsForGrid(iconWidth, this._grid);
}
if (!this.rtl && (this.itemCount % colums === 0)) {
this.gridTop++;
this.gridLeft = 0;
} else if (this.rtl && (this.gridLeft === 0)) {
this.gridTop++;
this.gridLeft = colums;
}
this._grid.layout_manager.attach(display, this.gridLeft, this.gridTop, 1, 1);
if (!this.rtl)
this.gridLeft++;
else if (this.rtl)
this.gridLeft--;
this.itemCount++;
}
getFirstResult() {
if (this._grid.get_n_children() > 0)
return this._grid.get_child_at_index(0)._delegate;
else
return null;
}
}
export class SearchResults extends St.BoxLayout {
static [GObject.signals] = {
'terms-changed': {},
'have-results': {},
'no-results': {},
};
static {
GObject.registerClass(this);
}
constructor(menuLayout) {
super({
...getOrientationProp(true),
y_expand: true,
x_expand: true,
x_align: Clutter.ActorAlign.FILL,
});
this._menuLayout = menuLayout;
const {searchProviderDisplayId} = menuLayout.menuButton;
this._displayId = `display_${searchProviderDisplayId}`;
this.searchType = this._menuLayout.search_display_type;
this.layout = ArcMenuManager.settings.get_enum('menu-layout');
this._content = new St.BoxLayout({
...getOrientationProp(true),
x_align: Clutter.ActorAlign.FILL,
});
this.add_child(this._content);
this._statusText = new St.Label();
this._statusBin = new St.Bin({
x_align: Clutter.ActorAlign.CENTER,
y_align: Clutter.ActorAlign.CENTER,
x_expand: true,
y_expand: true,
});
this.add_child(this._statusBin);
this._statusBin.set_child(this._statusText);
this._highlightDefault = true;
this._defaultResult = null;
this._startingSearch = false;
this._terms = [];
this._results = {};
this._providers = [];
this._highlighter = new Highlighter();
this.recentFilesManager = new RecentFilesManager();
this._searchSettings = new Gio.Settings({schema_id: SEARCH_PROVIDERS_SCHEMA});
this._searchSettings.connectObject('changed::disabled', this._reloadRemoteProviders.bind(this), this);
this._searchSettings.connectObject('changed::enabled', this._reloadRemoteProviders.bind(this), this);
this._searchSettings.connectObject('changed::disable-external', this._reloadRemoteProviders.bind(this), this);
this._searchSettings.connectObject('changed::sort-order', this._reloadRemoteProviders.bind(this), this);
ArcMenuManager.extension.searchProviderEmitter.connectObject('search-provider-added',
(_s, provider) => this._registerProvider(provider), this);
ArcMenuManager.extension.searchProviderEmitter.connectObject('search-provider-removed',
(_s, provider) => this._unregisterProvider(provider), this);
this._searchTimeoutId = null;
this._cancellable = new Gio.Cancellable();
const appSystem = Shell.AppSystem.get_default();
appSystem.connectObject('installed-changed', this._reloadRemoteProviders.bind(this), this);
this._registerGnomeShellProviders();
this._reloadRemoteProviders();
this.connect('destroy', this._onDestroy.bind(this));
}
get terms() {
return this._terms;
}
setStyle(style) {
if (this._statusText)
this._statusText.style_class = style;
}
_onDestroy() {
this._cancellable.cancel();
this._cancellable = null;
ArcMenuManager.extension.searchProviderEmitter.disconnectObject(this);
this._searchSettings.disconnectObject(this);
Shell.AppSystem.get_default().disconnectObject(this);
this._clearSearchTimeout();
this._terms = [];
this._results = {};
this._clearDisplay();
this._defaultResult = null;
this._startingSearch = false;
this._providers.forEach(provider => {
if (provider[this._displayId]) {
provider[this._displayId].destroy();
delete provider[this._displayId];
}
});
this._providers = null;
this.recentFilesManager.destroy();
this.recentFilesManager = null;
this._highlighter = null;
this._searchSettings = null;
this._menuLayout = null;
}
_registerGnomeShellProviders() {
const searchResults = Main.overview.searchController._searchResults;
const providers = searchResults._providers.filter(p => !p.isRemoteProvider);
providers.forEach(this._registerProvider.bind(this));
if (ArcMenuManager.settings.get_boolean('search-provider-open-windows'))
this._registerProvider(new OpenWindowSearchProvider());
if (ArcMenuManager.settings.get_boolean('search-provider-recent-files'))
this._registerProvider(new RecentFilesSearchProvider(this.recentFilesManager));
}
_reloadRemoteProviders() {
const currentTerms = this._terms;
// cancel any active search
if (this._terms.length !== 0)
this._reset();
const remoteProviders = this._providers.filter(p => p.isRemoteProvider);
remoteProviders.forEach(provider => {
this._unregisterProvider(provider);
});
const providers = RemoteSearch.loadRemoteSearchProviders(this._searchSettings);
providers.forEach(this._registerProvider.bind(this));
// restart any active search
if (currentTerms.length > 0)
this.setTerms(currentTerms);
}
_registerProvider(provider) {
provider.searchInProgress = false;
this._providers.push(provider);
this._ensureProviderDisplay(provider);
}
_unregisterProvider(provider) {
const index = this._providers.indexOf(provider);
this._providers.splice(index, 1);
if (provider[this._displayId]) {
provider[this._displayId].destroy();
delete provider[this._displayId];
}
}
_clearSearchTimeout() {
if (this._searchTimeoutId) {
GLib.source_remove(this._searchTimeoutId);
this._searchTimeoutId = null;
}
}
async _doProviderSearch(provider, previousResults) {
provider.searchInProgress = true;
let results;
if (this._isSubSearch && previousResults) {
results = await provider.getSubsearchResultSet(
previousResults,
this._terms,
this._cancellable);
} else {
results = await provider.getInitialResultSet(
this._terms,
this._cancellable);
}
this._results[provider.id] = results;
this._updateResults(provider, results);
}
_reset() {
this._terms = [];
this._results = {};
this._clearDisplay();
this._clearSearchTimeout();
this._defaultResult = null;
this._startingSearch = false;
this._updateSearchProgress();
}
_doSearch() {
this._startingSearch = false;
const previousResults = this._results;
this._results = {};
this._providers.forEach(provider => {
const previousProviderResults = previousResults[provider.id];
this._doProviderSearch(provider, previousProviderResults);
});
this._updateSearchProgress();
this._clearSearchTimeout();
}
_onSearchTimeout() {
this._searchTimeoutId = null;
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.
const searchString = terms.join(' ');
const previousSearchString = this._terms.join(' ');
if (searchString === previousSearchString)
return;
this._startingSearch = true;
this.recentFilesManager.cancelCurrentQueries();
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 === null)
this._searchTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 150, this._onSearchTimeout.bind(this));
this._highlighter = new Highlighter(this._terms);
this.emit('terms-changed');
}
_ensureProviderDisplay(provider) {
if (provider[this._displayId])
return;
let providerDisplay;
if (provider.appInfo)
providerDisplay = new ListSearchResults(provider, this);
else
providerDisplay = new AppSearchResults(provider, this);
providerDisplay.hide();
this._content.add_child(providerDisplay);
provider[this._displayId] = providerDisplay;
}
_clearDisplay() {
this._providers.forEach(provider => {
provider[this._displayId]?.clear();
});
}
_maybeSetInitialSelection() {
let newDefaultResult = null;
const providers = this._providers;
for (let i = 0; i < providers.length; i++) {
const provider = providers[i];
const display = provider[this._displayId];
if (!display.visible)
continue;
const 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() {
const haveResults = this._providers.some(provider => {
const display = provider[this._displayId];
return display.getFirstResult() !== null;
});
this._statusBin.visible = !haveResults;
if (haveResults) {
this.emit('have-results');
} else if (!haveResults) {
if (this.searchInProgress)
this._statusText.set_text(_('Searching...'));
else
this._statusText.set_text(_('No results.'));
this.emit('no-results');
}
}
_updateResults(provider, results) {
const terms = this._terms;
const display = provider[this._displayId];
display.updateSearch(results, terms, () => {
provider.searchInProgress = false;
this._maybeSetInitialSelection();
this._updateSearchProgress();
});
}
highlightDefault(highlight) {
this._highlightDefault = highlight;
this._setSelected(this._defaultResult, highlight);
}
getTopResult() {
return this._defaultResult;
}
_setSelected(result, selected) {
if (!result)
return;
if (selected && !result.has_style_pseudo_class('active'))
result.add_style_pseudo_class('active');
else if (!selected)
result.remove_style_pseudo_class('active');
}
hasActiveResult() {
return !!this._defaultResult && this._highlightDefault;
}
highlightTerms(description) {
if (!description)
return '';
return this._highlighter.highlight(description);
}
}
export class ArcSearchProviderInfo extends BaseMenuItem {
static {
GObject.registerClass(this);
}
constructor(provider, menuLayout) {
super(menuLayout);
this.provider = provider;
this._menuLayout = menuLayout;
this._appInfo = provider.appInfo;
this.style = 'padding-top: 6px; padding-bottom: 6px;';
this.x_expand = false;
this.x_align = Clutter.ActorAlign.START;
this.description = this._appInfo.get_description ? this._appInfo.get_description() : null;
if (this.description)
this.description = this.description.split('\n')[0];
this.label = new St.Label({
text: this._appInfo.get_name(),
x_align: Clutter.ActorAlign.START,
y_align: Clutter.ActorAlign.CENTER,
style: 'font-weight: bold;',
});
this._moreLabel = new St.Label({
x_align: Clutter.ActorAlign.END,
y_align: Clutter.ActorAlign.CENTER,
});
this.add_child(this.label);
this.add_child(this._moreLabel);
}
setMoreCount(count) {
this._moreLabel.text = _('+ %d more', '+ %d more', count).format(count);
this._moreLabel.visible = count > 0;
}
}