ladybird/UI/AppKit/Interface/Tab.mm
Andreas Kling 82b270a154 UI: Add WebContent PID debug preference
Register debug.process.show_web_content_process_id and use it to append
the active WebContent process ID to tab titles and tab tooltips in the
Qt, Gtk, and AppKit frontends.

Keep the stored page title unchanged so toggling the preference can
refresh each tab without waiting for a new title update.
2026-05-24 13:11:17 +02:00

479 lines
16 KiB
Text

/*
* Copyright (c) 2023-2026, Tim Flynn <trflynn89@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/Function.h>
#include <AK/OwnPtr.h>
#include <AK/String.h>
#include <LibCore/Resource.h>
#include <LibURL/URL.h>
#include <LibWebView/Application.h>
#include <LibWebView/Settings.h>
#include <LibWebView/ViewImplementation.h>
#include <LibWebView/WebContentClient.h>
#import <Application/ApplicationDelegate.h>
#import <Interface/BookmarksBar.h>
#import <Interface/LadybirdWebView.h>
#import <Interface/SearchPanel.h>
#import <Interface/Tab.h>
#import <Interface/TabController.h>
#import <Utilities/Conversions.h>
#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;
static constexpr CGFloat const TAB_ICON_SIZE = 16;
static constexpr NSUInteger const TAB_LOADING_SPINNER_SEGMENT_COUNT = 12;
class TabSettingsObserver final : public WebView::SettingsObserver {
public:
explicit TabSettingsObserver(Function<void(WebView::ConfigVariableID)> callback)
: m_callback(move(callback))
{
}
virtual void config_variable_changed(WebView::ConfigVariableID variable) override
{
m_callback(variable);
}
private:
Function<void(WebView::ConfigVariableID)> m_callback;
};
static NSImage* tab_loading_spinner_icon(NSUInteger frame)
{
auto* image = [NSImage imageWithSize:NSMakeSize(TAB_ICON_SIZE, TAB_ICON_SIZE)
flipped:NO
drawingHandler:^BOOL(NSRect) {
auto* context = [NSGraphicsContext currentContext].CGContext;
auto* color = [NSColor labelColor];
static constexpr CGFloat radians_per_segment = 2.0 * 3.14159265358979323846 / TAB_LOADING_SPINNER_SEGMENT_COUNT;
CGContextSaveGState(context);
CGContextTranslateCTM(context, TAB_ICON_SIZE / 2.0, TAB_ICON_SIZE / 2.0);
for (NSUInteger segment = 0; segment < TAB_LOADING_SPINNER_SEGMENT_COUNT; ++segment) {
auto alpha = static_cast<CGFloat>(((segment + frame % TAB_LOADING_SPINNER_SEGMENT_COUNT) % TAB_LOADING_SPINNER_SEGMENT_COUNT) + 1) / TAB_LOADING_SPINNER_SEGMENT_COUNT;
auto* segment_color = [color colorWithAlphaComponent:alpha];
CGContextSaveGState(context);
CGContextRotateCTM(context, static_cast<CGFloat>(segment) * radians_per_segment);
CGContextSetStrokeColorWithColor(context, segment_color.CGColor);
CGContextSetLineWidth(context, 2);
CGContextSetLineCap(context, kCGLineCapRound);
CGContextMoveToPoint(context, 0, -4);
CGContextAddLineToPoint(context, 0, -7);
CGContextStrokePath(context);
CGContextRestoreGState(context);
}
CGContextRestoreGState(context);
return YES;
}];
return image;
}
@interface Tab () <LadybirdWebViewObserver>
{
BOOL m_loading;
NSUInteger m_loading_spinner_frame;
__strong NSTimer* m_loading_spinner_timer;
OwnPtr<TabSettingsObserver> m_settings_observer;
}
@property (nonatomic, strong) NSString* title;
@property (nonatomic, strong) NSImage* favicon;
@property (nonatomic, strong) NSTitlebarAccessoryViewController* bookmarks_bar_controller;
@property (nonatomic, strong) SearchPanel* search_panel;
@end
@implementation Tab
@dynamic title;
+ (NSImage*)defaultFavicon
{
static NSImage* default_favicon;
static dispatch_once_t token;
dispatch_once(&token, ^{
auto default_favicon_path = MUST(Core::Resource::load_from_uri("resource://icons/48x48/app-browser.png"sv));
auto* ns_default_favicon_path = Ladybird::string_to_ns_string(default_favicon_path->filesystem_path());
default_favicon = [[NSImage alloc] initWithContentsOfFile:ns_default_favicon_path];
});
return default_favicon;
}
- (instancetype)init
{
auto* web_view = [[LadybirdWebView alloc] init:self];
return [self initWithWebView:web_view];
}
- (instancetype)initAsChild:(Tab*)parent
pageIndex:(u64)page_index
{
auto* web_view = [[LadybirdWebView alloc] initAsChild:self parent:[parent web_view] pageIndex:page_index];
return [self initWithWebView:web_view];
}
- (instancetype)initWithWebView:(LadybirdWebView*)web_view
{
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);
if (self = [super initWithWebView:web_view windowRect:window_rect]) {
// Remember last window position
self.frameAutosaveName = @"window";
self.favicon = [Tab defaultFavicon];
self.title = @"New Tab";
__weak Tab* weak_self = self;
m_settings_observer = make<TabSettingsObserver>([weak_self](WebView::ConfigVariableID variable) {
if (variable != WebView::ConfigVariableID::ShowWebContentProcessIDInTabTitle)
return;
Tab* strong_self = weak_self;
if (strong_self == nil)
return;
[strong_self updateTabTitleAndFavicon];
});
[self updateTabTitleAndFavicon];
[self setTitleVisibility:NSWindowTitleHidden];
[self setIsVisible:YES];
auto* bookmarks_bar = [[BookmarksBar alloc] init];
self.bookmarks_bar_controller = [[NSTitlebarAccessoryViewController alloc] init];
[self.bookmarks_bar_controller setView:bookmarks_bar];
[self.bookmarks_bar_controller setLayoutAttribute:NSLayoutAttributeBottom];
[self updateBookmarksBarDisplay:WebView::Application::settings().show_bookmarks_bar()];
[self addTitlebarAccessoryViewController:self.bookmarks_bar_controller];
self.search_panel = [[SearchPanel alloc] init];
[self.search_panel setHidden:YES];
auto* stack_view = [NSStackView stackViewWithViews:@[
self.search_panel,
self.web_view,
]];
[stack_view setOrientation:NSUserInterfaceLayoutOrientationVertical];
[stack_view setSpacing:0];
[self setContentView:stack_view];
[[self.search_panel leadingAnchor] constraintEqualToAnchor:[self.contentView leadingAnchor]].active = YES;
}
return self;
}
- (void)dealloc
{
[m_loading_spinner_timer invalidate];
}
#pragma mark - Public methods
- (void)find:(id)sender
{
[self.search_panel find:sender];
}
- (void)findNextMatch:(id)sender
{
[self.search_panel findNextMatch:sender];
}
- (void)findPreviousMatch:(id)sender
{
[self.search_panel findPreviousMatch:sender];
}
- (void)useSelectionForFind:(id)sender
{
[self.search_panel useSelectionForFind:sender];
}
#pragma mark - Private methods
- (TabController*)tabController
{
return (TabController*)[self windowController];
}
- (NSImage*)tabIcon
{
if (m_loading)
return tab_loading_spinner_icon(m_loading_spinner_frame);
return self.favicon;
}
- (NSString*)displayTitle
{
if (!WebView::Application::settings().config_variable_as_bool(WebView::ConfigVariableID::ShowWebContentProcessIDInTabTitle))
return self.title;
auto title = MUST(String::formatted("{} [{}]", Ladybird::ns_string_to_string(self.title), [[self web_view] view].client().pid()));
return Ladybird::string_to_ns_string(title);
}
- (void)updateLoadingSpinner
{
if (!m_loading)
return;
m_loading_spinner_frame = (m_loading_spinner_frame + 1) % TAB_LOADING_SPINNER_SEGMENT_COUNT;
[self updateTabTitleAndFavicon];
}
- (void)setTabLoading:(BOOL)loading
{
if (m_loading == loading)
return;
m_loading = loading;
m_loading_spinner_frame = 0;
if (m_loading) {
__weak Tab* weak_self = self;
m_loading_spinner_timer = [NSTimer timerWithTimeInterval:0.08
repeats:YES
block:^(NSTimer*) {
Tab* strong_self = weak_self;
if (strong_self == nil)
return;
[strong_self updateLoadingSpinner];
}];
[[NSRunLoop mainRunLoop] addTimer:m_loading_spinner_timer forMode:NSRunLoopCommonModes];
} else {
[m_loading_spinner_timer invalidate];
m_loading_spinner_timer = nil;
}
[self updateTabTitleAndFavicon];
}
- (void)updateTabTitleAndFavicon
{
static constexpr CGFloat TITLE_FONT_SIZE = 12;
NSFont* title_font = [NSFont systemFontOfSize:TITLE_FONT_SIZE];
auto* favicon_attachment = [[NSTextAttachment alloc] init];
favicon_attachment.image = [self tabIcon];
// 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])];
// adjust the favicon image to middle center the title text
CGFloat offset_y = (title_font.capHeight - TAB_ICON_SIZE) / 2.f;
[favicon_attachment setBounds:CGRectMake(0, offset_y, TAB_ICON_SIZE, TAB_ICON_SIZE)];
auto* title_attributes = @{
NSForegroundColorAttributeName : [NSColor textColor],
NSFontAttributeName : title_font
};
auto* display_title = [self displayTitle];
auto* title_attribute = [[NSAttributedString alloc] initWithString:display_title
attributes:title_attributes];
auto* spacing_attribute = [[NSAttributedString alloc] initWithString:@" "
attributes:title_attributes];
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];
if ([[self tab] respondsToSelector:@selector(setToolTip:)])
[(id)[self tab] setToolTip:display_title];
}
- (void)togglePageMuteState:(id)button
{
auto& view = [[self web_view] view];
view.toggle_page_mute_state();
switch (view.audio_play_state()) {
case Web::HTML::AudioPlayState::Paused:
[[self tab] setAccessoryView:nil];
break;
case Web::HTML::AudioPlayState::Playing:
[button setImage:[self iconForPageMuteState]];
[button setToolTip:[self toolTipForPageMuteState]];
break;
}
}
- (NSImage*)iconForPageMuteState
{
auto& view = [[self web_view] view];
switch (view.page_mute_state()) {
case Web::HTML::MuteState::Muted:
return [NSImage imageNamed:NSImageNameTouchBarAudioOutputVolumeOffTemplate];
case Web::HTML::MuteState::Unmuted:
return [NSImage imageNamed:NSImageNameTouchBarAudioOutputVolumeHighTemplate];
}
VERIFY_NOT_REACHED();
}
- (NSString*)toolTipForPageMuteState
{
auto& view = [[self web_view] view];
switch (view.page_mute_state()) {
case Web::HTML::MuteState::Muted:
return @"Unmute tab";
case Web::HTML::MuteState::Unmuted:
return @"Mute tab";
}
VERIFY_NOT_REACHED();
}
#pragma mark - LadybirdWebViewObserver
- (String const&)onCreateNewTab:(Optional<URL::URL> const&)url
activateTab:(Web::HTML::ActivateTab)activate_tab
{
auto* delegate = (ApplicationDelegate*)[NSApp delegate];
auto* controller = [delegate createNewTab:url
fromTab:self
activateTab:activate_tab];
auto* tab = (Tab*)[controller window];
return [[tab web_view] handle];
}
- (String const&)onCreateChildTab:(Optional<URL::URL> const&)url
activateTab:(Web::HTML::ActivateTab)activate_tab
pageIndex:(u64)page_index
{
auto* delegate = (ApplicationDelegate*)[NSApp delegate];
auto* controller = [delegate createChildTab:url
fromTab:self
activateTab:activate_tab
pageIndex:page_index];
auto* tab = (Tab*)[controller window];
return [[tab web_view] handle];
}
- (void)onLoadStart:(URL::URL const&)url isRedirect:(BOOL)is_redirect
{
self.title = Ladybird::string_to_ns_string(url.serialize());
self.favicon = [Tab defaultFavicon];
[self setTabLoading:YES];
[self updateTabTitleAndFavicon];
[[self tabController] onFaviconChange:nil];
[[self tabController] onLoadStart:url isRedirect:is_redirect];
}
- (void)onLoadFinish:(URL::URL const&)url
{
[self setTabLoading:NO];
[[self tabController] onLoadFinish:url];
}
- (void)onURLChange:(URL::URL const&)url
{
[[self tabController] onURLChange:url];
}
- (void)onTitleChange:(Utf16String const&)title
{
self.title = Ladybird::utf16_string_to_ns_string(title);
[self updateTabTitleAndFavicon];
}
- (void)onFaviconChange:(Gfx::Bitmap const&)bitmap
{
auto* favicon = Ladybird::gfx_bitmap_to_ns_image(bitmap);
[favicon setResizingMode:NSImageResizingModeStretch];
self.favicon = favicon;
[self updateTabTitleAndFavicon];
[[self tabController] onFaviconChange:favicon];
}
- (BookmarksBar*)bookmarksBar
{
return (BookmarksBar*)[self.bookmarks_bar_controller view];
}
- (void)rebuildBookmarksBar
{
[[self bookmarksBar] rebuild];
}
- (void)updateBookmarksBarDisplay:(bool)show_bookmarks_bar
{
[self.bookmarks_bar_controller setHidden:!show_bookmarks_bar];
}
- (void)onAudioPlayStateChange:(Web::HTML::AudioPlayState)play_state
{
auto& view = [[self web_view] view];
switch (play_state) {
case Web::HTML::AudioPlayState::Paused:
if (view.page_mute_state() == Web::HTML::MuteState::Unmuted) {
[[self tab] setAccessoryView:nil];
}
break;
case Web::HTML::AudioPlayState::Playing:
auto* button = [NSButton buttonWithImage:[self iconForPageMuteState]
target:self
action:@selector(togglePageMuteState:)];
[button setToolTip:[self toolTipForPageMuteState]];
[[self tab] setAccessoryView:button];
break;
}
}
- (void)onEnterFullscreenWindow
{
[[self tabController] onEnterFullscreenWindow];
}
- (void)onExitFullscreenWindow
{
[[self tabController] onExitFullscreenWindow];
}
- (void)onFindInPageResult:(size_t)current_match_index
totalMatchCount:(Optional<size_t> const&)total_match_count
{
[self.search_panel onFindInPageResult:current_match_index
totalMatchCount:total_match_count];
}
@end