diff --git a/UI/AppKit/Interface/Autocomplete.h b/UI/AppKit/Interface/Autocomplete.h index 76e7aaac5de..b082a5ff175 100644 --- a/UI/AppKit/Interface/Autocomplete.h +++ b/UI/AppKit/Interface/Autocomplete.h @@ -8,12 +8,17 @@ #include #include +#include #import +static constexpr auto MAXIMUM_VISIBLE_AUTOCOMPLETE_SUGGESTIONS = 8uz; + @protocol AutocompleteObserver - (void)onSelectedSuggestion:(String)suggestion; +- (void)onHighlightedSuggestion:(String)suggestion; +- (void)onAutocompleteDidClose; @end @@ -22,8 +27,11 @@ - (instancetype)init:(id)observer withToolbarItem:(NSToolbarItem*)toolbar_item; -- (void)showWithSuggestions:(Vector)suggestions; +- (void)showWithSuggestions:(Vector)suggestions + selectedRow:(NSInteger)selected_row; +- (void)clearSelection; - (BOOL)close; +- (BOOL)isVisible; - (Optional)selectedSuggestion; diff --git a/UI/AppKit/Interface/Autocomplete.mm b/UI/AppKit/Interface/Autocomplete.mm index cab02a7cac0..408db8e630a 100644 --- a/UI/AppKit/Interface/Autocomplete.mm +++ b/UI/AppKit/Interface/Autocomplete.mm @@ -8,9 +8,229 @@ #import static NSString* const AUTOCOMPLETE_IDENTIFIER = @"Autocomplete"; -static constexpr auto MAX_NUMBER_OF_ROWS = 8uz; -static constexpr CGFloat const POPOVER_PADDING = 6; +static NSString* const AUTOCOMPLETE_SECTION_HEADER_IDENTIFIER = @"AutocompleteSectionHeader"; +static constexpr CGFloat const POPOVER_PADDING = 8; static constexpr CGFloat const MINIMUM_WIDTH = 100; +static constexpr CGFloat const CELL_HORIZONTAL_PADDING = 8; +static constexpr CGFloat const CELL_VERTICAL_PADDING = 10; +static constexpr CGFloat const CELL_ICON_SIZE = 16; +static constexpr CGFloat const CELL_ICON_TEXT_SPACING = 6; +static constexpr CGFloat const CELL_LABEL_VERTICAL_SPACING = 5; +static constexpr CGFloat const SECTION_HEADER_HORIZONTAL_PADDING = 10; +static constexpr CGFloat const SECTION_HEADER_VERTICAL_PADDING = 4; + +static NSFont* autocomplete_primary_font(); +static NSFont* autocomplete_secondary_font(); +static NSFont* autocomplete_section_header_font(); + +enum class AutocompleteRowKind { + SectionHeader, + Suggestion, +}; + +struct AutocompleteRowModel { + AutocompleteRowKind kind; + String text; + size_t suggestion_index { 0 }; +}; + +static CGFloat autocomplete_text_field_height(NSFont* font) +{ + static CGFloat primary_text_field_height = 0; + static CGFloat secondary_text_field_height = 0; + static CGFloat section_header_text_field_height = 0; + static dispatch_once_t token; + dispatch_once(&token, ^{ + auto* text_field = [[NSTextField alloc] initWithFrame:NSZeroRect]; + [text_field setBezeled:NO]; + [text_field setDrawsBackground:NO]; + [text_field setEditable:NO]; + [text_field setStringValue:@"Ladybird"]; + + [text_field setFont:autocomplete_primary_font()]; + primary_text_field_height = ceil([text_field fittingSize].height); + + [text_field setFont:autocomplete_secondary_font()]; + secondary_text_field_height = ceil([text_field fittingSize].height); + + [text_field setFont:autocomplete_section_header_font()]; + section_header_text_field_height = ceil([text_field fittingSize].height); + }); + + if (font == autocomplete_secondary_font()) + return secondary_text_field_height; + if (font == autocomplete_section_header_font()) + return section_header_text_field_height; + return primary_text_field_height; +} + +static CGFloat autocomplete_row_height() +{ + static CGFloat row_height = 0; + static dispatch_once_t token; + dispatch_once(&token, ^{ + auto content_height = max(CELL_ICON_SIZE, + autocomplete_text_field_height(autocomplete_primary_font()) + + CELL_LABEL_VERTICAL_SPACING + + autocomplete_text_field_height(autocomplete_secondary_font())); + row_height = ceil(content_height + (CELL_VERTICAL_PADDING * 2)); + }); + return row_height; +} + +static CGFloat autocomplete_section_header_height() +{ + static CGFloat row_height = 0; + static dispatch_once_t token; + dispatch_once(&token, ^{ + row_height = ceil(autocomplete_text_field_height(autocomplete_section_header_font()) + (SECTION_HEADER_VERTICAL_PADDING * 2)); + }); + return row_height; +} + +static NSFont* autocomplete_primary_font() +{ + static NSFont* font; + static dispatch_once_t token; + dispatch_once(&token, ^{ + font = [NSFont systemFontOfSize:[NSFont systemFontSize] weight:NSFontWeightSemibold]; + }); + return font; +} + +static NSFont* autocomplete_secondary_font() +{ + static NSFont* font; + static dispatch_once_t token; + dispatch_once(&token, ^{ + font = [NSFont systemFontOfSize:[NSFont smallSystemFontSize]]; + }); + return font; +} + +static NSFont* autocomplete_section_header_font() +{ + static NSFont* font; + static dispatch_once_t token; + dispatch_once(&token, ^{ + font = [NSFont systemFontOfSize:[NSFont smallSystemFontSize] weight:NSFontWeightSemibold]; + }); + return font; +} + +static NSImage* search_suggestion_icon() +{ + static NSImage* image; + static dispatch_once_t token; + dispatch_once(&token, ^{ + image = [NSImage imageWithSystemSymbolName:@"magnifyingglass" accessibilityDescription:@""]; + [image setSize:NSMakeSize(CELL_ICON_SIZE, CELL_ICON_SIZE)]; + }); + return image; +} + +static NSImage* literal_url_suggestion_icon() +{ + static NSImage* image; + static dispatch_once_t token; + dispatch_once(&token, ^{ + image = [NSImage imageWithSystemSymbolName:@"globe" accessibilityDescription:@""]; + [image setSize:NSMakeSize(CELL_ICON_SIZE, CELL_ICON_SIZE)]; + }); + return image; +} + +@protocol AutocompleteTableViewHoverObserver + +- (void)autocompleteTableViewHoveredRowChanged:(NSInteger)row; + +@end + +@interface AutocompleteRowView : NSTableRowView +@end + +@implementation AutocompleteRowView + +- (void)drawSelectionInRect:(NSRect)dirtyRect +{ + auto selection_rect = NSInsetRect(self.bounds, 2, 3); + auto* selection_path = [NSBezierPath bezierPathWithRoundedRect:selection_rect xRadius:6 yRadius:6]; + + [[[NSColor controlAccentColor] colorWithAlphaComponent:0.25] setFill]; + [selection_path fill]; +} + +@end + +@interface AutocompleteSuggestionView : NSTableCellView + +@property (nonatomic, strong) NSImageView* icon_view; +@property (nonatomic, strong) NSTextField* title_text_field; +@property (nonatomic, strong) NSTextField* url_text_field; + +@end + +@implementation AutocompleteSuggestionView +@end + +@interface AutocompleteSectionHeaderView : NSTableCellView + +@property (nonatomic, strong) NSTextField* text_field; + +@end + +@implementation AutocompleteSectionHeaderView +@end + +@interface AutocompleteScrollView : NSScrollView +@end + +@implementation AutocompleteScrollView + +- (void)scrollWheel:(NSEvent*)event +{ +} + +@end + +@interface AutocompleteTableView : NSTableView + +@property (nonatomic, weak) id hover_observer; +@property (nonatomic, strong) NSTrackingArea* tracking_area; + +@end + +@implementation AutocompleteTableView + +- (void)updateTrackingAreas +{ + if (self.tracking_area != nil) + [self removeTrackingArea:self.tracking_area]; + + self.tracking_area = [[NSTrackingArea alloc] initWithRect:NSZeroRect + options:NSTrackingActiveAlways | NSTrackingInVisibleRect | NSTrackingMouseMoved | NSTrackingMouseEnteredAndExited + owner:self + userInfo:nil]; + [self addTrackingArea:self.tracking_area]; + + [super updateTrackingAreas]; +} + +- (void)mouseMoved:(NSEvent*)event +{ + [super mouseMoved:event]; + + auto point = [self convertPoint:event.locationInWindow fromView:nil]; + [self.hover_observer autocompleteTableViewHoveredRowChanged:[self rowAtPoint:point]]; +} + +- (void)mouseExited:(NSEvent*)event +{ + [super mouseExited:event]; + [self.hover_observer autocompleteTableViewHoveredRowChanged:-1]; +} + +@end @interface AutocompleteWindow : NSWindow @end @@ -29,9 +249,10 @@ static constexpr CGFloat const MINIMUM_WIDTH = 100; @end -@interface Autocomplete () +@interface Autocomplete () { - Vector m_suggestions; + Vector m_suggestions; + Vector m_rows; } @property (nonatomic, weak) id observer; @@ -41,6 +262,15 @@ static constexpr CGFloat const MINIMUM_WIDTH = 100; @property (nonatomic, strong) NSView* content_view; @property (nonatomic, strong) NSScrollView* scroll_view; @property (nonatomic, strong) NSTableView* table_view; +@property (nonatomic, strong) NSMutableDictionary* suggestion_icons; + +- (NSInteger)tableRowForSuggestionIndex:(NSInteger)suggestion_index; +- (BOOL)isSelectableRow:(NSInteger)row; +- (CGFloat)heightOfRowAtIndex:(size_t)row; +- (CGFloat)tableHeightForVisibleSuggestionCount:(size_t)visible_suggestion_count; +- (void)rebuildRows; +- (void)selectRow:(NSInteger)row notifyObserver:(BOOL)notify_observer; +- (NSInteger)stepToSelectableRowFrom:(NSInteger)row direction:(NSInteger)direction; @end @@ -56,22 +286,30 @@ static constexpr CGFloat const MINIMUM_WIDTH = 100; auto* column = [[NSTableColumn alloc] init]; [column setEditable:NO]; - self.table_view = [[NSTableView alloc] initWithFrame:NSZeroRect]; + self.table_view = [[AutocompleteTableView alloc] initWithFrame:NSZeroRect]; [self.table_view setAction:@selector(selectSuggestion:)]; [self.table_view setBackgroundColor:[NSColor clearColor]]; [self.table_view setHeaderView:nil]; - [self.table_view setIntercellSpacing:NSMakeSize(0, 5)]; + [self.table_view setIntercellSpacing:NSMakeSize(0, 0)]; [self.table_view setRefusesFirstResponder:YES]; - [self.table_view setRowSizeStyle:NSTableViewRowSizeStyleDefault]; + [self.table_view setStyle:NSTableViewStyleFullWidth]; + [self.table_view setRowSizeStyle:NSTableViewRowSizeStyleCustom]; + [self.table_view setRowHeight:autocomplete_row_height()]; + [self.table_view setSelectionHighlightStyle:NSTableViewSelectionHighlightStyleRegular]; [self.table_view addTableColumn:column]; [self.table_view setDataSource:self]; [self.table_view setDelegate:self]; [self.table_view setTarget:self]; + [(AutocompleteTableView*)self.table_view setHover_observer:self]; - self.scroll_view = [[NSScrollView alloc] initWithFrame:NSZeroRect]; + self.scroll_view = [[AutocompleteScrollView alloc] initWithFrame:NSZeroRect]; + [self.scroll_view setAutohidesScrollers:YES]; [self.scroll_view setBorderType:NSNoBorder]; [self.scroll_view setDrawsBackground:NO]; - [self.scroll_view setHasVerticalScroller:YES]; + [self.scroll_view setHasHorizontalScroller:NO]; + [self.scroll_view setHasVerticalScroller:NO]; + [self.scroll_view setHorizontalScrollElasticity:NSScrollElasticityNone]; + [self.scroll_view setVerticalScrollElasticity:NSScrollElasticityNone]; [self.scroll_view setDocumentView:self.table_view]; self.content_view = [[NSView alloc] initWithFrame:NSZeroRect]; @@ -79,11 +317,13 @@ static constexpr CGFloat const MINIMUM_WIDTH = 100; [self.content_view.layer setBackgroundColor:[NSColor windowBackgroundColor].CGColor]; [self.content_view.layer setCornerRadius:8]; [self.content_view addSubview:self.scroll_view]; + self.suggestion_icons = [NSMutableDictionary dictionary]; self.popup_window = [[AutocompleteWindow alloc] initWithContentRect:NSZeroRect styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO]; + [self.popup_window setAcceptsMouseMovedEvents:YES]; [self.popup_window setBackgroundColor:[NSColor clearColor]]; [self.popup_window setContentView:self.content_view]; [self.popup_window setHasShadow:YES]; @@ -97,15 +337,36 @@ static constexpr CGFloat const MINIMUM_WIDTH = 100; #pragma mark - Public methods -- (void)showWithSuggestions:(Vector)suggestions +- (void)showWithSuggestions:(Vector)suggestions + selectedRow:(NSInteger)selected_row { m_suggestions = move(suggestions); + [self rebuildRows]; + [self.suggestion_icons removeAllObjects]; + + for (auto const& suggestion : m_suggestions) { + if (suggestion.favicon_base64_png.has_value()) { + auto* suggestion_text = Ladybird::string_to_ns_string(suggestion.text); + if (auto* favicon = Ladybird::image_from_base64_png(*suggestion.favicon_base64_png, NSMakeSize(CELL_ICON_SIZE, CELL_ICON_SIZE)); favicon != nil) + [self.suggestion_icons setObject:favicon forKey:suggestion_text]; + } + } + [self.table_view reloadData]; - if (m_suggestions.is_empty()) + if (m_rows.is_empty()) [self close]; else [self show]; + + auto table_row = [self tableRowForSuggestionIndex:selected_row]; + if (table_row == NSNotFound) + [self clearSelection]; + else if (table_row != self.table_view.selectedRow) { + // Refreshing the default row should not behave like an explicit + // highlight, or the location field will re-preview the suggestion. + [self selectRow:table_row notifyObserver:NO]; + } } - (BOOL)close @@ -117,19 +378,30 @@ static constexpr CGFloat const MINIMUM_WIDTH = 100; [parent_window removeChildWindow:self.popup_window]; [self.popup_window orderOut:nil]; + [self.observer onAutocompleteDidClose]; return YES; } +- (BOOL)isVisible +{ + return self.popup_window.isVisible; +} + +- (void)clearSelection +{ + [self.table_view deselectAll:nil]; +} + - (Optional)selectedSuggestion { if (!self.popup_window.isVisible || self.table_view.numberOfRows == 0) return {}; auto row = [self.table_view selectedRow]; - if (row < 0) + if (![self isSelectableRow:row]) return {}; - return m_suggestions[row]; + return m_suggestions[m_rows[row].suggestion_index].text; } - (BOOL)selectNextSuggestion @@ -139,11 +411,13 @@ static constexpr CGFloat const MINIMUM_WIDTH = 100; if (!self.popup_window.isVisible) { [self show]; - [self selectRow:0]; + if (auto row = [self stepToSelectableRowFrom:-1 direction:1]; row != NSNotFound) + [self selectRow:row notifyObserver:YES]; return YES; } - [self selectRow:[self.table_view selectedRow] + 1]; + if (auto row = [self stepToSelectableRowFrom:[self.table_view selectedRow] direction:1]; row != NSNotFound) + [self selectRow:row notifyObserver:YES]; return YES; } @@ -154,11 +428,13 @@ static constexpr CGFloat const MINIMUM_WIDTH = 100; if (!self.popup_window.isVisible) { [self show]; - [self selectRow:self.table_view.numberOfRows - 1]; + if (auto row = [self stepToSelectableRowFrom:0 direction:-1]; row != NSNotFound) + [self selectRow:row notifyObserver:YES]; return YES; } - [self selectRow:[self.table_view selectedRow] - 1]; + if (auto row = [self stepToSelectableRowFrom:[self.table_view selectedRow] direction:-1]; row != NSNotFound) + [self selectRow:row notifyObserver:YES]; return YES; } @@ -170,23 +446,137 @@ static constexpr CGFloat const MINIMUM_WIDTH = 100; #pragma mark - Private methods +- (void)rebuildRows +{ + m_rows.clear(); + m_rows.ensure_capacity(m_suggestions.size() * 2); + + auto current_section = WebView::AutocompleteSuggestionSection::None; + for (size_t suggestion_index = 0; suggestion_index < m_suggestions.size(); ++suggestion_index) { + auto const& suggestion = m_suggestions[suggestion_index]; + if (suggestion.section != WebView::AutocompleteSuggestionSection::None && suggestion.section != current_section) { + current_section = suggestion.section; + m_rows.append({ + .kind = AutocompleteRowKind::SectionHeader, + .text = MUST(String::from_utf8(WebView::autocomplete_section_title(current_section))), + }); + } + + m_rows.append({ .kind = AutocompleteRowKind::Suggestion, .suggestion_index = suggestion_index }); + } +} + +- (BOOL)isSelectableRow:(NSInteger)row +{ + if (row < 0 || row >= static_cast(m_rows.size())) + return NO; + return m_rows[row].kind == AutocompleteRowKind::Suggestion; +} + +- (NSInteger)tableRowForSuggestionIndex:(NSInteger)suggestion_index +{ + if (suggestion_index < 0) + return NSNotFound; + + for (size_t row = 0; row < m_rows.size(); ++row) { + auto const& row_model = m_rows[row]; + if (row_model.kind == AutocompleteRowKind::Suggestion + && row_model.suggestion_index == static_cast(suggestion_index)) + return static_cast(row); + } + + return NSNotFound; +} + +- (CGFloat)heightOfRowAtIndex:(size_t)row +{ + VERIFY(row < m_rows.size()); + return m_rows[row].kind == AutocompleteRowKind::SectionHeader + ? autocomplete_section_header_height() + : autocomplete_row_height(); +} + +- (CGFloat)tableHeightForVisibleSuggestionCount:(size_t)visible_suggestion_count +{ + if (visible_suggestion_count == 0) + return 0; + + CGFloat total_height = 0; + size_t seen_suggestion_count = 0; + + for (size_t row = 0; row < m_rows.size(); ++row) { + total_height += [self heightOfRowAtIndex:row]; + if (m_rows[row].kind == AutocompleteRowKind::Suggestion) { + ++seen_suggestion_count; + if (seen_suggestion_count >= visible_suggestion_count) + break; + } + } + + return ceil(total_height); +} + +- (NSInteger)stepToSelectableRowFrom:(NSInteger)row direction:(NSInteger)direction +{ + if (self.table_view.numberOfRows == 0) + return NSNotFound; + + auto candidate = row; + for (NSInteger attempt = 0; attempt < self.table_view.numberOfRows; ++attempt) { + candidate += direction; + if (candidate < 0) + candidate = self.table_view.numberOfRows - 1; + else if (candidate >= self.table_view.numberOfRows) + candidate = 0; + + if ([self isSelectableRow:candidate]) + return candidate; + } + + return NSNotFound; +} + +- (void)autocompleteTableViewHoveredRowChanged:(NSInteger)row +{ + if (![self isSelectableRow:row]) + return; + + if (row == self.table_view.selectedRow) + return; + + [self selectRow:row notifyObserver:YES]; +} + - (void)show { auto* toolbar_view = self.toolbar_item.view; auto* parent_window = [toolbar_view window]; if (parent_window == nil) return; + auto was_visible = self.popup_window.isVisible; - auto visible_row_count = min(m_suggestions.size(), MAX_NUMBER_OF_ROWS); - auto table_height = (self.table_view.rowHeight + self.table_view.intercellSpacing.height) * visible_row_count; + size_t visible_suggestion_count = 0; + for (auto const& row_model : m_rows) { + if (row_model.kind == AutocompleteRowKind::Suggestion) + ++visible_suggestion_count; + } + + auto visible_table_height = [self tableHeightForVisibleSuggestionCount:visible_suggestion_count]; auto width = max(toolbar_view.frame.size.width, MINIMUM_WIDTH); - auto content_size = NSMakeSize(width, table_height + (POPOVER_PADDING * 2)); + auto content_size = NSMakeSize(width, visible_table_height + (POPOVER_PADDING * 2)); [self.content_view setFrame:NSMakeRect(0, 0, content_size.width, content_size.height)]; [self.scroll_view setFrame:NSInsetRect(self.content_view.bounds, 0, POPOVER_PADDING)]; - [self.scroll_view setHasVerticalScroller:m_suggestions.size() > MAX_NUMBER_OF_ROWS]; - [self.table_view deselectAll:nil]; - [self.table_view scrollRowToVisible:0]; + + CGFloat document_width = self.scroll_view.contentSize.width; + [self.table_view setFrame:NSMakeRect(0, 0, document_width, visible_table_height)]; + + if (auto* column = self.table_view.tableColumns.firstObject) + [column setWidth:document_width]; + + if (!was_visible) + [self.table_view deselectAll:nil]; + [self.table_view scrollRowToVisible:self.table_view.selectedRow >= 0 ? self.table_view.selectedRow : 0]; auto anchor_rect = [toolbar_view convertRect:toolbar_view.bounds toView:nil]; auto popup_rect = [parent_window convertRectToScreen:anchor_rect]; @@ -195,53 +585,172 @@ static constexpr CGFloat const MINIMUM_WIDTH = 100; [self.popup_window setFrame:popup_rect display:NO]; - if (!self.popup_window.isVisible) + if (!was_visible) [parent_window addChildWindow:self.popup_window ordered:NSWindowAbove]; [self.popup_window orderFront:nil]; } - (void)selectRow:(NSInteger)row + notifyObserver:(BOOL)notify_observer { - if (row < 0) - row = self.table_view.numberOfRows - 1; - else if (row >= self.table_view.numberOfRows) - row = 0; + if (![self isSelectableRow:row]) + return; [self.table_view selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO]; [self.table_view scrollRowToVisible:[self.table_view selectedRow]]; + + if (notify_observer) { + if (auto suggestion = [self selectedSuggestion]; suggestion.has_value()) + [self.observer onHighlightedSuggestion:suggestion.release_value()]; + } } #pragma mark - NSTableViewDataSource - (NSInteger)numberOfRowsInTableView:(NSTableView*)tableView { - return static_cast(m_suggestions.size()); + return static_cast(m_rows.size()); } #pragma mark - NSTableViewDelegate +- (CGFloat)tableView:(NSTableView*)tableView heightOfRow:(NSInteger)row +{ + return [self heightOfRowAtIndex:static_cast(row)]; +} + +- (NSTableRowView*)tableView:(NSTableView*)tableView + rowViewForRow:(NSInteger)row +{ + return [[AutocompleteRowView alloc] initWithFrame:NSMakeRect(0, 0, NSWidth(tableView.bounds), [self tableView:tableView heightOfRow:row])]; +} + - (NSView*)tableView:(NSTableView*)table_view viewForTableColumn:(NSTableColumn*)table_column row:(NSInteger)row { - NSTableCellView* view = [table_view makeViewWithIdentifier:AUTOCOMPLETE_IDENTIFIER owner:self]; + auto const& row_model = m_rows[row]; + if (row_model.kind == AutocompleteRowKind::SectionHeader) { + AutocompleteSectionHeaderView* view = (AutocompleteSectionHeaderView*)[table_view makeViewWithIdentifier:AUTOCOMPLETE_SECTION_HEADER_IDENTIFIER owner:self]; + + if (view == nil) { + view = [[AutocompleteSectionHeaderView alloc] initWithFrame:NSZeroRect]; + + NSTextField* text_field = [[NSTextField alloc] initWithFrame:NSZeroRect]; + [text_field setBezeled:NO]; + [text_field setDrawsBackground:NO]; + [text_field setEditable:NO]; + [text_field setFont:autocomplete_section_header_font()]; + [text_field setSelectable:NO]; + [view addSubview:text_field]; + [view setText_field:text_field]; + [view setIdentifier:AUTOCOMPLETE_SECTION_HEADER_IDENTIFIER]; + } + + auto* header_text = Ladybird::string_to_ns_string(row_model.text); + auto header_height = autocomplete_text_field_height(autocomplete_section_header_font()); + [view setFrame:NSMakeRect(0, 0, NSWidth(table_view.bounds), [self tableView:table_view heightOfRow:row])]; + [view.text_field setStringValue:header_text]; + [view.text_field setTextColor:[NSColor tertiaryLabelColor]]; + [view.text_field setFrame:NSMakeRect( + SECTION_HEADER_HORIZONTAL_PADDING, + floor((NSHeight(view.bounds) - header_height) / 2.f), + NSWidth(view.bounds) - (SECTION_HEADER_HORIZONTAL_PADDING * 2), + header_height)]; + return view; + } + + AutocompleteSuggestionView* view = (AutocompleteSuggestionView*)[table_view makeViewWithIdentifier:AUTOCOMPLETE_IDENTIFIER owner:self]; if (view == nil) { - view = [[NSTableCellView alloc] initWithFrame:NSZeroRect]; + view = [[AutocompleteSuggestionView 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]; + NSImageView* icon_view = [[NSImageView alloc] initWithFrame:NSZeroRect]; + [icon_view setImageScaling:NSImageScaleProportionallyDown]; + [view addSubview:icon_view]; + [view setIcon_view:icon_view]; + + NSTextField* title_text_field = [[NSTextField alloc] initWithFrame:NSZeroRect]; + [title_text_field setBezeled:NO]; + [title_text_field setDrawsBackground:NO]; + [title_text_field setEditable:NO]; + [title_text_field setFont:autocomplete_primary_font()]; + [title_text_field setLineBreakMode:NSLineBreakByTruncatingTail]; + [title_text_field setSelectable:NO]; + [view addSubview:title_text_field]; + [view setTitle_text_field:title_text_field]; + + NSTextField* url_text_field = [[NSTextField alloc] initWithFrame:NSZeroRect]; + [url_text_field setBezeled:NO]; + [url_text_field setDrawsBackground:NO]; + [url_text_field setEditable:NO]; + [url_text_field setLineBreakMode:NSLineBreakByTruncatingTail]; + [url_text_field setSelectable:NO]; + [view addSubview:url_text_field]; + [view setUrl_text_field:url_text_field]; - [view addSubview:text_field]; - [view setTextField:text_field]; [view setIdentifier:AUTOCOMPLETE_IDENTIFIER]; } - [view.textField setStringValue:Ladybird::string_to_ns_string(m_suggestions[row])]; + auto const& suggestion = m_suggestions[row_model.suggestion_index]; + auto* suggestion_text = Ladybird::string_to_ns_string(suggestion.text); + auto* title_text = suggestion.title.has_value() ? Ladybird::string_to_ns_string(*suggestion.title) : nil; + auto* favicon = [self.suggestion_icons objectForKey:suggestion_text]; + auto* icon = suggestion.source == WebView::AutocompleteSuggestionSource::LiteralURL + ? literal_url_suggestion_icon() + : suggestion.source == WebView::AutocompleteSuggestionSource::Search ? search_suggestion_icon() + : favicon; + + [view setFrame:NSMakeRect(0, 0, NSWidth(table_view.bounds), [self tableView:table_view heightOfRow:row])]; + + auto primary_text_height = autocomplete_text_field_height(autocomplete_primary_font()); + auto secondary_text_height = autocomplete_text_field_height(autocomplete_secondary_font()); + CGFloat text_origin_x = CELL_HORIZONTAL_PADDING + CELL_ICON_SIZE + CELL_ICON_TEXT_SPACING; + CGFloat text_width = NSWidth(view.bounds) - text_origin_x - CELL_HORIZONTAL_PADDING; + + [view.icon_view setFrame:NSMakeRect( + CELL_HORIZONTAL_PADDING, + floor((NSHeight(view.bounds) - CELL_ICON_SIZE) / 2.f), + CELL_ICON_SIZE, + CELL_ICON_SIZE)]; + [view.icon_view setImage:icon]; + [view.icon_view setContentTintColor:suggestion.source != WebView::AutocompleteSuggestionSource::History ? [NSColor secondaryLabelColor] : nil]; + [view.icon_view setHidden:(icon == nil)]; + + if (title_text != nil) { + CGFloat text_block_height = primary_text_height + CELL_LABEL_VERTICAL_SPACING + secondary_text_height; + CGFloat text_block_origin_y = floor((NSHeight(view.bounds) - text_block_height) / 2.f); + + [view.title_text_field setHidden:NO]; + [view.title_text_field setStringValue:title_text]; + [view.title_text_field setTextColor:[NSColor textColor]]; + [view.title_text_field setFrame:NSMakeRect( + text_origin_x, + text_block_origin_y + secondary_text_height + CELL_LABEL_VERTICAL_SPACING, + text_width, + primary_text_height)]; + + [view.url_text_field setFont:autocomplete_secondary_font()]; + [view.url_text_field setTextColor:[NSColor secondaryLabelColor]]; + [view.url_text_field setFrame:NSMakeRect( + text_origin_x, + text_block_origin_y, + text_width, + secondary_text_height)]; + } else { + [view.title_text_field setHidden:YES]; + + [view.url_text_field setFont:[NSFont systemFontOfSize:[NSFont systemFontSize]]]; + [view.url_text_field setTextColor:[NSColor textColor]]; + [view.url_text_field setFrame:NSMakeRect( + text_origin_x, + floor((NSHeight(view.bounds) - primary_text_height) / 2.f), + text_width, + primary_text_height)]; + } + + [view.url_text_field setStringValue:suggestion_text]; return view; } diff --git a/UI/AppKit/Interface/Menu.mm b/UI/AppKit/Interface/Menu.mm index 07b8d0c20b1..236ff309516 100644 --- a/UI/AppKit/Interface/Menu.mm +++ b/UI/AppKit/Interface/Menu.mm @@ -4,8 +4,6 @@ * SPDX-License-Identifier: BSD-2-Clause */ -#include - #import #import #import @@ -186,25 +184,10 @@ private: __weak id m_control { nil }; }; -static NSImage* image_from_base64_png(StringView favicon_base64_png) +static void initialize_native_icon(WebView::Action& action, id control) { static constexpr CGFloat const MENU_ICON_SIZE = 16; - auto decoded = decode_base64(favicon_base64_png); - if (decoded.is_error()) - return nil; - - auto* data = [NSData dataWithBytes:decoded.value().data() - length:decoded.value().size()]; - - auto* image = [[NSImage alloc] initWithData:data]; - [image setSize:NSMakeSize(MENU_ICON_SIZE, MENU_ICON_SIZE)]; - - return image; -} - -static void initialize_native_icon(WebView::Action& action, id control) -{ switch (action.id()) { case WebView::ActionID::NavigateBack: set_control_image(control, @"chevron.left"); @@ -248,7 +231,7 @@ static void initialize_native_icon(WebView::Action& action, id control) break; case WebView::ActionID::BookmarkItem: if (auto icon = action.base64_png_icon(); icon.has_value()) - [control setImage:image_from_base64_png(*icon)]; + [control setImage:Ladybird::image_from_base64_png(*icon, NSMakeSize(MENU_ICON_SIZE, MENU_ICON_SIZE))]; else set_control_image(control, @"globe"); break; diff --git a/UI/AppKit/Interface/TabController.mm b/UI/AppKit/Interface/TabController.mm index d5f190e145c..92eee870f04 100644 --- a/UI/AppKit/Interface/TabController.mm +++ b/UI/AppKit/Interface/TabController.mm @@ -32,6 +32,131 @@ 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 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; +} + @interface LocationSearchField : NSSearchField - (BOOL)becomeFirstResponder; @@ -65,6 +190,8 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde 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; @@ -88,9 +215,20 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde @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 @@ -122,6 +260,8 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde [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; @@ -129,13 +269,28 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde self.autocomplete = [[Autocomplete alloc] init:self withToolbarItem:self.location_toolbar_item]; m_autocomplete = make(); - m_autocomplete->on_autocomplete_query_complete = [weak_self](auto suggestions) { + m_autocomplete->on_autocomplete_query_complete = [weak_self](auto suggestions, WebView::AutocompleteResultKind result_kind) { TabController* self = weak_self; if (self == nil) { return; } - [self.autocomplete showWithSuggestions:move(suggestions)]; + 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]; }; } @@ -260,12 +415,194 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde [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 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]; @@ -617,10 +954,15 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde 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; @@ -653,20 +995,49 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde 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 { - auto* location_search_field = (LocationSearchField*)[self.location_toolbar_item view]; + if (m_is_applying_inline_autocomplete) + return; - auto url_string = Ladybird::ns_string_to_string([location_search_field stringValue]); - m_autocomplete->query_autocomplete_engine(move(url_string)); + 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)]; diff --git a/UI/AppKit/Utilities/Conversions.h b/UI/AppKit/Utilities/Conversions.h index 6d0e4b15979..5ad1c62a592 100644 --- a/UI/AppKit/Utilities/Conversions.h +++ b/UI/AppKit/Utilities/Conversions.h @@ -30,6 +30,7 @@ ByteString ns_string_to_byte_string(NSString*); ByteString ns_data_to_string(NSData*); NSData* string_to_ns_data(StringView); +NSImage* image_from_base64_png(StringView, NSSize size); NSDictionary* deserialize_json_to_dictionary(StringView); diff --git a/UI/AppKit/Utilities/Conversions.mm b/UI/AppKit/Utilities/Conversions.mm index 4fe685a83bc..bed3e11fb19 100644 --- a/UI/AppKit/Utilities/Conversions.mm +++ b/UI/AppKit/Utilities/Conversions.mm @@ -5,6 +5,7 @@ * SPDX-License-Identifier: BSD-2-Clause */ +#include #include #import @@ -56,6 +57,19 @@ NSData* string_to_ns_data(StringView string) return [NSData dataWithBytes:string.characters_without_null_termination() length:string.length()]; } +NSImage* image_from_base64_png(StringView string, NSSize size) +{ + auto decoded = decode_base64(string); + if (decoded.is_error()) + return nil; + + auto* data = [NSData dataWithBytes:decoded.value().data() + length:decoded.value().size()]; + auto* image = [[NSImage alloc] initWithData:data]; + [image setSize:size]; + return image; +} + NSDictionary* deserialize_json_to_dictionary(StringView json) { auto* ns_json = string_to_ns_string(json);