ladybird/UI/Qt/Application.cpp
Andreas Kling 4f314b0aa0 UI/Qt: Unify browser chrome layout
Treat the toolbar, tab strip, and vertical sidebar as one browser
chrome surface. Add an explicit layout policy for platform window
controls and sidebar widths. This lets macOS use left-side traffic
lights while other platforms keep right-side custom controls.

Keep the macOS toolbar stable in vertical-tabs mode. Traffic lights and
navigation controls stay in the top row, and the sidebar sits below it.
Draw chrome/content separators explicitly so the content edge remains
clear without adding seams inside the chrome surface.

Retune tab, toolbar, and omnibox styling for the unified chrome. Tabs
use card-like selected states, quiet inactive states, and matching
horizontal and vertical geometry.

Make hover-expanded vertical tabs draw as a chrome overlay so the web
content does not relayout. Keep the overlay above native web views and
route macOS window drags through AppKit events for reliable dragging
from custom chrome.
2026-06-03 02:53:37 +02:00

512 lines
16 KiB
C++

/*
* Copyright (c) 2024, Andrew Kaster <akaster@serenityos.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibCore/ArgsParser.h>
#include <LibWebView/URL.h>
#include <UI/Qt/Application.h>
#include <UI/Qt/ChromeStyle.h>
#include <UI/Qt/EventLoopImplementationQt.h>
#if defined(AK_OS_MACOS)
# include <UI/Qt/MacWindow.h>
#endif
#include <UI/Qt/Settings.h>
#include <UI/Qt/StringUtils.h>
#include <UI/Qt/WebContentView.h>
#include <QClipboard>
#include <QDesktopServices>
#include <QDialog>
#include <QDialogButtonBox>
#include <QFileDialog>
#include <QFileOpenEvent>
#include <QFormLayout>
#include <QLineEdit>
#include <QMessageBox>
#include <QMimeData>
#include <QStandardPaths>
#if defined(AK_OS_WINDOWS)
# include <AK/Windows.h>
# include <LibCore/TimeZoneWatcher.h>
# include <QAbstractNativeEventFilter>
#endif
namespace Ladybird {
#if defined(AK_OS_WINDOWS)
class NativeWindowsTimeChangeEventFilter : public QAbstractNativeEventFilter {
public:
NativeWindowsTimeChangeEventFilter(Core::TimeZoneWatcher& time_zone_watcher)
: m_time_zone_watcher(time_zone_watcher)
{
}
bool nativeEventFilter(QByteArray const& event_type, void* message, qintptr*) override
{
if (event_type == QByteArrayLiteral("windows_generic_MSG")) {
auto msg = static_cast<MSG*>(message);
if (msg->message == WM_TIMECHANGE) {
m_time_zone_watcher.on_time_zone_changed();
}
}
return false;
}
private:
Core::TimeZoneWatcher& m_time_zone_watcher;
};
#endif
class LadybirdQApplication : public QApplication {
public:
explicit LadybirdQApplication(Main::Arguments& arguments)
: QApplication(arguments.argc, arguments.argv)
{
#if defined(AK_OS_MACOS)
install_appkit_event_capture();
#endif
update_chrome_style();
}
virtual bool event(QEvent* event) override
{
auto& application = static_cast<Application&>(WebView::Application::the());
auto const event_type = event->type();
#if defined(AK_OS_WINDOWS)
static Optional<NativeWindowsTimeChangeEventFilter> time_change_event_filter {};
if (auto time_zone_watcher = application.time_zone_watcher(); !time_change_event_filter.has_value() && time_zone_watcher.has_value()) {
time_change_event_filter.emplace(time_zone_watcher.value());
installNativeEventFilter(&time_change_event_filter.value());
}
#endif
switch (event_type) {
case QEvent::FileOpen: {
if (!application.on_open_file)
break;
auto const& open_event = *static_cast<QFileOpenEvent const*>(event);
auto file = ak_string_from_qstring(open_event.file());
if (auto file_url = WebView::sanitize_url(file); file_url.has_value())
application.on_open_file(file_url.release_value());
break;
}
default:
break;
}
auto handled = QApplication::event(event);
if (event_type == QEvent::ApplicationPaletteChange || event_type == QEvent::ThemeChange)
update_chrome_style();
return handled;
}
private:
void update_chrome_style()
{
setStyleSheet(ChromeStyle::application_style_sheet(palette()));
}
};
Application::Application() = default;
Application::~Application() = default;
void Application::create_platform_options(WebView::BrowserOptions&, WebView::RequestServerOptions&, WebView::WebContentOptions& web_content_options)
{
web_content_options.config_path = Settings::the()->directory();
}
NonnullOwnPtr<Core::EventLoop> Application::create_platform_event_loop()
{
if (!browser_options().headless_mode.has_value()) {
Core::EventLoopManager::install(*new EventLoopManagerQt);
m_application = make<LadybirdQApplication>(arguments());
}
auto event_loop = WebView::Application::create_platform_event_loop();
if (!browser_options().headless_mode.has_value())
static_cast<EventLoopImplementationQt&>(event_loop->impl()).set_main_loop();
return event_loop;
}
BrowserWindow& Application::new_window(Vector<URL::URL> const& initial_urls, WindowConfiguration const& configuration, BrowserWindow::IsPopupWindow is_popup_window, Tab* parent_tab, Optional<u64> page_index)
{
auto* window = new BrowserWindow(initial_urls, is_popup_window, parent_tab, move(page_index));
set_active_window(*window);
if (initial_urls.is_empty()) {
auto* tab = window->current_tab();
if (tab) {
tab->set_url_is_hidden(true);
tab->focus_location_editor();
}
}
window->set_window_rect(configuration.x, configuration.y, configuration.width, configuration.height);
if (configuration.maximized == true)
window->showMaximized();
else
window->show();
window->activateWindow();
window->raise();
return *window;
}
Optional<WebView::ViewImplementation&> Application::active_web_view() const
{
if (auto* active_tab = this->active_tab())
return active_tab->view();
return {};
}
Optional<WebView::ViewImplementation&> Application::open_blank_new_tab(Web::HTML::ActivateTab activate_tab) const
{
auto& tab = active_window().create_new_tab(activate_tab);
return tab.view();
}
void Application::open_url_in_new_window(URL::URL const& url)
{
this->new_window({ url });
}
Optional<ByteString> Application::ask_user_for_download_path(StringView file) const
{
auto default_path = QStandardPaths::writableLocation(QStandardPaths::DownloadLocation);
if (default_path.isNull() || default_path.isEmpty())
default_path = qstring_from_ak_string(file);
else
default_path = QDir { default_path }.filePath(qstring_from_ak_string(file));
auto path = QFileDialog::getSaveFileName(nullptr, "Select save location", default_path);
if (path.isNull())
return {};
return ak_byte_string_from_qstring(path);
}
void Application::display_download_confirmation_dialog(StringView download_name, LexicalPath const& path) const
{
auto message = MUST(String::formatted("{} saved to: {}", download_name, path));
QMessageBox dialog(active_tab());
dialog.setWindowTitle("Ladybird");
dialog.setIcon(QMessageBox::Information);
dialog.setText(qstring_from_ak_string(message));
dialog.addButton(QMessageBox::Ok);
dialog.addButton(QMessageBox::Open)->setText("Open folder");
if (dialog.exec() == QMessageBox::Open) {
auto path_url = QUrl::fromLocalFile(qstring_from_ak_string(path.dirname()));
QDesktopServices::openUrl(path_url);
}
}
void Application::display_error_dialog(StringView error_message) const
{
QMessageBox::warning(active_tab(), "Ladybird", qstring_from_ak_string(error_message));
}
static QClipboard::Mode clipboard_mode(QClipboard const& clipboard, Application::ClipboardType type)
{
switch (type) {
case WebView::Application::ClipboardType::Text:
return QClipboard::Clipboard;
case WebView::Application::ClipboardType::Selection:
return clipboard.supportsSelection() ? QClipboard::Selection : QClipboard::Clipboard;
}
VERIFY_NOT_REACHED();
}
bool Application::supports_clipboard_type(ClipboardType type) const
{
if (browser_options().headless_mode.has_value())
return WebView::Application::supports_clipboard_type(type);
switch (type) {
case WebView::Application::ClipboardType::Text:
return true;
case WebView::Application::ClipboardType::Selection:
return QGuiApplication::clipboard()->supportsSelection();
}
VERIFY_NOT_REACHED();
}
Utf16String Application::clipboard_text(ClipboardType type) const
{
if (browser_options().headless_mode.has_value())
return WebView::Application::clipboard_text(type);
auto const* clipboard = QGuiApplication::clipboard();
auto mode = clipboard_mode(*clipboard, type);
return utf16_string_from_qstring(clipboard->text(mode));
}
void Application::set_clipboard_text(String text, ClipboardType type)
{
if (browser_options().headless_mode.has_value()) {
WebView::Application::set_clipboard_text(text, type);
return;
}
auto* clipboard = QGuiApplication::clipboard();
auto mode = clipboard_mode(*clipboard, type);
clipboard->setText(qstring_from_ak_string(text), mode);
}
Vector<Web::Clipboard::SystemClipboardRepresentation> Application::clipboard_entries() const
{
if (browser_options().headless_mode.has_value())
return WebView::Application::clipboard_entries();
Vector<Web::Clipboard::SystemClipboardRepresentation> representations;
auto const* clipboard = QGuiApplication::clipboard();
auto const* mime_data = clipboard->mimeData();
if (!mime_data)
return {};
for (auto const& format : mime_data->formats()) {
auto data = ak_byte_string_from_qbytearray(mime_data->data(format));
auto mime_type = ak_string_from_qstring(format);
representations.empend(move(data), move(mime_type));
}
return representations;
}
void Application::insert_clipboard_entry(Web::Clipboard::SystemClipboardRepresentation entry)
{
if (browser_options().headless_mode.has_value()) {
WebView::Application::insert_clipboard_entry(move(entry));
return;
}
auto* mime_data = new QMimeData();
mime_data->setData(qstring_from_ak_string(entry.mime_type), qbytearray_from_ak_string(entry.data));
auto* clipboard = QGuiApplication::clipboard();
clipboard->setMimeData(mime_data);
}
void Application::update_tabs_display() const
{
for (auto* widget : QApplication::topLevelWidgets()) {
if (auto* window = as_if<BrowserWindow>(widget))
window->update_tabs_display();
}
}
void Application::rebuild_bookmarks_menu() const
{
for (auto* widget : QApplication::topLevelWidgets()) {
if (auto* window = as_if<BrowserWindow>(widget))
window->rebuild_bookmarks_menu();
}
}
void Application::update_bookmarks_bar_display(bool show_bookmarks_bar) const
{
for (auto* widget : QApplication::topLevelWidgets()) {
if (auto* window = as_if<BrowserWindow>(widget))
window->update_bookmarks_bar_display(show_bookmarks_bar);
}
}
void Application::update_reopen_recently_closed_actions() const
{
for (auto* widget : QApplication::topLevelWidgets()) {
if (auto* window = as_if<BrowserWindow>(widget))
window->update_reopen_recently_closed_action();
}
}
void Application::show_bookmark_context_menu(Gfx::IntPoint content_position, Optional<WebView::BookmarkItem const&> item, Optional<String const&> target_folder_id)
{
if (auto* active_tab = this->active_tab()) {
auto position = active_tab->view().mapToGlobal(QPoint { content_position.x(), content_position.y() });
active_tab->bookmarks_bar().show_context_menu(position, item, target_folder_id);
}
}
Optional<Application::BookmarkID> Application::bookmark_item_id_for_context_menu() const
{
if (auto* active_tab = this->active_tab()) {
auto const& bookmarks_bar = active_tab->bookmarks_bar();
return Application::BookmarkID {
.id = bookmarks_bar.selected_bookmark_menu_item_id(),
.target_folder_id = bookmarks_bar.selected_bookmark_menu_target_folder_id(),
};
}
return {};
}
template<typename PromiseType>
static NonnullRefPtr<PromiseType> display_add_or_edit_bookmark_dialog(
QWidget* parent,
QString const& dialog_title,
Optional<URL::URL const&> current_url,
Optional<String const&> current_title)
{
auto promise = PromiseType::construct();
auto* dialog = new QDialog(parent);
dialog->resize(400, dialog->height());
dialog->setWindowTitle(dialog_title);
dialog->setAttribute(Qt::WA_DeleteOnClose);
auto* url_edit = new QLineEdit(dialog);
auto* title_edit = new QLineEdit(dialog);
if (current_url.has_value())
url_edit->setText(qstring_from_ak_string(current_url->serialize()));
if (current_title.has_value())
title_edit->setText(qstring_from_ak_string(*current_title));
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, dialog);
QObject::connect(buttons, &QDialogButtonBox::accepted, dialog, &QDialog::accept);
QObject::connect(buttons, &QDialogButtonBox::rejected, dialog, &QDialog::reject);
auto* layout = new QFormLayout(dialog);
layout->addRow("URL:", url_edit);
layout->addRow("Title:", title_edit);
layout->addRow(buttons);
QObject::connect(dialog, &QDialog::finished, [promise, url_edit = QPointer { url_edit }, title_edit = QPointer { title_edit }](auto result) {
if (result != QDialog::Accepted || !url_edit || !title_edit) {
promise->reject(Error::from_errno(ECANCELED));
return;
}
auto url = WebView::sanitize_url(ak_string_from_qstring(url_edit->text()));
if (!url.has_value()) {
promise->reject(Error::from_errno(EINVAL));
return;
}
Optional<String> title;
if (auto title_text = ak_string_from_qstring(title_edit->text()); !title_text.is_empty())
title = move(title_text);
promise->resolve(WebView::BookmarkItem::Bookmark {
.url = url.release_value(),
.title = move(title),
.favicon_base64_png = {},
});
});
dialog->open();
return promise;
}
NonnullRefPtr<Application::BookmarkPromise> Application::display_add_bookmark_dialog() const
{
Optional<URL::URL> current_url;
Optional<String> current_title;
if (auto view = active_web_view(); view.has_value()) {
current_url = view->url();
current_title = view->title().to_utf8();
}
return display_add_or_edit_bookmark_dialog<BookmarkPromise>(active_tab(), "Add Bookmark", current_url, current_title);
}
NonnullRefPtr<Application::BookmarkPromise> Application::display_edit_bookmark_dialog(WebView::BookmarkItem::Bookmark const& current_bookmark) const
{
return display_add_or_edit_bookmark_dialog<BookmarkPromise>(active_tab(), "Edit Bookmark", current_bookmark.url, current_bookmark.title);
}
template<typename PromiseType>
static NonnullRefPtr<PromiseType> display_add_or_edit_bookmark_folder_dialog(
QWidget* parent,
QString const& dialog_title,
Optional<String const&> current_title)
{
auto promise = PromiseType::construct();
auto* dialog = new QDialog(parent);
dialog->resize(400, dialog->height());
dialog->setWindowTitle(dialog_title);
dialog->setAttribute(Qt::WA_DeleteOnClose);
auto* title_edit = new QLineEdit(dialog);
if (current_title.has_value())
title_edit->setText(qstring_from_ak_string(*current_title));
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, dialog);
QObject::connect(buttons, &QDialogButtonBox::accepted, dialog, &QDialog::accept);
QObject::connect(buttons, &QDialogButtonBox::rejected, dialog, &QDialog::reject);
auto* layout = new QFormLayout(dialog);
layout->addRow("Title:", title_edit);
layout->addRow(buttons);
QObject::connect(dialog, &QDialog::finished, [promise, title_edit = QPointer { title_edit }](auto result) {
if (result != QDialog::Accepted || !title_edit) {
promise->reject(Error::from_errno(ECANCELED));
return;
}
Optional<String> title;
if (auto title_text = ak_string_from_qstring(title_edit->text()); !title_text.is_empty())
title = move(title_text);
promise->resolve(WebView::BookmarkItem::Folder {
.title = move(title),
.children = {},
});
});
dialog->open();
return promise;
}
NonnullRefPtr<Application::BookmarkFolderPromise> Application::display_add_bookmark_folder_dialog() const
{
return display_add_or_edit_bookmark_folder_dialog<BookmarkFolderPromise>(active_tab(), "Add Folder", {});
}
NonnullRefPtr<Application::BookmarkFolderPromise> Application::display_edit_bookmark_folder_dialog(WebView::BookmarkItem::Folder const& current_folder) const
{
return display_add_or_edit_bookmark_folder_dialog<BookmarkFolderPromise>(active_tab(), "Edit Folder", current_folder.title);
}
void Application::on_devtools_enabled() const
{
WebView::Application::on_devtools_enabled();
if (m_active_window)
m_active_window->on_devtools_enabled();
}
void Application::on_devtools_disabled() const
{
WebView::Application::on_devtools_disabled();
if (m_active_window)
m_active_window->on_devtools_disabled();
}
void Application::on_recently_closed_entries_changed() const
{
update_reopen_recently_closed_actions();
}
}