// This file is part of the AppIndicator/KStatusNotifierItem GNOME Shell extension
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
const Atk = imports.gi.Atk
const Clutter = imports.gi.Clutter
const Gio = imports.gi.Gio
const GLib = imports.gi.GLib
const GdkPixbuf = imports.gi.GdkPixbuf
const Lang = imports.lang
const PopupMenu = imports.ui.popupMenu
const Signals = imports.signals
const St = imports.gi.St
const Extension = imports.misc.extensionUtils.getCurrentExtension()
const DBusInterfaces = Extension.imports.interfaces
const Util = Extension.imports.util
//////////////////////////////////////////////////////////////////////////
// PART ONE: "ViewModel" backend implementation.
// Both code and design are inspired by libdbusmenu
//////////////////////////////////////////////////////////////////////////
/**
* Saves menu property values and handles type checking and defaults
*/
const PropertyStore = new Lang.Class({
Name: 'DbusMenuPropertyStore',
_init: function(initial_properties) {
this._props = {}
if (initial_properties) {
for (let i in initial_properties) {
this.set(i, initial_properties[i])
}
}
},
set: function(name, value) {
if (name in PropertyStore.MandatedTypes && value && !value.is_of_type(PropertyStore.MandatedTypes[name]))
Util.Logger.warn("Cannot set property "+name+": type mismatch!")
else if (value)
this._props[name] = value
else
delete this._props[name]
},
get: function(name) {
if (name in this._props)
return this._props[name]
else if (name in PropertyStore.DefaultValues)
return PropertyStore.DefaultValues[name]
else
return null
}
})
// we list all the properties we know and use here, so we won' have to deal with unexpected type mismatches
PropertyStore.MandatedTypes = {
'visible' : GLib.VariantType.new("b"),
'enabled' : GLib.VariantType.new("b"),
'label' : GLib.VariantType.new("s"),
'type' : GLib.VariantType.new("s"),
'children-display' : GLib.VariantType.new("s"),
'icon-name' : GLib.VariantType.new("s"),
'icon-data' : GLib.VariantType.new("ay"),
'toggle-type' : GLib.VariantType.new("s"),
'toggle-state' : GLib.VariantType.new("i")
}
PropertyStore.DefaultValues = {
'visible': GLib.Variant.new_boolean(true),
'enabled': GLib.Variant.new_boolean(true),
'label' : GLib.Variant.new_string(''),
'type' : GLib.Variant.new_string("standard")
// elements not in here must return null
}
/**
* Represents a single menu item
*/
const DbusMenuItem = new Lang.Class({
Name: 'DbusMenuItem',
// will steal the properties object
_init: function(client, id, properties, children_ids) {
this._client = client
this._id = id
this._propStore = new PropertyStore(properties)
this._children_ids = children_ids
},
property_get: function(prop_name) {
let prop = this.property_get_variant(prop_name)
return prop ? prop.get_string()[0] : null
},
property_get_variant: function(prop_name) {
return this._propStore.get(prop_name)
},
property_get_bool: function(prop_name) {
let prop = this.property_get_variant(prop_name)
return prop ? prop.get_boolean() : false
},
property_get_int: function(prop_name) {
let prop = this.property_get_variant(prop_name)
return prop ? prop.get_int32() : 0
},
property_set: function(prop, value) {
this._propStore.set(prop, value)
this.emit('property-changed', prop, this.property_get_variant(prop))
},
get_children_ids: function() {
return this._children_ids.concat() // clone it!
},
add_child: function(pos, child_id) {
this._children_ids.splice(pos, 0, child_id)
this.emit('child-added', this._client.get_item(child_id), pos)
},
remove_child: function(child_id) {
// find it
let pos = -1
for (let i = 0; i < this._children_ids.length; ++i) {
if (this._children_ids[i] == child_id) {
pos = i
break
}
}
if (pos < 0) {
Util.Logger.fatal("Trying to remove child which doesn't exist")
} else {
this._children_ids.splice(pos, 1)
this.emit('child-removed', this._client.get_item(child_id))
}
},
move_child: function(child_id, newpos) {
// find the old position
let oldpos = -1
for (let i = 0; i < this._children_ids.length; ++i) {
if (this._children_ids[i] == child_id) {
oldpos = i
break
}
}
if (oldpos < 0) {
Util.Logger.fatal("tried to move child which wasn't in the list")
return
}
if (oldpos != newpos) {
this._children_ids.splice(oldpos, 1)
this._children_ids.splice(newpos, 0, child_id)
this.emit('child-moved', oldpos, newpos, this._client.get_item(child_id))
}
},
get_children: function() {
return this._children_ids.map(function(el) {
return this._client.get_item(el)
}, this)
},
handle_event: function(event, data, timestamp) {
if (!data)
data = GLib.Variant.new_int32(0)
this._client.send_event(this._id, event, data, timestamp)
},
get_id: function() {
return this._id
},
send_about_to_show: function() {
this._client.send_about_to_show(this._id)
}
})
Signals.addSignalMethods(DbusMenuItem.prototype)
const BusClientProxy = Gio.DBusProxy.makeProxyWrapper(DBusInterfaces.DBusMenu);
/**
* The client does the heavy lifting of actually reading layouts and distributing events
*/
const DBusClient = new Lang.Class({
Name: 'DbusMenuBusClient',
_init: function(busName, busPath) {
this._proxy = new BusClientProxy(Gio.DBus.session, busName, busPath, this._clientReady.bind(this))
this._items = { 0: new DbusMenuItem(this, 0, { 'children-display': GLib.Variant.new_string('submenu') }, []) }
// will be set to true if a layout update is requested while one is already in progress
// then the handler that completes the layout update will request another update
this._flagLayoutUpdateRequired = false
this._flagLayoutUpdateInProgress = false
// property requests are queued
this._propertiesRequestedFor = [ /* ids */ ]
},
get_root: function() {
return this._items[0]
},
_requestLayoutUpdate: function() {
if (this._flagLayoutUpdateInProgress)
this._flagLayoutUpdateRequired = true
else
this._beginLayoutUpdate()
},
_requestProperties: function(id) {
// if we don't have any requests queued, we'll need to add one
if (this._propertiesRequestedFor.length < 1)
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, this._beginRequestProperties.bind(this))
if (this._propertiesRequestedFor.filter(function(e) { return e === id }).length == 0)
this._propertiesRequestedFor.push(id)
},
_beginRequestProperties: function() {
this._proxy.GetGroupPropertiesRemote(this._propertiesRequestedFor, [], this._endRequestProperties.bind(this))
this._propertiesRequestedFor = []
return false
},
_endRequestProperties: function(result, error) {
if (error) {
Util.Logger.warn("Could not retrieve properties: "+error)
return
}
// for some funny reason, the result array is hidden in an array
result[0].forEach(function([id, properties]) {
if (!(id in this._items))
return
for (let prop in properties)
this._items[id].property_set(prop, properties[prop])
}, this)
},
// Traverses the list of cached menu items and removes everyone that is not in the list
// so we don't keep alive unused items
_gcItems: function() {
let tag = new Date().getTime()
let toTraverse = [ 0 ]
while (toTraverse.length > 0) {
let item = this.get_item(toTraverse.shift())
item._dbusClientGcTag = tag
Array.prototype.push.apply(toTraverse, item.get_children_ids())
}
for (let i in this._items)
if (this._items[i]._dbusClientGcTag != tag)
delete this._items[i]
},
// the original implementation will only request partial layouts if somehow possible
// we try to save us from multiple kinds of race conditions by always requesting a full layout
_beginLayoutUpdate: function() {
// we only read the type property, because if the type changes after reading all properties,
// the view would have to replace the item completely which we try to avoid
this._proxy.GetLayoutRemote(0, -1, [ 'type', 'children-display' ], this._endLayoutUpdate.bind(this))
this._flagLayoutUpdateRequired = false
this._flagLayoutUpdateInProgress = true
},
_endLayoutUpdate: function(result, error) {
if (error) {
Util.Logger.warn("While reading menu layout: "+error)
return
}
let [ revision, root ] = result
this._doLayoutUpdate(root)
this._gcItems()
if (this._flagLayoutUpdateRequired)
this._beginLayoutUpdate()
else
this._flagLayoutUpdateInProgress = false
},
_doLayoutUpdate: function(item) {
let [ id, properties, children ] = item
let children_unpacked = children.map(function(child) { return child.deep_unpack() })
let children_ids = children_unpacked.map(function(child) { return child[0] })
// make sure all our children exist
children_unpacked.forEach(this._doLayoutUpdate, this)
// make sure we exist
if (id in this._items) {
// we do, update our properties if necessary
for (let prop in properties) {
this._items[id].property_set(prop, properties[prop])
}
// make sure our children are all at the right place, and exist
let old_children_ids = this._items[id].get_children_ids()
for (let i = 0; i < children_ids.length; ++i) {
// try to recycle an old child
let old_child = -1
for (let j = 0; j < old_children_ids.length; ++j) {
if (old_children_ids[j] == children_ids[i]) {
old_child = old_children_ids.splice(j, 1)[0]
break
}
}
if (old_child < 0) {
// no old child found, so create a new one!
this._items[id].add_child(i, children_ids[i])
} else {
// old child found, reuse it!
this._items[id].move_child(children_ids[i], i)
}
}
// remove any old children that weren't reused
old_children_ids.forEach(function(child_id) { this._items[id].remove_child(child_id) }, this)
} else {
// we don't, so let's create us
this._items[id] = new DbusMenuItem(this, id, properties, children_ids)
this._requestProperties(id)
}
return id
},
_clientReady: function(result, error) {
if (error) {
Util.Logger.warn("Could not initialize menu proxy: "+error)
//FIXME: show message to the user?
}
this._requestLayoutUpdate()
// listen for updated layouts and properties
this._proxy.connectSignal("LayoutUpdated", this._onLayoutUpdated.bind(this))
this._proxy.connectSignal("ItemsPropertiesUpdated", this._onPropertiesUpdated.bind(this))
},
get_item: function(id) {
if (id in this._items)
return this._items[id]
Util.Logger.warn("trying to retrieve item for non-existing id "+id+" !?")
return null
},
// we don't need to cache and burst-send that since it will not happen that frequently
send_about_to_show: function(id) {
this._proxy.AboutToShowRemote(id, (function(result, error) {
if (error)
Util.Logger.warn("while calling AboutToShow: "+error)
else if (result && result[0])
this._requestLayoutUpdate()
}).bind(this))
},
send_event: function(id, event, params, timestamp) {
if (!this._proxy)
return
this._proxy.EventRemote(id, event, params, timestamp, function(result, error) { /* we don't care */ })
},
_onLayoutUpdated: function() {
this._requestLayoutUpdate()
},
_onPropertiesUpdated: function(proxy, name, [changed, removed]) {
changed.forEach(function([id, props]) {
if (!(id in this._items))
return
for (let prop in props)
this._items[id].property_set(prop, props[prop])
}, this)
removed.forEach(function([id, propNames]) {
if (!(id in this._items))
return
propNames.forEach(function(propName) {
this._items[id].property_set(propName, null)
}, this)
}, this)
},
destroy: function() {
this.emit('destroy')
Signals._disconnectAll.apply(this._proxy)
this._proxy = null
}
})
Signals.addSignalMethods(DBusClient.prototype)
//////////////////////////////////////////////////////////////////////////
// PART TWO: "View" frontend implementation.
//////////////////////////////////////////////////////////////////////////
// https://bugzilla.gnome.org/show_bug.cgi?id=731514
// GNOME 3.10 and 3.12 can't open a nested submenu.
// Patches have been written, but it's not clear when (if?) they will be applied.
// We also don't know whether they will be backported to 3.10, so we will work around
// it in the meantime. Offending versions can be clearly identified:
const NEED_NESTED_SUBMENU_FIX = '_setOpenedSubMenu' in PopupMenu.PopupMenu.prototype
/**
* Creates new wrapper menu items and injects methods for managing them at runtime.
*
* Many functions in this object will be bound to the created item and executed as event
* handlers, so any `this` will refer to a menu item create in createItem
*/
const MenuItemFactory = {
createItem: function(client, dbusItem) {
// first, decide whether it's a submenu or not
if (dbusItem.property_get("children-display") == "submenu")
var shellItem = new PopupMenu.PopupSubMenuMenuItem("FIXME")
else if (dbusItem.property_get("type") == "separator")
var shellItem = new PopupMenu.PopupSeparatorMenuItem('')
else
var shellItem = new PopupMenu.PopupMenuItem("FIXME")
shellItem._dbusItem = dbusItem
shellItem._dbusClient = client
if (shellItem instanceof PopupMenu.PopupMenuItem) {
shellItem._icon = new St.Icon({ style_class: 'popup-menu-icon', x_align: St.Align.END })
shellItem.actor.add(shellItem._icon, { x_align: St.Align.END })
shellItem.label.get_parent().child_set(shellItem.label, { expand: true })
}
// initialize our state
MenuItemFactory._updateLabel.call(shellItem)
MenuItemFactory._updateOrnament.call(shellItem)
MenuItemFactory._updateImage.call(shellItem)
MenuItemFactory._updateVisible.call(shellItem)
MenuItemFactory._updateSensitive.call(shellItem)
// initially create children
if (shellItem instanceof PopupMenu.PopupSubMenuMenuItem) {
let children = dbusItem.get_children()
for (let i = 0; i < children.length; ++i) {
shellItem.menu.addMenuItem(MenuItemFactory.createItem(client, children[i]))
}
}
// now, connect various events
Util.connectSmart(dbusItem, 'property-changed', shellItem, MenuItemFactory._onPropertyChanged)
Util.connectSmart(dbusItem, 'child-added', shellItem, MenuItemFactory._onChildAdded)
Util.connectSmart(dbusItem, 'child-removed', shellItem, MenuItemFactory._onChildRemoved)
Util.connectSmart(dbusItem, 'child-moved', shellItem, MenuItemFactory._onChildMoved)
Util.connectSmart(shellItem, 'activate', shellItem, MenuItemFactory._onActivate)
if (shellItem.menu)
Util.connectSmart(shellItem.menu, "open-state-changed", shellItem, MenuItemFactory._onOpenStateChanged)
return shellItem
},
_onOpenStateChanged: function(menu, open) {
if (open) {
if (NEED_NESTED_SUBMENU_FIX) {
// close our own submenus
if (menu._openedSubMenu)
menu._openedSubMenu.close(false)
// register ourselves and close sibling submenus
if (menu._parent._openedSubMenu && menu._parent._openedSubMenu !== menu)
menu._parent._openedSubMenu.close(true)
menu._parent._openedSubMenu = menu
}
this._dbusItem.handle_event("opened", null, 0)
this._dbusItem.send_about_to_show()
} else {
if (NEED_NESTED_SUBMENU_FIX) {
// close our own submenus
if (menu._openedSubMenu)
menu._openedSubMenu.close(false)
}
this._dbusItem.handle_event("closed", null, 0)
}
},
_onActivate: function() {
this._dbusItem.handle_event("clicked", GLib.Variant.new("i", 0), 0)
},
_onPropertyChanged: function(dbusItem, prop, value) {
if (prop == "toggle-type" || prop == "toggle-state")
MenuItemFactory._updateOrnament.call(this)
else if (prop == "label")
MenuItemFactory._updateLabel.call(this)
else if (prop == "enabled")
MenuItemFactory._updateSensitive.call(this)
else if (prop == "visible")
MenuItemFactory._updateVisible.call(this)
else if (prop == "icon-name" || prop == "icon-data")
MenuItemFactory._updateImage.call(this)
else if (prop == "type" || prop == "children-display")
MenuItemFactory._replaceSelf.call(this)
//else
// Util.Logger.debug("Unhandled property change: "+prop)
},
_onChildAdded: function(dbusItem, child, position) {
if (!(this instanceof PopupMenu.PopupSubMenuMenuItem)) {
Util.Logger.warn("Tried to add a child to non-submenu item. Better recreate it as whole")
MenuItemFactory._replaceSelf.call(this)
} else {
this.menu.addMenuItem(MenuItemFactory.createItem(this._dbusClient, child), position)
}
},
_onChildRemoved: function(dbusItem, child) {
if (!(this instanceof PopupMenu.PopupSubMenuMenuItem)) {
Util.Logger.warn("Tried to remove a child from non-submenu item. Better recreate it as whole")
MenuItemFactory._replaceSelf.call(this)
} else {
// find it!
this.menu._getMenuItems().forEach(function(item) {
if (item._dbusItem == child)
item.destroy()
})
}
},
_onChildMoved: function(dbusItem, child, oldpos, newpos) {
if (!(this instanceof PopupMenu.PopupSubMenuMenuItem)) {
Util.Logger.warn("Tried to move a child in non-submenu item. Better recreate it as whole")
MenuItemFactory._replaceSelf.call(this)
} else {
MenuUtils.moveItemInMenu(this.menu, child, newpos)
}
},
_updateLabel: function() {
let label = this._dbusItem.property_get("label").replace(/_([^_])/, "$1")
if (this.label) // especially on GS3.8, the separator item might not even have a hidden label
this.label.set_text(label)
},
_updateOrnament: function() {
if (!this.setOrnament) return // separators and alike might not have gotten the polyfill
if (this._dbusItem.property_get("toggle-type") == "checkmark" && this._dbusItem.property_get_int("toggle-state"))
this.setOrnament(PopupMenu.Ornament.CHECK)
else if (this._dbusItem.property_get("toggle-type") == "radio" && this._dbusItem.property_get_int("toggle-state"))
this.setOrnament(PopupMenu.Ornament.DOT)
else
this.setOrnament(PopupMenu.Ornament.NONE)
},
_updateImage: function() {
if (!this._icon) return // might be missing on submenus / separators
let iconName = this._dbusItem.property_get("icon-name")
let iconData = this._dbusItem.property_get_variant("icon-data")
if (iconName)
this._icon.icon_name = iconName
else if (iconData)
this._icon.gicon = GdkPixbuf.Pixbuf.new_from_stream(Gio.MemoryInputStream.new_from_bytes(iconData.get_data_as_bytes()), null)
},
_updateVisible: function() {
this.actor.visible = this._dbusItem.property_get_bool("visible")
},
_updateSensitive: function() {
this.setSensitive(this._dbusItem.property_get_bool("enabled"))
},
_replaceSelf: function(newSelf) {
// create our new self if needed
if (!newSelf)
newSelf = MenuItemFactory.createItem(this._dbusClient, this._dbusItem)
// first, we need to find our old position
let pos = -1
let family = this._parent._getMenuItems()
for (let i = 0; i < family.length; ++i) {
if (family[i] === this)
pos = i
}
if (pos < 0)
throw new Error("DBusMenu: can't replace non existing menu item")
// add our new self while we're still alive
this._parent.addMenuItem(newSelf, pos)
// now destroy our old self
this.destroy()
}
}
/**
* Utility functions not necessarily belonging into the item factory
*/
const MenuUtils = {
moveItemInMenu: function(menu, dbusItem, newpos) {
//HACK: we're really getting into the internals of the PopupMenu implementation
// First, find our wrapper. Children tend to lie. We do not trust the old positioning.
let family = menu._getMenuItems()
for (let i = 0; i < family.length; ++i) {
if (family[i]._dbusItem == dbusItem) {
// now, remove it
menu.box.remove_child(family[i].actor)
// and add it again somewhere else
if (newpos < family.length && family[newpos] != family[i])
menu.box.insert_child_below(family[i].actor, family[newpos].actor)
else
menu.box.add(family[i].actor)
// skip the rest
return
}
}
}
}
/**
* Processes DBus events, creates the menu items and handles the actions
*
* Something like a mini-god-object
*/
var Client = new Lang.Class({
Name: 'DbusMenuClient',
_init: function(busName, path) {
this.parent()
this._busName = busName
this._busPath = path
this._client = new DBusClient(busName, path)
this._rootMenu = null // the shell menu
this._rootItem = null // the DbusMenuItem for the root
},
// this will attach the client to an already existing menu that will be used as the root menu.
// it will also connect the client to be automatically destroyed when the menu dies.
attachToMenu: function(menu) {
this._rootMenu = menu
this._rootItem = this._client.get_root()
// cleanup: remove existing children (just in case)
this._rootMenu.removeAll()
if (NEED_NESTED_SUBMENU_FIX)
menu._setOpenedSubMenu = this._setOpenedSubmenu.bind(this)
// connect handlers
Util.connectSmart(menu, 'open-state-changed', this, '_onMenuOpened')
Util.connectSmart(menu, 'destroy', this, 'destroy')
Util.connectSmart(this._rootItem, 'child-added', this, '_onRootChildAdded')
Util.connectSmart(this._rootItem, 'child-removed', this, '_onRootChildRemoved')
Util.connectSmart(this._rootItem, 'child-moved', this, '_onRootChildMoved')
// Dropbox requires us to call AboutToShow(0) first
this._rootItem.send_about_to_show()
// fill the menu for the first time
this._rootItem.get_children().forEach(function(child) {
this._rootMenu.addMenuItem(MenuItemFactory.createItem(this, child))
}, this)
},
_setOpenedSubmenu: function(submenu) {
if (!submenu)
return
if (submenu._parent != this._rootMenu)
return
if (submenu === this._openedSubMenu)
return
if (this._openedSubMenu && this._openedSubMenu.isOpen)
this._openedSubMenu.close(true)
this._openedSubMenu = submenu
},
_onRootChildAdded: function(dbusItem, child, position) {
this._rootMenu.addMenuItem(MenuItemFactory.createItem(this, child), position)
},
_onRootChildRemoved: function(dbusItem, child) {
// children like to play hide and seek
// but we know how to find it for sure!
this._rootMenu._getMenuItems().forEach(function(item) {
if (item._dbusItem == child)
item.destroy()
})
},
_onRootChildMoved: function(dbusItem, child, oldpos, newpos) {
MenuUtils.moveItemInMenu(this._rootMenu, dbusItem, newpos)
},
_onMenuOpened: function(menu, state) {
if (!this._rootItem) return
if (state) {
if (this._openedSubMenu && this._openedSubMenu.isOpen)
this._openedSubMenu.close()
this._rootItem.handle_event("opened", null, 0)
this._rootItem.send_about_to_show()
} else {
this._rootItem.handle_event("closed", null, 0)
}
},
destroy: function() {
this.emit('destroy')
if (this._client)
this._client.destroy()
this._client = null
this._rootItem = null
this._rootMenu = null
}
})
Signals.addSignalMethods(Client.prototype)