Translate

Archives

Dual Menus for a GNOME Shell 3.2 Button

One of the current limitations with the GNOME 3.2 Shell top panel buttons is that there is no support for displaying separate menus for different mouse button clicks. Yes, yes, I can hear the GNOME Shell designers say with complete authority and conviction that this use case is by design. Their answer to any criticism, constructive or otherwise, about their design is generally that they know better than anybody else and their (never-published) usability studies support their design. Just look at the Suspend versa Power Off menu option debate that erupted when the GNOME Shell was originally released!

Anyway, this particular limitation has annoyed me for a while and I recently decided to look at the issue and try and come up with a simple workable solution to the limitation.

Suppose that I wanted the following menu to be displayed when I use my left mouse button to activate the GNOME Foot button on the top panel:

and I want the following menu options to be displayed when I use my right mouse button to activate the same top panel button:

There is nothing in the underlying Mutter code that limits support for such functionality. In fact Mutter has explicit support for returning an integer that represents a pressed or released mouse button. According to the Clutter (upon which Mutter is based) documentation, in the case of a standard scroll mouse, the following numbers are reliable:

  • 1 = Left mouse button
  • 2 = Scroll wheel
  • 3 = Right mouse button

For mice with more buttons, you may have to experiment to see which particular button returns which particular value.

In your code, how do you determine which particular mouse button was pressed? Simply connect an event handler (AKA callback or signal handlers) to the button-press-event and/or the button-release-event signals of a suitable actor. In the event handler, use get_button() to return the integer representing the specific mouse button that was pressed and/or released. You can use a single function for both press and release signals.

   ...

   _init: function(menuAlignment) {
        PanelMenu.ButtonBox.prototype._init.call(this, 
                                                 { reactive: true,
                                                   can_focus: true,
                                                   track_hover: true 
                                                 });
        ...  
        this.actor.connect('button-press-event', Lang.bind(this, this._onButtonPress));
        ...
    },

    _onButtonPress: function(actor, event) {
        let button = event.get_button();
        if (button == 1) {
            // do something
        } else if (button == 3) {
            // do something 
        }
    },

    ...


A button-press-event is emitted when a mouse button is pressed, but not necessarily released, on a reactive actor. A button-release-event is emitted when a mouse button is released on a reactive actor (even if the mouse was pressed down somewhere else beforehand). Usually you must explicitly enable the reactive property of an actor. By the way, a reactive actor (a Clutter concept) is an actor that can emit pointer events.

An alternative approach is to use Clutter.ClickAction.


   ...

   let clickAction = new Clutter.ClickAction();
   clickAction.connect('clicked', Lang.bind(this, function(button)  {           
       this._onButtonPress(button);
   }));
   this.actor.add_action(clickAction);

   ...

    _onButtonPress: function(button) {
        if (button == 1) {
            // do something
        } else if (button == 3) {
            // do something 
        }
    },

    ...


For examples of how and where Clutter.ClickAction is used in the GNOME Shell 3.2 code, look in /usr/share/gnome-shell/js/ui/shellEntry.js and /usr/share/gnome-shell/js/ui/workspace.js where it is used to implement clicked and long-press event handling.

Back to the problem at hand. It turns out that the major impediment to implementing dual action menus in GNOME Shell 3.2 lies in the code that implements the Button object. See /usr/share/gnome-shell/js/ui/panelMenu.js. Here is the relevant code:

function Button(menuAlignment) {
    this._init(menuAlignment);
}

Button.prototype = {
    __proto__: ButtonBox.prototype,

    _init: function(menuAlignment) {
        ButtonBox.prototype._init.call(this, { reactive: true,
                                               can_focus: true,
                                               track_hover: true });

        this.actor.connect('button-press-event', Lang.bind(this, this._onButtonPress));
        this.actor.connect('key-press-event', Lang.bind(this, this._onSourceKeyPress));
        this.menu = new PopupMenu.PopupMenu(this.actor, menuAlignment, St.Side.TOP);
        this.menu.actor.add_style_class_name('panel-menu');
        this.menu.connect('open-state-changed', Lang.bind(this, this._onOpenStateChanged));
        this.menu.actor.connect('key-press-event', Lang.bind(this, this._onMenuKeyPress));
        Main.uiGroup.add_actor(this.menu.actor);
        this.menu.actor.hide();
    },

function Button(menuAlignment) {
    this._init(menuAlignment);
}

Button.prototype = {
    __proto__: ButtonBox.prototype,

    _init: function(menuAlignment) {
        ButtonBox.prototype._init.call(this, { reactive: true,
                                               can_focus: true,
                                               track_hover: true });

        this.actor.connect('button-press-event', Lang.bind(this, this._onButtonPress));
        this.actor.connect('key-press-event', Lang.bind(this, this._onSourceKeyPress));
        this.menu = new PopupMenu.PopupMenu(this.actor, menuAlignment, St.Side.TOP);
        this.menu.actor.add_style_class_name('panel-menu');
        this.menu.connect('open-state-changed', Lang.bind(this, this._onOpenStateChanged));
        this.menu.actor.connect('key-press-event', Lang.bind(this, this._onMenuKeyPress));
        Main.uiGroup.add_actor(this.menu.actor);
        this.menu.actor.hide();
    },

   _onButtonPress: function(actor, event) {
        if (!this.menu.isOpen) {
            // Setting the max-height won't do any good if the minimum height of the
            // menu is higher then the screen; it's useful if part of the menu is
            // scrollable so the minimum height is smaller than the natural height
            let monitor = Main.layoutManager.primaryMonitor;
            this.menu.actor.style = ('max-height: ' +
                                     Math.round(monitor.height - Main.panel.actor.height) +
                                     'px;');
        }
        this.menu.toggle();
    },

    _onSourceKeyPress: function(actor, event) {
        let symbol = event.get_key_symbol();
        if (symbol == Clutter.KEY_space || symbol == Clutter.KEY_Return) {
            this.menu.toggle();
            return true;
        } else if (symbol == Clutter.KEY_Escape && this.menu.isOpen) {
            this.menu.close();
            return true;
        } else if (symbol == Clutter.KEY_Down) {
            if (!this.menu.isOpen)
                this.menu.toggle();
            this.menu.actor.navigate_focus(this.actor, Gtk.DirectionType.DOWN, false);
            return true;
        } else
            return false;
    },

   _onMenuKeyPress: function(actor, event) {
        let symbol = event.get_key_symbol();
        if (symbol == Clutter.KEY_Left || symbol == Clutter.KEY_Right) {
            let focusManager = St.FocusManager.get_for_stage(global.stage);
            let group = focusManager.get_group(this.actor);
            if (group) {
                let direction = (symbol == Clutter.KEY_Left) ? Gtk.DirectionType.LEFT : Gtk.DirectionType.RIGHT;
                group.navigate_focus(this.actor, direction, false);
                return true;
            }
        }
        return false;
    },

    _onOpenStateChanged: function(menu, open) {
        if (open)
            this.actor.add_style_pseudo_class('active');
        else
            this.actor.remove_style_pseudo_class('active');
    },

    destroy: function() {
        this.actor._delegate = null;

        this.menu.destroy();
        this.actor.destroy();

        this.emit('destroy');
    }
};
Signals.addSignalMethods(Button.prototype);


If you examine the above code, you will see that no attempt is made to differentiate between the different mouse buttons. As far as the above code is concerned, one mouse button is the same as another mouse button. Perhaps the designers of the GNOME Shell grew up using a single button Apple mouse or, as many commentators have suggested, the GNOME Shell was really designed for tablets and palmhelds where no mouse is generally used or available.

The solution to implementing dual menu functionality using mouse buttons was to modify the buttom-press-event signal handler in the above Button code to determine which mouse button was pressed and act accordingly.

Here is the source code for a simple GNOME Shell extension that I used to prototype a working implementation of dual menu functionality:

const St = imports.gi.St;
const Main = imports.ui.main;
const PanelMenu = imports.ui.panelMenu;
const PopupMenu = imports.ui.popupMenu;
const Lang = imports.lang;

const Clutter = imports.gi.Clutter;
const Shell = imports.gi.Shell;
const Signals = imports.signals;


function DualActionButton(menuAlignment) {
    this._init(menuAlignment);
}

DualActionButton.prototype = {
    __proto__: PanelMenu.ButtonBox.prototype,

    _init: function(menuAlignment) {
        PanelMenu.ButtonBox.prototype._init.call(this, { reactive: true,
                                               can_focus: true,
                                               track_hover: true });

        this.actor.connect('button-press-event', Lang.bind(this, this._onButtonPress));
        this.actor.connect('key-press-event', Lang.bind(this, this._onSourceKeyPress));

        this.menuL = new PopupMenu.PopupMenu(this.actor, menuAlignment, St.Side.TOP);
        this.menuL.actor.add_style_class_name('panel-menu');
        this.menuL.connect('open-state-changed', Lang.bind(this, this._onOpenStateChanged));
        this.menuL.actor.connect('key-press-event', Lang.bind(this, this._onMenuKeyPress));
        Main.uiGroup.add_actor(this.menuL.actor);
        this.menuL.actor.hide();

        this.menuR = new PopupMenu.PopupMenu(this.actor, menuAlignment, St.Side.TOP);
        this.menuR.actor.add_style_class_name('panel-menu');
        this.menuR.connect('open-state-changed', Lang.bind(this, this._onOpenStateChanged));
        this.menuR.actor.connect('key-press-event', Lang.bind(this, this._onMenuKeyPress));
        Main.uiGroup.add_actor(this.menuR.actor);
        this.menuR.actor.hide();
    },

    _onButtonPress: function(actor, event) {
        let button = event.get_button();
        if (button == 1) {
            if (this.menuL.isOpen) {
                this.menuL.close();
            } else {
                if (this.menuR.isOpen)
                    this.menuR.close();
                this.menuL.open();
            }
        } else if (button == 3) {
            if (this.menuR.isOpen) {
                this.menuR.close();
            } else {
                if (this.menuL.isOpen)
                    this.menuL.close();
                this.menuR.open();
            }
        }
    },

    _onButtonPress: function(actor, event) {
        let button = event.get_button();
        if (button == 1) {
            if (this.menuL.isOpen) {
                this.menuL.close();
            } else {
                if (this.menuR.isOpen)
                    this.menuR.close();
                this.menuL.open();
            }
        } else if (button == 3) {
            if (this.menuR.isOpen) {
                this.menuR.close();
            } else {
                if (this.menuL.isOpen)
                    this.menuL.close();
                this.menuR.open();
            }
        }
    },

    _onSourceKeyPress: function(actor, event) {
        let symbol = event.get_key_symbol();
        if (symbol == Clutter.KEY_space || symbol == Clutter.KEY_Return) {
            if (this.menuL.isOpen) {
                this.menuL.close();
            } else if (this.menuR.isOpen) {
                this.menuR.close();
            }
            return true;
        } else if (symbol == Clutter.KEY_Escape) {
            if (this.menuL.isOpen)
                this.menuL.close();
            if (this.menuR.isOpen)
                this.menuR.close();
            return true;
        } else
            return false;
    },

    _onMenuKeyPress: function(actor, event) {
        let symbol = event.get_key_symbol();
        if (symbol == Clutter.KEY_Left || symbol == Clutter.KEY_Right) {
            let focusManager = St.FocusManager.get_for_stage(global.stage);
            let group = focusManager.get_group(this.actor);
            if (group) {
                let direction = (symbol == Clutter.KEY_Left) ? Gtk.DirectionType.LEFT : Gtk.DirectionType.RIGHT;
                group.navigate_focus(this.actor, direction, false);
                return true;
            }
        }
        return false;
    },

    _onOpenStateChanged: function(menu, open) {
        if (open)
            this.actor.add_style_pseudo_class('active');
        else
            this.actor.remove_style_pseudo_class('active');
    },

    destroy: function() {
        this.actor._delegate = null;
        this.menuL.destroy();
        this.menuR.destroy();
        this.actor.destroy();
        this.emit('destroy');
    },

};
Signals.addSignalMethods(DualActionButton.prototype);


function DemoDualActionButton() {
   this._init();
}

DemoDualActionButton.prototype = {
    __proto__: DualActionButton.prototype,

    _init: function() {
        DualActionButton.prototype._init.call(this, 0.0);

        this._iconActor = new St.Icon({ icon_name: 'start-here',
                                        icon_type: St.IconType.SYMBOLIC,
                                        style_class: 'system-status-icon' });
        this.actor.add_actor(this._iconActor);
        this.actor.add_style_class_name('panel-status-button');

        let item = new PopupMenu.PopupMenuItem(_("Left Menu Item 1"));
        this.menuL.addMenuItem(item);
        item = new PopupMenu.PopupMenuItem(_("Left Menu Item 2"));
        this.menuL.addMenuItem(item);

        item = new PopupMenu.PopupMenuItem(_("Right Menu Item 1"));
        this.menuR.addMenuItem(item);
        item = new PopupMenu.PopupMenuItem(_("Right Menu Item 2"));
        this.menuR.addMenuItem(item);
        item = new PopupMenu.PopupMenuItem(_("Right Menu Item 3"));
        this.menuR.addMenuItem(item);
        item = new PopupMenu.PopupMenuItem(_("Right Menu Item 4"));
        this.menuR.addMenuItem(item);
    },

    enable: function() {
        Main.panel._centerBox.add(this.actor, { y_fill: true });
        Main.panel._menus.addMenu(this.menuL);
        Main.panel._menus.addMenu(this.menuR);
    },

    disable: function() {
        Main.panel._centerBox.remove_actor(this.actor);
        Main.panel._menus.removeMenu(this.menuL);
        Main.panel._menus.removeMenu(this.menuR);
    }
};


function init() {
    return new DemoDualActionButton();
}


The DualActionButton object code is simply a modified version of the Button code. Adding support for a center mouse button menu is trivial; I will leave that for you to do if you need it.

Note that I do not include a comma after the last name/value pair. According to the Mozilla JavaScript documentation, Douglas Crockford and others, this is the correct syntax. Unfortunately, many Shell extensions developers appear not to know the correct syntax for object literals and include the comma since gjs, which is based on the Mozilla SpiderMonkey JavaScript engine and the GObject introspection framework, does not complain about this particular syntax error.

For those readers who do not like to encapsulate the GNOME Shell extension enable and disable methods, and there are quite a few of you according to comments and email that I receive, here is the source code for function based initialization, enabling and disabling of the above extension.

let button;

function init() {
    button = new DemoDualActionButton();
}

function enable() {
    Main.panel._centerBox.add(button.actor, { y_fill: true });
    Main.panel._menus.addMenu(button.menuL);
    Main.panel._menus.addMenu(button.menuR);
}

function disable() {
    Main.panel._centerBox.remove_actor(button.actor);
    Main.panel._menus.removeMenu(button.menuL);
    Main.panel._menus.removeMenu(button.menuR);
}


Some people claim that the above code for handling a Shell extension is simpler and easier to understand than the object literal coding style that I generally use. My answer is that the function approach requires you to use at least one variable that has extension scope and, often times, many such variables in more complex Shell extensions. The currently non-standard Mozilla JavaScript let keyword limits a variable’s lexical scope to the block in which the variable is defined as well as any inner blocks contained inside the let block itself. However, if the var keyword is used instead of the let keyword or no keyword is used, the scope of the variable defaults to global. Global variables in JavaScript should be avoided where possible because of the potential side effects and errors they can introduce. This type of scoping error can be difficult to find and so I choose to avoid the issue entirely. Read Appendix A (Awful Parts) of Douglas Crockford’s seminal book JavaScript: The Good Parts if you do not understand why JavaScript variables with global scope are evil.

By the way, I am not sure if the concept of a dual action menu button would breach computer accessibility (a11y) guidelines for the GNOME Shell but given that the paradigm is common in Microsoft Windows, I suspect not.

I have created a simple GNOME Shell extension, demodualmenubutton, based on the above code which you can download from my GNOME Shell extensions website.

Enjoy!

14 comments to Dual Menus for a GNOME Shell 3.2 Button

  • I hate to be so curt. But why are you wasting your time on all these extensions (that I use daily by the way)? I see your name and this website across the net all the time – I learn from the pages you post, but one question remains: Why aren’t you leading the coding for GNOME?

    • Actually, I spend very little time or energy on GNOME extensions. Simply do not have the time or inclination. As for “leading the coding for GNOME”, it is not going to happen as I disagree with the design and execution philosophy of the GNOME Shell core team.

  • LONNIEFUTURE

    I was curious about your copyright. Does the copyright mean your work can’t be redistributed? Why copyright FOSS?

    • License and copyright are two different concepts which are often confused in the FOSS world. My work can be redistributed per GPL provided you leave my copyright notice on it. You can add your own copyright notice if you modify my work but you must leave my copyright notice in place.

      In the USA, and In most countries, you automatically have copyright to any published work. That happens by default as soon as the work is published. Indeed, you have to place your work explicitly in the public domain if you do not want the work to be copyrighted.

      See http://www.gnu.org/licenses/gpl-faq.html#RequiredToClaimCopyright for further information.

  • George

    Hi …

    I am using Linux Mint 12 and I installed some of your GNOME extensions. Great job by the way. Well The “autohide top bar” extension basically “ate” my system. I can’t use the GNOME 3 shell anymore because there’s barely any left. No menu bars on the apps. No menus at all because I took out the bottom one for a dock. OpenGL stopped rendering properly on gtxDock. No top panel is accessable at all. The screen rendering was messed up with a chewed interface that ate across the upper screen if I held ESC. I used synaptic (started via the terminal) to remove the extension and reloaded the shell with no change so then I rebooted. Still no change. Any idea how I can recover my system? Or do I have to spend several hours reinstalling everything all over again?

    Thanks

  • George

    Update …

    I fixed it by deleting the extensions folder.

    Thanks.

  • Daniel44x

    Your posts are almost at the professional level. This blog is probably the only blog where users can learn something about the gnome shell extensions (and the gnome shell in general). I have a similar question like Harvey: If you do not agree with the philosophy of the development of Gnome Shell team (very few people agree), why do not you join Cinnamon Shell development team. They definitely need a help, and btw their philosophy is not worse than GH in every way.

    • Danial, thank you for the kind words. I think that Clement Lefebvre is doing a wonderful job with Linux Mint and Cinnamon.

      • To back up your comments.

        I recently had the pleasure of exchanging comments with Clem on the cinnamon/themes git site and it’s been very cordial & professional. Cinnamon isn’t my thing, at least not in the current form but I have done a couple shell themes that support it & what he’s talking about for the future sounds really good. I expect version 1.1.4 to really generate even more interest.

  • John Glotzer

    Love your work as well. Perhaps a bit off topic – but – when in search mode can the shell be induced *not* to search contacts? While useful for some, I suspect it is merely annoying for many others. Thanks.

  • Mike

    Just curious why do you put effort into these extensions when you have stated that you disagree with the design and execution philosophy of the GNOME Shell Team? Do you feel in spite of these disagreements that GNOME still represents the best desktop for you? You’ve done great work just curious.

    • The commoditization of operating systems such Linux has been occurring for a number of years and is accelerating. The operating system is no longer an important part of a total solution. For Linux distributions, the message is clear; it no longer really matters whether it is Ubuntu, Red Hat, CentOS, SUSE Linux, etc. What matters is the user interface and the applications.

      A new platform is emerging – the web-based operating system. According to Mozilla:

      A truly Web-based OS for mobile phones and tablets would enable the ultimate in user choice and developer opportunity, both from a technology and an ecosystem point of view. Boot to Gecko is a project to build a OS that runs HTML5, JavaScript and CSS directly on device hardware without the need for an intermediate OS layer. The system will include a rich user experience, new APIs that expose the power of modern mobile phones through simple JavaScript interfaces; a privilege model to safely and consistently deliver these capabilities to websites and apps with the user in control. Boot to Gecko leverages BrowserID, the Open Web app ecosystem and an identity and apps model that puts users and developers in control.

      This platform is going to decimate the current personal computing platforms as we know them. The movers and shakers within the Linux ecosystem understand this even if the ordinary end user or developer does not.

      As a result, a lot more importance is being placed on the desktop environment running on top of a given Linux distribution than the distribution itself. Hence Unity, GNOME Shell, Cinnamon and so on. Personally, I suspect JavaScript, HTML5 and CSS3+ are going to be the development tools of choice for the platform of the future. Hence my interest in GNOME Shell, Cinnamon, Enyo and the like.

  • Atef

    i have learned a lot from this blog thank you, it may not be the right place to ask this will u ever provide an example for on screen object for instance if one created a new St.BoxLayout(); with the appropriate properties then add it to the main uigroup, after that create a button then add it to the boxLayout, finally restart the gnome-shell the object will be placed into the screen but the button does not react when a mouse is over it or clicked on it how can one accomplish that, am sorry if that seems stupid, am still new to the javascript of gnome-shell.