diff --git a/Ladybird/AppKit/Application/Application.h b/Ladybird/AppKit/Application/Application.h new file mode 100644 index 00000000000..f8de23dc8b1 --- /dev/null +++ b/Ladybird/AppKit/Application/Application.h @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#import + +@interface Application : NSApplication + +@end diff --git a/Ladybird/AppKit/Application/Application.mm b/Ladybird/AppKit/Application/Application.mm new file mode 100644 index 00000000000..c8091a85566 --- /dev/null +++ b/Ladybird/AppKit/Application/Application.mm @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include + +#import + +#if !__has_feature(objc_arc) +# error "This project requires ARC" +#endif + +@interface Application () +@end + +@implementation Application + +- (void)terminate:(id)sender +{ + Core::EventLoop::current().quit(0); +} + +@end diff --git a/Ladybird/AppKit/Application/ApplicationDelegate.h b/Ladybird/AppKit/Application/ApplicationDelegate.h new file mode 100644 index 00000000000..48999b6cf45 --- /dev/null +++ b/Ladybird/AppKit/Application/ApplicationDelegate.h @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include + +#import + +@class Tab; +@class TabController; + +@interface ApplicationDelegate : NSObject + +- (nullable instancetype)init:(Optional)initial_url + withCookieJar:(Browser::CookieJar)cookie_jar + webdriverContentIPCPath:(StringView)webdriver_content_ipc_path; + +- (nonnull TabController*)createNewTab:(Optional const&)url; +- (nonnull TabController*)createNewTab:(Optional const&)url + activateTab:(Web::HTML::ActivateTab)activate_tab; + +- (void)removeTab:(nonnull TabController*)controller; + +- (Browser::CookieJar&)cookieJar; +- (Optional const&)webdriverContentIPCPath; + +@end diff --git a/Ladybird/AppKit/Application/ApplicationDelegate.mm b/Ladybird/AppKit/Application/ApplicationDelegate.mm new file mode 100644 index 00000000000..f0bd395d39d --- /dev/null +++ b/Ladybird/AppKit/Application/ApplicationDelegate.mm @@ -0,0 +1,313 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include + +#import +#import +#import +#import + +#if !__has_feature(objc_arc) +# error "This project requires ARC" +#endif + +@interface ApplicationDelegate () +{ + Optional m_initial_url; + URL m_new_tab_page_url; + + // This will always be populated, but we cannot have a non-default constructible instance variable. + Optional m_cookie_jar; + + Optional m_webdriver_content_ipc_path; +} + +@property (nonatomic, strong) NSMutableArray* managed_tabs; + +- (NSMenuItem*)createApplicationMenu; +- (NSMenuItem*)createFileMenu; +- (NSMenuItem*)createEditMenu; +- (NSMenuItem*)createViewMenu; +- (NSMenuItem*)createHistoryMenu; +- (NSMenuItem*)createDebugMenu; +- (NSMenuItem*)createWindowsMenu; +- (NSMenuItem*)createHelpMenu; + +@end + +@implementation ApplicationDelegate + +- (instancetype)init:(Optional)initial_url + withCookieJar:(Browser::CookieJar)cookie_jar + webdriverContentIPCPath:(StringView)webdriver_content_ipc_path +{ + if (self = [super init]) { + [NSApp setMainMenu:[[NSMenu alloc] init]]; + + [[NSApp mainMenu] addItem:[self createApplicationMenu]]; + [[NSApp mainMenu] addItem:[self createFileMenu]]; + [[NSApp mainMenu] addItem:[self createEditMenu]]; + [[NSApp mainMenu] addItem:[self createViewMenu]]; + [[NSApp mainMenu] addItem:[self createHistoryMenu]]; + [[NSApp mainMenu] addItem:[self createDebugMenu]]; + [[NSApp mainMenu] addItem:[self createWindowsMenu]]; + [[NSApp mainMenu] addItem:[self createHelpMenu]]; + + self.managed_tabs = [[NSMutableArray alloc] init]; + + m_initial_url = move(initial_url); + m_new_tab_page_url = Ladybird::rebase_url_on_serenity_resource_root(Browser::default_new_tab_url); + + m_cookie_jar = move(cookie_jar); + + if (!webdriver_content_ipc_path.is_empty()) { + m_webdriver_content_ipc_path = webdriver_content_ipc_path; + } + + // Reduce the tooltip delay, as the default delay feels quite long. + [[NSUserDefaults standardUserDefaults] setObject:@100 forKey:@"NSInitialToolTipDelay"]; + } + + return self; +} + +#pragma mark - Public methods + +- (TabController*)createNewTab:(Optional const&)url +{ + return [self createNewTab:url activateTab:Web::HTML::ActivateTab::Yes]; +} + +- (TabController*)createNewTab:(Optional const&)url + activateTab:(Web::HTML::ActivateTab)activate_tab +{ + // This handle must be acquired before creating the new tab. + auto* current_tab = (Tab*)[NSApp keyWindow]; + + auto* controller = [[TabController alloc] init:url.value_or(m_new_tab_page_url)]; + [controller showWindow:nil]; + + if (current_tab) { + [[current_tab tabGroup] addWindow:controller.window]; + + // FIXME: Can we create the tabbed window above without it becoming active in the first place? + if (activate_tab == Web::HTML::ActivateTab::No) { + [current_tab orderFront:nil]; + } + } + + [self.managed_tabs addObject:controller]; + return controller; +} + +- (void)removeTab:(TabController*)controller +{ + [self.managed_tabs removeObject:controller]; +} + +- (Browser::CookieJar&)cookieJar +{ + return *m_cookie_jar; +} + +- (Optional const&)webdriverContentIPCPath +{ + return m_webdriver_content_ipc_path; +} + +#pragma mark - Private methods + +- (void)closeCurrentTab:(id)sender +{ + auto* current_tab = (Tab*)[NSApp keyWindow]; + [current_tab close]; +} + +- (void)openLocation:(id)sender +{ + auto* current_tab = (Tab*)[NSApp keyWindow]; + auto* controller = (TabController*)[current_tab windowController]; + [controller focusLocationToolbarItem]; +} + +- (void)clearHistory:(id)sender +{ + for (TabController* controller in self.managed_tabs) { + [controller clearHistory]; + } +} + +- (void)dumpCookies:(id)sender +{ + m_cookie_jar->dump_cookies(); +} + +- (NSMenuItem*)createApplicationMenu +{ + auto* menu = [[NSMenuItem alloc] init]; + + auto* process_name = [[NSProcessInfo processInfo] processName]; + auto* submenu = [[NSMenu alloc] initWithTitle:process_name]; + + [submenu addItem:[[NSMenuItem alloc] initWithTitle:[NSString stringWithFormat:@"About %@", process_name] + action:@selector(orderFrontStandardAboutPanel:) + keyEquivalent:@""]]; + [submenu addItem:[NSMenuItem separatorItem]]; + + [submenu addItem:[[NSMenuItem alloc] initWithTitle:[NSString stringWithFormat:@"Hide %@", process_name] + action:@selector(hide:) + keyEquivalent:@"h"]]; + [submenu addItem:[NSMenuItem separatorItem]]; + + [submenu addItem:[[NSMenuItem alloc] initWithTitle:[NSString stringWithFormat:@"Quit %@", process_name] + action:@selector(terminate:) + keyEquivalent:@"q"]]; + + [menu setSubmenu:submenu]; + return menu; +} + +- (NSMenuItem*)createFileMenu +{ + auto* menu = [[NSMenuItem alloc] init]; + auto* submenu = [[NSMenu alloc] initWithTitle:@"File"]; + + [submenu addItem:[[NSMenuItem alloc] initWithTitle:@"New Tab" + action:@selector(createNewTab:) + keyEquivalent:@"t"]]; + [submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Close Tab" + action:@selector(closeCurrentTab:) + keyEquivalent:@"w"]]; + [submenu addItem:[NSMenuItem separatorItem]]; + + [submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Open Location" + action:@selector(openLocation:) + keyEquivalent:@"l"]]; + + [menu setSubmenu:submenu]; + return menu; +} + +- (NSMenuItem*)createEditMenu +{ + auto* menu = [[NSMenuItem alloc] init]; + auto* submenu = [[NSMenu alloc] initWithTitle:@"Edit"]; + + [submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Undo" + action:@selector(undo:) + keyEquivalent:@"z"]]; + [submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Redo" + action:@selector(redo:) + keyEquivalent:@"y"]]; + [submenu addItem:[NSMenuItem separatorItem]]; + + [submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Cut" + action:@selector(cut:) + keyEquivalent:@"x"]]; + [submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Copy" + action:@selector(copy:) + keyEquivalent:@"c"]]; + [submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Paste" + action:@selector(paste:) + keyEquivalent:@"v"]]; + [submenu addItem:[NSMenuItem separatorItem]]; + + [submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Select all" + action:@selector(selectAll:) + keyEquivalent:@"a"]]; + + [menu setSubmenu:submenu]; + return menu; +} + +- (NSMenuItem*)createViewMenu +{ + auto* menu = [[NSMenuItem alloc] init]; + auto* submenu = [[NSMenu alloc] initWithTitle:@"View"]; + + [menu setSubmenu:submenu]; + return menu; +} + +- (NSMenuItem*)createHistoryMenu +{ + auto* menu = [[NSMenuItem alloc] init]; + auto* submenu = [[NSMenu alloc] initWithTitle:@"History"]; + + [submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Reload Page" + action:@selector(reload:) + keyEquivalent:@"r"]]; + [submenu addItem:[NSMenuItem separatorItem]]; + + [submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Navigate Back" + action:@selector(navigateBack:) + keyEquivalent:@"["]]; + [submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Navigate Forward" + action:@selector(navigateForward:) + keyEquivalent:@"]"]]; + [submenu addItem:[NSMenuItem separatorItem]]; + + [submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Clear History" + action:@selector(clearHistory:) + keyEquivalent:@""]]; + + [menu setSubmenu:submenu]; + return menu; +} + +- (NSMenuItem*)createDebugMenu +{ + auto* menu = [[NSMenuItem alloc] init]; + auto* submenu = [[NSMenu alloc] initWithTitle:@"Debug"]; + + [submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Dump Cookies" + action:@selector(dumpCookies:) + keyEquivalent:@""]]; + + [menu setSubmenu:submenu]; + return menu; +} + +- (NSMenuItem*)createWindowsMenu +{ + auto* menu = [[NSMenuItem alloc] init]; + auto* submenu = [[NSMenu alloc] initWithTitle:@"Windows"]; + + [NSApp setWindowsMenu:submenu]; + + [menu setSubmenu:submenu]; + return menu; +} + +- (NSMenuItem*)createHelpMenu +{ + auto* menu = [[NSMenuItem alloc] init]; + auto* submenu = [[NSMenu alloc] initWithTitle:@"Help"]; + + [NSApp setHelpMenu:submenu]; + + [menu setSubmenu:submenu]; + return menu; +} + +#pragma mark - NSApplicationDelegate + +- (void)applicationDidFinishLaunching:(NSNotification*)notification +{ + [self createNewTab:m_initial_url]; +} + +- (void)applicationWillTerminate:(NSNotification*)notification +{ +} + +- (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication*)sender +{ + return YES; +} + +@end diff --git a/Ladybird/AppKit/Application/EventLoopImplementation.h b/Ladybird/AppKit/Application/EventLoopImplementation.h new file mode 100644 index 00000000000..2bb130efa8a --- /dev/null +++ b/Ladybird/AppKit/Application/EventLoopImplementation.h @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include + +namespace Ladybird { + +class CFEventLoopManager final : public Core::EventLoopManager { +public: + virtual NonnullOwnPtr make_implementation() override; + + virtual int register_timer(Core::EventReceiver&, int interval_milliseconds, bool should_reload, Core::TimerShouldFireWhenNotVisible) override; + virtual bool unregister_timer(int timer_id) override; + + virtual void register_notifier(Core::Notifier&) override; + virtual void unregister_notifier(Core::Notifier&) override; + + virtual void did_post_event() override; + + // FIXME: These APIs only exist for obscure use-cases inside SerenityOS. Try to get rid of them. + virtual int register_signal(int, Function) override { return 0; } + virtual void unregister_signal(int) override { } +}; + +class CFEventLoopImplementation final : public Core::EventLoopImplementation { +public: + // FIXME: This currently only manages the main NSApp event loop, as that is all we currently + // interact with. When we need multiple event loops, or an event loop that isn't the + // NSApp loop, we will need to create our own CFRunLoop. + static NonnullOwnPtr create(); + + virtual int exec() override; + virtual size_t pump(PumpMode) override; + virtual void quit(int) override; + virtual void wake() override; + virtual void post_event(Core::EventReceiver& receiver, NonnullOwnPtr&&) override; + + // FIXME: These APIs only exist for obscure use-cases inside SerenityOS. Try to get rid of them. + virtual void unquit() override { } + virtual bool was_exit_requested() const override { return false; } + virtual void notify_forked_and_in_child() override { } + +private: + CFEventLoopImplementation() = default; + + int m_exit_code { 0 }; +}; + +} diff --git a/Ladybird/AppKit/Application/EventLoopImplementation.mm b/Ladybird/AppKit/Application/EventLoopImplementation.mm new file mode 100644 index 00000000000..3ec1580b458 --- /dev/null +++ b/Ladybird/AppKit/Application/EventLoopImplementation.mm @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include + +#import +#import +#import + +namespace Ladybird { + +struct ThreadData { + static ThreadData& the() + { + static thread_local ThreadData s_thread_data; + return s_thread_data; + } + + IDAllocator timer_id_allocator; + HashMap timers; + HashMap notifiers; +}; + +static void post_application_event() +{ + auto* event = [NSEvent otherEventWithType:NSEventTypeApplicationDefined + location:NSMakePoint(0, 0) + modifierFlags:0 + timestamp:0 + windowNumber:0 + context:nil + subtype:0 + data1:0 + data2:0]; + + [NSApp postEvent:event atStart:NO]; +} + +NonnullOwnPtr CFEventLoopManager::make_implementation() +{ + return CFEventLoopImplementation::create(); +} + +int CFEventLoopManager::register_timer(Core::EventReceiver& receiver, int interval_milliseconds, bool should_reload, Core::TimerShouldFireWhenNotVisible should_fire_when_not_visible) +{ + auto& thread_data = ThreadData::the(); + + auto timer_id = thread_data.timer_id_allocator.allocate(); + auto weak_receiver = receiver.make_weak_ptr(); + + auto interval_seconds = static_cast(interval_milliseconds) / 1000.0; + auto first_fire_time = CFAbsoluteTimeGetCurrent() + interval_seconds; + + auto* timer = CFRunLoopTimerCreateWithHandler( + kCFAllocatorDefault, first_fire_time, should_reload ? interval_seconds : 0, 0, 0, + ^(CFRunLoopTimerRef) { + auto receiver = weak_receiver.strong_ref(); + if (!receiver) { + return; + } + + if (should_fire_when_not_visible == Core::TimerShouldFireWhenNotVisible::No) { + if (!receiver->is_visible_for_timer_purposes()) { + return; + } + } + + Core::TimerEvent event(timer_id); + receiver->dispatch_event(event); + }); + + CFRunLoopAddTimer(CFRunLoopGetCurrent(), timer, kCFRunLoopDefaultMode); + thread_data.timers.set(timer_id, timer); + + return timer_id; +} + +bool CFEventLoopManager::unregister_timer(int timer_id) +{ + auto& thread_data = ThreadData::the(); + thread_data.timer_id_allocator.deallocate(timer_id); + + if (auto timer = thread_data.timers.take(timer_id); timer.has_value()) { + CFRunLoopTimerInvalidate(*timer); + return true; + } + + return false; +} + +static void socket_notifier(CFSocketRef socket, CFSocketCallBackType notification_type, CFDataRef, void const*, void* info) +{ + auto& notifier = *reinterpret_cast(info); + + // This socket callback is not quite re-entrant. If Core::Notifier::dispatch_event blocks, e.g. + // to wait upon a Core::Promise, this socket will not receive any more notifications until that + // promise is resolved or rejected. So we mark this socket as able to receive more notifications + // before dispatching the event, which allows it to be triggered again. + CFSocketEnableCallBacks(socket, notification_type); + + Core::NotifierActivationEvent event(notifier.fd()); + notifier.dispatch_event(event); + + // This manual process of enabling the callbacks also seems to require waking the event loop, + // otherwise it hangs indefinitely in any ongoing pump(PumpMode::WaitForEvents) invocation. + post_application_event(); +} + +void CFEventLoopManager::register_notifier(Core::Notifier& notifier) +{ + auto notification_type = kCFSocketNoCallBack; + + switch (notifier.type()) { + case Core::Notifier::Type::Read: + notification_type = kCFSocketReadCallBack; + break; + case Core::Notifier::Type::Write: + notification_type = kCFSocketWriteCallBack; + break; + default: + TODO(); + break; + } + + CFSocketContext context { .info = ¬ifier }; + auto* socket = CFSocketCreateWithNative(kCFAllocatorDefault, notifier.fd(), notification_type, &socket_notifier, &context); + + CFOptionFlags sockopt = CFSocketGetSocketFlags(socket); + sockopt &= ~kCFSocketAutomaticallyReenableReadCallBack; + sockopt &= ~kCFSocketCloseOnInvalidate; + CFSocketSetSocketFlags(socket, sockopt); + + auto* source = CFSocketCreateRunLoopSource(kCFAllocatorDefault, socket, 0); + CFRunLoopAddSource(CFRunLoopGetCurrent(), source, kCFRunLoopDefaultMode); + + ThreadData::the().notifiers.set(¬ifier, source); +} + +void CFEventLoopManager::unregister_notifier(Core::Notifier& notifier) +{ + if (auto source = ThreadData::the().notifiers.take(¬ifier); source.has_value()) { + CFRunLoopRemoveSource(CFRunLoopGetCurrent(), *source, kCFRunLoopDefaultMode); + CFRelease(*source); + } +} + +void CFEventLoopManager::did_post_event() +{ + post_application_event(); +} + +NonnullOwnPtr CFEventLoopImplementation::create() +{ + return adopt_own(*new CFEventLoopImplementation); +} + +int CFEventLoopImplementation::exec() +{ + [NSApp run]; + return m_exit_code; +} + +size_t CFEventLoopImplementation::pump(PumpMode mode) +{ + auto* wait_until = mode == PumpMode::WaitForEvents ? [NSDate distantFuture] : [NSDate distantPast]; + + auto* event = [NSApp nextEventMatchingMask:NSEventMaskAny + untilDate:wait_until + inMode:NSDefaultRunLoopMode + dequeue:YES]; + + while (event) { + if (event.type == NSEventTypeApplicationDefined) { + m_thread_event_queue.process(); + } else { + [NSApp sendEvent:event]; + } + + event = [NSApp nextEventMatchingMask:NSEventMaskAny + untilDate:nil + inMode:NSDefaultRunLoopMode + dequeue:YES]; + } + + return 0; +} + +void CFEventLoopImplementation::quit(int exit_code) +{ + m_exit_code = exit_code; + [NSApp stop:nil]; +} + +void CFEventLoopImplementation::wake() +{ + CFRunLoopWakeUp(CFRunLoopGetCurrent()); +} + +void CFEventLoopImplementation::post_event(Core::EventReceiver& receiver, NonnullOwnPtr&& event) +{ + m_thread_event_queue.post_event(receiver, move(event)); + + if (&m_thread_event_queue != &Core::ThreadEventQueue::current()) + wake(); +} + +} diff --git a/Ladybird/AppKit/System/Carbon.h b/Ladybird/AppKit/System/Carbon.h new file mode 100644 index 00000000000..5ed116ab7b3 --- /dev/null +++ b/Ladybird/AppKit/System/Carbon.h @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "Detail/Header.h" + +#import + +#include "Detail/Footer.h" diff --git a/Ladybird/AppKit/System/Cocoa.h b/Ladybird/AppKit/System/Cocoa.h new file mode 100644 index 00000000000..5830be83cfb --- /dev/null +++ b/Ladybird/AppKit/System/Cocoa.h @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "Detail/Header.h" + +#import + +#include "Detail/Footer.h" diff --git a/Ladybird/AppKit/System/CoreFoundation.h b/Ladybird/AppKit/System/CoreFoundation.h new file mode 100644 index 00000000000..d3affe91af4 --- /dev/null +++ b/Ladybird/AppKit/System/CoreFoundation.h @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include "Detail/Header.h" + +#import + +#include "Detail/Footer.h" diff --git a/Ladybird/AppKit/System/Detail/Footer.h b/Ladybird/AppKit/System/Detail/Footer.h new file mode 100644 index 00000000000..e13d175366e --- /dev/null +++ b/Ladybird/AppKit/System/Detail/Footer.h @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#if !defined(MACOS_UGLY_WORKAROUND) +# error Footer.h was included before Header.h. +#endif + +#undef Duration +#undef FixedPoint + +#undef MACOS_UGLY_WORKAROUND diff --git a/Ladybird/AppKit/System/Detail/Header.h b/Ladybird/AppKit/System/Detail/Header.h new file mode 100644 index 00000000000..8401e8a7244 --- /dev/null +++ b/Ladybird/AppKit/System/Detail/Header.h @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#if defined(MACOS_UGLY_WORKAROUND) +# error Header.h was included again before Footer.h. +#endif + +#define Duration DurationMacOS +#define FixedPoint FixedPointMacOS + +#define MACOS_UGLY_WORKAROUND diff --git a/Ladybird/AppKit/UI/Event.h b/Ladybird/AppKit/UI/Event.h new file mode 100644 index 00000000000..899b6009b12 --- /dev/null +++ b/Ladybird/AppKit/UI/Event.h @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +// FIXME: These should not be included outside of Serenity. +#include +#include + +#import + +namespace Ladybird { + +struct MouseEvent { + Gfx::IntPoint position {}; + GUI::MouseButton button { GUI::MouseButton::Primary }; + KeyModifier modifiers { KeyModifier::Mod_None }; +}; +MouseEvent ns_event_to_mouse_event(NSEvent*, NSView*, GUI::MouseButton); + +NSEvent* create_context_menu_mouse_event(NSView*, Gfx::IntPoint); +NSEvent* create_context_menu_mouse_event(NSView*, NSPoint); + +struct KeyEvent { + KeyCode key_code { KeyCode::Key_Invalid }; + KeyModifier modifiers { KeyModifier::Mod_None }; + u32 code_point { 0 }; +}; +KeyEvent ns_event_to_key_event(NSEvent*); + +} diff --git a/Ladybird/AppKit/UI/Event.mm b/Ladybird/AppKit/UI/Event.mm new file mode 100644 index 00000000000..a6aa23a18dc --- /dev/null +++ b/Ladybird/AppKit/UI/Event.mm @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include + +#import +#import +#import + +namespace Ladybird { + +static KeyModifier ns_modifiers_to_key_modifiers(NSEventModifierFlags modifier_flags, Optional button = {}) +{ + unsigned modifiers = KeyModifier::Mod_None; + + if ((modifier_flags & NSEventModifierFlagShift) != 0) { + modifiers |= KeyModifier::Mod_Shift; + } + if ((modifier_flags & NSEventModifierFlagControl) != 0) { + if (button == GUI::MouseButton::Primary) { + *button = GUI::MouseButton::Secondary; + } else { + modifiers |= KeyModifier::Mod_Ctrl; + } + } + if ((modifier_flags & NSEventModifierFlagOption) != 0) { + modifiers |= KeyModifier::Mod_Alt; + } + if ((modifier_flags & NSEventModifierFlagCommand) != 0) { + modifiers |= KeyModifier::Mod_Super; + } + + return static_cast(modifiers); +} + +MouseEvent ns_event_to_mouse_event(NSEvent* event, NSView* view, GUI::MouseButton button) +{ + auto position = [view convertPoint:event.locationInWindow fromView:nil]; + auto modifiers = ns_modifiers_to_key_modifiers(event.modifierFlags, button); + + return { ns_point_to_gfx_point(position), button, modifiers }; +} + +NSEvent* create_context_menu_mouse_event(NSView* view, Gfx::IntPoint position) +{ + return create_context_menu_mouse_event(view, gfx_point_to_ns_point(position)); +} + +NSEvent* create_context_menu_mouse_event(NSView* view, NSPoint position) +{ + return [NSEvent mouseEventWithType:NSEventTypeRightMouseUp + location:[view convertPoint:position fromView:nil] + modifierFlags:0 + timestamp:0 + windowNumber:[[view window] windowNumber] + context:nil + eventNumber:1 + clickCount:1 + pressure:1.0]; +} + +static KeyCode ns_key_code_to_key_code(unsigned short key_code, KeyModifier& modifiers) +{ + auto augment_modifiers_and_return = [&](auto key, auto modifier) { + modifiers = static_cast(static_cast(modifiers) | modifier); + return key; + }; + + // clang-format off + switch (key_code) { + case kVK_ANSI_0: return KeyCode::Key_0; + case kVK_ANSI_1: return KeyCode::Key_1; + case kVK_ANSI_2: return KeyCode::Key_2; + case kVK_ANSI_3: return KeyCode::Key_3; + case kVK_ANSI_4: return KeyCode::Key_4; + case kVK_ANSI_5: return KeyCode::Key_5; + case kVK_ANSI_6: return KeyCode::Key_6; + case kVK_ANSI_7: return KeyCode::Key_7; + case kVK_ANSI_8: return KeyCode::Key_8; + case kVK_ANSI_9: return KeyCode::Key_9; + case kVK_ANSI_A: return KeyCode::Key_A; + case kVK_ANSI_B: return KeyCode::Key_B; + case kVK_ANSI_C: return KeyCode::Key_C; + case kVK_ANSI_D: return KeyCode::Key_D; + case kVK_ANSI_E: return KeyCode::Key_E; + case kVK_ANSI_F: return KeyCode::Key_F; + case kVK_ANSI_G: return KeyCode::Key_G; + case kVK_ANSI_H: return KeyCode::Key_H; + case kVK_ANSI_I: return KeyCode::Key_I; + case kVK_ANSI_J: return KeyCode::Key_J; + case kVK_ANSI_K: return KeyCode::Key_K; + case kVK_ANSI_L: return KeyCode::Key_L; + case kVK_ANSI_M: return KeyCode::Key_M; + case kVK_ANSI_N: return KeyCode::Key_N; + case kVK_ANSI_O: return KeyCode::Key_O; + case kVK_ANSI_P: return KeyCode::Key_P; + case kVK_ANSI_Q: return KeyCode::Key_Q; + case kVK_ANSI_R: return KeyCode::Key_R; + case kVK_ANSI_S: return KeyCode::Key_S; + case kVK_ANSI_T: return KeyCode::Key_T; + case kVK_ANSI_U: return KeyCode::Key_U; + case kVK_ANSI_V: return KeyCode::Key_V; + case kVK_ANSI_W: return KeyCode::Key_W; + case kVK_ANSI_X: return KeyCode::Key_X; + case kVK_ANSI_Y: return KeyCode::Key_Y; + case kVK_ANSI_Z: return KeyCode::Key_Z; + case kVK_ANSI_Backslash: return KeyCode::Key_Backslash; + case kVK_ANSI_Comma: return KeyCode::Key_Comma; + case kVK_ANSI_Equal: return KeyCode::Key_Equal; + case kVK_ANSI_Grave: return KeyCode::Key_Backtick; + case kVK_ANSI_Keypad0: return augment_modifiers_and_return(KeyCode::Key_0, KeyModifier::Mod_Keypad); + case kVK_ANSI_Keypad1: return augment_modifiers_and_return(KeyCode::Key_1, KeyModifier::Mod_Keypad); + case kVK_ANSI_Keypad2: return augment_modifiers_and_return(KeyCode::Key_2, KeyModifier::Mod_Keypad); + case kVK_ANSI_Keypad3: return augment_modifiers_and_return(KeyCode::Key_3, KeyModifier::Mod_Keypad); + case kVK_ANSI_Keypad4: return augment_modifiers_and_return(KeyCode::Key_4, KeyModifier::Mod_Keypad); + case kVK_ANSI_Keypad5: return augment_modifiers_and_return(KeyCode::Key_5, KeyModifier::Mod_Keypad); + case kVK_ANSI_Keypad6: return augment_modifiers_and_return(KeyCode::Key_6, KeyModifier::Mod_Keypad); + case kVK_ANSI_Keypad7: return augment_modifiers_and_return(KeyCode::Key_7, KeyModifier::Mod_Keypad); + case kVK_ANSI_Keypad8: return augment_modifiers_and_return(KeyCode::Key_8, KeyModifier::Mod_Keypad); + case kVK_ANSI_Keypad9: return augment_modifiers_and_return(KeyCode::Key_9, KeyModifier::Mod_Keypad); + case kVK_ANSI_KeypadClear: return augment_modifiers_and_return(KeyCode::Key_Delete, KeyModifier::Mod_Keypad); + case kVK_ANSI_KeypadDecimal: return augment_modifiers_and_return(KeyCode::Key_Period, KeyModifier::Mod_Keypad); + case kVK_ANSI_KeypadDivide: return augment_modifiers_and_return(KeyCode::Key_Slash, KeyModifier::Mod_Keypad); + case kVK_ANSI_KeypadEnter: return augment_modifiers_and_return(KeyCode::Key_Return, KeyModifier::Mod_Keypad); + case kVK_ANSI_KeypadEquals: return augment_modifiers_and_return(KeyCode::Key_Equal, KeyModifier::Mod_Keypad); + case kVK_ANSI_KeypadMinus: return augment_modifiers_and_return(KeyCode::Key_Minus, KeyModifier::Mod_Keypad); + case kVK_ANSI_KeypadMultiply: return augment_modifiers_and_return(KeyCode::Key_Asterisk, KeyModifier::Mod_Keypad); + case kVK_ANSI_KeypadPlus: return augment_modifiers_and_return(KeyCode::Key_Plus, KeyModifier::Mod_Keypad); + case kVK_ANSI_LeftBracket: return KeyCode::Key_LeftBracket; + case kVK_ANSI_Minus: return KeyCode::Key_Minus; + case kVK_ANSI_Period: return KeyCode::Key_Period; + case kVK_ANSI_Quote: return KeyCode::Key_Apostrophe; + case kVK_ANSI_RightBracket: return KeyCode::Key_RightBracket; + case kVK_ANSI_Semicolon: return KeyCode::Key_Semicolon; + case kVK_ANSI_Slash: return KeyCode::Key_Slash; + case kVK_CapsLock: return KeyCode::Key_CapsLock; + case kVK_Command: return KeyCode::Key_Super; + case kVK_Control: return KeyCode::Key_Control; + case kVK_Delete: return KeyCode::Key_Backspace; + case kVK_DownArrow: return KeyCode::Key_Down; + case kVK_End: return KeyCode::Key_End; + case kVK_Escape: return KeyCode::Key_Escape; + case kVK_F1: return KeyCode::Key_F1; + case kVK_F2: return KeyCode::Key_F2; + case kVK_F3: return KeyCode::Key_F3; + case kVK_F4: return KeyCode::Key_F4; + case kVK_F5: return KeyCode::Key_F5; + case kVK_F6: return KeyCode::Key_F6; + case kVK_F7: return KeyCode::Key_F7; + case kVK_F8: return KeyCode::Key_F8; + case kVK_F9: return KeyCode::Key_F9; + case kVK_F10: return KeyCode::Key_F10; + case kVK_F11: return KeyCode::Key_F11; + case kVK_F12: return KeyCode::Key_F12; + case kVK_ForwardDelete: return KeyCode::Key_Delete; + case kVK_Home: return KeyCode::Key_Home; + case kVK_LeftArrow: return KeyCode::Key_Left; + case kVK_Option: return KeyCode::Key_Alt; + case kVK_PageDown: return KeyCode::Key_PageDown; + case kVK_PageUp: return KeyCode::Key_PageUp; + case kVK_Return: return KeyCode::Key_Return; + case kVK_RightArrow: return KeyCode::Key_Right; + case kVK_RightCommand: return KeyCode::Key_Super; // FIXME: We do not distinguish left-vs-right. + case kVK_RightControl: return KeyCode::Key_Control; // FIXME: We do not distinguish left-vs-right. + case kVK_RightOption: return KeyCode::Key_Alt; // FIXME: We do not distinguish left-vs-right. + case kVK_RightShift: return KeyCode::Key_RightShift; + case kVK_Shift: return KeyCode::Key_Shift; + case kVK_Space: return KeyCode::Key_Space; + case kVK_Tab: return KeyCode::Key_Tab; + case kVK_UpArrow: return KeyCode::Key_Up; + default: break; + } + // clang-format on + + return KeyCode::Key_Invalid; +} + +KeyEvent ns_event_to_key_event(NSEvent* event) +{ + auto modifiers = ns_modifiers_to_key_modifiers(event.modifierFlags); + auto key_code = ns_key_code_to_key_code(event.keyCode, modifiers); + + auto const* utf8 = [event.characters UTF8String]; + Utf8View utf8_view { StringView { utf8, strlen(utf8) } }; + + // FIXME: WebContent should really support multi-code point key events. + auto code_point = utf8_view.is_empty() ? 0u : *utf8_view.begin(); + + return { key_code, modifiers, code_point }; +} + +} diff --git a/Ladybird/AppKit/UI/LadybirdWebView.h b/Ladybird/AppKit/UI/LadybirdWebView.h new file mode 100644 index 00000000000..7a6722a7284 --- /dev/null +++ b/Ladybird/AppKit/UI/LadybirdWebView.h @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include + +#import + +@interface LadybirdWebView : NSClipView + +- (void)load:(URL const&)url; + +- (void)handleResize; +- (void)handleScroll; +- (void)handleVisibility:(BOOL)is_visible; + +@end diff --git a/Ladybird/AppKit/UI/LadybirdWebView.mm b/Ladybird/AppKit/UI/LadybirdWebView.mm new file mode 100644 index 00000000000..02673cc4b3c --- /dev/null +++ b/Ladybird/AppKit/UI/LadybirdWebView.mm @@ -0,0 +1,949 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include +#include + +#import +#import +#import +#import +#import +#import + +#if !__has_feature(objc_arc) +# error "This project requires ARC" +#endif + +static constexpr NSInteger CONTEXT_MENU_PLAY_PAUSE_TAG = 1; +static constexpr NSInteger CONTEXT_MENU_MUTE_UNMUTE_TAG = 2; +static constexpr NSInteger CONTEXT_MENU_CONTROLS_TAG = 3; +static constexpr NSInteger CONTEXT_MENU_LOOP_TAG = 4; + +// Calls to [NSCursor hide] and [NSCursor unhide] must be balanced. We use this struct to ensure +// we only call [NSCursor hide] once and to ensure that we do call [NSCursor unhide]. +// https://developer.apple.com/documentation/appkit/nscursor#1651301 +struct HideCursor { + HideCursor() + { + [NSCursor hide]; + } + + ~HideCursor() + { + [NSCursor unhide]; + } +}; + +@interface LadybirdWebView () +{ + OwnPtr m_web_view_bridge; + + URL m_context_menu_url; + Gfx::ShareableBitmap m_context_menu_bitmap; + + Optional m_hidden_cursor; +} + +@property (nonatomic, strong) NSMenu* page_context_menu; +@property (nonatomic, strong) NSMenu* link_context_menu; +@property (nonatomic, strong) NSMenu* image_context_menu; +@property (nonatomic, strong) NSMenu* video_context_menu; +@property (nonatomic, strong) NSTextField* status_label; +@property (nonatomic, strong) NSAlert* dialog; + +@end + +@implementation LadybirdWebView + +@synthesize page_context_menu = _page_context_menu; +@synthesize link_context_menu = _link_context_menu; +@synthesize image_context_menu = _image_context_menu; +@synthesize video_context_menu = _video_context_menu; +@synthesize status_label = _status_label; + +- (instancetype)init +{ + if (self = [super init]) { + auto* delegate = (ApplicationDelegate*)[NSApp delegate]; + auto* screens = [NSScreen screens]; + + Vector screen_rects; + screen_rects.ensure_capacity([screens count]); + + for (id screen in screens) { + auto screen_rect = Ladybird::ns_rect_to_gfx_rect([screen frame]); + screen_rects.unchecked_append(screen_rect); + } + + auto device_pixel_ratio = [[NSScreen mainScreen] backingScaleFactor]; + + m_web_view_bridge = MUST(Ladybird::WebViewBridge::create(move(screen_rects), device_pixel_ratio, [delegate webdriverContentIPCPath])); + [self setWebViewCallbacks]; + + auto* area = [[NSTrackingArea alloc] initWithRect:[self bounds] + options:NSTrackingActiveInKeyWindow | NSTrackingInVisibleRect | NSTrackingMouseMoved + owner:self + userInfo:nil]; + [self addTrackingArea:area]; + } + + return self; +} + +#pragma mark - Public methods + +- (void)load:(URL const&)url +{ + m_web_view_bridge->load(url); +} + +- (void)handleResize +{ + [self updateViewportRect:Ladybird::WebViewBridge::ForResize::Yes]; + [self updateStatusLabelPosition]; +} + +- (void)handleScroll +{ + [self updateViewportRect:Ladybird::WebViewBridge::ForResize::No]; + [self updateStatusLabelPosition]; +} + +- (void)handleVisibility:(BOOL)is_visible +{ + m_web_view_bridge->set_system_visibility_state(is_visible); +} + +#pragma mark - Private methods + +- (void)updateViewportRect:(Ladybird::WebViewBridge::ForResize)for_resize +{ + auto content_rect = [self frame]; + auto document_rect = [[self documentView] frame]; + auto device_pixel_ratio = m_web_view_bridge->device_pixel_ratio(); + + auto position = [&](auto content_size, auto document_size, auto scroll) { + return max(0, (document_size - content_size) * device_pixel_ratio * scroll); + }; + + auto horizontal_scroll = [[[self scrollView] horizontalScroller] floatValue]; + auto vertical_scroll = [[[self scrollView] verticalScroller] floatValue]; + + auto ns_viewport_rect = NSMakeRect( + position(content_rect.size.width, document_rect.size.width, horizontal_scroll), + position(content_rect.size.height, document_rect.size.height, vertical_scroll), + content_rect.size.width, + content_rect.size.height); + + auto viewport_rect = Ladybird::ns_rect_to_gfx_rect(ns_viewport_rect); + m_web_view_bridge->set_viewport_rect(viewport_rect, for_resize); +} + +- (void)updateStatusLabelPosition +{ + static constexpr CGFloat LABEL_INSET = 10; + + if (_status_label == nil || [[self status_label] isHidden]) { + return; + } + + auto visible_rect = [self visibleRect]; + auto status_label_rect = [self.status_label frame]; + + auto position = NSMakePoint(LABEL_INSET, visible_rect.origin.y + visible_rect.size.height - status_label_rect.size.height - LABEL_INSET); + [self.status_label setFrameOrigin:position]; +} + +- (void)setWebViewCallbacks +{ + m_web_view_bridge->on_layout = [self](auto content_size) { + auto ns_content_size = Ladybird::gfx_size_to_ns_size(content_size); + [[self documentView] setFrameSize:ns_content_size]; + }; + + m_web_view_bridge->on_ready_to_paint = [self]() { + [self setNeedsDisplay:YES]; + }; + + m_web_view_bridge->on_new_tab = [](auto activate_tab) { + auto* delegate = (ApplicationDelegate*)[NSApp delegate]; + auto* controller = [delegate createNewTab:"about:blank"sv activateTab:activate_tab]; + + auto* tab = (Tab*)[controller window]; + auto* web_view = [tab web_view]; + + return web_view->m_web_view_bridge->handle(); + }; + + m_web_view_bridge->on_activate_tab = [self]() { + [[self tab] orderFront:nil]; + }; + + m_web_view_bridge->on_close = [self]() { + [[self tab] close]; + }; + + m_web_view_bridge->on_load_start = [self](auto const& url, bool is_redirect) { + [[self tabController] onLoadStart:url isRedirect:is_redirect]; + [[self tab] onLoadStart:url]; + + if (_status_label != nil) { + [self.status_label setHidden:YES]; + } + }; + + m_web_view_bridge->on_title_change = [self](auto const& title) { + [[self tabController] onTitleChange:title]; + + auto* ns_title = Ladybird::string_to_ns_string(title); + [[self tab] onTitleChange:ns_title]; + }; + + m_web_view_bridge->on_favicon_change = [self](auto const& bitmap) { + static constexpr size_t FAVICON_SIZE = 16; + + auto png = Gfx::PNGWriter::encode(bitmap); + if (png.is_error()) { + return; + } + + auto* data = [NSData dataWithBytes:png.value().data() length:png.value().size()]; + + auto* favicon = [[NSImage alloc] initWithData:data]; + [favicon setResizingMode:NSImageResizingModeStretch]; + [favicon setSize:NSMakeSize(FAVICON_SIZE, FAVICON_SIZE)]; + + [[self tab] onFaviconChange:favicon]; + }; + + m_web_view_bridge->on_scroll = [self](auto position) { + [self scrollToPoint:Ladybird::gfx_point_to_ns_point(position)]; + [[self scrollView] reflectScrolledClipView:self]; + [self updateViewportRect:Ladybird::WebViewBridge::ForResize::No]; + }; + + m_web_view_bridge->on_cursor_change = [self](auto cursor) { + if (cursor == Gfx::StandardCursor::Hidden) { + if (!m_hidden_cursor.has_value()) { + m_hidden_cursor.emplace(); + } + + return; + } + + m_hidden_cursor.clear(); + + switch (cursor) { + case Gfx::StandardCursor::Arrow: + [[NSCursor arrowCursor] set]; + break; + case Gfx::StandardCursor::Crosshair: + [[NSCursor crosshairCursor] set]; + break; + case Gfx::StandardCursor::IBeam: + [[NSCursor IBeamCursor] set]; + break; + case Gfx::StandardCursor::ResizeHorizontal: + [[NSCursor resizeLeftRightCursor] set]; + break; + case Gfx::StandardCursor::ResizeVertical: + [[NSCursor resizeUpDownCursor] set]; + break; + case Gfx::StandardCursor::ResizeDiagonalTLBR: + // FIXME: AppKit does not have a corresponding cursor, so we should make one. + [[NSCursor arrowCursor] set]; + break; + case Gfx::StandardCursor::ResizeDiagonalBLTR: + // FIXME: AppKit does not have a corresponding cursor, so we should make one. + [[NSCursor arrowCursor] set]; + break; + case Gfx::StandardCursor::ResizeColumn: + // FIXME: AppKit does not have a corresponding cursor, so we should make one. + [[NSCursor arrowCursor] set]; + break; + case Gfx::StandardCursor::ResizeRow: + // FIXME: AppKit does not have a corresponding cursor, so we should make one. + [[NSCursor arrowCursor] set]; + break; + case Gfx::StandardCursor::Hand: + [[NSCursor pointingHandCursor] set]; + break; + case Gfx::StandardCursor::Help: + // FIXME: AppKit does not have a corresponding cursor, so we should make one. + [[NSCursor arrowCursor] set]; + break; + case Gfx::StandardCursor::Drag: + [[NSCursor closedHandCursor] set]; + break; + case Gfx::StandardCursor::DragCopy: + [[NSCursor dragCopyCursor] set]; + break; + case Gfx::StandardCursor::Move: + // FIXME: AppKit does not have a corresponding cursor, so we should make one. + [[NSCursor dragCopyCursor] set]; + break; + case Gfx::StandardCursor::Wait: + // FIXME: AppKit does not have a corresponding cursor, so we should make one. + [[NSCursor arrowCursor] set]; + break; + case Gfx::StandardCursor::Disallowed: + // FIXME: AppKit does not have a corresponding cursor, so we should make one. + [[NSCursor arrowCursor] set]; + break; + case Gfx::StandardCursor::Eyedropper: + // FIXME: AppKit does not have a corresponding cursor, so we should make one. + [[NSCursor arrowCursor] set]; + break; + case Gfx::StandardCursor::Zoom: + // FIXME: AppKit does not have a corresponding cursor, so we should make one. + [[NSCursor arrowCursor] set]; + break; + default: + break; + } + }; + + m_web_view_bridge->on_navigate_back = [self]() { + [[self tabController] navigateBack:nil]; + }; + + m_web_view_bridge->on_navigate_forward = [self]() { + [[self tabController] navigateForward:nil]; + }; + + m_web_view_bridge->on_refresh = [self]() { + [[self tabController] reload:nil]; + }; + + m_web_view_bridge->on_tooltip_entered = [self](auto const& tooltip) { + self.toolTip = Ladybird::string_to_ns_string(tooltip); + }; + + m_web_view_bridge->on_tooltip_left = [self]() { + self.toolTip = nil; + }; + + m_web_view_bridge->on_link_hover = [self](auto const& url) { + auto* url_string = Ladybird::string_to_ns_string(url.serialize()); + [self.status_label setStringValue:url_string]; + [self.status_label sizeToFit]; + [self.status_label setHidden:NO]; + + [self updateStatusLabelPosition]; + }; + + m_web_view_bridge->on_link_unhover = [self]() { + [self.status_label setHidden:YES]; + }; + + m_web_view_bridge->on_link_click = [self](auto const& url, auto const& target, unsigned modifiers) { + auto* delegate = (ApplicationDelegate*)[NSApp delegate]; + + if (modifiers == Mod_Super) { + [delegate createNewTab:url activateTab:Web::HTML::ActivateTab::No]; + } else if (target == "_blank"sv) { + [delegate createNewTab:url activateTab:Web::HTML::ActivateTab::Yes]; + } else { + [[self tabController] load:url]; + } + }; + + m_web_view_bridge->on_link_middle_click = [](auto url, auto, unsigned) { + auto* delegate = (ApplicationDelegate*)[NSApp delegate]; + [delegate createNewTab:url activateTab:Web::HTML::ActivateTab::No]; + }; + + m_web_view_bridge->on_context_menu_request = [self](auto position) { + auto* event = Ladybird::create_context_menu_mouse_event(self, position); + [NSMenu popUpContextMenu:self.page_context_menu withEvent:event forView:self]; + }; + + m_web_view_bridge->on_link_context_menu_request = [self](auto const& url, auto position) { + TemporaryChange change_url { m_context_menu_url, url }; + + auto* event = Ladybird::create_context_menu_mouse_event(self, position); + [NSMenu popUpContextMenu:self.link_context_menu withEvent:event forView:self]; + }; + + m_web_view_bridge->on_image_context_menu_request = [self](auto const& url, auto position, auto const& bitmap) { + TemporaryChange change_url { m_context_menu_url, url }; + TemporaryChange change_bitmap { m_context_menu_bitmap, bitmap }; + + auto* event = Ladybird::create_context_menu_mouse_event(self, position); + [NSMenu popUpContextMenu:self.image_context_menu withEvent:event forView:self]; + }; + + m_web_view_bridge->on_media_context_menu_request = [self](auto position, auto const& menu) { + if (!menu.is_video) { + NSLog(@"TODO: Implement audio context menu once audio elements are supported"); + return; + } + + TemporaryChange change_url { m_context_menu_url, menu.media_url }; + + auto* play_pause_menu_item = [self.video_context_menu itemWithTag:CONTEXT_MENU_PLAY_PAUSE_TAG]; + auto* mute_unmute_menu_item = [self.video_context_menu itemWithTag:CONTEXT_MENU_MUTE_UNMUTE_TAG]; + auto* controls_menu_item = [self.video_context_menu itemWithTag:CONTEXT_MENU_CONTROLS_TAG]; + auto* loop_menu_item = [self.video_context_menu itemWithTag:CONTEXT_MENU_LOOP_TAG]; + + if (menu.is_playing) { + [play_pause_menu_item setTitle:@"Pause"]; + } else { + [play_pause_menu_item setTitle:@"Play"]; + } + + if (menu.is_muted) { + [mute_unmute_menu_item setTitle:@"Unmute"]; + } else { + [mute_unmute_menu_item setTitle:@"Mute"]; + } + + auto controls_state = menu.has_user_agent_controls ? NSControlStateValueOn : NSControlStateValueOff; + [controls_menu_item setState:controls_state]; + + auto loop_state = menu.is_looping ? NSControlStateValueOn : NSControlStateValueOff; + [loop_menu_item setState:loop_state]; + + auto* event = Ladybird::create_context_menu_mouse_event(self, position); + [NSMenu popUpContextMenu:self.video_context_menu withEvent:event forView:self]; + }; + + m_web_view_bridge->on_alert = [self](auto const& message) { + auto* ns_message = Ladybird::string_to_ns_string(message); + + self.dialog = [[NSAlert alloc] init]; + [self.dialog setMessageText:ns_message]; + + [self.dialog beginSheetModalForWindow:[self window] + completionHandler:^(NSModalResponse) { + m_web_view_bridge->alert_closed(); + self.dialog = nil; + }]; + }; + + m_web_view_bridge->on_confirm = [self](auto const& message) { + auto* ns_message = Ladybird::string_to_ns_string(message); + + self.dialog = [[NSAlert alloc] init]; + [[self.dialog addButtonWithTitle:@"OK"] setTag:NSModalResponseOK]; + [[self.dialog addButtonWithTitle:@"Cancel"] setTag:NSModalResponseCancel]; + [self.dialog setMessageText:ns_message]; + + [self.dialog beginSheetModalForWindow:[self window] + completionHandler:^(NSModalResponse response) { + m_web_view_bridge->confirm_closed(response == NSModalResponseOK); + self.dialog = nil; + }]; + }; + + m_web_view_bridge->on_prompt = [self](auto const& message, auto const& default_) { + auto* ns_message = Ladybird::string_to_ns_string(message); + auto* ns_default = Ladybird::string_to_ns_string(default_); + + auto* input = [[NSTextField alloc] initWithFrame:NSMakeRect(0, 0, 200, 24)]; + [input setStringValue:ns_default]; + + self.dialog = [[NSAlert alloc] init]; + [[self.dialog addButtonWithTitle:@"OK"] setTag:NSModalResponseOK]; + [[self.dialog addButtonWithTitle:@"Cancel"] setTag:NSModalResponseCancel]; + [self.dialog setMessageText:ns_message]; + [self.dialog setAccessoryView:input]; + + self.dialog.window.initialFirstResponder = input; + + [self.dialog beginSheetModalForWindow:[self window] + completionHandler:^(NSModalResponse response) { + Optional text; + + if (response == NSModalResponseOK) { + text = Ladybird::ns_string_to_string([input stringValue]); + } + + m_web_view_bridge->prompt_closed(move(text)); + self.dialog = nil; + }]; + }; + + m_web_view_bridge->on_prompt_text_changed = [self](auto const& message) { + if (self.dialog == nil || [self.dialog accessoryView] == nil) { + return; + } + + auto* ns_message = Ladybird::string_to_ns_string(message); + + auto* input = (NSTextField*)[self.dialog accessoryView]; + [input setStringValue:ns_message]; + }; + + m_web_view_bridge->on_dialog_accepted = [self]() { + if (self.dialog == nil) { + return; + } + + [[self window] endSheet:[[self dialog] window] + returnCode:NSModalResponseOK]; + }; + + m_web_view_bridge->on_dialog_dismissed = [self]() { + if (self.dialog == nil) { + return; + } + + [[self window] endSheet:[[self dialog] window] + returnCode:NSModalResponseCancel]; + }; + + m_web_view_bridge->on_get_all_cookies = [](auto const& url) { + auto* delegate = (ApplicationDelegate*)[NSApp delegate]; + return [delegate cookieJar].get_all_cookies(url); + }; + + m_web_view_bridge->on_get_named_cookie = [](auto const& url, auto const& name) { + auto* delegate = (ApplicationDelegate*)[NSApp delegate]; + return [delegate cookieJar].get_named_cookie(url, name); + }; + + m_web_view_bridge->on_get_cookie = [](auto const& url, auto source) -> DeprecatedString { + auto* delegate = (ApplicationDelegate*)[NSApp delegate]; + return [delegate cookieJar].get_cookie(url, source); + }; + + m_web_view_bridge->on_set_cookie = [](auto const& url, auto const& cookie, auto source) { + auto* delegate = (ApplicationDelegate*)[NSApp delegate]; + [delegate cookieJar].set_cookie(url, cookie, source); + }; + + m_web_view_bridge->on_update_cookie = [](auto const& cookie) { + auto* delegate = (ApplicationDelegate*)[NSApp delegate]; + [delegate cookieJar].update_cookie(cookie); + }; + + m_web_view_bridge->on_restore_window = [self]() { + [[self window] setIsMiniaturized:NO]; + [[self window] orderFront:nil]; + }; + + m_web_view_bridge->on_reposition_window = [self](auto const& position) { + auto frame = [[self window] frame]; + frame.origin = Ladybird::gfx_point_to_ns_point(position); + [[self window] setFrame:frame display:YES]; + + return Ladybird::ns_point_to_gfx_point([[self window] frame].origin); + }; + + m_web_view_bridge->on_resize_window = [self](auto const& size) { + auto frame = [[self window] frame]; + frame.size = Ladybird::gfx_size_to_ns_size(size); + [[self window] setFrame:frame display:YES]; + + return Ladybird::ns_size_to_gfx_size([[self window] frame].size); + }; + + m_web_view_bridge->on_maximize_window = [self]() { + auto frame = [[NSScreen mainScreen] frame]; + [[self window] setFrame:frame display:YES]; + + return Ladybird::ns_rect_to_gfx_rect([[self window] frame]); + }; + + m_web_view_bridge->on_minimize_window = [self]() { + [[self window] setIsMiniaturized:YES]; + + return Ladybird::ns_rect_to_gfx_rect([[self window] frame]); + }; + + m_web_view_bridge->on_fullscreen_window = [self]() { + if (([[self window] styleMask] & NSWindowStyleMaskFullScreen) == 0) { + [[self window] toggleFullScreen:nil]; + } + + return Ladybird::ns_rect_to_gfx_rect([[self window] frame]); + }; +} + +- (Tab*)tab +{ + return (Tab*)[self window]; +} + +- (TabController*)tabController +{ + return (TabController*)[[self tab] windowController]; +} + +- (NSScrollView*)scrollView +{ + return (NSScrollView*)[self superview]; +} + +static void copy_text_to_clipboard(StringView text) +{ + auto* string = Ladybird::string_to_ns_string(text); + + auto* pasteBoard = [NSPasteboard generalPasteboard]; + [pasteBoard clearContents]; + [pasteBoard setString:string forType:NSPasteboardTypeString]; +} + +- (void)copy:(id)sender +{ + copy_text_to_clipboard(m_web_view_bridge->selected_text()); +} + +- (void)selectAll:(id)sender +{ + m_web_view_bridge->select_all(); +} + +- (void)takeVisibleScreenshot:(id)sender +{ + auto result = m_web_view_bridge->take_screenshot(WebView::ViewImplementation::ScreenshotType::Visible); + (void)result; // FIXME: Display an error if this failed. +} + +- (void)takeFullScreenshot:(id)sender +{ + auto result = m_web_view_bridge->take_screenshot(WebView::ViewImplementation::ScreenshotType::Full); + (void)result; // FIXME: Display an error if this failed. +} + +- (void)openLink:(id)sender +{ + m_web_view_bridge->on_link_click(m_context_menu_url, {}, 0); +} + +- (void)openLinkInNewTab:(id)sender +{ + m_web_view_bridge->on_link_middle_click(m_context_menu_url, {}, 0); +} + +- (void)copyLink:(id)sender +{ + copy_text_to_clipboard(m_context_menu_url.serialize()); +} + +- (void)copyImage:(id)sender +{ + auto* bitmap = m_context_menu_bitmap.bitmap(); + if (bitmap == nullptr) { + return; + } + + auto png = Gfx::PNGWriter::encode(*bitmap); + if (png.is_error()) { + return; + } + + auto* data = [NSData dataWithBytes:png.value().data() length:png.value().size()]; + + auto* pasteBoard = [NSPasteboard generalPasteboard]; + [pasteBoard clearContents]; + [pasteBoard setData:data forType:NSPasteboardTypePNG]; +} + +- (void)toggleMediaPlayState:(id)sender +{ + m_web_view_bridge->toggle_media_play_state(); +} + +- (void)toggleMediaMuteState:(id)sender +{ + m_web_view_bridge->toggle_media_mute_state(); +} + +- (void)toggleMediaControlsState:(id)sender +{ + m_web_view_bridge->toggle_media_controls_state(); +} + +- (void)toggleMediaLoopState:(id)sender +{ + m_web_view_bridge->toggle_media_loop_state(); +} + +#pragma mark - Properties + +- (NSMenu*)page_context_menu +{ + if (!_page_context_menu) { + _page_context_menu = [[NSMenu alloc] initWithTitle:@"Page Context Menu"]; + + [_page_context_menu addItem:[[NSMenuItem alloc] initWithTitle:@"Go Back" + action:@selector(navigateBack:) + keyEquivalent:@""]]; + [_page_context_menu addItem:[[NSMenuItem alloc] initWithTitle:@"Go Forward" + action:@selector(navigateForward:) + keyEquivalent:@""]]; + [_page_context_menu addItem:[[NSMenuItem alloc] initWithTitle:@"Reload" + action:@selector(reload:) + keyEquivalent:@""]]; + [_page_context_menu addItem:[NSMenuItem separatorItem]]; + + [_page_context_menu addItem:[[NSMenuItem alloc] initWithTitle:@"Copy" + action:@selector(copy:) + keyEquivalent:@""]]; + [_page_context_menu addItem:[[NSMenuItem alloc] initWithTitle:@"Select All" + action:@selector(selectAll:) + keyEquivalent:@""]]; + [_page_context_menu addItem:[NSMenuItem separatorItem]]; + + [_page_context_menu addItem:[[NSMenuItem alloc] initWithTitle:@"Take Visible Screenshot" + action:@selector(takeVisibleScreenshot:) + keyEquivalent:@""]]; + [_page_context_menu addItem:[[NSMenuItem alloc] initWithTitle:@"Take Full Screenshot" + action:@selector(takeFullScreenshot:) + keyEquivalent:@""]]; + } + + return _page_context_menu; +} + +- (NSMenu*)link_context_menu +{ + if (!_link_context_menu) { + _link_context_menu = [[NSMenu alloc] initWithTitle:@"Link Context Menu"]; + + [_link_context_menu addItem:[[NSMenuItem alloc] initWithTitle:@"Open" + action:@selector(openLink:) + keyEquivalent:@""]]; + [_link_context_menu addItem:[[NSMenuItem alloc] initWithTitle:@"Open in New Tab" + action:@selector(openLinkInNewTab:) + keyEquivalent:@""]]; + [_link_context_menu addItem:[NSMenuItem separatorItem]]; + + [_link_context_menu addItem:[[NSMenuItem alloc] initWithTitle:@"Copy URL" + action:@selector(copyLink:) + keyEquivalent:@""]]; + } + + return _link_context_menu; +} + +- (NSMenu*)image_context_menu +{ + if (!_image_context_menu) { + _image_context_menu = [[NSMenu alloc] initWithTitle:@"Image Context Menu"]; + + [_image_context_menu addItem:[[NSMenuItem alloc] initWithTitle:@"Open Image" + action:@selector(openLink:) + keyEquivalent:@""]]; + [_image_context_menu addItem:[[NSMenuItem alloc] initWithTitle:@"Open Image in New Tab" + action:@selector(openLinkInNewTab:) + keyEquivalent:@""]]; + [_image_context_menu addItem:[NSMenuItem separatorItem]]; + + [_image_context_menu addItem:[[NSMenuItem alloc] initWithTitle:@"Copy Image" + action:@selector(copyImage:) + keyEquivalent:@""]]; + [_image_context_menu addItem:[[NSMenuItem alloc] initWithTitle:@"Copy Image URL" + action:@selector(copyLink:) + keyEquivalent:@""]]; + } + + return _image_context_menu; +} + +- (NSMenu*)video_context_menu +{ + if (!_video_context_menu) { + _video_context_menu = [[NSMenu alloc] initWithTitle:@"Video Context Menu"]; + + auto* play_pause_menu_item = [[NSMenuItem alloc] initWithTitle:@"Play" + action:@selector(toggleMediaPlayState:) + keyEquivalent:@""]; + [play_pause_menu_item setTag:CONTEXT_MENU_PLAY_PAUSE_TAG]; + + auto* mute_unmute_menu_item = [[NSMenuItem alloc] initWithTitle:@"Mute" + action:@selector(toggleMediaMuteState:) + keyEquivalent:@""]; + [mute_unmute_menu_item setTag:CONTEXT_MENU_MUTE_UNMUTE_TAG]; + + auto* controls_menu_item = [[NSMenuItem alloc] initWithTitle:@"Controls" + action:@selector(toggleMediaControlsState:) + keyEquivalent:@""]; + [controls_menu_item setTag:CONTEXT_MENU_CONTROLS_TAG]; + + auto* loop_menu_item = [[NSMenuItem alloc] initWithTitle:@"Loop" + action:@selector(toggleMediaLoopState:) + keyEquivalent:@""]; + [loop_menu_item setTag:CONTEXT_MENU_LOOP_TAG]; + + [_video_context_menu addItem:play_pause_menu_item]; + [_video_context_menu addItem:mute_unmute_menu_item]; + [_video_context_menu addItem:controls_menu_item]; + [_video_context_menu addItem:loop_menu_item]; + [_video_context_menu addItem:[NSMenuItem separatorItem]]; + + [_video_context_menu addItem:[[NSMenuItem alloc] initWithTitle:@"Open Video" + action:@selector(openLink:) + keyEquivalent:@""]]; + [_video_context_menu addItem:[[NSMenuItem alloc] initWithTitle:@"Open Video in New Tab" + action:@selector(openLinkInNewTab:) + keyEquivalent:@""]]; + [_video_context_menu addItem:[NSMenuItem separatorItem]]; + + [_video_context_menu addItem:[[NSMenuItem alloc] initWithTitle:@"Copy Video URL" + action:@selector(copyLink:) + keyEquivalent:@""]]; + } + + return _video_context_menu; +} + +- (NSTextField*)status_label +{ + if (!_status_label) { + _status_label = [NSTextField labelWithString:@""]; + [_status_label setDrawsBackground:YES]; + [_status_label setBordered:YES]; + [_status_label setHidden:YES]; + + [self addSubview:_status_label]; + } + + return _status_label; +} + +#pragma mark - NSView + +- (void)drawRect:(NSRect)rect +{ + auto paintable = m_web_view_bridge->paintable(); + if (!paintable.has_value()) { + [super drawRect:rect]; + return; + } + + auto [bitmap, bitmap_size] = *paintable; + VERIFY(bitmap.format() == Gfx::BitmapFormat::BGRA8888); + + static constexpr size_t BITS_PER_COMPONENT = 8; + static constexpr size_t BITS_PER_PIXEL = 32; + static constexpr size_t COMPONENTS_PER_PIXEL = 4; + + auto* context = [[NSGraphicsContext currentContext] CGContext]; + CGContextSaveGState(context); + + auto device_pixel_ratio = m_web_view_bridge->device_pixel_ratio(); + auto inverse_device_pixel_ratio = m_web_view_bridge->inverse_device_pixel_ratio(); + + CGContextScaleCTM(context, inverse_device_pixel_ratio, inverse_device_pixel_ratio); + + auto* provider = CGDataProviderCreateWithData(nil, bitmap.scanline_u8(0), bitmap.size_in_bytes(), nil); + auto image_rect = CGRectMake(rect.origin.x * device_pixel_ratio, rect.origin.y * device_pixel_ratio, bitmap_size.width(), bitmap_size.height()); + + // Ideally, this would be NSBitmapImageRep, but the equivalent factory initWithBitmapDataPlanes: does + // not seem to actually respect endianness. We need NSBitmapFormatThirtyTwoBitLittleEndian, but the + // resulting image is always big endian. CGImageCreate actually does respect the endianness. + auto* bitmap_image = CGImageCreate( + bitmap_size.width(), + bitmap_size.height(), + BITS_PER_COMPONENT, + BITS_PER_PIXEL, + COMPONENTS_PER_PIXEL * bitmap.width(), + CGColorSpaceCreateDeviceRGB(), + kCGBitmapByteOrder32Little | kCGImageAlphaFirst, + provider, + nil, + NO, + kCGRenderingIntentDefault); + + auto* image = [[NSImage alloc] initWithCGImage:bitmap_image size:NSZeroSize]; + [image drawInRect:image_rect]; + + CGContextRestoreGState(context); + CGImageRelease(bitmap_image); + + [super drawRect:rect]; +} + +- (void)viewDidEndLiveResize +{ + [super viewDidEndLiveResize]; + [self handleResize]; +} + +- (BOOL)isFlipped +{ + // The origin of a NSScrollView is the lower-left corner, with the y-axis extending upwards. Instead, + // we want the origin to be the top-left corner, with the y-axis extending downward. + return YES; +} + +- (void)mouseMoved:(NSEvent*)event +{ + auto [position, button, modifiers] = Ladybird::ns_event_to_mouse_event(event, self, GUI::MouseButton::None); + m_web_view_bridge->mouse_move_event(position, button, modifiers); +} + +- (void)mouseDown:(NSEvent*)event +{ + [[self window] makeFirstResponder:self]; + + auto [position, button, modifiers] = Ladybird::ns_event_to_mouse_event(event, self, GUI::MouseButton::Primary); + + if (event.clickCount % 2 == 0) { + m_web_view_bridge->mouse_double_click_event(position, button, modifiers); + } else { + m_web_view_bridge->mouse_down_event(position, button, modifiers); + } +} + +- (void)mouseUp:(NSEvent*)event +{ + auto [position, button, modifiers] = Ladybird::ns_event_to_mouse_event(event, self, GUI::MouseButton::Primary); + m_web_view_bridge->mouse_up_event(position, button, modifiers); +} + +- (void)mouseDragged:(NSEvent*)event +{ + auto [position, button, modifiers] = Ladybird::ns_event_to_mouse_event(event, self, GUI::MouseButton::Primary); + m_web_view_bridge->mouse_move_event(position, button, modifiers); +} + +- (void)rightMouseDown:(NSEvent*)event +{ + [[self window] makeFirstResponder:self]; + + auto [position, button, modifiers] = Ladybird::ns_event_to_mouse_event(event, self, GUI::MouseButton::Secondary); + + if (event.clickCount % 2 == 0) { + m_web_view_bridge->mouse_double_click_event(position, button, modifiers); + } else { + m_web_view_bridge->mouse_down_event(position, button, modifiers); + } +} + +- (void)rightMouseUp:(NSEvent*)event +{ + auto [position, button, modifiers] = Ladybird::ns_event_to_mouse_event(event, self, GUI::MouseButton::Secondary); + m_web_view_bridge->mouse_up_event(position, button, modifiers); +} + +- (void)rightMouseDragged:(NSEvent*)event +{ + auto [position, button, modifiers] = Ladybird::ns_event_to_mouse_event(event, self, GUI::MouseButton::Secondary); + m_web_view_bridge->mouse_move_event(position, button, modifiers); +} + +- (void)keyDown:(NSEvent*)event +{ + auto [key_code, modifiers, code_point] = Ladybird::ns_event_to_key_event(event); + m_web_view_bridge->key_down_event(key_code, modifiers, code_point); +} + +- (void)keyUp:(NSEvent*)event +{ + auto [key_code, modifiers, code_point] = Ladybird::ns_event_to_key_event(event); + m_web_view_bridge->key_up_event(key_code, modifiers, code_point); +} + +@end diff --git a/Ladybird/AppKit/UI/LadybirdWebViewBridge.cpp b/Ladybird/AppKit/UI/LadybirdWebViewBridge.cpp new file mode 100644 index 00000000000..56ee2da9730 --- /dev/null +++ b/Ladybird/AppKit/UI/LadybirdWebViewBridge.cpp @@ -0,0 +1,325 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Ladybird { + +template +static T scale_for_device(T size, float device_pixel_ratio) +{ + return size.template to_type().scaled(device_pixel_ratio).template to_type(); +} + +ErrorOr> WebViewBridge::create(Vector screen_rects, float device_pixel_ratio, Optional webdriver_content_ipc_path) +{ + return adopt_nonnull_own_or_enomem(new (nothrow) WebViewBridge(move(screen_rects), device_pixel_ratio, move(webdriver_content_ipc_path))); +} + +WebViewBridge::WebViewBridge(Vector screen_rects, float device_pixel_ratio, Optional webdriver_content_ipc_path) + : m_screen_rects(move(screen_rects)) + , m_webdriver_content_ipc_path(move(webdriver_content_ipc_path)) +{ + m_device_pixel_ratio = device_pixel_ratio; + m_inverse_device_pixel_ratio = 1.0 / device_pixel_ratio; + + create_client(WebView::EnableCallgrindProfiling::No); +} + +WebViewBridge::~WebViewBridge() = default; + +void WebViewBridge::set_system_visibility_state(bool is_visible) +{ + client().async_set_system_visibility_state(is_visible); +} + +void WebViewBridge::set_viewport_rect(Gfx::IntRect viewport_rect, ForResize for_resize) +{ + viewport_rect.set_size(scale_for_device(viewport_rect.size(), m_device_pixel_ratio)); + m_viewport_rect = viewport_rect; + + client().async_set_viewport_rect(m_viewport_rect); + request_repaint(); + + if (for_resize == ForResize::Yes) { + handle_resize(); + } +} + +void WebViewBridge::mouse_down_event(Gfx::IntPoint position, GUI::MouseButton button, KeyModifier modifiers) +{ + client().async_mouse_down(to_content_position(position), to_underlying(button), to_underlying(button), modifiers); +} + +void WebViewBridge::mouse_up_event(Gfx::IntPoint position, GUI::MouseButton button, KeyModifier modifiers) +{ + client().async_mouse_up(to_content_position(position), to_underlying(button), to_underlying(button), modifiers); +} + +void WebViewBridge::mouse_move_event(Gfx::IntPoint position, GUI::MouseButton button, KeyModifier modifiers) +{ + client().async_mouse_move(to_content_position(position), 0, to_underlying(button), modifiers); +} + +void WebViewBridge::mouse_double_click_event(Gfx::IntPoint position, GUI::MouseButton button, KeyModifier modifiers) +{ + client().async_doubleclick(to_content_position(position), button, to_underlying(button), modifiers); +} + +void WebViewBridge::key_down_event(KeyCode key_code, KeyModifier modifiers, u32 code_point) +{ + client().async_key_down(key_code, modifiers, code_point); +} + +void WebViewBridge::key_up_event(KeyCode key_code, KeyModifier modifiers, u32 code_point) +{ + client().async_key_up(key_code, modifiers, code_point); +} + +Optional WebViewBridge::paintable() +{ + Gfx::Bitmap* bitmap = nullptr; + Gfx::IntSize bitmap_size; + + if (m_client_state.has_usable_bitmap) { + bitmap = m_client_state.front_bitmap.bitmap.ptr(); + bitmap_size = m_client_state.front_bitmap.last_painted_size; + } else { + bitmap = m_backup_bitmap.ptr(); + bitmap_size = m_backup_bitmap_size; + } + + if (!bitmap) + return {}; + return Paintable { *bitmap, bitmap_size }; +} + +void WebViewBridge::notify_server_did_layout(Badge, Gfx::IntSize content_size) +{ + if (on_layout) { + content_size = scale_for_device(content_size, m_inverse_device_pixel_ratio); + on_layout(content_size); + } +} + +void WebViewBridge::notify_server_did_paint(Badge, i32 bitmap_id, Gfx::IntSize size) +{ + if (m_client_state.back_bitmap.id == bitmap_id) { + m_client_state.has_usable_bitmap = true; + m_client_state.back_bitmap.pending_paints--; + m_client_state.back_bitmap.last_painted_size = size; + swap(m_client_state.back_bitmap, m_client_state.front_bitmap); + // We don't need the backup bitmap anymore, so drop it. + m_backup_bitmap = nullptr; + + if (on_ready_to_paint) + on_ready_to_paint(); + + if (m_client_state.got_repaint_requests_while_painting) { + m_client_state.got_repaint_requests_while_painting = false; + request_repaint(); + } + } +} + +void WebViewBridge::notify_server_did_invalidate_content_rect(Badge, Gfx::IntRect const&) +{ + request_repaint(); +} + +void WebViewBridge::notify_server_did_change_selection(Badge) +{ + request_repaint(); +} + +void WebViewBridge::notify_server_did_request_cursor_change(Badge, Gfx::StandardCursor cursor) +{ + if (on_cursor_change) + on_cursor_change(cursor); +} + +void WebViewBridge::notify_server_did_request_scroll(Badge, i32 x_delta, i32 y_delta) +{ + // FIXME: This currently isn't reached because we do not yet propagate mouse wheel events to WebContent. + // When that is implemented, make sure our mutations to the viewport position here are correct. + auto position = m_viewport_rect.location(); + position.set_x(position.x() + x_delta); + position.set_y(position.y() + y_delta); + + if (on_scroll) + on_scroll(position); +} + +void WebViewBridge::notify_server_did_request_scroll_to(Badge, Gfx::IntPoint position) +{ + if (on_scroll) + on_scroll(position); +} + +void WebViewBridge::notify_server_did_request_scroll_into_view(Badge, Gfx::IntRect const& rect) +{ + if (m_viewport_rect.contains(rect)) + return; + + auto position = m_viewport_rect.location(); + + if (rect.top() < m_viewport_rect.top()) { + position.set_y(rect.top()); + } else if (rect.top() > m_viewport_rect.top() && rect.bottom() > m_viewport_rect.bottom()) { + position.set_y(rect.bottom() - m_viewport_rect.height()); + } else { + return; + } + + if (on_scroll) + on_scroll(position); +} + +void WebViewBridge::notify_server_did_enter_tooltip_area(Badge, Gfx::IntPoint, DeprecatedString const& tooltip) +{ + if (on_tooltip_entered) + on_tooltip_entered(tooltip); +} + +void WebViewBridge::notify_server_did_leave_tooltip_area(Badge) +{ + if (on_tooltip_left) + on_tooltip_left(); +} + +void WebViewBridge::notify_server_did_request_alert(Badge, String const& message) +{ + if (on_alert) + on_alert(message); +} + +void WebViewBridge::alert_closed() +{ + client().async_alert_closed(); +} + +void WebViewBridge::notify_server_did_request_confirm(Badge, String const& message) +{ + if (on_confirm) + on_confirm(message); +} + +void WebViewBridge::confirm_closed(bool accepted) +{ + client().async_confirm_closed(accepted); +} + +void WebViewBridge::notify_server_did_request_prompt(Badge, String const& message, String const& default_) +{ + if (on_prompt) + on_prompt(message, default_); +} + +void WebViewBridge::prompt_closed(Optional response) +{ + client().async_prompt_closed(move(response)); +} + +void WebViewBridge::notify_server_did_request_set_prompt_text(Badge, String const& message) +{ + if (on_prompt_text_changed) + on_prompt_text_changed(message); +} + +void WebViewBridge::notify_server_did_request_accept_dialog(Badge) +{ + if (on_dialog_accepted) + on_dialog_accepted(); +} + +void WebViewBridge::notify_server_did_request_dismiss_dialog(Badge) +{ + if (on_dialog_dismissed) + on_dialog_dismissed(); +} + +void WebViewBridge::notify_server_did_request_file(Badge, DeprecatedString const& path, i32 request_id) +{ + auto file = Core::File::open(path, Core::File::OpenMode::Read); + + if (file.is_error()) + client().async_handle_file_return(file.error().code(), {}, request_id); + else + client().async_handle_file_return(0, IPC::File(*file.value()), request_id); +} + +void WebViewBridge::notify_server_did_finish_handling_input_event(bool) +{ +} + +void WebViewBridge::update_zoom() +{ +} + +Gfx::IntRect WebViewBridge::viewport_rect() const +{ + return m_viewport_rect; +} + +Gfx::IntPoint WebViewBridge::to_content_position(Gfx::IntPoint widget_position) const +{ + return scale_for_device(widget_position, m_device_pixel_ratio); +} + +Gfx::IntPoint WebViewBridge::to_widget_position(Gfx::IntPoint content_position) const +{ + return scale_for_device(content_position, m_inverse_device_pixel_ratio); +} + +void WebViewBridge::create_client(WebView::EnableCallgrindProfiling enable_callgrind_profiling) +{ + m_client_state = {}; + + auto candidate_web_content_paths = MUST(get_paths_for_helper_process("WebContent"sv)); + auto new_client = MUST(launch_web_content_process(*this, candidate_web_content_paths, enable_callgrind_profiling, WebView::IsLayoutTestMode::No, Ladybird::UseLagomNetworking::Yes)); + + m_client_state.client = new_client; + m_client_state.client->on_web_content_process_crash = [this] { + Core::deferred_invoke([this] { + handle_web_content_process_crash(); + }); + }; + + m_client_state.client_handle = MUST(Web::Crypto::generate_random_uuid()); + client().async_set_window_handle(m_client_state.client_handle); + + client().async_set_device_pixels_per_css_pixel(m_device_pixel_ratio); + client().async_update_system_fonts(Gfx::FontDatabase::default_font_query(), Gfx::FontDatabase::fixed_width_font_query(), Gfx::FontDatabase::window_title_font_query()); + update_palette(); + + if (!m_screen_rects.is_empty()) { + // FIXME: Update the screens again if they ever change. + client().async_update_screen_rects(m_screen_rects, 0); + } + + if (m_webdriver_content_ipc_path.has_value()) { + client().async_connect_to_webdriver(*m_webdriver_content_ipc_path); + } +} + +void WebViewBridge::update_palette() +{ + auto theme = MUST(Gfx::load_system_theme(DeprecatedString::formatted("{}/res/themes/Default.ini", s_serenity_resource_root))); + auto palette_impl = Gfx::PaletteImpl::create_with_anonymous_buffer(theme); + auto palette = Gfx::Palette(move(palette_impl)); + + client().async_update_system_theme(move(theme)); +} + +} diff --git a/Ladybird/AppKit/UI/LadybirdWebViewBridge.h b/Ladybird/AppKit/UI/LadybirdWebViewBridge.h new file mode 100644 index 00000000000..796f181c7a7 --- /dev/null +++ b/Ladybird/AppKit/UI/LadybirdWebViewBridge.h @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +// FIXME: These should not be included outside of Serenity. +#include +#include + +namespace Ladybird { + +class WebViewBridge final : public WebView::ViewImplementation { +public: + static ErrorOr> create(Vector screen_rects, float device_pixel_ratio, Optional webdriver_content_ipc_path); + virtual ~WebViewBridge() override; + + float device_pixel_ratio() const { return m_device_pixel_ratio; } + float inverse_device_pixel_ratio() const { return m_inverse_device_pixel_ratio; } + + void set_system_visibility_state(bool is_visible); + + enum class ForResize { + Yes, + No, + }; + void set_viewport_rect(Gfx::IntRect, ForResize = ForResize::No); + + void mouse_down_event(Gfx::IntPoint, GUI::MouseButton, KeyModifier); + void mouse_up_event(Gfx::IntPoint, GUI::MouseButton, KeyModifier); + void mouse_move_event(Gfx::IntPoint, GUI::MouseButton, KeyModifier); + void mouse_double_click_event(Gfx::IntPoint, GUI::MouseButton, KeyModifier); + + void key_down_event(KeyCode, KeyModifier, u32); + void key_up_event(KeyCode, KeyModifier, u32); + + struct Paintable { + Gfx::Bitmap& bitmap; + Gfx::IntSize bitmap_size; + }; + Optional paintable(); + + Function on_layout; + Function on_ready_to_paint; + + Function on_scroll; + + Function on_cursor_change; + + Function on_tooltip_entered; + Function on_tooltip_left; + + Function on_alert; + void alert_closed(); + + Function on_confirm; + void confirm_closed(bool); + + Function on_prompt; + Function on_prompt_text_changed; + void prompt_closed(Optional); + + Function on_dialog_accepted; + Function on_dialog_dismissed; + +private: + WebViewBridge(Vector screen_rects, float device_pixel_ratio, Optional webdriver_content_ipc_path); + + virtual void notify_server_did_layout(Badge, Gfx::IntSize content_size) override; + virtual void notify_server_did_paint(Badge, i32 bitmap_id, Gfx::IntSize) override; + virtual void notify_server_did_invalidate_content_rect(Badge, Gfx::IntRect const&) override; + virtual void notify_server_did_change_selection(Badge) override; + virtual void notify_server_did_request_cursor_change(Badge, Gfx::StandardCursor cursor) override; + virtual void notify_server_did_request_scroll(Badge, i32, i32) override; + virtual void notify_server_did_request_scroll_to(Badge, Gfx::IntPoint) override; + virtual void notify_server_did_request_scroll_into_view(Badge, Gfx::IntRect const&) override; + virtual void notify_server_did_enter_tooltip_area(Badge, Gfx::IntPoint, DeprecatedString const&) override; + virtual void notify_server_did_leave_tooltip_area(Badge) override; + virtual void notify_server_did_request_alert(Badge, String const& message) override; + virtual void notify_server_did_request_confirm(Badge, String const& message) override; + virtual void notify_server_did_request_prompt(Badge, String const& message, String const& default_) override; + virtual void notify_server_did_request_set_prompt_text(Badge, String const& message) override; + virtual void notify_server_did_request_accept_dialog(Badge) override; + virtual void notify_server_did_request_dismiss_dialog(Badge) override; + virtual void notify_server_did_request_file(Badge, DeprecatedString const& path, i32) override; + virtual void notify_server_did_finish_handling_input_event(bool event_was_accepted) override; + + virtual void update_zoom() override; + virtual Gfx::IntRect viewport_rect() const override; + virtual Gfx::IntPoint to_content_position(Gfx::IntPoint widget_position) const override; + virtual Gfx::IntPoint to_widget_position(Gfx::IntPoint content_position) const override; + + virtual void create_client(WebView::EnableCallgrindProfiling) override; + + void update_palette(); + + Vector m_screen_rects; + Gfx::IntRect m_viewport_rect; + + float m_inverse_device_pixel_ratio { 1.0 }; + + Optional m_webdriver_content_ipc_path; +}; + +} diff --git a/Ladybird/AppKit/UI/Tab.h b/Ladybird/AppKit/UI/Tab.h new file mode 100644 index 00000000000..172d200713e --- /dev/null +++ b/Ladybird/AppKit/UI/Tab.h @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include + +#import + +@class LadybirdWebView; + +@interface Tab : NSWindow + +- (void)onLoadStart:(URL const&)url; +- (void)onTitleChange:(NSString*)title; +- (void)onFaviconChange:(NSImage*)favicon; + +@property (nonatomic, strong) LadybirdWebView* web_view; + +@end diff --git a/Ladybird/AppKit/UI/Tab.mm b/Ladybird/AppKit/UI/Tab.mm new file mode 100644 index 00000000000..03711739736 --- /dev/null +++ b/Ladybird/AppKit/UI/Tab.mm @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#import +#import +#import +#import + +#include + +#if !__has_feature(objc_arc) +# error "This project requires ARC" +#endif + +static constexpr CGFloat const WINDOW_WIDTH = 1000; +static constexpr CGFloat const WINDOW_HEIGHT = 800; + +@interface Tab () + +@property (nonatomic, strong) NSString* title; +@property (nonatomic, strong) NSImage* favicon; + +@end + +@implementation Tab + +@dynamic title; + ++ (NSImage*)defaultFavicon +{ + static NSImage* default_favicon; + static dispatch_once_t token; + + dispatch_once(&token, ^{ + auto default_favicon_path = MUST(String::formatted("{}/res/icons/16x16/app-browser.png", s_serenity_resource_root)); + auto* ns_default_favicon_path = Ladybird::string_to_ns_string(default_favicon_path); + + default_favicon = [[NSImage alloc] initWithContentsOfFile:ns_default_favicon_path]; + }); + + return default_favicon; +} + +- (instancetype)init +{ + auto screen_rect = [[NSScreen mainScreen] frame]; + auto position_x = (NSWidth(screen_rect) - WINDOW_WIDTH) / 2; + auto position_y = (NSHeight(screen_rect) - WINDOW_HEIGHT) / 2; + + auto window_rect = NSMakeRect(position_x, position_y, WINDOW_WIDTH, WINDOW_HEIGHT); + auto style_mask = NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable; + + self = [super initWithContentRect:window_rect + styleMask:style_mask + backing:NSBackingStoreBuffered + defer:NO]; + + if (self) { + self.web_view = [[LadybirdWebView alloc] init]; + [self.web_view setPostsBoundsChangedNotifications:YES]; + + self.favicon = [Tab defaultFavicon]; + self.title = @"New Tab"; + [self updateTabTitleAndFavicon]; + + [self setTitleVisibility:NSWindowTitleHidden]; + [self setIsVisible:YES]; + + auto* scroll_view = [[NSScrollView alloc] initWithFrame:[self frame]]; + [scroll_view setHasVerticalScroller:YES]; + [scroll_view setHasHorizontalScroller:YES]; + [scroll_view setLineScroll:24]; + + [scroll_view setContentView:self.web_view]; + [scroll_view setDocumentView:[[NSView alloc] init]]; + + [[NSNotificationCenter defaultCenter] + addObserver:self + selector:@selector(onContentScroll:) + name:NSViewBoundsDidChangeNotification + object:[scroll_view contentView]]; + + [self setContentView:scroll_view]; + } + + return self; +} + +#pragma mark - Public methods + +- (void)onLoadStart:(URL const&)url +{ + self.title = Ladybird::string_to_ns_string(url.serialize()); + self.favicon = [Tab defaultFavicon]; + [self updateTabTitleAndFavicon]; +} + +- (void)onTitleChange:(NSString*)title +{ + self.title = title; + [self updateTabTitleAndFavicon]; +} + +- (void)onFaviconChange:(NSImage*)favicon +{ + self.favicon = favicon; + [self updateTabTitleAndFavicon]; +} + +#pragma mark - Private methods + +- (void)updateTabTitleAndFavicon +{ + auto* favicon_attachment = [[NSTextAttachment alloc] init]; + favicon_attachment.image = self.favicon; + + // By default, the image attachment will "automatically adapt to the surrounding font and color + // attributes in attributed strings". Therefore, we specify a clear color here to prevent the + // favicon from having a weird tint. + auto* favicon_attribute = (NSMutableAttributedString*)[NSMutableAttributedString attributedStringWithAttachment:favicon_attachment]; + [favicon_attribute addAttribute:NSForegroundColorAttributeName + value:[NSColor clearColor] + range:NSMakeRange(0, [favicon_attribute length])]; + + // By default, the text attachment will be aligned to the bottom of the string. We have to manually + // try to center it vertically. + // FIXME: Figure out a way to programmatically arrive at a good NSBaselineOffsetAttributeName. Using + // half the distance between the font's line height and the height of the favicon produces a + // value that results in the title being aligned too low still. + auto* title_attributes = @{ + NSForegroundColorAttributeName : [NSColor textColor], + NSBaselineOffsetAttributeName : @3 + }; + + auto* title_attribute = [[NSAttributedString alloc] initWithString:self.title + attributes:title_attributes]; + + auto* spacing_attribute = [[NSAttributedString alloc] initWithString:@" "]; + + auto* title_and_favicon = [[NSMutableAttributedString alloc] init]; + [title_and_favicon appendAttributedString:favicon_attribute]; + [title_and_favicon appendAttributedString:spacing_attribute]; + [title_and_favicon appendAttributedString:title_attribute]; + + [[self tab] setAttributedTitle:title_and_favicon]; +} + +- (void)onContentScroll:(NSNotification*)notification +{ + [[self web_view] handleScroll]; +} + +#pragma mark - NSWindow + +- (void)setIsVisible:(BOOL)flag +{ + [[self web_view] handleVisibility:flag]; + [super setIsVisible:flag]; +} + +- (void)setIsMiniaturized:(BOOL)flag +{ + [[self web_view] handleVisibility:!flag]; + [super setIsMiniaturized:flag]; +} + +@end diff --git a/Ladybird/AppKit/UI/TabController.h b/Ladybird/AppKit/UI/TabController.h new file mode 100644 index 00000000000..dc0f2c9f34c --- /dev/null +++ b/Ladybird/AppKit/UI/TabController.h @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include + +#import + +@interface TabController : NSWindowController + +- (instancetype)init:(URL)url; + +- (void)load:(URL const&)url; + +- (void)onLoadStart:(URL const&)url isRedirect:(BOOL)isRedirect; +- (void)onTitleChange:(DeprecatedString const&)title; + +- (void)navigateBack:(id)sender; +- (void)navigateForward:(id)sender; +- (void)reload:(id)sender; +- (void)clearHistory; + +- (void)focusLocationToolbarItem; + +@end diff --git a/Ladybird/AppKit/UI/TabController.mm b/Ladybird/AppKit/UI/TabController.mm new file mode 100644 index 00000000000..bc85e096646 --- /dev/null +++ b/Ladybird/AppKit/UI/TabController.mm @@ -0,0 +1,395 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include + +#import +#import +#import +#import +#import +#import + +#if !__has_feature(objc_arc) +# error "This project requires ARC" +#endif + +static NSString* const TOOLBAR_IDENTIFIER = @"Toolbar"; +static NSString* const TOOLBAR_NAVIGATE_BACK_IDENTIFIER = @"ToolbarNavigateBackIdentifier"; +static NSString* const TOOLBAR_NAVIGATE_FORWARD_IDENTIFIER = @"ToolbarNavigateForwardIdentifier"; +static NSString* const TOOLBAR_RELOAD_IDENTIFIER = @"ToolbarReloadIdentifier"; +static NSString* const TOOLBAR_LOCATION_IDENTIFIER = @"ToolbarLocationIdentifier"; +static NSString* const TOOLBAR_NEW_TAB_IDENTIFIER = @"ToolbarNewTabIdentifier"; +static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIdentifer"; + +enum class IsHistoryNavigation { + Yes, + No, +}; + +@interface TabController () +{ + URL m_url; + DeprecatedString m_title; + + Browser::History m_history; + IsHistoryNavigation m_is_history_navigation; +} + +@property (nonatomic, strong) NSToolbar* toolbar; +@property (nonatomic, strong) NSArray* toolbar_identifiers; + +@property (nonatomic, strong) NSToolbarItem* navigate_back_toolbar_item; +@property (nonatomic, strong) NSToolbarItem* navigate_forward_toolbar_item; +@property (nonatomic, strong) NSToolbarItem* reload_toolbar_item; +@property (nonatomic, strong) NSToolbarItem* location_toolbar_item; +@property (nonatomic, strong) NSToolbarItem* new_tab_toolbar_item; +@property (nonatomic, strong) NSToolbarItem* tab_overview_toolbar_item; + +@property (nonatomic, assign) NSLayoutConstraint* location_toolbar_item_width; + +@end + +@implementation TabController + +@synthesize toolbar_identifiers = _toolbar_identifiers; +@synthesize navigate_back_toolbar_item = _navigate_back_toolbar_item; +@synthesize navigate_forward_toolbar_item = _navigate_forward_toolbar_item; +@synthesize reload_toolbar_item = _reload_toolbar_item; +@synthesize location_toolbar_item = _location_toolbar_item; +@synthesize new_tab_toolbar_item = _new_tab_toolbar_item; +@synthesize tab_overview_toolbar_item = _tab_overview_toolbar_item; + +- (instancetype)init:(URL)url +{ + if (self = [super init]) { + self.toolbar = [[NSToolbar alloc] initWithIdentifier:TOOLBAR_IDENTIFIER]; + [self.toolbar setDelegate:self]; + [self.toolbar setDisplayMode:NSToolbarDisplayModeIconOnly]; + [self.toolbar setAllowsUserCustomization:NO]; + [self.toolbar setSizeMode:NSToolbarSizeModeRegular]; + + m_url = move(url); + m_is_history_navigation = IsHistoryNavigation::No; + } + + return self; +} + +#pragma mark - Public methods + +- (void)load:(URL const&)url +{ + [[self tab].web_view load:url]; +} + +- (void)onLoadStart:(URL const&)url isRedirect:(BOOL)isRedirect +{ + if (isRedirect) { + m_history.replace_current(url, m_title); + } + + auto* url_string = Ladybird::string_to_ns_string(url.serialize()); + auto* location_search_field = (NSSearchField*)[self.location_toolbar_item view]; + [location_search_field setStringValue:url_string]; + + if (m_is_history_navigation == IsHistoryNavigation::Yes) { + m_is_history_navigation = IsHistoryNavigation::No; + } else { + m_history.push(url, m_title); + } + + [self updateNavigationButtonStates]; +} + +- (void)onTitleChange:(DeprecatedString const&)title +{ + m_title = title; + m_history.update_title(m_title); +} + +- (void)navigateBack:(id)sender +{ + if (!m_history.can_go_back()) { + return; + } + + m_is_history_navigation = IsHistoryNavigation::Yes; + m_history.go_back(); + + auto url = m_history.current().url; + [self load:url]; +} + +- (void)navigateForward:(id)sender +{ + if (!m_history.can_go_forward()) { + return; + } + + m_is_history_navigation = IsHistoryNavigation::Yes; + m_history.go_forward(); + + auto url = m_history.current().url; + [self load:url]; +} + +- (void)reload:(id)sender +{ + m_is_history_navigation = IsHistoryNavigation::Yes; + + auto url = m_history.current().url; + [self load:url]; +} + +- (void)clearHistory +{ + m_history.clear(); + [self updateNavigationButtonStates]; +} + +- (void)focusLocationToolbarItem +{ + [self.window makeFirstResponder:self.location_toolbar_item.view]; +} + +#pragma mark - Private methods + +- (Tab*)tab +{ + return (Tab*)[self window]; +} + +- (void)createNewTab:(id)sender +{ + auto* delegate = (ApplicationDelegate*)[NSApp delegate]; + [delegate createNewTab:OptionalNone {}]; +} + +- (void)updateNavigationButtonStates +{ + auto* navigate_back_button = (NSButton*)[[self navigate_back_toolbar_item] view]; + [navigate_back_button setEnabled:m_history.can_go_back()]; + + auto* navigate_forward_button = (NSButton*)[[self navigate_forward_toolbar_item] view]; + [navigate_forward_button setEnabled:m_history.can_go_forward()]; +} + +- (void)showTabOverview:(id)sender +{ + [self.window toggleTabOverview:sender]; +} + +#pragma mark - Properties + +- (NSButton*)create_button:(NSImageName)image + with_action:(nonnull SEL)action +{ + auto* button = [NSButton buttonWithImage:[NSImage imageNamed:image] + target:self + action:action]; + [button setBordered:NO]; + + return button; +} + +- (NSToolbarItem*)navigate_back_toolbar_item +{ + if (!_navigate_back_toolbar_item) { + auto* button = [self create_button:NSImageNameGoBackTemplate + with_action:@selector(navigateBack:)]; + [button setEnabled:NO]; + + _navigate_back_toolbar_item = [[NSToolbarItem alloc] initWithItemIdentifier:TOOLBAR_NAVIGATE_BACK_IDENTIFIER]; + [_navigate_back_toolbar_item setView:button]; + } + + return _navigate_back_toolbar_item; +} + +- (NSToolbarItem*)navigate_forward_toolbar_item +{ + if (!_navigate_forward_toolbar_item) { + auto* button = [self create_button:NSImageNameGoForwardTemplate + with_action:@selector(navigateForward:)]; + [button setEnabled:NO]; + + _navigate_forward_toolbar_item = [[NSToolbarItem alloc] initWithItemIdentifier:TOOLBAR_NAVIGATE_FORWARD_IDENTIFIER]; + [_navigate_forward_toolbar_item setView:button]; + } + + return _navigate_forward_toolbar_item; +} + +- (NSToolbarItem*)reload_toolbar_item +{ + if (!_reload_toolbar_item) { + auto* button = [self create_button:NSImageNameRefreshTemplate + with_action:@selector(reload:)]; + + _reload_toolbar_item = [[NSToolbarItem alloc] initWithItemIdentifier:TOOLBAR_RELOAD_IDENTIFIER]; + [_reload_toolbar_item setView:button]; + } + + return _reload_toolbar_item; +} + +- (NSToolbarItem*)location_toolbar_item +{ + if (!_location_toolbar_item) { + auto* location_search_field = [[NSSearchField alloc] init]; + [location_search_field setPlaceholderString:@"Enter web address"]; + [location_search_field setTextColor:[NSColor textColor]]; + [location_search_field setDelegate:self]; + + _location_toolbar_item = [[NSToolbarItem alloc] initWithItemIdentifier:TOOLBAR_LOCATION_IDENTIFIER]; + [_location_toolbar_item setView:location_search_field]; + } + + return _location_toolbar_item; +} + +- (NSToolbarItem*)new_tab_toolbar_item +{ + if (!_new_tab_toolbar_item) { + auto* button = [self create_button:NSImageNameAddTemplate + with_action:@selector(createNewTab:)]; + + _new_tab_toolbar_item = [[NSToolbarItem alloc] initWithItemIdentifier:TOOLBAR_NEW_TAB_IDENTIFIER]; + [_new_tab_toolbar_item setView:button]; + } + + return _new_tab_toolbar_item; +} + +- (NSToolbarItem*)tab_overview_toolbar_item +{ + if (!_tab_overview_toolbar_item) { + auto* button = [self create_button:NSImageNameIconViewTemplate + with_action:@selector(showTabOverview:)]; + + _tab_overview_toolbar_item = [[NSToolbarItem alloc] initWithItemIdentifier:TOOLBAR_TAB_OVERVIEW_IDENTIFIER]; + [_tab_overview_toolbar_item setView:button]; + } + + return _tab_overview_toolbar_item; +} + +- (NSArray*)toolbar_identifiers +{ + if (!_toolbar_identifiers) { + _toolbar_identifiers = @[ + TOOLBAR_NAVIGATE_BACK_IDENTIFIER, + TOOLBAR_NAVIGATE_FORWARD_IDENTIFIER, + NSToolbarFlexibleSpaceItemIdentifier, + TOOLBAR_RELOAD_IDENTIFIER, + TOOLBAR_LOCATION_IDENTIFIER, + NSToolbarFlexibleSpaceItemIdentifier, + TOOLBAR_NEW_TAB_IDENTIFIER, + TOOLBAR_TAB_OVERVIEW_IDENTIFIER, + ]; + } + + return _toolbar_identifiers; +} + +#pragma mark - NSWindowController + +- (IBAction)showWindow:(id)sender +{ + self.window = [[Tab alloc] init]; + [self load:m_url]; + + [self.window setDelegate:self]; + + [self.window setToolbar:self.toolbar]; + [self.window setToolbarStyle:NSWindowToolbarStyleUnified]; + + [self.window makeKeyAndOrderFront:sender]; + + [self focusLocationToolbarItem]; +} + +#pragma mark - NSWindowDelegate + +- (void)windowWillClose:(NSNotification*)notification +{ + auto* delegate = (ApplicationDelegate*)[NSApp delegate]; + [delegate removeTab:self]; +} + +- (void)windowDidResize:(NSNotification*)notification +{ + if (self.location_toolbar_item_width != nil) { + self.location_toolbar_item_width.active = NO; + } + + auto width = [self window].frame.size.width * 0.6; + self.location_toolbar_item_width = [[[self.location_toolbar_item view] widthAnchor] constraintEqualToConstant:width]; + self.location_toolbar_item_width.active = YES; + + if (![[self window] inLiveResize]) { + [[[self tab] web_view] handleResize]; + } +} + +#pragma mark - NSToolbarDelegate + +- (NSToolbarItem*)toolbar:(NSToolbar*)toolbar + itemForItemIdentifier:(NSString*)identifier + willBeInsertedIntoToolbar:(BOOL)flag +{ + if ([identifier isEqual:TOOLBAR_NAVIGATE_BACK_IDENTIFIER]) { + return self.navigate_back_toolbar_item; + } + if ([identifier isEqual:TOOLBAR_NAVIGATE_FORWARD_IDENTIFIER]) { + return self.navigate_forward_toolbar_item; + } + if ([identifier isEqual:TOOLBAR_RELOAD_IDENTIFIER]) { + return self.reload_toolbar_item; + } + if ([identifier isEqual:TOOLBAR_LOCATION_IDENTIFIER]) { + return self.location_toolbar_item; + } + if ([identifier isEqual:TOOLBAR_NEW_TAB_IDENTIFIER]) { + return self.new_tab_toolbar_item; + } + if ([identifier isEqual:TOOLBAR_TAB_OVERVIEW_IDENTIFIER]) { + return self.tab_overview_toolbar_item; + } + + return nil; +} + +- (NSArray*)toolbarAllowedItemIdentifiers:(NSToolbar*)toolbar +{ + return self.toolbar_identifiers; +} + +- (NSArray*)toolbarDefaultItemIdentifiers:(NSToolbar*)toolbar +{ + return self.toolbar_identifiers; +} + +#pragma mark - NSSearchFieldDelegate + +- (BOOL)control:(NSControl*)control + textView:(NSTextView*)text_view + doCommandBySelector:(SEL)selector +{ + if (selector != @selector(insertNewline:)) { + return NO; + } + + auto* url_string = [[text_view textStorage] string]; + auto url = Ladybird::sanitize_url(url_string); + [self load:url]; + + [self.window makeFirstResponder:nil]; + return YES; +} + +@end diff --git a/Ladybird/AppKit/Utilities/Conversions.h b/Ladybird/AppKit/Utilities/Conversions.h new file mode 100644 index 00000000000..7aa9f1c0cb9 --- /dev/null +++ b/Ladybird/AppKit/Utilities/Conversions.h @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include + +#import + +namespace Ladybird { + +String ns_string_to_string(NSString*); +NSString* string_to_ns_string(StringView); + +Gfx::IntRect ns_rect_to_gfx_rect(NSRect); +NSRect gfx_rect_to_ns_rect(Gfx::IntRect); + +Gfx::IntSize ns_size_to_gfx_size(NSSize); +NSSize gfx_size_to_ns_size(Gfx::IntSize); + +Gfx::IntPoint ns_point_to_gfx_point(NSPoint); +NSPoint gfx_point_to_ns_point(Gfx::IntPoint); + +} diff --git a/Ladybird/AppKit/Utilities/Conversions.mm b/Ladybird/AppKit/Utilities/Conversions.mm new file mode 100644 index 00000000000..78066730fc3 --- /dev/null +++ b/Ladybird/AppKit/Utilities/Conversions.mm @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#import + +namespace Ladybird { + +String ns_string_to_string(NSString* string) +{ + auto const* utf8 = [string UTF8String]; + return MUST(String::from_utf8({ utf8, strlen(utf8) })); +} + +NSString* string_to_ns_string(StringView string) +{ + auto* data = [NSData dataWithBytes:string.characters_without_null_termination() length:string.length()]; + return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; +} + +Gfx::IntRect ns_rect_to_gfx_rect(NSRect rect) +{ + return { + static_cast(rect.origin.x), + static_cast(rect.origin.y), + static_cast(rect.size.width), + static_cast(rect.size.height), + }; +} + +NSRect gfx_rect_to_ns_rect(Gfx::IntRect rect) +{ + return NSMakeRect( + static_cast(rect.x()), + static_cast(rect.y()), + static_cast(rect.width()), + static_cast(rect.height())); +} + +Gfx::IntSize ns_size_to_gfx_size(NSSize size) +{ + return { + static_cast(size.width), + static_cast(size.height), + }; +} + +NSSize gfx_size_to_ns_size(Gfx::IntSize size) +{ + return NSMakeSize( + static_cast(size.width()), + static_cast(size.height())); +} + +Gfx::IntPoint ns_point_to_gfx_point(NSPoint point) +{ + return { + static_cast(point.x), + static_cast(point.y), + }; +} + +NSPoint gfx_point_to_ns_point(Gfx::IntPoint point) +{ + return NSMakePoint( + static_cast(point.x()), + static_cast(point.y())); +} + +} diff --git a/Ladybird/AppKit/Utilities/URL.h b/Ladybird/AppKit/Utilities/URL.h new file mode 100644 index 00000000000..bc899fffa3f --- /dev/null +++ b/Ladybird/AppKit/Utilities/URL.h @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include + +#import + +namespace Ladybird { + +URL sanitize_url(NSString*); +URL sanitize_url(StringView); + +URL rebase_url_on_serenity_resource_root(StringView); + +} diff --git a/Ladybird/AppKit/Utilities/URL.mm b/Ladybird/AppKit/Utilities/URL.mm new file mode 100644 index 00000000000..6b07e01e2c7 --- /dev/null +++ b/Ladybird/AppKit/Utilities/URL.mm @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include + +#import + +namespace Ladybird { + +URL sanitize_url(StringView url_string) +{ + if (url_string.starts_with('/') || FileSystem::exists(url_string)) + return MUST(String::formatted("file://{}", MUST(FileSystem::real_path(url_string)))); + + URL url { url_string }; + if (!url.is_valid()) + url = MUST(String::formatted("https://{}", url_string)); + + return url; +} + +URL sanitize_url(NSString* url_string) +{ + auto const* utf8 = [url_string UTF8String]; + return sanitize_url({ utf8, strlen(utf8) }); +} + +URL rebase_url_on_serenity_resource_root(StringView url_string) +{ + URL url { url_string }; + Vector paths; + + for (auto segment : s_serenity_resource_root.split('/')) + paths.append(move(segment)); + + for (size_t i = 0; i < url.path_segment_count(); ++i) + paths.append(url.path_segment_at_index(i)); + + url.set_paths(move(paths)); + + return url; +} + +} diff --git a/Ladybird/AppKit/main.mm b/Ladybird/AppKit/main.mm new file mode 100644 index 00000000000..c7792a39a6a --- /dev/null +++ b/Ladybird/AppKit/main.mm @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2023, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include +#include +#include + +#import +#import +#import +#import +#import +#import + +#if !__has_feature(objc_arc) +# error "This project requires ARC" +#endif + +ErrorOr serenity_main(Main::Arguments arguments) +{ + [Application sharedApplication]; + + Core::EventLoopManager::install(*new Ladybird::CFEventLoopManager); + Core::EventLoop event_loop; + + platform_init(); + + // NOTE: We only instantiate this to ensure that Gfx::FontDatabase has its default queries initialized. + Gfx::FontDatabase::set_default_font_query("Katica 10 400 0"); + Gfx::FontDatabase::set_fixed_width_font_query("Csilla 10 400 0"); + + StringView url; + StringView webdriver_content_ipc_path; + + Core::ArgsParser args_parser; + args_parser.set_general_help("The Ladybird web browser"); + args_parser.add_positional_argument(url, "URL to open", "url", Core::ArgsParser::Required::No); + args_parser.add_option(webdriver_content_ipc_path, "Path to WebDriver IPC for WebContent", "webdriver-content-path", 0, "path"); + args_parser.parse(arguments); + + auto sql_server_paths = TRY(get_paths_for_helper_process("SQLServer"sv)); + auto sql_client = TRY(SQL::SQLClient::launch_server_and_create_client(move(sql_server_paths))); + + auto database = TRY(Browser::Database::create(move(sql_client))); + auto cookie_jar = TRY(Browser::CookieJar::create(*database)); + + Optional initial_url; + if (auto parsed_url = Ladybird::sanitize_url(url); parsed_url.is_valid()) { + initial_url = move(parsed_url); + } + + auto* delegate = [[ApplicationDelegate alloc] init:move(initial_url) + withCookieJar:move(cookie_jar) + webdriverContentIPCPath:webdriver_content_ipc_path]; + + [NSApp setDelegate:delegate]; + [NSApp activateIgnoringOtherApps:YES]; + + return event_loop.exec(); +} diff --git a/Ladybird/CMakeLists.txt b/Ladybird/CMakeLists.txt index 032e1ffc9b6..70a6aeded4c 100644 --- a/Ladybird/CMakeLists.txt +++ b/Ladybird/CMakeLists.txt @@ -81,6 +81,8 @@ if (ENABLE_QT) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) find_package(Qt6 REQUIRED COMPONENTS Core Widgets Network Multimedia) +elseif (APPLE) + find_library(COCOA_LIBRARY Cocoa) endif() set(BROWSER_SOURCE_DIR ${SERENITY_SOURCE_DIR}/Userland/Applications/Browser/) @@ -124,11 +126,33 @@ if (ENABLE_QT) Qt/main.cpp ) target_link_libraries(ladybird PRIVATE Qt::Core Qt::Gui Qt::Network Qt::Widgets) +elseif (APPLE) + add_executable(ladybird MACOSX_BUNDLE + ${SOURCES} + AppKit/main.mm + AppKit/Application/Application.mm + AppKit/Application/ApplicationDelegate.mm + AppKit/Application/EventLoopImplementation.mm + AppKit/UI/Event.mm + AppKit/UI/LadybirdWebView.mm + AppKit/UI/LadybirdWebViewBridge.cpp + AppKit/UI/Tab.mm + AppKit/UI/TabController.mm + AppKit/Utilities/Conversions.mm + AppKit/Utilities/URL.mm + ) + target_include_directories(ladybird PRIVATE AppKit) + target_link_libraries(ladybird PRIVATE ${COCOA_LIBRARY}) + target_compile_options(ladybird PRIVATE + -fobjc-arc + -Wno-deprecated-anon-enum-enum-conversion # Required for CGImageCreate + ) else() # TODO: Check for other GUI frameworks here when we move them in-tree # For now, we can export a static library of common files for chromes to link to add_library(ladybird STATIC ${SOURCES}) endif() + target_sources(ladybird PUBLIC FILE_SET browser TYPE HEADERS BASE_DIRS ${SERENITY_SOURCE_DIR}/Userland/Applications FILES ${BROWSER_HEADERS} @@ -205,9 +229,7 @@ function(create_ladybird_bundle target_name) endif() endfunction() -if (ENABLE_QT) - create_ladybird_bundle(ladybird) -endif() +create_ladybird_bundle(ladybird) if(NOT CMAKE_SKIP_INSTALL_RULES) include(cmake/InstallRules.cmake) diff --git a/Meta/check-style.py b/Meta/check-style.py index 2f77634a017..f5208747913 100755 --- a/Meta/check-style.py +++ b/Meta/check-style.py @@ -34,6 +34,8 @@ LICENSE_HEADER_CHECK_EXCLUDES = { PRAGMA_ONCE_STRING = '#pragma once' PRAGMA_ONCE_CHECK_EXCLUDES = { 'Userland/Libraries/LibC/assert.h', + 'Ladybird/AppKit/System/Detail/Header.h', + 'Ladybird/AppKit/System/Detail/Footer.h', } # We make sure that there's a blank line before and after pragma once