blob: 81c770f6f36f95c9622c751dc7e83bc8ae75ae11 [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_focus_delegate.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"
@interface OmniboxTextController ()
/// The omnibox client.
@property(nonatomic, assign, readonly) OmniboxClient* client;
@end
@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;
/// Whether it's the lens overlay omnibox.
BOOL _inLensOverlay;
}
- (instancetype)initWithOmniboxController:(OmniboxController*)omniboxController
omniboxViewIOS:(OmniboxViewIOS*)omniboxViewIOS
inLensOverlay:(BOOL)inLensOverlay {
self = [super init];
if (self) {
_omniboxController = omniboxController;
_omniboxEditModel = omniboxController->edit_model();
_omniboxViewIOS = omniboxViewIOS;
_inLensOverlay = inLensOverlay;
}
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 {
[self hideKeyboard];
if (!_omniboxEditModel || !_omniboxEditModel->has_focus()) {
return;
}
[self.omniboxAutocompleteController endEditing];
if (OmniboxClient* client = self.client) {
RecordSuggestionsListScrolled(
client->GetPageClassification(/*is_prefetch=*/false),
_suggestionsListScrolled);
}
_omniboxEditModel->OnWillKillFocus();
_omniboxEditModel->OnKillFocus();
[self.textField exitPreEditState];
// The controller looks at the current pre-edit state, so the call to
// OnKillFocus() must come after exiting pre-edit.
[self.focusDelegate omniboxDidResignFirstResponder];
// Blow away any in-progress edits.
if (_omniboxViewIOS) {
_omniboxViewIOS->RevertAll();
}
DCHECK(![self.textField hasAutocompleteText]);
_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.client;
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 Open from Clipboard offers a suggestion, the popup may be opened when
// `OnSetFocus` is called on the model. The state of the popup is saved early
// to ignore that case.
BOOL popupOpenBeforeEdit = self.omniboxAutocompleteController.hasSuggestions;
OmniboxTextFieldIOS* textField = self.textField;
// Make sure the omnibox popup's semantic content attribute is set correctly.
[self.omniboxAutocompleteController
setSemanticContentAttribute:[textField bestSemanticContentAttribute]];
if (_omniboxViewIOS) {
_omniboxViewIOS->OnBeforePossibleChange();
}
if (_omniboxEditModel) {
_omniboxEditModel->OnSetFocus(/*control_down=*/false);
if (_inLensOverlay && textField.userText.length) {
_omniboxEditModel->SetUserText(textField.userText.cr_UTF16String);
_omniboxEditModel->StartAutocomplete(
/*has_selected_text=*/false,
/*prevent_inline_autocomplete=*/true);
} else {
_omniboxEditModel->StartZeroSuggestRequest();
}
}
// If the omnibox is displaying a URL and the popup is not showing, set the
// field into pre-editing state. If the omnibox is displaying search terms,
// leave the default behavior of positioning the cursor at the end of the
// text. If the popup is already open, that means that the omnibox is
// regaining focus after a popup scroll took focus away, so the pre-edit
// behavior should not be invoked. When `is_lens_overlay_` is true, the
// omnibox only display search terms.
if (!popupOpenBeforeEdit && !_inLensOverlay) {
[textField enterPreEditState];
}
// `location_bar_` is only forwarding the call to the BVC. This should only
// happen when the omnibox is being focused and it starts showing the popup;
// if the popup was already open, no need to call this.
if (!popupOpenBeforeEdit) {
[self.focusDelegate omniboxDidBecomeFirstResponder];
}
}
- (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 {
OmniboxTextFieldIOS* textField = self.textField;
if (textField.text.length == 0) {
// If the user taps backspace while the pre-edit text is showing,
// OnWillChange is invoked before this method and sets the text to an empty
// string, so use the `clearingPreEditText` to determine if the chip should
// be cleared or not.
if ([textField clearingPreEditText]) {
// In the case where backspace is tapped while in pre-edit mode,
// OnWillChange is called but OnDidChange is never called so ensure the
// clearingPreEditText flag is set to false again.
[textField setClearingPreEditText:NO];
// Explicitly set the input-in-progress flag. Normally this is set via
// in model()->OnAfterPossibleChange, but in this case the text has been
// set to the empty string by OnWillChange so when OnAfterPossibleChange
// checks if the text has changed it does not see any difference so it
// never sets the input-in-progress flag.
if (_omniboxEditModel) {
_omniboxEditModel->SetInputInProgress(YES);
}
}
}
}
#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 {
[self hideKeyboard];
_suggestionsListScrolled = YES;
}
- (void)hideKeyboard {
// 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];
}
}
- (void)refineWithText:(const std::u16string&)text {
OmniboxTextFieldIOS* textField = self.textField;
if (!_omniboxViewIOS) {
return;
}
// Exit preedit state and append the match. Refocus if necessary.
[textField exitPreEditState];
_omniboxViewIOS->SetUserText(text);
// Calling setText: does not trigger UIControlEventEditingChanged, so
// trigger that manually.
[textField sendActionsForControlEvents:UIControlEventEditingChanged];
[textField becomeFirstResponder];
if (@available(iOS 17, *)) {
// Set the caret pos to the end of the text (crbug.com/331622199).
_omniboxViewIOS->SetCaretPos(text.length());
}
}
#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];
}
/// Updates the appearance of popup to have proper text alignment.
- (void)updatePopupLayoutDirection {
OmniboxTextFieldIOS* textField = self.textField;
[self.omniboxAutocompleteController
setTextAlignment:[textField bestTextAlignment]];
[self.omniboxAutocompleteController
setSemanticContentAttribute:[textField bestSemanticContentAttribute]];
}
- (OmniboxClient*)client {
return _omniboxController ? _omniboxController->client() : nullptr;
}
@end