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.
This commit is contained in:
Andreas Kling 2026-06-16 01:35:03 +02:00 committed by Andreas Kling
parent cda7e381de
commit 19168446c6
6 changed files with 118 additions and 25 deletions

View file

@ -489,6 +489,54 @@ ViewImplementation::HistoryTraversalOutcome ViewImplementation::traverse_the_his
};
}
Vector<ViewImplementation::SessionHistoryTraversalMenuItem> 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<SessionHistoryTraversalMenuItem> 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<int>(target_step_index) - static_cast<int>(*current_used_step_index),
move(title),
move(url),
history_entry.has_value() ? move(history_entry->favicon_base64_png) : Optional<String> {},
});
};
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)

View file

@ -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<String> favicon_base64_png;
};
[[nodiscard]] HistoryTraversalOutcome traverse_the_history_by_delta(
int delta,
CheckForCancelation = CheckForCancelation::Yes,
Function<void(HistoryTraversalOutcome)> = nullptr);
[[nodiscard]] Vector<SessionHistoryTraversalMenuItem> session_history_traversal_menu_items(int direction) const;
void zoom_in();
void zoom_out();

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/Base64.h>
#include <LibCore/Resource.h>
#include <UI/Qt/ChromeStyle.h>
#include <UI/Qt/Icon.h>
@ -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<uint>(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);

View file

@ -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);

View file

@ -4,7 +4,6 @@
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/Base64.h>
#include <UI/Qt/Icon.h>
#include <UI/Qt/Menu.h>
#include <UI/Qt/StringUtils.h>
@ -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<uint>(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;

View file

@ -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<WebView::WebContentClient> 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()) {