/* -*- 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 #include 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 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 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 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(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(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 paramValue = aForSubmenu ? Some(false) : GetChecked(aMenuItem); RefPtr 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 mElement; RefPtr 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 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 submenu = dont_AddRef(g_menu_new()); RecomputeModelFor(submenu, aActions, *popup); nsAutoString label; child->AsElement()->GetAttr(nsGkAtoms::label, label); RefPtr 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(user_data); \ return menu->On##name_(); \ } METHOD_SIGNAL(Unmap); #undef METHOD_SIGNAL NativeMenuGtk::NativeMenuGtk(dom::Element* aElement) : mMenuModel(MakeRefPtr(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 NativeMenuGtk::Element() { return mMenuModel->Element(); } void NativeMenuGtk::ShowAsContextMenu(nsIFrame* aClickedFrame, const CSSIntPoint& aPosition) { if (mMenuModel->IsShowing()) { return; } RefPtr widget = aClickedFrame->PresContext()->GetRootWidget(); if (NS_WARN_IF(!widget)) { // XXX Do we need to close menus here? return; } auto* win = static_cast(widget->GetNativeData(NS_NATIVE_WINDOW)); if (NS_WARN_IF(!win)) { return; } auto* geckoWin = static_cast(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