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.
369 lines
14 KiB
C++
369 lines
14 KiB
C++
/*
|
|
* Copyright (c) 2026, Johan Dahlin <jdahlin@gmail.com>
|
|
*
|
|
* SPDX-License-Identifier: BSD-2-Clause
|
|
*/
|
|
|
|
#include <AK/NonnullOwnPtr.h>
|
|
#include <AK/OwnPtr.h>
|
|
#include <AK/String.h>
|
|
#include <AK/Vector.h>
|
|
#include <LibWebView/Application.h>
|
|
#include <LibWebView/Autocomplete.h>
|
|
#include <LibWebView/URL.h>
|
|
#include <UI/Gtk/GLibPtr.h>
|
|
#include <UI/Gtk/Widgets/Builder.h>
|
|
#include <UI/Gtk/Widgets/LadybirdLocationEntry.h>
|
|
|
|
struct LocationEntryState {
|
|
NonnullOwnPtr<WebView::Autocomplete> autocomplete;
|
|
Vector<WebView::AutocompleteSuggestion> suggestions;
|
|
int selected_index { -1 };
|
|
String user_text;
|
|
bool is_focused { false };
|
|
bool updating_text { false };
|
|
Function<void(String)> on_navigate;
|
|
};
|
|
|
|
#define LADYBIRD_LOCATION_ENTRY(obj) (reinterpret_cast<LadybirdLocationEntry*>(obj))
|
|
#define LADYBIRD_TYPE_LOCATION_ENTRY (ladybird_location_entry_get_type())
|
|
|
|
struct LadybirdLocationEntry {
|
|
GtkEntry parent_instance;
|
|
|
|
GtkPopover* popover { nullptr };
|
|
GtkListBox* list_box { nullptr };
|
|
// GObject allocates this struct with g_malloc0, which zero-fills without
|
|
// calling C++ constructors. OwnPtr is safe here because zero-initialized
|
|
// OwnPtr is equivalent to nullptr (empty state).
|
|
OwnPtr<LocationEntryState> state;
|
|
};
|
|
|
|
struct LadybirdLocationEntryClass {
|
|
GtkEntryClass parent_class;
|
|
};
|
|
|
|
G_DEFINE_FINAL_TYPE(LadybirdLocationEntry, ladybird_location_entry, GTK_TYPE_ENTRY)
|
|
|
|
static void ladybird_location_entry_update_display_attributes(LadybirdLocationEntry* self);
|
|
static void ladybird_location_entry_show_completions(LadybirdLocationEntry* self);
|
|
static void ladybird_location_entry_hide_completions(LadybirdLocationEntry* self);
|
|
static void ladybird_location_entry_navigate(LadybirdLocationEntry* self);
|
|
static void ladybird_location_entry_move_selection(LadybirdLocationEntry* self, int delta);
|
|
static void ladybird_location_entry_apply_selected_suggestion(LadybirdLocationEntry* self);
|
|
|
|
static void set_entry_text_suppressed(LadybirdLocationEntry* self, char const* text, bool move_cursor_to_end = false)
|
|
{
|
|
self->state->updating_text = true;
|
|
gtk_editable_set_text(GTK_EDITABLE(self), text);
|
|
if (move_cursor_to_end)
|
|
gtk_editable_set_position(GTK_EDITABLE(self), -1);
|
|
self->state->updating_text = false;
|
|
}
|
|
|
|
static void ladybird_location_entry_finalize(GObject* object)
|
|
{
|
|
auto* self = LADYBIRD_LOCATION_ENTRY(object);
|
|
if (self->popover) {
|
|
gtk_popover_popdown(self->popover);
|
|
gtk_widget_unparent(GTK_WIDGET(self->popover));
|
|
self->popover = nullptr;
|
|
}
|
|
self->state.clear();
|
|
G_OBJECT_CLASS(ladybird_location_entry_parent_class)->finalize(object);
|
|
}
|
|
|
|
static void ladybird_location_entry_class_init(LadybirdLocationEntryClass* klass)
|
|
{
|
|
auto* object_class = G_OBJECT_CLASS(klass);
|
|
object_class->finalize = ladybird_location_entry_finalize;
|
|
}
|
|
|
|
static void ladybird_location_entry_init(LadybirdLocationEntry* self)
|
|
{
|
|
self->state = adopt_own(*new LocationEntryState { .autocomplete = make<WebView::Autocomplete>(), .suggestions = {}, .user_text = {}, .on_navigate = {} });
|
|
|
|
gtk_widget_set_hexpand(GTK_WIDGET(self), TRUE);
|
|
|
|
if (auto const& search_engine = WebView::Application::settings().search_engine(); search_engine.has_value()) {
|
|
auto placeholder = ByteString::formatted("Search with {} or enter URL", search_engine->name);
|
|
gtk_entry_set_placeholder_text(GTK_ENTRY(self), placeholder.characters());
|
|
} else {
|
|
gtk_entry_set_placeholder_text(GTK_ENTRY(self), "Enter URL or search...");
|
|
}
|
|
|
|
// Load completion popover from resource
|
|
Ladybird::GObjectPtr builder { gtk_builder_new_from_resource("/org/ladybird/Ladybird/gtk/location-entry.ui") };
|
|
self->popover = LadybirdWidgets::get_builder_object<GtkPopover>(builder, "completion_popover");
|
|
self->list_box = LadybirdWidgets::get_builder_object<GtkListBox>(builder, "completion_list_box");
|
|
gtk_widget_set_parent(GTK_WIDGET(self->popover), GTK_WIDGET(self));
|
|
|
|
// Clicking a suggestion navigates to it
|
|
g_signal_connect_swapped(self->list_box, "row-activated", G_CALLBACK(+[](LadybirdLocationEntry* self, GtkListBoxRow* row) {
|
|
auto index = gtk_list_box_row_get_index(row);
|
|
if (index >= 0 && static_cast<size_t>(index) < self->state->suggestions.size()) {
|
|
set_entry_text_suppressed(self, self->state->suggestions[index].text.to_byte_string().characters());
|
|
ladybird_location_entry_hide_completions(self);
|
|
ladybird_location_entry_navigate(self);
|
|
}
|
|
}),
|
|
self);
|
|
|
|
// Autocomplete results callback
|
|
self->state->autocomplete->on_autocomplete_query_complete = [self](auto suggestions, auto) {
|
|
if (suggestions.is_empty() || !self->state->is_focused) {
|
|
ladybird_location_entry_hide_completions(self);
|
|
return;
|
|
}
|
|
self->state->suggestions = move(suggestions);
|
|
self->state->selected_index = -1;
|
|
ladybird_location_entry_show_completions(self);
|
|
};
|
|
|
|
// Text changed -> query autocomplete
|
|
g_signal_connect_swapped(self, "changed", G_CALLBACK(+[](LadybirdLocationEntry* self, GtkEditable*) {
|
|
if (!self->state->is_focused || self->state->updating_text)
|
|
return;
|
|
auto* text = gtk_editable_get_text(GTK_EDITABLE(self));
|
|
if (!text || text[0] == '\0') {
|
|
ladybird_location_entry_hide_completions(self);
|
|
return;
|
|
}
|
|
self->state->user_text = MUST(String::from_utf8(StringView { text, strlen(text) }));
|
|
self->state->autocomplete->query_autocomplete_engine(self->state->user_text);
|
|
}),
|
|
self);
|
|
|
|
// Enter navigates
|
|
g_signal_connect_swapped(self, "activate", G_CALLBACK(+[](LadybirdLocationEntry* self, GtkEntry*) {
|
|
ladybird_location_entry_hide_completions(self);
|
|
ladybird_location_entry_navigate(self);
|
|
}),
|
|
self);
|
|
|
|
// Key controller for Up/Down/Escape
|
|
auto* key_controller = gtk_event_controller_key_new();
|
|
g_signal_connect_swapped(key_controller, "key-pressed", G_CALLBACK(+[](LadybirdLocationEntry* self, guint keyval, guint, GdkModifierType) -> gboolean {
|
|
if (!gtk_widget_get_visible(GTK_WIDGET(self->popover)))
|
|
return GDK_EVENT_PROPAGATE;
|
|
|
|
switch (keyval) {
|
|
case GDK_KEY_Down:
|
|
ladybird_location_entry_move_selection(self, 1);
|
|
return GDK_EVENT_STOP;
|
|
case GDK_KEY_Up:
|
|
ladybird_location_entry_move_selection(self, -1);
|
|
return GDK_EVENT_STOP;
|
|
case GDK_KEY_Escape:
|
|
ladybird_location_entry_hide_completions(self);
|
|
if (!self->state->user_text.is_empty())
|
|
set_entry_text_suppressed(self, self->state->user_text.to_byte_string().characters());
|
|
return GDK_EVENT_STOP;
|
|
default:
|
|
return GDK_EVENT_PROPAGATE;
|
|
}
|
|
}),
|
|
self);
|
|
gtk_widget_add_controller(GTK_WIDGET(self), GTK_EVENT_CONTROLLER(key_controller));
|
|
|
|
// Focus tracking
|
|
auto* focus_controller = gtk_event_controller_focus_new();
|
|
g_signal_connect_swapped(focus_controller, "enter", G_CALLBACK(+[](LadybirdLocationEntry* self, GtkEventControllerFocus*) {
|
|
self->state->is_focused = true;
|
|
gtk_entry_set_attributes(GTK_ENTRY(self), nullptr);
|
|
}),
|
|
self);
|
|
g_signal_connect_swapped(focus_controller, "leave", G_CALLBACK(+[](LadybirdLocationEntry* self, GtkEventControllerFocus*) {
|
|
self->state->is_focused = false;
|
|
ladybird_location_entry_hide_completions(self);
|
|
ladybird_location_entry_update_display_attributes(self);
|
|
}),
|
|
self);
|
|
gtk_widget_add_controller(GTK_WIDGET(self), GTK_EVENT_CONTROLLER(focus_controller));
|
|
}
|
|
|
|
// Public API
|
|
|
|
LadybirdLocationEntry* ladybird_location_entry_new(void)
|
|
{
|
|
return LADYBIRD_LOCATION_ENTRY(g_object_new(LADYBIRD_TYPE_LOCATION_ENTRY, nullptr));
|
|
}
|
|
|
|
void ladybird_location_entry_set_url(LadybirdLocationEntry* self, char const* url)
|
|
{
|
|
set_entry_text_suppressed(self, url ? url : "");
|
|
|
|
// Extract scheme for security icon
|
|
if (url) {
|
|
auto sv = StringView(url, strlen(url));
|
|
auto colon = sv.find(':');
|
|
if (colon.has_value()) {
|
|
auto scheme = sv.substring_view(0, *colon);
|
|
auto scheme_bs = ByteString(scheme);
|
|
ladybird_location_entry_set_security_icon(self, scheme_bs.characters());
|
|
} else {
|
|
ladybird_location_entry_set_security_icon(self, nullptr);
|
|
}
|
|
} else {
|
|
ladybird_location_entry_set_security_icon(self, nullptr);
|
|
}
|
|
|
|
if (!self->state->is_focused)
|
|
ladybird_location_entry_update_display_attributes(self);
|
|
}
|
|
|
|
void ladybird_location_entry_set_text(LadybirdLocationEntry* self, char const* text)
|
|
{
|
|
set_entry_text_suppressed(self, text ? text : "");
|
|
gtk_entry_set_attributes(GTK_ENTRY(self), nullptr);
|
|
ladybird_location_entry_set_security_icon(self, nullptr);
|
|
}
|
|
|
|
void ladybird_location_entry_set_security_icon(LadybirdLocationEntry* self, char const* scheme)
|
|
{
|
|
if (!scheme) {
|
|
gtk_entry_set_icon_from_icon_name(GTK_ENTRY(self), GTK_ENTRY_ICON_PRIMARY, nullptr);
|
|
return;
|
|
}
|
|
|
|
auto sv = StringView(scheme, strlen(scheme));
|
|
if (sv == "https"sv) {
|
|
gtk_entry_set_icon_from_icon_name(GTK_ENTRY(self), GTK_ENTRY_ICON_PRIMARY, "channel-secure-symbolic");
|
|
gtk_entry_set_icon_tooltip_text(GTK_ENTRY(self), GTK_ENTRY_ICON_PRIMARY, "Secure connection");
|
|
} else if (sv == "file"sv || sv == "resource"sv || sv == "about"sv || sv == "data"sv) {
|
|
gtk_entry_set_icon_from_icon_name(GTK_ENTRY(self), GTK_ENTRY_ICON_PRIMARY, nullptr);
|
|
} else {
|
|
gtk_entry_set_icon_from_icon_name(GTK_ENTRY(self), GTK_ENTRY_ICON_PRIMARY, "channel-insecure-symbolic");
|
|
gtk_entry_set_icon_tooltip_text(GTK_ENTRY(self), GTK_ENTRY_ICON_PRIMARY, "Insecure connection");
|
|
}
|
|
}
|
|
|
|
void ladybird_location_entry_focus_and_select_all(LadybirdLocationEntry* self)
|
|
{
|
|
gtk_widget_grab_focus(GTK_WIDGET(self));
|
|
gtk_editable_select_region(GTK_EDITABLE(self), 0, -1);
|
|
}
|
|
|
|
void ladybird_location_entry_set_on_navigate(LadybirdLocationEntry* self, Function<void(String)> callback)
|
|
{
|
|
self->state->on_navigate = move(callback);
|
|
}
|
|
|
|
// Internal helpers
|
|
|
|
static void ladybird_location_entry_update_display_attributes(LadybirdLocationEntry* self)
|
|
{
|
|
auto* text = gtk_editable_get_text(GTK_EDITABLE(self));
|
|
if (!text || text[0] == '\0') {
|
|
gtk_entry_set_attributes(GTK_ENTRY(self), nullptr);
|
|
return;
|
|
}
|
|
|
|
auto url_str = StringView(text, strlen(text));
|
|
auto url_parts = WebView::break_url_into_parts(url_str);
|
|
|
|
auto* attrs = pango_attr_list_new();
|
|
|
|
if (url_parts.has_value()) {
|
|
auto* dim = pango_attr_foreground_alpha_new(40000);
|
|
pango_attr_list_insert(attrs, dim);
|
|
|
|
auto highlight_start = url_parts->scheme_and_subdomain.length();
|
|
auto highlight_end = highlight_start + url_parts->effective_tld_plus_one.length();
|
|
|
|
if (highlight_start < highlight_end) {
|
|
auto* domain_alpha = pango_attr_foreground_alpha_new(65535);
|
|
domain_alpha->start_index = highlight_start;
|
|
domain_alpha->end_index = highlight_end;
|
|
pango_attr_list_insert(attrs, domain_alpha);
|
|
|
|
auto* semi_bold = pango_attr_weight_new(PANGO_WEIGHT_MEDIUM);
|
|
semi_bold->start_index = highlight_start;
|
|
semi_bold->end_index = highlight_end;
|
|
pango_attr_list_insert(attrs, semi_bold);
|
|
}
|
|
}
|
|
|
|
gtk_entry_set_attributes(GTK_ENTRY(self), attrs);
|
|
pango_attr_list_unref(attrs);
|
|
}
|
|
|
|
static void ladybird_location_entry_navigate(LadybirdLocationEntry* self)
|
|
{
|
|
auto* text = gtk_editable_get_text(GTK_EDITABLE(self));
|
|
if (!text || text[0] == '\0')
|
|
return;
|
|
auto query = MUST(String::from_utf8(StringView { text, strlen(text) }));
|
|
if (auto url = WebView::sanitize_url(query, WebView::Application::settings().search_engine()); url.has_value()) {
|
|
if (self->state->on_navigate)
|
|
self->state->on_navigate(url->serialize());
|
|
}
|
|
}
|
|
|
|
static void ladybird_location_entry_show_completions(LadybirdLocationEntry* self)
|
|
{
|
|
GtkWidget* child;
|
|
while ((child = gtk_widget_get_first_child(GTK_WIDGET(self->list_box))) != nullptr)
|
|
gtk_list_box_remove(self->list_box, child);
|
|
|
|
for (auto const& suggestion : self->state->suggestions) {
|
|
auto byte_str = suggestion.text.to_byte_string();
|
|
auto* label = gtk_label_new(byte_str.characters());
|
|
gtk_label_set_xalign(GTK_LABEL(label), 0.0);
|
|
gtk_label_set_ellipsize(GTK_LABEL(label), PANGO_ELLIPSIZE_END);
|
|
gtk_widget_set_margin_start(label, 8);
|
|
gtk_widget_set_margin_end(label, 8);
|
|
gtk_widget_set_margin_top(label, 4);
|
|
gtk_widget_set_margin_bottom(label, 4);
|
|
gtk_list_box_append(self->list_box, label);
|
|
}
|
|
|
|
gtk_list_box_unselect_all(self->list_box);
|
|
|
|
auto entry_width = gtk_widget_get_width(GTK_WIDGET(self));
|
|
if (entry_width > 0)
|
|
gtk_widget_set_size_request(GTK_WIDGET(self->popover), entry_width, -1);
|
|
|
|
gtk_popover_popup(self->popover);
|
|
}
|
|
|
|
static void ladybird_location_entry_hide_completions(LadybirdLocationEntry* self)
|
|
{
|
|
self->state->suggestions.clear();
|
|
self->state->selected_index = -1;
|
|
gtk_popover_popdown(self->popover);
|
|
}
|
|
|
|
static void ladybird_location_entry_move_selection(LadybirdLocationEntry* self, int delta)
|
|
{
|
|
auto& state = *self->state;
|
|
if (state.suggestions.is_empty())
|
|
return;
|
|
|
|
auto new_index = state.selected_index + delta;
|
|
if (new_index < -1)
|
|
new_index = static_cast<int>(state.suggestions.size()) - 1;
|
|
if (new_index >= static_cast<int>(state.suggestions.size()))
|
|
new_index = -1;
|
|
|
|
state.selected_index = new_index;
|
|
|
|
if (state.selected_index >= 0) {
|
|
auto* row = gtk_list_box_get_row_at_index(self->list_box, state.selected_index);
|
|
gtk_list_box_select_row(self->list_box, row);
|
|
ladybird_location_entry_apply_selected_suggestion(self);
|
|
} else {
|
|
gtk_list_box_unselect_all(self->list_box);
|
|
set_entry_text_suppressed(self, state.user_text.to_byte_string().characters(), true);
|
|
}
|
|
}
|
|
|
|
static void ladybird_location_entry_apply_selected_suggestion(LadybirdLocationEntry* self)
|
|
{
|
|
auto& state = *self->state;
|
|
if (state.selected_index < 0 || static_cast<size_t>(state.selected_index) >= state.suggestions.size())
|
|
return;
|
|
|
|
set_entry_text_suppressed(self, state.suggestions[state.selected_index].text.to_byte_string().characters(), true);
|
|
}
|