blob: 821de256742cd9480a47f1d8518d0274d2e774ea [file] [log] [blame]
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "chrome/browser/ui/cocoa/touchbar/text_suggestions_touch_bar_controller.h"
#include "base/i18n/break_iterator.h"
#include "base/strings/sys_string_conversions.h"
#include "content/public/browser/render_widget_host_view.h"
#include "content/public/browser/web_contents.h"
#include "content/public/browser/web_contents_observer.h"
#import "ui/base/cocoa/touch_bar_util.h"
#include "ui/gfx/range/range.h"
namespace {
// Touch bar identifier.
NSString* const kTextSuggestionsTouchBarId = @"text-suggestions";
// Touch bar item identifiers.
NSString* const kTextSuggestionsItemsTouchId = @"TEXT-SUGGESTIONS-ITEMS";
} // namespace
namespace text_observer {
class WebContentsTextObserver : public content::WebContentsObserver {
public:
WebContentsTextObserver(content::WebContents* web_contents,
TextSuggestionsTouchBarController* owner)
: WebContentsObserver(web_contents), owner_(owner) {}
void UpdateWebContents(content::WebContents* web_contents) {
Observe(web_contents);
}
void DidChangeTextSelection(const base::string16& text,
const gfx::Range& range,
size_t offset) override {
[owner_ updateTextSelection:text range:range offset:offset];
}
void DidFinishLoad(content::RenderFrameHost* render_frame_host,
const GURL& validated_url) override {
[owner_ updateTextSelection:base::string16() range:gfx::Range() offset:0];
}
private:
TextSuggestionsTouchBarController* owner_; // weak
};
} // namespace text_observer
@interface TextSuggestionsTouchBarController () {
// An observer for text selection changes.
std::unique_ptr<text_observer::WebContentsTextObserver> observer_;
// The WebContents in which text is being selected and replaced.
content::WebContents* webContents_; // weak
// The WebTextfieldTouchBarController that invalidates the touch bar.
WebTextfieldTouchBarController* controller_; // weak
// The text on which suggestions are based.
base::scoped_nsobject<NSString> text_;
// A list of NSTextCheckingResults containing text suggestions for |text_| at
// |selectionRange_|.
base::scoped_nsobject<NSArray> suggestions_;
// The currently selected range. If the range has length = 0, it is simply a
// cursor position.
NSRange selectionRange_;
// The range of the word that's currently being edited. Used when replacing
// the current editing word with a selected suggestion. If length = 0, there
// is no word currently being edited and the suggestion will be placed at the
// cursor.
NSRange editingWordRange_;
// The location of |editingWordRange_| within the total text, which may be
// longer than the text received on text selection update. Used for checking
// when to ignore replacement text selections.
NSRange offsetEditingWordRange_;
// When YES, -updateTextSelection:range: should ignore a text selection that
// is equal to the editing word range. Set to YES when
// -replaceEditingWordWithSuggestion: modifies the current text selection.
// Reset to NO when the text selection for replacing the editing word reaches
// -updateTextSelection:range:.
BOOL shouldIgnoreReplacementSelection_;
}
@end
@implementation TextSuggestionsTouchBarController
- (BOOL)isTextfieldFocused {
return webContents_ && webContents_->IsFocusedElementEditable();
}
- (instancetype)initWithWebContents:(content::WebContents*)webContents
controller:
(WebTextfieldTouchBarController*)controller {
if ((self = [super init])) {
webContents_ = webContents;
controller_ = controller;
observer_.reset(
new text_observer::WebContentsTextObserver(webContents_, self));
shouldIgnoreReplacementSelection_ = NO;
}
return self;
}
- (NSTouchBar*)makeTouchBar {
if (![self isTextfieldFocused] || ![suggestions_ count])
return nil;
base::scoped_nsobject<NSTouchBar> touchBar([[ui::NSTouchBar() alloc] init]);
[touchBar
setCustomizationIdentifier:ui::GetTouchBarId(kTextSuggestionsTouchBarId)];
[touchBar setDelegate:self];
[touchBar setDefaultItemIdentifiers:@[ kTextSuggestionsItemsTouchId ]];
return touchBar.autorelease();
}
- (NSTouchBarItem*)touchBar:(NSTouchBar*)touchBar
makeItemForIdentifier:(NSTouchBarItemIdentifier)identifier {
if (![identifier hasSuffix:kTextSuggestionsItemsTouchId])
return nil;
return [self createCandidateListItem];
}
- (NSCandidateListTouchBarItem*)createCandidateListItem {
NSCandidateListTouchBarItem* candidateListItem =
[[NSCandidateListTouchBarItem alloc]
initWithIdentifier:kTextSuggestionsItemsTouchId];
candidateListItem.delegate = self;
if (selectionRange_.length)
candidateListItem.collapsed = YES;
[candidateListItem setCandidates:suggestions_
forSelectedRange:selectionRange_
inString:text_];
return candidateListItem;
}
- (void)candidateListTouchBarItem:(NSCandidateListTouchBarItem*)anItem
endSelectingCandidateAtIndex:(NSInteger)index {
if (index == NSNotFound)
return;
if (anItem) {
NSTextCheckingResult* selectedResult = anItem.candidates[index];
[self replaceEditingWordWithSuggestion:[selectedResult replacementString]];
}
ui::LogTouchBarUMA(ui::TouchBarAction::TEXT_SUGGESTION);
}
- (void)updateTextSelection:(const base::string16&)text
range:(const gfx::Range&)range
offset:(size_t)offset {
if (@available(macOS 10.12.2, *)) {
if (shouldIgnoreReplacementSelection_ &&
range == gfx::Range(offsetEditingWordRange_)) {
shouldIgnoreReplacementSelection_ = NO;
return;
}
if (![self isTextfieldFocused]) {
[controller_ invalidateTouchBar];
return;
}
if (!range.IsValid()) {
[self updateTextSelection:base::string16() range:gfx::Range() offset:0];
return;
}
text_.reset([base::SysUTF16ToNSString(text) retain]);
selectionRange_ =
NSMakeRange(range.start() - offset, range.end() - range.start());
editingWordRange_ =
[self editingWordRangeFromText:text
cursorPosition:selectionRange_.location];
offsetEditingWordRange_ = NSMakeRange(editingWordRange_.location + offset,
editingWordRange_.length);
[self requestSuggestions];
}
}
- (NSRange)editingWordRangeFromText:(const base::string16&)text
cursorPosition:(size_t)cursor {
// The cursor should not be off the end of the text.
DCHECK(cursor <= text.length());
// Default range is just the cursor position. This is used if BreakIterator
// cannot be initialized, there is no text in the textfield, or the cursor is
// at the front of a word.
size_t location = cursor;
size_t length = 0;
base::i18n::BreakIterator iter(text, base::i18n::BreakIterator::BREAK_WORD);
if (iter.Init()) {
// Repeat iter.Advance() until end of line is reached or
// current iterator position passes cursor position.
while (iter.pos() < cursor && iter.Advance()) {
}
// If BreakIterator stopped at the end of a word, the cursor is in/at the
// end of a word so the editing word range is [word start, word end].
if (iter.IsWord()) {
location = iter.prev();
length = iter.pos() - iter.prev();
}
// The suggestion text returned by AppKit has the necessary trailing
// whitespace which should replace the existing trailing whitespace.
// If the BreakIterator just iterated over whitespace or iterates over
// whitespace when it advances, modify the length of the editing word
// range to include the whitespace.
if ((iter.pos() && !iter.IsWord()) || (iter.Advance() && !iter.IsWord()))
length = iter.pos() - location;
}
return NSMakeRange(location, length);
}
- (void)requestSuggestions {
NSSpellChecker* spell_checker = [NSSpellChecker sharedSpellChecker];
[spell_checker
requestCandidatesForSelectedRange:selectionRange_
inString:text_
types:NSTextCheckingAllSystemTypes
options:nil
inSpellDocumentWithTag:0
completionHandler:^(
NSInteger sequenceNumber,
NSArray<NSTextCheckingResult*>* candidates) {
dispatch_async(dispatch_get_main_queue(), ^{
suggestions_.reset([candidates copy]);
[controller_ invalidateTouchBar];
});
}];
}
- (void)replaceEditingWordWithSuggestion:(NSString*)text {
// If the editing word is not selected in its entirety, modify the selection
// to cover the current editing word.
if (!NSEqualRanges(editingWordRange_, selectionRange_)) {
shouldIgnoreReplacementSelection_ = YES;
int start_adjust = editingWordRange_.location - selectionRange_.location;
int end_adjust = (editingWordRange_.location + editingWordRange_.length) -
(selectionRange_.location + selectionRange_.length);
webContents_->AdjustSelectionByCharacterOffset(start_adjust, end_adjust,
false);
}
webContents_->Replace(base::SysNSStringToUTF16(text));
}
- (void)setWebContents:(content::WebContents*)webContents {
webContents_ = webContents;
observer_->UpdateWebContents(webContents);
if (![self isTextfieldFocused]) {
[controller_ invalidateTouchBar];
return;
}
const base::string16 text =
webContents_->GetTopLevelRenderWidgetHostView()->GetSurroundingText();
const gfx::Range range =
webContents_->GetTopLevelRenderWidgetHostView()->GetSelectedRange();
const size_t offset = webContents_->GetTopLevelRenderWidgetHostView()
->GetOffsetForSurroundingText();
[self updateTextSelection:text range:range offset:offset];
}
- (content::WebContents*)webContents {
return webContents_;
}
- (void)setText:(NSString*)text {
text_.reset([text copy]);
}
- (NSString*)text {
return text_;
}
- (void)setSelectionRange:(const gfx::Range&)range {
selectionRange_ = range.ToNSRange();
}
- (gfx::Range)selectionRange {
return gfx::Range(selectionRange_);
}
- (void)setSuggestions:(NSArray*)suggestions {
suggestions_.reset([suggestions copy]);
}
- (NSArray*)suggestions {
return suggestions_;
}
- (WebTextfieldTouchBarController*)controller {
return controller_;
}
- (void)setShouldIgnoreReplacementSelection:(BOOL)shouldIgnore {
shouldIgnoreReplacementSelection_ = shouldIgnore;
}
- (void)setEditingWordRange:(const gfx::Range&)range offset:(size_t)offset {
editingWordRange_ = range.ToNSRange();
offsetEditingWordRange_ = NSMakeRange(editingWordRange_.location + offset,
editingWordRange_.length);
}
@end