/* * Copyright (c) 2023-2026, Tim Flynn * * SPDX-License-Identifier: BSD-2-Clause */ #include #include #include #include #include #include #import #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_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"; enum class LocationFieldDisplay { Editing, NotEditing, }; 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 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(index); } return NSNotFound; } static NSImage* location_field_globe_icon() { static NSImage* image; static dispatch_once_t token; dispatch_once(&token, ^{ image = [NSImage imageWithSystemSymbolName:@"globe" accessibilityDescription:@"Page icon"]; [image setSize:NSMakeSize(16, 16)]; }); return image; } @interface LocationSearchField : NSSearchField { BOOL m_loading; BOOL m_shows_page_icon; NSImage* m_favicon; NSProgressIndicator* m_loading_indicator; } - (BOOL)becomeFirstResponder; - (void)setLoading:(BOOL)loading; - (void)setFavicon:(NSImage*)favicon; - (void)setShowsPageIcon:(BOOL)showsPageIcon; @property (nonatomic, copy) void (^willBeginEditing)(void); @end @implementation LocationSearchField - (instancetype)init { if (self = [super init]) { m_loading_indicator = [[NSProgressIndicator alloc] init]; [m_loading_indicator setStyle:NSProgressIndicatorStyleSpinning]; [m_loading_indicator setControlSize:NSControlSizeSmall]; [m_loading_indicator setDisplayedWhenStopped:NO]; [m_loading_indicator setHidden:YES]; [self addSubview:m_loading_indicator]; [self updateLeadingIcon]; } return self; } - (BOOL)becomeFirstResponder { BOOL result = [super becomeFirstResponder]; if (result) { if (self.willBeginEditing) self.willBeginEditing(); [self performSelector:@selector(selectText:) withObject:self afterDelay:0]; } return result; } - (void)mouseDown:(NSEvent*)event { [super mouseDown:event]; if (self.willBeginEditing) self.willBeginEditing(); } - (void)layout { [super layout]; auto* cell = (NSSearchFieldCell*)[self cell]; auto search_button_rect = [cell searchButtonRectForBounds:[self bounds]]; auto indicator_size = NSMakeSize(16, 16); [m_loading_indicator setFrame:NSMakeRect( NSMidX(search_button_rect) - indicator_size.width / 2, NSMidY(search_button_rect) - indicator_size.height / 2, indicator_size.width, indicator_size.height)]; } - (void)setLoading:(BOOL)loading { if (m_loading == loading) return; m_loading = loading; if (m_loading) { [m_loading_indicator setHidden:NO]; [m_loading_indicator startAnimation:self]; } else { [m_loading_indicator stopAnimation:self]; [m_loading_indicator setHidden:YES]; } [self updateLeadingIcon]; } - (void)setFavicon:(NSImage*)favicon { m_favicon = [favicon copy]; [m_favicon setSize:NSMakeSize(16, 16)]; [m_favicon setTemplate:NO]; [self updateLeadingIcon]; } - (void)setShowsPageIcon:(BOOL)showsPageIcon { m_shows_page_icon = showsPageIcon; [self updateLeadingIcon]; } - (void)updateLeadingIcon { auto* cell = (NSSearchFieldCell*)[self cell]; auto* search_button = [cell searchButtonCell]; if (m_loading) { [search_button setImage:nil]; } else if (m_shows_page_icon && m_favicon != nil) { [search_button setImage:m_favicon]; } else if (!m_shows_page_icon) { [search_button setImage:[NSImage imageWithSystemSymbolName:@"magnifyingglass" accessibilityDescription:@"Search"]]; } else { [search_button setImage:location_field_globe_icon()]; } } // 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 () { u64 m_page_index; OwnPtr 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; Function m_pending_immediate_close; } @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 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(); 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]; // Do not update the popup while results are still changing. // Intermediate updates are triggered on every keystroke and would // cause visible flicker in the suggestion list. // Only final results are used to refresh the UI. if (result_kind == WebView::AutocompleteResultKind::Intermediate && [self.autocomplete isVisible]) 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()]; [(LocationSearchField*)[self.location_toolbar_item view] setFavicon:nil]; [(LocationSearchField*)[self.location_toolbar_item view] setLoading:YES]; } - (void)onLoadFinish:(URL::URL const&)url { (void)url; [(LocationSearchField*)[self.location_toolbar_item view] setLoading:NO]; } - (void)onFaviconChange:(NSImage*)favicon { [(LocationSearchField*)[self.location_toolbar_item view] setFavicon:favicon]; } - (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 focusWebView]; } - (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 restoreLocationFieldForEditing]; [self tab].preferred_first_responder = self.location_toolbar_item.view; [self.window makeFirstResponder:self.location_toolbar_item.view]; } - (void)focusWebViewWhenActivated { [self tab].preferred_first_responder = [self tab].web_view; } - (void)focusWebView { [self tab].preferred_first_responder = [self tab].web_view; [self.window makeFirstResponder:[self tab].web_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 display:(LocationFieldDisplay)display { NSMutableAttributedString* attributed_url; auto maybe_url = URL::create_with_url_or_path(url); auto display_url = MUST(String::from_utf8(url)); if (display == LocationFieldDisplay::NotEditing && maybe_url.has_value()) display_url = WebView::url_for_display(*maybe_url); auto url_parts = WebView::break_url_into_parts(url); auto* dark_attributes = @{ NSForegroundColorAttributeName : [NSColor systemGrayColor], }; auto* highlight_attributes = @{ NSForegroundColorAttributeName : [NSColor textColor], }; if (url_parts.has_value()) { attributed_url = [[NSMutableAttributedString alloc] init]; auto scheme_and_subdomain = url_parts->scheme_and_subdomain; auto remainder = url_parts->remainder; if (display == LocationFieldDisplay::NotEditing && maybe_url.has_value() && maybe_url->scheme().is_one_of("http"sv, "https"sv)) { auto scheme_prefix_length = maybe_url->scheme().bytes_as_string_view().length() + "://"sv.length(); scheme_and_subdomain = scheme_and_subdomain.substring_view(scheme_prefix_length); if (scheme_and_subdomain.starts_with("www."sv, CaseSensitivity::CaseInsensitive)) scheme_and_subdomain = scheme_and_subdomain.substring_view(4); if (remainder == "/"sv) remainder = {}; } auto* attributed_scheme_and_subdomain = [[NSAttributedString alloc] initWithString:Ladybird::string_to_ns_string(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(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(display_url) attributes:highlight_attributes]; } if (display == LocationFieldDisplay::NotEditing && maybe_url.has_value() && ![[attributed_url string] isEqualToString:Ladybird::string_to_ns_string(display_url)]) { attributed_url = [[NSMutableAttributedString alloc] initWithString:Ladybird::string_to_ns_string(display_url) attributes:highlight_attributes]; } auto* location_search_field = (LocationSearchField*)[self.location_toolbar_item view]; [location_search_field setAttributedStringValue:attributed_url]; [location_search_field setShowsPageIcon:url_parts.has_value()]; } - (void)setLocationFieldText:(StringView)url { [self setLocationFieldText:url display:LocationFieldDisplay::NotEditing]; } - (void)restoreLocationFieldForEditing { auto const& url = [[[self tab] web_view] view].url(); auto* location_search_field = (LocationSearchField*)[self.location_toolbar_item view]; if (![[location_search_field stringValue] isEqualToString:Ladybird::string_to_ns_string(WebView::url_for_display(url))]) return; m_is_applying_inline_autocomplete = true; [self setLocationFieldText:url.serialize() display:LocationFieldDisplay::Editing]; auto* editor = (NSTextView*)[location_search_field currentEditor]; if (editor != nil && [self.window firstResponder] == editor && ![editor hasMarkedText]) { auto* serialized_url = Ladybird::string_to_ns_string(url.serialize()); [editor setString:serialized_url]; [editor setSelectedRange:NSMakeRange(0, serialized_url.length)]; } m_is_applying_inline_autocomplete = false; } - (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 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]; } else { [[[self tab] web_view] view].load_navigation_error_page(location); } self.current_inline_autocomplete_suggestion = nil; self.suppressed_inline_autocomplete_query = nil; m_should_suppress_inline_autocomplete_on_next_change = false; [self focusWebView]; [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]; __weak TabController* weak_self = self; [location_search_field setWillBeginEditing:^{ [weak_self restoreLocationFieldForEditing]; }]; 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)windowDidChangeOcclusionState:(NSNotification*)notification { [[[self tab] web_view] handleVisibility:([self.window occlusionState] & NSWindowOcclusionStateVisible) != 0]; } - (void)windowDidResignKey:(NSNotification*)notification { [self.autocomplete close]; } - (BOOL)windowShouldClose:(NSWindow*)sender { if (![[[self tab] web_view] needsBeforeUnloadCheck]) { m_pending_immediate_close = [[[self tab] web_view] prepareForImmediateClose]; return true; } // 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]; auto request_close = AK::move(m_pending_immediate_close); if (request_close) Core::deferred_invoke(AK::move(request_close)); } - (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 - (void)controlTextDidBeginEditing:(NSNotification*)notification { [self restoreLocationFieldForEditing]; } - (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]; NSString* url_string = [[location_search_field stringValue] copy]; // AppKit can send this while focus is still settling into the field // editor. Wait until the next turn so transient notifications do not // format the live editor contents as a non-editing URL. dispatch_async(dispatch_get_main_queue(), ^{ auto* location_search_field = (LocationSearchField*)[self.location_toolbar_item view]; auto* editor = (NSTextView*)[location_search_field currentEditor]; if (editor != nil && [self.window firstResponder] == editor) return; 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:Ladybird::ns_string_to_string(url_string)]; }); } - (void)controlTextDidChange:(NSNotification*)notification { if (m_is_applying_inline_autocomplete) return; [(LocationSearchField*)[self.location_toolbar_item view] setShowsPageIcon:NO]; 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