diff options
Diffstat (limited to 'widget/gtk/NativeMenuGtk.cpp')
-rw-r--r-- | widget/gtk/NativeMenuGtk.cpp | 423 |
1 files changed, 423 insertions, 0 deletions
diff --git a/widget/gtk/NativeMenuGtk.cpp b/widget/gtk/NativeMenuGtk.cpp new file mode 100644 index 0000000000..7f2c5916fe --- /dev/null +++ b/widget/gtk/NativeMenuGtk.cpp @@ -0,0 +1,423 @@ +/* -*- Mode: c++; tab-width: 2; indent-tabs-mode: nil; -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "NativeMenuGtk.h" +#include "mozilla/dom/Document.h" +#include "mozilla/dom/DocumentInlines.h" +#include "mozilla/dom/XULCommandEvent.h" +#include "mozilla/WidgetUtilsGtk.h" +#include "mozilla/EventDispatcher.h" +#include "nsPresContext.h" +#include "nsIWidget.h" +#include "nsWindow.h" +#include "nsStubMutationObserver.h" +#include "mozilla/dom/Element.h" +#include "mozilla/StaticPrefs_widget.h" + +#include <dlfcn.h> +#include <gtk/gtk.h> + +namespace mozilla::widget { + +using GtkMenuPopupAtRect = void (*)(GtkMenu* menu, GdkWindow* rect_window, + const GdkRectangle* rect, + GdkGravity rect_anchor, + GdkGravity menu_anchor, + const GdkEvent* trigger_event); + +static bool IsDisabled(const dom::Element& aElement) { + return aElement.AttrValueIs(kNameSpaceID_None, nsGkAtoms::disabled, + nsGkAtoms::_true, eCaseMatters) || + aElement.AttrValueIs(kNameSpaceID_None, nsGkAtoms::hidden, + nsGkAtoms::_true, eCaseMatters); +} +static bool NodeIsRelevant(const nsINode& aNode) { + return aNode.IsAnyOfXULElements(nsGkAtoms::menu, nsGkAtoms::menuseparator, + nsGkAtoms::menuitem, nsGkAtoms::menugroup); +} + +// If this is a radio / checkbox menuitem, get the current value. +static Maybe<bool> GetChecked(const dom::Element& aMenuItem) { + static dom::Element::AttrValuesArray strings[] = {nsGkAtoms::checkbox, + nsGkAtoms::radio, nullptr}; + switch (aMenuItem.FindAttrValueIn(kNameSpaceID_None, nsGkAtoms::type, strings, + eCaseMatters)) { + case 0: + break; + case 1: + break; + default: + return Nothing(); + } + + return Some(aMenuItem.AttrValueIs(kNameSpaceID_None, nsGkAtoms::checked, + nsGkAtoms::_true, eCaseMatters)); +} + +struct Actions { + RefPtr<GSimpleActionGroup> mGroup; + size_t mNextActionIndex = 0; + + nsPrintfCString Register(const dom::Element&, bool aForSubmenu); + void Clear(); +}; + +static MOZ_CAN_RUN_SCRIPT void ActivateItem(dom::Element& aElement) { + if (Maybe<bool> checked = GetChecked(aElement)) { + if (!aElement.AttrValueIs(kNameSpaceID_None, nsGkAtoms::autocheck, + nsGkAtoms::_false, eCaseMatters)) { + bool newValue = !*checked; + if (newValue) { + aElement.SetAttr(kNameSpaceID_None, nsGkAtoms::checked, u"true"_ns, + true); + } else { + aElement.UnsetAttr(kNameSpaceID_None, nsGkAtoms::checked, true); + } + } + } + + RefPtr doc = aElement.OwnerDoc(); + RefPtr event = new dom::XULCommandEvent(doc, doc->GetPresContext(), nullptr); + IgnoredErrorResult rv; + event->InitCommandEvent(u"command"_ns, true, true, + nsGlobalWindowInner::Cast(doc->GetInnerWindow()), 0, + /* ctrlKey = */ false, /* altKey = */ false, + /* shiftKey = */ false, /* cmdKey = */ false, + /* button = */ MouseButton::ePrimary, nullptr, 0, rv); + if (MOZ_UNLIKELY(rv.Failed())) { + return; + } + aElement.DispatchEvent(*event); +} + +static MOZ_CAN_RUN_SCRIPT void ActivateSignal(GSimpleAction* aAction, + GVariant* aParam, + gpointer aUserData) { + RefPtr element = static_cast<dom::Element*>(aUserData); + ActivateItem(*element); +} + +static MOZ_CAN_RUN_SCRIPT void FireEvent(dom::Element* aTarget, + EventMessage aPopupMessage) { + nsEventStatus status = nsEventStatus_eIgnore; + WidgetMouseEvent event(true, aPopupMessage, nullptr, WidgetMouseEvent::eReal); + EventDispatcher::Dispatch(aTarget, nullptr, &event, nullptr, &status); +} + +static MOZ_CAN_RUN_SCRIPT void ChangeStateSignal(GSimpleAction* aAction, + GVariant* aParam, + gpointer aUserData) { + // TODO: Fire events when safe. These run at a bad time for now. + static constexpr bool kEnabled = false; + if (!kEnabled) { + return; + } + const bool open = g_variant_get_boolean(aParam); + RefPtr popup = static_cast<dom::Element*>(aUserData); + if (open) { + FireEvent(popup, eXULPopupShowing); + FireEvent(popup, eXULPopupShown); + } else { + FireEvent(popup, eXULPopupHiding); + FireEvent(popup, eXULPopupHidden); + } +} + +nsPrintfCString Actions::Register(const dom::Element& aMenuItem, + bool aForSubmenu) { + nsPrintfCString actionName("item-%zu", mNextActionIndex++); + Maybe<bool> paramValue = aForSubmenu ? Some(false) : GetChecked(aMenuItem); + RefPtr<GSimpleAction> action; + if (paramValue) { + action = dont_AddRef(g_simple_action_new_stateful( + actionName.get(), nullptr, g_variant_new_boolean(*paramValue))); + } else { + action = dont_AddRef(g_simple_action_new(actionName.get(), nullptr)); + } + if (aForSubmenu) { + g_signal_connect(action, "change-state", G_CALLBACK(ChangeStateSignal), + gpointer(&aMenuItem)); + } else { + g_signal_connect(action, "activate", G_CALLBACK(ActivateSignal), + gpointer(&aMenuItem)); + } + g_action_map_add_action(G_ACTION_MAP(mGroup.get()), G_ACTION(action.get())); + return actionName; +} + +void Actions::Clear() { + for (size_t i = 0; i < mNextActionIndex; ++i) { + g_action_map_remove_action(G_ACTION_MAP(mGroup.get()), + nsPrintfCString("item-%zu", i).get()); + } + mNextActionIndex = 0; +} + +class MenuModel final : public nsStubMutationObserver { + NS_DECL_ISUPPORTS + + NS_DECL_NSIMUTATIONOBSERVER_CONTENTREMOVED + NS_DECL_NSIMUTATIONOBSERVER_CONTENTINSERTED + NS_DECL_NSIMUTATIONOBSERVER_CONTENTAPPENDED + NS_DECL_NSIMUTATIONOBSERVER_ATTRIBUTECHANGED + + public: + explicit MenuModel(dom::Element* aElement) : mElement(aElement) { + mElement->AddMutationObserver(this); + mGMenu = dont_AddRef(g_menu_new()); + mActions.mGroup = dont_AddRef(g_simple_action_group_new()); + } + + GMenuModel* GetModel() { return G_MENU_MODEL(mGMenu.get()); } + GActionGroup* GetActionGroup() { + return G_ACTION_GROUP(mActions.mGroup.get()); + } + + dom::Element* Element() { return mElement; } + + void RecomputeModelIfNeeded(); + + bool IsShowing() { return mPoppedUp; } + void WillShow() { + mPoppedUp = true; + RecomputeModelIfNeeded(); + } + void DidHide() { mPoppedUp = false; } + + private: + virtual ~MenuModel() { mElement->RemoveMutationObserver(this); } + + void DirtyModel() { + mDirty = true; + if (mPoppedUp) { + RecomputeModelIfNeeded(); + } + } + + RefPtr<dom::Element> mElement; + RefPtr<GMenu> mGMenu; + Actions mActions; + bool mDirty = true; + bool mPoppedUp = false; +}; + +NS_IMPL_ISUPPORTS(MenuModel, nsIMutationObserver) + +void MenuModel::ContentRemoved(nsIContent* aChild, nsIContent*) { + if (NodeIsRelevant(*aChild)) { + DirtyModel(); + } +} + +void MenuModel::ContentInserted(nsIContent* aChild) { + if (NodeIsRelevant(*aChild)) { + DirtyModel(); + } +} + +void MenuModel::ContentAppended(nsIContent* aChild) { + if (NodeIsRelevant(*aChild)) { + DirtyModel(); + } +} + +void MenuModel::AttributeChanged(dom::Element* aElement, int32_t aNameSpaceID, + nsAtom* aAttribute, int32_t aModType, + const nsAttrValue* aOldValue) { + if (NodeIsRelevant(*aElement) && + (aAttribute == nsGkAtoms::label || aAttribute == nsGkAtoms::aria_label || + aAttribute == nsGkAtoms::disabled || aAttribute == nsGkAtoms::hidden)) { + DirtyModel(); + } +} + +static const dom::Element* GetMenuPopupChild(const dom::Element& aElement) { + for (const nsIContent* child = aElement.GetFirstChild(); child; + child = child->GetNextSibling()) { + if (child->IsXULElement(nsGkAtoms::menupopup)) { + return child->AsElement(); + } + } + return nullptr; +} + +static void RecomputeModelFor(GMenu* aMenu, Actions& aActions, + const dom::Element& aElement) { + RefPtr<GMenu> sectionMenu; + auto FlushSectionMenu = [&] { + if (sectionMenu) { + g_menu_append_section(aMenu, nullptr, G_MENU_MODEL(sectionMenu.get())); + sectionMenu = nullptr; + } + }; + + for (const nsIContent* child = aElement.GetFirstChild(); child; + child = child->GetNextSibling()) { + if (child->IsXULElement(nsGkAtoms::menuitem) && + !IsDisabled(*child->AsElement())) { + nsAutoString label; + child->AsElement()->GetAttr(nsGkAtoms::label, label); + if (label.IsEmpty()) { + child->AsElement()->GetAttr(nsGkAtoms::aria_label, label); + } + nsPrintfCString actionName( + "menu.%s", + aActions.Register(*child->AsElement(), /* aForSubmenu = */ false) + .get()); + g_menu_append(sectionMenu ? sectionMenu.get() : aMenu, + NS_ConvertUTF16toUTF8(label).get(), actionName.get()); + continue; + } + if (child->IsXULElement(nsGkAtoms::menuseparator)) { + FlushSectionMenu(); + sectionMenu = dont_AddRef(g_menu_new()); + continue; + } + if (child->IsXULElement(nsGkAtoms::menugroup)) { + FlushSectionMenu(); + sectionMenu = dont_AddRef(g_menu_new()); + RecomputeModelFor(sectionMenu, aActions, *child->AsElement()); + FlushSectionMenu(); + continue; + } + if (child->IsXULElement(nsGkAtoms::menu) && + !IsDisabled(*child->AsElement())) { + if (const auto* popup = GetMenuPopupChild(*child->AsElement())) { + RefPtr<GMenu> submenu = dont_AddRef(g_menu_new()); + RecomputeModelFor(submenu, aActions, *popup); + nsAutoString label; + child->AsElement()->GetAttr(nsGkAtoms::label, label); + RefPtr<GMenuItem> submenuItem = dont_AddRef(g_menu_item_new_submenu( + NS_ConvertUTF16toUTF8(label).get(), G_MENU_MODEL(submenu.get()))); + nsPrintfCString actionName( + "menu.%s", + aActions.Register(*popup, /* aForSubmenu = */ true).get()); + g_menu_item_set_attribute_value(submenuItem.get(), "submenu-action", + g_variant_new_string(actionName.get())); + g_menu_append_item(sectionMenu ? sectionMenu.get() : aMenu, + submenuItem.get()); + } + } + } + + FlushSectionMenu(); +} + +void MenuModel::RecomputeModelIfNeeded() { + if (!mDirty) { + return; + } + mActions.Clear(); + g_menu_remove_all(mGMenu.get()); + RecomputeModelFor(mGMenu.get(), mActions, *mElement); +} + +static GtkMenuPopupAtRect GetPopupAtRectFn() { + static GtkMenuPopupAtRect sFunc = + (GtkMenuPopupAtRect)dlsym(RTLD_DEFAULT, "gtk_menu_popup_at_rect"); + return sFunc; +} + +bool NativeMenuGtk::CanUse() { + return StaticPrefs::widget_gtk_native_context_menus() && GetPopupAtRectFn(); +} + +void NativeMenuGtk::FireEvent(EventMessage aPopupMessage) { + RefPtr target = Element(); + widget::FireEvent(target, aPopupMessage); +} + +#define METHOD_SIGNAL(name_) \ + static MOZ_CAN_RUN_SCRIPT_BOUNDARY void On##name_##Signal( \ + GtkWidget* widget, gpointer user_data) { \ + RefPtr menu = static_cast<NativeMenuGtk*>(user_data); \ + return menu->On##name_(); \ + } + +METHOD_SIGNAL(Unmap); + +#undef METHOD_SIGNAL + +NativeMenuGtk::NativeMenuGtk(dom::Element* aElement) + : mMenuModel(MakeRefPtr<MenuModel>(aElement)) { + // Floating, so no need to dont_AddRef. + mNativeMenu = gtk_menu_new_from_model(mMenuModel->GetModel()); + gtk_widget_insert_action_group(mNativeMenu.get(), "menu", + mMenuModel->GetActionGroup()); + g_signal_connect(mNativeMenu, "unmap", G_CALLBACK(OnUnmapSignal), this); +} + +NativeMenuGtk::~NativeMenuGtk() { + g_signal_handlers_disconnect_by_data(mNativeMenu, this); +} + +RefPtr<dom::Element> NativeMenuGtk::Element() { return mMenuModel->Element(); } + +void NativeMenuGtk::ShowAsContextMenu(nsIFrame* aClickedFrame, + const CSSIntPoint& aPosition) { + if (mMenuModel->IsShowing()) { + return; + } + RefPtr<nsIWidget> widget = aClickedFrame->PresContext()->GetRootWidget(); + if (NS_WARN_IF(!widget)) { + // XXX Do we need to close menus here? + return; + } + auto* win = static_cast<GdkWindow*>(widget->GetNativeData(NS_NATIVE_WINDOW)); + if (NS_WARN_IF(!win)) { + return; + } + + auto* geckoWin = static_cast<nsWindow*>(widget.get()); + // The position needs to be relative to our window. + auto pos = (aPosition * aClickedFrame->PresContext()->CSSToDevPixelScale()) - + geckoWin->WidgetToScreenOffset(); + auto gdkPos = geckoWin->DevicePixelsToGdkPointRoundDown( + LayoutDeviceIntPoint::Round(pos)); + + mMenuModel->WillShow(); + const GdkRectangle rect = {gdkPos.x, gdkPos.y, 1, 1}; + auto openFn = GetPopupAtRectFn(); + openFn(GTK_MENU(mNativeMenu.get()), win, &rect, GDK_GRAVITY_NORTH_WEST, + GDK_GRAVITY_NORTH_WEST, GetLastMousePressEvent()); + + RefPtr pin{this}; + FireEvent(eXULPopupShown); +} + +bool NativeMenuGtk::Close() { + if (!mMenuModel->IsShowing()) { + return false; + } + gtk_menu_popdown(GTK_MENU(mNativeMenu.get())); + return true; +} + +void NativeMenuGtk::OnUnmap() { + FireEvent(eXULPopupHiding); + + mMenuModel->DidHide(); + + FireEvent(eXULPopupHidden); + + for (NativeMenu::Observer* observer : mObservers.Clone()) { + observer->OnNativeMenuClosed(); + } +} + +void NativeMenuGtk::ActivateItem(dom::Element* aItemElement, Modifiers, + int16_t aButton, ErrorResult&) { + // TODO: For testing only. +} + +void NativeMenuGtk::OpenSubmenu(dom::Element*) { + // TODO: For testing mostly. +} + +void NativeMenuGtk::CloseSubmenu(dom::Element*) { + // TODO: For testing mostly. +} + +} // namespace mozilla::widget |