From c1fe912bf9d8fd7701f815b6cce3d948b4092972 Mon Sep 17 00:00:00 2001 From: Timothy Flynn Date: Sun, 30 Mar 2025 21:01:53 -0400 Subject: [PATCH] UI/AppKit: Implement an autocomplete view for the location bar --- UI/AppKit/CMakeLists.txt | 1 + UI/AppKit/Interface/Autocomplete.h | 33 +++++ UI/AppKit/Interface/Autocomplete.mm | 203 +++++++++++++++++++++++++++ UI/AppKit/Interface/TabController.mm | 83 +++++++++-- 4 files changed, 309 insertions(+), 11 deletions(-) create mode 100644 UI/AppKit/Interface/Autocomplete.h create mode 100644 UI/AppKit/Interface/Autocomplete.mm diff --git a/UI/AppKit/CMakeLists.txt b/UI/AppKit/CMakeLists.txt index 1705e2f5fbb..2ccb22d5aed 100644 --- a/UI/AppKit/CMakeLists.txt +++ b/UI/AppKit/CMakeLists.txt @@ -2,6 +2,7 @@ add_library(ladybird_impl STATIC ${LADYBIRD_SOURCES} Application/Application.mm Application/ApplicationDelegate.mm + Interface/Autocomplete.mm Interface/Event.mm Interface/InfoBar.mm Interface/LadybirdWebView.mm diff --git a/UI/AppKit/Interface/Autocomplete.h b/UI/AppKit/Interface/Autocomplete.h new file mode 100644 index 00000000000..420d15127a5 --- /dev/null +++ b/UI/AppKit/Interface/Autocomplete.h @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include + +#import + +@protocol AutocompleteObserver + +- (void)onSelectedSuggestion:(String)suggestion; + +@end + +@interface Autocomplete : NSPopover + +- (instancetype)init:(id)observer + withToolbarItem:(NSToolbarItem*)toolbar_item; + +- (void)showWithSuggestions:(Vector)suggestions; +- (BOOL)close; + +- (Optional)selectedSuggestion; + +- (BOOL)selectNextSuggestion; +- (BOOL)selectPreviousSuggestion; + +@end diff --git a/UI/AppKit/Interface/Autocomplete.mm b/UI/AppKit/Interface/Autocomplete.mm new file mode 100644 index 00000000000..3f817f707b0 --- /dev/null +++ b/UI/AppKit/Interface/Autocomplete.mm @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2025, Tim Flynn + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#import +#import + +static NSString* const AUTOCOMPLETE_IDENTIFIER = @"Autocomplete"; +static constexpr auto MAX_NUMBER_OF_ROWS = 8uz; +static constexpr auto POPOVER_PADDING = 6uz; + +@interface Autocomplete () +{ + Vector m_suggestions; +} + +@property (nonatomic, weak) id observer; +@property (nonatomic, weak) NSToolbarItem* toolbar_item; + +@property (nonatomic, strong) NSTableView* table_view; + +@end + +@implementation Autocomplete + +- (instancetype)init:(id)observer + withToolbarItem:(NSToolbarItem*)toolbar_item +{ + if (self = [super init]) { + self.observer = observer; + self.toolbar_item = toolbar_item; + + auto* column = [[NSTableColumn alloc] init]; + [column setEditable:NO]; + + self.table_view = [[NSTableView alloc] init]; + [self.table_view setAction:@selector(selectSuggestion:)]; + [self.table_view setBackgroundColor:[NSColor clearColor]]; + [self.table_view setIntercellSpacing:NSMakeSize(0, 5)]; + [self.table_view setHeaderView:nil]; + [self.table_view setRefusesFirstResponder:YES]; + [self.table_view setRowSizeStyle:NSTableViewRowSizeStyleDefault]; + [self.table_view addTableColumn:column]; + [self.table_view setDataSource:self]; + [self.table_view setDelegate:self]; + [self.table_view setTarget:self]; + + auto* scroll_view = [[NSScrollView alloc] init]; + [scroll_view setHasVerticalScroller:YES]; + [scroll_view setDocumentView:self.table_view]; + [scroll_view setDrawsBackground:NO]; + + auto* content_view = [[NSView alloc] init]; + [content_view addSubview:scroll_view]; + + auto* controller = [[NSViewController alloc] init]; + [controller setView:content_view]; + + [self setAnimates:NO]; + [self setBehavior:NSPopoverBehaviorTransient]; + [self setContentViewController:controller]; + [self setValue:[NSNumber numberWithBool:YES] forKeyPath:@"shouldHideAnchor"]; + } + + return self; +} + +#pragma mark - Public methods + +- (void)showWithSuggestions:(Vector)suggestions +{ + m_suggestions = move(suggestions); + [self.table_view reloadData]; + + if (m_suggestions.is_empty()) { + [self close]; + } else { + [self show]; + } +} + +- (BOOL)close +{ + if (!self.isShown) + return NO; + + [super close]; + return YES; +} + +- (Optional)selectedSuggestion +{ + if (!self.isShown || self.table_view.numberOfRows == 0) + return {}; + + auto row = [self.table_view selectedRow]; + if (row < 0) + return {}; + + return m_suggestions[row]; +} + +- (BOOL)selectNextSuggestion +{ + if (self.table_view.numberOfRows == 0) + return NO; + + if (!self.isShown) { + [self show]; + return YES; + } + + [self selectRow:[self.table_view selectedRow] + 1]; + return YES; +} + +- (BOOL)selectPreviousSuggestion +{ + if (self.table_view.numberOfRows == 0) + return NO; + + if (!self.isShown) { + [self show]; + return YES; + } + + [self selectRow:[self.table_view selectedRow] - 1]; + return YES; +} + +- (void)selectSuggestion:(id)sender +{ + if (auto suggestion = [self selectedSuggestion]; suggestion.has_value()) + [self.observer onSelectedSuggestion:suggestion.release_value()]; +} + +#pragma mark - Private methods + +- (void)show +{ + auto height = (self.table_view.rowHeight + self.table_view.intercellSpacing.height) * min(self.table_view.numberOfRows, MAX_NUMBER_OF_ROWS); + auto frame = NSMakeRect(0, 0, [[self.toolbar_item view] frame].size.width, height); + + [self.table_view.enclosingScrollView setFrame:NSInsetRect(frame, 0, POPOVER_PADDING)]; + [self setContentSize:frame.size]; + + [self.table_view deselectAll:nil]; + [self.table_view scrollRowToVisible:0]; + + [self showRelativeToToolbarItem:self.toolbar_item]; + + [self showRelativeToRect:self.toolbar_item.view.frame + ofView:self.toolbar_item.view + preferredEdge:NSRectEdgeMaxY]; +} + +- (void)selectRow:(NSInteger)row +{ + if (row < 0) + row = self.table_view.numberOfRows - 1; + else if (row >= self.table_view.numberOfRows) + row = 0; + + [self.table_view selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO]; + [self.table_view scrollRowToVisible:[self.table_view selectedRow]]; +} + +#pragma mark - NSTableViewDataSource + +- (NSInteger)numberOfRowsInTableView:(NSTableView*)tableView +{ + return static_cast(m_suggestions.size()); +} + +#pragma mark - NSTableViewDelegate + +- (NSView*)tableView:(NSTableView*)table_view + viewForTableColumn:(NSTableColumn*)table_column + row:(NSInteger)row +{ + NSTableCellView* view = [table_view makeViewWithIdentifier:AUTOCOMPLETE_IDENTIFIER owner:self]; + + if (view == nil) { + view = [[NSTableCellView alloc] initWithFrame:NSZeroRect]; + + NSTextField* text_field = [[NSTextField alloc] initWithFrame:NSZeroRect]; + [text_field setBezeled:NO]; + [text_field setDrawsBackground:NO]; + [text_field setEditable:NO]; + [text_field setSelectable:NO]; + + [view addSubview:text_field]; + [view setTextField:text_field]; + [view setIdentifier:AUTOCOMPLETE_IDENTIFIER]; + } + + [view.textField setStringValue:Ladybird::string_to_ns_string(m_suggestions[row])]; + return view; +} + +@end diff --git a/UI/AppKit/Interface/TabController.mm b/UI/AppKit/Interface/TabController.mm index 176b743519d..49d44ad6153 100644 --- a/UI/AppKit/Interface/TabController.mm +++ b/UI/AppKit/Interface/TabController.mm @@ -6,12 +6,15 @@ #include #include +#include #include #include #include #include #import +#import +#import #import #import #import @@ -48,7 +51,7 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde @end -@interface TabController () +@interface TabController () { u64 m_page_index; @@ -56,6 +59,8 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde TabSettings m_settings; + OwnPtr m_autocomplete; + bool m_can_navigate_back; bool m_can_navigate_forward; } @@ -73,6 +78,8 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde @property (nonatomic, strong) NSToolbarItem* new_tab_toolbar_item; @property (nonatomic, strong) NSToolbarItem* tab_overview_toolbar_item; +@property (nonatomic, strong) Autocomplete* autocomplete; + @property (nonatomic, assign) NSLayoutConstraint* location_toolbar_item_width; @end @@ -91,6 +98,8 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde - (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]; @@ -107,6 +116,18 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde if (auto const& user_agent_preset = WebView::Application::web_content_options().user_agent_preset; user_agent_preset.has_value()) m_settings.user_agent_name = *user_agent_preset; + self.autocomplete = [[Autocomplete alloc] init:self withToolbarItem:self.location_toolbar_item]; + m_autocomplete = make(); + + m_autocomplete->on_autocomplete_query_complete = [weak_self](auto suggestions) { + TabController* self = weak_self; + if (self == nil) { + return; + } + + [self.autocomplete showWithSuggestions:move(suggestions)]; + }; + m_can_navigate_back = false; m_can_navigate_forward = false; } @@ -278,6 +299,22 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde [location_search_field setAttributedStringValue:attributed_url]; } +- (BOOL)navigateToLocation:(String)location +{ + Optional search_engine_url; + if (auto const& search_engine = WebView::Application::settings().search_engine(); search_engine.has_value()) + search_engine_url = search_engine->query_url; + + if (auto url = WebView::sanitize_url(location, search_engine_url); url.has_value()) { + [self loadURL:*url]; + } + + [self.window makeFirstResponder:nil]; + [self.autocomplete close]; + + return YES; +} + - (void)updateNavigationButtonStates { auto* navigate_back_button = (NSButton*)[[self navigate_back_toolbar_item] view]; @@ -685,21 +722,30 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde textView:(NSTextView*)text_view doCommandBySelector:(SEL)selector { + if (selector == @selector(cancelOperation:)) { + if ([self.autocomplete close]) + 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 url_string = Ladybird::ns_string_to_string([[text_view textStorage] string]); + auto location = [self.autocomplete selectedSuggestion].value_or_lazy_evaluated([&]() { + return Ladybird::ns_string_to_string([[text_view textStorage] string]); + }); - Optional search_engine_url; - if (auto const& search_engine = WebView::Application::settings().search_engine(); search_engine.has_value()) - search_engine_url = search_engine->query_url; - - if (auto url = WebView::sanitize_url(url_string, search_engine_url); url.has_value()) { - [self loadURL:*url]; - } - - [self.window makeFirstResponder:nil]; + [self navigateToLocation:move(location)]; return YES; } @@ -711,4 +757,19 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde [self setLocationFieldText:url_string]; } +- (void)controlTextDidChange:(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->query_autocomplete_engine(move(url_string)); +} + +#pragma mark - AutocompleteObserver + +- (void)onSelectedSuggestion:(String)suggestion +{ + [self navigateToLocation:move(suggestion)]; +} + @end