blob: a038a4f83c46100abbfbfb616d8ddabfefd37972 [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/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/browser/omnibox_prefs.h"
#include "components/strings/grit/components_strings.h"
#include "third_party/icu/source/common/unicode/ubidi.h"
#include "ui/base/l10n/l10n_util.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::operator<(const Selection& b) const {
if (line == b.line)
return state < b.state;
return line < b.line;
}
bool OmniboxPopupModel::Selection::IsChangeToKeyword(Selection from) const {
return state == KEYWORD && from.state != KEYWORD;
}
bool OmniboxPopupModel::Selection::IsButtonFocused() const {
return state != NORMAL && state != KEYWORD;
}
///////////////////////////////////////////////////////////////////////////////
// OmniboxPopupModel
const size_t OmniboxPopupModel::kNoMatch = static_cast<size_t>(-1);
OmniboxPopupModel::OmniboxPopupModel(OmniboxPopupView* popup_view,
OmniboxEditModel* edit_model,
PrefService* pref_service)
: view_(popup_view),
edit_model_(edit_model),
pref_service_(pref_service),
selection_(kNoMatch, NORMAL) {
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);
}
}
}
bool OmniboxPopupModel::IsOpen() const {
return view_->IsOpen();
}
void OmniboxPopupModel::SetSelection(Selection new_selection,
bool reset_to_default,
bool force_update_ui) {
if (result().empty())
return;
// Cancel the query so the matches don't change on the user.
autocomplete_controller()->Stop(false);
if (new_selection == selection_ && !force_update_ui)
return; // Nothing else to do.
// We need to update selection before notifying any views, as they will query
// selection_ to update themselves.
const Selection old_selection = selection_;
selection_ = new_selection;
view_->OnSelectionChanged(old_selection, selection_);
if (selection_.line == kNoMatch)
return;
const AutocompleteMatch& match = result().match_at(selection_.line);
DCHECK((selection_.state != KEYWORD) || match.associated_keyword.get());
if (selection_.IsButtonFocused()) {
old_focused_url_ = match.destination_url;
edit_model_->SetAccessibilityLabel(match);
// TODO(tommycli): Fold the focus hint into view_->OnSelectionChanged().
// Caveat: We must update the accessibility label before notifying the View.
view_->ProvideButtonFocusHint(selected_line());
}
base::string16 keyword;
bool is_keyword_hint;
TemplateURLService* service = edit_model_->client()->GetTemplateURLService();
match.GetKeywordUIState(service, &keyword, &is_keyword_hint);
if (selection_.state == FOCUSED_BUTTON_HEADER) {
// If the new selection is a Header, the temporary text is an empty string.
edit_model_->OnPopupDataChanged(base::string16(),
/*is_temporary_text=*/true,
base::string16(), keyword, is_keyword_hint,
base::string16());
} else if (old_selection.line != selection_.line ||
old_selection.IsButtonFocused()) {
// Otherwise, only update the edit model for line number changes, or
// when the old selection was a button. Updating the edit model for every
// state change breaks keyword mode.
if (reset_to_default) {
edit_model_->OnPopupDataChanged(
match.inline_autocompletion,
/*is_temporary_text=*/false, match.prefix_autocompletion, keyword,
is_keyword_hint, match.fill_into_edit_additional_text);
} else {
edit_model_->OnPopupDataChanged(
match.fill_into_edit,
/*is_temporary_text=*/true, base::UTF8ToUTF16(""), keyword,
is_keyword_hint, match.fill_into_edit_additional_text);
}
}
}
void OmniboxPopupModel::ResetToInitialState() {
size_t new_line = result().default_match() ? 0 : kNoMatch;
SetSelection(Selection(new_line, NORMAL), /*reset_to_default=*/true);
view_->OnDragCanceled();
}
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.
size_t old_selected_line = selected_line();
// 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);
// Clamp the old selection to the new size of result(), since there may be
// fewer results now.
if (old_selected_line != kNoMatch)
old_selected_line = std::min(line, result().size() - 1);
// 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.
SetSelection(Selection(old_selected_line, NORMAL), 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);
}
bool OmniboxPopupModel::SelectionOnInitialLine() const {
size_t initial_line = result().default_match() ? 0 : kNoMatch;
return selected_line() == initial_line;
}
void OmniboxPopupModel::OnResultChanged() {
rich_suggestion_bitmaps_.clear();
const AutocompleteResult& result = this->result();
size_t old_selected_line = selected_line();
if (result.default_match()) {
Selection selection(0, selected_line_state());
// If selected line state was |BUTTON_FOCUSED_TAB_SWITCH| and nothing has
// changed leave it.
const bool has_focused_match =
selection.state == FOCUSED_BUTTON_TAB_SWITCH &&
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)
std::vector<OmniboxPopupModel::Selection>
OmniboxPopupModel::GetAllAvailableSelectionsSorted(Direction direction,
Step step) const {
// First enumerate all the accessible states based on |direction| and |step|,
// as well as enabled feature flags. This doesn't mean each match will have
// all of these states - just that it's possible to get there, if available.
std::vector<LineState> all_states;
if (step == kWholeLine || step == kAllLines) {
// In the case of whole-line stepping, only the NORMAL state is accessible.
all_states.push_back(NORMAL);
} else {
// Arrow keys should never reach the header controls.
if (step == kStateOrLine)
all_states.push_back(FOCUSED_BUTTON_HEADER);
all_states.push_back(NORMAL);
if (OmniboxFieldTrial::IsSuggestionButtonRowEnabled()) {
// The button row experiment makes things simple. We no longer access
// keyword mode tab button in this case.
all_states.push_back(FOCUSED_BUTTON_REMOVE_SUGGESTION);
all_states.push_back(FOCUSED_BUTTON_KEYWORD);
all_states.push_back(FOCUSED_BUTTON_TAB_SWITCH);
all_states.push_back(FOCUSED_BUTTON_PEDAL);
} else {
// Keyword mode is only accessible by Tabbing forward.
if (direction == kForward) {
if (step == kStateOrLine) {
all_states.push_back(KEYWORD);
}
}
all_states.push_back(FOCUSED_BUTTON_REMOVE_SUGGESTION);
all_states.push_back(FOCUSED_BUTTON_TAB_SWITCH);
}
}
DCHECK(std::is_sorted(all_states.begin(), all_states.end()))
<< "This algorithm depends on a sorted list of line states.";
// Now, for each accessible line, add all the available line states to a list.
std::vector<Selection> available_selections;
{
auto add_available_line_states_for_line = [&](size_t line) {
for (LineState state : all_states) {
Selection selection(line, state);
if (IsControlPresentOnMatch(selection))
available_selections.push_back(selection);
}
};
for (size_t line = 0; line < result().size(); ++line) {
add_available_line_states_for_line(line);
}
}
DCHECK(
std::is_sorted(available_selections.begin(), available_selections.end()))
<< "This algorithm depends on a sorted list of available selections.";
return available_selections;
}
OmniboxPopupModel::Selection OmniboxPopupModel::GetNextSelection(
Direction direction,
Step step) const {
if (result().empty()) {
return selection_;
}
// Implementing this was like a Google Interview Problem. It was always a
// tough problem to handle all the cases, but has gotten much harder since
// we can now hide whole rows from view by collapsing sections.
//
// The only sane thing to do is to first enumerate all available selections.
// Other approaches I've tried all end up being a jungle of branching code.
// It's not necessarily optimal to generate this list for each keypress, but
// in practice it's only something like ~10 elements long, and makes the code
// easy to reason about.
std::vector<Selection> all_available_selections =
GetAllAvailableSelectionsSorted(direction, step);
if (all_available_selections.empty())
return selection_;
// Handle the simple case of just getting the first or last element.
if (step == kAllLines) {
return direction == kForward ? all_available_selections.back()
: all_available_selections.front();
}
if (direction == kForward) {
// To go forward, we want to change to the first selection that's larger
// than the current |selection_|, and std::upper_bound() does just that.
const auto next =
std::upper_bound(all_available_selections.begin(),
all_available_selections.end(), selection_);
// If we can't find any selections larger than the current |selection_|
// wrap.
if (next == all_available_selections.end())
return all_available_selections.front();
// Normal case where we found the next selection.
return *next;
} else if (direction == kBackward) {
// To go backwards, decrement one from std::lower_bound(), which finds the
// current selection. I didn't use std::find() here, because
// std::lower_bound() can gracefully handle the case where |selection_| is
// no longer within the list of available selections.
const auto current =
std::lower_bound(all_available_selections.begin(),
all_available_selections.end(), selection_);
// If the current selection is the first one, wrap.
if (current == all_available_selections.begin())
return all_available_selections.back();
// Decrement one from the current selection.
return *(current - 1);
}
NOTREACHED();
return selection_;
}
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_;
}
bool OmniboxPopupModel::IsControlPresentOnMatch(Selection selection) const {
if (selection.line >= result().size()) {
return false;
}
const auto& match = result().match_at(selection.line);
// Skip rows that are hidden because their header is collapsed, unless the
// user is trying to focus the header itself (which is still shown).
if (selection.state != FOCUSED_BUTTON_HEADER &&
match.suggestion_group_id.has_value() && pref_service_ &&
omnibox::IsSuggestionGroupIdHidden(pref_service_,
match.suggestion_group_id.value())) {
return false;
}
switch (selection.state) {
case FOCUSED_BUTTON_HEADER: {
// For the first match, if it a suggestion_group_id, then it has a header.
if (selection.line == 0)
return match.suggestion_group_id.has_value();
// Otherwise, we only show headers that are distinct from the previous
// match's header.
const auto& previous_match = result().match_at(selection.line - 1);
return match.suggestion_group_id.has_value() &&
match.suggestion_group_id != previous_match.suggestion_group_id;
}
case NORMAL:
return true;
case KEYWORD:
return match.associated_keyword != nullptr;
case FOCUSED_BUTTON_REMOVE_SUGGESTION:
// Remove suggestion buttons are suppressed for matches with an associated
// keyword or tab match, unless dedicated button row is enabled.
if (OmniboxFieldTrial::IsSuggestionButtonRowEnabled())
return match.SupportsDeletion();
else
return !match.associated_keyword &&
!match.ShouldShowTabMatchButtonInlineInResultView() &&
match.SupportsDeletion();
case FOCUSED_BUTTON_KEYWORD:
return match.associated_keyword != nullptr;
case FOCUSED_BUTTON_TAB_SWITCH:
// Buttons are suppressed for matches with an associated keyword, unless
// dedicated button row is enabled.
if (OmniboxFieldTrial::IsSuggestionButtonRowEnabled())
return match.has_tab_match;
else
return match.ShouldShowTabMatchButtonInlineInResultView();
case FOCUSED_BUTTON_PEDAL:
return match.pedal != nullptr;
default:
break;
}
NOTREACHED();
return false;
}
bool OmniboxPopupModel::TriggerSelectionAction(Selection selection,
base::TimeTicks timestamp) {
// Early exit for the kNoMatch case. Also exits if the calling UI passes in
// an invalid |selection|.
if (selection.line >= result().size())
return false;
auto& match = result().match_at(selection.line);
switch (selection.state) {
case FOCUSED_BUTTON_HEADER:
DCHECK(match.suggestion_group_id.has_value());
omnibox::ToggleSuggestionGroupIdVisibility(
pref_service_, match.suggestion_group_id.value());
break;
case FOCUSED_BUTTON_KEYWORD:
// TODO(yoangela): Merge logic with mouse/gesture events in
// OmniboxSuggestionButtonRowView::ButtonPressed - This case currently
// is only reached by the call in OmniboxViewViews::HandleKeyEvent.
if (edit_model()->is_keyword_hint()) {
// TODO(yoangela): Rename once tab to keyword search is deprecated
// Accept/ClearKeyword() has special conditions to handle searches
// initiated by pressing Tab. Since tab+enter on this button behaves
// more similar to a Tab than a Keyboard shortcut, it's easier
// for now to treat it as a Tab entry method, otherwise the
// autocomplete results will reset, leaving us in an unknown state.
edit_model()->AcceptKeyword(metrics::OmniboxEventProto::TAB);
}
break;
case FOCUSED_BUTTON_TAB_SWITCH:
DCHECK(timestamp != base::TimeTicks());
edit_model()->OpenMatch(match, WindowOpenDisposition::SWITCH_TO_TAB,
GURL(), base::string16(), selected_line(),
timestamp);
break;
case FOCUSED_BUTTON_PEDAL:
DCHECK(timestamp != base::TimeTicks());
DCHECK(match.pedal);
edit_model()->ExecutePedal(match, timestamp);
break;
case FOCUSED_BUTTON_REMOVE_SUGGESTION:
TryDeletingLine(selection.line);
break;
default:
// Behavior is not yet supported, return false.
return false;
}
return true;
}
base::string16 OmniboxPopupModel::GetAccessibilityLabelForCurrentSelection(
const base::string16& match_text,
int* label_prefix_length) {
size_t line = selection_.line;
DCHECK_NE(line, kNoMatch)
<< "GetAccessibilityLabelForCurrentSelection should never be called if "
"the current selection is kNoMatch.";
const AutocompleteMatch& match = result().match_at(line);
int additional_message_id = 0;
switch (selection_.state) {
case FOCUSED_BUTTON_HEADER: {
bool group_hidden = omnibox::IsSuggestionGroupIdHidden(
pref_service_, match.suggestion_group_id.value());
int message_id = group_hidden ? IDS_ACC_HEADER_SHOW_SUGGESTIONS_BUTTON
: IDS_ACC_HEADER_HIDE_SUGGESTIONS_BUTTON;
return l10n_util::GetStringFUTF16(
message_id,
result().GetHeaderForGroupId(match.suggestion_group_id.value()));
}
case NORMAL:
if (IsControlPresentOnMatch(Selection(line, FOCUSED_BUTTON_TAB_SWITCH))) {
additional_message_id = IDS_ACC_TAB_SWITCH_SUFFIX;
}
// Don't add an additional message for removable suggestions without
// button focus, since they are relatively common.
break;
case KEYWORD:
// TODO(tommycli): Investigate whether the accessibility messaging for
// Keyword mode belongs here.
break;
case FOCUSED_BUTTON_REMOVE_SUGGESTION:
additional_message_id = IDS_ACC_REMOVE_SUGGESTION_FOCUSED_PREFIX;
break;
case FOCUSED_BUTTON_KEYWORD:
// TODO(yoangela): Add an accessibility message for the Keyword button
// in the button-row UI configuration.
break;
case FOCUSED_BUTTON_TAB_SWITCH:
additional_message_id = IDS_ACC_TAB_SWITCH_SUFFIX;
break;
case FOCUSED_BUTTON_PEDAL:
// TODO(orinj): Add an accessibility message for the Pedal button
// in the button-row UI configuration.
break;
default:
break;
}
// If there's a button focused, we don't want the "n of m" message announced.
size_t total_matches = selection_.IsButtonFocused() ? 0 : result().size();
return AutocompleteMatchType::ToAccessibilityLabel(
match, match_text, selection_.line, total_matches, additional_message_id,
label_prefix_length);
}
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);
}
}
}