blob: 4efa5443ad9a1cccb4005929977725aa475f4418 [file] [log] [blame]
// Copyright (c) 2012 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.
#include "components/omnibox/browser/omnibox_popup_model.h"
#include <algorithm>
#include "base/bind.h"
#include "base/feature_list.h"
#include "base/strings/string_util.h"
#include "base/strings/utf_string_conversions.h"
#include "components/bookmarks/browser/bookmark_model.h"
#include "components/omnibox/browser/autocomplete_match.h"
#include "components/omnibox/browser/autocomplete_match_type.h"
#include "components/omnibox/browser/omnibox_client.h"
#include "components/omnibox/browser/omnibox_edit_controller.h"
#include "components/omnibox/browser/omnibox_field_trial.h"
#include "components/omnibox/browser/omnibox_popup_view.h"
#include "components/omnibox/common/omnibox_features.h"
#include "third_party/icu/source/common/unicode/ubidi.h"
#include "ui/gfx/geometry/rect.h"
#if !defined(OS_ANDROID) && !defined(OS_IOS)
#include "components/omnibox/browser/vector_icons.h" // nogncheck
#include "ui/gfx/paint_vector_icon.h"
#include "ui/gfx/vector_icon_types.h"
#endif
///////////////////////////////////////////////////////////////////////////////
// OmniboxPopupModel::Selection
bool OmniboxPopupModel::Selection::operator==(const Selection& b) const {
return line == b.line && state == b.state;
}
bool OmniboxPopupModel::Selection::operator!=(const Selection& b) const {
return !operator==(b);
}
bool OmniboxPopupModel::Selection::IsChangeToKeyword(Selection from) const {
return state == KEYWORD && from.state != KEYWORD;
}
OmniboxPopupModel::Selection OmniboxPopupModel::Selection::With(
LineState new_state) const {
return Selection(line, new_state);
}
///////////////////////////////////////////////////////////////////////////////
// OmniboxPopupModel
const size_t OmniboxPopupModel::kNoMatch = static_cast<size_t>(-1);
OmniboxPopupModel::OmniboxPopupModel(OmniboxPopupView* popup_view,
OmniboxEditModel* edit_model)
: view_(popup_view),
edit_model_(edit_model),
selection_(kNoMatch, NORMAL),
has_selected_match_(false) {
edit_model->set_popup_model(this);
}
OmniboxPopupModel::~OmniboxPopupModel() = default;
// static
void OmniboxPopupModel::ComputeMatchMaxWidths(int contents_width,
int separator_width,
int description_width,
int available_width,
bool description_on_separate_line,
bool allow_shrinking_contents,
int* contents_max_width,
int* description_max_width) {
available_width = std::max(available_width, 0);
*contents_max_width = std::min(contents_width, available_width);
*description_max_width = std::min(description_width, available_width);
// If the description is empty, or the contents and description are on
// separate lines, each can get the full available width.
if (!description_width || description_on_separate_line)
return;
// If we want to display the description, we need to reserve enough space for
// the separator.
available_width -= separator_width;
if (available_width < 0) {
*description_max_width = 0;
return;
}
if (contents_width + description_width > available_width) {
if (allow_shrinking_contents) {
// Try to split the available space fairly between contents and
// description (if one wants less than half, give it all it wants and
// give the other the remaining space; otherwise, give each half).
// However, if this makes the contents too narrow to show a significant
// amount of information, give the contents more space.
*contents_max_width = std::max(
(available_width + 1) / 2, available_width - description_width);
const int kMinimumContentsWidth = 300;
*contents_max_width = std::min(
std::min(std::max(*contents_max_width, kMinimumContentsWidth),
contents_width),
available_width);
}
// Give the description the remaining space, unless this makes it too small
// to display anything meaningful, in which case just hide the description
// and let the contents take up the whole width.
*description_max_width =
std::min(description_width, available_width - *contents_max_width);
const int kMinimumDescriptionWidth = 75;
if (*description_max_width <
std::min(description_width, kMinimumDescriptionWidth)) {
*description_max_width = 0;
// Since we're not going to display the description, the contents can have
// the space we reserved for the separator.
available_width += separator_width;
*contents_max_width = std::min(contents_width, available_width);
}
}
}
// static
// Defines forward and backward ordering for possible line states.
OmniboxPopupModel::LineState OmniboxPopupModel::GetNextLineState(
LineState state,
Direction direction) {
const bool button_row = OmniboxFieldTrial::IsSuggestionButtonRowEnabled();
switch (direction) {
case kForward:
switch (state) {
case NO_STATE:
return NORMAL;
case NORMAL:
return button_row ? FOCUSED_BUTTON_KEYWORD : KEYWORD;
case KEYWORD:
return button_row ? FOCUSED_BUTTON_TAB_SWITCH : BUTTON_FOCUSED;
case BUTTON_FOCUSED:
return NO_STATE;
case FOCUSED_BUTTON_KEYWORD:
return FOCUSED_BUTTON_TAB_SWITCH;
case FOCUSED_BUTTON_TAB_SWITCH:
return FOCUSED_BUTTON_PEDAL;
case FOCUSED_BUTTON_PEDAL:
return NO_STATE;
default:
break;
}
break;
case kBackward:
switch (state) {
case NO_STATE:
return button_row ? FOCUSED_BUTTON_PEDAL : BUTTON_FOCUSED;
case NORMAL:
return NO_STATE;
case KEYWORD:
return NORMAL;
case BUTTON_FOCUSED:
return KEYWORD;
case FOCUSED_BUTTON_KEYWORD:
return NORMAL;
case FOCUSED_BUTTON_TAB_SWITCH:
return FOCUSED_BUTTON_KEYWORD;
case FOCUSED_BUTTON_PEDAL:
return FOCUSED_BUTTON_TAB_SWITCH;
default:
break;
}
break;
default:
break;
}
NOTREACHED();
return OmniboxPopupModel::NO_STATE;
}
bool OmniboxPopupModel::IsOpen() const {
return view_->IsOpen();
}
void OmniboxPopupModel::SetSelectedLine(size_t line,
bool reset_to_default,
bool force) {
const AutocompleteResult& result = this->result();
if (result.empty())
return;
// Cancel the query so the matches don't change on the user.
autocomplete_controller()->Stop(false);
if (line != kNoMatch)
line = std::min(line, result.size() - 1);
has_selected_match_ = !reset_to_default;
if (line == selected_line() && !force)
return; // Nothing else to do.
// We need to update selection before calling InvalidateLine(), since it will
// use selection to determine how to draw. We also need to update
// |selection_.line| before calling OnPopupDataChanged(), so that when the
// edit notifies its controller that something has changed, the controller
// can get the correct updated data.
const Selection old_selection = selection_;
selection_ = Selection(line, NORMAL);
view_->OnSelectionChanged(old_selection, selection_);
if (line == kNoMatch)
return;
// Update the edit with the new data for this match.
// TODO(pkasting): If |selection_.line| moves to the controller, this can be
// eliminated and just become a call to the observer on the edit.
base::string16 keyword;
bool is_keyword_hint;
TemplateURLService* service = edit_model_->client()->GetTemplateURLService();
const AutocompleteMatch& match = result.match_at(line);
match.GetKeywordUIState(service, &keyword, &is_keyword_hint);
if (reset_to_default) {
edit_model_->OnPopupDataChanged(match.inline_autocompletion,
/*is_temporary_text=*/false, keyword,
is_keyword_hint);
} else {
edit_model_->OnPopupDataChanged(match.fill_into_edit,
/*is_temporary_text=*/true, keyword,
is_keyword_hint);
}
}
void OmniboxPopupModel::ResetToInitialState() {
size_t new_line = result().default_match() ? 0 : kNoMatch;
SetSelectedLine(new_line, true, false);
view_->OnDragCanceled();
}
void OmniboxPopupModel::SetSelectedLineState(LineState state) {
DCHECK(!result().empty());
DCHECK_NE(kNoMatch, selected_line());
const AutocompleteMatch& match = result().match_at(selected_line());
GURL current_destination(match.destination_url);
DCHECK((state != KEYWORD) || match.associated_keyword.get());
if (state == BUTTON_FOCUSED)
old_focused_url_ = current_destination;
selection_ = Selection(selected_line(), state);
view_->InvalidateLine(selected_line());
if (state == BUTTON_FOCUSED) {
edit_model_->SetAccessibilityLabel(match);
view_->ProvideButtonFocusHint(selected_line());
}
}
void OmniboxPopupModel::TryDeletingLine(size_t line) {
// When called with line == selected_line(), we could use
// GetInfoForCurrentText() here, but it seems better to try and delete the
// actual selection, rather than any "in progress, not yet visible" one.
if (line == kNoMatch)
return;
// Cancel the query so the matches don't change on the user.
autocomplete_controller()->Stop(false);
const AutocompleteMatch& match = result().match_at(line);
if (match.SupportsDeletion()) {
// Try to preserve the selection even after match deletion.
const size_t old_selected_line = selected_line();
const bool was_temporary_text = has_selected_match_;
// This will synchronously notify both the edit and us that the results
// have changed, causing both to revert to the default match.
autocomplete_controller()->DeleteMatch(match);
const AutocompleteResult& result = this->result();
if (!result.empty() &&
(was_temporary_text || old_selected_line != selected_line())) {
// Move the selection to the next choice after the deleted one.
// SetSelectedLine() will clamp to take care of the case where we deleted
// the last item.
// TODO(pkasting): Eventually the controller should take care of this
// before notifying us, reducing flicker. At that point the check for
// deletability can move there too.
SetSelectedLine(old_selected_line, false, true);
}
}
}
bool OmniboxPopupModel::IsStarredMatch(const AutocompleteMatch& match) const {
auto* bookmark_model = edit_model_->client()->GetBookmarkModel();
return bookmark_model && bookmark_model->IsBookmarked(match.destination_url);
}
void OmniboxPopupModel::OnResultChanged() {
rich_suggestion_bitmaps_.clear();
const AutocompleteResult& result = this->result();
size_t old_selected_line = selected_line();
has_selected_match_ = false;
if (result.default_match()) {
Selection selection(0, selected_line_state());
// If selected line state was |BUTTON_FOCUSED| and nothing has changed,
// leave it.
const bool has_focused_match =
selection.state == BUTTON_FOCUSED &&
result.match_at(selection.line).has_tab_match;
const bool has_changed =
selection.line != old_selected_line ||
result.match_at(selection.line).destination_url != old_focused_url_;
if (!has_focused_match || has_changed) {
selection.state = NORMAL;
}
selection_ = selection;
} else {
selection_ = Selection(kNoMatch, NORMAL);
}
bool popup_was_open = view_->IsOpen();
view_->UpdatePopupAppearance();
if (view_->IsOpen() != popup_was_open)
edit_model_->controller()->OnPopupVisibilityChanged();
}
const SkBitmap* OmniboxPopupModel::RichSuggestionBitmapAt(
int result_index) const {
const auto iter = rich_suggestion_bitmaps_.find(result_index);
if (iter == rich_suggestion_bitmaps_.end()) {
return nullptr;
}
return &iter->second;
}
void OmniboxPopupModel::SetRichSuggestionBitmap(int result_index,
const SkBitmap& bitmap) {
rich_suggestion_bitmaps_[result_index] = bitmap;
view_->UpdatePopupAppearance();
}
// Android and iOS have their own platform-specific icon logic.
#if !defined(OS_ANDROID) && !defined(OS_IOS)
gfx::Image OmniboxPopupModel::GetMatchIcon(const AutocompleteMatch& match,
SkColor vector_icon_color) {
gfx::Image extension_icon =
edit_model_->client()->GetIconIfExtensionMatch(match);
// Extension icons are the correct size for non-touch UI but need to be
// adjusted to be the correct size for touch mode.
if (!extension_icon.IsEmpty())
return edit_model_->client()->GetSizedIcon(extension_icon);
// Get the favicon for navigational suggestions.
if (!AutocompleteMatch::IsSearchType(match.type) &&
match.type != AutocompleteMatchType::DOCUMENT_SUGGESTION) {
// Because the Views UI code calls GetMatchIcon in both the layout and
// painting code, we may generate multiple OnFaviconFetched callbacks,
// all run one after another. This seems to be harmless as the callback
// just flips a flag to schedule a repaint. However, if it turns out to be
// costly, we can optimize away the redundant extra callbacks.
gfx::Image favicon = edit_model_->client()->GetFaviconForPageUrl(
match.destination_url,
base::BindOnce(&OmniboxPopupModel::OnFaviconFetched,
weak_factory_.GetWeakPtr(), match.destination_url));
// Extension icons are the correct size for non-touch UI but need to be
// adjusted to be the correct size for touch mode.
if (!favicon.IsEmpty())
return edit_model_->client()->GetSizedIcon(favicon);
}
const auto& vector_icon_type = match.GetVectorIcon(IsStarredMatch(match));
return edit_model_->client()->GetSizedIcon(vector_icon_type,
vector_icon_color);
}
#endif // !defined(OS_ANDROID) && !defined(OS_IOS)
bool OmniboxPopupModel::SelectedLineIsTabSwitchSuggestion() {
return selected_line() != kNoMatch &&
result().match_at(selected_line()).IsTabSwitchSuggestion();
}
OmniboxPopupModel::Selection OmniboxPopupModel::GetNextSelection(
Direction direction,
Step step) const {
if (result().empty()) {
return selection_;
}
Selection next = selection_;
const bool skip_keyword =
!OmniboxFieldTrial::IsExperimentalKeywordModeEnabled() &&
step == kStateOrNothing;
// This block handles state transitions within the current line.
if (step == kStateOrLine || step == kStateOrNothing) {
LineState next_state =
GetNextAvailableLineState(next, direction, skip_keyword);
if (next_state != NO_STATE) {
next.state = next_state;
return next;
}
if (step == kStateOrNothing) {
return next;
}
}
// The rest handles stepping to other lines.
const int size = result().size();
const int line =
step == kAllLines
? (direction == kForward ? (size - 1) : 0)
: ((next.line + (direction == kForward ? 1 : (size - 1))) % size);
next.line = line;
LineState next_state = GetNextAvailableLineState(
Selection(line, NO_STATE), (step != kStateOrLine) ? kForward : direction,
skip_keyword);
if (!OmniboxFieldTrial::IsSuggestionButtonRowEnabled() &&
(step == kStateOrLine) && direction != kForward &&
next_state == KEYWORD) {
// When semi-stepping backward with no button row, skip over keyword.
next_state =
GetNextAvailableLineState(next.With(KEYWORD), direction, skip_keyword);
}
next.state = next_state;
return next;
}
OmniboxPopupModel::Selection OmniboxPopupModel::StepSelection(
Direction direction,
Step step) {
// This block steps the popup model, with special consideration
// for existing keyword logic in the edit model, where AcceptKeyword and
// ClearKeyword must be called before changing the selected line.
const auto old_selection = selection();
const auto new_selection = GetNextSelection(direction, step);
if (new_selection.IsChangeToKeyword(old_selection)) {
edit_model()->AcceptKeyword(metrics::OmniboxEventProto::TAB);
} else if (old_selection.IsChangeToKeyword(new_selection)) {
edit_model()->ClearKeyword();
}
SetSelection(new_selection);
return selection_;
}
OmniboxPopupModel::Selection OmniboxPopupModel::ClearSelectionState() {
// This is subtle. DCHECK in SetSelectedLineState will fail if there are no
// results, which can happen when the popup gets closed. In that case, though,
// the state is left as NORMAL.
if (selection_.state != NORMAL) {
SetSelectedLineState(NORMAL);
}
return selection_;
}
bool OmniboxPopupModel::IsSelectionAvailable(Selection selection) const {
if (selection.line >= result().size()) {
return false;
}
const auto& match = result().match_at(selection.line);
switch (selection.state) {
case NO_STATE:
return false;
case NORMAL:
return true;
case KEYWORD:
return match.associated_keyword != nullptr;
case BUTTON_FOCUSED:
// TODO(orinj): Here is an opportunity to clean up the presentational
// logic that pkasting wanted to take out of AutocompleteMatch. The view
// should be driven by the model, so this is really the place to decide.
return match.ShouldShowTabMatchButton();
case FOCUSED_BUTTON_KEYWORD:
return match.associated_keyword != nullptr;
case FOCUSED_BUTTON_TAB_SWITCH:
return match.has_tab_match;
case FOCUSED_BUTTON_PEDAL:
return match.pedal != nullptr;
default:
break;
}
NOTREACHED();
return false;
}
void OmniboxPopupModel::SetSelection(Selection selection) {
if (selection.line != selection_.line) {
SetSelectedLine(selection.line, false, false);
}
if (selection.state != selection_.state) {
SetSelectedLineState(selection.state);
}
}
OmniboxPopupModel::LineState OmniboxPopupModel::GetNextAvailableLineState(
Selection from,
Direction direction,
bool skip_keyword) const {
Selection to = from;
do {
to.state = GetNextLineState(to.state, direction);
if (skip_keyword && to.state == KEYWORD) {
to.state = GetNextLineState(to.state, direction);
}
} while (to.state != OmniboxPopupModel::NO_STATE &&
!IsSelectionAvailable(to));
return to.state;
}
void OmniboxPopupModel::OnFaviconFetched(const GURL& page_url,
const gfx::Image& icon) {
if (icon.IsEmpty() || !view_->IsOpen())
return;
// Notify all affected matches.
for (size_t i = 0; i < result().size(); ++i) {
auto& match = result().match_at(i);
if (!AutocompleteMatch::IsSearchType(match.type) &&
match.destination_url == page_url) {
view_->OnMatchIconUpdated(i);
}
}
}