/** * V-Shell (Vertical Workspaces) * search.js * * @author GdH * @copyright 2022 - 2024 * @license GPL-3.0 * */ 'use strict'; import GLib from 'gi://GLib'; import Clutter from 'gi://Clutter'; import St from 'gi://St'; import Shell from 'gi://Shell'; import GObject from 'gi://GObject'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import * as Search from 'resource:///org/gnome/shell/ui/search.js'; import * as AppDisplay from 'resource:///org/gnome/shell/ui/appDisplay.js'; import * as SystemActions from 'resource:///org/gnome/shell/misc/systemActions.js'; import { Highlighter } from 'resource:///org/gnome/shell/misc/util.js'; let Me; // gettext let _; let opt; const SEARCH_MAX_WIDTH = 1092; export const SearchModule = class { constructor(me) { Me = me; opt = Me.opt; _ = Me.gettext; this._firstActivation = true; this.moduleEnabled = false; this._overrides = null; } cleanGlobals() { Me = null; opt = null; _ = null; } update(reset) { this.moduleEnabled = opt.get('searchModule'); const conflict = false; reset = reset || !this.moduleEnabled || conflict; // don't touch the original code if module disabled if (reset && !this._firstActivation) { this._disableModule(); } else if (!reset) { this._firstActivation = false; this._activateModule(); } if (reset && this._firstActivation) console.debug(' SearchModule - Keeping untouched'); } _activateModule() { this._updateSearchViewWidth(); if (!this._overrides) this._overrides = new Me.Util.Overrides(); this._overrides.addOverride('AppSearchProvider', AppDisplay.AppSearchProvider.prototype, AppSearchProvider); this._overrides.addOverride('SearchResult', Search.SearchResult.prototype, SearchResult); this._overrides.addOverride('SearchResultsView', Search.SearchResultsView.prototype, SearchResultsView); this._overrides.addOverride('ListSearchResults', Search.ListSearchResults.prototype, ListSearchResults); this._overrides.addOverride('ListSearchResult', Search.ListSearchResult.prototype, ListSearchResultOverride); this._overrides.addOverride('Highlighter', Highlighter.prototype, HighlighterOverride); // Don't expand the search view vertically and align it to the top // this is important in the static workspace mode when the search view bg is not transparent // also the "Searching..." and "No Results" notifications will be closer to the search entry, with the distance given by margin-top in the stylesheet Main.overview.searchController.y_align = Clutter.ActorAlign.START; // Increase the maxResults for app search so that it can show more results in case the user decreases the size of the result icon const appSearchDisplay = Main.overview.searchController._searchResults._providers.filter(p => p.id === 'applications')[0]?.display; if (appSearchDisplay) appSearchDisplay._maxResults = 12; console.debug(' SearchModule - Activated'); } _disableModule() { const reset = true; const searchResults = Main.overview.searchController._searchResults; if (searchResults?._searchTimeoutId) { GLib.source_remove(searchResults._searchTimeoutId); searchResults._searchTimeoutId = 0; } this._updateSearchViewWidth(reset); if (this._overrides) this._overrides.removeAll(); this._overrides = null; Main.overview.searchController.y_align = Clutter.ActorAlign.FILL; console.debug(' WorkspaceSwitcherPopupModule - Disabled'); } _updateSearchViewWidth(reset = false) { const searchContent = Main.overview.searchController._searchResults._content; if (reset) { searchContent.set_style(''); } else { let width = SEARCH_MAX_WIDTH; if (Me.Util.monitorHasLowResolution()) width = Math.round(width * 0.8); width = Math.round(width * opt.SEARCH_VIEW_SCALE); searchContent.set_style(`max-width: ${width}px;`); } } }; const ListSearchResults = { _getMaxDisplayedResults() { return opt.SEARCH_MAX_ROWS; }, }; // AppDisplay.AppSearchProvider const AppSearchProvider = { getInitialResultSet(terms, cancellable) { // Defer until the parental controls manager is initialized, so the // results can be filtered correctly. if (!this._parentalControlsManager.initialized) { return new Promise(resolve => { let initializedId = this._parentalControlsManager.connect('app-filter-changed', async () => { if (this._parentalControlsManager.initialized) { this._parentalControlsManager.disconnect(initializedId); resolve(await this.getInitialResultSet(terms, cancellable)); } }); }); } const pattern = terms.join(' '); let appInfoList = Shell.AppSystem.get_default().get_installed(); let weightList = {}; appInfoList = appInfoList.filter(appInfo => { try { appInfo.get_id(); // catch invalid file encodings } catch (e) { return false; } let string = ''; let name; let shouldShow = false; if (appInfo.get_display_name) { // show only launchers that should be visible in this DE shouldShow = appInfo.should_show() && this._parentalControlsManager.shouldShowApp(appInfo); if (shouldShow) { let id = appInfo.get_id().split('.'); id = id[id.length - 2] || ''; let baseName = appInfo.get_string('Name') || ''; let dispName = appInfo.get_display_name() || ''; let gName = appInfo.get_generic_name() || ''; let description = appInfo.get_description() || ''; let categories = appInfo.get_string('Categories')?.replace(/;/g, ' ') || ''; let keywords = appInfo.get_string('Keywords')?.replace(/;/g, ' ') || ''; name = `${dispName} ${id}`; string = `${dispName} ${gName} ${baseName} ${description} ${categories} ${keywords} ${id}`; } } let m = -1; if (shouldShow && opt.SEARCH_FUZZY) { m = Me.Util.fuzzyMatch(pattern, name); m = (m + Me.Util.strictMatch(pattern, string)) / 2; } else if (shouldShow) { m = Me.Util.strictMatch(pattern, string); } if (m !== -1) weightList[appInfo.get_id()] = m; return shouldShow && (m !== -1); }); appInfoList.sort((a, b) => weightList[a.get_id()] > weightList[b.get_id()]); const usage = Shell.AppUsage.get_default(); // sort apps by usage list appInfoList.sort((a, b) => usage.compare(a.get_id(), b.get_id())); // prefer apps where any word in their name starts with the pattern appInfoList.sort((a, b) => Me.Util.isMoreRelevant(a.get_display_name(), b.get_display_name(), pattern)); let results = appInfoList.map(app => app.get_id()); if (opt.SEARCH_APP_GRID_MODE && Main.overview.dash.showAppsButton.checked) this._filterAppGrid(results); results = results.concat(this._systemActions.getMatchingActions(terms)); return new Promise(resolve => resolve(results)); }, _filterAppGrid(results) { const icons = Main.overview._overview.controls._appDisplay._orderedItems; icons.forEach(icon => { icon.visible = results.includes(icon.id); }); }, // App search result size createResultObject(resultMeta) { let iconSize = opt.SEARCH_ICON_SIZE; if (!iconSize) { iconSize = Me.Util.monitorHasLowResolution() ? 64 : 96; } if (resultMeta.id.endsWith('.desktop')) { const icon = new AppDisplay.AppIcon(this._appSys.lookup_app(resultMeta['id']), { expandTitleOnHover: false, }); icon.icon.setIconSize(iconSize); return icon; } else { this._iconSize = iconSize; return new SystemActionIcon(this, resultMeta); } }, }; const SystemActionIcon = GObject.registerClass({ // Registered name should be unique GTypeName: `SystemAction${Math.floor(Math.random() * 1000)}`, }, class SystemActionIcon extends Search.GridSearchResult { _init(provider, metaInfo, resultsView) { super._init(provider, metaInfo, resultsView); if (!Clutter.Container) this.add_style_class_name('grid-search-result-46'); this.icon._setSizeManually = true; this.icon.setIconSize(provider._iconSize); } activate() { SystemActions.getDefault().activateAction(this.metaInfo['id']); Main.overview.hide(); } }); const SearchResult = { activate() { this.provider.activateResult(this.metaInfo.id, this._resultsView.terms); if (this.metaInfo.clipboardText) { St.Clipboard.get_default().set_text( St.ClipboardType.CLIPBOARD, this.metaInfo.clipboardText); } // don't close overview if Shift key is pressed - Shift moves windows to the workspace if (!Me.Util.isShiftPressed()) Main.overview.toggle(); }, }; const SearchResultsView = { 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) this._searchTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, opt.SEARCH_DELAY, this._onSearchTimeout.bind(this)); this._highlighter = new Highlighter(this._terms); this.emit('terms-changed'); }, _doSearch() { this._startingSearch = false; let previousResults = this._results; this._results = {}; const term0 = this._terms[0]; const onlySupportedProviders = term0.startsWith(Me.WSP_PREFIX) || term0.startsWith(Me.ESP_PREFIX) || term0.startsWith(Me.RFSP_PREFIX); this._providers.forEach(provider => { const supportedProvider = ['open-windows', 'extensions', 'recent-files'].includes(provider.id); if (!onlySupportedProviders || (onlySupportedProviders && supportedProvider)) { let previousProviderResults = previousResults[provider.id]; this._doProviderSearch(provider, previousProviderResults); } else { // hide unwanted providers, they will show() automatically when needed provider.display.visible = false; } }); this._updateSearchProgress(); this._clearSearchTimeout(); }, _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.')); } }, _highlightFirstVisibleAppGridIcon() { const appDisplay = Main.overview._overview.controls._appDisplay; // appDisplay.grab_key_focus(); for (const icon of appDisplay._orderedItems) { if (icon.visible) { appDisplay.selectApp(icon.id); break; } } }, _maybeSetInitialSelection() { if (opt.SEARCH_APP_GRID_MODE && Main.overview.dash.showAppsButton.checked) { this._highlightFirstVisibleAppGridIcon(); return; } 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.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; } }, highlightDefault(highlight) { if (opt.SEARCH_APP_GRID_MODE && Main.overview.dash.showAppsButton.checked) { if (highlight) this._highlightFirstVisibleAppGridIcon(); } else { this._highlightDefault = highlight; this._setSelected(this._defaultResult, highlight); } }, }; // Add highlighting of the "name" part of the result for all providers const ListSearchResultOverride = { _highlightTerms() { let markup = this._resultsView.highlightTerms(this.metaInfo['name']); this.label_actor.clutter_text.set_markup(markup); markup = this._resultsView.highlightTerms(this.metaInfo['description'].split('\n')[0]); this._descriptionLabel.clutter_text.set_markup(markup); }, }; const HighlighterOverride = { /** * @param {?string[]} terms - list of terms to highlight */ /* constructor(terms) { if (!terms) return; const escapedTerms = terms .map(term => Shell.util_regex_escape(term)) .filter(term => term.length > 0); if (escapedTerms.length === 0) return; this._highlightRegex = new RegExp( `(${escapedTerms.join('|')})`, 'gi'); },*/ /** * Highlight all occurences of the terms defined for this * highlighter in the provided text using markup. * * @param {string} text - text to highlight the defined terms in * @returns {string} */ highlight(text, options) { if (!this._highlightRegex) return GLib.markup_escape_text(text, -1); // force use local settings if the class is overridden by another extension (WSP, ESP) const o = options || opt; let escaped = []; let lastMatchEnd = 0; let match; let style = ['', '']; if (o.HIGHLIGHT_DEFAULT) style = ['', '']; // The default highlighting by the bold style causes text to be "randomly" ellipsized in cases where it's not necessary // and also blurry // Underscore doesn't affect label size and all looks better else if (o.HIGHLIGHT_UNDERLINE) style = ['', '']; while ((match = this._highlightRegex.exec(text))) { if (match.index > lastMatchEnd) { let unmatched = GLib.markup_escape_text( text.slice(lastMatchEnd, match.index), -1); escaped.push(unmatched); } let matched = GLib.markup_escape_text(match[0], -1); escaped.push(`${style[0]}${matched}${style[1]}`); lastMatchEnd = match.index + match[0].length; } let unmatched = GLib.markup_escape_text( text.slice(lastMatchEnd), -1); escaped.push(unmatched); return escaped.join(''); }, };