// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- const Clutter = imports.gi.Clutter; const Gtk = imports.gi.Gtk; 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 Main = imports.ui.main; const Search = imports.ui.search; const SearchDisplay = imports.ui.searchDisplay; const ShellEntry = imports.ui.shellEntry; const Tweener = imports.ui.tweener; const BaseTab = new Lang.Class({ Name: 'BaseTab', _init: function(titleActor, pageActor, name, a11yIcon) { 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' }); if (this.title.can_focus) { Main.ctrlAltTabManager.addGroup(this.title, name, a11yIcon); } else { Main.ctrlAltTabManager.addGroup(this.page, name, a11yIcon, { proxy: this.title, focusCallback: Lang.bind(this, this._a11yFocus) }); } this.visible = false; }, show: function(animate) { this.visible = true; this.page.show(); if (!animate) return; this.page.opacity = 0; 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(); }) }); }, _a11yFocus: function() { this._activate(); this.page.navigate_focus(null, Gtk.DirectionType.TAB_FORWARD, false); }, _activate: function() { this.emit('activated'); } }); Signals.addSignalMethods(BaseTab.prototype); const ViewTab = new Lang.Class({ Name: 'ViewTab', Extends: BaseTab, _init: function(id, label, pageActor, a11yIcon) { this.id = id; let titleActor = new St.Button({ label: label, style_class: 'view-tab-title' }); titleActor.connect('clicked', Lang.bind(this, this._activate)); this.parent(titleActor, pageActor, label, a11yIcon); } }); const SearchTab = new Lang.Class({ Name: 'SearchTab', Extends: BaseTab, _init: function() { this.active = false; this._searchPending = false; this._searchTimeoutId = 0; this._searchSystem = new Search.SearchSystem(); this._openSearchSystem = new Search.OpenSearchSystem(); this._entry = new St.Entry({ name: 'searchEntry', /* Translators: this is the text displayed in the search entry when no search is active; it should not exceed ~30 characters. */ hint_text: _("Type to search..."), track_hover: true, can_focus: true }); ShellEntry.addContextMenu(this._entry); this._text = this._entry.clutter_text; this._text.connect('key-press-event', Lang.bind(this, this._onKeyPress)); this._inactiveIcon = new St.Icon({ style_class: 'search-entry-icon', icon_name: 'edit-find', icon_type: St.IconType.SYMBOLIC }); this._activeIcon = new St.Icon({ style_class: 'search-entry-icon', icon_name: 'edit-clear', icon_type: St.IconType.SYMBOLIC }); this._entry.set_secondary_icon(this._inactiveIcon); this._iconClickedId = 0; this._searchResults = new SearchDisplay.SearchResults(this._searchSystem, this._openSearchSystem); this.parent(this._entry, this._searchResults.actor, _("Search"), 'edit-find'); this._text.connect('text-changed', Lang.bind(this, this._onTextChanged)); this._text.connect('key-press-event', Lang.bind(this, function (o, e) { // We can't connect to 'activate' here because search providers // might want to do something with the modifiers in activateSelected. let symbol = e.get_key_symbol(); if (symbol == Clutter.Return || symbol == Clutter.KP_Enter) { if (this._searchTimeoutId > 0) { Mainloop.source_remove(this._searchTimeoutId); this._doSearch(); } this._searchResults.activateSelected(); return true; } return false; })); this._entry.connect('notify::mapped', Lang.bind(this, this._onMapped)); global.stage.connect('notify::key-focus', Lang.bind(this, this._updateCursorVisibility)); this._capturedEventId = 0; }, hide: function() { this.parent(); // 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: function () { this._text.text = ''; global.stage.set_key_focus(null); this._text.set_cursor_visible(true); this._text.set_selection(0, 0); }, _updateCursorVisibility: function() { let focus = global.stage.get_key_focus(); this._text.set_cursor_visible(focus == this._text); }, _onMapped: function() { if (this._entry.mapped) { // Enable 'find-as-you-type' this._capturedEventId = global.stage.connect('captured-event', Lang.bind(this, this._onCapturedEvent)); 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; } }, addSearchProvider: function(provider) { this._searchSystem.registerProvider(provider); this._searchResults.createProviderMeta(provider); }, removeSearchProvider: function(provider) { this._searchSystem.unregisterProvider(provider); this._searchResults.destroyProviderMeta(provider); }, startSearch: function(event) { global.stage.set_key_focus(this._text); this._text.event(event, false); }, // the entry does not show the hint _isActivated: function() { return this._text.text == this._entry.get_text(); }, _onTextChanged: function (se, prop) { let searchPreviouslyActive = this.active; this.active = this._entry.get_text() != ''; this._searchPending = this.active && !searchPreviouslyActive; if (this._searchPending) { this._searchResults.startingSearch(); } if (this.active) { this._entry.set_secondary_icon(this._activeIcon); if (this._iconClickedId == 0) { this._iconClickedId = this._entry.connect('secondary-icon-clicked', Lang.bind(this, function() { this._reset(); })); } this._activate(); } else { if (this._iconClickedId > 0) this._entry.disconnect(this._iconClickedId); this._iconClickedId = 0; this._entry.set_secondary_icon(this._inactiveIcon); this.emit('search-cancelled'); } if (!this.active) { 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.active) return true; this._searchResults.selectUp(false); return true; } else if (symbol == Clutter.Down) { if (!this.active) return true; this._searchResults.selectDown(false); return true; } else if (symbol == Clutter.Escape) { if (this._isActivated()) { this._reset(); return true; } } return false; }, _onCapturedEvent: function(actor, event) { if (event.type() == Clutter.EventType.BUTTON_PRESS) { let source = event.get_source(); if (source != this._text && this._text.text == '' && !Main.layoutManager.keyboardBox.contains(source)) { // 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 false; }, _doSearch: function () { this._searchTimeoutId = 0; let text = this._text.get_text().replace(/^\s+/g, '').replace(/\s+$/g, ''); this._searchResults.doSearch(text); return false; } }); const ViewSelector = new Lang.Class({ Name: 'ViewSelector', _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._searchArea.set_child(this._searchTab.title); this._addTab(this._searchTab); this._searchTab.connect('search-cancelled', Lang.bind(this, function() { this._switchTab(this._activeTab); })); Main.overview.connect('item-drag-begin', Lang.bind(this, this._switchDefaultTab)); this._stageKeyPressId = 0; Main.overview.connect('showing', Lang.bind(this, function () { this._switchDefaultTab(); this._stageKeyPressId = global.stage.connect('key-press-event', Lang.bind(this, this._onStageKeyPress)); })); Main.overview.connect('hiding', Lang.bind(this, function () { this._switchDefaultTab(); if (this._stageKeyPressId != 0) { global.stage.disconnect(this._stageKeyPressId); this._stageKeyPressId = 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(id, title, pageActor, a11yIcon) { let viewTab = new ViewTab(id, title, pageActor, a11yIcon); this._tabs.push(viewTab); this._tabBox.add(viewTab.title); this._addTab(viewTab); }, _switchTab: function(tab) { let firstSwitch = this._activeTab == null; 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(); } } // Only fade when switching between tabs, // not when setting the initially selected one. if (!tab.visible) tab.show(!firstSwitch); }, switchTab: function(id) { for (let i = 0; i < this._tabs.length; i++) if (this._tabs[i].id == id) { this._switchTab(this._tabs[i]); break; } }, _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_text_direction() == Clutter.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_text_direction() == Clutter.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; })); }, _onStageKeyPress: function(actor, event) { let modifiers = event.get_state(); 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._searchTab.active) this._prevTab(); return true; } else if (symbol == Clutter.Page_Down) { if (!this._searchTab.active) this._nextTab(); return true; } } else if (Clutter.keysym_to_unicode(symbol)) { this._searchTab.startSearch(event); } return false; }, addSearchProvider: function(provider) { this._searchTab.addSearchProvider(provider); }, removeSearchProvider: function(provider) { this._searchTab.removeSearchProvider(provider); } }); Signals.addSignalMethods(ViewSelector.prototype);