3cb1fb7428
There is currently no proper transition from search results to the session: Only the top bar fades, but the rest of the screen just changes abruptly at the end of the transition. Fix this by hiding search results before leaving the overview, so the regular transition can take place. Close https://gitlab.gnome.org/GNOME/gnome-shell/-/issues/3821 Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/3152>
354 lines
11 KiB
JavaScript
354 lines
11 KiB
JavaScript
// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
|
|
|
|
import Clutter from 'gi://Clutter';
|
|
import GObject from 'gi://GObject';
|
|
import St from 'gi://St';
|
|
|
|
import * as Main from './main.js';
|
|
import * as Search from './search.js';
|
|
import * as ShellEntry from './shellEntry.js';
|
|
|
|
const FocusTrap = GObject.registerClass(
|
|
class FocusTrap extends St.Widget {
|
|
vfunc_navigate_focus(from, direction) {
|
|
if (direction === St.DirectionType.TAB_FORWARD ||
|
|
direction === St.DirectionType.TAB_BACKWARD)
|
|
return super.vfunc_navigate_focus(from, direction);
|
|
return false;
|
|
}
|
|
});
|
|
|
|
function getTermsForSearchString(searchString) {
|
|
searchString = searchString.replace(/^\s+/g, '').replace(/\s+$/g, '');
|
|
if (searchString === '')
|
|
return [];
|
|
return searchString.split(/\s+/);
|
|
}
|
|
|
|
export const SearchController = GObject.registerClass({
|
|
Properties: {
|
|
'search-active': GObject.ParamSpec.boolean(
|
|
'search-active', 'search-active', 'search-active',
|
|
GObject.ParamFlags.READABLE,
|
|
false),
|
|
},
|
|
}, class SearchController extends St.Widget {
|
|
_init(searchEntry, showAppsButton) {
|
|
super._init({
|
|
name: 'searchController',
|
|
layout_manager: new Clutter.BinLayout(),
|
|
x_expand: true,
|
|
y_expand: true,
|
|
visible: false,
|
|
});
|
|
|
|
this._showAppsButton = showAppsButton;
|
|
this._showAppsButton.connect('notify::checked', this._onShowAppsButtonToggled.bind(this));
|
|
|
|
this._activePage = null;
|
|
|
|
this._searchActive = false;
|
|
|
|
this._entry = searchEntry;
|
|
ShellEntry.addContextMenu(this._entry);
|
|
|
|
this._text = this._entry.clutter_text;
|
|
this._text.connect('text-changed', this._onTextChanged.bind(this));
|
|
this._text.connect('key-press-event', this._onKeyPress.bind(this));
|
|
this._text.connect('key-focus-in', () => {
|
|
this._searchResults.highlightDefault(true);
|
|
});
|
|
this._text.connect('key-focus-out', () => {
|
|
this._searchResults.highlightDefault(false);
|
|
});
|
|
this._entry.connect('popup-menu', () => {
|
|
if (!this._searchActive)
|
|
return;
|
|
|
|
this._entry.menu.close();
|
|
this._searchResults.popupMenuDefault();
|
|
});
|
|
this._entry.connect('notify::mapped', this._onMapped.bind(this));
|
|
global.stage.connectObject('notify::key-focus',
|
|
this._onStageKeyFocusChanged.bind(this), this);
|
|
|
|
this._entry.set_primary_icon(new St.Icon({
|
|
style_class: 'search-entry-icon',
|
|
icon_name: 'edit-find-symbolic',
|
|
}));
|
|
this._clearIcon = new St.Icon({
|
|
style_class: 'search-entry-icon',
|
|
icon_name: 'edit-clear-symbolic',
|
|
});
|
|
|
|
this._iconClickedId = 0;
|
|
this._capturedEventId = 0;
|
|
|
|
this._searchResults = new Search.SearchResultsView();
|
|
this.add_child(this._searchResults);
|
|
Main.ctrlAltTabManager.addGroup(this._entry, _('Search'), 'shell-focus-search-symbolic');
|
|
|
|
// Since the entry isn't inside the results container we install this
|
|
// dummy widget as the last results container child so that we can
|
|
// include the entry in the keynav tab path
|
|
this._focusTrap = new FocusTrap({can_focus: true});
|
|
this._focusTrap.connect('key-focus-in', () => {
|
|
this._entry.grab_key_focus();
|
|
});
|
|
this._searchResults.add_child(this._focusTrap);
|
|
|
|
global.focus_manager.add_group(this._searchResults);
|
|
|
|
this._stageKeyPressId = 0;
|
|
Main.overview.connect('showing', () => {
|
|
this._stageKeyPressId =
|
|
global.stage.connect('key-press-event', this._onStageKeyPress.bind(this));
|
|
});
|
|
Main.overview.connect('hiding', () => {
|
|
if (this._stageKeyPressId !== 0) {
|
|
global.stage.disconnect(this._stageKeyPressId);
|
|
this._stageKeyPressId = 0;
|
|
}
|
|
});
|
|
}
|
|
|
|
prepareToEnterOverview() {
|
|
this.reset();
|
|
this._setSearchActive(false);
|
|
}
|
|
|
|
prepareToLeaveOverview() {
|
|
this._setSearchActive(false);
|
|
}
|
|
|
|
vfunc_unmap() {
|
|
this.reset();
|
|
|
|
super.vfunc_unmap();
|
|
}
|
|
|
|
_setSearchActive(searchActive) {
|
|
if (this._searchActive === searchActive)
|
|
return;
|
|
|
|
this._searchActive = searchActive;
|
|
this.notify('search-active');
|
|
}
|
|
|
|
_onShowAppsButtonToggled() {
|
|
this._setSearchActive(false);
|
|
}
|
|
|
|
_onStageKeyPress(actor, event) {
|
|
// Ignore events while anything but the overview has
|
|
// pushed a modal (system modals, looking glass, ...)
|
|
if (Main.modalCount > 1)
|
|
return Clutter.EVENT_PROPAGATE;
|
|
|
|
let symbol = event.get_key_symbol();
|
|
|
|
if (symbol === Clutter.KEY_Escape) {
|
|
if (this._searchActive)
|
|
this.reset();
|
|
else if (this._showAppsButton.checked)
|
|
this._showAppsButton.checked = false;
|
|
else
|
|
Main.overview.hide();
|
|
return Clutter.EVENT_STOP;
|
|
} else if (this._shouldTriggerSearch(symbol)) {
|
|
this.startSearch(event);
|
|
}
|
|
return Clutter.EVENT_PROPAGATE;
|
|
}
|
|
|
|
_searchCancelled() {
|
|
this._setSearchActive(false);
|
|
|
|
// Leave the entry focused when it doesn't have any text;
|
|
// when replacing a selected search term, Clutter emits
|
|
// two 'text-changed' signals, one for deleting the previous
|
|
// text and one for the new one - the second one is handled
|
|
// incorrectly when we remove focus
|
|
// (https://bugzilla.gnome.org/show_bug.cgi?id=636341) */
|
|
if (this._text.text !== '')
|
|
this.reset();
|
|
}
|
|
|
|
reset() {
|
|
// Don't drop the key focus on Clutter's side if anything but the
|
|
// overview has pushed a modal (e.g. system modals when activated using
|
|
// the overview).
|
|
if (Main.modalCount <= 1)
|
|
global.stage.set_key_focus(null);
|
|
|
|
this._entry.text = '';
|
|
|
|
this._text.set_cursor_visible(true);
|
|
this._text.set_selection(0, 0);
|
|
}
|
|
|
|
_onStageKeyFocusChanged() {
|
|
let focus = global.stage.get_key_focus();
|
|
let appearFocused = this._entry.contains(focus) ||
|
|
this._searchResults.contains(focus);
|
|
|
|
this._text.set_cursor_visible(appearFocused);
|
|
|
|
if (appearFocused)
|
|
this._entry.add_style_pseudo_class('focus');
|
|
else
|
|
this._entry.remove_style_pseudo_class('focus');
|
|
}
|
|
|
|
_onMapped() {
|
|
if (this._entry.mapped) {
|
|
// Enable 'find-as-you-type'
|
|
this._capturedEventId =
|
|
global.stage.connect('captured-event', this._onCapturedEvent.bind(this));
|
|
this._text.set_cursor_visible(true);
|
|
this._text.set_selection(0, 0);
|
|
} else {
|
|
// Disable 'find-as-you-type'
|
|
if (this._capturedEventId > 0)
|
|
global.stage.disconnect(this._capturedEventId);
|
|
this._capturedEventId = 0;
|
|
}
|
|
}
|
|
|
|
_shouldTriggerSearch(symbol) {
|
|
if (symbol === Clutter.KEY_Multi_key)
|
|
return true;
|
|
|
|
if (symbol === Clutter.KEY_BackSpace && this._searchActive)
|
|
return true;
|
|
|
|
let unicode = Clutter.keysym_to_unicode(symbol);
|
|
if (unicode === 0)
|
|
return false;
|
|
|
|
if (getTermsForSearchString(String.fromCharCode(unicode)).length > 0)
|
|
return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
startSearch(event) {
|
|
global.stage.set_key_focus(this._text);
|
|
this._text.event(event, false);
|
|
}
|
|
|
|
// the entry does not show the hint
|
|
_isActivated() {
|
|
return this._text.text === this._entry.get_text();
|
|
}
|
|
|
|
_onTextChanged() {
|
|
let terms = getTermsForSearchString(this._entry.get_text());
|
|
|
|
const searchActive = terms.length > 0;
|
|
this._searchResults.setTerms(terms);
|
|
|
|
if (searchActive) {
|
|
this._setSearchActive(true);
|
|
|
|
this._entry.set_secondary_icon(this._clearIcon);
|
|
|
|
if (this._iconClickedId === 0) {
|
|
this._iconClickedId =
|
|
this._entry.connect('secondary-icon-clicked', this.reset.bind(this));
|
|
}
|
|
} else {
|
|
if (this._iconClickedId > 0) {
|
|
this._entry.disconnect(this._iconClickedId);
|
|
this._iconClickedId = 0;
|
|
}
|
|
|
|
this._entry.set_secondary_icon(null);
|
|
this._searchCancelled();
|
|
}
|
|
}
|
|
|
|
_onKeyPress(entry, event) {
|
|
let symbol = event.get_key_symbol();
|
|
if (symbol === Clutter.KEY_Escape) {
|
|
if (this._isActivated()) {
|
|
this.reset();
|
|
return Clutter.EVENT_STOP;
|
|
}
|
|
} else if (this._searchActive) {
|
|
let arrowNext, nextDirection;
|
|
if (entry.get_text_direction() === Clutter.TextDirection.RTL) {
|
|
arrowNext = Clutter.KEY_Left;
|
|
nextDirection = St.DirectionType.LEFT;
|
|
} else {
|
|
arrowNext = Clutter.KEY_Right;
|
|
nextDirection = St.DirectionType.RIGHT;
|
|
}
|
|
|
|
if (symbol === Clutter.KEY_Tab) {
|
|
this._searchResults.navigateFocus(St.DirectionType.TAB_FORWARD);
|
|
return Clutter.EVENT_STOP;
|
|
} else if (symbol === Clutter.KEY_ISO_Left_Tab) {
|
|
this._focusTrap.can_focus = false;
|
|
this._searchResults.navigateFocus(St.DirectionType.TAB_BACKWARD);
|
|
this._focusTrap.can_focus = true;
|
|
return Clutter.EVENT_STOP;
|
|
} else if (symbol === Clutter.KEY_Down) {
|
|
this._searchResults.navigateFocus(St.DirectionType.DOWN);
|
|
return Clutter.EVENT_STOP;
|
|
} else if (symbol === arrowNext && this._text.cursor_position === -1) {
|
|
this._searchResults.navigateFocus(nextDirection);
|
|
return Clutter.EVENT_STOP;
|
|
} else if (symbol === Clutter.KEY_Return || symbol === Clutter.KEY_KP_Enter) {
|
|
this._searchResults.activateDefault();
|
|
return Clutter.EVENT_STOP;
|
|
}
|
|
}
|
|
return Clutter.EVENT_PROPAGATE;
|
|
}
|
|
|
|
_onCapturedEvent(actor, event) {
|
|
if (event.type() === Clutter.EventType.BUTTON_PRESS) {
|
|
const targetActor = global.stage.get_event_actor(event);
|
|
if (targetActor !== this._text &&
|
|
this._text.has_key_focus() &&
|
|
this._text.text === '' &&
|
|
!this._text.has_preedit() &&
|
|
!Main.layoutManager.keyboardBox.contains(targetActor)) {
|
|
// the user clicked outside after activating the entry, but
|
|
// with no search term entered and no keyboard button pressed
|
|
// - cancel the search
|
|
this.reset();
|
|
}
|
|
}
|
|
|
|
return Clutter.EVENT_PROPAGATE;
|
|
}
|
|
|
|
/**
|
|
* addProvider:
|
|
*
|
|
* Add a search provider to the controller.
|
|
*
|
|
* @param {object} provider - a search provider implementation
|
|
*/
|
|
addProvider(provider) {
|
|
this._searchResults._registerProvider(provider);
|
|
}
|
|
|
|
/**
|
|
* removeProvider:
|
|
*
|
|
* Remove a search provider from the controller.
|
|
*
|
|
* @param {object} provider - a search provider implementation
|
|
*/
|
|
removeProvider(provider) {
|
|
this._searchResults._unregisterProvider(provider);
|
|
}
|
|
|
|
get searchActive() {
|
|
return this._searchActive;
|
|
}
|
|
});
|