Files
ladybird/UI/AppKit/Interface/TabController.mm
Andreas Kling 1802a05bc2 UI/AppKit: Show rich autocomplete suggestion rows
Render AutocompleteSuggestion rows with section headers, favicons,
titles, and secondary text in the child-window popup instead of just
plain strings.

Move the AppKit popup and inline completion onto the shared suggestion
model, and share the base64 PNG decoding helper with the application
menu icon loading path.
2026-04-16 21:01:28 +02:00

1047 lines
36 KiB
Plaintext

/*
* Copyright (c) 2023-2026, Tim Flynn <trflynn89@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibWebView/Application.h>
#include <LibWebView/Autocomplete.h>
#include <LibWebView/BookmarkStore.h>
#include <LibWebView/URL.h>
#include <LibWebView/ViewImplementation.h>
#import <Application/ApplicationDelegate.h>
#import <Interface/Autocomplete.h>
#import <Interface/LadybirdWebView.h>
#import <Interface/Menu.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 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_ZOOM_IDENTIFIER = @"ToolbarZoomIdentifier";
static NSString* const TOOLBAR_BOOKMARK_IDENTIFIER = @"ToolbarBookmarkIdentifier";
static NSString* const TOOLBAR_NEW_TAB_IDENTIFIER = @"ToolbarNewTabIdentifier";
static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIdentifier";
static NSString* candidate_by_trimming_root_trailing_slash(NSString* candidate);
static bool query_matches_candidate_exactly(NSString* query, NSString* candidate)
{
auto* trimmed_candidate = candidate_by_trimming_root_trailing_slash(candidate);
return [trimmed_candidate compare:query options:NSCaseInsensitiveSearch] == NSOrderedSame;
}
static NSString* inline_autocomplete_text_for_candidate(NSString* query, NSString* candidate)
{
if (query.length == 0 || candidate.length <= query.length)
return nil;
auto prefix_range = [candidate rangeOfString:query options:NSCaseInsensitiveSearch | NSAnchoredSearch];
if (prefix_range.location == NSNotFound)
return nil;
auto* suffix = [candidate substringFromIndex:query.length];
return [query stringByAppendingString:suffix];
}
static NSString* inline_autocomplete_text_for_suggestion(NSString* query, NSString* suggestion_text)
{
auto* trimmed_suggestion_text = candidate_by_trimming_root_trailing_slash(suggestion_text);
if (auto* direct_match = inline_autocomplete_text_for_candidate(query, trimmed_suggestion_text); direct_match != nil)
return direct_match;
if ([trimmed_suggestion_text hasPrefix:@"www."]) {
auto* stripped_www_suggestion = [trimmed_suggestion_text substringFromIndex:4];
if (auto* stripped_www_match = inline_autocomplete_text_for_candidate(query, stripped_www_suggestion); stripped_www_match != nil)
return stripped_www_match;
}
for (NSString* scheme_prefix in @[ @"https://", @"http://" ]) {
if (![trimmed_suggestion_text hasPrefix:scheme_prefix])
continue;
auto* stripped_suggestion = [trimmed_suggestion_text substringFromIndex:scheme_prefix.length];
if (auto* stripped_match = inline_autocomplete_text_for_candidate(query, stripped_suggestion); stripped_match != nil)
return stripped_match;
if ([stripped_suggestion hasPrefix:@"www."]) {
auto* stripped_www_suggestion = [stripped_suggestion substringFromIndex:4];
if (auto* stripped_www_match = inline_autocomplete_text_for_candidate(query, stripped_www_suggestion); stripped_www_match != nil)
return stripped_www_match;
}
}
return nil;
}
static bool suggestion_matches_query_exactly(NSString* query, NSString* suggestion_text)
{
auto* trimmed_suggestion_text = candidate_by_trimming_root_trailing_slash(suggestion_text);
if (query_matches_candidate_exactly(query, trimmed_suggestion_text))
return true;
if ([trimmed_suggestion_text hasPrefix:@"www."]) {
auto* stripped_www_suggestion = [trimmed_suggestion_text substringFromIndex:4];
if (query_matches_candidate_exactly(query, stripped_www_suggestion))
return true;
}
for (NSString* scheme_prefix in @[ @"https://", @"http://" ]) {
if (![trimmed_suggestion_text hasPrefix:scheme_prefix])
continue;
auto* stripped_suggestion = [trimmed_suggestion_text substringFromIndex:scheme_prefix.length];
if (query_matches_candidate_exactly(query, stripped_suggestion))
return true;
if ([stripped_suggestion hasPrefix:@"www."]) {
auto* stripped_www_suggestion = [stripped_suggestion substringFromIndex:4];
if (query_matches_candidate_exactly(query, stripped_www_suggestion))
return true;
}
}
return false;
}
static NSString* candidate_by_trimming_root_trailing_slash(NSString* candidate)
{
if (![candidate hasSuffix:@"/"])
return candidate;
auto* host_and_path = candidate;
for (NSString* scheme_prefix in @[ @"https://", @"http://" ]) {
if ([host_and_path hasPrefix:scheme_prefix]) {
host_and_path = [host_and_path substringFromIndex:scheme_prefix.length];
break;
}
}
auto first_slash = [host_and_path rangeOfString:@"/"];
if (first_slash.location == NSNotFound || first_slash.location != host_and_path.length - 1)
return candidate;
return [candidate substringToIndex:candidate.length - 1];
}
static bool should_suppress_inline_autocomplete_for_selector(SEL selector)
{
return selector == @selector(deleteBackward:)
|| selector == @selector(deleteBackwardByDecomposingPreviousCharacter:)
|| selector == @selector(deleteForward:)
|| selector == @selector(deleteToBeginningOfLine:)
|| selector == @selector(deleteToEndOfLine:)
|| selector == @selector(deleteWordBackward:)
|| selector == @selector(deleteWordForward:);
}
static NSInteger autocomplete_suggestion_index(NSString* suggestion_text, Vector<WebView::AutocompleteSuggestion> const& suggestions)
{
for (size_t index = 0; index < suggestions.size(); ++index) {
auto* candidate_text = Ladybird::string_to_ns_string(suggestions[index].text);
if ([candidate_text isEqualToString:suggestion_text])
return static_cast<NSInteger>(index);
}
return NSNotFound;
}
@interface LocationSearchField : NSSearchField
- (BOOL)becomeFirstResponder;
@end
@implementation LocationSearchField
- (BOOL)becomeFirstResponder
{
BOOL result = [super becomeFirstResponder];
if (result)
[self performSelector:@selector(selectText:) withObject:self afterDelay:0];
return result;
}
// NSSearchField does not provide an intrinsic width, which causes an ambiguous layout warning when the toolbar auto-
// measures this view. This provides an initial fallback, which is overridden with an explicit width in windowDidResize.
- (NSSize)intrinsicContentSize
{
auto size = [super intrinsicContentSize];
if (size.width < 0)
size.width = 400;
return size;
}
@end
@interface TabController () <NSToolbarDelegate, NSSearchFieldDelegate, AutocompleteObserver>
{
u64 m_page_index;
OwnPtr<WebView::Autocomplete> m_autocomplete;
bool m_is_applying_inline_autocomplete;
bool m_should_suppress_inline_autocomplete_on_next_change;
bool m_fullscreen_requested_for_web_content;
bool m_fullscreen_exit_was_ui_initiated;
bool m_fullscreen_should_restore_tab_bar;
}
@property (nonatomic, assign) BOOL already_requested_close;
@property (nonatomic, strong) Tab* parent;
@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* zoom_toolbar_item;
@property (nonatomic, strong) NSToolbarItem* bookmark_toolbar_item;
@property (nonatomic, strong) NSToolbarItem* new_tab_toolbar_item;
@property (nonatomic, strong) NSToolbarItem* tab_overview_toolbar_item;
@property (nonatomic, strong) Autocomplete* autocomplete;
@property (nonatomic, copy) NSString* current_inline_autocomplete_suggestion;
@property (nonatomic, copy) NSString* suppressed_inline_autocomplete_query;
@property (nonatomic, assign) NSLayoutConstraint* location_toolbar_item_width;
- (NSString*)currentLocationFieldQuery;
- (BOOL)applyInlineAutocompleteSuggestionText:(NSString*)suggestion_text
forQuery:(NSString*)query;
- (void)applyLocationFieldInlineAutocompleteText:(NSString*)inline_text
forQuery:(NSString*)query;
- (NSInteger)applyInlineAutocomplete:(Vector<WebView::AutocompleteSuggestion> const&)suggestions;
- (void)previewHighlightedSuggestionInLocationField:(String const&)suggestion;
- (void)restoreLocationFieldQuery;
@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 zoom_toolbar_item = _zoom_toolbar_item;
@synthesize bookmark_toolbar_item = _bookmark_toolbar_item;
@synthesize new_tab_toolbar_item = _new_tab_toolbar_item;
@synthesize tab_overview_toolbar_item = _tab_overview_toolbar_item;
- (instancetype)init
{
if (self = [super init]) {
__weak TabController* weak_self = self;
self.toolbar = [[NSToolbar alloc] initWithIdentifier:TOOLBAR_IDENTIFIER];
[self.toolbar setDelegate:self];
[self.toolbar setDisplayMode:NSToolbarDisplayModeIconOnly];
if (@available(macOS 15, *)) {
if ([self.toolbar respondsToSelector:@selector(setAllowsDisplayModeCustomization:)]) {
[self.toolbar performSelector:@selector(setAllowsDisplayModeCustomization:) withObject:nil];
}
}
[self.toolbar setAllowsUserCustomization:NO];
[self.toolbar setSizeMode:NSToolbarSizeModeRegular];
m_page_index = 0;
m_is_applying_inline_autocomplete = false;
m_should_suppress_inline_autocomplete_on_next_change = false;
m_fullscreen_requested_for_web_content = false;
m_fullscreen_exit_was_ui_initiated = true;
m_fullscreen_should_restore_tab_bar = false;
self.autocomplete = [[Autocomplete alloc] init:self withToolbarItem:self.location_toolbar_item];
m_autocomplete = make<WebView::Autocomplete>();
m_autocomplete->on_autocomplete_query_complete = [weak_self](auto suggestions, WebView::AutocompleteResultKind result_kind) {
TabController* self = weak_self;
if (self == nil) {
return;
}
auto selected_row = [self applyInlineAutocomplete:suggestions];
if (result_kind == WebView::AutocompleteResultKind::Intermediate && [self.autocomplete isVisible]) {
if (auto selected_suggestion = [self.autocomplete selectedSuggestion];
selected_suggestion.has_value()) {
for (auto const& suggestion : suggestions) {
if (suggestion.text == *selected_suggestion)
return;
}
}
[self.autocomplete clearSelection];
return;
}
[self.autocomplete showWithSuggestions:move(suggestions)
selectedRow:selected_row];
};
}
return self;
}
- (instancetype)initAsChild:(Tab*)parent
pageIndex:(u64)page_index
{
if (self = [self init]) {
self.parent = parent;
m_page_index = page_index;
m_fullscreen_requested_for_web_content = false;
m_fullscreen_exit_was_ui_initiated = true;
m_fullscreen_should_restore_tab_bar = false;
}
return self;
}
#pragma mark - Public methods
- (void)loadURL:(URL::URL const&)url
{
[[self tab].web_view loadURL:url];
}
- (void)onLoadStart:(URL::URL const&)url isRedirect:(BOOL)isRedirect
{
[self setLocationFieldText:url.serialize()];
}
- (void)onURLChange:(URL::URL const&)url
{
[self setLocationFieldText:url.serialize()];
// Don't steal focus from the location bar when loading the new tab page
if (url != WebView::Application::settings().new_tab_page_url()) {
[self.window makeFirstResponder:[self tab].web_view];
}
}
- (void)onEnterFullscreenWindow
{
m_fullscreen_requested_for_web_content = true;
if (([self.window styleMask] & NSWindowStyleMaskFullScreen) == 0) {
[self.window toggleFullScreen:nil];
}
}
- (void)onExitFullscreenWindow
{
if (([self.window styleMask] & NSWindowStyleMaskFullScreen) != 0) {
m_fullscreen_exit_was_ui_initiated = false;
[self.window toggleFullScreen:nil];
}
}
- (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];
self.tab.titlebarAppearsTransparent = NO;
[delegate createNewTab:WebView::Application::settings().new_tab_page_url()
fromTab:[self tab]
activateTab:Web::HTML::ActivateTab::Yes];
self.tab.titlebarAppearsTransparent = YES;
}
- (void)setLocationFieldText:(StringView)url
{
NSMutableAttributedString* attributed_url;
auto* dark_attributes = @{
NSForegroundColorAttributeName : [NSColor systemGrayColor],
};
auto* highlight_attributes = @{
NSForegroundColorAttributeName : [NSColor textColor],
};
if (auto url_parts = WebView::break_url_into_parts(url); url_parts.has_value()) {
attributed_url = [[NSMutableAttributedString alloc] init];
auto* attributed_scheme_and_subdomain = [[NSAttributedString alloc]
initWithString:Ladybird::string_to_ns_string(url_parts->scheme_and_subdomain)
attributes:dark_attributes];
auto* attributed_effective_tld_plus_one = [[NSAttributedString alloc]
initWithString:Ladybird::string_to_ns_string(url_parts->effective_tld_plus_one)
attributes:highlight_attributes];
auto* attributed_remainder = [[NSAttributedString alloc]
initWithString:Ladybird::string_to_ns_string(url_parts->remainder)
attributes:dark_attributes];
[attributed_url appendAttributedString:attributed_scheme_and_subdomain];
[attributed_url appendAttributedString:attributed_effective_tld_plus_one];
[attributed_url appendAttributedString:attributed_remainder];
} else {
attributed_url = [[NSMutableAttributedString alloc]
initWithString:Ladybird::string_to_ns_string(url)
attributes:highlight_attributes];
}
auto* location_search_field = (LocationSearchField*)[self.location_toolbar_item view];
[location_search_field setAttributedStringValue:attributed_url];
}
- (NSString*)currentLocationFieldQuery
{
auto* location_search_field = (LocationSearchField*)[self.location_toolbar_item view];
auto* editor = (NSTextView*)[location_search_field currentEditor];
// Inline autocomplete mutates the field contents in place, so callers
// need a detached copy of the typed prefix for asynchronous comparisons.
if (editor == nil || [self.window firstResponder] != editor)
return [[location_search_field stringValue] copy];
auto* text = [[editor textStorage] string];
auto selected_range = [editor selectedRange];
if (selected_range.location == NSNotFound)
return [text copy];
if (selected_range.length == 0)
return [text copy];
if (NSMaxRange(selected_range) != text.length)
return [text copy];
return [[text substringToIndex:selected_range.location] copy];
}
- (NSInteger)applyInlineAutocomplete:(Vector<WebView::AutocompleteSuggestion> const&)suggestions
{
if (m_is_applying_inline_autocomplete)
return NSNotFound;
auto* location_search_field = (LocationSearchField*)[self.location_toolbar_item view];
auto* editor = (NSTextView*)[location_search_field currentEditor];
if (editor == nil || [self.window firstResponder] != editor || [editor hasMarkedText])
return NSNotFound;
auto* current_text = [[editor textStorage] string];
auto selected_range = [editor selectedRange];
if (selected_range.location == NSNotFound)
return NSNotFound;
auto current_text_length = current_text.length;
NSString* query = nil;
if (selected_range.length == 0) {
if (selected_range.location != current_text_length)
return NSNotFound;
query = current_text;
} else {
if (NSMaxRange(selected_range) != current_text_length)
return NSNotFound;
query = [current_text substringToIndex:selected_range.location];
}
if (suggestions.is_empty())
return NSNotFound;
// Row 0 drives both the visible highlight and (if its text prefix-matches
// the query) the inline completion preview. The user-visible rule is
// "the top row is the default action"; see the Qt implementation in
// UI/Qt/LocationEdit.cpp for a longer discussion.
// A literal URL always wins: no preview, restore the typed text.
if (suggestions.first().source == WebView::AutocompleteSuggestionSource::LiteralURL) {
self.current_inline_autocomplete_suggestion = nil;
if (selected_range.length != 0 || ![current_text isEqualToString:query])
[self restoreLocationFieldQuery];
return 0;
}
// Backspace suppression: the user just deleted into this query, so don't
// re-apply an inline preview — but still honor the "highlight the top
// row" rule.
if (self.suppressed_inline_autocomplete_query != nil && [self.suppressed_inline_autocomplete_query isEqualToString:query]) {
self.current_inline_autocomplete_suggestion = nil;
if (selected_range.length != 0 || ![current_text isEqualToString:query])
[self restoreLocationFieldQuery];
return 0;
}
// Preserve an existing inline preview if its row is still present and
// still extends the typed prefix. This keeps the preview stable while the
// user is still forward-typing into a suggestion.
if (self.current_inline_autocomplete_suggestion != nil) {
auto preserved_row = autocomplete_suggestion_index(self.current_inline_autocomplete_suggestion, suggestions);
if (preserved_row != NSNotFound) {
if (auto* preserved_inline = inline_autocomplete_text_for_suggestion(query, self.current_inline_autocomplete_suggestion); preserved_inline != nil) {
[self applyLocationFieldInlineAutocompleteText:preserved_inline forQuery:query];
return preserved_row;
}
}
}
// Try to inline-preview row 0 specifically.
auto* row_0_text = Ladybird::string_to_ns_string(suggestions.first().text);
if (auto* row_0_inline = inline_autocomplete_text_for_suggestion(query, row_0_text); row_0_inline != nil) {
self.current_inline_autocomplete_suggestion = row_0_text;
[self applyLocationFieldInlineAutocompleteText:row_0_inline forQuery:query];
return 0;
}
// Row 0 does not prefix-match the query: clear any stale inline preview,
// restore the typed text, and still highlight row 0.
self.current_inline_autocomplete_suggestion = nil;
if (selected_range.length != 0 || ![current_text isEqualToString:query])
[self restoreLocationFieldQuery];
return 0;
}
- (BOOL)applyInlineAutocompleteSuggestionText:(NSString*)suggestion_text
forQuery:(NSString*)query
{
if (suggestion_matches_query_exactly(query, suggestion_text)) {
[self restoreLocationFieldQuery];
self.current_inline_autocomplete_suggestion = nil;
return YES;
}
auto* inline_text = inline_autocomplete_text_for_suggestion(query, suggestion_text);
if (inline_text == nil)
return NO;
self.current_inline_autocomplete_suggestion = suggestion_text;
[self applyLocationFieldInlineAutocompleteText:inline_text forQuery:query];
return YES;
}
- (void)applyLocationFieldInlineAutocompleteText:(NSString*)inline_text
forQuery:(NSString*)query
{
auto* location_search_field = (LocationSearchField*)[self.location_toolbar_item view];
auto* editor = (NSTextView*)[location_search_field currentEditor];
if (editor == nil || [self.window firstResponder] != editor || [editor hasMarkedText])
return;
auto* current_text = [[editor textStorage] string];
auto selected_range = [editor selectedRange];
auto completion_range = NSMakeRange(query.length, inline_text.length - query.length);
if ([current_text isEqualToString:inline_text] && NSEqualRanges(selected_range, completion_range))
return;
m_is_applying_inline_autocomplete = true;
[location_search_field setStringValue:inline_text];
[editor setString:inline_text];
[editor setSelectedRange:completion_range];
m_is_applying_inline_autocomplete = false;
}
- (void)previewHighlightedSuggestionInLocationField:(String const&)suggestion
{
auto* query = [self currentLocationFieldQuery];
auto* suggestion_text = Ladybird::string_to_ns_string(suggestion);
[self applyInlineAutocompleteSuggestionText:suggestion_text forQuery:query];
}
- (void)restoreLocationFieldQuery
{
auto* location_search_field = (LocationSearchField*)[self.location_toolbar_item view];
auto* editor = (NSTextView*)[location_search_field currentEditor];
if (editor == nil || [self.window firstResponder] != editor || [editor hasMarkedText])
return;
auto* query = [self currentLocationFieldQuery];
auto* current_text = [[editor textStorage] string];
auto selected_range = [editor selectedRange];
auto query_selection = NSMakeRange(query.length, 0);
if ([current_text isEqualToString:query] && NSEqualRanges(selected_range, query_selection))
return;
m_is_applying_inline_autocomplete = true;
[location_search_field setStringValue:query];
[editor setString:query];
[editor setSelectedRange:query_selection];
m_is_applying_inline_autocomplete = false;
}
- (BOOL)navigateToLocation:(String)location
{
m_autocomplete->cancel_pending_query();
if (auto url = WebView::sanitize_url(location, WebView::Application::settings().search_engine()); url.has_value()) {
[self loadURL:*url];
}
self.current_inline_autocomplete_suggestion = nil;
self.suppressed_inline_autocomplete_query = nil;
m_should_suppress_inline_autocomplete_on_next_change = false;
[self.window makeFirstResponder:nil];
[self.autocomplete close];
return YES;
}
- (void)showTabOverview:(id)sender
{
self.tab.titlebarAppearsTransparent = NO;
[self.window toggleTabOverview:sender];
self.tab.titlebarAppearsTransparent = YES;
}
#pragma mark - Properties
- (NSButton*)create_button:(NSImageName)image
with_action:(nonnull SEL)action
with_tooltip:(NSString*)tooltip
{
auto* button = [NSButton buttonWithImage:[NSImage imageNamed:image]
target:self
action:action];
if (tooltip) {
[button setToolTip:tooltip];
}
[button setBordered:YES];
return button;
}
- (NSToolbarItem*)navigate_back_toolbar_item
{
if (!_navigate_back_toolbar_item) {
auto* button = Ladybird::create_application_button([[[self tab] web_view] view].navigate_back_action());
_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 = Ladybird::create_application_button([[[self tab] web_view] view].navigate_forward_action());
_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 = Ladybird::create_application_button(WebView::Application::the().reload_action());
_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 = [[LocationSearchField alloc] init];
[location_search_field setPlaceholderString:@"Enter web address"];
[location_search_field setTextColor:[NSColor textColor]];
[location_search_field setDelegate:self];
if (@available(macOS 26, *)) {
[location_search_field setBordered:YES];
}
_location_toolbar_item = [[NSToolbarItem alloc] initWithItemIdentifier:TOOLBAR_LOCATION_IDENTIFIER];
[_location_toolbar_item setView:location_search_field];
}
return _location_toolbar_item;
}
- (NSToolbarItem*)zoom_toolbar_item
{
if (!_zoom_toolbar_item) {
auto* button = Ladybird::create_application_button([[[self tab] web_view] view].reset_zoom_action());
_zoom_toolbar_item = [[NSToolbarItem alloc] initWithItemIdentifier:TOOLBAR_ZOOM_IDENTIFIER];
[_zoom_toolbar_item setView:button];
}
return _zoom_toolbar_item;
}
- (NSToolbarItem*)bookmark_toolbar_item
{
if (!_bookmark_toolbar_item) {
auto* button = Ladybird::create_application_button([[[self tab] web_view] view].toggle_bookmark_action());
_bookmark_toolbar_item = [[NSToolbarItem alloc] initWithItemIdentifier:TOOLBAR_BOOKMARK_IDENTIFIER];
[_bookmark_toolbar_item setView:button];
}
return _bookmark_toolbar_item;
}
- (NSToolbarItem*)new_tab_toolbar_item
{
if (!_new_tab_toolbar_item) {
auto* button = [self create_button:NSImageNameAddTemplate
with_action:@selector(createNewTab:)
with_tooltip:@"New tab"];
_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:)
with_tooltip:@"Show all tabs"];
_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,
TOOLBAR_BOOKMARK_IDENTIFIER,
TOOLBAR_ZOOM_IDENTIFIER,
NSToolbarFlexibleSpaceItemIdentifier,
TOOLBAR_NEW_TAB_IDENTIFIER,
TOOLBAR_TAB_OVERVIEW_IDENTIFIER,
];
}
return _toolbar_identifiers;
}
#pragma mark - NSWindowController
- (IBAction)showWindow:(id)sender
{
self.window = self.parent
? [[Tab alloc] initAsChild:self.parent pageIndex:m_page_index]
: [[Tab alloc] init];
[self.window setDelegate:self];
[self.window setToolbar:self.toolbar];
[self.window setToolbarStyle:NSWindowToolbarStyleUnified];
[self.window makeKeyAndOrderFront:sender];
[self focusLocationToolbarItem];
auto* delegate = (ApplicationDelegate*)[NSApp delegate];
[delegate setActiveTab:[self tab]];
}
#pragma mark - NSWindowDelegate
- (void)windowDidBecomeMain:(NSNotification*)notification
{
auto* delegate = (ApplicationDelegate*)[NSApp delegate];
[delegate setActiveTab:[self tab]];
}
- (void)windowDidResignKey:(NSNotification*)notification
{
[self.autocomplete close];
}
- (BOOL)windowShouldClose:(NSWindow*)sender
{
// Prevent closing on first request so WebContent can cleanly shutdown (e.g. asking if the user is sure they want
// to leave, closing WebSocket connections, etc.)
if (!self.already_requested_close) {
self.already_requested_close = true;
[[[self tab] web_view] requestClose];
return false;
}
// If the user has already requested a close, then respect the user's request and just close the tab.
// For example, the WebContent process may not be responding.
return true;
}
- (void)windowWillClose:(NSNotification*)notification
{
auto* delegate = (ApplicationDelegate*)[NSApp delegate];
[delegate removeTab:self];
}
- (void)windowDidMove:(NSNotification*)notification
{
auto position = Ladybird::ns_point_to_gfx_point([[self tab] frame].origin);
[[[self tab] web_view] setWindowPosition:position];
}
- (void)windowWillStartLiveResize:(NSNotification*)notification
{
[self.autocomplete close];
}
- (void)windowDidResize:(NSNotification*)notification
{
[self.autocomplete close];
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;
[[[self tab] web_view] handleResize];
}
- (void)windowDidChangeBackingProperties:(NSNotification*)notification
{
[[[self tab] web_view] handleDevicePixelRatioChange];
}
- (void)windowDidChangeScreen:(NSNotification*)notification
{
[[[self tab] web_view] handleDisplayRefreshRateChange];
}
- (void)windowWillEnterFullScreen:(NSNotification*)notification
{
if (m_fullscreen_requested_for_web_content) {
[self.toolbar setVisible:NO];
[[self tab] updateBookmarksBarDisplay:NO];
m_fullscreen_should_restore_tab_bar = [[self.window tabGroup] isTabBarVisible];
if (m_fullscreen_should_restore_tab_bar) {
[self.window toggleTabBar:nil];
}
}
}
- (void)windowDidEnterFullScreen:(NSNotification*)notification
{
if (m_fullscreen_requested_for_web_content)
[[[self tab] web_view] handleEnteredFullScreen];
}
- (void)windowWillExitFullScreen:(NSNotification*)notification
{
if (exchange(m_fullscreen_exit_was_ui_initiated, true))
[[[self tab] web_view] handleExitFullScreen];
}
- (void)windowDidExitFullScreen:(NSNotification*)notification
{
if (exchange(m_fullscreen_requested_for_web_content, false)) {
[self.toolbar setVisible:YES];
[[self tab] updateBookmarksBarDisplay:WebView::Application::settings().show_bookmarks_bar()];
if (m_fullscreen_should_restore_tab_bar && ![[self.window tabGroup] isTabBarVisible]) {
[self.window toggleTabBar:nil];
}
}
[[[self tab] web_view] handleExitedFullScreen];
}
- (NSApplicationPresentationOptions)window:(NSWindow*)window
willUseFullScreenPresentationOptions:(NSApplicationPresentationOptions)proposed_options
{
if (m_fullscreen_requested_for_web_content) {
return NSApplicationPresentationAutoHideDock
| NSApplicationPresentationAutoHideToolbar
| NSApplicationPresentationAutoHideMenuBar
| NSApplicationPresentationFullScreen;
}
return proposed_options;
}
#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_ZOOM_IDENTIFIER]) {
return self.zoom_toolbar_item;
}
if ([identifier isEqual:TOOLBAR_BOOKMARK_IDENTIFIER]) {
return self.bookmark_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 (should_suppress_inline_autocomplete_for_selector(selector))
m_should_suppress_inline_autocomplete_on_next_change = true;
if (selector == @selector(cancelOperation:)) {
if ([self.autocomplete close])
return YES;
auto const& url = [[[self tab] web_view] view].url();
self.suppressed_inline_autocomplete_query = nil;
m_should_suppress_inline_autocomplete_on_next_change = false;
[self setLocationFieldText:url.serialize()];
[self.window makeFirstResponder:nil];
return YES;
}
if (selector == @selector(moveDown:)) {
if ([self.autocomplete selectNextSuggestion])
return YES;
}
if (selector == @selector(moveUp:)) {
if ([self.autocomplete selectPreviousSuggestion])
return YES;
}
if (selector != @selector(insertNewline:)) {
return NO;
}
auto location = [self.autocomplete selectedSuggestion].value_or_lazy_evaluated([&]() {
return Ladybird::ns_string_to_string([[text_view textStorage] string]);
});
[self navigateToLocation:move(location)];
return YES;
}
- (void)controlTextDidEndEditing:(NSNotification*)notification
{
auto* location_search_field = (LocationSearchField*)[self.location_toolbar_item view];
auto url_string = Ladybird::ns_string_to_string([location_search_field stringValue]);
m_autocomplete->cancel_pending_query();
self.current_inline_autocomplete_suggestion = nil;
self.suppressed_inline_autocomplete_query = nil;
m_should_suppress_inline_autocomplete_on_next_change = false;
[self.autocomplete close];
[self setLocationFieldText:url_string];
}
- (void)controlTextDidChange:(NSNotification*)notification
{
if (m_is_applying_inline_autocomplete)
return;
auto* query = [self currentLocationFieldQuery];
if (m_should_suppress_inline_autocomplete_on_next_change) {
self.suppressed_inline_autocomplete_query = query;
m_should_suppress_inline_autocomplete_on_next_change = false;
} else if (self.suppressed_inline_autocomplete_query != nil && ![self.suppressed_inline_autocomplete_query isEqualToString:query]) {
self.suppressed_inline_autocomplete_query = nil;
}
if (self.suppressed_inline_autocomplete_query == nil && self.current_inline_autocomplete_suggestion != nil) {
if (![self applyInlineAutocompleteSuggestionText:self.current_inline_autocomplete_suggestion forQuery:query])
self.current_inline_autocomplete_suggestion = nil;
}
auto url_string = Ladybird::ns_string_to_string(query);
m_autocomplete->query_autocomplete_engine(move(url_string), MAXIMUM_VISIBLE_AUTOCOMPLETE_SUGGESTIONS);
}
#pragma mark - AutocompleteObserver
- (void)onHighlightedSuggestion:(String)suggestion
{
[self previewHighlightedSuggestionInLocationField:suggestion];
}
- (void)onAutocompleteDidClose
{
self.current_inline_autocomplete_suggestion = nil;
[self restoreLocationFieldQuery];
}
- (void)onSelectedSuggestion:(String)suggestion
{
[self navigateToLocation:move(suggestion)];
}
@end