gnome-shell/js/ui/viewSelector.js
Dan Winship 4dd4c9f99f Use more actor.grab_key_focus() and less stage.connect('key-press-event')
Until recently, the clutter keyboard focus was almost always kept on
the stage, and bits of code that wanted to do stuff with the keyboard
would just watch for key-press-events on the stage. In several places,
the code wasn't even bothering to ensure that the focus was on the
stage, which caused problems with other actors that explicitly grabbed
focus.

A previous fix for this (f21403fd) was to always reset the focus to
the stage after calling pushModal(), but a better fix is to just
actually make use of the keyboard focus everywhere rather than having
everyone try to read events off the stage.

Now pushModal(actor) also does actor.grab_key_focus(), and various
bits of code have been changed to read key events off their own
toplevels rather than off the stage, meaning there's no chance of them
accidentally getting someone else's events.

https://bugzilla.gnome.org/show_bug.cgi?id=618885
2010-12-20 17:32:07 -05:00

617 lines
21 KiB
JavaScript

/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */
const Clutter = imports.gi.Clutter;
const Mainloop = imports.mainloop;
const Meta = imports.gi.Meta;
const Signals = imports.signals;
const Lang = imports.lang;
const Shell = imports.gi.Shell;
const St = imports.gi.St;
const Gettext = imports.gettext.domain('gnome-shell');
const _ = Gettext.gettext;
const Main = imports.ui.main;
const Search = imports.ui.search;
const SearchDisplay = imports.ui.searchDisplay;
const Tweener = imports.ui.tweener;
function SearchEntry(focusBase) {
this._init(focusBase);
}
SearchEntry.prototype = {
_init : function(focusBase) {
this.actor = new St.Entry({ name: 'searchEntry',
hint_text: _("Search your computer") });
this.entry = this.actor.clutter_text;
this.actor.clutter_text.connect('text-changed', Lang.bind(this,
function() {
if (this.isActive())
this.actor.set_secondary_icon_from_file(global.imagedir +
'close-black.svg');
else
this.actor.set_secondary_icon_from_file(null);
}));
this.actor.connect('secondary-icon-clicked', Lang.bind(this,
function() {
this.reset();
}));
this.actor.connect('destroy', Lang.bind(this, this._onDestroy));
global.stage.connect('notify::key-focus', Lang.bind(this, this._updateCursorVisibility));
this.pane = null;
this._capturedEventId = 0;
this._focusBase = focusBase;
},
_updateCursorVisibility: function() {
let focus = global.stage.get_key_focus();
if (focus == this._focusBase || focus == this.entry)
this.entry.set_cursor_visible(true);
else
this.entry.set_cursor_visible(false);
},
show: function() {
if (this._capturedEventId == 0)
this._capturedEventId = global.stage.connect('captured-event',
Lang.bind(this, this._onCapturedEvent));
this.entry.set_cursor_visible(true);
this.entry.set_selection(0, 0);
},
hide: function() {
if (this._capturedEventId > 0) {
global.stage.disconnect(this._capturedEventId);
this._capturedEventId = 0;
}
},
reset: function () {
let [x, y, mask] = global.get_pointer();
let actor = global.stage.get_actor_at_pos (Clutter.PickMode.REACTIVE,
x, y);
// this.actor is never hovered directly, only its clutter_text and icon
let hovered = this.actor == actor.get_parent();
this.actor.set_hover(hovered);
this.entry.text = '';
// Return focus to the viewSelector
global.stage.set_key_focus(this._focusBase);
this.entry.set_cursor_visible(true);
this.entry.set_selection(0, 0);
},
getText: function () {
return this.entry.get_text().replace(/^\s+/g, '').replace(/\s+$/g, '');
},
// some search term has been entered
isActive: function() {
return this.actor.get_text() != '';
},
// the entry does not show the hint
_isActivated: function() {
return this.entry.text == this.actor.get_text();
},
_onCapturedEvent: function(actor, event) {
let source = event.get_source();
let panelEvent = source && Main.panel.actor.contains(source);
switch (event.type()) {
case Clutter.EventType.BUTTON_PRESS:
// the user clicked outside after activating the entry, but
// with no search term entered - cancel the search
if (source != this.entry && this.entry.text == '') {
this.reset();
// allow only panel events to continue
return !panelEvent;
}
return false;
case Clutter.EventType.KEY_PRESS:
// If some "special" actor grabbed the focus (run
// dialog, looking glass); we don't want to interfere
// with that
let focus = global.stage.get_key_focus();
if (focus != this._focusBase && focus != this.entry)
return false;
let sym = event.get_key_symbol();
// If we have an active search, Escape cancels it - if we
// haven't, the key is ignored
if (sym == Clutter.Escape)
if (this._isActivated()) {
this.reset();
return true;
} else {
return false;
}
// Ignore non-printable keys
if (!Clutter.keysym_to_unicode(sym))
return false;
// Search started - move the key focus to the entry and
// "repeat" the event
if (!this._isActivated()) {
global.stage.set_key_focus(this.entry);
this.entry.event(event, false);
}
return false;
default:
// Suppress all other events outside the panel while the entry
// is activated and no search has been entered - any click
// outside the entry will cancel the search
return (this.entry.text == '' && !panelEvent);
}
},
_onDestroy: function() {
if (this._capturedEventId > 0) {
global.stage.disconnect(this._capturedEventId);
this._capturedEventId = 0;
}
}
};
Signals.addSignalMethods(SearchEntry.prototype);
function BaseTab(titleActor, pageActor) {
this._init(titleActor, pageActor);
}
BaseTab.prototype = {
_init: function(titleActor, pageActor) {
this.title = titleActor;
this.page = new St.Bin({ child: pageActor,
x_align: St.Align.START,
y_align: St.Align.START,
x_fill: true,
y_fill: true,
style_class: 'view-tab-page' });
this.visible = false;
},
show: function() {
this.visible = true;
this.page.opacity = 0;
this.page.show();
Tweener.addTween(this.page,
{ opacity: 255,
time: 0.1,
transition: 'easeOutQuad' });
},
hide: function() {
this.visible = false;
Tweener.addTween(this.page,
{ opacity: 0,
time: 0.1,
transition: 'easeOutQuad',
onComplete: Lang.bind(this,
function() {
this.page.hide();
})
});
},
_activate: function() {
this.emit('activated');
}
};
Signals.addSignalMethods(BaseTab.prototype);
function ViewTab(label, pageActor) {
this._init(label, pageActor);
}
ViewTab.prototype = {
__proto__: BaseTab.prototype,
_init: function(label, pageActor) {
let titleActor = new St.Button({ label: label,
style_class: 'view-tab-title' });
titleActor.connect('clicked', Lang.bind(this, this._activate));
BaseTab.prototype._init.call(this, titleActor, pageActor);
}
};
function SearchTab(focusBase) {
this._init(focusBase);
}
SearchTab.prototype = {
__proto__: BaseTab.prototype,
_init: function(focusBase) {
this._searchActive = false;
this._searchPending = false;
this._keyPressId = 0;
this._searchTimeoutId = 0;
this._focusBase = focusBase;
this._searchSystem = new Search.SearchSystem();
this._searchEntry = new SearchEntry(focusBase);
this._searchResults = new SearchDisplay.SearchResults(this._searchSystem);
BaseTab.prototype._init.call(this,
this._searchEntry.actor,
this._searchResults.actor);
this._searchEntry.entry.connect('text-changed',
Lang.bind(this, this._onTextChanged));
this._searchEntry.entry.connect('activate', Lang.bind(this, function (se) {
if (this._searchTimeoutId > 0) {
Mainloop.source_remove(this._searchTimeoutId);
this._doSearch();
}
this._searchResults.activateSelected();
return true;
}));
},
setFindAsYouType: function(enabled) {
if (enabled)
this._searchEntry.show();
else
this._searchEntry.hide();
},
show: function() {
BaseTab.prototype.show.call(this);
if (this._keyPressId == 0)
this._keyPressId = this._searchEntry.entry.connect('key-press-event',
Lang.bind(this, this._onKeyPress));
},
hide: function() {
BaseTab.prototype.hide.call(this);
if (this._keyPressId > 0) {
this._searchEntry.entry.disconnect(this._keyPressId);
this._keyPressId = 0;
}
this._searchEntry.reset();
},
addSearchProvider: function(provider) {
this._searchSystem.registerProvider(provider);
this._searchResults.createProviderMeta(provider);
},
_onTextChanged: function (se, prop) {
let searchPreviouslyActive = this._searchActive;
this._searchActive = this._searchEntry.isActive();
this._searchPending = this._searchActive && !searchPreviouslyActive;
if (this._searchPending) {
this._searchResults.startingSearch();
}
if (this._searchActive) {
this._activate();
} else {
this.emit('search-cancelled');
}
if (!this._searchActive) {
if (this._searchTimeoutId > 0) {
Mainloop.source_remove(this._searchTimeoutId);
this._searchTimeoutId = 0;
}
return;
}
if (this._searchTimeoutId > 0)
return;
this._searchTimeoutId = Mainloop.timeout_add(150, Lang.bind(this, this._doSearch));
},
_onKeyPress: function(entry, event) {
let symbol = event.get_key_symbol();
if (symbol == Clutter.Up) {
if (!this._searchActive)
return true;
this._searchResults.selectUp(false);
return true;
} else if (symbol == Clutter.Down) {
if (!this._searchActive)
return true;
this._searchResults.selectDown(false);
return true;
}
return false;
},
_doSearch: function () {
this._searchTimeoutId = 0;
let text = this._searchEntry.getText();
this._searchResults.updateSearch(text);
return false;
}
};
function ViewSelector() {
this._init();
}
ViewSelector.prototype = {
_init : function() {
this.actor = new St.BoxLayout({ name: 'viewSelector',
vertical: true });
// The tab bar is located at the top of the view selector and
// holds both "normal" tab labels and the search entry. The former
// is left aligned, the latter right aligned - unless the text
// direction is RTL, in which case the order is reversed.
this._tabBar = new Shell.GenericContainer();
this._tabBar.connect('get-preferred-width',
Lang.bind(this, this._getPreferredTabBarWidth));
this._tabBar.connect('get-preferred-height',
Lang.bind(this, this._getPreferredTabBarHeight));
this._tabBar.connect('allocate',
Lang.bind(this, this._allocateTabBar));
this.actor.add(this._tabBar);
// Box to hold "normal" tab labels
this._tabBox = new St.BoxLayout({ name: 'viewSelectorTabBar' });
this._tabBar.add_actor(this._tabBox);
// The searchArea just holds the entry
this._searchArea = new St.Bin({ name: 'searchArea' });
this._tabBar.add_actor(this._searchArea);
// The page area holds the tab pages. Every page is given the
// area's full allocation, so that the pages would appear on top
// of each other if the inactive ones weren't hidden.
this._pageArea = new Shell.Stack();
this.actor.add(this._pageArea, { x_fill: true,
y_fill: true,
expand: true });
this._tabs = [];
this._activeTab = null;
this._searchTab = new SearchTab(this.actor);
this._searchArea.set_child(this._searchTab.title);
this._addTab(this._searchTab);
this._searchTab.connect('search-cancelled', Lang.bind(this,
function() {
this._switchTab(this._activeTab);
}));
this._keyPressId = 0;
this._itemDragBeginId = 0;
this._overviewHidingId = 0;
// Public constraints which may be used to tie actors' height or
// vertical position to the current tab's content; as the content's
// height and position depend on the view selector's style properties
// (e.g. font size, padding, spacing, ...) it would be extremely hard
// and ugly to get these from the outside. While it would be possible
// to use position and height properties directly, outside code would
// need to ensure that the content is properly allocated before
// accessing the properties.
this.constrainY = new Clutter.BindConstraint({ source: this._pageArea,
coordinate: Clutter.BindCoordinate.Y });
this.constrainHeight = new Clutter.BindConstraint({ source: this._pageArea,
coordinate: Clutter.BindCoordinate.HEIGHT });
},
_addTab: function(tab) {
tab.page.hide();
this._pageArea.add_actor(tab.page);
tab.connect('activated', Lang.bind(this, function(tab) {
this._switchTab(tab);
}));
},
addViewTab: function(title, pageActor) {
let viewTab = new ViewTab(title, pageActor);
this._tabs.push(viewTab);
this._tabBox.add(viewTab.title);
this._addTab(viewTab);
},
_switchTab: function(tab) {
if (this._activeTab && this._activeTab.visible) {
if (this._activeTab == tab)
return;
this._activeTab.title.remove_style_pseudo_class('selected');
this._activeTab.hide();
}
if (tab != this._searchTab) {
tab.title.add_style_pseudo_class('selected');
this._activeTab = tab;
if (this._searchTab.visible) {
this._searchTab.hide();
}
}
if (!tab.visible)
tab.show();
// Pull a Meg Ryan:
if (Main.overview && Main.overview.workspaces) {
if (tab != this._tabs[0]) {
Tweener.addTween(Main.overview.workspaces.actor,
{ opacity: 0,
time: 0.1,
transition: 'easeOutQuad',
onComplete: Lang.bind(this,
function() {
Main.overview.workspaces.actor.hide();
Main.overview.workspaces.actor.opacity = 255;
})
});
} else {
Main.overview.workspaces.actor.opacity = 0;
Main.overview.workspaces.actor.show();
Tweener.addTween(Main.overview.workspaces.actor,
{ opacity: 255,
time: 0.1,
transition: 'easeOutQuad' });
}
}
},
_switchDefaultTab: function() {
if (this._tabs.length > 0)
this._switchTab(this._tabs[0]);
},
_nextTab: function() {
if (this._tabs.length == 0 ||
this._tabs[this._tabs.length - 1] == this._activeTab)
return;
for (let i = 0; i < this._tabs.length; i++)
if (this._tabs[i] == this._activeTab) {
this._switchTab(this._tabs[i + 1]);
return;
}
},
_prevTab: function() {
if (this._tabs.length == 0 || this._tabs[0] == this._activeTab)
return;
for (let i = 0; i < this._tabs.length; i++)
if (this._tabs[i] == this._activeTab) {
this._switchTab(this._tabs[i - 1]);
return;
}
},
_getPreferredTabBarWidth: function(box, forHeight, alloc) {
let children = box.get_children();
for (let i = 0; i < children.length; i++) {
let [childMin, childNat] = children[i].get_preferred_width(forHeight);
alloc.min_size += childMin;
alloc.natural_size += childNat;
}
},
_getPreferredTabBarHeight: function(box, forWidth, alloc) {
let children = box.get_children();
for (let i = 0; i < children.length; i++) {
let [childMin, childNatural] = children[i].get_preferred_height(forWidth);
if (childMin > alloc.min_size)
alloc.min_size = childMin;
if (childNatural > alloc.natural_size)
alloc.natural_size = childNatural;
}
},
_allocateTabBar: function(container, box, flags) {
let allocWidth = box.x2 - box.x1;
let allocHeight = box.y2 - box.y1;
let [searchMinWidth, searchNatWidth] = this._searchArea.get_preferred_width(-1);
let [barMinWidth, barNatWidth] = this._tabBox.get_preferred_width(-1);
let childBox = new Clutter.ActorBox();
childBox.y1 = 0;
childBox.y2 = allocHeight;
if (this.actor.get_direction() == St.TextDirection.RTL) {
childBox.x1 = allocWidth - barNatWidth;
childBox.x2 = allocWidth;
} else {
childBox.x1 = 0;
childBox.x2 = barNatWidth;
}
this._tabBox.allocate(childBox, flags);
if (this.actor.get_direction() == St.TextDirection.RTL) {
childBox.x1 = 0;
childBox.x2 = searchNatWidth;
} else {
childBox.x1 = allocWidth - searchNatWidth;
childBox.x2 = allocWidth;
}
this._searchArea.allocate(childBox, flags);
Meta.later_add(Meta.LaterType.BEFORE_REDRAW, Lang.bind(this,
function() {
this.constrainY.offset = this.actor.y;
}));
},
_onKeyPress: function(actor, event) {
let modifiers = Shell.get_event_state(event);
let symbol = event.get_key_symbol();
if (symbol == Clutter.Escape) {
Main.overview.hide();
return true;
} else if (modifiers & Clutter.ModifierType.CONTROL_MASK) {
if (symbol == Clutter.Page_Up) {
if (!this._searchActive)
this._prevTab();
return true;
} else if (symbol == Clutter.Page_Down) {
if (!this._searchActive)
this._nextTab();
return true;
}
}
return false;
},
addSearchProvider: function(provider) {
this._searchTab.addSearchProvider(provider);
},
show: function() {
this._searchTab.setFindAsYouType(true);
if (this._itemDragBeginId == 0)
this._itemDragBeginId = Main.overview.connect('item-drag-begin',
Lang.bind(this, this._switchDefaultTab));
if (this._overviewHidingId == 0)
this._overviewHidingId = Main.overview.connect('hiding',
Lang.bind(this, this._switchDefaultTab));
if (this._keyPressId == 0)
this._keyPressId = this.actor.connect('key-press-event',
Lang.bind(this, this._onKeyPress));
this._switchDefaultTab();
},
hide: function() {
this._searchTab.setFindAsYouType(false);
if (this._keyPressId > 0) {
this.actor.disconnect(this._keyPressId);
this._keyPressId = 0;
}
if (this._itemDragBeginId > 0) {
Main.overview.disconnect(this._itemDragBeginId);
this._itemDragBeginId = 0;
}
if (this._overviewHidingId > 0) {
Main.overview.disconnect(this._overviewHidingId);
this._overviewHidingId = 0;
}
}
};
Signals.addSignalMethods(ViewSelector.prototype);