mirror of
https://github.com/LadybirdBrowser/ladybird
synced 2026-04-25 17:25:08 +02:00
Add the complete browser UI: - BrowserWindow: AdwHeaderBar with navigation, tab management via AdwTabView/AdwTabBar, find-in-page, fullscreen, zoom, D-Bus single-instance with open/activate handlers - Tab: WebContentView lifecycle, ViewImplementation callbacks for title, URL, favicon, cursor, tooltips, dialogs, window management - LadybirdBrowserWindow: GtkBuilder template widget with toolbar, tab bar, find bar, devtools banner, and hamburger menu - LadybirdLocationEntry: URL entry with autocomplete, domain highlighting, and security icon - Menu: GAction-based context menus and application menu with keyboard accelerators - Dialogs: JS alert/confirm/prompt (AdwAlertDialog), color picker, file picker, select dropdown, download save dialog, toast - GtkBuilder .ui resources for browser window, location entry completions, and list popovers Updates Application and main.cpp to create browser windows and handle D-Bus activation from remote instances.
348 lines
13 KiB
C++
348 lines
13 KiB
C++
/*
|
|
* Copyright (c) 2026, Johan Dahlin <jdahlin@gmail.com>
|
|
*
|
|
* SPDX-License-Identifier: BSD-2-Clause
|
|
*/
|
|
|
|
#include <LibGfx/Bitmap.h>
|
|
#include <LibGfx/Cursor.h>
|
|
#include <LibURL/URL.h>
|
|
#include <LibWeb/HTML/SelectItem.h>
|
|
#include <LibWebView/Menu.h>
|
|
#include <LibWebView/URL.h>
|
|
#include <UI/Gtk/BrowserWindow.h>
|
|
#include <UI/Gtk/Dialogs.h>
|
|
#include <UI/Gtk/Events.h>
|
|
#include <UI/Gtk/GLibPtr.h>
|
|
#include <UI/Gtk/Menu.h>
|
|
#include <UI/Gtk/Tab.h>
|
|
#include <UI/Gtk/WebContentView.h>
|
|
#include <UI/Gtk/Widgets/Builder.h>
|
|
|
|
namespace Ladybird {
|
|
|
|
struct ListPopoverShell {
|
|
GtkPopover* popover { nullptr };
|
|
GtkListBox* list_box { nullptr };
|
|
};
|
|
|
|
static ListPopoverShell create_list_popover_shell(GtkWidget* parent)
|
|
{
|
|
GObjectPtr builder { gtk_builder_new_from_resource("/org/ladybird/Ladybird/gtk/list-popover.ui") };
|
|
auto* popover = LadybirdWidgets::get_builder_object<GtkPopover>(builder, "popover");
|
|
auto* list_box = LadybirdWidgets::get_builder_object<GtkListBox>(builder, "list_box");
|
|
gtk_widget_set_parent(GTK_WIDGET(popover), parent);
|
|
return { popover, list_box };
|
|
}
|
|
|
|
static void append_select_option_row(GtkListBox* list_box, char const* label, bool selected, bool disabled, unsigned id, int margin_start)
|
|
{
|
|
auto* row = gtk_list_box_row_new();
|
|
auto* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
|
|
gtk_widget_set_margin_start(box, margin_start);
|
|
gtk_widget_set_margin_end(box, 8);
|
|
gtk_widget_set_margin_top(box, 4);
|
|
gtk_widget_set_margin_bottom(box, 4);
|
|
|
|
if (selected) {
|
|
auto* check = gtk_image_new_from_icon_name("object-select-symbolic");
|
|
gtk_box_append(GTK_BOX(box), check);
|
|
} else {
|
|
auto* spacer = gtk_image_new_from_icon_name("object-select-symbolic");
|
|
gtk_widget_set_opacity(spacer, 0);
|
|
gtk_box_append(GTK_BOX(box), spacer);
|
|
}
|
|
|
|
auto* label_widget = gtk_label_new(label);
|
|
gtk_label_set_xalign(GTK_LABEL(label_widget), 0.0);
|
|
gtk_box_append(GTK_BOX(box), label_widget);
|
|
|
|
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box);
|
|
gtk_widget_set_sensitive(row, !disabled);
|
|
g_object_set_data(G_OBJECT(row), "item-id", GUINT_TO_POINTER(id));
|
|
gtk_list_box_append(GTK_LIST_BOX(list_box), row);
|
|
}
|
|
|
|
Tab::Tab(BrowserWindow& window, URL::URL url)
|
|
: Tab(window, nullptr, 0)
|
|
{
|
|
m_initial_url = move(url);
|
|
if (!m_initial_url.scheme().is_empty())
|
|
navigate(m_initial_url);
|
|
}
|
|
|
|
Tab::Tab(BrowserWindow& window, WebView::WebContentClient& parent_client, u64 page_index)
|
|
: Tab(window, &parent_client, page_index)
|
|
{
|
|
}
|
|
|
|
Tab::Tab(BrowserWindow& window, RefPtr<WebView::WebContentClient> parent_client, size_t page_index)
|
|
: m_window(window)
|
|
{
|
|
m_web_view = ladybird_web_view_new();
|
|
gtk_widget_set_vexpand(GTK_WIDGET(m_web_view), TRUE);
|
|
gtk_widget_set_hexpand(GTK_WIDGET(m_web_view), TRUE);
|
|
m_view = adopt_own(*new WebContentView(m_web_view, parent_client, page_index));
|
|
|
|
setup_callbacks();
|
|
}
|
|
|
|
Tab::~Tab() = default;
|
|
|
|
void Tab::setup_callbacks()
|
|
{
|
|
auto* root = GTK_WIDGET(m_web_view);
|
|
|
|
m_view->on_title_change = [this](auto const& title) {
|
|
if (m_tab_page) {
|
|
auto utf8 = title.to_utf8();
|
|
auto byte_str = ByteString(utf8.bytes_as_string_view());
|
|
adw_tab_page_set_title(m_tab_page, byte_str.characters());
|
|
}
|
|
};
|
|
|
|
m_view->on_url_change = [this](auto const& url) {
|
|
if (m_window.current_tab() != this)
|
|
return;
|
|
if (BrowserWindow::is_internal_url(url)) {
|
|
m_window.update_location_entry(""sv);
|
|
return;
|
|
}
|
|
auto url_string = url.serialize();
|
|
m_window.update_location_entry(url_string.bytes_as_string_view());
|
|
};
|
|
|
|
m_view->on_load_start = [this](auto const&, bool) {
|
|
if (m_tab_page)
|
|
adw_tab_page_set_loading(m_tab_page, TRUE);
|
|
};
|
|
|
|
m_view->on_load_finish = [this](auto const&) {
|
|
if (m_tab_page)
|
|
adw_tab_page_set_loading(m_tab_page, FALSE);
|
|
};
|
|
|
|
m_view->on_cursor_change = [root](auto const& cursor) {
|
|
auto cursor_name = cursor.visit(
|
|
[](Gfx::StandardCursor standard_cursor) -> StringView {
|
|
return standard_cursor_to_css_name(standard_cursor);
|
|
},
|
|
[](Gfx::ImageCursor const&) -> StringView {
|
|
return "default"sv;
|
|
});
|
|
auto cursor_name_str = ByteString(cursor_name);
|
|
GObjectPtr gdk_cursor { gdk_cursor_new_from_name(cursor_name_str.characters(), nullptr) };
|
|
gtk_widget_set_cursor(root, GDK_CURSOR(gdk_cursor.ptr()));
|
|
};
|
|
|
|
m_view->on_enter_tooltip_area = [root](auto const& tooltip) {
|
|
auto text = ByteString(tooltip);
|
|
gtk_widget_set_tooltip_text(root, text.characters());
|
|
};
|
|
|
|
m_view->on_leave_tooltip_area = [root]() {
|
|
gtk_widget_set_tooltip_text(root, nullptr);
|
|
};
|
|
|
|
m_view->on_link_hover = [root](auto const& url) {
|
|
auto url_string = url.serialize();
|
|
auto byte_string = ByteString(url_string.bytes_as_string_view());
|
|
gtk_widget_set_tooltip_text(root, byte_string.characters());
|
|
};
|
|
|
|
m_view->on_link_unhover = [root]() {
|
|
gtk_widget_set_tooltip_text(root, nullptr);
|
|
};
|
|
|
|
m_view->on_new_web_view = [this](auto activate_tab, auto, auto page_index) -> String {
|
|
if (page_index.has_value()) {
|
|
auto& new_tab = m_window.create_child_tab(activate_tab, *this, page_index.value());
|
|
return new_tab.view().handle();
|
|
}
|
|
auto& new_tab = m_window.create_new_tab(activate_tab);
|
|
return new_tab.view().handle();
|
|
};
|
|
|
|
m_view->on_activate_tab = [root]() {
|
|
gtk_widget_grab_focus(root);
|
|
};
|
|
|
|
m_view->on_close = [this]() {
|
|
m_window.close_tab(*this);
|
|
};
|
|
|
|
m_view->on_zoom_level_changed = [this]() {
|
|
m_window.update_zoom_label();
|
|
};
|
|
|
|
// Dialogs
|
|
m_view->on_request_alert = [this](auto const& message) {
|
|
Dialogs::show_alert(m_window.gtk_window(), m_view.ptr(), message);
|
|
};
|
|
|
|
m_view->on_request_confirm = [this](auto const& message) {
|
|
Dialogs::show_confirm(m_window.gtk_window(), m_view.ptr(), message);
|
|
};
|
|
|
|
m_view->on_request_prompt = [this](auto const& message, auto const& default_value) {
|
|
Dialogs::show_prompt(m_window.gtk_window(), m_view.ptr(), message, default_value);
|
|
};
|
|
|
|
m_view->on_request_color_picker = [this](Color current_color) {
|
|
Dialogs::show_color_picker(m_window.gtk_window(), m_view.ptr(), current_color);
|
|
};
|
|
|
|
m_view->on_request_file_picker = [this](auto const& accepted_file_types, auto allow_multiple) {
|
|
Dialogs::show_file_picker(m_window.gtk_window(), m_view.ptr(), accepted_file_types, allow_multiple);
|
|
};
|
|
|
|
m_view->on_request_select_dropdown = [this](Gfx::IntPoint content_position, i32 minimum_width, Vector<Web::HTML::SelectItem> items) {
|
|
show_select_dropdown(content_position, minimum_width, move(items));
|
|
};
|
|
|
|
m_view->on_find_in_page = [this](auto current_match_index, auto const& total_match_count) {
|
|
m_window.update_find_in_page_result(current_match_index, total_match_count);
|
|
};
|
|
|
|
m_view->on_fullscreen_window = [this]() {
|
|
gtk_window_fullscreen(m_window.gtk_window());
|
|
};
|
|
|
|
m_view->on_exit_fullscreen_window = [this]() {
|
|
gtk_window_unfullscreen(m_window.gtk_window());
|
|
};
|
|
|
|
m_view->on_restore_window = [this]() {
|
|
gtk_window_unmaximize(m_window.gtk_window());
|
|
gtk_window_unfullscreen(m_window.gtk_window());
|
|
};
|
|
|
|
m_view->on_maximize_window = [this]() {
|
|
gtk_window_maximize(m_window.gtk_window());
|
|
};
|
|
|
|
m_view->on_minimize_window = [this]() {
|
|
gtk_window_minimize(m_window.gtk_window());
|
|
};
|
|
|
|
m_view->on_reposition_window = [](auto) {
|
|
// GTK4 removed window repositioning APIs. On Wayland, clients cannot
|
|
// position their own top-level windows. On other backends, GTK4 chose
|
|
// not to expose it for portability.
|
|
};
|
|
|
|
m_view->on_resize_window = [this](auto size) {
|
|
gtk_window_set_default_size(m_window.gtk_window(), size.width(), size.height());
|
|
};
|
|
|
|
m_view->on_favicon_change = [this](auto const& bitmap) {
|
|
if (!m_tab_page)
|
|
return;
|
|
g_autoptr(GBytes) bytes = g_bytes_new(bitmap.scanline_u8(0), bitmap.size_in_bytes());
|
|
GObjectPtr texture { gdk_memory_texture_new(bitmap.width(), bitmap.height(), GDK_MEMORY_B8G8R8A8_PREMULTIPLIED, bytes, bitmap.pitch()) };
|
|
adw_tab_page_set_icon(m_tab_page, G_ICON(texture.ptr()));
|
|
};
|
|
|
|
m_view->on_audio_play_state_changed = [this](auto play_state) {
|
|
if (m_tab_page) {
|
|
adw_tab_page_set_indicator_icon(m_tab_page,
|
|
play_state == Web::HTML::AudioPlayState::Playing
|
|
? g_themed_icon_new("audio-volume-high-symbolic")
|
|
: nullptr);
|
|
}
|
|
};
|
|
|
|
// FIXME: Support non-modal JS dialogs (on_request_set_prompt_text,
|
|
// on_request_accept_dialog, on_request_dismiss_dialog) for WebDriver support.
|
|
|
|
// Context menus
|
|
create_context_menu(*root, *m_view, m_view->page_context_menu());
|
|
create_context_menu(*root, *m_view, m_view->link_context_menu());
|
|
create_context_menu(*root, *m_view, m_view->image_context_menu());
|
|
create_context_menu(*root, *m_view, m_view->media_context_menu());
|
|
}
|
|
|
|
void Tab::navigate(URL::URL const& url)
|
|
{
|
|
m_view->load(url);
|
|
}
|
|
|
|
void Tab::show_select_dropdown(Gfx::IntPoint content_position, i32 minimum_width, Vector<Web::HTML::SelectItem> items)
|
|
{
|
|
auto* root = GTK_WIDGET(m_web_view);
|
|
auto shell = create_list_popover_shell(root);
|
|
auto* popover = shell.popover;
|
|
auto* list_box = shell.list_box;
|
|
gtk_widget_set_size_request(GTK_WIDGET(popover), minimum_width, -1);
|
|
|
|
auto device_pixel_ratio = m_view->device_pixel_ratio();
|
|
GdkRectangle rect = {
|
|
static_cast<int>(content_position.x() / device_pixel_ratio),
|
|
static_cast<int>(content_position.y() / device_pixel_ratio),
|
|
1, 1
|
|
};
|
|
gtk_popover_set_pointing_to(GTK_POPOVER(popover), &rect);
|
|
gtk_list_box_set_selection_mode(GTK_LIST_BOX(list_box), GTK_SELECTION_NONE);
|
|
|
|
for (auto const& item : items) {
|
|
item.visit(
|
|
[&](Web::HTML::SelectItemOption const& option) {
|
|
append_select_option_row(list_box, option.label.to_byte_string().characters(), option.selected, option.disabled, option.id, 8);
|
|
},
|
|
[&](Web::HTML::SelectItemOptionGroup const& group) {
|
|
auto* header = gtk_label_new(group.label.to_byte_string().characters());
|
|
gtk_label_set_xalign(GTK_LABEL(header), 0.0);
|
|
gtk_widget_add_css_class(header, "heading");
|
|
gtk_widget_set_margin_start(header, 8);
|
|
gtk_widget_set_margin_top(header, 6);
|
|
gtk_widget_set_margin_bottom(header, 2);
|
|
auto* header_row = gtk_list_box_row_new();
|
|
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(header_row), header);
|
|
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(header_row), FALSE);
|
|
gtk_list_box_row_set_selectable(GTK_LIST_BOX_ROW(header_row), FALSE);
|
|
gtk_list_box_append(GTK_LIST_BOX(list_box), header_row);
|
|
|
|
for (auto const& option : group.items) {
|
|
append_select_option_row(list_box, option.label.to_byte_string().characters(), option.selected, option.disabled, option.id, 16);
|
|
}
|
|
},
|
|
[&](Web::HTML::SelectItemSeparator const&) {
|
|
auto* sep_row = gtk_list_box_row_new();
|
|
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(sep_row), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
|
|
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(sep_row), FALSE);
|
|
gtk_list_box_row_set_selectable(GTK_LIST_BOX_ROW(sep_row), FALSE);
|
|
gtk_list_box_append(GTK_LIST_BOX(list_box), sep_row);
|
|
},
|
|
[&](auto const&) {});
|
|
}
|
|
|
|
struct DropdownState {
|
|
WebContentView* view;
|
|
GtkPopover* popover;
|
|
bool selected { false };
|
|
};
|
|
auto* dropdown_state = new DropdownState { m_view.ptr(), GTK_POPOVER(popover) };
|
|
|
|
g_signal_connect(list_box, "row-activated", G_CALLBACK(+[](GtkListBox*, GtkListBoxRow* row, gpointer user_data) {
|
|
auto* state = static_cast<DropdownState*>(user_data);
|
|
auto item_id = GPOINTER_TO_UINT(g_object_get_data(G_OBJECT(row), "item-id"));
|
|
state->selected = true;
|
|
state->view->select_dropdown_closed(item_id);
|
|
gtk_popover_popdown(state->popover);
|
|
}),
|
|
dropdown_state);
|
|
|
|
g_signal_connect(popover, "closed", G_CALLBACK(+[](GtkPopover* popover, gpointer user_data) {
|
|
auto* state = static_cast<DropdownState*>(user_data);
|
|
if (!state->selected)
|
|
state->view->select_dropdown_closed({});
|
|
delete state;
|
|
gtk_widget_unparent(GTK_WIDGET(popover));
|
|
}),
|
|
dropdown_state);
|
|
|
|
gtk_popover_popup(GTK_POPOVER(popover));
|
|
}
|
|
|
|
}
|