"use strict"; var EXPORTED_SYMBOLS = ["TopMenuService"]; var Cu = Components.utils; var Cc = Components.classes; var Ci = Components.interfaces; Cu.import("resource://gre/modules/ctypes.jsm"); Cu.import("chrome://topmenu/content/log4moz.js"); Cu.import("chrome://topmenu/content/glib.js"); Cu.import("chrome://topmenu/content/gobject.js"); Cu.import("chrome://topmenu/content/gdk.js"); Cu.import("chrome://topmenu/content/gdk-pixbuf.js"); Cu.import("chrome://topmenu/content/gtk.js"); Cu.import("chrome://topmenu/content/topmenu-client.js"); Cu.import("chrome://topmenu/content/vkgdkmap.js"); var log = Log4Moz.repository.getLogger("topmenu.TopMenuService"); /* Escape a string so that it is a valid regular expression literal. */ function escapePreg(s) { if (!s) return ""; var regex = new RegExp('[.\\\\+*?\\[\\^\\]$(){}=!<>|:\\-]', 'g'); return s.replace(regex, "\\$&"); } function getBaseWindowInterface(w) { return w.QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIWebNavigation) .QueryInterface(Ci.nsIDocShellTreeItem) .treeOwner .QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIBaseWindow); } function getTopLevelGdkWindow(w) { var baseWindow = getBaseWindowInterface(w); var handle = baseWindow.nativeHandle; var gdkWindow = new gdk.GdkWindow.ptr(ctypes.UInt64(handle)); return gdk.gdk_window_get_toplevel(gdkWindow); } function createMutationObserver(window, proxy) { return new window.MutationObserver(function(mutations) { try { mutations.forEach(function(mutation) { proxy.handleMutation(mutation); }); } catch (ex) { log.warn("Exception processing menubar changes: " + ex); } }); } function WindowProxy(window) { var menubars = window.document.getElementsByTagNameNS('http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul', 'menubar'); if (menubars.length === 0) { // No menubars, give up constructing the object return; } this.srcWindow = window; this.srcMenuBar = menubars[0]; // A hack to heuristically detect if we must export this menubar. var srcMenuBarParent = this.srcMenuBar.parentNode; if (srcMenuBarParent.firstChild !== this.srcMenuBar) { // This menu is not the first child on the parent item. // Do not export this menubar. this.srcWindow = null; this.srcMenuBar = null; return; } this.gdkWindow = getTopLevelGdkWindow(window); this.flags = {}; this.updateFlags(); var self = this; this.lastCallbackId = 0; this.callbackItems = {}; this.callbacks = { activate : gtk.GtkMenuItemActivate.ptr(function (gtkItem, data) { try { var id = ctypes.cast(data, ctypes.uintptr_t).value; var item = self.callbackItems[id]; self.activateItem(item); } catch (ex) { Cu.reportError(ex); log.error("Exception in callback: " + ex.toString()); } }), select : gtk.GtkItemSelect.ptr(function (gtkItem, data) { try { var id = ctypes.cast(data, ctypes.uintptr_t).value; var item = self.callbackItems[id]; self.selectItem(item); } catch (ex) { Cu.reportError(ex); log.error("Exception in callback: " + ex.toString()); } }), deselect : gtk.GtkItemDeselect.ptr(function (gtkItem, data) { try { var id = ctypes.cast(data, ctypes.uintptr_t).value; var item = self.callbackItems[id]; self.deselectItem(item); } catch (ex) { Cu.reportError(ex); log.error("Exception in callback: " + ex.toString()); } }), monitor_available : gobject.GObjectNotifyCallback.ptr(function (obj, pspec, data) { self.updateMenuBarVisibility(); }), keypress : function(e) { try { self.onKeyPress(e); } catch (ex) { Cu.reportError(ex); log.error("Exception in callback: " + ex.toString()); } } } // Initialize the Gtk widgets for this window this.accelGroup = gobject.ref_sink(gtk.gtk_accel_group_new()); this.appMenuBar = gobject.ref_sink(topmenu_client.topmenu_app_menu_bar_new()); gtk.gtk_widget_show(this.appMenuBar); topmenu_client.topmenu_client_connect_window_widget(this.gdkWindow, this.appMenuBar); // Connect to the first available menu bar this.srcMenuBar = menubars[0]; this.observer = createMutationObserver(window, this); this.observer.observe(this.srcMenuBar, { childList: true, attributes: true, subtree: true }); // Let's find currently added menu items and proxy them this.srcMenuBar.topmenuData = { gtkMenu: this.appMenuBar } for (var item = this.srcMenuBar.firstChild; item; item = item.nextSibling) { this.addItem(item, this.srcMenuBar, -1); } this.appMenu = this.buildAppMenu(); if (this.appMenu) { topmenu_client.topmenu_app_menu_bar_set_app_menu(this.appMenuBar, this.appMenu); } window.document.addEventListener("keypress", this.callbacks.keypress); // Connect to the appmenu monitor this.monitor = topmenu_client.topmenu_monitor_get_instance(); this.monitorConnectionId = gobject.signal_connect(this.monitor, "notify::available", this.callbacks.monitor_available, null); // This will hide the source menu bar if everything's OK. this.updateMenuBarVisibility(); } WindowProxy.prototype.setMenuBarVisibility = function(visible) { if (this.srcMenuBar) { this.srcMenuBar.hidden = !visible; // A hack for windows containing the menubar in a toolbar. var toolbar = this.srcMenuBar.parentNode.parentNode; if (toolbar && toolbar.tagName === 'toolbar') { toolbar.hidden = !visible; } } } WindowProxy.prototype.updateMenuBarVisibility = function() { var topmenuAvailable = topmenu_client.topmenu_monitor_is_topmenu_available(this.monitor); this.setMenuBarVisibility(!topmenuAvailable); } WindowProxy.prototype.buildAppMenuItem = function(itemId, stockId) { var item = this.srcWindow.document.getElementById(itemId); if (item) { var gtkItem = gtk.gtk_image_menu_item_new_from_stock(stockId, null); gtk.gtk_widget_show(gtkItem); var callbackId = this.lastCallbackId++; this.callbackItems[callbackId] = item; gobject.signal_connect(gtkItem, 'activate', this.callbacks.activate, glib.gpointer(callbackId)); return gtkItem; } else { return null; } } WindowProxy.prototype.buildAppMenu = function() { var menu = gobject.ref_sink(gtk.gtk_menu_new()); var item; item = this.buildAppMenuItem('aboutName', 'gtk-about'); if (item) { gtk.gtk_menu_shell_append(menu, item); } item = this.buildAppMenuItem('menu_preferences', 'gtk-preferences'); if (item) { gtk.gtk_menu_shell_append(menu, item); } item = gtk.gtk_separator_menu_item_new(); gtk.gtk_widget_show(item); gtk.gtk_menu_shell_append(menu, item); item = this.buildAppMenuItem('menu_FileQuitItem', 'gtk-quit'); if (item) { gtk.gtk_menu_shell_append(menu, item); } return menu; } WindowProxy.prototype.handleMutation = function(mutation) { var target = mutation.target; switch (mutation.type) { case 'childList': switch (target.tagName) { case 'menupopup': // Get the parent menu for this menupopup: target = mutation.target.parentNode; if ('topmenuData' in target) { this.updateMenu(target, mutation.previousSibling, mutation.removedNodes, mutation.addedNodes); } else { log.warn("I do not know about this menupoup"); } break; case 'menubar': // Changes in the root menu bar. if ('topmenuData' in target) { this.updateMenu(target, mutation.previousSibling, mutation.removedNodes, mutation.addedNodes); } else { log.warn("I do not know about this menubar"); } } break; case 'attributes': switch (target.tagName) { case 'menupopup': case 'menuitem': case 'menu': if ('topmenuData' in target) { this.updateItem(mutation.target, mutation.attributeName); } break; case 'command': if (mutation.attributeName === 'disabled' && 'topmenuData' in target) { target.topmenuData.menuitems.forEach(function(item) { this.updateItem(item, 'disabled'); }, this); } } break; } } WindowProxy.prototype.updateFlags = function() { var prefs = Cc["@mozilla.org/preferences-service;1"]. getService(Ci.nsIPrefService).getBranch(""); this.flags.accelKey = prefs.getIntPref("ui.key.accelKey"); this.flags.alwaysAppendAccessKeys = prefs.getComplexValue("intl.menuitems.alwaysappendaccesskeys", Ci.nsIPrefLocalizedString).data == "true"; this.flags.insertSeparatorBeforeAccessKeys = prefs.getComplexValue( "intl.menuitems.insertseparatorbeforeaccesskeys", Ci.nsIPrefLocalizedString).data == "true"; this.flags.ellipsis = prefs.getComplexValue("intl.ellipsis", Ci.nsIPrefLocalizedString).data; } /* Convert from XUL mnemonic format to Gtk+ */ /* https://developer.mozilla.org/En/XUL_Tutorial/Accesskey_display_rules */ WindowProxy.prototype.createLabelWithAccessKey = function(label, accesskey) { label = label.replace(/_/g, "__"); if (accesskey) { var regex = new RegExp(escapePreg(accesskey), "i"); if (!this.flags.alwaysAppendAccessKeys && label.search(regex) >= 0) { /* "Inline" access key (ex. "_File") */ return label.replace(regex, "_$&"); } else { /* Appended access key (ex. "File (_A)") */ if (label.slice(-this.flags.ellipsis.length) == this.flags.ellipsis) { /* Label ends with ellipsis */ return label.substring(0, label.length-this.flags.ellipsis.length) + (this.flags.insertSeparatorBeforeAccessKeys ? " " : "") + "(_" + accesskey.toUpperCase() + ")" + this.flags.ellipsis; } else { return label + (this.flags.insertSeparatorBeforeAccessKeys ? " " : "") + "(_" + accesskey.toUpperCase() + ")"; } } } else { /* No access key */ return label; } } WindowProxy.prototype.createImageWidgetFromImage = function(image, rect) { var canvas = this.srcWindow.document.createElementNS("http://www.w3.org/1999/xhtml","canvas"); var ctx = canvas.getContext('2d'); if (!rect[2]) rect[2] = image.width; if (!rect[3]) rect[3] = image.height; ctx.drawImage(image, rect[0], rect[1], rect[2], rect[3], 0, 0, 16, 16); var img_data = ctx.getImageData(0,0, 16, 16); var pix_data = ctypes.cast(ctypes.uint8_t.array()(img_data.data).address(), glib.guchar.ptr); var pixbuf = gobject.ref_sink(gdk_pixbuf.gdk_pixbuf_new_from_data(pix_data, gdk_pixbuf.GDK_COLORSPACE_RGB, true, 8, 16, 16, 16 * 4, null, null)); var pixbuf_copy = gobject.ref_sink(gdk_pixbuf.gdk_pixbuf_copy(pixbuf)); var gtkImage = gtk.gtk_image_new_from_pixbuf(pixbuf_copy); pixbuf.dispose(); pixbuf_copy.dispose(); return gtkImage; } WindowProxy.prototype.createImageWidget = function(item, style) { var image_uri = item.getAttribute("image"); var res; if (!image_uri) { var style_regex = /url\("(.*?)"\)/; var image_style = style.getPropertyValue("list-style-image"); res = style_regex.exec(image_style); if (res) { image_uri = res[1]; } } if (image_uri) { /* Test if it is a Gtk stock image first */ var mozicon_regex = /moz-icon:\/\/stock\/(.*?)\?/; res = mozicon_regex.exec(image_uri); if (res) { return gtk.gtk_image_new_from_stock(res[1], gtk.GTK_ICON_SIZE_MENU); } /* Handle image clipping */ var rect = [0, 0, 0, 0]; var region = style.getPropertyValue("-moz-image-region"); if (region && region !== "auto") { var region_regex = /rect\((\d+)px, (\d+)px, (\d+)px, (\d+)px\)/; var values = region_regex.exec(region); if (values) { rect[0] = parseInt(values[4]); /* Left */ rect[1] = parseInt(values[1]); /* Top */ rect[2] = parseInt(values[2]) - rect[0]; /* Right - Left*/ rect[3] = parseInt(values[3]) - rect[1]; /* Bottom - Top */ } } var img_elem = new this.srcWindow.Image(); img_elem.src = image_uri; if (img_elem.complete) { return this.createImageWidgetFromImage(img_elem, rect); } else { // Set a handler to try again when the image is loaded var self = this; img_elem.onload = function() { self.updateItem(item, "image"); } item.topmenuImgLoader = img_elem; return "pending"; } } return null; } WindowProxy.prototype.addAccelerator = function(gtkItem, item) { var keyId = item.getAttribute("key"); if (!keyId) return false; var keyItem = this.srcWindow.document.getElementById(keyId); if (!keyItem) return false; var keyItemMods = keyItem.getAttribute("modifiers"); var gtkMods = 0; if (keyItemMods) { keyItemMods = keyItemMods.split(/[\s,]+/); for (var i in keyItemMods) { var mod = keyItemMods[i]; if (mod === "accel") { /* Read platform default accelerator key from prefs */ switch (this.flags.accelKey) { case this.srcWindow.KeyEvent.DOM_VK_SHIFT: mod = "shift"; break; case this.srcWindow.KeyEvent.DOM_VK_CONTROL: mod = "control"; break; case this.srcWindow.KeyEvent.DOM_VK_ALT: mod = "alt"; break; case this.srcWindow.KeyEvent.DOM_VK_META: mod = "meta"; break; } } switch (mod) { case "shift": gtkMods += gdk.GDK_SHIFT_MASK; break; case "alt": gtkMods += gdk.GDK_ALT_MASK; break; case "meta": gtkMods += gdk.GDK_META_MASK; break; case "control": gtkMods += gdk.GDK_CONTROL_MASK; break; default: continue; } } } var keyItemKey = keyItem.getAttribute("key"); var keyItemCode = keyItem.getAttribute("keycode"); var gtkKey = 0; if (keyItemKey) { gtkKey = gdk.gdk_unicode_to_keyval(keyItemKey.charCodeAt(0)); } else if (keyItemCode) { gtkKey = vkgdkmap[keyItemCode]; if (!gtkKey) { log.info("Keycode " + keyItemCode + " is unmapped"); } } if (gtkKey) { gtk.gtk_widget_add_accelerator(gtkItem, "activate", this.accelGroup, gtkKey, gtkMods, gtk.GTK_ACCEL_VISIBLE); } } WindowProxy.prototype.addItem = function(item, menu, position) { var disabled = item.disabled; var style = this.srcWindow.getComputedStyle(item, null); var label = this.createLabelWithAccessKey(item.getAttribute('label'), item.accessKey); var visible = !item.hidden && !item.collapsed && style.display !== 'none'; var hasSubMenu = item.tagName === "menu" && item.firstChild && item.firstChild.tagName === "menupopup"; var gtkItem; if (item.tagName === "menuseparator") { gtkItem = gobject.ref_sink(gtk.gtk_separator_menu_item_new()); } else if (item.tagName === "menu") { gtkItem = gobject.ref_sink(gtk.gtk_menu_item_new_with_mnemonic(label)); } else if (item.tagName === "menuitem") { var image = this.createImageWidget(item, style); if (image) { gtkItem = gobject.ref_sink(gtk.gtk_image_menu_item_new_with_mnemonic(label)); if (image !== "pending") { gtk.gtk_image_menu_item_set_image(gtkItem, image); } } else { var type = item.getAttribute("type"); var checked = item.getAttribute("checked") ? true : false; switch (type) { case "checkbox": gtkItem = gobject.ref_sink(gtk.gtk_check_menu_item_new_with_mnemonic(label)); gtk.gtk_check_menu_item_set_active(gtkItem, checked); break; case "radio": gtkItem = gobject.ref_sink(gtk.gtk_check_menu_item_new_with_mnemonic(label)); gtk.gtk_check_menu_item_set_draw_as_radio(gtkItem, true); gtk.gtk_check_menu_item_set_active(gtkItem, checked); break; default: gtkItem = gobject.ref_sink(gtk.gtk_menu_item_new_with_mnemonic(label)); } } var command = item.getAttribute('command'); if (command) { command = this.srcWindow.document.getElementById(command); if (command) { if (!('topmenuData' in command)) { command.topmenuData = { menuitems: [item] }; } else { command.topmenuData.menuitems.push(item); } this.observer.observe(command, {attributes: true}); if (command.getAttribute('disabled')) { disabled = true; } } } if (item.hasAttribute("key")) { try { this.addAccelerator(gtkItem, item); } catch (ex) { log.warn("Exception during accelerator computation: " + ex); } } } else if (item.tagName === "spacer") { // Nothing to do return; } else { log.warn("Unknown tagName: " + item.tagName); return; } gtk.gtk_widget_set_sensitive(gtkItem, !disabled); gtk.gtk_widget_set_visible(gtkItem, visible); item.topmenuData = { gtkItem : gtkItem, callbackId: this.lastCallbackId++ }; this.callbackItems[item.topmenuData.callbackId] = item; if (hasSubMenu) { var gtkSubMenu = this.addMenu(item); gtk.gtk_menu_item_set_submenu(gtkItem, gtkSubMenu); } var gtkMenu = menu.topmenuData.gtkMenu; if (position >= 0) { gtk.gtk_menu_shell_insert(gtkMenu, gtkItem, position); } else { gtk.gtk_menu_shell_append(gtkMenu, gtkItem); } var conn_id; if (hasSubMenu) { conn_id = gobject.signal_connect(gtkItem, 'select', this.callbacks.select, glib.gpointer(item.topmenuData.callbackId)); item.topmenuData.selectConnectionId = conn_id; conn_id = gobject.signal_connect(gtkItem, 'deselect', this.callbacks.deselect, glib.gpointer(item.topmenuData.callbackId)); item.topmenuData.deselectConnectionId = conn_id; } else { conn_id = gobject.signal_connect(gtkItem, 'activate', this.callbacks.activate, glib.gpointer(item.topmenuData.callbackId)); item.topmenuData.activateConnectionId = conn_id; } return gtkItem; } WindowProxy.prototype.addMenu = function(menu) { var gtkMenu = gobject.ref_sink(gtk.gtk_menu_new()); menu.topmenuData.gtkMenu = gtkMenu; var popup = menu.firstChild; for (var item = popup.firstChild; item; item = item.nextSibling) { this.addItem(item, menu, -1); } return gtkMenu; } WindowProxy.prototype.removeItem = function(item) { var hasSubMenu = item.tagName === "menu" && item.firstChild && item.firstChild.tagName === "menupopup"; if (hasSubMenu) { this.removeMenu(item); } delete this.callbackItems[item.topmenuData.callbackId]; var gtkItem = item.topmenuData.gtkItem; gtk.gtk_widget_destroy(gtkItem); gtkItem.dispose(); delete item.topmenuData; } WindowProxy.prototype.removeMenu = function(menu) { var gtkMenu = menu.topmenuData.gtkMenu; gtk.gtk_widget_destroy(gtkMenu); gtkMenu.dispose(); } WindowProxy.prototype.updateItem = function(item, changedAttr) { var gtkItem = item.topmenuData.gtkItem; var style = this.srcWindow.getComputedStyle(item, null); switch (changedAttr) { case "accesskey": case "label": var label = this.createLabelWithAccessKey(item.getAttribute('label'), item.accessKey); gtk.gtk_menu_item_set_label(gtkItem, label); break; case "disabled": var disabled = item.disabled; var command = item.getAttribute('command'); if (!disabled && command) { command = this.srcWindow.document.getElementById(command); if (command) { if (command.getAttribute('disabled')) { disabled = true; } } } gtk.gtk_widget_set_sensitive(gtkItem, !disabled); break; case "hidden": case "collapsed": var visible = !item.hidden && !item.collapsed && style.display !== 'none'; gtk.gtk_widget_set_visible(gtkItem, visible); break; case "image": var image = this.createImageWidget(item, style); if (image !== 'pending') { delete item.topmenuImgLoader; gtk.gtk_image_menu_item_set_image(gtkItem, image); } break; } } WindowProxy.prototype.updateMenu = function(menu, prevItem, removedItems, addedItems) { var position, i; var popup = menu.firstChild; if (removedItems.length > 0) { for (i = 0; i < removedItems.length; i++) { this.removeItem(removedItems[i]); } } if (addedItems.length > 0) { if (prevItem) { position = -1; i = 2; for (var node = popup.firstChild; node; node = node.nextSibling, i++) { if (node === prevItem) { position = i; break; } } if (position < 0) { log.warn("prevItem node not found"); position = 0; } } else { position = 0; } for (i = 0; i < addedItems.length; i++) { this.addItem(addedItems[i], menu, position); } } } WindowProxy.prototype.activateItem = function(item) { this.fakeCommandEvent(item); } WindowProxy.prototype.selectItem = function(item) { var menu = item; var popup = menu.firstChild; if (popup.tagName !== 'menupopup') { log.warn("Selected a menu without a menupopup"); return; } this.fakePopupEvent(popup, "popupshowing"); menu.open = "true"; // A hack for Firefox and friends if (menu.id === 'edit-menu' && 'gEditUIVisible' in this.srcWindow) { this.srcWindow.gEditUIVisible = true; this.srcWindow.goUpdateGlobalEditMenuItems(); } // Let's update the label of every item at least. for (var i = popup.firstChild; i; i = i.nextSibling) { switch (i.tagName) { case 'menu': case 'menuitem': this.updateItem(i, 'label'); this.updateItem(i, 'hidden'); break; case 'menuseparator': this.updateItem(i, 'hidden'); break; } } this.fakePopupEvent(popup, "popupshown"); } WindowProxy.prototype.deselectItem = function(item) { var menu = item; var popup = menu.firstChild; if (popup.tagName !== 'menupopup') { log.warn("Selected a menu without a menupopup"); return; } this.fakePopupEvent(popup, "popuphiding"); menu.removeAttribute('open'); if (menu.id === 'edit-menu' && 'gEditUIVisible' in this.srcWindow) { this.srcWindow.gEditUIVisible = false; } this.fakePopupEvent(popup, "popuphidden"); } WindowProxy.prototype.onKeyPress = function(event) { if (!event.altKey) return; if (!event.which) return; var char = String.fromCharCode(event.which).toLowerCase(); if (!char) return; for (var item = this.srcMenuBar.firstChild; item; item = item.nextSibling) { if ('topmenuData' in item && item.getAttribute('accesskey').toLowerCase() === char) { event.preventDefault(); event.stopImmediatePropagation(); gtk.gtk_widget_mnemonic_activate(item.topmenuData.gtkItem, false); } } } WindowProxy.prototype.fakePopupEvent = function(popup, type) { var window = this.srcWindow; var popupEvent = window.document.createEvent("MouseEvent"); popupEvent.initMouseEvent(type, true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null); popup.dispatchEvent(popupEvent); } WindowProxy.prototype.fakeActiveEvent = function(item, type) { var window = this.srcWindow; var activeEvent = window.document.createEvent("Events"); activeEvent.initEvent(type, true, false); item.dispatchEvent(activeEvent); } WindowProxy.prototype.fakeCommandEvent = function(item) { var window = this.srcWindow; var commandEvent = window.document.createEvent("XULCommandEvent"); commandEvent.initCommandEvent("command", true, true, window, 0, false, false, false, false, null); item.dispatchEvent(commandEvent); } WindowProxy.prototype.unload = function() { this.setMenuBarVisibility(true); if (this.gdkWindow) { topmenu_client.topmenu_client_disconnect_window(this.gdkWindow); } } WindowProxy.prototype.dispose = function() { if (this.srcWindow) { this.srcWindow.document.removeEventListener("keypress", this.callbacks.keypress); } if (this.monitor) { if (this.monitorConnectionId) { gobject.g_signal_handler_disconnect(this.monitor, this.monitorConnectionId); } this.monitorConnectionId = 0; this.monitor = null; } if (this.observer) { this.observer.disconnect(); this.observer = null; } if (this.appMenuBar) { this.appMenuBar.dispose(); this.appMenuBar = null; } if (this.appMenu) { this.appMenu.dispose(); this.appMenu = null; } if (this.accelGroup) { this.accelGroup.dispose(); this.accelGroup = null; } this.gdkWindow = null; this.srcMenuBar = null; this.srcWindow = null; this.menus = null; this.items = null; } var TopMenuService = { createWindowProxy: function(window) { return new WindowProxy(window); } }