From 19168446c6304876b2bf6fac53a670cf42c3ec0f Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Tue, 16 Jun 2026 01:35:03 +0200 Subject: [PATCH] UI/Qt: Add navigation history menus Show native history menus when users right-click or long-press the back and forward toolbar buttons. Populate entries from the UI-process session history mirror, using saved history titles and favicons when available and falling back to the URL and globe icon otherwise. Share the Qt base64 PNG icon helper so bookmark menus and navigation history menus render stored favicons consistently. --- Libraries/LibWebView/ViewImplementation.cpp | 48 +++++++++++++++++++++ Libraries/LibWebView/ViewImplementation.h | 7 +++ UI/Qt/Icon.cpp | 21 +++++++++ UI/Qt/Icon.h | 1 + UI/Qt/Menu.cpp | 25 +---------- UI/Qt/Tab.cpp | 41 +++++++++++++++++- 6 files changed, 118 insertions(+), 25 deletions(-) diff --git a/Libraries/LibWebView/ViewImplementation.cpp b/Libraries/LibWebView/ViewImplementation.cpp index 7c9d16f21f..17c5c7ac55 100644 --- a/Libraries/LibWebView/ViewImplementation.cpp +++ b/Libraries/LibWebView/ViewImplementation.cpp @@ -489,6 +489,54 @@ ViewImplementation::HistoryTraversalOutcome ViewImplementation::traverse_the_his }; } +Vector ViewImplementation::session_history_traversal_menu_items(int direction) const +{ + VERIFY(direction == -1 || direction == 1); + + auto current_used_step_index = m_session_history.current_used_step_index(); + if (!current_used_step_index.has_value()) + return {}; + + Vector items; + auto append_item = [&](size_t target_step_index, TraversableSessionHistory::Entry const& target_entry) { + auto history_entry = Application::history_store().entry_for_url(target_entry.url); + auto url = target_entry.url.serialize(); + auto title = history_entry.has_value() && history_entry->title.has_value() && !history_entry->title->is_empty() + ? move(*history_entry->title) + : url; + items.append({ + static_cast(target_step_index) - static_cast(*current_used_step_index), + move(title), + move(url), + history_entry.has_value() ? move(history_entry->favicon_base64_png) : Optional {}, + }); + }; + + if (direction < 0) { + for (size_t target_step_index = *current_used_step_index; target_step_index > 0; --target_step_index) { + auto target_step = m_session_history.step_at(target_step_index - 1); + if (!target_step.has_value()) + continue; + auto const* target_entry = m_session_history.top_level_entry_for_step(*target_step); + if (!target_entry) + continue; + append_item(target_step_index - 1, *target_entry); + } + } else { + for (size_t target_step_index = *current_used_step_index + 1; target_step_index < m_session_history.used_step_count(); ++target_step_index) { + auto target_step = m_session_history.step_at(target_step_index); + if (!target_step.has_value()) + continue; + auto const* target_entry = m_session_history.top_level_entry_for_step(*target_step); + if (!target_entry) + continue; + append_item(target_step_index, *target_entry); + } + } + + return items; +} + void ViewImplementation::zoom_in() { if (m_zoom_level >= ZOOM_MAX_LEVEL) diff --git a/Libraries/LibWebView/ViewImplementation.h b/Libraries/LibWebView/ViewImplementation.h index 788bd498e8..1130dceb29 100644 --- a/Libraries/LibWebView/ViewImplementation.h +++ b/Libraries/LibWebView/ViewImplementation.h @@ -109,10 +109,17 @@ public: bool will_change_top_level_entry { false }; bool waiting_for_cancelation_check { false }; }; + struct SessionHistoryTraversalMenuItem { + int delta { 0 }; + String title; + String url; + Optional favicon_base64_png; + }; [[nodiscard]] HistoryTraversalOutcome traverse_the_history_by_delta( int delta, CheckForCancelation = CheckForCancelation::Yes, Function = nullptr); + [[nodiscard]] Vector session_history_traversal_menu_items(int direction) const; void zoom_in(); void zoom_out(); diff --git a/UI/Qt/Icon.cpp b/UI/Qt/Icon.cpp index a1ee1b1d31..40336a2165 100644 --- a/UI/Qt/Icon.cpp +++ b/UI/Qt/Icon.cpp @@ -4,6 +4,7 @@ * SPDX-License-Identifier: BSD-2-Clause */ +#include #include #include #include @@ -28,6 +29,26 @@ QIcon load_icon_from_uri(StringView uri) return QIcon { path }; } +QIcon icon_from_base64_png(StringView favicon_base64_png, int logical_size) +{ + auto decoded = decode_base64(favicon_base64_png); + if (decoded.is_error()) + return {}; + + QPixmap pixmap; + if (!pixmap.loadFromData(decoded.value().data(), static_cast(decoded.value().size()), "PNG")) + return {}; + + QIcon icon; + for (auto device_pixel_ratio : ICON_DEVICE_PIXEL_RATIOS) { + auto size = logical_size * device_pixel_ratio; + auto scaled_pixmap = pixmap.scaled(size, size, Qt::KeepAspectRatio, Qt::SmoothTransformation); + scaled_pixmap.setDevicePixelRatio(device_pixel_ratio); + icon.addPixmap(scaled_pixmap); + } + return icon; +} + static QPen chrome_icon_pen(QColor const& color, qreal width) { return QPen(color, width, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin); diff --git a/UI/Qt/Icon.h b/UI/Qt/Icon.h index 1d13b936ef..2ce945e27e 100644 --- a/UI/Qt/Icon.h +++ b/UI/Qt/Icon.h @@ -45,6 +45,7 @@ enum class ChromeIcon { constexpr inline auto ICON_DEVICE_PIXEL_RATIOS = to_array({ 1, 2, 3 }); QIcon load_icon_from_uri(StringView); +QIcon icon_from_base64_png(StringView, int logical_size); QIcon create_chrome_icon(ChromeIcon, QPalette const&); QIcon loading_spinner_icon(QPalette const& palette, int frame); diff --git a/UI/Qt/Menu.cpp b/UI/Qt/Menu.cpp index fdbf477e8f..e4370820bd 100644 --- a/UI/Qt/Menu.cpp +++ b/UI/Qt/Menu.cpp @@ -4,7 +4,6 @@ * SPDX-License-Identifier: BSD-2-Clause */ -#include #include #include #include @@ -141,30 +140,10 @@ static void add_properties(QObject& object, T& menu_or_action) object.setProperty(key.to_byte_string().characters(), qstring_from_ak_string(value)); } -static QIcon icon_from_base64_png(StringView favicon_base64_png) +static void initialize_native_control(WebView::Action& action, QAction& qaction, QPalette const& palette, IncludeActionIcon include_action_icon) { static constexpr int const MENU_ICON_SIZE = 16; - auto decoded = decode_base64(favicon_base64_png); - if (decoded.is_error()) - return {}; - - QPixmap pixmap; - if (!pixmap.loadFromData(decoded.value().data(), static_cast(decoded.value().size()), "PNG")) - return {}; - - QIcon icon; - for (auto device_pixel_ratio : ICON_DEVICE_PIXEL_RATIOS) { - auto size = MENU_ICON_SIZE * device_pixel_ratio; - auto scaled_pixmap = pixmap.scaled(size, size, Qt::KeepAspectRatio, Qt::SmoothTransformation); - scaled_pixmap.setDevicePixelRatio(device_pixel_ratio); - icon.addPixmap(scaled_pixmap); - } - return icon; -} - -static void initialize_native_control(WebView::Action& action, QAction& qaction, QPalette const& palette, IncludeActionIcon include_action_icon) -{ switch (action.id()) { case WebView::ActionID::NavigateBack: if (include_action_icon == IncludeActionIcon::Yes) @@ -209,7 +188,7 @@ static void initialize_native_control(WebView::Action& action, QAction& qaction, break; case WebView::ActionID::BookmarkItem: if (auto icon = action.base64_png_icon(); icon.has_value()) - qaction.setIcon(icon_from_base64_png(*icon)); + qaction.setIcon(icon_from_base64_png(*icon, MENU_ICON_SIZE)); else qaction.setIcon(create_chrome_icon(ChromeIcon::Globe, palette)); break; diff --git a/UI/Qt/Tab.cpp b/UI/Qt/Tab.cpp index facbedad3d..cab4b91c06 100644 --- a/UI/Qt/Tab.cpp +++ b/UI/Qt/Tab.cpp @@ -88,6 +88,43 @@ static QToolButton* create_toolbar_button(QWidget& parent, QAction& action) return button; } +static void populate_navigation_history_menu(QMenu& menu, WebContentView& view, int direction) +{ + static constexpr int const MENU_ICON_SIZE = 16; + + menu.clear(); + + for (auto const& item : view.session_history_traversal_menu_items(direction)) { + auto* action = menu.addAction(qstring_from_ak_string(item.title)); + action->setToolTip(qstring_from_ak_string(item.url)); + if (item.favicon_base64_png.has_value()) + action->setIcon(icon_from_base64_png(*item.favicon_base64_png, MENU_ICON_SIZE)); + else + action->setIcon(create_chrome_icon(ChromeIcon::Globe, menu.palette())); + QObject::connect(action, &QAction::triggered, &view, [&view, delta = item.delta] { + (void)view.traverse_the_history_by_delta(delta); + }); + } +} + +static QToolButton* create_navigation_history_toolbar_button(QWidget& parent, QAction& action, WebContentView& view, int direction) +{ + auto* button = create_toolbar_button(parent, action); + auto* menu = new QMenu(button); + QObject::connect(menu, &QMenu::aboutToShow, button, [menu, &view, direction] { + populate_navigation_history_menu(*menu, view, direction); + }); + button->setMenu(menu); + button->setPopupMode(QToolButton::DelayedPopup); + button->setContextMenuPolicy(Qt::CustomContextMenu); + QObject::connect(button, &QToolButton::customContextMenuRequested, button, [button, menu, &view, direction](QPoint const&) { + populate_navigation_history_menu(*menu, view, direction); + if (!menu->isEmpty()) + button->showMenu(); + }); + return button; +} + static constexpr int TOOLBAR_HORIZONTAL_MARGIN = 12; static constexpr int TOOLBAR_VERTICAL_MARGIN = 2; static constexpr int TOOLBAR_MACOS_TRAFFIC_LIGHTS_CONTROL_GAP = 22; @@ -213,8 +250,8 @@ Tab::Tab(BrowserWindow* window, RefPtr parent_client, navigation_button_layout->addWidget(create_toolbar_button(*navigation_button_cluster, *m_toggle_vertical_tabs_expanded_action)); m_sidebar_toggle_navigation_spacer = new QSpacerItem(0, 0, QSizePolicy::Fixed, QSizePolicy::Minimum); navigation_button_layout->addItem(m_sidebar_toggle_navigation_spacer); - navigation_button_layout->addWidget(create_toolbar_button(*navigation_button_cluster, *m_navigate_back_action)); - navigation_button_layout->addWidget(create_toolbar_button(*navigation_button_cluster, *m_navigate_forward_action)); + navigation_button_layout->addWidget(create_navigation_history_toolbar_button(*navigation_button_cluster, *m_navigate_back_action, view(), -1)); + navigation_button_layout->addWidget(create_navigation_history_toolbar_button(*navigation_button_cluster, *m_navigate_forward_action, view(), 1)); navigation_button_layout->addWidget(create_toolbar_button(*navigation_button_cluster, *m_reload_action)); if (use_left_traffic_light_window_controls()) {