ladybird/UI/AppKit/Interface/Tab.mm
Timothy Flynn 7fc71e563b LibWebView+UI: Reduce boilerplate to update the bookmarks bar display
We currently use LibWebView's Application as the entry point to learn
about the bookmarks bar being shown/hidden, and propagate that through
virtual methods. At the time this was added, AppKit's Tab window and
Qt's BrowserWindow did not have a settings observer. They do now, so
let's skip a couple of middle-men.

For AppKit, we change the settings observer to just (weakly) store the
Tab instance so that we don't have to add callback functions for each
setting.
2026-06-03 18:14:12 -04:00

482 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(Tab* tab)
: m_tab(tab)
{
}
private:
// These are forward-declared so that they may access non-public Tab methods.
virtual void show_bookmarks_bar_changed() override;
virtual void config_variable_changed(WebView::ConfigVariableID variable) override;
__weak Tab* m_tab { nil };
};
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";
[self updateTabTitleAndFavicon];
[self setTitleVisibility:NSWindowTitleHidden];
[self setIsVisible:YES];
m_settings_observer = make<TabSettingsObserver>(self);
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
void TabSettingsObserver::show_bookmarks_bar_changed()
{
[m_tab updateBookmarksBarDisplay:WebView::Application::settings().show_bookmarks_bar()];
}
void TabSettingsObserver::config_variable_changed(WebView::ConfigVariableID variable)
{
if (variable == WebView::ConfigVariableID::ShowWebContentProcessIDInTabTitle)
[m_tab updateTabTitleAndFavicon];
}