blob: 921e59ef50f61187ef09a164b38c34f1d40f787b [file] [log] [blame]
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "ios/chrome/browser/omnibox/model/omnibox_text_controller.h"
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
#import "base/memory/raw_ptr.h"
#import "base/metrics/user_metrics.h"
#import "base/metrics/user_metrics_action.h"
#import "components/omnibox/browser/omnibox_controller.h"
#import "components/omnibox/browser/omnibox_view.h"
#import "ios/chrome/browser/omnibox/model/autocomplete_suggestion.h"
#import "ios/chrome/browser/omnibox/model/omnibox_autocomplete_controller.h"
#import "ios/chrome/browser/omnibox/model/omnibox_text_controller_delegate.h"
#import "ios/chrome/browser/omnibox/public/omnibox_metrics_helper.h"
#import "ios/chrome/browser/omnibox/ui_bundled/omnibox_text_field_ios.h"
#import "ios/chrome/browser/omnibox/ui_bundled/omnibox_view_ios.h"
#import "ios/chrome/browser/shared/ui/util/pasteboard_util.h"
#import "ios/chrome/common/NSString+Chromium.h"
#import "net/base/apple/url_conversions.h"
@implementation OmniboxTextController {
/// Controller of the omnibox.
raw_ptr<OmniboxController> _omniboxController;
/// Controller of the omnibox view.
raw_ptr<OmniboxViewIOS> _omniboxViewIOS;
/// Omnibox edit model. Should only be used for text interactions.
raw_ptr<OmniboxEditModel> _omniboxEditModel;
// Whether the popup was scrolled during this omnibox interaction.
BOOL _suggestionsListScrolled;
}
- (instancetype)initWithOmniboxController:(OmniboxController*)omniboxController
omniboxViewIOS:(OmniboxViewIOS*)omniboxViewIOS {
self = [super init];
if (self) {
_omniboxController = omniboxController;
_omniboxEditModel = omniboxController->edit_model();
_omniboxViewIOS = omniboxViewIOS;
}
return self;
}
- (void)disconnect {
_omniboxController = nullptr;
_omniboxEditModel = nullptr;
_omniboxViewIOS = nullptr;
}
- (void)updateAppearance {
if (!_omniboxEditModel) {
return;
}
// If Siri is thinking, treat that as user input being in progress. It is
// unsafe to modify the text field while voice entry is pending.
if (_omniboxEditModel->ResetDisplayTexts()) {
if (_omniboxViewIOS) {
// Revert everything to the baseline look.
_omniboxViewIOS->RevertAll();
}
} else if (!_omniboxEditModel->has_focus()) {
// Even if the change wasn't "user visible" to the model, it still may be
// necessary to re-color to the URL string. Only do this if the omnibox is
// not currently focused.
NSAttributedString* as = [[NSMutableAttributedString alloc]
initWithString:base::SysUTF16ToNSString(
_omniboxEditModel->GetPermanentDisplayText())];
[self.textField setText:as userTextLength:[as length]];
}
}
- (BOOL)isOmniboxFirstResponder {
return [self.textField isFirstResponder];
}
- (void)endEditing {
// This check is a tentative fix for a crash that happens when calling
// `resignFirstResponder`. TODO(crbug.com/375429786): Verify the crash rate
// and remove the comment or check if needed.
if (self.textField.window) {
[self.textField resignFirstResponder];
}
if (_omniboxViewIOS) {
_omniboxViewIOS->EndEditing(_suggestionsListScrolled);
}
_suggestionsListScrolled = NO;
}
- (void)insertTextToOmnibox:(NSString*)text {
[self.textField insertTextWhileEditing:text];
// The call to `setText` shouldn't be needed, but without it the "Go" button
// of the keyboard is disabled.
[self.textField setText:text];
// Notify the accessibility system to start reading the new contents of the
// Omnibox.
UIAccessibilityPostNotification(UIAccessibilityScreenChangedNotification,
self.textField);
}
#pragma mark - Autocomplete events
- (void)setAdditionalText:(const std::u16string&)text {
if (!text.length()) {
self.textField.additionalText = nil;
return;
}
[self.textField setAdditionalText:[NSString cr_fromString16:u" - " + text]];
}
#pragma mark - Omnibox text events
- (void)onUserRemoveAdditionalText {
[self setAdditionalText:u""];
if (_omniboxEditModel) {
_omniboxEditModel->UpdateInput(/*has_selected_text=*/false,
/*prevent_inline_autocomplete=*/true);
}
}
- (void)onThumbnailSet:(BOOL)hasThumbnail {
[self.omniboxAutocompleteController setHasThumbnail:hasThumbnail];
}
- (void)onUserRemoveThumbnail {
// Update the client state.
if (_omniboxController && _omniboxController->client()) {
_omniboxController->client()->OnThumbnailRemoved();
}
// Update the popup for suggestion wrapping.
[self.omniboxAutocompleteController setHasThumbnail:NO];
if (self.textField.userText.length) {
// If the omnibox is not empty, start autocomplete.
if (_omniboxEditModel) {
_omniboxEditModel->UpdateInput(/*has_selected_text=*/false,
/*prevent_inline_autocomplete=*/true);
}
} else {
if (_omniboxViewIOS) {
_omniboxViewIOS->CloseOmniboxPopup();
}
}
}
- (void)clearText {
OmniboxTextFieldIOS* textField = self.textField;
// Ensure omnibox is first responder. This will bring up the keyboard so the
// user can start typing a new query.
if (![textField isFirstResponder]) {
[textField becomeFirstResponder];
}
if (textField.text.length != 0) {
// Remove the text in the omnibox.
// Calling -[UITextField setText:] does not trigger
// -[id<UITextFieldDelegate> textDidChange] so it must be called explicitly.
[textField clearAutocompleteText];
[textField exitPreEditState];
[textField setText:@""];
if (_omniboxViewIOS) {
_omniboxViewIOS->OnDidChange(/*processing_user_input=*/true);
}
}
// Calling OnDidChange() can trigger a scroll event, which removes focus from
// the omnibox.
[textField becomeFirstResponder];
}
- (void)acceptInput {
RecordAction(base::UserMetricsAction("MobileOmniboxUse"));
RecordAction(base::UserMetricsAction("IOS.Omnibox.AcceptDefaultSuggestion"));
if (_omniboxEditModel) {
// The omnibox edit model doesn't support accepting input with no text.
// Delegate the call to the client instead.
if (OmniboxClient* client = [self omniboxClient];
client && !self.textField.text.length) {
client->OnThumbnailOnlyAccept();
} else {
_omniboxEditModel->OpenSelection();
}
}
if (_omniboxViewIOS) {
_omniboxViewIOS->RevertAll();
}
}
- (void)prepareForScribble {
OmniboxTextFieldIOS* textModel = self.textField;
if (textModel.isPreEditing) {
[textModel exitPreEditState];
[textModel setText:@""];
}
[textModel clearAutocompleteText];
}
- (void)cleanupAfterScribble {
[self.textField clearAutocompleteText];
[self.textField setAdditionalText:nil];
}
- (void)onTextInputModeChange {
// Update the popup to align suggestions with the text in the textField.
[self.omniboxAutocompleteController updatePopupSuggestions];
}
- (void)onDidBeginEditing {
if (_omniboxViewIOS) {
_omniboxViewIOS->OnDidBeginEditing();
}
}
- (BOOL)shouldChangeCharactersInRange:(NSRange)range
replacementString:(NSString*)newText {
if (_omniboxViewIOS) {
return _omniboxViewIOS->OnWillChange(range, newText);
}
return YES;
}
- (void)textDidChangeWithUserEvent:(BOOL)isProcessingUserEvent {
if (_omniboxViewIOS) {
_omniboxViewIOS->OnDidChange(isProcessingUserEvent);
}
}
- (void)onAcceptAutocomplete {
if (_omniboxViewIOS) {
_omniboxViewIOS->OnAcceptAutocomplete();
}
}
- (void)onCopy {
NSString* selectedText = nil;
NSInteger startLocation = 0;
OmniboxTextFieldIOS* textField = self.textField;
if ([textField isPreEditing]) {
selectedText = textField.text;
startLocation = 0;
} else {
UITextRange* selectedRange = [textField selectedTextRange];
selectedText = [textField textInRange:selectedRange];
UITextPosition* start = [textField beginningOfDocument];
// The following call to `-offsetFromPosition:toPosition:` gives the offset
// in terms of the number of "visible characters." The documentation does
// not specify whether this means glyphs or UTF16 chars. This does not
// matter for the current implementation of AdjustTextForCopy(), but it may
// become an issue at some point.
startLocation = [textField offsetFromPosition:start
toPosition:[selectedRange start]];
}
std::u16string text = selectedText.cr_UTF16String;
GURL URL;
bool writeURL = false;
// Model can be nullptr in tests.
if (_omniboxEditModel) {
_omniboxEditModel->AdjustTextForCopy(startLocation, &text, &URL, &writeURL);
}
// Create the pasteboard item manually because the pasteboard expects a single
// item with multiple representations. This is expressed as a single
// NSDictionary with multiple keys, one for each representation.
NSMutableDictionary* item = [NSMutableDictionary dictionaryWithCapacity:2];
[item setObject:[NSString cr_fromString16:text]
forKey:UTTypePlainText.identifier];
using enum OmniboxCopyType;
if (writeURL && URL.is_valid()) {
[item setObject:net::NSURLWithGURL(URL) forKey:UTTypeURL.identifier];
if ([textField isPreEditing]) {
RecordOmniboxCopy(kPreEditURL);
} else {
RecordOmniboxCopy(kEditedURL);
}
} else {
RecordOmniboxCopy(kText);
}
StoreItemInPasteboard(item);
}
- (void)willPaste {
if (_omniboxEditModel) {
_omniboxEditModel->OnPaste();
}
[self.textField exitPreEditState];
}
- (void)onDeleteBackward {
if (_omniboxViewIOS) {
_omniboxViewIOS->OnDeleteBackward();
}
}
#pragma mark - Omnibox popup event
- (void)previewSuggestion:(id<AutocompleteSuggestion>)suggestion
isFirstUpdate:(BOOL)isFirstUpdate {
// On first update, don't set the preview text, as omnibox will automatically
// receive the suggestion as inline autocomplete through OmniboxViewIOS.
if (!isFirstUpdate) {
[self previewSuggestion:suggestion];
}
[self.delegate omniboxTextController:self
didPreviewSuggestion:suggestion
isFirstUpdate:isFirstUpdate];
}
- (void)onScroll {
/// Hides the keyboard.
[self.textField resignFirstResponder];
_suggestionsListScrolled = YES;
}
#pragma mark - Private
/// Previews `suggestion` in the Omnibox. Called when a suggestion is
/// highlighted in the popup.
- (void)previewSuggestion:(id<AutocompleteSuggestion>)suggestion {
OmniboxTextFieldIOS* textModel = self.textField;
NSAttributedString* previewText = suggestion.omniboxPreviewText;
[textModel exitPreEditState];
[textModel setAdditionalText:nil];
[textModel setText:previewText userTextLength:previewText.length];
}
#pragma mark - Private
- (OmniboxClient*)omniboxClient {
return _omniboxController ? _omniboxController->client() : nullptr;
}
@end