Browse Source

UI/AppKit: Implement an autocomplete view for the location bar

Timothy Flynn 1 week ago
parent
commit
c1fe912bf9

+ 1 - 0
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

+ 33 - 0
UI/AppKit/Interface/Autocomplete.h

@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2025, Tim Flynn <trflynn89@ladybird.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#pragma once
+
+#include <AK/String.h>
+#include <AK/Vector.h>
+
+#import <Cocoa/Cocoa.h>
+
+@protocol AutocompleteObserver <NSObject>
+
+- (void)onSelectedSuggestion:(String)suggestion;
+
+@end
+
+@interface Autocomplete : NSPopover
+
+- (instancetype)init:(id<AutocompleteObserver>)observer
+     withToolbarItem:(NSToolbarItem*)toolbar_item;
+
+- (void)showWithSuggestions:(Vector<String>)suggestions;
+- (BOOL)close;
+
+- (Optional<String>)selectedSuggestion;
+
+- (BOOL)selectNextSuggestion;
+- (BOOL)selectPreviousSuggestion;
+
+@end

+ 203 - 0
UI/AppKit/Interface/Autocomplete.mm

@@ -0,0 +1,203 @@
+/*
+ * Copyright (c) 2025, Tim Flynn <trflynn89@ladybird.org>
+ *
+ * SPDX-License-Identifier: BSD-2-Clause
+ */
+
+#import <Interface/Autocomplete.h>
+#import <Utilities/Conversions.h>
+
+static NSString* const AUTOCOMPLETE_IDENTIFIER = @"Autocomplete";
+static constexpr auto MAX_NUMBER_OF_ROWS = 8uz;
+static constexpr auto POPOVER_PADDING = 6uz;
+
+@interface Autocomplete () <NSTableViewDataSource, NSTableViewDelegate>
+{
+    Vector<String> m_suggestions;
+}
+
+@property (nonatomic, weak) id<AutocompleteObserver> observer;
+@property (nonatomic, weak) NSToolbarItem* toolbar_item;
+
+@property (nonatomic, strong) NSTableView* table_view;
+
+@end
+
+@implementation Autocomplete
+
+- (instancetype)init:(id<AutocompleteObserver>)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<String>)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<String>)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<NSInteger>(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

+ 71 - 10
UI/AppKit/Interface/TabController.mm

@@ -6,12 +6,15 @@
 
 #include <LibWeb/Loader/UserAgent.h>
 #include <LibWebView/Application.h>
+#include <LibWebView/Autocomplete.h>
 #include <LibWebView/SearchEngine.h>
 #include <LibWebView/URL.h>
 #include <LibWebView/UserAgent.h>
 #include <LibWebView/ViewImplementation.h>
 
 #import <Application/ApplicationDelegate.h>
+#import <Interface/Autocomplete.h>
+#import <Interface/Event.h>
 #import <Interface/LadybirdWebView.h>
 #import <Interface/Tab.h>
 #import <Interface/TabController.h>
@@ -48,7 +51,7 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde
 
 @end
 
-@interface TabController () <NSToolbarDelegate, NSSearchFieldDelegate>
+@interface TabController () <NSToolbarDelegate, NSSearchFieldDelegate, AutocompleteObserver>
 {
     u64 m_page_index;
 
@@ -56,6 +59,8 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde
 
     TabSettings m_settings;
 
+    OwnPtr<WebView::Autocomplete> 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<WebView::Autocomplete>();
+
+        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<StringView> 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(insertNewline:)) {
-        return NO;
+    if (selector == @selector(cancelOperation:)) {
+        if ([self.autocomplete close])
+            return YES;
     }
 
-    auto url_string = Ladybird::ns_string_to_string([[text_view textStorage] string]);
+    if (selector == @selector(moveDown:)) {
+        if ([self.autocomplete selectNextSuggestion])
+            return YES;
+    }
 
-    Optional<StringView> 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 (selector == @selector(moveUp:)) {
+        if ([self.autocomplete selectPreviousSuggestion])
+            return YES;
+    }
 
-    if (auto url = WebView::sanitize_url(url_string, search_engine_url); url.has_value()) {
-        [self loadURL:*url];
+    if (selector != @selector(insertNewline:)) {
+        return NO;
     }
 
-    [self.window makeFirstResponder:nil];
+    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;
 }
 
@@ -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