From b7646d18ae34a38dd3103421cfbff053e907cfa1 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Sun, 29 Nov 2009 17:45:30 -0500 Subject: [PATCH] Add search.js, rebase search system on top The high level goal is to separate the concern of searching for things with display of those things; for example in newer mockups, applications are displayed exactly the same as they look in the AppWell. Another goal was optimizing for speed; for example, application search was pushed mostly down into C, and we avoid lowercasing and normalizing every item over and over. https://bugzilla.gnome.org/show_bug.cgi?id=603523 --- data/theme/gnome-shell.css | 34 ++- js/misc/docInfo.js | 57 +++- js/ui/appDisplay.js | 92 ++++++- js/ui/dash.js | 521 ++++++++++++++++++++----------------- js/ui/docDisplay.js | 50 +++- js/ui/overview.js | 1 + js/ui/placeDisplay.js | 236 ++++++++--------- js/ui/search.js | 272 +++++++++++++++++++ src/shell-app-system.c | 443 +++++++++++++++++++------------ src/shell-app-system.h | 23 +- 10 files changed, 1163 insertions(+), 566 deletions(-) create mode 100644 js/ui/search.js diff --git a/data/theme/gnome-shell.css b/data/theme/gnome-shell.css index 8b4263064..7fc91ffe5 100644 --- a/data/theme/gnome-shell.css +++ b/data/theme/gnome-shell.css @@ -152,17 +152,6 @@ StTooltip { spacing: 12px; } -.dash-search-section-header { - padding: 6px 0px; - spacing: 4px; - font-size: 12px; - color: #bbbbbb; -} - -.dash-search-section-title, dash-search-section-count { - font-weight: bold; -} - #searchEntry { padding: 4px; border-bottom: 1px solid #262626; @@ -237,6 +226,29 @@ StTooltip { height: 16px; } +.dash-search-section-header { + padding: 6px 0px; + spacing: 4px; +} + +.dash-search-section-results { + color: #ffffff; + padding-left: 4px; +} + +.dash-search-section-list-results { + spacing: 4px; +} + +.dash-search-result-content { + padding: 2px; +} + +.dash-search-result-content:selected { + padding: 1px; + border: 1px solid #262626; +} + /* GenericDisplay */ .generic-display-container { diff --git a/js/misc/docInfo.js b/js/misc/docInfo.js index e60d7ca61..559e9ecef 100644 --- a/js/misc/docInfo.js +++ b/js/misc/docInfo.js @@ -7,6 +7,7 @@ const Shell = imports.gi.Shell; const Lang = imports.lang; const Signals = imports.signals; +const Search = imports.ui.search; const Main = imports.ui.main; const THUMBNAIL_ICON_MARGIN = 2; @@ -23,6 +24,7 @@ DocInfo.prototype = { // correctly. See http://bugzilla.gnome.org/show_bug.cgi?id=567094 this.timestamp = recentInfo.get_modified().getTime() / 1000; this.name = recentInfo.get_display_name(); + this._lowerName = this.name.toLowerCase(); this.uri = recentInfo.get_uri(); this.mimeType = recentInfo.get_mime_type(); }, @@ -35,8 +37,24 @@ DocInfo.prototype = { Shell.DocSystem.get_default().open(this.recentInfo); }, - exists : function() { - return this.recentInfo.exists(); + matchTerms: function(terms) { + let mtype = Search.MatchType.NONE; + for (let i = 0; i < terms.length; i++) { + let term = terms[i]; + let idx = this._lowerName.indexOf(term); + if (idx == 0) { + if (mtype != Search.MatchType.NONE) + return Search.MatchType.MULTIPLE; + mtype = Search.MatchType.PREFIX; + } else if (idx > 0) { + if (mtype != Search.MatchType.NONE) + return Search.MatchType.MULTIPLE; + mtype = Search.MatchType.SUBSTRING; + } else { + continue; + } + } + return mtype; } }; @@ -93,6 +111,41 @@ DocManager.prototype = { queueExistenceCheck: function(count) { return this._docSystem.queue_existence_check(count); + }, + + initialSearch: function(terms) { + let multipleMatches = []; + let prefixMatches = []; + let substringMatches = []; + for (let i = 0; i < this._infosByTimestamp.length; i++) { + let item = this._infosByTimestamp[i]; + let mtype = item.matchTerms(terms); + if (mtype == Search.MatchType.MULTIPLE) + multipleMatches.push(item.uri); + else if (mtype == Search.MatchType.PREFIX) + prefixMatches.push(item.uri); + else if (mtype == Search.MatchType.SUBSTRING) + substringMatches.push(item.uri); + } + return multipleMatches.concat(prefixMatches.concat(substringMatches)); + }, + + subsearch: function(previousResults, terms) { + let multipleMatches = []; + let prefixMatches = []; + let substringMatches = []; + for (let i = 0; i < previousResults.length; i++) { + let uri = previousResults[i]; + let item = this._infosByUri[uri]; + let mtype = item.matchTerms(terms); + if (mtype == Search.MatchType.MULTIPLE) + multipleMatches.push(uri); + else if (mtype == Search.MatchType.PREFIX) + prefixMatches.push(uri); + else if (mtype == Search.MatchType.SUBSTRING) + substringMatches.push(uri); + } + return multipleMatches.concat(prefixMatches.concat(substringMatches)); } } diff --git a/js/ui/appDisplay.js b/js/ui/appDisplay.js index 651ab3ac4..e7c372a78 100644 --- a/js/ui/appDisplay.js +++ b/js/ui/appDisplay.js @@ -18,6 +18,7 @@ const AppFavorites = imports.ui.appFavorites; const DND = imports.ui.dnd; const GenericDisplay = imports.ui.genericDisplay; const Main = imports.ui.main; +const Search = imports.ui.search; const Workspaces = imports.ui.workspaces; const APPICON_SIZE = 48; @@ -134,17 +135,10 @@ AppDisplay.prototype = { this._addApp(app); } } else { - // Loop over the toplevel menu items, load the set of desktop file ids - // associated with each one. - let allMenus = this._appSystem.get_menus(); - for (let i = 0; i < allMenus.length; i++) { - let menu = allMenus[i]; - let menuApps = this._appSystem.get_applications_for_menu(menu.id); - - for (let j = 0; j < menuApps.length; j++) { - let app = menuApps[j]; - this._addApp(app); - } + let apps = this._appSystem.get_flattened_apps(); + for (let i = 0; i < apps.length; i++) { + let app = apps[i]; + this._addApp(app); } } @@ -220,6 +214,82 @@ AppDisplay.prototype = { Signals.addSignalMethods(AppDisplay.prototype); +function BaseAppSearchProvider() { + this._init(); +} + +BaseAppSearchProvider.prototype = { + __proto__: Search.SearchProvider.prototype, + + _init: function(name) { + Search.SearchProvider.prototype._init.call(this, name); + this._appSys = Shell.AppSystem.get_default(); + }, + + getResultMeta: function(resultId) { + let app = this._appSys.get_app(resultId); + if (!app) + return null; + return { 'id': resultId, + 'name': app.get_name(), + 'icon': app.create_icon_texture(Search.RESULT_ICON_SIZE)}; + }, + + activateResult: function(id) { + let app = this._appSys.get_app(id); + app.launch(); + } +}; + +function AppSearchProvider() { + this._init(); +} + +AppSearchProvider.prototype = { + __proto__: BaseAppSearchProvider.prototype, + + _init: function() { + BaseAppSearchProvider.prototype._init.call(this, _("APPLICATIONS")); + }, + + getInitialResultSet: function(terms) { + return this._appSys.initial_search(false, terms); + }, + + getSubsearchResultSet: function(previousResults, terms) { + return this._appSys.subsearch(false, previousResults, terms); + }, + + expandSearch: function(terms) { + log("TODO expand search"); + } +} + +function PrefsSearchProvider() { + this._init(); +} + +PrefsSearchProvider.prototype = { + __proto__: BaseAppSearchProvider.prototype, + + _init: function() { + BaseAppSearchProvider.prototype._init.call(this, _("PREFERENCES")); + }, + + getInitialResultSet: function(terms) { + return this._appSys.initial_search(true, terms); + }, + + getSubsearchResultSet: function(previousResults, terms) { + return this._appSys.subsearch(true, previousResults, terms); + }, + + expandSearch: function(terms) { + let controlCenter = this._appSys.load_from_desktop_file('gnomecc.desktop'); + controlCenter.launch(); + Main.overview.hide(); + } +} function AppIcon(app) { this._init(app); diff --git a/js/ui/dash.js b/js/ui/dash.js index 1fc2184d6..3fe7e95b5 100644 --- a/js/ui/dash.js +++ b/js/ui/dash.js @@ -17,6 +17,10 @@ const DocDisplay = imports.ui.docDisplay; const PlaceDisplay = imports.ui.placeDisplay; const GenericDisplay = imports.ui.genericDisplay; const Main = imports.ui.main; +const Search = imports.ui.search; + +// 25 search results (per result type) should be enough for everyone +const MAX_RENDERED_SEARCH_RESULTS = 25; const DEFAULT_PADDING = 4; const DEFAULT_SPACING = 4; @@ -332,6 +336,254 @@ SearchEntry.prototype = { }; Signals.addSignalMethods(SearchEntry.prototype); +function SearchResult(provider, metaInfo, terms) { + this._init(provider, metaInfo, terms); +} + +SearchResult.prototype = { + _init: function(provider, metaInfo, terms) { + this.provider = provider; + this.metaInfo = metaInfo; + this.actor = new St.Clickable({ style_class: 'dash-search-result', + reactive: true, + x_align: St.Align.START, + x_fill: true, + y_fill: true }); + this.actor._delegate = this; + + let content = provider.createResultActor(metaInfo, terms); + if (content == null) { + content = new St.BoxLayout({ style_class: 'dash-search-result-content' }); + let title = new St.Label({ text: this.metaInfo['name'] }); + let icon = this.metaInfo['icon']; + content.add(icon, { y_fill: false }); + content.add(title, { expand: true, y_fill: false }); + } + this._content = content; + this.actor.set_child(content); + + this.actor.connect('clicked', Lang.bind(this, this._onResultClicked)); + }, + + setSelected: function(selected) { + this._content.set_style_pseudo_class(selected ? 'selected' : null); + }, + + activate: function() { + this.provider.activateResult(this.metaInfo.id); + Main.overview.toggle(); + }, + + _onResultClicked: function(actor, event) { + this.activate(); + } +} + +function OverflowSearchResults(provider) { + this._init(provider); +} + +OverflowSearchResults.prototype = { + __proto__: Search.SearchResultDisplay.prototype, + + _init: function(provider) { + Search.SearchResultDisplay.prototype._init.call(this, provider); + this.actor = new St.OverflowBox({ style_class: 'dash-search-section-list-results' }); + }, + + renderResults: function(results, terms) { + for (let i = 0; i < results.length && i < MAX_RENDERED_SEARCH_RESULTS; i++) { + let result = results[i]; + let meta = this.provider.getResultMeta(result); + let display = new SearchResult(this.provider, meta, terms); + this.actor.add_actor(display.actor); + } + }, + + getVisibleCount: function() { + return this.actor.get_n_visible(); + }, + + selectIndex: function(index) { + let nVisible = this.actor.get_n_visible(); + let children = this.actor.get_children(); + if (this.selectionIndex >= 0) { + let prevActor = children[this.selectionIndex]; + prevActor._delegate.setSelected(false); + } + this.selectionIndex = -1; + if (index >= nVisible) + return false; + else if (index < 0) + return false; + let targetActor = children[index]; + targetActor._delegate.setSelected(true); + this.selectionIndex = index; + return true; + } +} + +function SearchResults(searchSystem) { + this._init(searchSystem); +} + +SearchResults.prototype = { + _init: function(searchSystem) { + this._searchSystem = searchSystem; + + this.actor = new St.BoxLayout({ name: 'dashSearchResults', + vertical: true }); + this._searchingNotice = new St.Label({ style_class: 'dash-search-starting', + text: _("Searching...") }); + this.actor.add(this._searchingNotice); + this._selectedProvider = -1; + this._providers = this._searchSystem.getProviders(); + this._providerMeta = []; + for (let i = 0; i < this._providers.length; i++) { + let provider = this._providers[i]; + let providerBox = new St.BoxLayout({ style_class: 'dash-search-section', + vertical: true }); + let titleButton = new St.Button({ style_class: 'dash-search-section-header', + reactive: true, + x_fill: true, + y_fill: true }); + titleButton.connect('clicked', Lang.bind(this, function () { this._onHeaderClicked(provider); })); + providerBox.add(titleButton); + let titleBox = new St.BoxLayout(); + titleButton.set_child(titleBox); + let title = new St.Label({ text: provider.title }); + let count = new St.Label(); + titleBox.add(title, { expand: true }); + titleBox.add(count); + + let resultDisplayBin = new St.Bin({ style_class: 'dash-search-section-results', + x_fill: true, + y_fill: true }); + providerBox.add(resultDisplayBin, { expand: true }); + let resultDisplay = provider.createResultContainerActor(); + if (resultDisplay == null) { + resultDisplay = new OverflowSearchResults(provider); + } + resultDisplayBin.set_child(resultDisplay.actor); + + this._providerMeta.push({ actor: providerBox, + resultDisplay: resultDisplay, + count: count }); + this.actor.add(providerBox); + } + }, + + _clearDisplay: function() { + this._selectedProvider = -1; + this._visibleResultsCount = 0; + for (let i = 0; i < this._providerMeta.length; i++) { + let meta = this._providerMeta[i]; + meta.resultDisplay.clear(); + meta.actor.hide(); + } + }, + + reset: function() { + this._searchSystem.reset(); + this._searchingNotice.hide(); + this._clearDisplay(); + }, + + startingSearch: function() { + this.reset(); + this._searchingNotice.show(); + }, + + _metaForProvider: function(provider) { + return this._providerMeta[this._providers.indexOf(provider)]; + }, + + updateSearch: function (searchString) { + let results = this._searchSystem.updateSearch(searchString); + + this._searchingNotice.hide(); + this._clearDisplay(); + + let terms = this._searchSystem.getTerms(); + + for (let i = 0; i < results.length; i++) { + let [provider, providerResults] = results[i]; + let meta = this._metaForProvider(provider); + meta.actor.show(); + meta.resultDisplay.renderResults(providerResults, terms); + meta.count.set_text(""+providerResults.length); + } + + this.selectDown(); + + return true; + }, + + _onHeaderClicked: function(provider) { + provider.expandSearch(this._searchSystem.getTerms()); + }, + + _modifyActorSelection: function(resultDisplay, up) { + let success; + let index = resultDisplay.getSelectionIndex(); + if (up && index == -1) + index = resultDisplay.getVisibleCount() - 1; + else if (up) + index = index - 1; + else + index = index + 1; + return resultDisplay.selectIndex(index); + }, + + selectUp: function() { + for (let i = this._selectedProvider; i >= 0; i--) { + let meta = this._providerMeta[i]; + if (!meta.actor.visible) + continue; + let success = this._modifyActorSelection(meta.resultDisplay, true); + if (success) { + this._selectedProvider = i; + return; + } + } + if (this._providerMeta.length > 0) { + this._selectedProvider = this._providerMeta.length - 1; + this.selectUp(); + } + }, + + selectDown: function() { + let current = this._selectedProvider; + if (current == -1) + current = 0; + for (let i = current; i < this._providerMeta.length; i++) { + let meta = this._providerMeta[i]; + if (!meta.actor.visible) + continue; + let success = this._modifyActorSelection(meta.resultDisplay, false); + if (success) { + this._selectedProvider = i; + return; + } + } + if (this._providerMeta.length > 0) { + this._selectedProvider = 0; + this.selectDown(); + } + }, + + activateSelected: function() { + let current = this._selectedProvider; + if (current < 0) + return; + let meta = this._providerMeta[current]; + let resultDisplay = meta.resultDisplay; + let children = resultDisplay.actor.get_children(); + let targetActor = children[resultDisplay.getSelectionIndex()]; + targetActor._delegate.activate(); + } +} + function MoreLink() { this._init(); } @@ -500,9 +752,9 @@ Dash.prototype = { vertical: true, reactive: true }); - // Size for this one explicitly set from overlay.js - this.searchArea = new Big.Box({ y_align: Big.BoxAlignment.CENTER }); - + // The searchArea just holds the entry + this.searchArea = new St.BoxLayout({ name: "dashSearchArea", + vertical: true }); this.sectionArea = new St.BoxLayout({ name: "dashSections", vertical: true }); @@ -517,16 +769,35 @@ Dash.prototype = { this._searchActive = false; this._searchPending = false; this._searchEntry = new SearchEntry(); - this.searchArea.append(this._searchEntry.actor, Big.BoxPackFlags.EXPAND); + this.searchArea.add(this._searchEntry.actor, { y_fill: false, expand: true }); + + this._searchSystem = new Search.SearchSystem(); + this._searchSystem.registerProvider(new AppDisplay.AppSearchProvider()); + this._searchSystem.registerProvider(new AppDisplay.PrefsSearchProvider()); + this._searchSystem.registerProvider(new PlaceDisplay.PlaceSearchProvider()); + this._searchSystem.registerProvider(new DocDisplay.DocSearchProvider()); + + this.searchResults = new SearchResults(this._searchSystem); + this.actor.add(this.searchResults.actor); + this.searchResults.actor.hide(); this._searchTimeoutId = 0; this._searchEntry.entry.connect('text-changed', Lang.bind(this, function (se, prop) { let text = this._searchEntry.getText(); - text = text.replace(/^\s+/g, "").replace(/\s+$/g, "") + text = text.replace(/^\s+/g, "").replace(/\s+$/g, ""); let searchPreviouslyActive = this._searchActive; this._searchActive = text != ''; this._searchPending = this._searchActive && !searchPreviouslyActive; - this._updateDashActors(); + if (this._searchPending) { + this.searchResults.startingSearch(); + } + if (this._searchActive) { + this.searchResults.actor.show(); + this.sectionArea.hide(); + } else { + this.searchResults.actor.hide(); + this.sectionArea.show(); + } if (!this._searchActive) { if (this._searchTimeoutId > 0) { Mainloop.source_remove(this._searchTimeoutId); @@ -543,24 +814,15 @@ Dash.prototype = { Mainloop.source_remove(this._searchTimeoutId); this._doSearch(); } - // Only one of the displays will have an item selected, so it's ok to - // call activateSelected() on all of them. - for (var i = 0; i < this._searchSections.length; i++) { - let section = this._searchSections[i]; - section.resultArea.display.activateSelected(); - } + this.searchResults.activateSelected(); return true; })); this._searchEntry.entry.connect('key-press-event', Lang.bind(this, function (se, e) { - let text = this._searchEntry.getText(); let symbol = e.get_key_symbol(); if (symbol == Clutter.Escape) { // Escape will keep clearing things back to the desktop. - // If we are showing a particular section of search, go back to all sections. - if (this._searchResultsSingleShownSection != null) - this._showAllSearchSections(); // If we have an active search, we remove it. - else if (this._searchActive) + if (this._searchActive) this._searchEntry.reset(); // Next, if we're in one of the "more" modes or showing the details pane, close them else if (this._activePane != null) @@ -572,44 +834,14 @@ Dash.prototype = { } else if (symbol == Clutter.Up) { if (!this._searchActive) return true; - // selectUp and selectDown wrap around in their respective displays - // too, but there doesn't seem to be any flickering if we first select - // something in one display, but then unset the selection, and move - // it to the other display, so it's ok to do that. - for (var i = 0; i < this._searchSections.length; i++) { - let section = this._searchSections[i]; - if (section.resultArea.display.hasSelected() && !section.resultArea.display.selectUp()) { - if (this._searchResultsSingleShownSection != section.type) { - // We need to move the selection to the next section above this section that has items, - // wrapping around at the bottom, if necessary. - let newSectionIndex = this._findAnotherSectionWithItems(i, -1); - if (newSectionIndex >= 0) { - this._searchSections[newSectionIndex].resultArea.display.selectLastItem(); - section.resultArea.display.unsetSelected(); - } - } - break; - } - } + this.searchResults.selectUp(); + return true; } else if (symbol == Clutter.Down) { if (!this._searchActive) return true; - for (var i = 0; i < this._searchSections.length; i++) { - let section = this._searchSections[i]; - if (section.resultArea.display.hasSelected() && !section.resultArea.display.selectDown()) { - if (this._searchResultsSingleShownSection != section.type) { - // We need to move the selection to the next section below this section that has items, - // wrapping around at the top, if necessary. - let newSectionIndex = this._findAnotherSectionWithItems(i, 1); - if (newSectionIndex >= 0) { - this._searchSections[newSectionIndex].resultArea.display.selectFirstItem(); - section.resultArea.display.unsetSelected(); - } - } - break; - } - } + + this.searchResults.selectDown(); return true; } return false; @@ -666,102 +898,12 @@ Dash.prototype = { this._docDisplay.emit('changed'); this.sectionArea.add(this._docsSection.actor, { expand: true }); - - /***** Search Results *****/ - - this._searchResultsSection = new Section(_("SEARCH RESULTS"), true); - - this._searchResultsSingleShownSection = null; - - this._searchResultsSection.header.connect('back-link-activated', Lang.bind(this, function () { - this._showAllSearchSections(); - })); - - this._searchSections = [ - { type: APPS, - title: _("APPLICATIONS"), - header: null, - resultArea: null - }, - { type: PREFS, - title: _("PREFERENCES"), - header: null, - resultArea: null - }, - { type: DOCS, - title: _("RECENT DOCUMENTS"), - header: null, - resultArea: null - }, - { type: PLACES, - title: _("PLACES"), - header: null, - resultArea: null - } - ]; - - for (var i = 0; i < this._searchSections.length; i++) { - let section = this._searchSections[i]; - section.header = new SearchSectionHeader(section.title, - Lang.bind(this, - function () { - this._showSingleSearchSection(section.type); - })); - this._searchResultsSection.content.add(section.header.actor); - section.resultArea = new ResultArea(section.type, GenericDisplay.GenericDisplayFlags.DISABLE_VSCROLLING); - this._searchResultsSection.content.add(section.resultArea.actor, { expand: true }); - createPaneForDetails(this, section.resultArea.display); - } - - this.sectionArea.add(this._searchResultsSection.actor, { expand: true }); - this._searchResultsSection.actor.hide(); }, _doSearch: function () { this._searchTimeoutId = 0; let text = this._searchEntry.getText(); - text = text.replace(/^\s+/g, "").replace(/\s+$/g, ""); - - let selectionSet = false; - - for (var i = 0; i < this._searchSections.length; i++) { - let section = this._searchSections[i]; - section.resultArea.display.setSearch(text); - let itemCount = section.resultArea.display.getMatchedItemsCount(); - let itemCountText = itemCount + ""; - section.header.countText.text = itemCountText; - - if (this._searchResultsSingleShownSection == section.type) { - this._searchResultsSection.header.setCountText(itemCountText); - if (itemCount == 0) { - section.resultArea.actor.hide(); - } else { - section.resultArea.actor.show(); - } - } else if (this._searchResultsSingleShownSection == null) { - // Don't show the section if it has no results - if (itemCount == 0) { - section.header.actor.hide(); - section.resultArea.actor.hide(); - } else { - section.header.actor.show(); - section.resultArea.actor.show(); - } - } - - // Refresh the selection when a new search is applied. - section.resultArea.display.unsetSelected(); - if (!selectionSet && section.resultArea.display.hasItems() && - (this._searchResultsSingleShownSection == null || this._searchResultsSingleShownSection == section.type)) { - section.resultArea.display.selectFirstItem(); - selectionSet = true; - } - } - - // Here work around a bug that I never quite tracked down - // the root cause of; it appeared that the search results - // section was getting a 0 height allocation. - this._searchResultsSection.content.queue_relayout(); + this.searchResults.updateSearch(text); return false; }, @@ -794,101 +936,6 @@ Dash.prototype = { } })); Main.overview.addPane(pane); - }, - - _updateDashActors: function() { - if (this._searchPending) { - this._searchResultsSection.actor.show(); - // We initially hide all sections when we start a search. When the search timeout - // first runs, the sections that have matching results are shown. As the search - // is refined, only the sections that have matching results will be shown. - for (let i = 0; i < this._searchSections.length; i++) { - let section = this._searchSections[i]; - section.header.actor.hide(); - section.resultArea.actor.hide(); - } - this._appsSection.actor.hide(); - this._placesSection.actor.hide(); - this._docsSection.actor.hide(); - } else if (!this._searchActive) { - this._showAllSearchSections(); - this._searchResultsSection.actor.hide(); - this._appsSection.actor.show(); - this._placesSection.actor.show(); - this._docsSection.actor.show(); - } - }, - - _showSingleSearchSection: function(type) { - // We currently don't allow going from showing one section to showing another section. - if (this._searchResultsSingleShownSection != null) { - throw new Error("We were already showing a single search section: '" + this._searchResultsSingleShownSection - + "' when _showSingleSearchSection() was called for '" + type + "'"); - } - for (var i = 0; i < this._searchSections.length; i++) { - let section = this._searchSections[i]; - if (section.type == type) { - // This will be the only section shown. - section.resultArea.display.selectFirstItem(); - let itemCount = section.resultArea.display.getMatchedItemsCount(); - let itemCountText = itemCount + ""; - section.header.actor.hide(); - this._searchResultsSection.header.setTitle(section.title); - this._searchResultsSection.header.setBackLinkVisible(true); - this._searchResultsSection.header.setCountText(itemCountText); - } else { - // We need to hide this section. - section.header.actor.hide(); - section.resultArea.actor.hide(); - section.resultArea.display.unsetSelected(); - } - } - this._searchResultsSingleShownSection = type; - }, - - _showAllSearchSections: function() { - if (this._searchResultsSingleShownSection != null) { - let selectionSet = false; - for (var i = 0; i < this._searchSections.length; i++) { - let section = this._searchSections[i]; - if (section.type == this._searchResultsSingleShownSection) { - // This will no longer be the only section shown. - let itemCount = section.resultArea.display.getMatchedItemsCount(); - if (itemCount != 0) { - section.header.actor.show(); - section.resultArea.display.selectFirstItem(); - selectionSet = true; - } - this._searchResultsSection.header.setTitle(_("SEARCH RESULTS")); - this._searchResultsSection.header.setBackLinkVisible(false); - this._searchResultsSection.header.setCountText(""); - } else { - // We need to restore this section. - let itemCount = section.resultArea.display.getMatchedItemsCount(); - if (itemCount != 0) { - section.header.actor.show(); - section.resultArea.actor.show(); - // This ensures that some other section will have the selection if the - // single section that was being displayed did not have any items. - if (!selectionSet) { - section.resultArea.display.selectFirstItem(); - selectionSet = true; - } - } - } - } - this._searchResultsSingleShownSection = null; - } - }, - - _findAnotherSectionWithItems: function(index, increment) { - let pos = _getIndexWrapped(index, increment, this._searchSections.length); - while (pos != index) { - if (this._searchSections[pos].resultArea.display.hasItems()) - return pos; - pos = _getIndexWrapped(pos, increment, this._searchSections.length); - } - return -1; } }; Signals.addSignalMethods(Dash.prototype); diff --git a/js/ui/docDisplay.js b/js/ui/docDisplay.js index 9ba6c4c65..ee4fa7ee5 100644 --- a/js/ui/docDisplay.js +++ b/js/ui/docDisplay.js @@ -10,11 +10,14 @@ 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; @@ -179,13 +182,8 @@ DocDisplay.prototype = { this._matchedItemKeys = []; let docIdsToRemove = []; for (docId in this._allItems) { - // this._allItems[docId].exists() checks if the resource still exists - if (this._allItems[docId].exists()) { - this._matchedItems[docId] = 1; - this._matchedItemKeys.push(docId); - } else { - docIdsToRemove.push(docId); - } + this._matchedItems[docId] = 1; + this._matchedItemKeys.push(docId); } for (docId in docIdsToRemove) { @@ -479,3 +477,41 @@ DashDocDisplay.prototype = { Signals.addSignalMethods(DashDocDisplay.prototype); +function DocSearchProvider() { + this._init(); +} + +DocSearchProvider.prototype = { + __proto__: Search.SearchProvider.prototype, + + _init: function(name) { + Search.SearchProvider.prototype._init.call(this, _("DOCUMENTS")); + 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"); + } +}; diff --git a/js/ui/overview.js b/js/ui/overview.js index 74859a724..a9697fff0 100644 --- a/js/ui/overview.js +++ b/js/ui/overview.js @@ -184,6 +184,7 @@ Overview.prototype = { this._dash.actor.set_size(displayGridColumnWidth, contentHeight); this._dash.searchArea.height = this._workspacesY - contentY; this._dash.sectionArea.height = this._workspacesHeight; + this._dash.searchResults.actor.height = this._workspacesHeight; // place the 'Add Workspace' button in the bottom row of the grid addRemoveButtonSize = Math.floor(displayGridRowHeight * 3/5); diff --git a/js/ui/placeDisplay.js b/js/ui/placeDisplay.js index 82a36745d..868ad11a5 100644 --- a/js/ui/placeDisplay.js +++ b/js/ui/placeDisplay.js @@ -15,7 +15,7 @@ const _ = Gettext.gettext; const DND = imports.ui.dnd; const Main = imports.ui.main; -const GenericDisplay = imports.ui.genericDisplay; +const Search = imports.ui.search; const NAUTILUS_PREFS_DIR = '/apps/nautilus/preferences'; const DESKTOP_IS_HOME_KEY = NAUTILUS_PREFS_DIR + '/desktop_is_home_dir'; @@ -30,16 +30,30 @@ const PLACES_ICON_SIZE = 16; * @iconFactory: A JavaScript callback which will create an icon texture given a size parameter * @launch: A JavaScript callback to launch the entry */ -function PlaceInfo(name, iconFactory, launch) { - this._init(name, iconFactory, launch); +function PlaceInfo(id, name, iconFactory, launch) { + this._init(id, name, iconFactory, launch); } PlaceInfo.prototype = { - _init: function(name, iconFactory, launch) { + _init: function(id, name, iconFactory, launch) { + this.id = id; this.name = name; + this._lowerName = name.toLowerCase(); this.iconFactory = iconFactory; this.launch = launch; - this.id = null; + }, + + matchTerms: function(terms) { + let mtype = Search.MatchType.NONE; + for (let i = 0; i < terms.length; i++) { + let term = terms[i]; + let idx = this._lowerName.indexOf(term); + if (idx == 0) + return Search.MatchType.PREFIX; + else if (idx > 0) + mtype = Search.MatchType.SUBSTRING; + } + return mtype; } } @@ -52,6 +66,7 @@ PlacesManager.prototype = { let gconf = Shell.GConf.get_default(); gconf.watch_directory(NAUTILUS_PREFS_DIR); + this._defaultPlaces = []; this._mounts = []; this._bookmarks = []; this._isDesktopHome = false; @@ -60,7 +75,7 @@ PlacesManager.prototype = { let homeUri = homeFile.get_uri(); let homeLabel = Shell.util_get_label_for_uri (homeUri); let homeIcon = Shell.util_get_icon_for_uri (homeUri); - this._home = new PlaceInfo(homeLabel, + this._home = new PlaceInfo('special:home', homeLabel, function(size) { return Shell.TextureCache.get_default().load_gicon(homeIcon, size); }, @@ -73,7 +88,7 @@ PlacesManager.prototype = { let desktopUri = desktopFile.get_uri(); let desktopLabel = Shell.util_get_label_for_uri (desktopUri); let desktopIcon = Shell.util_get_icon_for_uri (desktopUri); - this._desktopMenu = new PlaceInfo(desktopLabel, + this._desktopMenu = new PlaceInfo('special:desktop', desktopLabel, function(size) { return Shell.TextureCache.get_default().load_gicon(desktopIcon, size); }, @@ -81,7 +96,7 @@ PlacesManager.prototype = { Gio.app_info_launch_default_for_uri(desktopUri, global.create_app_launch_context()); }); - this._connect = new PlaceInfo(_("Connect to..."), + this._connect = new PlaceInfo('special:connect', _("Connect to..."), function (size) { return Shell.TextureCache.get_default().load_icon_name("applications-internet", size); }, @@ -101,7 +116,7 @@ PlacesManager.prototype = { } if (networkApp != null) { - this._network = new PlaceInfo(networkApp.get_name(), + this._network = new PlaceInfo('special:network', networkApp.get_name(), function(size) { return networkApp.create_icon_texture(size); }, @@ -110,6 +125,16 @@ PlacesManager.prototype = { }); } + this._defaultPlaces.push(this._home); + + if (!this._isDesktopHome) + this._defaultPlaces.push(this._desktopMenu); + + if (this._network) + this._defaultPlaces.push(this._network); + + this._defaultPlaces.push(this._connect); + /* * Show devices, code more or less ported from nautilus-places-sidebar.c */ @@ -238,7 +263,7 @@ PlacesManager.prototype = { continue; let icon = Shell.util_get_icon_for_uri(bookmark); - let item = new PlaceInfo(label, + let item = new PlaceInfo('bookmark:' + bookmark, label, function(size) { return Shell.TextureCache.get_default().load_gicon(icon, size); }, @@ -267,7 +292,8 @@ PlacesManager.prototype = { let mountIcon = mount.get_icon(); let root = mount.get_root(); let mountUri = root.get_uri(); - let devItem = new PlaceInfo(mountLabel, + let devItem = new PlaceInfo('mount:' + mountUri, + mountLabel, function(size) { return Shell.TextureCache.get_default().load_gicon(mountIcon, size); }, @@ -282,16 +308,7 @@ PlacesManager.prototype = { }, getDefaultPlaces: function () { - let places = [this._home]; - - if (!this._isDesktopHome) - places.push(this._desktopMenu); - - if (this._network) - places.push(this._network); - - places.push(this._connect); - return places; + return this._defaultPlaces; }, getBookmarks: function () { @@ -300,6 +317,28 @@ PlacesManager.prototype = { getMounts: function () { return this._mounts; + }, + + _lookupById: function(sourceArray, id) { + for (let i = 0; i < sourceArray.length; i++) { + let place = sourceArray[i]; + if (place.id == id) + return place; + } + return null; + }, + + lookupPlaceById: function(id) { + let colonIdx = id.indexOf(':'); + let type = id.substring(0, colonIdx); + let sourceArray = null; + if (type == 'special') + sourceArray = this._defaultPlaces; + else if (type == 'mount') + sourceArray = this._mounts; + else if (type == 'bookmark') + sourceArray = this._bookmarks; + return this._lookupById(sourceArray, id); } }; @@ -421,120 +460,67 @@ DashPlaceDisplay.prototype = { Signals.addSignalMethods(DashPlaceDisplay.prototype); - -function PlaceDisplayItem(placeInfo) { - this._init(placeInfo); +function PlaceSearchProvider() { + this._init(); } -PlaceDisplayItem.prototype = { - __proto__: GenericDisplay.GenericDisplayItem.prototype, +PlaceSearchProvider.prototype = { + __proto__: Search.SearchProvider.prototype, - _init : function(placeInfo) { - GenericDisplay.GenericDisplayItem.prototype._init.call(this); - this._info = placeInfo; - - this._setItemInfo(placeInfo.name, ''); + _init: function() { + Search.SearchProvider.prototype._init.call(this, _("PLACES")); }, - //// Public method overrides //// - - // Opens an application represented by this display item. - launch : function() { - this._info.launch(); + getResultMeta: function(resultId) { + let placeInfo = Main.placesManager.lookupPlaceById(resultId); + if (!placeInfo) + return null; + return { 'id': resultId, + 'name': placeInfo.name, + 'icon': placeInfo.iconFactory(Search.RESULT_ICON_SIZE) }; }, - shellWorkspaceLaunch: function() { - this._info.launch(); + activateResult: function(id) { + let placeInfo = Main.placesManager.lookupPlaceById(id); + placeInfo.launch(); }, - //// Protected method overrides //// - - // Returns an icon for the item. - _createIcon: function() { - return this._info.iconFactory(GenericDisplay.ITEM_DISPLAY_ICON_SIZE); + _compareResultMeta: function (idA, idB) { + let infoA = Main.placesManager.lookupPlaceById(idA); + let infoB = Main.placesManager.lookupPlaceById(idB); + return infoA.name.localeCompare(infoB.name); }, - // Returns a preview icon for the item. - _createPreviewIcon: function() { - return this._info.iconFactory(GenericDisplay.PREVIEW_ICON_SIZE); + _searchPlaces: function(places, terms) { + let multipleResults = []; + let prefixResults = []; + let substringResults = []; + + terms = terms.map(String.toLowerCase); + + for (let i = 0; i < places.length; i++) { + let place = places[i]; + let mtype = place.matchTerms(terms); + if (mtype == Search.MatchType.MULTIPLE) + multipleResults.push(place.id); + else if (mtype == Search.MatchType.PREFIX) + prefixResults.push(place.id); + else if (mtype == Search.MatchType.SUBSTRING) + substringResults.push(place.id); + } + multipleResults.sort(this._compareResultMeta); + prefixResults.sort(this._compareResultMeta); + substringResults.sort(this._compareResultMeta); + return multipleResults.concat(prefixResults.concat(substringResults)); + }, + + getInitialResultSet: function(terms) { + let places = Main.placesManager.getAllPlaces(); + return this._searchPlaces(places, terms); + }, + + getSubsearchResultSet: function(previousResults, terms) { + let places = previousResults.map(function (id) { return Main.placesManager.lookupPlaceById(id); }); + return this._searchPlaces(places, terms); } - -}; - -function PlaceDisplay(flags) { - this._init(flags); } - -PlaceDisplay.prototype = { - __proto__: GenericDisplay.GenericDisplay.prototype, - - _init: function(flags) { - GenericDisplay.GenericDisplay.prototype._init.call(this, flags); - this._stale = true; - Main.placesManager.connect('places-updated', Lang.bind(this, function (e) { - this._stale = true; - })); - }, - - //// Protected method overrides //// - _refreshCache: function () { - if (!this._stale) - return true; - this._allItems = {}; - let array = Main.placesManager.getAllPlaces(); - for (let i = 0; i < array.length; i ++) { - // We are using an array id as placeInfo id because placeInfo doesn't have any - // other information piece that can be used as a unique id. There are different - // types of placeInfo, such as devices and directories that would result in differently - // structured ids. Also the home directory can show up in both the default places and in - // bookmarks which means its URI can't be used as a unique id. (This does mean it can - // appear twice in search results, though that doesn't happen at the moment because we - // name it "Home Folder" in default places and it's named with the user's system name - // if it appears as a bookmark.) - let placeInfo = array[i]; - placeInfo.id = i; - this._allItems[i] = placeInfo; - } - this._stale = false; - return false; - }, - - // Sets the list of the displayed items. - _setDefaultList: function() { - this._matchedItems = {}; - this._matchedItemKeys = []; - for (id in this._allItems) { - this._matchedItems[id] = 1; - this._matchedItemKeys.push(id); - } - this._matchedItemKeys.sort(Lang.bind(this, this._compareItems)); - }, - - // Checks if the item info can be a match for the search string by checking - // the name of the place. Item info is expected to be PlaceInfo. - // Returns a boolean flag indicating if itemInfo is a match. - _isInfoMatching: function(itemInfo, search) { - if (search == null || search == '') - return true; - - let name = itemInfo.name.toLowerCase(); - if (name.indexOf(search) >= 0) - return true; - - return false; - }, - - // Compares items associated with the item ids based on the alphabetical order - // of the item names. - // Returns an integer value indicating the result of the comparison. - _compareItems: function(itemIdA, itemIdB) { - let placeA = this._allItems[itemIdA]; - let placeB = this._allItems[itemIdB]; - return placeA.name.localeCompare(placeB.name); - }, - - // Creates a PlaceDisplayItem based on itemInfo, which is expected to be a PlaceInfo object. - _createDisplayItem: function(itemInfo) { - return new PlaceDisplayItem(itemInfo); - } -}; diff --git a/js/ui/search.js b/js/ui/search.js new file mode 100644 index 000000000..2b738523c --- /dev/null +++ b/js/ui/search.js @@ -0,0 +1,272 @@ +/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */ + +const Signals = imports.signals; +const St = imports.gi.St; + +const RESULT_ICON_SIZE = 24; + +// Not currently referenced by the search API, but +// this enumeration can be useful for provider +// implementations. +const MatchType = { + NONE: 0, + MULTIPLE: 1, + PREFIX: 2, + SUBSTRING: 3 +}; + +function SearchResultDisplay(provider) { + this._init(provider); +} + +SearchResultDisplay.prototype = { + _init: function(provider) { + this.provider = provider; + this.actor = null; + this.selectionIndex = -1; + }, + + /** + * renderResults: + * @results: List of identifier strings + * @terms: List of search term strings + * + * Display the given search matches which resulted + * from the given terms. It's expected that not + * all results will fit in the space for the container + * actor; in this case, show as many as makes sense + * for your result type. + * + * The terms are useful for search match highlighting. + */ + renderResults: function(results, terms) { + throw new Error("not implemented"); + }, + + /** + * clear: + * Remove all results from this display and reset the selection index. + */ + clear: function() { + this.actor.get_children().forEach(function (actor) { actor.destroy(); }); + this.selectionIndex = -1; + }, + + /** + * getSelectionIndex: + * + * Returns the index of the selected actor, or -1 if none. + */ + getSelectionIndex: function() { + return this.selectionIndex; + }, + + /** + * getVisibleResultCount: + * + * Returns: The number of actors visible. + */ + getVisibleResultCount: function() { + throw new Error("not implemented"); + }, + + /** + * selectIndex: + * @index: Integer index + * + * Move selection to the given index. + * Return true if successful, false if no more results + * available. + */ + selectIndex: function() { + throw new Error("not implemented"); + } +}; + +/** + * SearchProvider: + * + * Subclass this object to add a new result type + * to the search system, then call registerProvider() + * in SearchSystem with an instance. + */ +function SearchProvider(title) { + this._init(title); +} + +SearchProvider.prototype = { + _init: function(title) { + this.title = title; + }, + + /** + * getInitialResultSet: + * @terms: Array of search terms, treated as logical OR + * + * Called when the user first begins a search (most likely + * therefore a single term of length one or two), or when + * a new term is added. + * + * Should return an array of result identifier strings representing + * items which match the given search terms. This + * is expected to be a substring match on the metadata for a given + * item. Ordering of returned results is up to the discretion of the provider, + * but you should follow these heruistics: + * + * * Put items which match multiple search terms before single matches + * * Put items which match on a prefix before non-prefix substring matches + * + * This function should be fast; do not perform unindexed full-text searches + * or network queries. + */ + getInitialResultSet: function(terms) { + throw new Error("not implemented"); + }, + + /** + * getSubsearchResultSet: + * @previousResults: Array of item identifiers + * @newTerms: Updated search terms + * + * Called when a search is performed which is a "subsearch" of + * the previous search; i.e. when every search term has exactly + * one corresponding term in the previous search which is a prefix + * of the new term. + * + * This allows search providers to only search through the previous + * result set, rather than possibly performing a full re-query. + */ + getSubsearchResultSet: function(previousResults, newTerms) { + throw new Error("not implemented"); + }, + + /** + * getResultInfo: + * @id: Result identifier string + * + * Return an object with 'id', 'name', (both strings) and 'icon' (Clutter.Texture) + * properties which describe the given search result. + */ + getResultMeta: function(id) { + throw new Error("not implemented"); + }, + + /** + * createResultContainer: + * + * Search providers may optionally override this to render their + * results in a custom fashion. The default implementation + * will create a vertical list. + * + * Returns: An instance of SearchResultDisplay. + */ + createResultContainerActor: function() { + return null; + }, + + /** + * createResultActor: + * @resultMeta: Object with result metadata + * @terms: Array of search terms, should be used for highlighting + * + * Search providers may optionally override this to render a + * particular serch result in a custom fashion. The default + * implementation will show the icon next to the name. + * + * The actor should be an instance of St.Widget, with the style class + * 'dash-search-result-content'. + */ + createResultActor: function(resultMeta, terms) { + return null; + }, + + /** + * activateResult: + * @id: Result identifier string + * + * Called when the user chooses a given result. + */ + activateResult: function(id) { + throw new Error("not implemented"); + }, + + /** + * expandSearch: + * + * Called when the user clicks on the header for this + * search section. Should typically launch an external program + * displaying search results for that item type. + */ + expandSearch: function(terms) { + throw new Error("not implemented"); + } +} +Signals.addSignalMethods(SearchProvider.prototype); + +function SearchSystem() { + this._init(); +} + +SearchSystem.prototype = { + _init: function() { + this._providers = []; + this.reset(); + }, + + registerProvider: function (provider) { + this._providers.push(provider); + }, + + getProviders: function() { + return this._providers; + }, + + getTerms: function() { + return this._previousTerms; + }, + + reset: function() { + this._previousTerms = []; + this._previousResults = []; + }, + + updateSearch: function(searchString) { + searchString = searchString.replace(/^\s+/g, "").replace(/\s+$/g, ""); + if (searchString == '') + return null; + + let terms = searchString.split(/\s+/); + let isSubSearch = terms.length == this._previousTerms.length; + if (isSubSearch) { + for (let i = 0; i < terms.length; i++) { + if (terms[i].indexOf(this._previousTerms[i]) != 0) { + isSubSearch = false; + break; + } + } + } + + let results = []; + if (isSubSearch) { + for (let i = 0; i < this._previousResults.length; i++) { + let [provider, previousResults] = this._previousResults[i]; + let providerResults = provider.getSubsearchResultSet(previousResults, terms); + if (providerResults.length > 0) + results.push([provider, providerResults]); + } + } else { + for (let i = 0; i < this._providers.length; i++) { + let provider = this._providers[i]; + let providerResults = provider.getInitialResultSet(terms); + if (providerResults.length > 0) + results.push([provider, providerResults]); + } + } + + this._previousTerms = terms; + this._previousResults = results; + + return results; + } +} +Signals.addSignalMethods(SearchSystem.prototype); diff --git a/src/shell-app-system.c b/src/shell-app-system.c index 0f61576d5..ffe3ccc2a 100644 --- a/src/shell-app-system.c +++ b/src/shell-app-system.c @@ -48,9 +48,7 @@ struct _ShellAppSystemPrivate { GHashTable *app_id_to_info; GHashTable *app_id_to_app; - GHashTable *cached_menu_contents; /* > */ - GSList *cached_app_menus; /* ShellAppMenuEntry */ - + GSList *cached_flattened_apps; /* ShellAppInfo */ GSList *cached_settings; /* ShellAppInfo */ gint app_monitor_id; @@ -58,7 +56,6 @@ struct _ShellAppSystemPrivate { guint app_change_timeout_id; }; -static void free_appinfo_gslist (gpointer list); static void shell_app_system_finalize (GObject *object); static gboolean on_tree_changed (gpointer user_data); static void on_tree_changed_cb (GMenuTree *tree, gpointer user_data); @@ -83,6 +80,10 @@ struct _ShellAppInfo { */ guint refcount; + char *casefolded_name; + char *name_collation_key; + char *casefolded_description; + GMenuTreeItem *entry; GKeyFile *keyfile; @@ -104,6 +105,11 @@ shell_app_info_unref (ShellAppInfo *info) { if (--info->refcount > 0) return; + + g_free (info->casefolded_name); + g_free (info->name_collation_key); + g_free (info->casefolded_description); + switch (info->type) { case SHELL_APP_INFO_TYPE_ENTRY: @@ -129,7 +135,7 @@ shell_app_info_new_from_tree_item (GMenuTreeItem *item) if (!item) return NULL; - info = g_slice_alloc (sizeof (ShellAppInfo)); + info = g_slice_alloc0 (sizeof (ShellAppInfo)); info->type = SHELL_APP_INFO_TYPE_ENTRY; info->refcount = 1; info->entry = gmenu_tree_item_ref (item); @@ -141,7 +147,7 @@ shell_app_info_new_from_window (MetaWindow *window) { ShellAppInfo *info; - info = g_slice_alloc (sizeof (ShellAppInfo)); + info = g_slice_alloc0 (sizeof (ShellAppInfo)); info->type = SHELL_APP_INFO_TYPE_WINDOW; info->refcount = 1; info->window = g_object_ref (window); @@ -159,7 +165,7 @@ shell_app_info_new_from_keyfile_take_ownership (GKeyFile *keyfile, { ShellAppInfo *info; - info = g_slice_alloc (sizeof (ShellAppInfo)); + info = g_slice_alloc0 (sizeof (ShellAppInfo)); info->type = SHELL_APP_INFO_TYPE_DESKTOP_FILE; info->refcount = 1; info->keyfile = keyfile; @@ -167,29 +173,6 @@ shell_app_info_new_from_keyfile_take_ownership (GKeyFile *keyfile, return info; } -static gpointer -shell_app_menu_entry_copy (gpointer entryp) -{ - ShellAppMenuEntry *entry; - ShellAppMenuEntry *copy; - entry = entryp; - copy = g_new0 (ShellAppMenuEntry, 1); - copy->name = g_strdup (entry->name); - copy->id = g_strdup (entry->id); - copy->icon = g_strdup (entry->icon); - return copy; -} - -static void -shell_app_menu_entry_free (gpointer entryp) -{ - ShellAppMenuEntry *entry = entryp; - g_free (entry->name); - g_free (entry->id); - g_free (entry->icon); - g_free (entry); -} - static void shell_app_system_class_init(ShellAppSystemClass *klass) { GObjectClass *gobject_class = (GObjectClass *)klass; @@ -225,9 +208,6 @@ shell_app_system_init (ShellAppSystem *self) /* Key is owned by info */ priv->app_id_to_app = g_hash_table_new (g_str_hash, g_str_equal); - priv->cached_menu_contents = g_hash_table_new_full (g_str_hash, g_str_equal, - g_free, free_appinfo_gslist); - /* For now, we want to pick up Evince, Nautilus, etc. We'll * handle NODISPLAY semantics at a higher level or investigate them * case by case. @@ -257,15 +237,12 @@ shell_app_system_finalize (GObject *object) gmenu_tree_unref (priv->apps_tree); gmenu_tree_unref (priv->settings_tree); - g_hash_table_destroy (priv->cached_menu_contents); - g_hash_table_destroy (priv->app_id_to_info); g_hash_table_destroy (priv->app_id_to_app); - g_slist_foreach (priv->cached_app_menus, (GFunc)shell_app_menu_entry_free, NULL); - g_slist_free (priv->cached_app_menus); - priv->cached_app_menus = NULL; - + g_slist_foreach (priv->cached_flattened_apps, (GFunc)shell_app_info_unref, NULL); + g_slist_free (priv->cached_flattened_apps); + priv->cached_flattened_apps = NULL; g_slist_foreach (priv->cached_settings, (GFunc)shell_app_info_unref, NULL); g_slist_free (priv->cached_settings); priv->cached_settings = NULL; @@ -273,60 +250,10 @@ shell_app_system_finalize (GObject *object) G_OBJECT_CLASS (shell_app_system_parent_class)->finalize(object); } -static void -free_appinfo_gslist (gpointer listp) -{ - GSList *list = listp; - g_slist_foreach (list, (GFunc) shell_app_info_unref, NULL); - g_slist_free (list); -} - -static void -reread_directories (ShellAppSystem *self, GSList **cache, GMenuTree *tree) -{ - GMenuTreeDirectory *trunk; - GSList *entries; - GSList *iter; - - trunk = gmenu_tree_get_root_directory (tree); - entries = gmenu_tree_directory_get_contents (trunk); - - g_slist_foreach (*cache, (GFunc)shell_app_menu_entry_free, NULL); - g_slist_free (*cache); - *cache = NULL; - - for (iter = entries; iter; iter = iter->next) - { - GMenuTreeItem *item = iter->data; - - switch (gmenu_tree_item_get_type (item)) - { - case GMENU_TREE_ITEM_DIRECTORY: - { - GMenuTreeDirectory *dir = iter->data; - ShellAppMenuEntry *shell_entry = g_new0 (ShellAppMenuEntry, 1); - shell_entry->name = g_strdup (gmenu_tree_directory_get_name (dir)); - shell_entry->id = g_strdup (gmenu_tree_directory_get_menu_id (dir)); - shell_entry->icon = g_strdup (gmenu_tree_directory_get_icon (dir)); - - *cache = g_slist_prepend (*cache, shell_entry); - } - break; - default: - break; - } - - gmenu_tree_item_unref (item); - } - *cache = g_slist_reverse (*cache); - - g_slist_free (entries); - gmenu_tree_item_unref (trunk); -} - static GSList * gather_entries_recurse (ShellAppSystem *monitor, GSList *apps, + GHashTable *unique, GMenuTreeDirectory *root) { GSList *contents; @@ -342,13 +269,17 @@ gather_entries_recurse (ShellAppSystem *monitor, case GMENU_TREE_ITEM_ENTRY: { ShellAppInfo *app = shell_app_info_new_from_tree_item (item); - apps = g_slist_prepend (apps, app); + if (!g_hash_table_lookup (unique, shell_app_info_get_id (app))) + { + apps = g_slist_prepend (apps, app); + g_hash_table_insert (unique, (char*)shell_app_info_get_id (app), app); + } } break; case GMENU_TREE_ITEM_DIRECTORY: { GMenuTreeDirectory *dir = (GMenuTreeDirectory*)item; - apps = gather_entries_recurse (monitor, apps, dir); + apps = gather_entries_recurse (monitor, apps, unique, dir); } break; default: @@ -365,6 +296,7 @@ gather_entries_recurse (ShellAppSystem *monitor, static void reread_entries (ShellAppSystem *self, GSList **cache, + GHashTable *unique, GMenuTree *tree) { GMenuTreeDirectory *trunk; @@ -375,46 +307,40 @@ reread_entries (ShellAppSystem *self, g_slist_free (*cache); *cache = NULL; - *cache = gather_entries_recurse (self, *cache, trunk); + *cache = gather_entries_recurse (self, *cache, unique, trunk); gmenu_tree_item_unref (trunk); } static void -cache_by_id (ShellAppSystem *self, GSList *apps, gboolean ref) +cache_by_id (ShellAppSystem *self, GSList *apps) { GSList *iter; for (iter = apps; iter; iter = iter->next) { ShellAppInfo *info = iter->data; - if (ref) - shell_app_info_ref (info); + shell_app_info_ref (info); /* the name is owned by the info itself */ - g_hash_table_insert (self->priv->app_id_to_info, (char*)shell_app_info_get_id (info), - info); + g_hash_table_replace (self->priv->app_id_to_info, (char*)shell_app_info_get_id (info), + info); } } static void reread_menus (ShellAppSystem *self) { - GSList *apps; - GMenuTreeDirectory *trunk; + GHashTable *unique = g_hash_table_new (g_str_hash, g_str_equal); - reread_directories (self, &(self->priv->cached_app_menus), self->priv->apps_tree); + reread_entries (self, &(self->priv->cached_flattened_apps), unique, self->priv->apps_tree); + g_hash_table_remove_all (unique); + reread_entries (self, &(self->priv->cached_settings), unique, self->priv->settings_tree); + g_hash_table_destroy (unique); - reread_entries (self, &(self->priv->cached_settings), self->priv->settings_tree); - - /* Now loop over applications.menu and settings.menu, inserting each by desktop file - * ID into a hash */ g_hash_table_remove_all (self->priv->app_id_to_info); - trunk = gmenu_tree_get_root_directory (self->priv->apps_tree); - apps = gather_entries_recurse (self, NULL, trunk); - gmenu_tree_item_unref (trunk); - cache_by_id (self, apps, FALSE); - g_slist_free (apps); - cache_by_id (self, self->priv->cached_settings, TRUE); + + cache_by_id (self, self->priv->cached_flattened_apps); + cache_by_id (self, self->priv->cached_settings); } static gboolean @@ -423,7 +349,6 @@ on_tree_changed (gpointer user_data) ShellAppSystem *self = SHELL_APP_SYSTEM (user_data); reread_menus (self); - g_hash_table_remove_all (self->priv->cached_menu_contents); g_signal_emit (self, signals[INSTALLED_CHANGED], 0); @@ -469,21 +394,8 @@ shell_app_info_get_type (void) return gtype; } -GType -shell_app_menu_entry_get_type (void) -{ - static GType gtype = G_TYPE_INVALID; - if (gtype == G_TYPE_INVALID) - { - gtype = g_boxed_type_register_static ("ShellAppMenuEntry", - shell_app_menu_entry_copy, - shell_app_menu_entry_free); - } - return gtype; -} - /** - * shell_app_system_get_applications_for_menu: + * shell_app_system_get_flattened_apps: * * Traverses a toplevel menu, and returns all items under it. Nested items * are flattened. This value is computed on initial call and cached thereafter @@ -492,41 +404,9 @@ shell_app_menu_entry_get_type (void) * Return value: (transfer none) (element-type ShellAppInfo): List of applications */ GSList * -shell_app_system_get_applications_for_menu (ShellAppSystem *self, - const char *menu) +shell_app_system_get_flattened_apps (ShellAppSystem *self) { - GSList *apps; - - apps = g_hash_table_lookup (self->priv->cached_menu_contents, menu); - if (!apps) - { - char *path; - GMenuTreeDirectory *menu_entry; - path = g_strdup_printf ("/%s", menu); - menu_entry = gmenu_tree_get_directory_from_path (self->priv->apps_tree, path); - g_free (path); - g_assert (menu_entry != NULL); - - apps = gather_entries_recurse (self, NULL, menu_entry); - g_hash_table_insert (self->priv->cached_menu_contents, g_strdup (menu), apps); - - gmenu_tree_item_unref (menu_entry); - } - - return apps; -} - -/** - * shell_app_system_get_menus: - * - * Returns a list of toplevel #ShellAppMenuEntry items - * - * Return value: (transfer none) (element-type AppMenuEntry): List of toplevel menus - */ -GSList * -shell_app_system_get_menus (ShellAppSystem *monitor) -{ - return monitor->priv->cached_app_menus; + return self->priv->cached_flattened_apps; } /** @@ -711,6 +591,249 @@ shell_app_system_lookup_heuristic_basename (ShellAppSystem *system, return NULL; } +typedef enum { + MATCH_NONE, + MATCH_MULTIPLE, /* Matches multiple terms */ + MATCH_PREFIX, /* Strict prefix */ + MATCH_SUBSTRING /* Not prefix, substring */ +} ShellAppInfoSearchMatch; + +static char * +normalize_and_casefold (const char *str) +{ + char *normalized, *result; + + if (str == NULL) + return NULL; + + normalized = g_utf8_normalize (str, -1, G_NORMALIZE_ALL); + result = g_utf8_casefold (normalized, -1); + g_free (normalized); + return result; +} + +static void +shell_app_info_init_search_data (ShellAppInfo *info) +{ + const char *name; + const char *comment; + + g_assert (info->type == SHELL_APP_INFO_TYPE_ENTRY); + + name = gmenu_tree_entry_get_name ((GMenuTreeEntry*)info->entry); + info->casefolded_name = normalize_and_casefold (name); + info->name_collation_key = g_utf8_collate_key (name, -1); + + comment = gmenu_tree_entry_get_comment ((GMenuTreeEntry*)info->entry); + info->casefolded_description = normalize_and_casefold (comment); +} + +static ShellAppInfoSearchMatch +shell_app_info_match_terms (ShellAppInfo *info, + GSList *terms) +{ + GSList *iter; + ShellAppInfoSearchMatch match; + + if (G_UNLIKELY(!info->casefolded_name)) + shell_app_info_init_search_data (info); + + match = MATCH_NONE; + for (iter = terms; iter; iter = iter->next) + { + const char *term = iter->data; + const char *p; + + p = strstr (info->casefolded_name, term); + if (p == info->casefolded_name) + { + if (match != MATCH_NONE) + return MATCH_MULTIPLE; + else + match = MATCH_PREFIX; + } + else if (p != NULL) + match = MATCH_SUBSTRING; + + if (!info->casefolded_description) + continue; + p = strstr (info->casefolded_description, term); + if (p != NULL) + match = MATCH_SUBSTRING; + } + return match; +} + +static gint +shell_app_info_compare (gconstpointer a, + gconstpointer b, + gpointer data) +{ + ShellAppSystem *system = data; + const char *id_a = a; + const char *id_b = b; + ShellAppInfo *info_a = g_hash_table_lookup (system->priv->app_id_to_info, id_a); + ShellAppInfo *info_b = g_hash_table_lookup (system->priv->app_id_to_info, id_b); + + return strcmp (info_a->name_collation_key, info_b->name_collation_key); +} + +static GSList * +sort_and_concat_results (ShellAppSystem *system, + GSList *multiple_matches, + GSList *prefix_matches, + GSList *substring_matches) +{ + multiple_matches = g_slist_sort_with_data (multiple_matches, shell_app_info_compare, system); + prefix_matches = g_slist_sort_with_data (prefix_matches, shell_app_info_compare, system); + substring_matches = g_slist_sort_with_data (substring_matches, shell_app_info_compare, system); + return g_slist_concat (multiple_matches, g_slist_concat (prefix_matches, substring_matches)); +} + +/** + * normalize_terms: + * @terms: (element-type utf8): Input search terms + * + * Returns: (element-type utf8) (transfer full): Unicode-normalized and lowercased terms + */ +static GSList * +normalize_terms (GSList *terms) +{ + GSList *normalized_terms = NULL; + GSList *iter; + for (iter = terms; iter; iter = iter->next) + { + const char *term = iter->data; + normalized_terms = g_slist_prepend (normalized_terms, normalize_and_casefold (term)); + } + return normalized_terms; +} + +static inline void +shell_app_system_do_match (ShellAppSystem *system, + ShellAppInfo *info, + GSList *terms, + GSList **multiple_results, + GSList **prefix_results, + GSList **substring_results) +{ + const char *id = shell_app_info_get_id (info); + ShellAppInfoSearchMatch match; + + if (shell_app_info_get_is_nodisplay (info)) + return; + + match = shell_app_info_match_terms (info, terms); + switch (match) + { + case MATCH_NONE: + break; + case MATCH_MULTIPLE: + *multiple_results = g_slist_prepend (*multiple_results, (char *) id); + break; + case MATCH_PREFIX: + *prefix_results = g_slist_prepend (*prefix_results, (char *) id); + break; + case MATCH_SUBSTRING: + *substring_results = g_slist_prepend (*substring_results, (char *) id); + break; + } +} + +static GSList * +shell_app_system_initial_search_internal (ShellAppSystem *self, + GSList *terms, + GSList *source) +{ + GSList *multiple_results = NULL; + GSList *prefix_results = NULL; + GSList *substring_results = NULL; + GSList *iter; + GSList *normalized_terms = normalize_terms (terms); + + for (iter = source; iter; iter = iter->next) + { + ShellAppInfo *info = iter->data; + + shell_app_system_do_match (self, info, normalized_terms, &multiple_results, &prefix_results, &substring_results); + } + g_slist_foreach (normalized_terms, (GFunc)g_free, NULL); + g_slist_free (normalized_terms); + + return sort_and_concat_results (self, multiple_results, prefix_results, substring_results); +} + +/** + * shell_app_system_initial_search: + * @self: A #ShellAppSystem + * @prefs: %TRUE iff we should search preferences instead of apps + * @terms: (element-type utf8): List of terms, logical OR + * + * Search through applications for the given search terms. Note that returned + * strings are only valid until a return to the main loop. + * + * Returns: (transfer container) (element-type utf8): List of application identifiers + */ +GSList * +shell_app_system_initial_search (ShellAppSystem *self, + gboolean prefs, + GSList *terms) +{ + return shell_app_system_initial_search_internal (self, terms, + prefs ? self->priv->cached_settings : self->priv->cached_flattened_apps); +} + +/** + * shell_app_system_subsearch: + * @self: A #ShellAppSystem + * @prefs: %TRUE iff we should search preferences instead of apps + * @previous_results: (element-type utf8): List of previous results + * @terms: (element-type utf8): List of terms, logical OR + * + * Search through a previous result set; for more information, see + * js/ui/search.js. Note the value of @prefs must be + * the same as passed to shell_app_system_initial_search(). Note that returned + * strings are only valid until a return to the main loop. + * + * Returns: (transfer container) (element-type utf8): List of application identifiers + */ +GSList * +shell_app_system_subsearch (ShellAppSystem *system, + gboolean prefs, + GSList *previous_results, + GSList *terms) +{ + GSList *iter; + GSList *multiple_results = NULL; + GSList *prefix_results = NULL; + GSList *substring_results = NULL; + GSList *normalized_terms = normalize_terms (terms); + + /* Note prefs is deliberately ignored; both apps and prefs are in app_id_to_app, + * but we have the parameter for consistency and in case in the future + * they're not in the same data structure. + */ + + for (iter = previous_results; iter; iter = iter->next) + { + const char *id = iter->data; + ShellAppInfo *info; + + info = g_hash_table_lookup (system->priv->app_id_to_info, id); + if (!info) + continue; + + shell_app_system_do_match (system, info, normalized_terms, &multiple_results, &prefix_results, &substring_results); + } + g_slist_foreach (normalized_terms, (GFunc)g_free, NULL); + g_slist_free (normalized_terms); + + /* Note that a shorter term might have matched as a prefix, but + when extended only as a substring, so we have to redo the + sort rather than reusing the existing ordering */ + return sort_and_concat_results (system, multiple_results, prefix_results, substring_results); +} + const char * shell_app_info_get_id (ShellAppInfo *info) { diff --git a/src/shell-app-system.h b/src/shell-app-system.h index 3e3bee111..d6c75817c 100644 --- a/src/shell-app-system.h +++ b/src/shell-app-system.h @@ -37,18 +37,6 @@ struct _ShellAppSystemClass GType shell_app_system_get_type (void) G_GNUC_CONST; ShellAppSystem* shell_app_system_get_default(void); -GSList *shell_app_system_get_applications_for_menu (ShellAppSystem *system, const char *menu); - -typedef struct _ShellAppMenuEntry ShellAppMenuEntry; - -struct _ShellAppMenuEntry { - char *name; - char *id; - char *icon; -}; - -GType shell_app_menu_entry_get_type (void); - typedef struct _ShellAppInfo ShellAppInfo; #define SHELL_TYPE_APP_INFO (shell_app_info_get_type ()) @@ -85,8 +73,17 @@ ShellApp *shell_app_system_lookup_heuristic_basename (ShellAppSystem *system, co ShellAppInfo *shell_app_system_create_from_window (ShellAppSystem *system, MetaWindow *window); -GSList *shell_app_system_get_menus (ShellAppSystem *system); +GSList *shell_app_system_get_flattened_apps (ShellAppSystem *system); GSList *shell_app_system_get_all_settings (ShellAppSystem *system); +GSList *shell_app_system_initial_search (ShellAppSystem *system, + gboolean prefs, + GSList *terms); + +GSList *shell_app_system_subsearch (ShellAppSystem *system, + gboolean prefs, + GSList *previous_results, + GSList *terms); + #endif /* __SHELL_APP_SYSTEM_H__ */