blob: 0cfeb53390e47009698cd4136d948ce38e79a799 [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/suggested_text_touch_bar_controller.h"
#include "base/i18n/break_iterator.h"
#include "base/strings/sys_string_conversions.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 kSuggestedTextTouchBarId = @"suggested-text";
// Touch bar item identifiers.
NSString* const kSuggestedTextItemsTouchId = @"SUGGESTED-TEXT-ITEMS";
} // namespace
namespace text_observer {
class WebContentsTextObserver : public content::WebContentsObserver {
public:
WebContentsTextObserver(content::WebContents* web_contents,
SuggestedTextTouchBarController* 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) override {
if (@available(macOS 10.12.2, *)) {
[owner_ webContentsTextSelectionChanged:text range:range.ToNSRange()];
}
}
void DidFinishLoad(content::RenderFrameHost* render_frame_host,
const GURL& validated_url) override {
if (@available(macOS 10.12.2, *)) {
[owner_ webContentsFinishedLoading];
}
}
private:
SuggestedTextTouchBarController* owner_; // weak
};
} // namespace text_observer
@interface SuggestedTextTouchBarController () {
// 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_;
}
@end
@implementation SuggestedTextTouchBarController
- (instancetype)initWithWebContents:(content::WebContents*)webContents
controller:
(WebTextfieldTouchBarController*)controller {
if ((self = [super init])) {
webContents_ = webContents;
controller_ = controller;
observer_.reset(
new text_observer::WebContentsTextObserver(webContents_, self));
}
return self;
}
- (NSTouchBar*)makeTouchBar {
if (!webContents_ || !webContents_->IsFocusedElementEditable())
return nil;
base::scoped_nsobject<NSTouchBar> touchBar([[ui::NSTouchBar() alloc] init]);
[touchBar
setCustomizationIdentifier:ui::GetTouchBarId(kSuggestedTextTouchBarId)];
[touchBar setDelegate:self];
[touchBar setDefaultItemIdentifiers:@[ kSuggestedTextItemsTouchId ]];
return touchBar.autorelease();
}
- (NSTouchBarItem*)touchBar:(NSTouchBar*)touchBar
makeItemForIdentifier:(NSTouchBarItemIdentifier)identifier {
if (![identifier hasSuffix:kSuggestedTextItemsTouchId])
return nil;
return [self createCandidateListItem];
}
- (NSCandidateListTouchBarItem*)createCandidateListItem {
NSCandidateListTouchBarItem* candidateListItem =
[[NSCandidateListTouchBarItem alloc]
initWithIdentifier:kSuggestedTextItemsTouchId];
[candidateListItem setCandidates:suggestions_
forSelectedRange:selectionRange_
inString:text_];
candidateListItem.delegate = self;
if (selectionRange_.length)
candidateListItem.collapsed = YES;
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)webContentsTextSelectionChanged:(const base::string16&)text
range:(NSRange)range {
if (webContents_->IsFocusedElementEditable()) {
[self setText:base::SysUTF16ToNSString(text)];
selectionRange_ = range;
editingWordRange_ =
[self editingWordRangeFromText:text cursorPosition:range.location];
[self requestSuggestionsForText:text_ inRange:range];
} else {
[controller_ invalidateTouchBar];
}
}
- (void)webContentsFinishedLoading {
if (webContents_->IsFocusedElementEditable()) {
// TODO(tnijssen): Actually request the current text and cursor position.
[self setText:@""];
selectionRange_ = NSMakeRange(0, 0);
editingWordRange_ = NSMakeRange(0, 0);
[self requestSuggestionsForText:@"" inRange:NSMakeRange(0, 0)];
}
}
- (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)requestSuggestionsForText:(NSString*)text inRange:(NSRange)range {
NSSpellChecker* spell_checker = [NSSpellChecker sharedSpellChecker];
[spell_checker
requestCandidatesForSelectedRange:range
inString:text
types:NSTextCheckingAllSystemTypes
options:nil
inSpellDocumentWithTag:0
completionHandler:^(
NSInteger sequenceNumber,
NSArray<NSTextCheckingResult*>* candidates) {
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_)) {
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)setText:(NSString*)text {
text_.reset([text copy]);
}
- (NSString*)text {
return text_;
}
- (void)setSuggestions:(NSArray*)suggestions {
suggestions_.reset([suggestions copy]);
}
- (NSArray*)suggestions {
return suggestions_;
}
- (WebTextfieldTouchBarController*)controller {
return controller_;
}
- (void)setWebContents:(content::WebContents*)webContents {
// TODO(tnijssen): Update the text suggestions when we change web contents.
webContents_ = webContents;
observer_->UpdateWebContents(webContents);
}
- (content::WebContents*)webContents {
return webContents_;
}
- (void)setSelectionRange:(NSRange)range {
selectionRange_ = range;
}
- (NSRange)selectionRange {
return selectionRange_;
}
@end