WebPopupMenuProxyGtk.cpp   [plain text]


/*
 * Copyright (C) 2011 Igalia S.L.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 * 1. Redistributions of source code must retain the above copyright
 *    notice, this list of conditions and the following disclaimer.
 * 2. Redistributions in binary form must reproduce the above copyright
 *    notice, this list of conditions and the following disclaimer in the
 *    documentation and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
 * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
 * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
 * THE POSSIBILITY OF SUCH DAMAGE.
 */

#include "config.h"
#include "WebPopupMenuProxyGtk.h"

#include "NativeWebMouseEvent.h"
#include "WebPopupItem.h"
#include <WebCore/GtkUtilities.h>
#include <WebCore/GtkVersioning.h>
#include <WebCore/IntRect.h>
#include <gtk/gtk.h>
#include <wtf/glib/GUniquePtr.h>
#include <wtf/text/CString.h>

namespace WebKit {
using namespace WebCore;

enum Columns {
    Label,
    Tooltip,
    IsGroup,
    IsSelected,
    IsEnabled,
    Index,

    Count
};

WebPopupMenuProxyGtk::WebPopupMenuProxyGtk(GtkWidget* webView, WebPopupMenuProxy::Client& client)
    : WebPopupMenuProxy(client)
    , m_webView(webView)
{
}

WebPopupMenuProxyGtk::~WebPopupMenuProxyGtk()
{
    cancelTracking();
}

void WebPopupMenuProxyGtk::selectItem(unsigned itemIndex)
{
    if (m_client)
        m_client->setTextFromItemForPopupMenu(this, itemIndex);
    m_selectedItem = itemIndex;
}

void WebPopupMenuProxyGtk::activateItem(Optional<unsigned> itemIndex)
{
    if (m_client)
        m_client->valueChangedForPopupMenu(this, itemIndex.valueOr(m_selectedItem.valueOr(-1)));
}

bool WebPopupMenuProxyGtk::activateItemAtPath(GtkTreePath* path)
{
    auto* model = gtk_tree_view_get_model(GTK_TREE_VIEW(m_treeView));
    GtkTreeIter iter;
    gtk_tree_model_get_iter(model, &iter, path);
    gboolean isGroup, isEnabled;
    guint index;
    gtk_tree_model_get(model, &iter, Columns::IsGroup, &isGroup, Columns::IsEnabled, &isEnabled, Columns::Index, &index, -1);
    if (isGroup || !isEnabled)
        return false;

    activateItem(index);
    hidePopupMenu();
    return true;
}

bool WebPopupMenuProxyGtk::handleKeyPress(unsigned keyval, uint32_t timestamp)
{
    if (!m_popup)
        return false;

    if (keyval == GDK_KEY_Escape) {
        hidePopupMenu();
        return true;
    }

    return typeAheadFind(keyval, timestamp);
}

void WebPopupMenuProxyGtk::activateSelectedItem()
{
    if (!m_popup)
        return;

    GtkTreeModel* model;
    GtkTreeIter iter;
    if (!gtk_tree_selection_get_selected(gtk_tree_view_get_selection(GTK_TREE_VIEW(m_treeView)), &model, &iter))
        return;

    GUniquePtr<GtkTreePath> path(gtk_tree_model_get_path(model, &iter));
    activateItemAtPath(path.get());
}

#if !USE(GTK4)
gboolean WebPopupMenuProxyGtk::treeViewButtonReleaseEventCallback(GtkWidget* treeView, GdkEvent* event, WebPopupMenuProxyGtk* popupMenu)
{
    guint button;
    gdk_event_get_button(event, &button);
    if (button != GDK_BUTTON_PRIMARY)
        return FALSE;

    double x, y;
    gdk_event_get_coords(event, &x, &y);
    GUniqueOutPtr<GtkTreePath> path;
    if (!gtk_tree_view_get_path_at_pos(GTK_TREE_VIEW(treeView), x, y, &path.outPtr(), nullptr, nullptr, nullptr))
        return FALSE;

    return popupMenu->activateItemAtPath(path.get());
}

gboolean WebPopupMenuProxyGtk::buttonPressEventCallback(GtkWidget* widget, GdkEventButton* event, WebPopupMenuProxyGtk* popupMenu)
{
    if (!popupMenu->m_device)
        return FALSE;

    popupMenu->hidePopupMenu();
    return TRUE;
}

gboolean WebPopupMenuProxyGtk::keyPressEventCallback(GtkWidget* widget, GdkEvent* event, WebPopupMenuProxyGtk* popupMenu)
{
    if (!popupMenu->m_device)
        return FALSE;

    guint keyval;
    gdk_event_get_keyval(event, &keyval);
    if (popupMenu->handleKeyPress(keyval, gdk_event_get_time(event)))
        return TRUE;

    // Forward the event to the tree view.
    gtk_widget_event(popupMenu->m_treeView, event);
    return TRUE;
}
#endif

void WebPopupMenuProxyGtk::createPopupMenu(const Vector<WebPopupItem>& items, int32_t selectedIndex)
{
    ASSERT(!m_popup);

    GRefPtr<GtkTreeStore> model = adoptGRef(gtk_tree_store_new(Columns::Count, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_BOOLEAN, G_TYPE_BOOLEAN, G_TYPE_BOOLEAN, G_TYPE_UINT));
    GtkTreeIter parentIter;
    unsigned index = 0;
    m_paths.reserveInitialCapacity(items.size());
    for (const auto& item : items) {
        if (item.m_isLabel) {
            gtk_tree_store_insert_with_values(model.get(), &parentIter, nullptr, -1,
                Columns::Label, item.m_text.stripWhiteSpace().utf8().data(),
                Columns::IsGroup, TRUE,
                Columns::IsEnabled, TRUE,
                -1);
            // We never need the path for group labels.
            m_paths.uncheckedAppend(nullptr);
        } else {
            GtkTreeIter iter;
            bool isSelected = selectedIndex && static_cast<unsigned>(selectedIndex) == index;
            gtk_tree_store_insert_with_values(model.get(), &iter, item.m_text.startsWith("    ") ? &parentIter : nullptr, -1,
                Columns::Label, item.m_text.stripWhiteSpace().utf8().data(),
                Columns::Tooltip, item.m_toolTip.isEmpty() ? nullptr : item.m_toolTip.utf8().data(),
                Columns::IsGroup, FALSE,
                Columns::IsSelected, isSelected,
                Columns::IsEnabled, item.m_isEnabled,
                Columns::Index, index,
                -1);
            if (isSelected) {
                ASSERT(!m_selectedItem);
                m_selectedItem = index;
            }
            m_paths.uncheckedAppend(GUniquePtr<GtkTreePath>(gtk_tree_model_get_path(GTK_TREE_MODEL(model.get()), &iter)));
        }
        index++;
    }

    m_treeView = gtk_tree_view_new_with_model(GTK_TREE_MODEL(model.get()));
    auto* treeView = GTK_TREE_VIEW(m_treeView);
    g_signal_connect_swapped(treeView, "row-activated", G_CALLBACK(+[](WebPopupMenuProxyGtk* popupMenu, GtkTreePath* path) {
        popupMenu->activateItemAtPath(path);
    }), this);

#if !USE(GTK4)
    g_signal_connect_after(treeView, "button-release-event", G_CALLBACK(treeViewButtonReleaseEventCallback), this);
#endif
    gtk_tree_view_set_tooltip_column(treeView, Columns::Tooltip);
    gtk_tree_view_set_show_expanders(treeView, FALSE);
    gtk_tree_view_set_level_indentation(treeView, 12);
    gtk_tree_view_set_enable_search(treeView, FALSE);
    gtk_tree_view_set_activate_on_single_click(treeView, TRUE);
    gtk_tree_view_set_hover_selection(treeView, TRUE);
    gtk_tree_view_set_headers_visible(treeView, FALSE);
    gtk_tree_view_insert_column_with_data_func(treeView, 0, nullptr, gtk_cell_renderer_text_new(), [](GtkTreeViewColumn*, GtkCellRenderer* renderer, GtkTreeModel* model, GtkTreeIter* iter, gpointer) {
        GUniqueOutPtr<char> label;
        gboolean isGroup, isEnabled;
        gtk_tree_model_get(model, iter, Columns::Label, &label.outPtr(), Columns::IsGroup, &isGroup, Columns::IsEnabled, &isEnabled, -1);
        if (isGroup) {
            GUniquePtr<char> markup(g_strdup_printf("<b>%s</b>", label.get()));
            g_object_set(renderer, "markup", markup.get(), nullptr);
        } else
            g_object_set(renderer, "text", label.get(), "sensitive", isEnabled, nullptr);
    }, nullptr, nullptr);
    gtk_tree_view_expand_all(treeView);

    auto* selection = gtk_tree_view_get_selection(treeView);
    gtk_tree_selection_set_mode(selection, GTK_SELECTION_SINGLE);
    gtk_tree_selection_unselect_all(selection);
    gtk_tree_selection_set_select_function(selection, [](GtkTreeSelection*, GtkTreeModel* model, GtkTreePath* path, gboolean selected, gpointer) -> gboolean {
        GtkTreeIter iter;
        gtk_tree_model_get_iter(model, &iter, path);
        gboolean isGroup, isEnabled;
        gtk_tree_model_get(model, &iter, Columns::IsGroup, &isGroup, Columns::IsEnabled, &isEnabled, -1);
        return !isGroup && isEnabled;
    }, nullptr, nullptr);

    auto* swindow = gtk_scrolled_window_new();
    gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(swindow), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
#if USE(GTK4)
    gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(swindow), m_treeView);
#else
    gtk_scrolled_window_set_shadow_type(GTK_SCROLLED_WINDOW(swindow), GTK_SHADOW_ETCHED_IN);
    gtk_container_add(GTK_CONTAINER(swindow), m_treeView);
    gtk_widget_show(m_treeView);
#endif

#if USE(GTK4)
    m_popup = gtk_popover_new();
    g_signal_connect_swapped(m_popup, "closed", G_CALLBACK(+[](WebPopupMenuProxyGtk* popupMenu) {
        popupMenu->hidePopupMenu();
    }), this);
    gtk_popover_set_has_arrow(GTK_POPOVER(m_popup), FALSE);
    gtk_popover_set_position(GTK_POPOVER(m_popup), GTK_POS_BOTTOM);
    gtk_popover_set_child(GTK_POPOVER(m_popup), swindow);
    gtk_widget_set_parent(m_popup, m_webView);

    auto* controller = gtk_event_controller_key_new();
    g_signal_connect_swapped(controller, "key-pressed", G_CALLBACK(+[](WebPopupMenuProxyGtk* popupMenu, unsigned keyval, unsigned, GdkModifierType, GtkEventController* controller) {
        auto* event = gtk_event_controller_get_current_event(controller);
        if (popupMenu->typeAheadFind(keyval, gdk_event_get_time(event)))
            return GDK_EVENT_STOP;
        return gtk_event_controller_key_forward(GTK_EVENT_CONTROLLER_KEY(controller), popupMenu->m_treeView);
    }), this);
    gtk_widget_add_controller(m_popup, controller);
#else
    m_popup = gtk_window_new(GTK_WINDOW_POPUP);
    g_signal_connect(m_popup, "button-press-event", G_CALLBACK(buttonPressEventCallback), this);
    g_signal_connect(m_popup, "key-press-event", G_CALLBACK(keyPressEventCallback), this);
    gtk_window_set_type_hint(GTK_WINDOW(m_popup), GDK_WINDOW_TYPE_HINT_COMBO);
    gtk_window_set_resizable(GTK_WINDOW(m_popup), FALSE);
    gtk_container_add(GTK_CONTAINER(m_popup), swindow);
    gtk_widget_show(swindow);
#endif
}

void WebPopupMenuProxyGtk::show()
{
    if (m_selectedItem) {
        auto& selectedPath = m_paths[m_selectedItem.value()];
        ASSERT(selectedPath);
        gtk_tree_view_scroll_to_cell(GTK_TREE_VIEW(m_treeView), selectedPath.get(), nullptr, TRUE, 0.5, 0);
        gtk_tree_view_set_cursor(GTK_TREE_VIEW(m_treeView), selectedPath.get(), nullptr, FALSE);
    }
    gtk_widget_grab_focus(m_treeView);
    gtk_widget_show(m_popup);
}

void WebPopupMenuProxyGtk::showPopupMenu(const IntRect& rect, TextDirection, double /* pageScaleFactor */, const Vector<WebPopupItem>& items, const PlatformPopupMenuData&, int32_t selectedIndex)
{
    createPopupMenu(items, selectedIndex);
    ASSERT(m_popup);

    GtkRequisition treeViewRequisition;
    gtk_widget_get_preferred_size(m_treeView, &treeViewRequisition, nullptr);
    auto* column = gtk_tree_view_get_column(GTK_TREE_VIEW(m_treeView), Columns::Label);
    gint itemHeight;
    gtk_tree_view_column_cell_get_size(column, nullptr, nullptr, nullptr, nullptr, &itemHeight);
#if !USE(GTK4)
    gint verticalSeparator;
    gtk_widget_style_get(m_treeView, "vertical-separator", &verticalSeparator, nullptr);
    itemHeight += verticalSeparator;
#endif
    if (!itemHeight)
        return;

#if !USE(GTK4)
    auto* toplevel = gtk_widget_get_toplevel(m_webView);
    if (GTK_IS_WINDOW(toplevel)) {
        gtk_window_set_transient_for(GTK_WINDOW(m_popup), GTK_WINDOW(toplevel));
        gtk_window_group_add_window(gtk_window_get_group(GTK_WINDOW(toplevel)), GTK_WINDOW(m_popup));
    }
    gtk_window_set_attached_to(GTK_WINDOW(m_popup), m_webView);
    gtk_window_set_screen(GTK_WINDOW(m_popup), gtk_widget_get_screen(m_webView));
#endif

    auto* display = gtk_widget_get_display(m_webView);
#if USE(GTK4)
    auto* monitor = gdk_display_get_monitor_at_surface(display, gtk_native_get_surface(gtk_widget_get_native(m_webView)));
#else
    auto* monitor = gdk_display_get_monitor_at_window(display, gtk_widget_get_window(m_webView));
#endif
    GdkRectangle area;
    gdk_monitor_get_workarea(monitor, &area);
    int width = std::min(rect.width(), area.width);
    size_t itemCount = std::min<size_t>(items.size(), (area.height / 3) / itemHeight);

#if USE(GTK4)
    auto* swindow = GTK_SCROLLED_WINDOW(gtk_popover_get_child(GTK_POPOVER(m_popup)));
#else
    auto* swindow = GTK_SCROLLED_WINDOW(gtk_bin_get_child(GTK_BIN(m_popup)));
#endif
    // Disable scrollbars when there's only one item to ensure the scrolled window doesn't take them into account when calculating its minimum size.
    gtk_scrolled_window_set_policy(swindow, GTK_POLICY_NEVER, itemCount > 1 ? GTK_POLICY_AUTOMATIC : GTK_POLICY_NEVER);
    gtk_widget_realize(m_treeView);
    gtk_tree_view_columns_autosize(GTK_TREE_VIEW(m_treeView));
    gtk_scrolled_window_set_min_content_width(swindow, width);
    gtk_widget_set_size_request(m_popup, width, -1);
    gtk_scrolled_window_set_min_content_height(swindow, itemCount * itemHeight);

#if USE(GTK4)
    GdkRectangle windowRect = { rect.x(), rect.y(), rect.width(), rect.height() };
    gtk_popover_set_pointing_to(GTK_POPOVER(m_popup), &windowRect);
    show();
#else
#if GTK_CHECK_VERSION(3, 24, 0)
    GdkRectangle windowRect = { rect.x(), rect.y(), rect.width(), rect.height() };
    gtk_widget_translate_coordinates(m_webView, toplevel, windowRect.x, windowRect.y, &windowRect.x, &windowRect.y);
    gdk_window_move_to_rect(gtk_widget_get_window(m_popup), &windowRect, GDK_GRAVITY_SOUTH_WEST, GDK_GRAVITY_NORTH_WEST,
        static_cast<GdkAnchorHints>(GDK_ANCHOR_FLIP | GDK_ANCHOR_SLIDE | GDK_ANCHOR_RESIZE), 0, 0);
#else
    GtkRequisition menuRequisition;
    gtk_widget_get_preferred_size(m_popup, &menuRequisition, nullptr);

    IntPoint menuPosition = convertWidgetPointToScreenPoint(m_webView, rect.location());
    if (menuPosition.x() + menuRequisition.width > area.x + area.width)
        menuPosition.setX(area.x + area.width - menuRequisition.width);

    if (menuPosition.y() + rect.height() + menuRequisition.height <= area.y + area.height
        || menuPosition.y() - area.y < (area.y + area.height) - (menuPosition.y() + rect.height()))
        menuPosition.move(0, rect.height());
    else
        menuPosition.move(0, -menuRequisition.height);
    gtk_window_move(GTK_WINDOW(m_popup), menuPosition.x(), menuPosition.y());
#endif

    const GdkEvent* event = m_client->currentlyProcessedMouseDownEvent() ? m_client->currentlyProcessedMouseDownEvent()->nativeEvent() : nullptr;
    m_device = event ? gdk_event_get_device(event) : nullptr;
    if (!m_device)
        m_device = gtk_get_current_event_device();
    if (m_device && gdk_device_get_display(m_device) != display)
        m_device = nullptr;
    if (!m_device)
        m_device = gdk_seat_get_pointer(gdk_display_get_default_seat(display));
    ASSERT(m_device);
    if (gdk_device_get_source(m_device) == GDK_SOURCE_KEYBOARD)
        m_device = gdk_device_get_associated_device(m_device);

    gtk_grab_add(m_popup);
    auto grabResult = gdk_seat_grab(gdk_device_get_seat(m_device), gtk_widget_get_window(m_popup), GDK_SEAT_CAPABILITY_ALL, TRUE, nullptr, nullptr, [](GdkSeat*, GdkWindow*, gpointer userData) {
        static_cast<WebPopupMenuProxyGtk*>(userData)->show();
    }, this);

    // PopupMenu can fail to open when there is no mouse grab.
    // Ensure WebCore does not go into some pesky state.
    if (grabResult != GDK_GRAB_SUCCESS) {
       m_client->failedToShowPopupMenu();
       return;
    }
#endif
}

void WebPopupMenuProxyGtk::hidePopupMenu()
{
    if (!m_popup)
        return;

#if !USE(GTK4)
    if (m_device) {
        gdk_seat_ungrab(gdk_device_get_seat(m_device));
        gtk_grab_remove(m_popup);
        gtk_window_set_transient_for(GTK_WINDOW(m_popup), nullptr);
        gtk_window_set_attached_to(GTK_WINDOW(m_popup), nullptr);
        m_device = nullptr;
    }
#endif

    activateItem(WTF::nullopt);

    if (m_currentSearchString) {
        g_string_free(m_currentSearchString, TRUE);
        m_currentSearchString = nullptr;
    }

#if USE(GTK4)
    g_clear_pointer(&m_popup, gtk_widget_unparent);
#else
    gtk_widget_destroy(m_popup);
    m_popup = nullptr;
#endif
}

void WebPopupMenuProxyGtk::cancelTracking()
{
    if (!m_popup)
        return;

    g_signal_handlers_disconnect_matched(m_popup, G_SIGNAL_MATCH_DATA, 0, 0, nullptr, nullptr, this);
    hidePopupMenu();
}

Optional<unsigned> WebPopupMenuProxyGtk::typeAheadFindIndex(unsigned keyval, uint32_t time)
{
    gunichar keychar = gdk_keyval_to_unicode(keyval);
    if (!g_unichar_isprint(keychar))
        return WTF::nullopt;

    if (time < m_previousKeyEventTime)
        return WTF::nullopt;

    static const uint32_t typeaheadTimeoutMs = 1000;
    if (time - m_previousKeyEventTime > typeaheadTimeoutMs) {
        if (m_currentSearchString)
            g_string_truncate(m_currentSearchString, 0);
    }
    m_previousKeyEventTime = time;

    if (!m_currentSearchString)
        m_currentSearchString = g_string_new(nullptr);
    g_string_append_unichar(m_currentSearchString, keychar);

    int prefixLength = -1;
    if (keychar == m_repeatingCharacter)
        prefixLength = 1;
    else
        m_repeatingCharacter = m_currentSearchString->len == 1 ? keychar : '\0';

    GtkTreeModel* model;
    GtkTreeIter iter;
    guint selectedIndex = 0;
    if (gtk_tree_selection_get_selected(gtk_tree_view_get_selection(GTK_TREE_VIEW(m_treeView)), &model, &iter))
        gtk_tree_model_get(model, &iter, Columns::Index, &selectedIndex, -1);

    unsigned index = selectedIndex;
    if (m_repeatingCharacter != '\0')
        index++;
    auto itemCount = m_paths.size();
    index %= itemCount;

    GUniquePtr<char> normalizedPrefix(g_utf8_normalize(m_currentSearchString->str, prefixLength, G_NORMALIZE_ALL));
    GUniquePtr<char> prefix(normalizedPrefix ? g_utf8_casefold(normalizedPrefix.get(), -1) : nullptr);
    if (!prefix)
        return WTF::nullopt;

    model = gtk_tree_view_get_model(GTK_TREE_VIEW(m_treeView));
    for (unsigned i = 0; i < itemCount; i++, index = (index + 1) % itemCount) {
        auto& path = m_paths[index];
        if (!path || !gtk_tree_model_get_iter(model, &iter, path.get()))
            continue;

        GUniqueOutPtr<char> label;
        gboolean isEnabled;
        gtk_tree_model_get(model, &iter, Columns::Label, &label.outPtr(), Columns::IsEnabled, &isEnabled, -1);
        if (!isEnabled)
            continue;

        GUniquePtr<char> normalizedText(g_utf8_normalize(label.get(), -1, G_NORMALIZE_ALL));
        GUniquePtr<char> text(normalizedText ? g_utf8_casefold(normalizedText.get(), -1) : nullptr);
        if (!text)
            continue;

        if (!strncmp(prefix.get(), text.get(), strlen(prefix.get())))
            return index;
    }

    return WTF::nullopt;
}

bool WebPopupMenuProxyGtk::typeAheadFind(unsigned keyval, uint32_t timestamp)
{
    auto searchIndex = typeAheadFindIndex(keyval, timestamp);
    if (!searchIndex)
        return false;

    auto& path = m_paths[searchIndex.value()];
    ASSERT(path);
    gtk_tree_view_scroll_to_cell(GTK_TREE_VIEW(m_treeView), path.get(), nullptr, TRUE, 0.5, 0);
    gtk_tree_view_set_cursor(GTK_TREE_VIEW(m_treeView), path.get(), nullptr, FALSE);
    selectItem(searchIndex.value());

    return true;
}

} // namespace WebKit